@@ -4,6 +4,7 @@ import { WorkspaceClient } from "@databricks/sdk-experimental";
44import dotenv from "dotenv" ;
55import pc from "picocolors" ;
66import { createLogger } from "../logging/logger" ;
7+ import { hashSQL , loadCache , type MetricCacheEntry , saveCache } from "./cache" ;
78import {
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