Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion docs/docs/development/type-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ Pass `--wait` for CI and production builds, where accurate types must be present
npx @databricks/appkit generate-types --wait
```

#### Wait limit

`--wait` never waits forever. If the warehouse hasn't reached `RUNNING` within the limit (default **300 seconds**), the generator stops waiting:

- If committed types exist, it keeps them, prints a warning and exits 0. The warning includes the error code, the warehouse ID, the state it was stuck in and the limit, e.g. `warehouse_unavailable: warehouse abc123 is still STARTING after the 300s wait limit; kept existing analytics.d.ts`.
- If there are no committed types to fall back on, it fails with exit code 1 and a message with the same `warehouse_unavailable: …` line.

One limit covers the whole run: query types and metric-view types share it, so a stuck warehouse costs at most one limit. A warehouse that is already `RUNNING` is not affected.

The limit also holds when the warehouse API stops answering: every status request made while waiting is cut off when the limit runs out. The first status check, and the start request for a stopped warehouse, each get the time left in the limit, but at least 10 seconds and at most 30 seconds. So with a limit under 10 seconds, a slow API can still take up to 10 seconds to answer.

Set the limit in seconds with the `--wait-timeout` flag or the `APPKIT_TYPEGEN_WAIT_TIMEOUT` environment variable (the flag wins). `0` checks the warehouse once and doesn't wait. The environment variable also applies to `appKitTypesPlugin()` when it runs in a production Vite build.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 still starts a stopped warehouse. Suggest: "0 checks the warehouse once (starting it if it's stopped) and doesn't wait."


```bash
npx @databricks/appkit generate-types --wait --wait-timeout 120
APPKIT_TYPEGEN_WAIT_TIMEOUT=120 npm run build
```

#### CI resilience: committed types as fallback

In blocking mode (`--wait`), the generator attempts to fetch real types from your warehouse, but delegates to **committed type files** (`shared/appkit-types/analytics.d.ts` and, when Metric Views are configured, `shared/appkit-types/metric-views.d.ts`) as the fallback when the warehouse is unreachable. These generated files should be part of your repository. On a fresh CI checkout, every build attempts to DESCRIBE against the warehouse; the committed types are used only when that cannot complete.
Expand All @@ -102,7 +120,7 @@ The generator **never overwrites committed types with degraded (`result: unknown
A **two-bucket failure taxonomy** determines whether the build crashes or falls back to committed types:

- **Deterministic failures (always crash):** SQL syntax errors in your queries (genuine DESCRIBE failure against a reachable warehouse), HTTP 404 (bad or unknown warehouse ID), HTTP 400 (malformed request). These are developer or configuration errors that committed types must not hide.
- **Environmental failures (gate on committed types):** Authentication failures (401/403), network unreachability, warehouse unavailability (cold, deleting, or deleted), wait timeout on `RUNNING`, or any unrecognized failure. If every type file required by the app exists, the build **keeps them, emits a loud warning to stderr, and succeeds (exit 0)**. If a required file is missing, the build **crashes** with a message instructing you to run `npx @databricks/appkit generate-types --wait` locally (against a reachable warehouse) and commit the generated type files.
- **Environmental failures (gate on committed types):** Authentication failures (401/403), network unreachability, warehouse unavailability (cold, deleting, or deleted), the [wait limit](#wait-limit) running out before `RUNNING`, or any unrecognized failure. If every type file required by the app exists, the build **keeps them, emits a loud warning to stderr, and succeeds (exit 0)**. If a required file is missing, the build **crashes** with a message instructing you to run `npx @databricks/appkit generate-types --wait` locally (against a reachable warehouse) and commit the generated type files.

The loud warning is a single greppable stderr line naming the coarse cause (auth blocked / warehouse unreachable / warehouse unavailable) and the warehouse ID, so CI logs surface that the build fell back to committed types.

Expand Down
188 changes: 165 additions & 23 deletions packages/appkit/src/type-generator/index.ts

Large diffs are not rendered by default.

63 changes: 51 additions & 12 deletions packages/appkit/src/type-generator/query-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,20 @@ import {
sqlTypeToMarker,
} from "./types";
import {
describeStatusTimeout,
describeWaitTimeout,
getWarehouseState,
remainingWaitMs,
resolveWarehouseWaitTimeoutMs,
startWarehouse,
statusRequestTimeoutMs,
WarehouseStatusTimeoutError,
WarehouseWaitTimeoutError,
waitUntilRunning,
} from "./warehouse-status";

const logger = createLogger("type-generator:query-registry");

/**
* Upper bound on how long a `blocking`-mode preflight will wait for a starting
* warehouse to reach RUNNING before giving up (~5 min). Generous enough to ride
* out a cold start without hanging an interactive CLI invocation indefinitely.
*/
const PREFLIGHT_WAIT_MAX_MS = 300_000;

/**
* Numeric-context patterns for positional type inference.
* Hoisted to module scope — safe because matchAll() clones the regex internally.
Expand Down Expand Up @@ -498,6 +498,11 @@ export function inferParameterTypes(
* immediately), "blocking" waits for a starting warehouse and starts (then
* waits for) a stopped one, treating only a deleted/deleting warehouse as
* fatal. Defaults to "non-blocking".
* @param options.waitTimeoutMs - "blocking" only: how long to wait for the
* warehouse to reach RUNNING. Defaults to {@link resolveWarehouseWaitTimeoutMs}
* (`APPKIT_TYPEGEN_WAIT_TIMEOUT` seconds, else 5 min). When it runs out the
* run degrades like any environmental failure and reports the timeout in
* `environmentalDetail`.
* @returns an array of query schemas
*/
export async function generateQueriesFromDescribe(
Expand All @@ -507,13 +512,17 @@ export async function generateQueriesFromDescribe(
noCache?: boolean;
concurrency?: number;
mode?: PreflightMode;
waitTimeoutMs?: number;
/** Internal: epoch-ms deadline shared with the metric-view wait. */
waitDeadline?: number;
} = {},
): Promise<QueryGenerationResult> {
const {
noCache = false,
concurrency: rawConcurrency = 10,
mode = "non-blocking",
} = options;
const waitTimeoutMs = resolveWarehouseWaitTimeoutMs(options.waitTimeoutMs);
const concurrency =
typeof rawConcurrency === "number" && Number.isFinite(rawConcurrency)
? Math.max(1, Math.floor(rawConcurrency))
Expand Down Expand Up @@ -651,6 +660,9 @@ export async function generateQueriesFromDescribe(
// gate's warning can say why generation fell back to committed types.
let hadEnvironmentalFailure = false;
let environmentalCause: "auth" | "unreachable" | "unavailable" | undefined;
// Human-readable reason for the environmental failure, when there is a more
// specific one than the coarse cause (e.g. the wait limit ran out).
let environmentalDetail: string | undefined;

if (uncachedQueries.length > 0) {
// One-time warehouse preflight (before issuing any DESCRIBE). A single
Expand All @@ -672,7 +684,11 @@ export async function generateQueriesFromDescribe(
decision = "degradeAll";
} else {
try {
const state = await getWarehouseState(client, warehouseId);
const state = await getWarehouseState(client, warehouseId, {
timeoutMs: statusRequestTimeoutMs(
remainingWaitMs(waitTimeoutMs, options.waitDeadline),
),
});
decision = decidePreflight(state, mode);
if (decision === "fatal") {
// DELETED/DELETING is state-based and environmental.
Expand All @@ -685,10 +701,15 @@ export async function generateQueriesFromDescribe(
// poll to RUNNING. treatStoppedAsTransient rides out the stale
// pre-start STOPPED/STOPPING reading the start hasn't propagated past
// yet — only DELETED/DELETING (or the deadline) ends the wait early.
await startWarehouse(client, warehouseId);
await startWarehouse(client, warehouseId, {
timeoutMs: statusRequestTimeoutMs(
remainingWaitMs(waitTimeoutMs, options.waitDeadline),
),
});
const final = await waitUntilRunning(client, warehouseId, {
maxMs: PREFLIGHT_WAIT_MAX_MS,
maxMs: remainingWaitMs(waitTimeoutMs, options.waitDeadline),
treatStoppedAsTransient: true,
lastKnownState: state,
});
if (final === "RUNNING") {
decision = "proceed";
Expand All @@ -701,7 +722,8 @@ export async function generateQueriesFromDescribe(
}
if (decision === "waitThenProceed") {
const final = await waitUntilRunning(client, warehouseId, {
maxMs: PREFLIGHT_WAIT_MAX_MS,
maxMs: remainingWaitMs(waitTimeoutMs, options.waitDeadline),
lastKnownState: state,
});
if (final === "RUNNING") {
decision = "proceed";
Expand All @@ -713,7 +735,21 @@ export async function generateQueriesFromDescribe(
}
}
} catch (err) {
if (isConnectivityError(err)) {
if (err instanceof WarehouseWaitTimeoutError) {
// The wait limit ran out with the warehouse still not RUNNING. This is
// environmental: the has-types gate falls back to committed types or
// fails with this detail.
decision = "degradeAll";
isEnvironmental = true;
environmentalCause = "unavailable";
environmentalDetail = describeWaitTimeout(err, waitTimeoutMs);
} else if (err instanceof WarehouseStatusTimeoutError) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test reaches this branch or its twin at index.ts:813: none makes the first getWarehouse or startWarehouse hang. One test per path would cover it: use fake timers to go past the 10s bound, then check the fallback warning and the warehouse_unavailable failure.

// The warehouse API itself didn't answer in time: same handling.
decision = "degradeAll";
isEnvironmental = true;
environmentalCause = "unavailable";
environmentalDetail = describeStatusTimeout(err);
} else if (isConnectivityError(err)) {
// Warehouse unreachable (transient outage): degrade rather than fail —
// never fail a build on a blip. Still environmental, so the caller's
// has-types gate decides warn-and-fall-back (committed types present)
Expand Down Expand Up @@ -1080,6 +1116,9 @@ export async function generateQueriesFromDescribe(
environmentalCause: hadEnvironmentalFailure
? environmentalCause
: undefined,
environmentalDetail: hadEnvironmentalFailure
? environmentalDetail
: undefined,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -803,7 +803,10 @@ describe("generateQueriesFromDescribe", () => {

// The stopped warehouse was started, then described once it came up.
expect(mocks.startWarehouse).toHaveBeenCalledTimes(1);
expect(mocks.startWarehouse).toHaveBeenCalledWith({ id: "wh-123" });
expect(mocks.startWarehouse).toHaveBeenCalledWith(
{ id: "wh-123" },
{ signal: expect.any(AbortSignal) },
);
expect(mocks.executeStatement).toHaveBeenCalledTimes(1);
expect(schemas).toHaveLength(1);
expect(schemas[0].name).toBe("a");
Expand Down
7 changes: 5 additions & 2 deletions packages/appkit/src/type-generator/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,12 +773,15 @@ describe("generateFromEntryPoint — metric-view emission", () => {
expect(mocks.startWarehouse).toHaveBeenCalledWith(
expect.anything(),
"wh-1",
// Bounded like the status check, so a start that never answers can't hang.
{ timeoutMs: 30_000 },
);
expect(mocks.waitUntilRunning).toHaveBeenCalledWith(
expect.anything(),
"wh-1",
expect.objectContaining({
maxMs: 300_000,
// The default 5-min budget, minus what the run already spent.
maxMs: expect.closeTo(300_000, -4),
treatStoppedAsTransient: true,
}),
);
Expand Down Expand Up @@ -868,7 +871,7 @@ describe("generateFromEntryPoint — metric-view emission", () => {
expect(mocks.waitUntilRunning).toHaveBeenCalledWith(
expect.anything(),
"wh-1",
expect.objectContaining({ maxMs: 300_000 }),
expect.objectContaining({ maxMs: expect.closeTo(300_000, -4) }),
);
expect(mocks.executeStatement).not.toHaveBeenCalled();
// Degraded metric artifacts are NOT written in blocking mode.
Expand Down
Loading
Loading