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);
|
|
});
|
|
});
|