CI (SIAX Cloud) / integration (push) Successful in 43s
CI (SIAX Cloud) / security (push) Successful in 21s
CI (SIAX Cloud) / quality (push) Successful in 1m5s
Security Scan (SIAX Cloud) / security (push) Successful in 23s
CI (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m0s
CI (SIAX Cloud) / integration (pull_request) Successful in 42s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 38s
BI-P018 — every field the prompt names, plus the one it names by its absence.
The record carries `compiled_query_hash` and never the statement, and that is
ENFORCED: `assertNoRawSql` runs before any record is emitted and refuses one
whose `error` field looks like SQL. The realistic leak is ClickHouse's own
error text, which echoes the query — the test uses exactly that string. The
recorder writes at EVERY transition, because a recorder that only writes at
the end loses precisely the executions worth investigating. Illegal state
transitions throw rather than overwrite a terminal row.
The one place raw SQL is retained stays BI-P017's analyst statement history,
where a privileged human ran unreviewed SQL and "who ran what" must survive.
Different record, different retention posture, stated in both files.
BI-P019 — "stops" is VERIFIED, not requested. Aborting the client stops US
waiting, not the server; KILL QUERY without SYNC tells you nothing; even SYNC
can return while the query unwinds. So cancelAndVerify aborts, kills, then
POLLS the process list until the query is GONE or the deadline passes — and
if the deadline passes it returns `verified_stopped: false` rather than a
cheerful `true`. The return type is an outcome object, not a boolean, so a
caller cannot mistake "we asked" for "it stopped". An unreachable engine is
read as "still running" (fail closed), so the kill is still attempted at the
moment an operator most needs it to be.
Proven live against ClickHouse 24.8.14.39, with the counterfactual first:
* an identical bomb left alone IS still running after 2s — so "gone after
we cancelled" is not just "the query was short";
* the cancelled query had read 6,559,476,156 rows, and was verified gone
57ms after the request;
* system.query_log records ExceptionWhileProcessing with code 394,
QUERY_WAS_CANCELLED — ClickHouse's verdict, not ours.
BI-P021 — the honest version. A budget checked once before execution bounds
nothing, and the module says so in its first paragraph. ClickHouse's
max_rows_to_read bounds ONE query; it cannot express "this tenant may scan X
per hour", which is what a budget is for. So enforcement polls the engine's
live read_rows/read_bytes counters and CANCELS the running query, through
BI-P019 with verification, when window spend would be exceeded.
Measured live: a 1,000,000-row window cancelled the query at 130,883,409 rows,
92ms in, verified stopped, code 394. THE OVERSHOOT IS DOCUMENTED, in the module
and in the evidence: enforcement is granular to the poll interval, so this is
a bound of the form "within roughly one poll interval of engine throughput",
not a hard cap. Quoting it as a hard cap would be wrong.
Two more things stated rather than implied:
* `enforceInFlight: false` is reported by describeEnforcement() as
"NOT ENFORCED — this budget is advisory", and the constructor REFUSES to
accept enforceInFlight without a process control;
* an unshared window store is reported as non-binding across replicas
("with N replicas a tenant can spend up to N times this budget").
* a query the budget killed is still charged for the work it burned. Not
charging is how a tenant learns the cheapest query is a cancelled one.
Evidence: evidence/bi-p019-p021/cancellation-and-budget.json
268 unit tests, 7 live tests. typecheck + eslint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
/**
|
|
* tests/analytics/query-execution-model.test.ts
|
|
* ==============================================
|
|
* BI-P018 (execution model), BI-P019 (cancellation logic) and BI-P021
|
|
* (cost/budget) — the parts provable without an engine.
|
|
*
|
|
* The engine-dependent claims — "cancellation actually stops a running query",
|
|
* "the budget actually stops a running query" — are NOT asserted here. They
|
|
* live in tests/integration/query-cancellation-budget.integration.test.ts
|
|
* against a live ClickHouse, because a fake process-control that returns
|
|
* `isRunning: false` on the second call would make this file pass while
|
|
* proving nothing about the server.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { SiaxError } from '@/src/platform/errors';
|
|
import {
|
|
assertLegalTransition,
|
|
assertNoRawSql,
|
|
compiledQueryHash,
|
|
QueryExecutionRecorder,
|
|
TERMINAL_STATES,
|
|
type QueryExecutionRecord,
|
|
} from '@/src/analytics/execution/query-execution-record';
|
|
import { cancelAndVerify, type EngineProcessControl } from '@/src/analytics/execution/cancellation';
|
|
import {
|
|
costUnits,
|
|
DEFAULT_QUERY_BUDGET,
|
|
InMemoryWindowStore,
|
|
QueryBudgetEnforcer,
|
|
} from '@/src/analytics/cost/query-budget';
|
|
|
|
// ── BI-P018 ────────────────────────────────────────────────────────────────
|
|
|
|
function recorder(sink: { write: (r: QueryExecutionRecord) => void }, now = () => 1_000_000) {
|
|
return new QueryExecutionRecorder(
|
|
{
|
|
tenant_id: 't_alpha',
|
|
project_id: 'p_main',
|
|
principal_id: 'u_1',
|
|
workload: 'dashboard',
|
|
engine: 'clickhouse',
|
|
correlation_id: 'c_1',
|
|
sink,
|
|
now,
|
|
},
|
|
'opt_q1',
|
|
{
|
|
hash: compiledQueryHash('SELECT 1', { param_tenant: 't_alpha' }),
|
|
datasets: ['ds_sales'],
|
|
metrics: ['revenue'],
|
|
},
|
|
);
|
|
}
|
|
|
|
describe('BI-P018 — every field the prompt names, and none it forbids', () => {
|
|
it('carries all eighteen required fields once settled', async () => {
|
|
const written: QueryExecutionRecord[] = [];
|
|
let clock = 1_000_000;
|
|
const r = recorder({ write: (x) => void written.push(x) }, () => clock);
|
|
await r.submitted();
|
|
clock += 50;
|
|
await r.started();
|
|
clock += 700;
|
|
const final = await r.finish('succeeded', {
|
|
rows: 12,
|
|
rows_scanned: 40_960,
|
|
bytes_scanned: 1_234_567,
|
|
cost_units: 0.5,
|
|
});
|
|
|
|
for (const field of [
|
|
'query_id',
|
|
'tenant_id',
|
|
'project_id',
|
|
'principal_id',
|
|
'workload',
|
|
'submitted_at',
|
|
'started_at',
|
|
'completed_at',
|
|
'engine',
|
|
'compiled_query_hash',
|
|
'datasets',
|
|
'metrics',
|
|
'status',
|
|
'rows',
|
|
'rows_scanned',
|
|
'bytes_scanned',
|
|
'duration_ms',
|
|
'cache_status',
|
|
'cost_units',
|
|
]) {
|
|
expect(final[field as keyof QueryExecutionRecord], `missing ${field}`).toBeDefined();
|
|
}
|
|
expect(final.duration_ms).toBe(700);
|
|
expect(final.status).toBe('succeeded');
|
|
});
|
|
|
|
it('writes at EVERY transition, so an execution that never finishes is still visible', async () => {
|
|
const written: QueryExecutionRecord[] = [];
|
|
const r = recorder({ write: (x) => void written.push(x) });
|
|
await r.submitted();
|
|
await r.started();
|
|
expect(written.map((w) => w.status)).toEqual(['queued', 'running']);
|
|
// A recorder that only wrote at the end would have zero rows here — and
|
|
// would lose precisely the executions worth investigating.
|
|
});
|
|
|
|
it('stores a hash, never the statement', async () => {
|
|
const written: QueryExecutionRecord[] = [];
|
|
const r = recorder({ write: (x) => void written.push(x) });
|
|
await r.submitted();
|
|
await r.started();
|
|
const final = await r.finish('succeeded', { rows: 1 });
|
|
const serialized = JSON.stringify(final);
|
|
expect(serialized).not.toMatch(/SELECT/i);
|
|
expect(final.compiled_query_hash).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it('REFUSES to emit a record whose error field carries statement text', () => {
|
|
const record: QueryExecutionRecord = {
|
|
query_id: 'q',
|
|
execution_id: 'e',
|
|
tenant_id: 't_alpha',
|
|
principal_id: 'u',
|
|
workload: 'api',
|
|
submitted_at: new Date().toISOString(),
|
|
engine: 'clickhouse',
|
|
compiled_query_hash: 'a'.repeat(64),
|
|
datasets: [],
|
|
metrics: [],
|
|
status: 'failed',
|
|
cache_status: 'miss',
|
|
// The realistic leak: ClickHouse's error text echoes the query.
|
|
error: 'Code: 47. Unknown identifier in scope SELECT tenant_id FROM opt_analytics.sales',
|
|
};
|
|
expect(() => assertNoRawSql(record)).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('hashes the same statement with different parameters differently', () => {
|
|
const a = compiledQueryHash('SELECT 1 WHERE t = {t:String}', { param_t: 't_alpha' });
|
|
const b = compiledQueryHash('SELECT 1 WHERE t = {t:String}', { param_t: 't_beta' });
|
|
expect(a).not.toBe(b);
|
|
// ...and identically regardless of key order.
|
|
expect(compiledQueryHash('q', { b: '2', a: '1' })).toBe(compiledQueryHash('q', { a: '1', b: '2' }));
|
|
});
|
|
|
|
it('refuses an illegal state transition rather than overwriting a terminal row', () => {
|
|
for (const terminal of TERMINAL_STATES) {
|
|
expect(() => assertLegalTransition(terminal, 'running')).toThrowError(SiaxError);
|
|
}
|
|
expect(() => assertLegalTransition('queued', 'succeeded')).toThrowError(SiaxError);
|
|
expect(() => assertLegalTransition('running', 'succeeded')).not.toThrow();
|
|
});
|
|
|
|
it('records a cache hit as a cache hit, not as a very fast query', async () => {
|
|
const written: QueryExecutionRecord[] = [];
|
|
const r = recorder({ write: (x) => void written.push(x) });
|
|
await r.submitted();
|
|
await r.servedFromCache(42);
|
|
const final = written.at(-1) as QueryExecutionRecord;
|
|
expect(final.cache_status).toBe('hit');
|
|
expect(final.rows_scanned).toBe(0);
|
|
expect(final.bytes_scanned).toBe(0);
|
|
});
|
|
|
|
it('requires a verified tenant and principal', () => {
|
|
for (const bad of [{ tenant_id: '' }, { principal_id: '' }]) {
|
|
expect(
|
|
() =>
|
|
new QueryExecutionRecorder(
|
|
{
|
|
tenant_id: 't_alpha',
|
|
principal_id: 'u_1',
|
|
workload: 'api',
|
|
engine: 'clickhouse',
|
|
sink: { write: () => undefined },
|
|
...bad,
|
|
},
|
|
'q',
|
|
{ hash: 'h', datasets: [], metrics: [] },
|
|
),
|
|
).toThrowError(SiaxError);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── BI-P019 (logic only; the engine claim is in the integration suite) ─────
|
|
|
|
describe('BI-P019 — cancellation reports what it VERIFIED, not what it requested', () => {
|
|
function control(script: boolean[]): EngineProcessControl & { kills: number } {
|
|
let i = 0;
|
|
return {
|
|
engine: 'test',
|
|
kills: 0,
|
|
async isRunning() {
|
|
const v = script[Math.min(i, script.length - 1)];
|
|
i += 1;
|
|
return v;
|
|
},
|
|
async requestKill() {
|
|
(this as { kills: number }).kills += 1;
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
|
|
it('reports verified_stopped only after OBSERVING the query gone', async () => {
|
|
// running, running, running, gone
|
|
const c = control([true, true, true, false]);
|
|
const out = await cancelAndVerify(c, 'q1', { timeoutMs: 5_000, pollIntervalMs: 1 });
|
|
expect(out.verified_stopped).toBe(true);
|
|
expect(out.kill_accepted).toBe(true);
|
|
expect(c.kills).toBe(1);
|
|
});
|
|
|
|
it('reports verified_stopped=false when the query outlives the deadline — it does not claim success', async () => {
|
|
const c = control([true]); // never stops
|
|
let clock = 0;
|
|
const out = await cancelAndVerify(c, 'q1', {
|
|
timeoutMs: 100,
|
|
pollIntervalMs: 10,
|
|
now: () => (clock += 30),
|
|
sleep: async () => undefined,
|
|
});
|
|
expect(out.verified_stopped).toBe(false);
|
|
expect(out.already_finished).toBe(false);
|
|
});
|
|
|
|
it('does not claim to have stopped a query that had already finished', async () => {
|
|
const c = control([false]);
|
|
const out = await cancelAndVerify(c, 'q1', { timeoutMs: 100 });
|
|
expect(out.already_finished).toBe(true);
|
|
expect(out.verified_stopped).toBe(true);
|
|
});
|
|
|
|
it('keeps polling when the engine is unreachable, then reports unverified', async () => {
|
|
let clock = 0;
|
|
const flaky: EngineProcessControl = {
|
|
engine: 'test',
|
|
isRunning: async () => {
|
|
throw new Error('engine unreachable');
|
|
},
|
|
requestKill: async () => true,
|
|
};
|
|
// isRunning throws on the FIRST call too, which cancelAndVerify treats as
|
|
// "assume it was running" — the fail-closed reading.
|
|
const out = await cancelAndVerify(flaky, 'q1', {
|
|
timeoutMs: 50,
|
|
pollIntervalMs: 10,
|
|
now: () => (clock += 20),
|
|
sleep: async () => undefined,
|
|
});
|
|
expect(out.verified_stopped).toBe(false);
|
|
});
|
|
|
|
it('aborts the client side as well as the server side', async () => {
|
|
let aborted = false;
|
|
await cancelAndVerify(control([true, false]), 'q1', {
|
|
timeoutMs: 1_000,
|
|
pollIntervalMs: 1,
|
|
abort: () => {
|
|
aborted = true;
|
|
},
|
|
});
|
|
expect(aborted).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── BI-P021 ────────────────────────────────────────────────────────────────
|
|
|
|
describe('BI-P021 — the budget says plainly what it does and does not bound', () => {
|
|
const ledger = {
|
|
entries: [] as unknown[],
|
|
record(e: unknown) {
|
|
this.entries.push(e);
|
|
},
|
|
};
|
|
|
|
it('refuses to be constructed as an in-flight enforcer without a process control', () => {
|
|
expect(
|
|
() =>
|
|
new QueryBudgetEnforcer({
|
|
budget: DEFAULT_QUERY_BUDGET,
|
|
store: new InMemoryWindowStore(),
|
|
ledger,
|
|
enforceInFlight: true,
|
|
}),
|
|
).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('declares admission-only mode as advisory, in words', () => {
|
|
const e = new QueryBudgetEnforcer({
|
|
budget: DEFAULT_QUERY_BUDGET,
|
|
store: new InMemoryWindowStore(),
|
|
ledger,
|
|
enforceInFlight: false,
|
|
});
|
|
expect(e.describeEnforcement().binding).toBe(false);
|
|
expect(e.describeEnforcement().in_flight).toContain('NOT ENFORCED');
|
|
});
|
|
|
|
it('declares the per-process window as non-binding across replicas', () => {
|
|
const e = new QueryBudgetEnforcer({
|
|
budget: DEFAULT_QUERY_BUDGET,
|
|
store: new InMemoryWindowStore(),
|
|
ledger,
|
|
control: {
|
|
engine: 't',
|
|
isRunning: async () => false,
|
|
requestKill: async () => true,
|
|
progress: async () => undefined,
|
|
},
|
|
enforceInFlight: true,
|
|
storeIsShared: false,
|
|
});
|
|
expect(e.describeEnforcement().across_replicas).toContain('N times this budget');
|
|
expect(e.describeEnforcement().binding).toBe(false);
|
|
});
|
|
|
|
it('rejects an estimate that does not fit the remaining window', async () => {
|
|
const store = new InMemoryWindowStore();
|
|
const e = new QueryBudgetEnforcer({
|
|
budget: { ...DEFAULT_QUERY_BUDGET, maxRowsScanned: 1_000 },
|
|
store,
|
|
ledger,
|
|
enforceInFlight: false,
|
|
});
|
|
expect((await e.admit('t_alpha', { rows_to_read: 100, bytes_to_read: 1 })).admitted).toBe(true);
|
|
await store.add('t_alpha', { rows: 999, bytes: 0, units: 0, windowMs: DEFAULT_QUERY_BUDGET.windowMs });
|
|
const decision = await e.admit('t_alpha', { rows_to_read: 100, bytes_to_read: 1 });
|
|
expect(decision.admitted).toBe(false);
|
|
expect(decision.reason).toContain('rows');
|
|
});
|
|
|
|
it('throttles instead of rejecting when the budget says so', async () => {
|
|
const store = new InMemoryWindowStore();
|
|
const e = new QueryBudgetEnforcer({
|
|
budget: { ...DEFAULT_QUERY_BUDGET, maxRowsScanned: 10, onExhausted: 'throttle', throttleDelayMs: 250 },
|
|
store,
|
|
ledger,
|
|
enforceInFlight: false,
|
|
});
|
|
await store.add('t_alpha', { rows: 100, bytes: 0, units: 0, windowMs: DEFAULT_QUERY_BUDGET.windowMs });
|
|
const decision = await e.admit('t_alpha', { rows_to_read: 1, bytes_to_read: 1 });
|
|
expect(decision.admitted).toBe(true);
|
|
expect(decision.delay_ms).toBe(250);
|
|
});
|
|
|
|
it('charges for the work a cancelled query already did', async () => {
|
|
const store = new InMemoryWindowStore();
|
|
const entries: Array<{ terminated_by_budget: boolean; cost_units: number }> = [];
|
|
const e = new QueryBudgetEnforcer({
|
|
budget: DEFAULT_QUERY_BUDGET,
|
|
store,
|
|
ledger: { record: (x) => void entries.push(x as never) },
|
|
enforceInFlight: false,
|
|
});
|
|
const settled = await e.settle({
|
|
tenant_id: 't_alpha',
|
|
principal_id: 'u',
|
|
query_id: 'q',
|
|
cost: { rows_scanned: 1e9, bytes_scanned: 2 ** 30, execution_ms: 1000, engine: 'clickhouse' },
|
|
terminated_by_budget: true,
|
|
});
|
|
expect(settled.cost_units).toBeGreaterThan(0);
|
|
expect(entries[0].terminated_by_budget).toBe(true);
|
|
// Not charging for it is how a tenant learns the cheapest query is one
|
|
// that gets cancelled.
|
|
expect(entries[0].cost_units).toBe(settled.cost_units);
|
|
});
|
|
|
|
it('weights bytes above rows, so a wide scan is not billed like a narrow one', () => {
|
|
const narrow = costUnits({ rows_scanned: 1e9, bytes_scanned: 1e8, execution_ms: 0, engine: 'e' });
|
|
const wide = costUnits({ rows_scanned: 1e8, bytes_scanned: 1e10, execution_ms: 0, engine: 'e' });
|
|
expect(wide).toBeGreaterThan(narrow);
|
|
});
|
|
|
|
it('expires spend out of the rolling window', async () => {
|
|
let clock = 0;
|
|
const store = new InMemoryWindowStore(() => clock);
|
|
await store.add('t_alpha', { rows: 100, bytes: 0, units: 1, windowMs: 1000 });
|
|
expect((await store.get('t_alpha', 1000)).rows_scanned).toBe(100);
|
|
clock += 1500;
|
|
expect((await store.get('t_alpha', 1000)).rows_scanned).toBe(0);
|
|
});
|
|
|
|
it('in-flight enforcement returns immediately when the query is already gone', async () => {
|
|
const e = new QueryBudgetEnforcer({
|
|
budget: { ...DEFAULT_QUERY_BUDGET, maxRowsScanned: 1 },
|
|
store: new InMemoryWindowStore(),
|
|
ledger,
|
|
control: {
|
|
engine: 't',
|
|
isRunning: async () => false,
|
|
requestKill: async () => true,
|
|
progress: async () => undefined,
|
|
},
|
|
enforceInFlight: true,
|
|
});
|
|
const out = await e.enforceInFlight('t_alpha', 'q');
|
|
expect(out.terminated).toBe(false);
|
|
expect(out.polls).toBe(1);
|
|
});
|
|
});
|