Repository navigation
fix(cli): return failure exit code for SQL batches #116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,7 @@ import type { Argv } from "yargs" | |
| import { readFileSync, openSync, readSync, closeSync } from "node:fs" | ||
| import { analyzeSql, isReadonlySqlSetting, splitSql, JobStatus, requestRaw, getCurrentUser, type JobID, type QueryResult } from "@clickzetta/sdk" | ||
| import type { GlobalArgs } from "../cli.js" | ||
| import { success, successRows, error, handledError, parseOutputArgs, renderOutput, renderErrorOutput } from "../output/index.js" | ||
| import { success, successRows, error, handledError, parseOutputArgs, renderOutput, renderErrorOutput, EXIT_BIZ_ERROR } from "../output/index.js" | ||
| import { maskRows } from "../output/masking.js" | ||
| import { logOperation } from "../logger.js" | ||
| import { type ExecContext, classifyExecError, execSql, execSqlWithRetry, getExecContext, isQueryResult, validateIdentifier } from "./exec.js" | ||
|
|
@@ -502,6 +502,13 @@ async function resolveAccountDisplayName(ctx: ExecContext) { | |
| } | ||
| } | ||
|
|
||
| // Per-statement failures are already rendered inline, so error() would print a | ||
| // second envelope; set the exit code and telemetry error it would have set. | ||
| function markStatementFailure(message: string) { | ||
| process.exitCode = EXIT_BIZ_ERROR | ||
| ;(process as unknown as Record<string, unknown>).lastError = message | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LOW (confidence: medium) — this newly routes raw engine error text from batch statements into telemetry, untruncated. ;(process as unknown as Record<string, unknown>).lastError = message
Before this PR a batch partial failure exited 0, so telemetry recorded If that is a concern, the fix is at the one shared writer rather than here (see the |
||
| } | ||
|
|
||
| async function formatQueryError(r: QueryResult, ctx: ExecContext, profileName?: string, fallback = "Query failed") { | ||
| return formatBillingError({ | ||
| code: r.errorCode, | ||
|
|
@@ -669,6 +676,8 @@ async function handler(argv: SqlArgs): Promise<void> { | |
| } | ||
| })) | ||
| success({ statements: results, count: statements.length }, { format, rowsKey: "statements" }) | ||
| const failed = results.find((r) => r.status === "error") | ||
| if (failed) markStatementFailure(String(failed.error)) | ||
|
Comment on lines
+679
to
+680
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MEDIUM (confidence: high that the behavior changes; asking about intent) — please confirm the const failed = results.find((r) => r.status === "error")
if (failed) markStatementFailure(String(failed.error))The PR title, body, and validation notes are all about It looks deliberate (the Second, smaller point on the same two lines: this works only because it runs after // success() resets exitCode to EXIT_OK; override AFTER it so a partial
// failure surfaces as a non-zero exit for scripts.
if (failed > 0) process.exitCode = EXIT_BIZ_ERRORPer |
||
| return | ||
| } | ||
| ctx = await getExecContext(argv) | ||
|
|
@@ -705,6 +714,7 @@ async function handler(argv: SqlArgs): Promise<void> { | |
| const r = await execSqlWithRetry(ctx, stmt, { hints: accumulatedHints, timeoutMs: argv.timeout * 1000, configStatements }) | ||
| if (isQueryResult(r) && r.status === JobStatus.FAILED) { | ||
| const line = { index: i, sql: stmt, error: { code: r.errorCode ?? "SQL_ERROR", message: await formatQueryError(r, ctx, argv.profile) }, time_ms: Date.now() - t0, ...(r.jobId ? { job_id: r.jobId } : {}) } | ||
| markStatementFailure(line.error.message) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LOW (confidence: high) — in a batch, each failure overwrites markStatementFailure(line.error.message)Calling it per-statement also means three mutation sites for one piece of run state. The established shape for this in the package is to count failures in the loop and set the exit code once after it ( Not a correctness bug: the exit code is 1 either way, and the existing tests only assert the exit code, not |
||
| process.stdout.write(renderOutput(line, format, batchField) + "\n") | ||
| logOperation("sql", { sql: stmt, ok: false, errorCode: r.errorCode }) | ||
| } else if (isQueryResult(r)) { | ||
|
|
@@ -717,6 +727,7 @@ async function handler(argv: SqlArgs): Promise<void> { | |
| } catch (err) { | ||
| const { code, message } = classifyExecError(err) | ||
| const line = { index: i, sql: stmt, error: { code, message: await formatClassifiedError({ code, message, ctx, profileName: argv.profile }) }, time_ms: Date.now() - t0 } | ||
| markStatementFailure(line.error.message) | ||
| process.stdout.write(renderOutput(line, format, batchField) + "\n") | ||
| logOperation("sql", { sql: stmt, ok: false, errorCode: code }) | ||
| } | ||
|
|
@@ -858,8 +869,8 @@ export function registerSqlCommand(cli: Argv<GlobalArgs>): void { | |
| .option("header", { type: "boolean", default: true, describe: "Include column names in output. Use --no-header or -N to suppress." }) | ||
| .option("N", { type: "boolean", hidden: true }) | ||
| .option("limit", { type: "number", default: 100, describe: "Max rows to return (0 for unlimited)" }) | ||
| .option("batch", { alias: "B", type: "boolean", default: false, describe: "Batch mode: execute multiple semicolon-separated statements sequentially" }) | ||
| .option("dry-run", { type: "boolean", default: false, describe: "EXPLAIN recognized readonly queries. Multiple queries require sql_split=true; writes, session commands and unknown syntax are rejected." }) | ||
| .option("batch", { alias: "B", type: "boolean", default: false, describe: "Batch mode: execute all statements sequentially; exit non-zero if any fails" }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LOW (confidence: high) — the rewrite drops the only hint in .option("batch", { alias: "B", type: "boolean", default: false, describe: "Batch mode: execute all statements sequentially; exit non-zero if any fails" })Upstream of this change it read "execute multiple semicolon-separated statements sequentially". "all statements" no longer tells a reader where the statement boundary comes from, and nothing else in this command's help or epilogue says it. Adding the exit-code sentence does not require removing that: "Batch mode: execute multiple semicolon-separated statements sequentially; exit non-zero if any fails" keeps both. One adjacent note while you are in this string, not a request to change code: "execute all statements sequentially" is accurate for the exit code but not for the output shape — |
||
| .option("dry-run", { type: "boolean", default: false, describe: "EXPLAIN recognized readonly queries; exit non-zero if any fails. Multiple queries require sql_split=true; writes, session commands and unknown syntax are rejected." }) | ||
| .epilogue([ | ||
| "Examples:", | ||
| " cz-cli sql \"SELECT * FROM orders LIMIT 10\"", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { beforeEach, expect, test } from "bun:test" | ||
| import { join } from "node:path" | ||
| import { onFetch, requireTestHome, sqlFailure, sqlSuccess, stubStudioContext } from "./support/cz-fixtures.js" | ||
|
|
||
| const { execute } = await import("../src/execute.ts") | ||
|
|
||
| beforeEach(async () => { | ||
| stubStudioContext() | ||
| await Bun.write(join(requireTestHome(), ".clickzetta", "profiles.toml"), [ | ||
| 'default_profile = "test"', | ||
| "[profiles.test]", | ||
| 'pat = "pat"', | ||
| 'service = "uat-api.clickzetta.com"', | ||
| 'instance = "inst"', | ||
| 'workspace = "ws0"', | ||
| ].join("\n")) | ||
| }) | ||
|
|
||
| // Exercise the real command and SDK with only the HTTP boundary substituted. | ||
| for (const input of ["execute", "file"] as const) { | ||
| for (const failure of ["none", "job", "http"] as const) { | ||
| test(`batch ${input}: ${failure} failure preserves results and exit status`, async () => { | ||
| const submitted: string[] = [] | ||
| onFetch({ | ||
| match: (url) => url.includes("/lh/submitJob"), | ||
| respond: (_url, _method, body) => { | ||
| const query = (body as { jobDesc: { sqlJob: { query: string[] } } }).jobDesc.sqlJob.query[0] | ||
| submitted.push(query) | ||
| if (query.includes("SELECT 2") && failure === "job") { | ||
| return sqlFailure("CZLH-42000", "Statement failed") | ||
| } | ||
| if (query.includes("SELECT 2") && failure === "http") { | ||
| return new Response("Submission rejected", { status: 400 }) | ||
| } | ||
| return sqlSuccess(["value"], [[1]]) | ||
| }, | ||
| }) | ||
|
|
||
| const sql = "SELECT 1; SELECT 2; SELECT 3;" | ||
| const file = join(requireTestHome(), "batch.sql") | ||
| if (input === "file") await Bun.write(file, sql) | ||
| const result = await execute("sql --batch --sync", input === "file" ? ["-f", file] : ["-e", sql]) | ||
| const rows = result.output.trim().split("\n").map((line) => JSON.parse(line)) | ||
|
|
||
| expect(submitted).toHaveLength(3) | ||
| expect(rows.map((row) => row.index)).toEqual([0, 1, 2]) | ||
| expect(rows[0].rows).toEqual([[1]]) | ||
| expect(rows[2].rows).toEqual([[1]]) | ||
| expect(result.exitCode).toBe(failure === "none" ? 0 : 1) | ||
| if (failure === "none") { | ||
| expect(rows.every((row) => !row.error)).toBe(true) | ||
| return | ||
| } | ||
| expect(rows[1].error.code).toBe(failure === "job" ? "CZLH-42000" : "HTTP_400") | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| for (const failure of ["none", "job", "http"] as const) { | ||
| test(`dry-run: ${failure} failure preserves results and exit status`, async () => { | ||
| await Bun.write(join(requireTestHome(), ".clickzetta", "czcli.json"), '{"sql_split":true}') | ||
| onFetch({ | ||
| match: (url) => url.includes("/lh/submitJob"), | ||
| respond: (_url, _method, body) => { | ||
| const query = (body as { jobDesc: { sqlJob: { query: string[] } } }).jobDesc.sqlJob.query[0] | ||
| if (query.includes("SELECT 2") && failure === "job") return sqlFailure("CZLH-42000", "Statement failed") | ||
| if (query.includes("SELECT 2") && failure === "http") return new Response("Submission rejected", { status: 400 }) | ||
| return sqlSuccess(["plan"], [["ok"]]) | ||
| }, | ||
| }) | ||
|
|
||
| const result = await execute("sql", ["SELECT 1; SELECT 2", "--dry-run"]) | ||
| const statements = JSON.parse(result.output).data.statements as { status: string }[] | ||
|
|
||
| expect(statements.map((s) => s.status)).toEqual(failure === "none" ? ["ok", "ok"] : ["ok", "error"]) | ||
| expect(result.exitCode).toBe(failure === "none" ? 0 : 1) | ||
| }) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MEDIUM (confidence: medium) — this helper re-implements the body of
error(), and makesprocess.lastErrora process-global with two independent writers in two modules.output/index.ts:134-135is the same two statements:lastErroris an undeclared slot onprocessreached only by casting. Its readers arerun-cli.ts:865andexecute.ts:108; until now its only writer waserror(), so the cast lived once, next to the readers' contract. Writing it from a command module means the shape of that channel is now asserted in two places, and the cast hides any divergence from the type system.The smaller correct change is to export the pair from
output/index.ts— e.g.export function markFailure(message: string, exitCode = EXIT_BIZ_ERROR)— haveerror()call it, and import it here. That keeps one writer and removes theas unknown as Record<string, unknown>cast fromsql.ts.Worth noting the two existing partial-failure precedents set the exit code only and never touch
lastError(status.ts:43,status.ts:81,analytics-agent.ts:1621), so there are now three variants of "mark this run as failed" in the package.