Skip to content

Commit 261a943

Browse files
committed
fix: address PR review comments
- Explicitly classify dataset statuses into ok/error/pending buckets so `shuttingdown` and any unrecognized values are treated as still-pending rather than slipping through as a false-positive "loaded". Only `ready`/`disabled`/`refreshing` count as terminal-ok; only `error` short-circuits with `DatasetReadinessError`. Timeout messages now include each non-terminal-ok dataset's status so the user can tell `initializing` from `shuttingdown` from a typo. - Make dataset readiness failures always fatal once the check is opted in (`dataset-ready-timeout-seconds > 0`), independent of `fail-on-test-error` (which only governs runtime-probe results). Match action.yml/README/CHANGELOG to that behavior. The opt-out is `dataset-ready-timeout-seconds: 0`. - Add deploy + runtime tests covering each new path: terminal-ok values beyond `ready`, `shuttingdown`/unknown handling, and the fail-on-test-error-false-but-dataset-error-still-fatal case.
1 parent 762e261 commit 261a943

9 files changed

Lines changed: 145 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
### Added
1010
- Auto-capture a `repository` tag from `GITHUB_REPOSITORY` when set, sanitized to fit the API's tag-value rule (`/``_`). Users can override by setting `repository:` explicitly in the `tags` input.
11-
- New post-deploy dataset readiness check: poll `GET /v1/datasets?status=true` until every dataset leaves `initializing`, fail immediately on `error`. Configured via `dataset-ready-timeout-seconds` (default `300`, set to `0` to skip). Dataset states are surfaced as a `datasets` action output and as a table in the GitHub job step summary.
11+
- New post-deploy dataset readiness check: poll `GET /v1/datasets?status=true` until every dataset reaches a terminal-ok state (`ready`, `disabled`, or `refreshing`); fail the job immediately on `error` or on timeout-while-pending — regardless of `fail-on-test-error`, which still only governs runtime-probe results. Statuses like `shuttingdown` and any unrecognized values are treated as still-pending so the loop never returns a false-positive "loaded". Configured via `dataset-ready-timeout-seconds` (default `300`, set `0` to skip). Dataset states are surfaced as a `datasets` action output and as a table in the GitHub job step summary.
1212

1313
### Changed
1414
- `parseTags` now validates tag values against the Spice Cloud API rule (alphanumeric plus `_@-`). Previously the action only enforced length, so values like `repo: foo/bar` would round-trip to the API and fail there with a generic 400.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ Grant exactly the scopes for the features you use. The "All-in" row at the botto
105105
| `test-mcp-arguments` | no | `{}` | JSON-encoded arguments for the MCP tool call. |
106106
| `test-warmup-seconds` | no | `60` | Max wait for `isSpiceReady()` before running probes. |
107107
| `test-timeout-seconds` | no | `30` | Per-probe HTTP timeout. |
108-
| `dataset-ready-timeout-seconds` | no | `300` | Max wait for every dataset to leave `initializing` (via `GET /v1/datasets?status=true`). Action fails immediately if any dataset enters `error`. Set to `0` to skip. |
108+
| `dataset-ready-timeout-seconds` | no | `300` | Max wait for every dataset (via `GET /v1/datasets?status=true`) to reach a terminal-ok state (`ready`/`disabled`/`refreshing`). The job fails the moment any dataset enters `error`, or when the timeout elapses while any dataset is still pending — independent of `fail-on-test-error`. Set to `0` to skip the check. |
109109
| `runtime-url` | no | derived | Override probe base URL. By default derived from the app's region as `https://<region>-prod-aws-data.spiceai.io`. |
110110
| `fail-on-test-error` | no | `true` | Fail the job when any probe fails. |
111111
| `api-url` | no | `https://api.spice.ai` | Management API base URL. |

__tests__/deploy.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,37 @@ describe("runDeploy", () => {
385385
expect(probeSql).not.toHaveBeenCalled();
386386
});
387387

388+
it("dataset readiness failures are fatal even when fail-on-test-error is false", async () => {
389+
// The opt-out for the dataset check is `dataset-ready-timeout-seconds: 0`.
390+
// `fail-on-test-error` only governs runtime-probe results.
391+
const listApps = vi.fn().mockResolvedValue([sampleApp]);
392+
const createDeployment = vi.fn().mockResolvedValue(succeededDeployment);
393+
const getApiKeys = vi.fn().mockResolvedValue({ api_key: "rk", api_key_2: null });
394+
const api = fakeApi({ listApps, createDeployment, getApiKeys });
395+
396+
const fakeRuntime = {
397+
waitForReady: vi.fn().mockResolvedValue(undefined),
398+
waitForDatasetsReady: vi.fn().mockRejectedValue(
399+
Object.assign(new Error("1 dataset(s) failed to load: foo: bad creds"), {
400+
name: "DatasetReadinessError",
401+
datasets: [{ name: "foo", status: "Error", error_message: "bad creds" }],
402+
}),
403+
),
404+
} as unknown as RuntimeClient;
405+
406+
await expect(
407+
runDeploy(
408+
api,
409+
{
410+
...baseInputs,
411+
datasetReadyTimeoutSeconds: 60,
412+
failOnTestError: false,
413+
},
414+
{ runtimeFactory: () => fakeRuntime },
415+
),
416+
).rejects.toThrow(/dataset.*failed to load/);
417+
});
418+
388419
it("returns dataset states when all datasets are ready", async () => {
389420
const listApps = vi.fn().mockResolvedValue([sampleApp]);
390421
const createDeployment = vi.fn().mockResolvedValue(succeededDeployment);

__tests__/runtime.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,55 @@ describe("RuntimeClient", () => {
263263
await expect(rt.waitForDatasetsReady(5)).rejects.toThrow(/did not finish loading/);
264264
});
265265

266+
it("waitForDatasetsReady accepts disabled and refreshing as terminal-ok", async () => {
267+
const fetchImpl = vi.fn().mockResolvedValue(
268+
new Response(
269+
JSON.stringify([
270+
{ name: "a", status: "Ready" },
271+
{ name: "b", status: "Disabled" },
272+
{ name: "c", status: "Refreshing" },
273+
]),
274+
{ status: 200, headers: { "content-type": "application/json" } },
275+
),
276+
);
277+
const rt = new RuntimeClient({
278+
apiKey: "k",
279+
baseUrl: "https://x.example",
280+
warmupSeconds: 0,
281+
timeoutSeconds: 5,
282+
sdkFactory: () => makeSdk(),
283+
fetchImpl,
284+
});
285+
const datasets = await rt.waitForDatasetsReady(60);
286+
expect(datasets.map((d) => d.name)).toEqual(["a", "b", "c"]);
287+
});
288+
289+
it("waitForDatasetsReady treats shuttingdown and unknown statuses as still-pending (does not return early)", async () => {
290+
const fetchImpl = vi.fn().mockResolvedValue(
291+
new Response(
292+
JSON.stringify([
293+
{ name: "a", status: "Ready" },
294+
{ name: "b", status: "ShuttingDown" },
295+
{ name: "c", status: "QuantumFlux" },
296+
]),
297+
{ status: 200, headers: { "content-type": "application/json" } },
298+
),
299+
);
300+
const clock = makeClock();
301+
const rt = new RuntimeClient({
302+
apiKey: "k",
303+
baseUrl: "https://x.example",
304+
warmupSeconds: 0,
305+
timeoutSeconds: 5,
306+
sdkFactory: () => makeSdk(),
307+
fetchImpl,
308+
clock,
309+
});
310+
await expect(rt.waitForDatasetsReady(5)).rejects.toThrow(
311+
/did not finish loading.*ShuttingDown.*QuantumFlux/s,
312+
);
313+
});
314+
266315
it("waitForDatasetsReady is a no-op when timeout is 0", async () => {
267316
const fetchImpl = vi.fn();
268317
const rt = new RuntimeClient({

action.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,11 @@ inputs:
152152
dataset-ready-timeout-seconds:
153153
description: |
154154
Maximum seconds to wait for every dataset reported by `GET /v1/datasets?status=true`
155-
to leave the `initializing` state before running runtime probes. The action fails
156-
immediately if any dataset enters the `error` state. Set to `0` to skip the check.
155+
to reach a terminal-ok state (`ready`, `disabled`, or `refreshing`) before running
156+
runtime probes. The action fails the job immediately if any dataset enters `error`,
157+
or if the timeout elapses while datasets are still pending — regardless of
158+
`fail-on-test-error`, which only governs runtime-probe results. To disable the
159+
dataset check entirely, set this to `0`.
157160
required: false
158161
default: "300"
159162
runtime-url:

dist/index.js

Lines changed: 20 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/deploy.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,10 +243,11 @@ async function runPostDeployChecks(
243243
try {
244244
datasets = await runtime.waitForDatasetsReady(inputs.datasetReadyTimeoutSeconds);
245245
} catch (err) {
246+
// Dataset readiness failures are always fatal once the check is opted in
247+
// (i.e. `dataset-ready-timeout-seconds > 0`) — `fail-on-test-error` only
248+
// governs runtime-probe results. The opt-out is `dataset-ready-timeout-seconds: 0`.
246249
if (err instanceof DatasetReadinessError) datasets = err.datasets;
247-
const message = (err as Error).message;
248-
if (inputs.failOnTestError) throw new Error(message);
249-
core.warning(message);
250+
throw err;
250251
}
251252
}
252253

src/runtime.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,18 @@ export class RuntimeClient {
177177
}
178178

179179
/**
180-
* Poll `/v1/datasets?status=true` until every dataset is in a terminal-ready state
181-
* (`ready`, `disabled`, or `refreshing`). Throws `DatasetReadinessError` immediately
182-
* if any dataset reports `error`, or if the timeout expires while datasets are still
183-
* `initializing`.
180+
* Poll `/v1/datasets?status=true` until every dataset is in a terminal-ok state.
181+
*
182+
* State machine (statuses are matched case-insensitively):
183+
* - `error` → throw `DatasetReadinessError` immediately.
184+
* - `ready` / `disabled` / `refreshing`
185+
* → terminal-ok; counts toward "all loaded".
186+
* - `initializing`, `shuttingdown`,
187+
* or any unknown value → still pending; keep polling.
188+
*
189+
* On timeout, throws `DatasetReadinessError` listing each non-terminal-ok dataset
190+
* with its current status so the user can tell `initializing` from `shuttingdown`
191+
* or an unrecognized value.
184192
*/
185193
async waitForDatasetsReady(timeoutSeconds: number): Promise<DatasetState[]> {
186194
if (timeoutSeconds <= 0) return [];
@@ -197,7 +205,7 @@ export class RuntimeClient {
197205
continue;
198206
}
199207

200-
const errored = last.filter((d) => normalizeStatus(d.status) === "error");
208+
const errored = last.filter((d) => classifyStatus(d.status) === "error");
201209
if (errored.length > 0) {
202210
const detail = errored
203211
.map((d) => `${d.name}: ${d.error_message ?? d.error?.code ?? "<no message>"}`)
@@ -208,23 +216,23 @@ export class RuntimeClient {
208216
);
209217
}
210218

211-
const pending = last.filter((d) => normalizeStatus(d.status) === "initializing");
219+
const pending = last.filter((d) => classifyStatus(d.status) === "pending");
212220
if (pending.length === 0) {
213221
core.info(`All ${last.length} dataset(s) loaded.`);
214222
return last;
215223
}
216224

217225
core.info(
218-
`${last.length - pending.length}/${last.length} dataset(s) loaded (still initializing: ${pending.map((d) => d.name).join(", ")})`,
226+
`${last.length - pending.length}/${last.length} dataset(s) loaded (waiting on: ${pending.map((d) => `${d.name} [${d.status}]`).join(", ")})`,
219227
);
220228
await this.clock.sleep(3_000);
221229
}
222230

223231
const stillPending = last
224-
.filter((d) => normalizeStatus(d.status) === "initializing")
225-
.map((d) => d.name);
232+
.filter((d) => classifyStatus(d.status) === "pending")
233+
.map((d) => `${d.name} [${d.status}]`);
226234
throw new DatasetReadinessError(
227-
`Datasets did not finish loading within ${timeoutSeconds}s (still initializing: ${stillPending.join(", ") || "<unknown>"}).`,
235+
`Datasets did not finish loading within ${timeoutSeconds}s (waiting on: ${stillPending.join(", ") || "<unknown>"}).`,
228236
last,
229237
);
230238
}
@@ -313,8 +321,19 @@ export function truncate(value: string, max: number): string {
313321
return `${value.slice(0, max)}…`;
314322
}
315323

316-
function normalizeStatus(status: string): string {
317-
return (status ?? "").trim().toLowerCase();
324+
const TERMINAL_OK_DATASET_STATUSES = new Set(["ready", "disabled", "refreshing"]);
325+
326+
/**
327+
* Bucket a dataset status string into one of three classes the readiness loop
328+
* needs to make decisions. Anything that isn't an explicit terminal-ok or `error`
329+
* value (e.g. `shuttingdown`, future-added states, typos) is treated as `pending`
330+
* so the loop keeps polling rather than declaring a misclassified success.
331+
*/
332+
function classifyStatus(status: string): "ok" | "error" | "pending" {
333+
const s = (status ?? "").trim().toLowerCase();
334+
if (s === "error") return "error";
335+
if (TERMINAL_OK_DATASET_STATUSES.has(s)) return "ok";
336+
return "pending";
318337
}
319338

320339
function summarizeChat(body: string): string | undefined {

0 commit comments

Comments
 (0)