feat: add LDBC SNB Interactive short-read benchmark (Lane 1)#225
Conversation
Fixes a scaling bug discovered while running the LDBC SNB benchmark (#225): `refreshStatistics()` on the SQLite backend ran a bare, unscoped `ANALYZE` (no table argument), which does two things wrong: 1. **Unscoped**: it re-analyzes every table in the database file, not just TypeGraph's own tables. The Postgres backend already scopes its `ANALYZE` to TypeGraph-managed tables (`coreAnalyzeStatements` in `postgres.ts`) — SQLite's never did. 2. **Unbounded**: it does a full table/index scan per call. Postgres's `ANALYZE` always examines a bounded, fixed-size sample of rows (governed by `default_statistics_target`) regardless of table size, so it never showed this problem. SQLite's `ANALYZE` scans the whole table unless bounded by `PRAGMA analysis_limit` (a feature that exists for exactly this reason, per SQLite's own docs). `bulkCreate`/`bulkInsert` on nodes and edges auto-trigger this refresh once a single call's row count crosses `AUTO_REFRESH_STATISTICS_ROW_THRESHOLD` (1000 rows, added in #212). #212's own PR description says the design deliberately did not cover "loops of small batches that never individually reach the threshold" — but never considered loops of *large* batches (each already over the threshold), which is exactly what any real streaming bulk loader does for a multi-million-row dataset (and what this repo's own `backend-setup.md` docs recommend: `for (const batch of batches) { await store.nodes.Document.bulkCreate(batch); }`). Each such batch independently re-triggers the refresh, and with unbounded per-call cost growing with total table size, total load time integrates to **O(n²)**. Discovered via a real LDBC SNB SF1 load (packages/benchmarks, #225): the comments stage (~2M rows) ran for **over 4.5 hours without finishing**, versus 46 minutes for the ~1M-row posts stage just before it — a ratio far beyond what row-count alone explains. Isolated the cause with controlled reproductions (60k-200k row loads), confirmed it disappears with a single node kind and no edges, persists without any ontology involved, and correlates with total graph size rather than any one table's size. ## Fix `refreshStatistics()` on the SQLite backend now: - Sets `PRAGMA analysis_limit = 1000` (SQLite's own suggested value for large databases) before running `ANALYZE`, bounding per-call cost regardless of table size. - Scopes `ANALYZE` to TypeGraph's own tables (`typegraph_nodes`, `typegraph_edges`, `typegraph_node_uniques`, the fulltext table, plus the `recorded_*` tables when present), matching the Postgres backend. ## Verification - New test file `tests/backends/sqlite/refresh-statistics-scope.test.ts` (4 tests): pins the `analysis_limit` value, asserts an unrelated table sharing the same connection is never touched, asserts no throw when `recorded_*` tables are absent, and bounds batch-to-batch growth in a repeated-large-bulkInsert loop. Confirmed these tests **fail against the pre-fix code** (reverted the fix, re-ran — 2 of 4 failed as expected) before restoring the fix. - Existing `tests/auto-refresh-statistics.test.ts` (#212's suite): all 8 tests still pass unchanged — no change to the trigger semantics, only to what `refreshStatistics()` itself does on SQLite. - Full `packages/typegraph` suite: 229 files / 4525 tests passed (898 skipped, Postgres-dependent — see below), 0 failures. - Property-based suite: 345 passed / 1 skipped, 0 failures. - Postgres suite (`pnpm test:postgres`, unaffected by this SQLite-only change but run per this repo's convention for backend changes): 67 files / 1816 tests passed, 0 failures. - Manual repro: 100k-row load (with the `Message` ontology) completes in ~8.4s with only ~2.1x growth from first batch to last (consistent with normal B-tree logarithmic growth); 200k-row load (no ontology) completes in ~22.9s with ~2.7x growth. Both previously would have shown the ~5x-per-58k-row trend that led to the multi-hour SF1 hang.
SF10 IS2 latency cliff: root cause found and fixedThe first real SF10 EC2 run (10x SF1 data volume) surfaced a severe regression: IS2 (friends' recent messages) median latency jumped 689x on the SQLite backend (74ms → 51s) and 28x on Postgres (247ms → 7.0s), while Neo4j (7.3x) and LadybugDB (2.6x) scaled near-linearly over the same data growth. Everything else (IS1, IS3-IS7) stayed competitive. Root cause (independently investigated and cross-checked against this codebase): the covering index this branch already added for the Post/Comment Fixed in two places:
Full investigation write-up (ruled-out hypotheses, EXPLAIN QUERY PLAN evidence, the isolated-vs-combined-scale local repros): see the linked artifact in session notes. Not yet done: a real SF10 re-run to confirm the fix closes the gap at true scale — local repros verified the index-shape change and plan flip, but held off on the ~10-13h EC2 cost pending approval. Tracked as the next step before this branch's results can be trusted for the public comparison draft. |
|
Synced the PR #245 review fixes onto this branch's copy of the same commit (`a42151c`) — `from_idx`/`to_idx` now also cover the join's target-id column (a second real gap found in review, beyond the `valid_from` fix), plus the `cacheSizeKib` validation and changeset guidance fixes. Full SQLite suite: 4,539 passed, 0 failed. |
Adds packages/benchmarks/src/real/, the first lane of the real-workload benchmark program in docs/design/benchmark-program-plan.md: IS1-IS7 implemented through the TypeGraph query builder (no hand SQL) against TypeGraph/SQLite and TypeGraph/PostgreSQL, and via idiomatic Cypher against Neo4j and LadybugDB, on the official LDBC SNB Interactive dataset. Includes the shared fairness harness (row-count parity gate, noisy-sample detection, competitor doctor, summary.json writer), a committed tiny smoke fixture, and a CI-safe smoke test that degrades gracefully when Docker/optional packages are unavailable. Also fixes a long-standing root .gitignore bug: a bare `reports/` rule meant for Stryker mutation-testing output was unscoped and had silently excluded packages/benchmarks/reports/history.jsonl from every commit since it was introduced. Verified end-to-end on all four engines against the smoke fixture with 100% row-count parity across all seven queries. An attempted real SF1 run surfaced a bulk-load scaling issue in TypeGraph (documented in packages/benchmarks/reports/snb-lane1-results.md as a finding, not fixed here per the plan's benchmarks-only scope).
The TypeGraph/PostgreSQL container's 4GB tmpfs ran out of space loading
real LDBC SF1 data ("no space left on device") on an 8GB Docker VM —
the exact lesson the sibling braiddb project's own SNB driver
documents (real SF1 plus indexes overflows a RAM-backed data dir).
PGDATA now lives on the container's disk-backed writable layer instead
of a sized tmpfs. Neo4j's /data mount had the same latent risk (a fixed
6g tmpfs never exercised at SF1 scale before now) — switched to a named
docker volume, matching how braiddb's own Neo4j driver avoids tmpfs for
anything past its tiny synthetic profile. /logs and /tmp stay tmpfs on
both since their contents don't scale with dataset size.
Separately: the harness had no per-engine failure isolation, so the
Postgres crash above took the whole process down with it — losing
typegraph-sqlite's already-completed 87-minute SF1 run's structured
results.json/summary.json (the console log still had the printed
numbers, but the JSON artifacts were never written). Extracted the
per-engine load+measure logic into runEngine() and wrapped each
engine's turn in a try/catch: one engine's crash is now recorded as an
explicit failed row (never a silent loss) in results.json, and the run
continues to the remaining engines and still writes every artifact.
--check now also exits non-zero on an engine failure, not just a
row-count mismatch, since a doctor-runnable engine crashing mid-run is
a real regression distinct from "not runnable" (which must stay green).
Neo4j schema indexes are scoped to one label each and never inherited across the other labels a multi-label node carries. The containerOf/ replyOf edge-wiring steps MATCH by id filtered on the concrete :Post/ :Comment label (not :Message), so without a label-specific index those MATCHes fell back to a full label scan per row — at SF1 scale (~1M Post nodes) a single 5,000-row containerOf batch turned into billions of comparisons and multi-hour hangs. Verified via EXPLAIN against a throwaway container: both edge-wiring queries now plan as NodeUniqueIndexSeek(Locking) instead of NodeByLabelScan. Smoke suite still passes with 100% row-count parity across all four engines.
Runs the existing, unmodified snb-short-reads.ts on a dedicated, ephemeral EC2 instance instead of local hardware, via SSM Run Command (no SSH, no key pairs — matches the nicia-sandbox host security group, which has no inbound SSH at all). launch provisions the box, waits for bootstrap, and fires the benchmark command in the background; collect polls until it finishes, writes results locally, appends to reports/history.jsonl, and terminates the instance. Motivated by local runs being slowed by Time Machine backup contention and Docker Desktop's 8GB/8-CPU VM sharing resources with several other projects' running containers.
…medir resolveDatasetRoot's SF1_CACHE_DIR is built from os.homedir(), which resolves on whichever machine evaluates it. The EC2 bootstrap script imported that same joined constant, so it baked the *local* (macOS) homedir into the remote Ubuntu box's dataset download path instead of /root — caught in the smoke dry run's decoded user-data before it could break a real SF1 attempt (smoke skips the dataset step, so it never surfaced there). Export the path segments instead of the joined path, and join them against /root on the bootstrap-script side.
Real end-to-end verification of the new EC2 runner (launch/collect, SSM Run Command, result parsing) against the committed smoke fixture, run on a c7i.4xlarge in nicia-sandbox. All 4 engines, 100% row-count parity.
The official LDBC SF1 archive extracts into its own social_network-...-LongDateFormatter/ subdirectory, not flat into the current directory. sf1DownloadInstructions()'s documented curl+tar steps (and the EC2 bootstrap script, which copied them) both expected dynamic/person_0_0.csv directly under the cache dir, so a fresh auto-download always failed dataset resolution afterward. This path was never exercised locally (dev always used --data-dir against a pre-extracted copy from the sibling braiddb project's cache), so nothing caught it until the first fully-automated EC2 bootstrap tried a real download. tar --strip-components=1 lands dynamic/, static/, etc. directly in the cache dir, matching what isSnbDatagenDirectory already expects. Verified against the real cached archive before pushing.
AWS-RunShellScript's document parameter executionTimeout defaults to 3600s (1h) and is entirely separate from send-command's top-level --timeout-seconds (a delivery timeout, not an execution timeout). Without setting it explicitly, the benchmark's SSM command was killed after exactly one hour regardless of --benchmark-timeout-seconds, mid-run, with no error from the benchmark itself. Caught because the real SF1 run was still healthy (InProgress, log advancing) right up until the silent 1-hour cutoff. Also wrap collect()'s result-parsing/writing in try/finally so instance termination always runs (unless --keep), even when parsing fails or the command didn't reach Success — previously an early throw left the instance running and unbilled-for-nothing until the bootstrap script's 6h dead-man's switch caught it, discovered when exactly this happened after the executionTimeout cutoff.
The bootstrap script's self-shutdown safety net was a hardcoded flat 360 minutes (6h) — coincidentally the exact same duration as the benchmark's own default SSM executionTimeout. A real SF1 run that legitimately took close to 6 hours got killed by its own safety net with ladybugdb (the last engine) still finishing, losing the whole run one step from completion (confirmed via StateReason=Client.InstanceInitiatedShutdown). The dead-man's switch exists to catch "collect was never invoked", not to race a benchmark that's still running. Its duration is now --bootstrap-timeout-seconds + --benchmark-timeout-seconds + a 1h buffer, so it can only fire after both of those windows have already closed.
A real SF1 run's sqlite (74.5min) + postgres (78.4min) + neo4j (4.5min) load phases alone already used ~2.6h, and the fourth launch hit the 6h executionTimeout with only ladybugdb's load left unfinished (sqlite/postgres/neo4j all completed cleanly, all 21 queries done). 6h was an initial guess with no real data behind it; 10h leaves comfortable margin now that we have real per-engine timings. The dead-man's switch scales with this automatically (it's derived from bootstrap + benchmark timeouts, not hardcoded).
LadybugDB (Kuzu-family) stores relationships in a columnar CSR (Compressed Sparse Row) adjacency structure, built once for bulk COPY FROM but rebuilt on every incremental MATCH+CREATE edge write. The previous loader batched writes via UNWIND+CREATE (matching every other engine driver's own pattern), which scaled roughly cubically: a controlled repro showed edge insertion going from 46s at 50k edges to 388s at 100k edges (~8.4x time for 2x data). At SF1 scale (~2.4M edges) this made ladybugdb's load impractical — a real SF1 EC2 run got the other three engines done in ~2.6h combined, then stalled on ladybugdb for 4+ hours without finishing. Rewrote loadSnbDataset() to stream each entity/edge kind to a staging CSV file, then issue one COPY <table> FROM per file, in dependency order (nodes before edges referencing them) via the same stageComplete() sequencing the streaming reader already guarantees. Multi-pair relationship tables (HasCreator, ReplyOf) disambiguate via COPY's from=/to= options, verified against the installed engine version directly (Kuzu's own docs disagreed with themselves on the header option name). parallel=false avoids a known limitation with quoted newlines in the default parallel CSV reader. Verified: smoke suite still passes with 100% row-count parity across all 4 engines. A same-scale repro against the new COPY-based path shows edge loading dropping from 388,056ms to 57ms at 100k edges (~6,800x) and scaling linearly through 800k edges (a scale never reached by the old path in practical time).
Add a bench:snb:generate-smoke-fixture script so knip recognizes generate-smoke-fixture.ts as a real entry point instead of a script only discoverable via a comment. Drop the `export` keyword from SF1_CACHE_DIR, collectHardwareInfo, HardwareInfo, DoctorCheck, LaneHistoryEntry, SnbEngineOptions, and snb-graph.ts's individual node/edge definitions (Person/Forum/Post/Comment/Message/knows/ hasCreator/containerOf/replyOf) plus its SnbGraph type — all genuinely module-private (only ever consumed within their own file; other files import the composed snbGraph/SnbStore/EngineVersion/etc. instead). Also drops types.ts's SnbRowSink re-export, which nothing actually imported (engine drivers import it directly from dataset/ldbc-csv.ts). Verified: knip clean, typecheck clean, smoke suite still 100% row-count parity across all 4 engines.
The COPY FROM rewrite (previous commit) fixed the scaling problem but
introduced a correctness bug at real SF1 scale: a real SF1 EC2 run
failed loading ladybugdb with "expected 4 values per row, but got
more" on a forum title containing an embedded comma
("Group for Charles_V,_Holy_Roman_Emperor in Copenhagen") — despite
being correctly RFC4180-quoted.
Reproduced locally against the real dataset: the exact same line
parses fine in total isolation (a 2-line test file), but fails
against the real 90,492-row file at the identical line — the failing
line's byte offset (~1,082,649) sits suspiciously close to a 1MB
buffer boundary. This points to a bug in Ladybug's CSV parser mis-
handling a quoted field that straddles an internal read-buffer
boundary in large files, not a bug in this loader's own escaping.
Comma is a poor delimiter choice for LDBC's natural-language content
regardless — titles/post/comment text contain commas constantly,
forcing quoting (and this bug's exposure) on a large fraction of
rows. Switching to `|` (one of Kuzu/Ladybug's supported delimiters)
means real content almost never needs quoting at all, since a literal
pipe essentially never appears in prose.
Verified against the real cached SF1 dataset end-to-end (not just the
one failing table): the full dataset now loads via the actual
ladybugdb engine driver in 20.4s, with all 7 IS queries running
cleanly afterward.
…o4j) From the run that caught the ladybugdb CSV delimiter bug: sqlite, postgres, and neo4j all completed successfully with full IS1-7 results before ladybugdb failed. Real numbers worth keeping rather than discarding.
reports/snb-lane1-results.md now leads with a real, single-run SF1 result across all four engines (100% row-count parity, 0 failures) instead of the "does not complete in a practical time" finding — documents the three scaling bugs found and fixed to get there (SQLite ANALYZE, Neo4j missing Post/Comment indexes, LadybugDB CSR-vs- incremental-writes), and files a new follow-up: TypeGraph's SQLite/Postgres backends load ~65-100x slower than Neo4j/LadybugDB at this scale, likely because the latter two use an engine-native bulk path bulkInsert doesn't have an equivalent of. README.md's bench:snb:sf1 section: replaces the outdated "does not complete" caution with real load-time expectations per engine, adds the EC2 runner as an alternative to local hardware, and fixes the manual download instructions' own missing --strip-components=1 (the official archive extracts into its own subdirectory, not flat — the same bug the EC2 bootstrap script had until an earlier commit here). Also commits the 4 new real SF1 history.jsonl entries (one per engine) from the run these numbers come from.
Profiling a synthetic 1M-row bulk load showed store.refreshStatistics() firing on every single bulkInsert() call, not just occasionally: the auto-trigger checks each individual call's row count against the threshold (1000), and our loader's batches (2000 rows) always exceed it, so every batch re-triggers a full statistics refresh across all core tables. Disabling entirely a flat ~2.7x speedup for node inserts in isolation (22-75ms/batch growing with table size -> flat ~17-23ms) on a synthetic 1M-row repro, since both engine drivers already call store.refreshStatistics() explicitly once after the whole load finishes — the auto-trigger was pure redundant work for this caller. Not a library-behavior change: autoRefreshStatistics is an existing, documented store option; this only sets it for how this specific benchmark already uses the store.
Each bulkInsert() call gets its own transaction (runInWriteTransaction), while the bind-parameter-limit chunking that keeps individual INSERT statements within the driver's bound happens *inside* that call, independent of this outer size. A smaller outer batch just means more transactions/round-trips for the same total rows, not smaller/safer statements. Profiled on a synthetic 500k-row repro (post-#227): 2,000 (the old value) was consistently the slowest across two separate runs; anything >=10,000 was 25-30% faster, with the exact optimum noisy run-to-run on this shared dev machine (10k/20k/50k traded places for fastest between runs). 20,000 is a well-supported middle ground, not a precisely tuned peak — comfortably below both SQLite's (~3,640) and Postgres's (~7,281) internal per-statement chunk sizes' multiples, so this still issues multiple appropriately-sized statements per call rather than one enormous one. Verified: smoke suite still 100% row-count parity across all 4 engines.
Each message's root-post walk in the LDBC IS2 benchmark query was independent of the others but ran sequentially in a for loop. Switching to Promise.all overlaps round-trip latency across up to 10 messages instead of paying it serially, with no query-shape change. Verified functionally (row-count parity, smoke + full SF1) and via a same-machine controlled A/B on SQLite (~1.8x faster, not a regression as a cross-machine comparison against the EC2 baseline table falsely suggested).
Same pattern as IS2: the parent-author lookup and the replies fetch each depend only on message.id, not on each other, so they now run concurrently via Promise.all instead of paying two round trips serially. The knows-check that genuinely depends on both results stays sequential after them. Verified via row-count parity (100% comparable) at smoke and full real SF1 scale.
… fetches" This reverts commit 8fb6a98.
This reverts commit f0e54cf.
Fresh full SF1 EC2 run reflecting PR #227 (edge-creation N+1 endpoint check) and the loader batch-size tuning: sqlite load time down ~1.85x, postgres down ~7x. Document that fix alongside the existing three. Also documents the concurrent-root-walk experiment for IS2/IS7: tried, measured on this same dedicated box, and reverted — neutral for SQLite (which serializes concurrent execute() calls anyway) and a mild regression for Postgres (this benchmark's Postgres runs over localhost Docker, so there's little real round-trip latency to overlap).
…can caveat Documents keySystemColumns on the public performance/indexes page, with the reverse-traversal covering-index example that motivated it. Corrects the existing "covering indexes enable index-only scans" claim with the real limitation found this cycle: PostgreSQL doesn't serve Index Only Scan for JSONB expression indexes, only real stored columns (EXPLAIN ANALYZE, BUFFERS confirmed) — with a workaround via a self-maintained generated column until TypeGraph has a built-in one. Also adds batch-sizing guidance for large multi-call bulk imports (larger per-call batches measurably reduce transaction-commit and auto-refresh-statistics overhead), and a short note that these findings came from validating TypeGraph against the LDBC SNB Interactive benchmark, without publishing comparative numbers against other engines (not yet at publishable-comparison confidence per the internal benchmark report).
Generalizes dataset/resolve.ts from hardcoded SF1-only constants (SF1_ARCHIVE/SF1_CACHE_DIR/SF1_DOWNLOAD_URL) to a per-profile spec map (SNB_DATASET_SPECS), so adding a new scale factor is a data change, not a new code path. Threads sf10 through the CLI (--profile parsing, warmup/sample defaults), the EC2 bootstrap script (dataset download step now works for any real profile), and the EC2 runner (profile validation, a new bench:snb:sf10 script, and a profile-aware benchmark-timeout default — sf10 defaults to 36h given it's ~10x SF1's row counts with no direct measurement yet of how each engine's load time actually scales at that volume). Needed to get a second scale-factor data point for a defensible public performance comparison (single-scale, single-run numbers aren't enough to claim a trend holds).
…er pin
Replaces the online batched UNWIND ... IN TRANSACTIONS Cypher load with
Neo4j's own offline neo4j-admin database import full, run as a one-off
docker run against the same named volume before the server container
starts. Neo4j's own documentation recommends this over online writes
past ~10M records into an empty database — SF1 scale is well past
that, so the previous load-time number understated what a real
deployment would show. Stages the LDBC CSVs into neo4j-admin's own
:ID/:START_ID/:END_ID header shape first, typing every property
:string explicitly so imported values stay byte-identical to what the
retired Cypher CREATE path produced. Verified: row counts match
exactly at real SF1 scale (9,892 persons, 1,003,605 posts, 2,052,169
comments), load time drops ~6.7-6.9x in same-machine A/B testing
(confirmed twice independently).
Also replaces the flat 2g/2g heap/page-cache constants with sizing
derived from neo4j-admin's own `server memory-recommendation --docker`
against the memory actually visible to the Docker daemon (not
os.totalmem() — Docker Desktop's VM can expose far less RAM than the
host), with a documented heuristic fallback. The flat 2g cap
artificially starved Neo4j on hosts with far more available memory
while the other three engines used it uncapped or unconstrained.
Both issues, plus two more below, were found by an adversarial
fairness review run ahead of publishing a benchmark comparison —
self-graded competitor code is exactly how these comparisons go wrong.
Separately, a dedicated query-plan audit (PROFILE against every
IS1-IS7 query at real scale) found: (1) IS2's friend-frontier step,
IS3, and IS7's knows-check all compiled to a full NodeIndexScan
instead of NodeUniqueIndexSeek under Neo4j's default Cypher 25 planner
for the pattern `MATCH (:Person {id: $id})-[:KNOWS]->(friend:Person)
...` — a real planner limitation for this exact shape, not a missing
index. Pinning `CYPHER 5` (the legacy planner) on those three queries
restores the seek, confirmed via PROFILE showing NodeUniqueIndexSeek.
(2) The Message(creationDate) index was never used by any IS1-IS7
query plan — pure write-time cost with no read-side payoff. Dropped.
waitUntil's check() call had no exception handling — describeInstanceState right after run-instances reliably hit AWS's own eventual-consistency window (InvalidInstanceID.NotFound for an instance id the API itself just returned), and that exception propagated immediately instead of being retried like a normal "not ready yet" poll result. Reproduced twice in a row this session, each orphaning a running-but-unconfigured EC2 instance that had to be found and terminated manually. A thrown error from check() is now treated as "not ready yet" and retried until the deadline, with the last error surfaced in the timeout message so a genuine, persistent failure (bad credentials, wrong region) is still diagnosable rather than silently retrying to a generic timeout.
Uses the new walAutocheckpointPages pragma (previous commit) to close the SF10 load-time gap root-caused in this session: SQLite's ~9-hour SF10 load, ~4.85x slower than Postgres running the exact same bulkInsert path in-process, traced to SQLite's untuned ~4MiB default checkpoint threshold getting more expensive as the database file grows over a large load. 100,000 pages is the empirically-validated value (a local repro swept 1K-1M pages; throughput plateaus, then regresses again past ~100K). Also runs an explicit wal_checkpoint(TRUNCATE) right after the load finishes, before any query is measured: without it, whatever's still sitting in the WAL when the load's row count doesn't land exactly on a checkpoint boundary would make every subsequent query pay a WAL-scan cost the other three engines don't — a fairness gap the raised checkpoint threshold would otherwise introduce. Verified via `bench:snb:smoke --engines=typegraph-sqlite --check` (real end-to-end load + IS1-7 query run). Full-dataset load-time improvement not yet re-measured against real SF10/EC2 — pending a decision on whether that expensive re-run is worth it now versus batched with other benchmark changes.
Fixes a knip test:unused failure. Nothing outside this file imports it; the EC2 bootstrap script deliberately re-derives the same path itself (via the still-exported cacheRelativeSegments) rather than calling this, since it needs "/root" on the remote box, not this process's own homedir().
The fairness label on every SQL engine driver claims "indexes
materialized... after bulk load, matching the documented production
path" — false for the one SNB-specific index (the IS2 covering index
fixed earlier this branch). It was passed to createSqliteTables/
createPostgresTables's own indexes option, which bakes it straight
into the initial CREATE TABLE/migration DDL: it existed from row 1,
and every Post/Comment/Person/Forum insert paid its maintenance cost
live for the entire load, not just after.
defineGraph() already has an indexes option, read by
store.materializeIndexes() (already called by both engine drivers
after loadSnbDataset() finishes) — it was simply never wired up here.
Moved snbIndexes from createSqliteTables/createPostgresTables to
defineGraph({ indexes }), so materializeIndexes() now does real work
instead of operating on an empty declaration list.
Verified directly: the index is absent from sqlite_master through
createStoreWithSchema's bootstrap DDL and only appears after an
explicit materializeIndexes() call. Also verified end-to-end via
bench:snb:smoke --engines=typegraph-sqlite --check.
This only affects the one benchmark-specific covering index — the
base indexes createSqliteTables/createPostgresTables always create
(kind/temporal/deleted-at indexes, edge traversal indexes) are core
TypeGraph library behavior, not something this benchmark controls,
and are out of scope here.
An unprovisioned gp3 volume silently gets the account's baseline (3,000 IOPS / 125 MB/s) regardless of size, since gp3 (unlike gp2) decouples IOPS/throughput from volume size entirely. A root-cause investigation confirmed this baseline genuinely bottlenecks SQLite's bulk-load checkpoint I/O once the database reaches a few GB: checkpoint flushes revert from large sequential writes to small ~4KB random writes that pin against the IOPS ceiling regardless of wal_autocheckpoint tuning — exactly the condition SF10's real load hits, which is why the wal_autocheckpoint fix (443c8d2) showed no real-world improvement on an actual SF10 EC2 run despite being locally validated. Validated on real EC2/EBS infrastructure via a cheap diagnostic instance before spending another multi-hour SF10 run: explicitly provisioning 10,000 IOPS / 400 MB/s clears the ceiling (iostat showed sustained 3,000-11,000+ write IOPS instead of a hard pin at exactly 3,000) and confirmed the checkpoint tuning is genuinely complementary once unblocked (23% faster than SQLite's default checkpoint interval on the same large, provisioned-IOPS volume). Combined with the already-merged checkpoint and deferred-index fixes, an SF10 run's SQLite load time drops from 8.96h to 3.10h (2.89x).
…artifacts collect() previously embedded results.json, summary.json, the new history.jsonl lines, and a console-log tail all in the same long-running SSM command's own StandardOutputContent, which AWS hard- caps at 24,000 characters. That worked until all four engines succeeded on the same run for the first time (SF10 attempt 8): the combined size finally exceeded the cap, silently truncating mid-JSON and dropping the last engine's history entry — results.json and summary.json survived only because they're written earlier in the script's output. The backgrounded benchmark command now reports only its exit code inline (negligible size) and writes a small sentinel file recording history.jsonl's line count at start, so the correct tail offset survives past the run's end. collect() fetches results.json, summary.json, and the new history lines via three separate, later SSM commands instead — each gets its own full 24,000-character budget, so no single artifact's growth can crowd out another's. The verbose console-log tail is now fetched only on a non-zero exit code (the one case it's actually useful for), at 200 lines instead of 60, since it's no longer competing for space with data collect() actually parses.
typegraph-sqlite/postgres/neo4j entries from the successful attempt 8 run (combined checkpoint tuning + deferred index + IOPS provisioning + correct instance type). ladybugdb's entry was lost to the SSM truncation bug fixed in 5ec4f0d — not reconstructed here to avoid injecting imprecise, hand-rounded numbers into a precision-sensitive trend log.
The results doc still described attempt 5's unoptimized 8.96h SQLite load time as the standout finding, and its own root-cause section said the wal_autocheckpoint fix was "investigated, not yet implemented" — both wrong as of this session, and internally inconsistent with the doc's own Next steps checklist, which already marked the fix as implemented. Documents the full eight-attempt arc: attempts 1-5 (memory exhaustion disguised as networking, already documented), attempt 6 (a second bug, the covering index wasn't genuinely deferred), attempt 7 (the checkpoint + index fixes combined showed zero real-world improvement), and the round-two root cause (an unprovisioned gp3 volume's default IOPS ceiling), validated cheaply on a diagnostic instance before attempt 8 confirmed the fix: SQLite load time 8.96h -> 3.10h (2.89x), total wall clock 11h10m -> ~5h22m. Also updates the SF10 query-latency table with attempt 8's real numbers and adds an explanation of why IS2 is ~100-1000x slower than every other query on every engine (an intentional, un-batched, ~21-round-trip design, not an engine difference).
…arding the result store.materializeIndexes() is deliberately best-effort — it reports failures as a per-index status rather than throwing — so both the SQLite and Postgres drivers awaiting the call and discarding its result would silently proceed even if the SNB covering index this benchmark's fairness label depends on was never created. assertMessageIndexMaterialized() (schema/snb-graph.ts) checks the result for the specific index and throws if it wasn't created or already materialized; both drivers now call it immediately after materializeIndexes().
A review checked this benchmark's queries against the official LDBC
reference implementation (ldbc/ldbc_snb_interactive_v1_impls) and
found every engine driver diverged from spec in ways row-count-only
parity could never catch:
- IS2 measured the wrong workload in all three engines: traversing to
the given person's *friends* and measuring messages *they* authored,
instead of official IS2's own definition ("recent messages of a
person" — the given person's own messages). Every engine shared the
identical mistake.
- IS2/IS3/IS6/IS7 silently omitted official output fields on one or
more engines: message content (TypeGraph's IS2, TypeGraph's and
LadybugDB's IS7), author/moderator names (Neo4j's and LadybugDB's
IS2, LadybugDB's IS7), forum id/title (Neo4j's and LadybugDB's IS6).
IS7's replyAuthorKnowsOriginalMessageAuthor boolean was computed as
a bulk existence check and never actually attached per-row.
TypeGraph's IS3 had no defined ordering at all.
- Id tie-breaks (IS2/IS3/IS7) were lexicographic on this benchmark's
kind-prefixed ids ("message:10" sorting before "message:2") instead
of the numeric ordering official ids (plain BIGINTs) require.
Every query across all three engines now returns exactly the official
LDBC output fields. A shared compareIdsAscending() helper
(engines/types.ts) makes every id tie-break numeric-aware via
Intl-collation's `numeric: true` option, applied as a final JS-side
resort immediately before building each query's result — regardless
of what a query's own native ORDER BY/SQL did on a tie — so the row
order captured for parity comparison can't drift between engines.
IS2's per-kind candidate fetch is over-fetched (limit 20, not 10) as a
defensive buffer against that same tie-break imprecision excluding a
genuine top-10 candidate before the final correct sort trims to 10.
canonicalDigest() (engines/types.ts) is the other half of this fix —
see the following commit for the parity-gate upgrade that consumes it.
Neo4j's fairness label is also corrected to describe the current
schema (no Post/Comment constraints, matching the earlier commit that
removed them) instead of stale metadata.
…gest comparison Row-count agreement is exactly how the previous commit's IS2 workload bug and field-omission bugs went undetected for as long as they did — every engine shared the identical wrong IS2 semantics and still agreed on row count, and a missing field doesn't change how many rows a query returns. evaluateParity() (harness/parity.ts) now compares each sampled request's canonicalDigest() output across engines, in addition to the existing row-count check — EngineRowCounts renamed to EngineQueryOutcomes to carry both. measureQuery() (snb-short-reads.ts) collects digests alongside row counts from each SnbQueryResult. Verified on the smoke fixture: this immediately caught one more real bug before landing — TypeGraph's IS3 digest used field name `personId`, Neo4j's and LadybugDB's used `id` (identical underlying values, incomparable digests) — fixed as part of the previous commit. All 7 queries now pass true value-level parity across all four engines, not just row-count parity.
…ing instance type
Two independent EC2-runner correctness fixes:
- collect()'s failure predicate only checked SSM status and exit code,
so a successful SSM command whose results.json somehow came back
empty (resultsText === "{}") still reported success. Added
!hasParseableResults to the failure check. Also fixed: canonical
reports/history.jsonl was appended before validating the run
succeeded, so a failed run's partial per-engine rows could get mixed
into the trend log looking like an ordinary result — raw history
lines are now always preserved locally (run-scoped, not shared) for
post-mortem, and the canonical file is only appended after every
success condition passes.
- --profile=sf10 always defaulted to c7i.4xlarge regardless of
profile — the same 32GB instance type that OOM'd on four separate
SF10 attempts before r7i.4xlarge (128GB) was found to be required.
DEFAULT_INSTANCE_TYPE_BY_PROFILE now defaults sf10 to r7i.4xlarge;
--instance-type still overrides it explicitly.
Two rounds of review found real correctness bugs in every IS1-IS7 query implementation (see the preceding commits) — none of the published SF1/SF10 query-latency numbers in this doc reflect the correct queries anymore, and several also predate the value-level parity gate that would have caught them sooner. - Top-of-file notice broadened from "IS2 is invalidated" to "every query-latency number is invalidated" — IS3/IS6/IS7 also had field-omission bugs on one or more engines, not just IS2. - Neo4j's load time is now also flagged invalidated: the Post/Comment constraint removal changes work done inside its timed load(), not just its query-time fairness — the doc previously claimed load times were unaffected, which was true for SQLite/Postgres/LadybugDB but not Neo4j. - The SF1 and SF10 query-latency tables (and the paragraphs of analysis that used to follow them) are deleted rather than kept as "mostly still right," since Neo4j's IS6 and TypeGraph's/LadybugDB's IS7 now do measurably more real work than before. - Smoke-scale section notes the fixture was re-verified end-to-end: all 7 queries now pass value-level digest parity, not just row-count parity. - Next steps checklist records every round-2 finding and fix. - ec2-benchmark-runner.md's --instance-type default doc updated for the profile-aware default; a nonexistent bench:snb:sf10:ec2 script reference and a stale "no direct measurement yet" line corrected (the latter already partly fixed in code comments; this closes the matching doc gap).
…ss likely to fail A third review found the previous round's fix — over-fetch 20 candidates per kind via a native LIMIT, then numerically re-sort in JS — was still not provably correct. The native LIMIT applies before this file's numeric-aware final sort, ordered by the native query layer's own plain lexicographic id tie-break. A same-creationDate tie cluster larger than the buffer (e.g. 30 messages sharing one timestamp, all with a fixed 20-row buffer) can rank a genuinely-top-10 message (by numeric id) past whatever cutoff the lexicographic order chose ahead of it — verified concretely: ids message:1..30 all tied on creationDate, LIMIT 20 (lex order) fetches 1,10-19,2,20-27, and even the correct numeric resort of *that* candidate set produces 1,2,10-17 instead of the true 1-10. No fixed buffer size is provably safe against this, only fetching every candidate and limiting after the correct sort is. Removed the native LIMIT (and the now-unused IS2_CANDIDATE_LIMIT constant) from all three engines' per-kind IS2 fetch; the existing numeric-aware final sort + slice(0, 10) now operates over the complete candidate set. A person's own authored-message count is bounded by realistic LDBC activity levels, so this stays a cheap point-adjacent fetch rather than a table scan.
…ul EC2 collect()
collect()'s success predicate only checked results.json; a run whose
summary.json came back as the "not found" sentinel ({}) still had it
written (as an empty, invalid summary) without being flagged, and
missing history.jsonl lines were silently accepted either way. A
"successful" run could therefore lose the reproducibility metadata
(engine versions, hardware, git sha) the results doc cites, or its
canonical trend-log entry, while still reporting success and
terminating the instance.
hasParseableSummary/hasHistoryLines now participate in the failure
predicate alongside hasParseableResults — a genuinely complete run
writes all three unconditionally (summary.json always, at least one
history line per engine that completed), so missing any one is a
real failure, not an acceptable partial result.
…ormance claim
A third review found several places still described the parity gate
as row-count-only after it was upgraded to value-level digest
comparison, plus two unrelated stale claims:
- Runtime console output ("=== Row-count parity ==="), CLI option
doc, and code comments (cli.ts, request-plan.ts, snb-short-reads.ts,
run-sf1-ec2.ts) updated to describe both signals.
- README's parity section, "what every run writes" description, and
fairness-harness summary updated the same way.
- README's SF1 load-time numbers (75-80 min TypeGraph loads) predated
the N+1 endpoint-lookup fix (PR #227) that cut them to ~40/~12
minutes — updated to the current numbers, with a note that the
results doc is the canonical source if they drift again.
- Results doc and README both described Neo4j's current loader as the
retired batched `UNWIND ... IN TRANSACTIONS` approach; it's been the
offline `neo4j-admin database import` for a while — corrected in
both places.
- README now states plainly that this lane adapts LDBC SNB rather than
claiming full schema conformance: the schema flattens two
official-schema edges (City via IS_LOCATED_IN, Forum's moderator via
HAS_MODERATOR) into plain properties, identically across all four
engines. Query output fields and ordering are verified against the
official reference implementation; the schema-level simplification
is a separate, deliberate, and already-documented choice
(schema/snb-graph.ts's module doc) that the README should name
rather than let "official" imply exact conformance.
# Conflicts: # packages/typegraph/src/backend/sqlite/local.ts # packages/typegraph/tests/backends/sqlite/local-pragma-defaults.test.ts
Caught by CI's Lint & Type Check job on the merge commit, not by local verification beforehand.
… padded ids Removing IS2's native LIMIT entirely (the previous fix for the lexicographic-tie-break bug) was correctness-proof but wrong in a new way: every engine now fetched every message a person ever authored, transferring full content over the network for engines that pay a real round trip (Postgres, Neo4j), and diverging from the official query's own `ORDER BY ... LIMIT 10` applied before the root-post-author walk. Root-caused properly instead: dataset/ldbc-csv.ts zero-pads every id's numeric portion to a fixed width, so a plain lexicographic `ORDER BY id ASC` (SQL or Cypher alike) already agrees with numeric order, for any tie-cluster size. This lets native ORDER BY/LIMIT 10 be restored everywhere — TypeGraph/LadybugDB fetch each of Post/Comment's own top 10 and merge (provably equal to the true top 10 of the union), Neo4j's unified :Message label needs only one query.
…ss-engine consensus Cross-engine agreement is consensus, not proof — this lane has already shipped two bugs (the friend-workload bug, the lexicographic-tie-break bug) that every engine reproduced identically, which parity alone could never catch. The smoke fixture now includes a dedicated person who authors 25 same-creationDate comments (dataset/smoke-fixture-constants.ts, additive — doesn't perturb any existing row's random generation). With an identical creationDate, ascending message id is the only remaining tie-break, so that person's correct IS2 answer (the cluster's 10 smallest message ids) is known in advance. The new bench:snb:verify-is2-tie-break script checks each doctor-runnable engine's actual result against that known answer directly, not against the other engines. All 4 engines pass.
… collect() A doctor-runnable-engine filter is correct for a local/CI run (a no-Docker environment should still exit 0 on the two embedded engines) but wrong for a paid, multi-hour EC2 run: a container failing to start on the instance would get silently filtered out rather than recorded as a failure, and nothing checked how many engines actually produced results — an incomplete 1-3 engine run could satisfy every existing check and terminate the instance as a reported success. collect() now parses results.json's engines list and requires all four canonical names (harness/doctor.ts's SNB_ENGINE_NAMES) to be present, and additionally fetches + preserves competitor-doctor.json locally (success or failure) so an incomplete run is diagnosable without re-connecting to the instance.
…ngine-completeness gate README's Lane 1 section, the EC2 runner doc, and the results doc all described the round-3 "fetch everything, sort in JS" IS2 fix as final — update all three to describe the actual fix (fixed-width padded ids + restored native ORDER BY/LIMIT), the new adversarial oracle test, and collect()'s new complete-engine-set requirement. Smoke fixture row counts (31 persons, 105 comments) updated to match the new tie-cluster fixture data.
…ring bug
The tie-cluster fixture used one contiguous id range (120..144) — all
3-digit numbers, so unpadded lexicographic order and numeric order coincide
by construction (same-length numeral strings always compare identically
both ways). The oracle would pass whether or not dataset/ldbc-csv.ts's
zero-padding fix was actually applied, catching nothing.
Replaced with two disjoint blocks of different digit widths: 120..129
(3-digit) and 1000..1014 (4-digit). Unpadded lexicographic order ranks
every 4-digit id ahead of every 3-digit one ("1000" < "120"
character-by-character), so an unpadded engine now incorrectly returns
1000..1009 instead of the true answer, 120..129 — verified directly by
temporarily reverting the padding fix and confirming all 4 engines fail
with exactly that wrong answer, then restoring it and confirming they pass
again.
Round 5's finding — the oracle's original contiguous-id-range fixture didn't actually reproduce the ordering bug it claimed to guard against — and its verified fix (cross-digit-width blocks, confirmed by temporarily reverting the padding fix and observing all 4 engines fail as predicted).
Real EC2 runs on commit 2bc7f74 (all five review rounds' fixes applied): 0 engine failures, 7/7 queries at full value-level digest parity, both scales. Replaces every invalidated number this doc previously flagged with real query-latency data, and reconfirms SF10's load-time fixes independently (a second data point closely matching attempt 8's, plus a now-possible fair SF1-to-SF10 growth-rate comparison under identical fixes). history.jsonl gets the 8 new entries from both runs.
…ovenance overclaim, Neo4j fairness gap - README's SF1 load-time example numbers (SQLite ~40min, Neo4j ~5min) were already stale against the fresh run (9.8min, 1.2min) the moment they were written down twice. Removed the duplicated volatile numbers entirely; the doc now only points at snb-lane1-results.md, the single source that actually gets updated each run. - snb-lane1-results.md (and the PR body) claimed both SF10 runs were "on the fully-fixed commit" — only the fresh run (2bc7f74) was; attempt 8 was commit a58ae38, load-time fixes only, predating the five rounds of query-correctness review. Also replaced "normal run-to-run variance" with the actual 2.1-10.7% per-engine spread and reframed as "a consistent second data point," not proof of reproducibility from two samples. - neo4j.ts's machine-readable fairness string said the offline neo4j-admin import path is used "for both smoke and SF1" — it's used for every profile including SF10, confirmed by this session's own SF10 run's console log.
"Ordinary EC2/EBS run-to-run noise" asserted a causal characterization two cross-commit observations can't support — the two SF10 runs are on different commits, so a small, real, commit-attributable effect can't be ruled out from two samples. "2.1-10.7% spread in both directions" also conflated a signed range with magnitude (the actual range is -2.1% to +10.7%, not two-sided 2.1-10.7%). Replaced both instances (top status paragraph, SF10 section intro, the load-time callout, and the Next steps checklist entry) with the signed range and "a consistent second data point, not proof of run-to-run variance or causality."
Missed sibling of the SF10 fix: "consistent with ordinary run-to-run variance, not a regression or improvement" for Postgres/LadybugDB's SF1 load times made the same unsupported causal claim from a single cross-commit comparison — and the actual magnitudes (-3.5% Postgres, -22.7% LadybugDB) aren't obviously "ordinary" anyway. States the exact percentages and notes cross-commit observations can't establish causality, matching the SF10 section's fix. PR body's parallel sentence updated the same way.
First lane of the real-workload benchmark program: the LDBC SNB Interactive short-read queries (IS1-IS7), run against four engines under a shared fairness harness, at both SF1 and SF10 scale.
packages/benchmarks/src/real/: schema (schema/snb-graph.ts), dataset streaming reader (dataset/ldbc-csv.ts) + a tiny committed smoke fixture, four engine drivers (engines/typegraph-sqlite.ts,typegraph-postgres.ts,neo4j.ts,ladybug.ts), and a lane-agnostic harness (harness/: stats with CV-based noisy-sample flagging, value-level parity gate, competitor doctor,summary.jsonwriter,history.jsonlappender).engines/typegraph-queries.ts's module doc explains the.prepare()/.execute()fairness rationale and theMessageontological-supertype workaround for the polymorphicreplyOfedge).REL TABLEs) instead of TypeGraph's ontology workaround.packages/benchmarks/src/real/ec2/: an EC2 runner (docs/ec2-benchmark-runner.md) so SF1/SF10 can run on a dedicated, ephemeral cloud instance instead of local hardware. SSM Run Command is the default, no-key-pair control channel; generic enough for anyone who clones the repo to point at their own AWS account. An opt-in--ssh-public-key-pathdiagnostic fallback exists for when SSM itself is what's broken. Hardened over five rounds of review — see below.bench:snb:smoke:checkruns the tiny fixture end-to-end; a competitor-doctor preflight reports missing Docker/packages as explicit failed rows (never silent), and the run stays green with 0 or 1 runnable engines — it only fails on a genuine parity mismatch (row count or value digest) between 2+ engines that actually ran..gitignore's unscopedreports/rule (meant for Stryker mutation-testing output) had been silently excludingpackages/benchmarks/reports/history.jsonlfrom every commit since it was introduced — scoped topackages/typegraph/reports/so both the existing synthetic perf suite's history and this lane's history are actually committed going forward.One
packages/typegraphchange:walAutocheckpointPages, a newcreateLocalSqliteBackendpragma option (see "SF10" below for why). This branch andmainindependently arrived at the same fix;main's version (merged separately as #251, with an upper-bound validation this branch's original commit didn't have) is now what this branch carries after reconciling withmain— see "mainreconciliation" below. Everything else is benchmarks-only; three other library fixes were needed along the way but each landed separately in its own PR (#226, #227).Review history: 5 rounds, all fixed and re-verified with a fresh EC2 run
Five rounds of review found issues serious enough that no query-latency number from before this PR's fixes could be trusted. All findings below are fixed; a fresh SF1 + SF10 EC2 run on the fully-fixed commit (
2bc7f74f) has since replaced every invalidated number — see Results below.Round 1 (6 findings)
ldbc/ldbc_snb_interactive_v1_impls): IS2 is "recent messages of a person" — the given person's own messages, tie-brokenmessageId ASC. This PR's drivers traversed to that person's friends and measured messages they authored, tie-brokenDESC— a materially different, heavier workload. Every engine shared the identical mistake, which is exactly why row-count parity never caught it. Fixed intypegraph-queries.ts,neo4j.ts, andladybug.ts.materializeIndexes()'s best-effort result was silently discarded in both the SQLite and Postgres drivers. Fixed:assertMessageIndexMaterialized()(schema/snb-graph.ts) checks the result and throws if the index wasn't created or already materialized.collect()(run-sf1-ec2.ts) extracted the benchmark's exit code but never checked it. Fixed: throws (after writing whatever partial artifacts exist) on non-SuccessSSM status, a missing exit-code marker, or a nonzero exit code.--profile=sf10always defaulted toc7i.4xlarge, the same 32GB instance type that OOM'd on four separate SF10 attempts. Fixed: profile-aware default (r7i.4xlargeforsf10);--instance-typestill overrides explicitly.snb_post_id/snb_comment_idconstraints had no current query-time consumer (every by-id match goes through the shared:Messagelabel). Fixed: removed.Round 2 (5 more findings — the smoke run in round 1 proved execution and row-count parity, not LDBC semantic parity)
localeCompare("message:10"sorting before"message:2") despite official ids being numeric. Fixed: content + names added to all three engines' IS2; a sharedcompareIdsAscending()helper (engines/types.ts) makes every id tie-break numeric-aware.SnbQueryResult.digest,canonicalDigest()inengines/types.ts,harness/parity.ts) — engines can no longer agree on count while disagreeing on field values, omitted fields, or order. Verified on the smoke fixture: this immediately caught one real remaining bug (TypeGraph's IS3 digest used the field namepersonId, Neo4j/LadybugDB usedid— same values, incomparable digests) before landing at all 7 queries passing true value-level parity across all four engines.resultsText === "{}"didn't feed the failure predicate). Fixed:!hasParseableResultsadded tocollect()'s failure check.reports/history.jsonlwas appended before validating the run succeeded. Fixed: raw history lines are always preserved locally (run-scoped, not shared) for post-mortem; the canonical file is only appended after every success condition passes. A related, smaller finding — four smoke-test history rows this session accidentally generated recorded a git SHA that didn't contain the uncommitted fixes producing them — was caught and reverted, not committed.load(). Both fixed.Round 3 (2 more P1s — round 2's own IS2 fix wasn't actually correctness-proof — plus 3 P2s)
IS2_CANDIDATE_LIMITbuffer (a fixed-size nativeLIMITre-sorted numerically in JS) wasn't provably correct. Confirmed with a concrete counter-example: a same-creationDatetie cluster larger than the buffer (e.g. 30 messages sharing one timestamp, ids 1-30) causes the native lexicographicLIMITto exclude genuinely-top-10 candidates (numeric ids 3-9) before the JS resort ever sees them — even a correct numeric resort of a wrong candidate set produces a wrong answer. Fixed by removing the nativeLIMITentirely across all three engines: IS2 now fetches all of a person's own messages (unlimited), sorts numerically, and slices to 10 purely in JS — correct regardless of tie-cluster size.collect()'s success predicate still didn't require a parseablesummary.jsonor non-emptyhistory.jsonllines — onlyresults.jsonwas checked. Fixed:hasParseableSummary/hasHistoryLinesadded alongside the existing checks; a run is only "successful" when all three reproducibility artifacts are present.cli.ts,request-plan.ts,snb-short-reads.ts's console output, the README, and the results doc — corrected to describe both row-count and value-digest parity, matching round 2's actual gate upgrade.neo4j-admin database import— corrected in both places. Also softened the lane's "official LDBC" framing: the README now explicitly names the two relationships this schema deliberately flattens into properties (Person.cityId,Forum.moderatorId) instead of modeling as edges, and calls the lane "LDBC SNB-derived" rather than a full schema-conformance claim — the query fields and ordering are still verified against the official reference; the schema-shape simplification is a separate, honestly-disclosed thing.Round 4 (2 more P1s — round 3's own IS2 fix traded correctness for a new fairness problem)
LIMITentirely) was correctness-proof but changed the measured workload. The official query applies its ownORDER BY messageCreationDate DESC, messageId ASC LIMIT 10engine-side, before the root-post-author walk. Fetching every message a person ever authored instead — every engine, unconditionally — transferred full message content over the network for engines that pay a real round trip (Postgres, Neo4j), disproportionately penalizing them versus the embedded engines, and diverged from official semantics. The repo's own index documentation notes some IS2 candidate pools run to thousands of rows, so this wasn't a theoretical concern. Root-caused properly instead of patched again:dataset/ldbc-csv.tsnow zero-pads every id's numeric portion to a fixed width, so a plain lexicographicORDER BY id ASC(SQL or Cypher alike) already agrees with numeric order, for any tie-cluster size — this is what actually broke in round 2/3, not the presence of aLIMITper se. With that fixed, nativeORDER BY ... LIMIT 10is restored everywhere: TypeGraph/LadybugDB fetch each of Post/Comment's own top 10 and merge (any message in the true top 10 of the union must be in its own type's top 10 too, so this is provably equal to the true top 10, for any candidate-pool or tie-cluster size, transferring at most 20 rows regardless of how many messages the person actually authored); Neo4j's unified:Messagelabel needs only one query.bench:snb:verify-is2-tie-break, an adversarial correctness oracle independent of cross-engine consensus. Every engine agreeing has already gone wrong twice in this lane (the friend-workload bug, the lexicographic-tie-break bug) — consensus alone doesn't prove correctness. The smoke fixture now includes a dedicated person who authors 25 same-creationDate comments; with no other tie-breaking signal, that person's correct IS2 answer (the cluster's 10 smallest message ids) is knowable in advance, and the new script checks each doctor-runnable engine's actual result against that known answer directly. All 4 engines pass.collect()now parsesresults.json.enginesand requires all four canonical engine names to be present before declaring success, and additionally fetches + preservescompetitor-doctor.jsonlocally (success or failure) so an incomplete run is diagnosable without re-connecting to the instance.Round 5 (1 more P1 — round 4's own new oracle test didn't actually test anything)
dataset/ldbc-csv.ts's zero-padding fix was actually applied, catching nothing; it was consensus theater dressed up as an oracle. Fixed: split into two blocks of different digit widths (3-digit120..129, 4-digit1000..1014) — unpadded lexicographic order now ranks every 4-digit id ahead of every 3-digit one ("1000" < "120"character-by-character), so an unpadded engine returns the wrong answer (1000..1009) instead of the true one (120..129). Verified directly, not just reasoned about: temporarily reverted the padding fix, confirmed all 4 engines fail with exactly that predicted wrong answer, then restored it and confirmed all 4 pass again.Results
Full writeup:
packages/benchmarks/reports/snb-lane1-results.md. Both scales below are fresh runs on commit2bc7f74f(every fix from all 5 rounds in place): 0 engine failures, 7/7 queries at full value-level digest parity (not row-count alone) at both scales. Single run each — not yet a publishable comparison per the program plan's multi-run bar (tracked in the results doc's Next steps).SF1 (9,892 persons, 1,003,605 posts, 2,052,169 comments) — 7/7
comparable=yes, 0 failuresSQLite and Neo4j both load dramatically faster than the last SF1 run reported in this PR (SQLite ~4.1x, Neo4j ~4.3x) — not a new optimization, but the SF10 investigation's covering-index-deferral and constraint-removal fixes finally showing up in an SF1 number for the first time (that run predates both). Postgres and LadybugDB, whose load paths weren't touched by either fix, measured 3.5% and 22.7% faster than the previous run, respectively — cross-commit observations that can't establish run-to-run variance or causality on their own. See the results doc for the full query-latency table (IS2 still dominates every engine's latency, as structurally expected — a top-10 selection plus a per-message root-post-author walk).
SF10 (65,645 persons, 7,435,696 posts, 21,865,475 comments) — 7/7
comparable=yes, 0 failuresGetting one clean, fast SF10 run originally took eight EC2 attempts across two root-causing efforts — a memory-exhaustion failure disguised as a networking problem (attempts 1-5), then an EBS gp3 volume's default IOPS ceiling that made a correctly-implemented, locally-validated SQLite checkpoint-tuning fix show zero real-world improvement (attempts 6-8). Full narrative in the results doc. This run is on commit
2bc7f74f(all 5 review rounds); attempt 8 above was commita58ae38e(load-time fixes only, predating those rounds) — a consistent second load-time data point, not the same commit:The second run differed by -2.1% to +10.7% across engines. These provide a consistent second load-time point, but two cross-commit observations do not establish run-to-run variance or causality — the two runs are on different commits, so a small, real, commit-attributable effect can't be ruled out from two samples alone. With a fresh SF1 run now available under the identical (
2bc7f74f) fixes, the results doc also derives a fair SF1-to-SF10 growth-rate comparison for the first time: Postgres grows almost exactly linearly with the ~10.65x comment-count increase; SQLite grows noticeably super-linearly (~18.6x), consistent with the documentedwal_autocheckpointtuning's own caveat that it "holds through roughly 50,000-100,000 pages, then regresses again" — worth a deeper look if pursued further, not investigated in this PR.