Files
0pt/tests/analytics/query-execution-model.test.ts

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