bench(plugin): a reproducible harness for the render queue's storage costs - #119
Conversation
…costs Every scheduling decision in this package is justified by a number, and the two numbers the queue design rests on cannot be reproduced: #80 cites `20-lanesim.mjs` and `21-duerank.mjs`, neither of which is in this repository. Worse, the two figures that ARE in the tree disagree by 80x, and both describe a one-sided ascending range read over the same index on the same table: util/renderSchedule.js 20 keys in 0.43 ms ~21 us/row util/backlogSnapshot.js "~3.5s per 2,000 rows" ~1.75 ms/row Which one is right decides the architecture. At 21 us/row you can stream the whole due set through a bounded heap once a minute and order it exactly — the "buffer everything due now" design works. At 1.75 ms/row a 500k-row due set takes 15 minutes to walk and priority has to live in the index itself. The likely reconciliation is that backlogSnapshot yields every 200 rows beside bot traffic and seeks the absolute index minimum, while the claim scan seeks a floor and never yields — so this measures yielding as an explicit variable rather than assuming it away. Seven questions, each with a stated consequence for the design (see README): the claim-shaped read at four limits; yielding vs not; K per-lane seeks vs one large seek; single-attribute update vs whole-record put; one indexed attribute vs two; two-sided vs one-sided range with a limit that can and cannot fill; and whether the seek point degrades under churn while a floored seek stays immune. Q4 and Q5 decide the change worth making on correctness grounds regardless of the numbers: give the queue its own indexed column and leave the freshness deadline a plain unindexed timestamp, so nothing outside the funnel ever has to decode a due time. NEITHER RUN MODE HAS BEEN EXECUTED END TO END, and the README and run.sh both say so. Docker was unavailable where this was written; local mode reached Harper startup and failed in `checkForExistingInstall` against a freshly installed root — a Harper bootstrap problem rather than a problem with the measurements. The first run should be treated as calibration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sure Both were wrong in the first run, and one of them looked like a refutation. Q6 (two-sided range) used a window ABOVE every seeded row, so there were no rows above the lower bound at all — and the failure mode being tested is precisely "O(rows above the lower bound)". It measured an empty seek and reported it as cheap. The window now sits LOW and NARROW with a limit larger than the number of matches, so the post-filter has to walk. Q7 (seek degradation) patched keys in key order. Production repeatedly CLAIMS THE HEAD and writes those rows forward, so dead index entries pile up at the seek point rather than spreading over a key range — which is the whole effect. It now reads the head, moves what it read, repeats, and samples both seeks as it goes so a trend is visible instead of a before/after pair one cold page can dominate. The floored seek was also pinned at `now - 60min`, inside the region the churn had just rewritten, which is why it measured SLOWER than the unfloored seek; the floor now follows the first row observed, as production's does. Also records the three things that made the harness silently succeed while running nothing: a read-only mount fails Harper's component loader with EROFS on the node_modules symlink; `handleApplication` is never invoked on a root component (it is the hook for a component used AS an extension, so it needs `jsResource`); and Harper intercepts `process.exit`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive benchmark harness under bench/queue-index to measure and analyze the storage costs of the render queue in HarperDB, comparing different schema designs and access patterns. The feedback identifies critical issues where process.exit(1) is used, which would cause the process to hang due to HarperDB intercepting it, and suggests using SIGTERM instead. Additionally, it addresses a permission issue with mktemp -d in Docker mode, recommending chmod 777 to allow the container user to access the staged directory.
| if (!(await tablesReady())) { | ||
| console.error('[bench] databases.bench never appeared — the schema did not load'); | ||
| process.exit(1); | ||
| } |
There was a problem hiding this comment.
Since HarperDB intercepts process.exit (as documented in the README and at the end of main), calling process.exit(1) here will cause the process to silently hang instead of terminating. Consider signaling the process with SIGTERM to ensure it exits reliably on failure.
| if (!(await tablesReady())) { | |
| console.error('[bench] databases.bench never appeared — the schema did not load'); | |
| process.exit(1); | |
| } | |
| if (!(await tablesReady())) { | |
| console.error('[bench] databases.bench never appeared — the schema did not load'); | |
| process.kill(process.pid, 'SIGTERM'); | |
| } |
| main().catch((e) => { | ||
| console.error('[bench] failed', e); | ||
| process.exit(1); | ||
| }); |
There was a problem hiding this comment.
Similar to the schema loading failure, calling process.exit(1) here will cause the process to silently hang because HarperDB intercepts process.exit. Use process.kill(process.pid, 'SIGTERM') to ensure the process terminates on unhandled exceptions.
| main().catch((e) => { | |
| console.error('[bench] failed', e); | |
| process.exit(1); | |
| }); | |
| main().catch((e) => { | |
| console.error('[bench] failed', e); | |
| process.kill(process.pid, 'SIGTERM'); | |
| }); |
| STAGE="$(mktemp -d)" | ||
| cp "$HERE"/config.yaml "$HERE"/schema.graphql "$HERE"/bench.js "$STAGE/" | ||
| trap 'rm -rf "$STAGE"' EXIT |
There was a problem hiding this comment.
On Linux, mktemp -d creates a directory with 0700 permissions (readable/writable only by the host user). When running in Docker mode, the container typically runs as a non-root user (e.g., harperdb), which will not have permissions to read the staged files or write node_modules inside the mounted directory. This leads to silent failures where the component fails to load. Explicitly setting the directory permissions to 777 ensures the container user can access and write to it.
| STAGE="$(mktemp -d)" | |
| cp "$HERE"/config.yaml "$HERE"/schema.graphql "$HERE"/bench.js "$STAGE/" | |
| trap 'rm -rf "$STAGE"' EXIT | |
| STAGE="$(mktemp -d)" | |
| chmod 777 "$STAGE" | |
| cp "$HERE"/config.yaml "$HERE"/schema.graphql "$HERE"/bench.js "$STAGE/" | |
| trap 'rm -rf "$STAGE"' EXIT |
Corrected Q6 / Q7 — both original claims confirmed, and my first run was wrongBoth of the first run's scale-dependent tests were badly constructed, and both produced a false negative. Fixed and re-run at 200,000 rows / 40,000 reschedules. Q7 — the seek degradation is real, and reproduces almost exactlyHead-claim-then-reschedule (the production shape), sampling both seeks as it goes:
Monotonic, linear, and the floored seek is flat throughout. My first run reported no degradation because it patched keys in key order rather than repeatedly churning the head — so dead entries spread over a key range instead of piling at the seek point, which is the entire mechanism. It also pinned the "floored" seek inside the region the churn had just rewritten, which is why that one came out slower than the unfloored seek. Both were artifacts of the test, not findings. One nuance worth keeping: the 5.6 ms is the cost of a limit-20 seek walking 40,000 dead entries. A large read pays that walk once and then reads at 2.4 µs/row, so the degradation is prohibitive for small reads and amortised in big ones. Q6 — the two-sided range is catastrophic, confirmedLow, narrow window (139 matching rows) with a limit of 1,390 that cannot fill, against 200,000 rows above the lower bound:
256× slower. Unchanged from the first runReads ~2.4 µs/row and flat, yielding free, So of #80's figures: the seek degradation and the two-sided-range penalty hold; the "second index is 39–48% of reschedule wall clock" claim does not (it is ~13%), and "a lane change is a cheap in-place update" does not ( |
…t readable Two findings from @gemini-code-assist on #119, both applied. PROCESS.EXIT ON THE ERROR PATHS. I fixed the success path to signal itself because Harper intercepts `process.exit`, and then left both failure paths on `process.exit(1)` — so a schema that never loaded, or any unhandled rejection, printed its message and then hung as a running server. A failed run looked exactly like a slow one. Both now signal SIGTERM. MKTEMP PERMISSIONS. `mktemp -d` is 0700 owned by the host user, and the container runs as `harperdb` and has to CREATE `node_modules` inside the mount to symlink itself. Without a chmod the component fails with EACCES and the server comes up perfectly healthy having loaded nothing — the same silent-success failure this harness already hit once with a read-only mount. Docker Desktop on macOS remaps ownership and hid it; on Linux and in CI it would not. Safe to widen: a throwaway directory holding three harness files, removed on exit.
|
Thanks @gemini-code-assist — both applied. Both were mine to have caught.
|
|
Thank you for the update, @harper-joseph. It is excellent to see the harness now correctly handling the |
…ttern The chmod fix in 1ab6fe4 was the third time this harness came up healthy having loaded nothing — after a read-only mount and after `handleApplication` on a root component. Two of those were already listed; this adds the third and states the common shape, which is the actually useful warning: every one of them fails by SUCCEEDING. Harper starts, reports healthy, and runs none of the harness. So the operational rule is that a run printing no `[bench]` lines means the component did not load, not that the measurement is slow.
The prior two commits were written in a worktree with no node_modules, so prettier could not run on them.
Why
Every scheduling decision in this package is justified by a number, and the numbers the queue design rests on cannot be reproduced — #80 cites
20-lanesim.mjsand21-duerank.mjs, neither of which is in this repo. Worse, two figures that are in the tree disagree by 80×, and both describe a one-sided ascending range read over the same index on the same table:util/renderSchedule.jsutil/backlogSnapshot.jsWhich one is right decides the architecture, so I stopped designing and measured.
What it found (Harper 5.2.4, 200,000 rows, realistic key shape)
Reads are cheap and flat, and yielding is free. 7.5 µs/row at limit 20, then 2.3–2.4 µs/row from 200 to 20,000. Yielding every 200 rows: 2.375 vs 2.387 µs/row — no cost. So
renderSchedule.jsis right, andbacklogSnapshot.js's calibration comment is off by ~760× for the read it describes (4.6 ms measured for exactly 2,000 rows). ItsMAX_ON_DEMAND_CAPis documented as "~3 minutes of yielding walk"; it is ~0.24 s. Extrapolated, a full 1.6M-row sweep is ~3.8 s.Writes are the expensive thing — 32× reads.
put, one indexed attributeput, queue key split into its own columnput, two indexed attributespatchof a single attributeTwo claims in #80 do not survive this:
patchis 37% worse thanput, so "a lane change is an in-place numeric update" is not an advantage.Per-lane seeks: 0.244 ms for three vs 0.133 ms for one covering the same rows — cheap either way, consistent with #80's 0.29–0.32 ms.
What it does not yet settle
The first run got both scale-dependent tests wrong, and the second commit here fixes them:
O(rows above the lower bound). It measured an empty seek and called it cheap.Re-run in progress. I'll post the corrected Q6/Q7 numbers as a comment.
Notes for whoever edits this next
Three ways the harness silently succeeded while running nothing, all now documented in the README:
EROFSon thenode_modulessymlink — and the server comes up healthy having loaded nothing. It stages into a temp dir and mounts read-write.handleApplicationis never invoked on a root component — it is the hook for a component used as an extension. NeedsjsResource.process.exit, so the harness signals itself.Docker mode mirrors how kohls-pr's CI stands Harper up. It refuses to run against
~/hdb.🤖 Generated with Claude Code