Skip to content

Commit 572b3bd

Browse files
committed
fix: gauntlet cycle 1 must-fixes for PR-5.1.5 (skip-scan hardening + coverage)
Applies the four must-fix and three cheap should-fix findings from the PR-5.1.5 sub-phase gauntlet (preset pr-2, parallel executors): - Branch-ordinal hardening: every skip-scan successor/latest probe now tags its UNION ALL arms with a constant ordinal and selects via ORDER BY br LIMIT 1, so the choice is SQL-guaranteed instead of relying on Append's undocumented syntactic arm order. Verified equivalent + still index-descent-shaped on the 2.1M-row prototype container (21ms -> 31ms). - NULLS-LAST fidelity tests: a stamped row beats a newer NULL-stamped row, and an all-NULL-stamped series still surfaces via the fallback arm. - Summary successor-branch tests: multi-format engine, second engine, and second query_idx all survive enumeration. - Discovery-parity test: collectQueryGroups equals the replaced GROUP BY oracle over a fixture spanning two datasets, every NULL/non-NULL variant and scale combination, and two storages. - Migration 006's backfill UPDATE is now exercised against pre-existing rows (apply 001, seed, apply the rest, assert stamped + no drift). - The index-shape test parses INCLUDE payloads instead of silently swallowing them, pins INCLUDE (value_ns) and commit_timestamp DESC on the summary index. - Docs: collectGroups docblock re-attached, README pool default 4 -> 8, e2e schema init now includes 007 with an accurate comment, orphan-row divergence documented in summary.ts, stale DISTINCT ON comment reworded. Deferred (per the synthesizer's should-fix/nit triage): deriving SUMMARY_CONCURRENCY from the pool config, sharing sargableDimEq with summary.ts. Signed-off-by: "Connor Tsui" <connor@spiraldb.com>
1 parent 5c5cb42 commit 572b3bd

6 files changed

Lines changed: 341 additions & 34 deletions

File tree

benchmarks-website/migrate/tests/postgres_e2e.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,18 @@ use vortex_bench_migrate::verify::run_postgres_value_verify;
3131
use vortex_bench_server::family;
3232
use vortex_bench_server::schema::COMMITS_DDL;
3333

34-
/// The authoritative Postgres schema, applied to the container at init: the base
35-
/// DDL plus the 006 read-path migration, whose denormalized
36-
/// `query_measurements.commit_timestamp` column the loader's post-COPY
37-
/// denormalization UPDATE requires (matching the prod target, where every
38-
/// migration is applied before a load).
34+
/// The Postgres schema applied to the container at init: the schema-shape
35+
/// migrations the loader touches -- the 001 base DDL, the 006 read-path
36+
/// migration (whose denormalized `query_measurements.commit_timestamp` column
37+
/// the loader's post-COPY denormalization UPDATE requires), and the 007
38+
/// covering-index swap (index-only, but kept so the container's index set
39+
/// matches prod's). The 002-005 role/grant migrations are deliberately
40+
/// omitted: they configure RDS auth, which a throwaway container neither has
41+
/// nor needs.
3942
const SCHEMA_SQL: &str = concat!(
4043
include_str!("../../../migrations/001_initial_schema.sql"),
4144
include_str!("../../../migrations/006_read_path_perf.sql"),
45+
include_str!("../../../migrations/007_summary_covering_index.sql"),
4246
);
4347

4448
/// Per-table row counts the fixture loads. Drives the count assertions.

benchmarks-website/web/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Connection config is read by `lib/db.ts`:
3535
| `BENCH_DB_REGION` | for IAM | AWS region for the RDS IAM signer; required when no password is set. IAM token signing also needs AWS credentials in the runtime environment. |
3636
| `BENCH_DB_SSL` | no (`verify-full`) | `verify-full` validates the certificate chain and hostname; `disable` is for local non-TLS containers only. Any other value fails loudly. |
3737
| `BENCH_DB_CA` | prod | PEM contents of the Amazon RDS CA bundle; Node's trust store does not include the RDS roots, so `verify-full` against RDS fails without it. |
38-
| `BENCH_DB_POOL_MAX` | no (4) | Max pool connections per serverless instance. |
38+
| `BENCH_DB_POOL_MAX` | no (8) | Max pool connections per serverless instance; the per-render summary fan-out (`SUMMARY_CONCURRENCY`) is sized to this default. |
3939

4040
## CDN caching
4141

benchmarks-website/web/lib/groups.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,189 @@ describe.skipIf(!dockerAvailable())('summary math fidelity (testcontainers Postg
464464

465465
// Slug-decode rejection short-circuits to a 400 before any DB call, so this
466466
// runs without Docker, matching the chart route's input-validation contract.
467+
// PR-5.1.5 read-path skip-scan fidelity. The canonical fixture above cannot
468+
// exercise these paths: it seeds a single query group with one format per
469+
// engine and stamps every commit_timestamp, so the summary skip scan's
470+
// format-successor branch, the NULLS-LAST latest emulation (a transient
471+
// NULL-stamped row must not beat an older stamped row; an all-NULL series must
472+
// still appear via the fallback arm), and most of the discovery successor's
473+
// NULL-partition branches never execute. This block seeds exactly those shapes.
474+
describe.skipIf(!dockerAvailable())(
475+
'read-path skip-scan fidelity (testcontainers Postgres)',
476+
() => {
477+
let container: StartedPostgreSqlContainer;
478+
479+
const COMMIT_A = 'a'.repeat(40);
480+
const COMMIT_B = 'b'.repeat(40);
481+
482+
beforeAll(async () => {
483+
container = await startBenchContainer();
484+
const pool = getPool();
485+
// COMMIT_B is NEWER than COMMIT_A: the NULLS-LAST probe must still prefer
486+
// COMMIT_A's stamped row over COMMIT_B's unstamped one.
487+
await pool.query(
488+
`INSERT INTO commits (commit_sha, timestamp, message, tree_sha, url) VALUES
489+
($1, '2026-05-01T12:00:00Z', 'older', $3, $4),
490+
($2, '2026-05-02T12:00:00Z', 'newer', $3, $5)`,
491+
[COMMIT_A, COMMIT_B, TREE_SHA, commitUrl(COMMIT_A), commitUrl(COMMIT_B)],
492+
);
493+
// One v2-allowlisted query group (tpch / NULL / '1' / nvme) shaped to fire
494+
// every summary successor branch: e1 has TWO formats (format successor),
495+
// e2 is a second engine (engine successor), q2 exists for e1:f1 (query_idx
496+
// successor). e1:f1 additionally carries a NEWER NULL-stamped row that must
497+
// lose to the stamped 111, and e9:f9 is an all-NULL-stamped series that
498+
// must surface through the fallback arm.
499+
const rows: ReadonlyArray<
500+
readonly [number, string, number, string, string, number, boolean]
501+
> = [
502+
// [measurement_id, sha, query_idx, engine, format, value_ns, stamped]
503+
[1, COMMIT_A, 1, 'e1', 'f1', 111, true],
504+
[2, COMMIT_B, 1, 'e1', 'f1', 222, false],
505+
[3, COMMIT_A, 1, 'e1', 'f2', 444, true],
506+
[4, COMMIT_A, 1, 'e2', 'f1', 555, true],
507+
[5, COMMIT_A, 2, 'e1', 'f1', 666, true],
508+
[6, COMMIT_B, 1, 'e9', 'f9', 333, false],
509+
];
510+
for (const [mid, sha, queryIdx, engine, format, valueNs, stamped] of rows) {
511+
await pool.query(
512+
`INSERT INTO query_measurements
513+
(measurement_id, commit_sha, dataset, dataset_variant, scale_factor,
514+
query_idx, storage, engine, format, value_ns, all_runtimes_ns,
515+
commit_timestamp)
516+
VALUES ($1, $2, 'tpch', NULL, '1', $3, 'nvme', $4, $5, $6, '{1}'::bigint[],
517+
CASE WHEN $7 THEN (SELECT timestamp FROM commits WHERE commit_sha = $2)
518+
ELSE NULL END)`,
519+
[mid, sha, queryIdx, engine, format, valueNs, stamped],
520+
);
521+
}
522+
// Discovery fan-out: every NULL/non-NULL combination of the two nullable
523+
// dimensions across two storages and two query indices, plus a second
524+
// sparse dataset, so all 15 successor branches and both NULL-partition
525+
// steps execute against the GROUP BY oracle below.
526+
let mid = 100;
527+
for (const variant of ['v1', 'v2', null]) {
528+
for (const scale of ['1', '10', null]) {
529+
for (const storage of ['nvme', 's3']) {
530+
for (const queryIdx of [1, 2]) {
531+
mid += 1;
532+
await pool.query(
533+
`INSERT INTO query_measurements
534+
(measurement_id, commit_sha, dataset, dataset_variant, scale_factor,
535+
query_idx, storage, engine, format, value_ns, all_runtimes_ns)
536+
VALUES ($1, $2, 'alpha', $3, $4, $5, $6, 'e1', 'f1', 1, '{1}'::bigint[])`,
537+
[mid, COMMIT_A, variant, scale, queryIdx, storage],
538+
);
539+
}
540+
}
541+
}
542+
}
543+
await pool.query(
544+
`INSERT INTO query_measurements
545+
(measurement_id, commit_sha, dataset, dataset_variant, scale_factor,
546+
query_idx, storage, engine, format, value_ns, all_runtimes_ns)
547+
VALUES (200, $1, 'beta', NULL, NULL, 7, 's3', 'e1', 'f1', 1, '{1}'::bigint[])`,
548+
[COMMIT_A],
549+
);
550+
});
551+
552+
afterAll(async () => {
553+
await resetPool();
554+
await container.stop();
555+
});
556+
557+
it('prefers a stamped row over a newer NULL-stamped row and keeps all-NULL series', async () => {
558+
const summary = await collectGroupSummary(
559+
{
560+
k: 'QueryGroup',
561+
dataset: 'tpch',
562+
dataset_variant: null,
563+
scale_factor: '1',
564+
storage: 'nvme',
565+
},
566+
[],
567+
);
568+
if (summary === null || summary.type !== 'queryBenchmark') {
569+
throw new Error(`expected queryBenchmark summary, got ${summary?.type}`);
570+
}
571+
const byName = new Map(summary.rankings.map((r) => [r.name, r.totalRuntime]));
572+
// e1:f1's latest for Q1 is the STAMPED 111, not the newer NULL-stamped 222
573+
// (plus its Q2 value 666); e9:f9 has only NULL-stamped rows and must still
574+
// appear via the fallback arm. Flipping the probe to a plain
575+
// `commit_timestamp DESC` (NULLS FIRST) order fails this test.
576+
expect(byName.get('e1:f1')).toBeCloseTo(111 + 666, 6);
577+
expect(byName.get('e9:f9')).toBeCloseTo(333, 6);
578+
});
579+
580+
it('enumerates the format, engine, and query_idx successor branches', async () => {
581+
const summary = await collectGroupSummary(
582+
{
583+
k: 'QueryGroup',
584+
dataset: 'tpch',
585+
dataset_variant: null,
586+
scale_factor: '1',
587+
storage: 'nvme',
588+
},
589+
[],
590+
);
591+
if (summary === null || summary.type !== 'queryBenchmark') {
592+
throw new Error(`expected queryBenchmark summary, got ${summary?.type}`);
593+
}
594+
const byName = new Map(summary.rankings.map((r) => [r.name, r.totalRuntime]));
595+
// Four distinct series: e1's second format (format successor), e2 (engine
596+
// successor), and e1:f1's q2 row (query_idx successor) all survive.
597+
expect([...byName.keys()].sort()).toEqual(['e1:f1', 'e1:f2', 'e2:f1', 'e9:f9']);
598+
expect(byName.get('e1:f2')).toBeCloseTo(444, 6);
599+
expect(byName.get('e2:f1')).toBeCloseTo(555, 6);
600+
});
601+
602+
it('discovery skip scan matches the GROUP BY oracle across NULL partitions', async () => {
603+
// The replaced GROUP BY is the oracle: identical tuples, identical
604+
// NULLS FIRST presentation order, computed over the same seeded data.
605+
const oracle = await getPool().query<{
606+
dataset: string;
607+
dataset_variant: string | null;
608+
scale_factor: string | null;
609+
storage: string;
610+
query_idx: number;
611+
}>(
612+
`SELECT dataset, dataset_variant, scale_factor, storage, query_idx
613+
FROM query_measurements
614+
GROUP BY dataset, dataset_variant, scale_factor, storage, query_idx
615+
ORDER BY dataset, dataset_variant NULLS FIRST,
616+
scale_factor NULLS FIRST, storage, query_idx`,
617+
);
618+
const expected = new Map<string, string[]>();
619+
for (const row of oracle.rows) {
620+
const groupSlug = groupKeyToSlug({
621+
k: 'QueryGroup',
622+
dataset: row.dataset,
623+
dataset_variant: row.dataset_variant,
624+
scale_factor: row.scale_factor,
625+
storage: row.storage,
626+
});
627+
const chartSlug = chartKeyToSlug({
628+
k: 'QueryMeasurement',
629+
dataset: row.dataset,
630+
dataset_variant: row.dataset_variant,
631+
scale_factor: row.scale_factor,
632+
storage: row.storage,
633+
query_idx: row.query_idx,
634+
});
635+
const charts = expected.get(groupSlug) ?? [];
636+
charts.push(chartSlug);
637+
expected.set(groupSlug, charts);
638+
}
639+
const groups = await collectGroups();
640+
const actual = new Map<string, string[]>(
641+
groups
642+
.filter((g) => groupKeyFromSlug(g.slug).k === 'QueryGroup')
643+
.map((g) => [g.slug, g.charts.map((c) => c.slug)]),
644+
);
645+
expect(actual).toEqual(expected);
646+
});
647+
},
648+
);
649+
467650
describe('GET /api/group/[slug] input validation (no DB)', () => {
468651
it('returns 400 for a structurally malformed slug', async () => {
469652
const slug = 'not-a-slug';

benchmarks-website/web/lib/queries.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -770,10 +770,16 @@ const DISCOVERY_COLS = 'q.dataset, q.dataset_variant, q.scale_factor, q.storage,
770770
* query_idx)` tuple in index order (ASC, NULLS LAST on the two nullable
771771
* columns). A single row comparison cannot express this (it would not be a
772772
* btree index qual past column 1, and NULL components poison it), so the
773-
* successor is a `UNION ALL` of mutually-ordered single-inequality branches,
774-
* evaluated lazily under the caller's `LIMIT 1` (Append stops at the first
775-
* row): deepest level first (next query_idx within the same group), then next
776-
* storage, scale_factor, dataset_variant, dataset.
773+
* successor is a `UNION ALL` of single-inequality branches that partition the
774+
* tuples greater than `s` -- deepest level first (next query_idx within the
775+
* same group), then next storage, scale_factor, dataset_variant, dataset.
776+
* Every qualifying row satisfies exactly one branch and all of branch N's rows
777+
* precede branch N+1's in tuple order, so the successor is the row from the
778+
* lowest-numbered non-empty branch: each branch carries a constant `br`
779+
* ordinal and the caller selects via `ORDER BY br LIMIT 1`, a SQL-guaranteed
780+
* choice rather than a reliance on Append's (undocumented) syntactic arm
781+
* order. See `collectQuerySummary` for the same construction and its cost
782+
* note.
777783
*
778784
* The nullable levels (scale_factor, dataset_variant) follow NULLS LAST order
779785
* with two branches each: `col > s.col` walks the non-NULL values (vacuously
@@ -827,7 +833,7 @@ function discoverySuccessorSql(): string {
827833
branches.push('q.dataset > s.dataset');
828834
return branches
829835
.map(
830-
(branch) => `(SELECT ${DISCOVERY_COLS}
836+
(branch, i) => `(SELECT ${i + 1} AS br, ${DISCOVERY_COLS}
831837
FROM query_measurements q
832838
WHERE ${branch}
833839
ORDER BY ${DISCOVERY_COLS}
@@ -862,6 +868,7 @@ async function collectQueryGroups(): Promise<Group[]> {
862868
FROM tuples s
863869
CROSS JOIN LATERAL (
864870
${discoverySuccessorSql()}
871+
ORDER BY br
865872
LIMIT 1
866873
) nxt
867874
)
@@ -1042,12 +1049,6 @@ function discoverGroups(groupKind: GroupKind): Promise<Group[]> {
10421049
}
10431050
}
10441051

1045-
/**
1046-
* Collect every group + chart link derivable from the data, the shared
1047-
* implementation behind `GET /api/groups`. Iterates the [`FAMILIES`] registry
1048-
* in order, attaches each group's summary + description, then applies the
1049-
* canonical [`GROUP_ORDER`].
1050-
*/
10511052
/**
10521053
* Bound on how many per-group summary queries run concurrently in
10531054
* [`collectGroups`] (PR-5.1.5 fix e). Kept at the `BENCH_DB_POOL_MAX` default so
@@ -1086,8 +1087,14 @@ async function mapWithConcurrency<T, R>(
10861087
return results;
10871088
}
10881089

1090+
/**
1091+
* Collect every group + chart link derivable from the data, the shared
1092+
* implementation behind `GET /api/groups`. Iterates the [`FAMILIES`] registry
1093+
* in order, attaches each group's summary + description, then applies the
1094+
* canonical [`GROUP_ORDER`].
1095+
*/
10891096
export async function collectGroups(): Promise<Group[]> {
1090-
// Discover families in parallel each scans a different fact table, so they
1097+
// Discover families in parallel -- each scans a different fact table, so they
10911098
// overlap rather than running one after another (PR-5.1.5 fix e). Concat
10921099
// preserves the FAMILIES registry order the final sort expects.
10931100
const perFamily = await Promise.all(FAMILIES.map((family) => discoverGroups(family.groupKind)));

benchmarks-website/web/lib/summary.ts

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -371,10 +371,17 @@ async function collectQuerySummary(
371371
// are index columns 5-7 (the planner degrades the row form to a filter over
372372
// a full scan). It is instead three single-column-inequality branches (next
373373
// format within the series' query_idx+engine, then next engine within its
374-
// query_idx, then next query_idx), each fully index-sargable. `UNION ALL`
375-
// under `LIMIT 1` evaluates the branches lazily in order (Append stops at
376-
// the first row), and branch order equals tuple-successor order, so the
377-
// first non-empty branch IS the successor.
374+
// query_idx, then next query_idx), each fully index-sargable. The branches
375+
// partition the tuples greater than the current series (every qualifying
376+
// row satisfies exactly one branch, and all of branch N's rows precede all
377+
// of branch N+1's rows in tuple order), so the successor is the row from
378+
// the lowest-numbered non-empty branch. Each branch carries a constant
379+
// `br` ordinal and the union is selected via `ORDER BY br LIMIT 1`, which
380+
// makes that choice a SQL-guaranteed ordering rather than a reliance on
381+
// Append evaluating `UNION ALL` arms in syntactic order (which Postgres
382+
// does today but does not document). The ordinal sort costs at most one
383+
// extra single-row descent per branch per step (~1.5x measured), not the
384+
// full-scan cliff the skip scan exists to avoid.
378385
//
379386
// - Every probe's ORDER BY spells out the full index prefix (dataset,
380387
// dataset_variant, scale_factor, storage, ...) even though those columns
@@ -391,10 +398,17 @@ async function collectQuerySummary(
391398
// index is `commit_timestamp DESC`, i.e. NULLS FIRST, so that order is not
392399
// index-provided. The per-series latest probe therefore takes the newest
393400
// `commit_timestamp IS NOT NULL` row first (index-ordered descent past the
394-
// NULL block) and falls back to an arbitrary NULL-timestamp row only when
395-
// the series has no timestamped rows, which is exactly the NULLS LAST
396-
// semantics. (Which row wins among all-NULL ties is unspecified, as it
397-
// already was under `DISTINCT ON`.)
401+
// NULL block, ordinal 1) and falls back to an arbitrary NULL-timestamp row
402+
// (ordinal 2) only when the series has no timestamped rows, which is
403+
// exactly the NULLS LAST semantics; the two branches are selected via the
404+
// same `ORDER BY br LIMIT 1` guarantee as the successor probe. (Which row
405+
// wins among all-NULL ties is unspecified, as it already was under
406+
// `DISTINCT ON`. One deliberate divergence from the replaced query: the
407+
// old form's `JOIN commits` silently excluded an orphan row whose
408+
// commit_sha has no commits row, while the skip scan never joins commits,
409+
// so such an orphan -- impossible unless a writer violates the
410+
// commits-upsert-first invariant -- would now surface via the NULL
411+
// fallback instead of vanishing. Fail-visible is preferred.)
398412
//
399413
// The `value_ns > 0` filter rides inside every probe: it is read from the
400414
// index leaf (INCLUDE), so the enumeration lands directly on series that have
@@ -429,7 +443,7 @@ async function collectQuerySummary(
429443
SELECT nxt.query_idx, nxt.engine, nxt.format
430444
FROM series s
431445
CROSS JOIN LATERAL (
432-
(SELECT q.query_idx, q.engine, q.format
446+
(SELECT 1 AS br, q.query_idx, q.engine, q.format
433447
FROM query_measurements q
434448
WHERE ${groupPred}
435449
AND q.query_idx = s.query_idx
@@ -439,7 +453,7 @@ async function collectQuerySummary(
439453
ORDER BY ${indexOrder}
440454
LIMIT 1)
441455
UNION ALL
442-
(SELECT q.query_idx, q.engine, q.format
456+
(SELECT 2 AS br, q.query_idx, q.engine, q.format
443457
FROM query_measurements q
444458
WHERE ${groupPred}
445459
AND q.query_idx = s.query_idx
@@ -448,13 +462,14 @@ async function collectQuerySummary(
448462
ORDER BY ${indexOrder}
449463
LIMIT 1)
450464
UNION ALL
451-
(SELECT q.query_idx, q.engine, q.format
465+
(SELECT 3 AS br, q.query_idx, q.engine, q.format
452466
FROM query_measurements q
453467
WHERE ${groupPred}
454468
AND q.query_idx > s.query_idx
455469
AND q.value_ns > 0
456470
ORDER BY ${indexOrder}
457471
LIMIT 1)
472+
ORDER BY br
458473
LIMIT 1
459474
) nxt
460475
)
@@ -463,7 +478,7 @@ async function collectQuerySummary(
463478
latest.value_ns AS value_ns
464479
FROM series s
465480
CROSS JOIN LATERAL (
466-
(SELECT q.value_ns::float8 AS value_ns
481+
(SELECT 1 AS br, q.value_ns::float8 AS value_ns
467482
FROM query_measurements q
468483
WHERE ${groupPred}
469484
AND q.query_idx = s.query_idx
@@ -474,7 +489,7 @@ async function collectQuerySummary(
474489
ORDER BY ${indexOrder}, q.commit_timestamp DESC
475490
LIMIT 1)
476491
UNION ALL
477-
(SELECT q.value_ns::float8 AS value_ns
492+
(SELECT 2 AS br, q.value_ns::float8 AS value_ns
478493
FROM query_measurements q
479494
WHERE ${groupPred}
480495
AND q.query_idx = s.query_idx
@@ -483,6 +498,7 @@ async function collectQuerySummary(
483498
AND q.value_ns > 0
484499
AND q.commit_timestamp IS NULL
485500
LIMIT 1)
501+
ORDER BY br
486502
LIMIT 1
487503
) latest
488504
ORDER BY s.query_idx, s.engine || ':' || s.format

0 commit comments

Comments
 (0)