Files
0pt/tests/integration/materialization-drift-clickhouse.integration.test.ts
Claude Opus 5 96ff601248
CI (SIAX Cloud) / security (push) Successful in 49s
Security Scan (SIAX Cloud) / security (push) Successful in 50s
CI (SIAX Cloud) / integration (push) Successful in 55s
CI (SIAX Cloud) / quality (push) Successful in 1m20s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m21s
CI (SIAX Cloud) / security (pull_request) Successful in 42s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 37s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m10s
feat(analytics): BI-P105 — a pre-aggregate serves only while it is provably undrifted
A slow query is a complaint; a drifted pre-aggregate answers instantly, with
confidence, and is wrong. So the router is fail-closed on eight named reasons,
and the one people leave out — drift_never_verified — is the one that matters
on launch day: a brand-new aggregate is exactly as unproven as a drifted one.

Proved against ClickHouse 26.3.10.60, 2M rows, a real materialized view
(evidence/bi-p105/materialization-drift.json):

  after backfill      match, 48 buckets, 801 571 002 both sides
  GDPR erasure        ALTER TABLE ... DELETE on the source. An MV is an INSERT
                      trigger and never sees a mutation, so the aggregate kept
                      the deleted revenue: it would have reported 801 571 002
                      against a true 761 580 554 — 39 990 448 overstated,
                      5.251%, with NOTHING stale. Staleness at the moment of
                      the decision: 1 second.
  guarded router      base_table (state_not_serving)
  counterfactual      a staleness-only router SERVES it — measured, not assumed
  after rebuild       match, and routing returns to the materialization

Drift detection treats a MISSING bucket as drift even when every value present
is correct (late-arriving rows are the sneakiest failure), and an empty
comparison as insufficient_data rather than match — the same false green
BI-P025 found in a quality check over an empty table.

The measured speed-up is reported as a distribution, not a figure: p50 3.4x,
but the run-to-run range spans 0.6x-7.5x on one idle host, so quoting "3.4x"
as a property of the system would be misleading (BI-P104 doctrine).

19 unit tests + 4 against live ClickHouse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 03:56:32 +02:00

349 lines
13 KiB
TypeScript

/**
* tests/integration/materialization-drift-clickhouse.integration.test.ts
* =======================================================================
* BI-P105 against a LIVE ClickHouse. Skips honestly when CLICKHOUSE_URL is
* unset; with it set and the server down, it FAILS rather than skipping.
*
* BI-P105's DoD is "performance improvement measured and freshness semantics
* documented", and both halves are done here against real data rather than
* asserted. What the DoD does not say, and what this file is really about, is
* that a pre-aggregate fails SILENTLY: it answers instantly and wrongly.
*
* The drill drives a real ClickHouse materialized view through the exact
* sequence that produces a confident wrong number in production:
*
* 1. 2M source rows, a SummingMergeTree target and a materialized view.
* 2. Backfill, verify with the SHIPPED detector: verdict `match`.
* 3. Measure both paths repeatedly, and report the DISTRIBUTION — a single
* speed-up figure quoted as a system property is misleading (BI-P104).
* 4. `ALTER TABLE ... DELETE` on the source: a GDPR erasure, the single most
* common real cause of pre-aggregate drift. A ClickHouse materialized
* view is an INSERT trigger; it does not see mutations, so the target
* keeps the deleted revenue forever and nothing errors.
* 5. The detector must catch it, and `selectQuerySource` must refuse to
* serve. The counterfactual — a router that checks only staleness — is
* run on the same state and MUST serve, with the wrong number recorded
* in kronor.
*
* Everything measured lands in evidence/bi-p105/materialization-drift.json.
*
* docker run -d --name ch -p 28123:8123 -e CLICKHOUSE_PASSWORD=test \
* clickhouse/clickhouse-server:26.3
* CLICKHOUSE_URL=http://127.0.0.1:28123 CLICKHOUSE_AUTH=default:test \
* pnpm vitest run tests/integration/materialization-drift-clickhouse.integration.test.ts
*
* It CREATEs and DROPs its own database. Never point it at the b0ts
* ClickHouse or any quarantined market data.
*/
import fs from 'node:fs';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
applyDriftReport,
detectDrift,
type AggregateBucket,
type DriftReport,
} from '@/src/analytics/materialization/drift-detector';
import {
materializationStaleness,
selectQuerySource,
type MaterializationSpec,
type MaterializationStatus,
} from '@/src/analytics/materialization/materialization';
import type { AnalyticsQueryRequest } from '@/src/analytics/contract/query-request';
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
const CLICKHOUSE_AUTH = process.env.CLICKHOUSE_AUTH;
const run = CLICKHOUSE_URL ? describe : describe.skip;
const DB = `opt_p105_${Date.now().toString(36)}`;
const ROWS = 2_000_000;
const REPETITIONS = 9;
const headers: Record<string, string> = CLICKHOUSE_AUTH
? { Authorization: `Basic ${Buffer.from(CLICKHOUSE_AUTH).toString('base64')}` }
: {};
async function ch(query: string): Promise<string> {
const res = await fetch(CLICKHOUSE_URL ?? 'http://skipped.invalid', {
method: 'POST',
headers,
body: query,
});
const text = await res.text();
if (!res.ok) throw new Error(`ClickHouse ${res.status}: ${text.slice(0, 400)}`);
return text;
}
/** Rows as buckets keyed "date|region", which is what the detector compares. */
async function bucketsFrom(query: string): Promise<AggregateBucket[]> {
const text = await ch(`${query} FORMAT JSONEachRow`);
return text
.trim()
.split('\n')
.filter(Boolean)
.map((line) => {
const r = JSON.parse(line) as { d: string; region: string; revenue: number; orders: number };
return { key: `${r.d}|${r.region}`, values: { revenue: Number(r.revenue), orders: Number(r.orders) } };
});
}
/** Median and the spread around it. A single number is not a system property. */
function distribution(samples: number[]): {
n: number;
min: number;
p50: number;
p95: number;
max: number;
spread_ratio: number;
} {
const s = [...samples].sort((a, b) => a - b);
const at = (q: number) => s[Math.min(s.length - 1, Math.floor(q * s.length))];
return {
n: s.length,
min: s[0],
p50: at(0.5),
p95: at(0.95),
max: s[s.length - 1],
spread_ratio: Number((s[s.length - 1] / Math.max(s[0], 1e-9)).toFixed(2)),
};
}
async function timeQuery(query: string): Promise<number> {
const t0 = performance.now();
await ch(`${query} FORMAT JSONEachRow`);
return performance.now() - t0;
}
const SOURCE_AGG = `
SELECT toString(order_date) AS d, region, sum(amount) AS revenue, count() AS orders
FROM ${DB}.sales WHERE tenant_id = 't_alpha' GROUP BY d, region ORDER BY d, region`;
const TARGET_AGG = `
SELECT toString(order_date) AS d, region, sum(revenue) AS revenue, sum(orders) AS orders
FROM ${DB}.sales_daily WHERE tenant_id = 't_alpha' GROUP BY d, region ORDER BY d, region`;
const SPEC: MaterializationSpec = {
materialization_id: 'mv_sales_daily',
tenant_id: 't_alpha',
project_id: 'p_main',
dataset: 'ds_sales',
strategy: 'clickhouse_materialized_view',
target_ref: `${DB}.sales_daily`,
metrics: ['revenue', 'orders'],
dimensions: ['order_date', 'region'],
time_dimension: 'order_date',
grain: 'day',
max_staleness_seconds: 3600,
drift_check_interval_seconds: 86_400,
tolerance: { absolute: 0.5, relative: 0.0001 },
};
const REQUEST: AnalyticsQueryRequest = {
dataset: 'ds_sales',
metrics: ['revenue'],
dimensions: ['order_date'],
filters: [],
timeRange: { grain: 'day' },
};
const evidence: Record<string, unknown> = {};
let status: MaterializationStatus;
run('BI-P105 materialization against a LIVE ClickHouse', () => {
beforeAll(async () => {
evidence.clickhouse_version = (await ch('SELECT version()')).trim();
evidence.recorded_at = new Date().toISOString();
evidence.source_rows = ROWS;
await ch(`CREATE DATABASE IF NOT EXISTS ${DB}`);
await ch(`
CREATE TABLE ${DB}.sales (
tenant_id LowCardinality(String),
order_date Date,
region LowCardinality(String),
customer_id UInt32,
amount Decimal(18, 2)
) ENGINE = MergeTree ORDER BY (tenant_id, order_date, region)`);
await ch(`
INSERT INTO ${DB}.sales
SELECT
if(number % 5 = 0, 't_beta', 't_alpha') AS tenant_id,
toDate('2026-08-01') + toIntervalDay(number % 30) AS order_date,
['SE','NO','DK','FI'][(number % 4) + 1] AS region,
toUInt32(number % 5000) AS customer_id,
toDecimal64((number % 977) + 13, 2) AS amount
FROM numbers(${ROWS})`);
await ch(`
CREATE TABLE ${DB}.sales_daily (
tenant_id LowCardinality(String),
order_date Date,
region LowCardinality(String),
revenue Decimal(38, 2),
orders UInt64
) ENGINE = SummingMergeTree ORDER BY (tenant_id, order_date, region)`);
await ch(`
CREATE MATERIALIZED VIEW ${DB}.sales_daily_mv TO ${DB}.sales_daily AS
SELECT tenant_id, order_date, region, sum(amount) AS revenue, count() AS orders
FROM ${DB}.sales GROUP BY tenant_id, order_date, region`);
// The MV is an INSERT trigger: it saw nothing that was already there, so
// the backfill is explicit. (This is drift cause #1, and it is why the
// first check below has to be able to fail.)
await ch(`
INSERT INTO ${DB}.sales_daily
SELECT tenant_id, order_date, region, sum(amount) AS revenue, count() AS orders
FROM ${DB}.sales GROUP BY tenant_id, order_date, region`);
await ch(`OPTIMIZE TABLE ${DB}.sales_daily FINAL`);
status = {
materialization_id: SPEC.materialization_id,
state: 'serving',
data_watermark: new Date().toISOString(),
last_refresh_at: new Date().toISOString(),
};
}, 300_000);
afterAll(async () => {
if (!CLICKHOUSE_URL) return;
try {
await ch(`DROP DATABASE IF EXISTS ${DB}`);
} finally {
const dir = path.join(process.cwd(), 'evidence/bi-p105');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'materialization-drift.json'),
`${JSON.stringify(evidence, null, 2)}\n`,
);
}
}, 60_000);
it('the freshly backfilled aggregate MATCHES its source', async () => {
const report = detectDrift({
fromSource: await bucketsFrom(SOURCE_AGG),
fromAggregate: await bucketsFrom(TARGET_AGG),
tolerance: SPEC.tolerance,
metrics: ['revenue', 'orders'],
});
expect(report.buckets_compared).toBeGreaterThan(0); // an empty compare proves nothing
expect(report.verdict).toBe('match');
status = applyDriftReport(status, report);
evidence.after_backfill = {
verdict: report.verdict,
buckets_compared: report.buckets_compared,
revenue_total: report.totals.revenue,
};
}, 120_000);
it('measures the improvement as a DISTRIBUTION, not a single number', async () => {
const base: number[] = [];
const agg: number[] = [];
for (let i = 0; i < REPETITIONS; i += 1) {
// Interleaved so a machine that gets busy halfway through penalises both.
base.push(await timeQuery(SOURCE_AGG));
agg.push(await timeQuery(TARGET_AGG));
}
const baseD = distribution(base);
const aggD = distribution(agg);
evidence.performance = {
note:
'ClickHouse caches marks and pages between repetitions, so these are WARM numbers on one host ' +
'with nothing else running. They describe this shape of query on this data, not a system property.',
repetitions: REPETITIONS,
base_table_ms: baseD,
pre_aggregate_ms: aggD,
speedup_p50: Number((baseD.p50 / Math.max(aggD.p50, 1e-9)).toFixed(1)),
speedup_range_min_max: [
Number((baseD.min / Math.max(aggD.max, 1e-9)).toFixed(1)),
Number((baseD.max / Math.max(aggD.min, 1e-9)).toFixed(1)),
],
};
// The claim under test is only that the pre-aggregate is not SLOWER; the
// magnitude is reported, never asserted as a threshold.
expect(aggD.p50).toBeLessThanOrEqual(baseD.p50);
}, 300_000);
it('a GDPR erasure on the source DRIFTS the aggregate, and the drift is caught', async () => {
const before = await bucketsFrom(TARGET_AGG);
// The mutation a right-to-erasure request produces. A materialized view is
// an insert trigger — it never sees this.
await ch(
`ALTER TABLE ${DB}.sales DELETE WHERE tenant_id = 't_alpha' AND customer_id < 250 SETTINGS mutations_sync = 2`,
);
const fromSource = await bucketsFrom(SOURCE_AGG);
const fromAggregate = await bucketsFrom(TARGET_AGG);
expect(fromAggregate).toEqual(before); // the aggregate genuinely did not move
const report: DriftReport = detectDrift({
fromSource,
fromAggregate,
tolerance: SPEC.tolerance,
metrics: ['revenue', 'orders'],
});
expect(report.verdict).toBe('drifted');
expect(report.worst).toBeDefined();
const drifted = applyDriftReport(status, report);
const guarded = selectQuerySource({ request: REQUEST, spec: SPEC, status: drifted, now: new Date() });
expect(guarded.source).toBe('base_table');
// COUNTERFACTUAL: a router that checks only staleness serves it, and this
// is the number a dashboard would have shown.
const stalenessOnlyServes =
(materializationStaleness(drifted, new Date()) ?? 0) <= SPEC.max_staleness_seconds;
expect(stalenessOnlyServes).toBe(true);
evidence.gdpr_erasure_drill = {
mutation: "ALTER TABLE sales DELETE WHERE tenant_id='t_alpha' AND customer_id < 250",
verdict: report.verdict,
buckets_compared: report.buckets_compared,
revenue_true_after_erasure: report.totals.revenue.source,
revenue_pre_aggregate_would_report: report.totals.revenue.aggregate,
revenue_overstatement: report.totals.revenue.delta,
revenue_overstatement_pct: Number(
((report.totals.revenue.delta / report.totals.revenue.source) * 100).toFixed(3),
),
worst_bucket: report.worst,
guarded_router_decision: guarded,
staleness_only_router_would_serve: stalenessOnlyServes,
staleness_seconds_at_decision: materializationStaleness(drifted, new Date()),
note:
'The aggregate was NOT stale by any clock: it had just been refreshed, and every freshness check ' +
'passed. Only the source comparison caught it. This is why staleness alone is not a correctness ' +
'guarantee.',
};
status = drifted;
}, 300_000);
it('and a refresh that re-derives the aggregate clears the drift', async () => {
await ch(`TRUNCATE TABLE ${DB}.sales_daily`);
await ch(`
INSERT INTO ${DB}.sales_daily
SELECT tenant_id, order_date, region, sum(amount) AS revenue, count() AS orders
FROM ${DB}.sales GROUP BY tenant_id, order_date, region`);
await ch(`OPTIMIZE TABLE ${DB}.sales_daily FINAL`);
const report = detectDrift({
fromSource: await bucketsFrom(SOURCE_AGG),
fromAggregate: await bucketsFrom(TARGET_AGG),
tolerance: SPEC.tolerance,
metrics: ['revenue', 'orders'],
});
expect(report.verdict).toBe('match');
const healed: MaterializationStatus = applyDriftReport(
{ ...status, state: 'serving', data_watermark: new Date().toISOString() },
report,
);
const decision = selectQuerySource({ request: REQUEST, spec: SPEC, status: healed, now: new Date() });
expect(decision.source).toBe('materialization');
evidence.after_rebuild = {
verdict: report.verdict,
revenue_total: report.totals.revenue,
routing: decision,
};
}, 300_000);
});