Skip to content

Commit 3f426e7

Browse files
committed
feat(appkit): metrics describe-cache with retry-driven convergence and last-known-good degradation
Signed-off-by: Atila Fassina <atila@fassina.eu>
1 parent f02fe5a commit 3f426e7

3 files changed

Lines changed: 504 additions & 24 deletions

File tree

packages/appkit/src/type-generator/cache.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
22
import fs from "node:fs/promises";
33
import path from "node:path";
44
import { createLogger } from "../logging/logger";
5+
import type { MetricSchema } from "./metric-registry";
56

67
const logger = createLogger("type-generator:cache");
78

@@ -16,14 +17,39 @@ interface CacheEntry {
1617
retry: boolean;
1718
}
1819

20+
/**
21+
* One cached metric-view DESCRIBE outcome.
22+
*
23+
* `hash` is md5 over `"<source>|<lane>"` — the two config inputs that
24+
* determine a DESCRIBE — so editing either invalidates the entry. `schema`
25+
* is the full {@link MetricSchema} persisted verbatim (it is JSON-safe by
26+
* design), letting a warm pass regenerate both metric artifacts without a
27+
* single warehouse call. `retry: true` marks a degraded outcome (DESCRIBE
28+
* skipped, unanswered, or failed): the cached schema still renders
29+
* artifacts, but the next eligible pass re-describes exactly these keys so
30+
* degraded schemas converge to real ones.
31+
*/
32+
export interface MetricCacheEntry {
33+
hash: string;
34+
schema: MetricSchema;
35+
retry: boolean;
36+
}
37+
1938
/**
2039
* Cache interface
2140
* @property version - the version of the cache
2241
* @property queries - the queries in the cache
42+
* @property metrics - cached metric-view schemas keyed by metric key.
43+
* OPTIONAL on purpose: version "3" files written before this section
44+
* existed load unchanged (absent ⇒ treated as empty by the metric path),
45+
* and the query path's `noCache` reinit literal stays valid as-is. The
46+
* section rides through the query path's load → mutate → save cycle as a
47+
* plain sibling key, so query-side saves preserve it byte-for-byte.
2348
*/
2449
interface Cache {
2550
version: string;
2651
queries: Record<string, CacheEntry>;
52+
metrics?: Record<string, MetricCacheEntry>;
2753
}
2854

2955
export const CACHE_VERSION = "3";

packages/appkit/src/type-generator/index.ts

Lines changed: 124 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { WorkspaceClient } from "@databricks/sdk-experimental";
44
import dotenv from "dotenv";
55
import pc from "picocolors";
66
import { createLogger } from "../logging/logger";
7+
import { hashSQL, loadCache, type MetricCacheEntry, saveCache } from "./cache";
78
import {
89
createWorkspaceDescribeFetcher,
910
type DescribeFetcher,
@@ -256,6 +257,10 @@ async function isWarehouseRunning(
256257
* @param options - the options for the generation
257258
* @param options.entryPoint - the entry point file
258259
* @param options.outFile - the output file
260+
* @param options.noCache - skip the typegen cache entirely: every query is
261+
* re-described, and the metric path ignores its cached schemas (every
262+
* configured key becomes describe-needed) and overwrites the cache's
263+
* `metrics` section with this pass's results.
259264
* @param options.mode - preflight policy (see {@link PreflightMode}). For
260265
* queries, `"non-blocking"` never probes or describes the warehouse. For
261266
* metric views, `"non-blocking"` makes one status-only probe and DESCRIBEs
@@ -330,12 +335,61 @@ export async function generateFromEntryPoint(options: {
330335
if (metricConfig) {
331336
const resolution = resolveMetricConfig(metricConfig);
332337

338+
// Metric schemas persist in the shared typegen cache as a `metrics`
339+
// section (sibling of `queries`, same file, same version) keyed by
340+
// metric key with md5("<source>|<lane>") as the change detector. The
341+
// cache is (re)loaded here — strictly AFTER generateQueriesFromDescribe
342+
// above has finished its own load → mutate → save cycle — so the single
343+
// metric-side save below re-serializes the exact `queries` object it
344+
// just read and can never clobber a query entry.
345+
const cache = await loadCache();
346+
347+
// The section is consumed through a null-prototype copy: metric keys
348+
// are user-controlled config input and "__proto__" passes the metric
349+
// key regex — on a plain object, writing it would hit the
350+
// Object.prototype setter (mutating the object's prototype and silently
351+
// dropping the entry) instead of storing data. A null prototype also
352+
// keeps partition reads from resolving inherited names ("constructor",
353+
// "toString", ...) as phantom entries.
354+
const metricsSection: Record<string, MetricCacheEntry> =
355+
Object.create(null);
356+
if (!noCache && cache.metrics) {
357+
for (const key of Object.keys(cache.metrics)) {
358+
metricsSection[key] = cache.metrics[key];
359+
}
360+
}
361+
362+
// Partition BEFORE any gate/preflight decision: a hit (hash match and
363+
// not flagged for retry) is served from cache no matter what the
364+
// warehouse is doing — a degraded-mode pass falls back to
365+
// last-known-good schemas exactly like queries degrade to cached
366+
// types. Only the remainder — new keys, edited entries, and
367+
// retry-flagged degraded entries — is eligible for DESCRIBE, so a
368+
// fully-warm pass makes zero warehouse calls and constructs zero
369+
// clients. `noCache` left the section empty above, which makes every
370+
// configured key describe-needed here.
371+
const hitSchemas = new Map<string, MetricSchema>();
372+
const describeNeeded: typeof resolution.entries = [];
373+
// Parallel to describeNeeded: the config hash to persist per key.
374+
const neededHashes: string[] = [];
375+
for (const entry of resolution.entries) {
376+
const hash = hashSQL(`${entry.source}|${entry.lane}`);
377+
const prior = metricsSection[entry.key];
378+
if (prior && prior.hash === hash && !prior.retry) {
379+
hitSchemas.set(entry.key, prior.schema);
380+
} else {
381+
describeNeeded.push(entry);
382+
neededHashes.push(hash);
383+
}
384+
}
385+
333386
// At most ONE WorkspaceClient per generation pass for the whole metric
334387
// path: the non-blocking status probe, the blocking preflight, and the
335388
// default DESCRIBE fetcher all share this lazily-created instance. A
336389
// pass that never contacts the warehouse constructs zero clients: an
337390
// injected metricFetcher covers fetching (and skips probe/preflight),
338-
// and an empty metricViews map has nothing to describe in any mode.
391+
// and a pass with nothing describe-needed — fully-warm cache or an
392+
// empty metricViews map — has nothing to describe in any mode.
339393
let metricClient: WorkspaceClient | undefined;
340394
const getMetricClient = (): WorkspaceClient => {
341395
metricClient ??= new WorkspaceClient({});
@@ -357,7 +411,7 @@ export async function generateFromEntryPoint(options: {
357411
if (
358412
mode === "blocking" &&
359413
metricFetcher === undefined &&
360-
resolution.entries.length > 0
414+
describeNeeded.length > 0
361415
) {
362416
try {
363417
const state = await getWarehouseState(getMetricClient(), warehouseId);
@@ -395,40 +449,43 @@ export async function generateFromEntryPoint(options: {
395449
// start the warehouse) decides whether to describe now or emit degraded
396450
// artifacts that a later blocking run refreshes. An injected
397451
// metricFetcher always runs: it doesn't hit a warehouse (tests/CI
398-
// inject mocks), so gating it would only skip meaningful work. An empty
399-
// metricViews map needs no probe either — nothing would be described
452+
// inject mocks), so gating it would only skip meaningful work. A pass
453+
// with nothing describe-needed — fully-warm cache or an empty
454+
// metricViews map — needs no probe either: nothing would be described
400455
// in any mode.
401456
const describeNow =
402457
metricFetcher !== undefined ||
403458
mode !== "non-blocking" ||
404-
resolution.entries.length === 0 ||
459+
describeNeeded.length === 0 ||
405460
(await isWarehouseRunning(getMetricClient, warehouseId));
406461

407-
let metricSchemas: MetricSchema[];
462+
let described: MetricSchema[];
408463
let failures: MetricSyncFailure[] = [];
409464
if (preflightFatalMessage !== undefined) {
410465
// Fatal preflight (deleted/deleting warehouse): fail exactly like the
411466
// query path's fatal preflight — skip the DESCRIBE batch, emit
412467
// degraded schemas so both artifacts are still written, and record
413-
// one fatal error per key. The shared end-of-run throw below
468+
// one fatal error per describe-needed key (cache hits are unaffected:
469+
// they serve their cached schemas). The shared end-of-run throw below
414470
// (TypegenFatalError, or TypegenSyntaxError's fatalQueries when
415471
// syntax errors coexist) surfaces them after the writes, identically
416472
// to query fatals.
417-
metricSchemas = resolution.entries.map(emptyMetricSchema);
418-
for (const entry of resolution.entries) {
473+
described = describeNeeded.map(emptyMetricSchema);
474+
for (const entry of describeNeeded) {
419475
fatalErrors.push({ name: entry.key, message: preflightFatalMessage });
420476
}
421-
} else if (resolution.entries.length === 0) {
422-
// Nothing configured to describe: syncMetrics would be a no-op, and
423-
// building its default fetcher would construct a client for nothing.
424-
// Emit the (empty) artifacts directly.
425-
metricSchemas = [];
477+
} else if (describeNeeded.length === 0) {
478+
// Nothing left to describe — every configured key (if any) was a
479+
// cache hit. syncMetrics would be a no-op, and building its default
480+
// fetcher would construct a client for nothing. The artifacts below
481+
// regenerate from cached schemas alone.
482+
described = [];
426483
} else if (describeNow) {
427484
const fetcher =
428485
metricFetcher ??
429486
createWorkspaceDescribeFetcher(getMetricClient(), warehouseId);
430-
({ schemas: metricSchemas, failures } = await syncMetrics(
431-
resolution,
487+
({ schemas: described, failures } = await syncMetrics(
488+
{ entries: describeNeeded },
432489
fetcher,
433490
));
434491

@@ -454,7 +511,7 @@ export async function generateFromEntryPoint(options: {
454511
// unknown — not errors. One summary line, no per-key warns; failed
455512
// keys are excluded (the warn loop above already reported them).
456513
const failedKeys = new Set(failures.map((f) => f.key));
457-
const degradedKeys = metricSchemas
514+
const degradedKeys = described
458515
.filter((s) => s.degraded && !failedKeys.has(s.key))
459516
.map((s) => s.key);
460517
if (degradedKeys.length > 0) {
@@ -467,20 +524,63 @@ export async function generateFromEntryPoint(options: {
467524
}
468525
} else {
469526
// Deliberately un-probed DESCRIBEs, not failures: emit every
470-
// configured key as a degraded schema (permissive types, empty
527+
// describe-needed key as a degraded schema (permissive types, empty
471528
// runtime allowlists) so both artifacts always exist, and say so
472-
// once — no per-key warnings (nothing failed). The dev warehouse
473-
// watch (or the next blocking run) re-enters this path with the
474-
// warehouse RUNNING and lands the real schemas.
475-
metricSchemas = resolution.entries.map(emptyMetricSchema);
529+
// once — no per-key warnings (nothing failed). Cache hits keep
530+
// serving their last-known-good schemas — only the remainder
531+
// degrades. The dev warehouse watch (or the next blocking run)
532+
// re-enters this path with the warehouse RUNNING and lands the real
533+
// schemas.
534+
described = describeNeeded.map(emptyMetricSchema);
476535
logger.info(
477536
"Warehouse %s is not running — wrote degraded metric types (permissive) for %d metric view(s) (%s); they will refresh once the warehouse is available.",
478537
warehouseId,
479-
resolution.entries.length,
480-
resolution.entries.map((e) => e.key).join(", "),
538+
describeNeeded.length,
539+
describeNeeded.map((e) => e.key).join(", "),
481540
);
482541
}
483542

543+
// Persist this pass's outcomes for exactly the keys it owned (the
544+
// describe-needed set): a successful DESCRIBE caches `retry: false`;
545+
// every degraded outcome — syncMetrics failures and non-terminal
546+
// states, the gate-skip path, and the fatal-preflight path (the last
547+
// two never entered syncMetrics) — caches its degraded schema with
548+
// `retry: true` so the next eligible pass re-describes only these
549+
// keys. Hits were partitioned out above and are never rewritten, which
550+
// is what lets a warehouse-down pass keep last-known-good entries
551+
// intact. One save per pass; with `noCache` the section was started
552+
// empty, so saving overwrites it with this pass's results alone.
553+
if (describeNeeded.length > 0 || noCache) {
554+
for (let i = 0; i < describeNeeded.length; i++) {
555+
// syncMetrics (and both .map(emptyMetricSchema) branches) return
556+
// one schema per entry in entry order, so described[i] always
557+
// belongs to describeNeeded[i] / neededHashes[i].
558+
metricsSection[describeNeeded[i].key] = {
559+
hash: neededHashes[i],
560+
schema: described[i],
561+
retry: described[i].degraded === true,
562+
};
563+
}
564+
cache.metrics = metricsSection;
565+
await saveCache(cache);
566+
}
567+
568+
// Merge cached hits with fresh results back into config order
569+
// (resolution.entries order — the renderers sort internally where
570+
// determinism matters).
571+
const describedByKey = new Map<string, MetricSchema>();
572+
for (const schema of described) {
573+
describedByKey.set(schema.key, schema);
574+
}
575+
const metricSchemas = resolution.entries.map(
576+
(entry) =>
577+
hitSchemas.get(entry.key) ??
578+
describedByKey.get(entry.key) ??
579+
// Unreachable: every entry is either a hit or describe-needed, and
580+
// every describe-needed entry yields exactly one schema above.
581+
emptyMetricSchema(entry),
582+
);
583+
484584
const metricFile =
485585
metricOutFile ?? path.join(path.dirname(outFile), METRIC_TYPES_FILE);
486586
const metricDeclarations = generateMetricTypeDeclarations(metricSchemas);

0 commit comments

Comments
 (0)