Repository navigation
Conversation
There was a problem hiding this comment.
Code Review
This pull request adds a new integration test suite and associated fixtures to verify that a sourced record's metadata and blob converge correctly during competing cache fills across multiple nodes. The feedback recommends replacing CommonJS-specific globals with ESM-safe fallbacks, ensuring parallel processes are tracked for cleanup even if one fails, wrapping test cleanup steps in try-catch blocks to prevent resource leaks, and adding safety checks for potentially null payload references.
|
Reviewed; no blockers found. |
There was a problem hiding this comment.
Reviewed fae303a and found no blocking issues. No new blocking findings were confirmed in the supplied diff. Material concerns at the changed paths are already covered by the supplied discussion and were not repeated.
—
Generated by Barber AI
heskew
left a comment
There was a problem hiding this comment.
Reviewed exact head fae303aa. I found no distinct new code defect, so I am not adding duplicate inline comments.
Requesting changes for the exact-head correctness and qualification gaps already identified by @cb1kenobi: this head advances core to ac6007b while Pro still resolves @harperfast/rocksdb-js 2.6.1, which predates the verification-table consumer required by that core change, and the added regression has never run in CI against this core/dependency combination. Current Pro main now carries RocksDB 2.8.0 and a newer core containing the merged companion, so rebasing and resolving the sole submodule conflict should address the dependency mismatch; the focused regression then needs to run on that rebased head before approval.
@cb1kenobi’s equal-version/lastModified, LMDB, pre-existing-row recovery note, and inconclusive-barrier threads remain useful coverage and documentation follow-ups. Under the scope rule for this review, I am not treating those as additional blockers absent a demonstrated regression introduced by this PR.
Verification here: npm ci and git diff --check pass. Build and full lint reproduce documented baseline failures only. The focused integration test could not start because this macOS environment lacks the required 127.0.0.2 loopback alias; that is environmental, not a product failure. The core companion, HarperFast/harper#2065, is merged.
🤖 Posted by Codex on behalf of @heskew
Pin connections to every worker on two replicated nodes and race independent sourcedFrom fills through an external barrier. Require the raw record, point reads, metadata, and blob payload to converge on one write. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Clarify why the raw stores are scanned again after every worker has materialized the record. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Pre-push review (codex graded + Gemini + Cursor Composer + Harper-domain adjudication) on this rebase found several reliability/correctness gaps in the harper-pro#645 regression: - rawOperation and the cluster_status poll used bare fetch with no AbortSignal, so a node that accepts the connection but never answers hangs the retry loop until the suite's outer timeout. Bound both, and widen the add_node retry classification to treat an abort/timeout as retryable (it previously only matched ECONNREFUSED/ECONNRESET/connect errors and would hard-fail on a timeout instead of retrying). - The final per-worker assertion loop had no retry, unlike every other probe in this file, so a blob mid-write/pending-replication 500 could fail a trial that was otherwise converged; wrap it in the same bounded retry. - Nothing asserted the two source fills came from different nodes, so the regression could silently degrade to racing one node against itself; assert distinct originTrial.calls[].node values. - PairPointProbe/PairScanProbe scanned from start: null and returned the first version row for a key; take the last (highest-version) row instead and start the scan at the key, removing an open question about whether a superseded row could stand in for the live one. - Reworded the post-probe re-scan comment to state why it's needed (full per-node records for the deepEqual/payloadToken checks, not just the version+token match waitForAllWorkers already proved) instead of narrating the line below it. Verified: build clean, npm run test:unit (1068 passing), lint:required clean, and the targeted regression 10/10 (env -u HDB_ROOT) after each round of fixes — including catching that the first AbortSignal.timeout(5000) on add_node was too tight for this host's current load and needed widening to 20000ms plus the retry-classification fix above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JipTU3AWDqkji2bbgQVgDJ
Delta pre-push review round 2 (codex resume + Gemini) on the prior fix
commit found two real problems and disproved one of my own assumptions:
- Verified against lmdb-js's own RangeOptions doc ("Include version numbers
in each entry returned") that versions:true decorates one row per key,
it does not expose multiple rows — so the prior commit's "take the last
version row" comment and logic were solving a problem that can't occur.
Simplified both probes back to a single bounded check-then-break at
start: target.id (keeps the earlier scan-bound efficiency fix, drops the
now-disproven multi-row handling and its inaccurate comment).
- rawOperation's catch only forwarded error.message. Node's fetch wraps a
low-level connection failure (e.g. a peer not yet listening) in a generic
"fetch failed" TypeError and puts the real code on error.cause.code, so
the add_node retry-classification regex could never see it and would
hard-fail setup on a transient connection error instead of retrying.
Append error.cause.code to the forwarded message.
- Reworded the post-probe re-scan comment: waitForAllWorkers's probes do
carry full records, its stability check just doesn't compare them: the
re-scan exists to get the node[0]/node[1] pairing the assertions need,
not because a full record was otherwise unavailable.
Deferred (recorded for the PR body instead of fixed here — each is a
judgment call already surfaced in round 1's decision ledger, not a defect):
bootstrap-phase/wait-budget arithmetic that can exceed the suite's 300s
timeout on a genuine wedge, the barrier's fixed 10s window under load, and
running all 10 trials on every cluster CI pass.
Verified: build clean (also recovered node_modules from a pnpm-layout
rewrite the review CLI made mid-run per the known
harper-pro-local-engine-override issue: rm pnpm-lock.yaml
pnpm-workspace.yaml && npm ci), npm run test:unit (1068 passing),
lint:required clean, targeted regression 10/10 (env -u HDB_ROOT).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JipTU3AWDqkji2bbgQVgDJ
fae303a to
61e72b5
Compare
cb1kenobi
left a comment
There was a problem hiding this comment.
This commit only touches the new integration test and its fixture, and the delta (bounded timeouts, wrapped-cause forwarding, the reverted version-scan logic, the distinct-origin-node assertion, and the retried final probe) checks out against the diff. I traced the barrier, both raw-store probes, the convergence waits, and the teardown path and found no new blocking defect. Concerns already raised on these paths — equal-version/lastModified coverage, LMDB engine coverage, the inconclusive-barrier assertion, the suite time budget, and the ESM module.path fallback — were left to their existing threads. One thing worth a look before merge: the cluster_status poll passes a signal option to sendOperation in clusterShared.mjs, which is not in this diff, so I could not confirm it is actually forwarded to fetch.
—
Reviewed 61e72b5
The regression only exercised the easy half of the convergence claim: the origin never reported a `lastModified`, so each node minted its own monotonic version and ordinary version ordering settled every race. The tie — both replicas at the same candidate version, which cache-fill resolution has to break without node identity — was never reached, and only the default storage engine ran even though the resolution branches on `isRocksDB`. Adds a `tied-late` shape that parks the losing fill at the origin until its peer's record has replicated in, so the fill commits against a raced record at an equal version, and runs the suite over rocksdb and lmdb the way fullyConnectedReplication.test.mjs does. A barrier that never staged two concurrent fills now restages on a suite-wide budget instead of failing a trial that demonstrated nothing. Mutating core's guard at resources/Table.ts:7214 — either to `true` or to `>=`, which reinstates the divergence — survives the previous test and fails this one on both engines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pre-push review found three ways this file can fail as a process rather than as a test, all of them worse now that it bootstraps twice — once per engine. `connectNodes` budgeted each retry attempt but not each phase, so a node that accepts the operations socket and never answers could spend ~22 minutes across the three loops, and twice that across both suites: past the 25-minute cluster job cap, so the workflow dies before any phase reports its own diagnostic. Each phase now carries one deadline and hands its remaining budget to the request. `pendingFills` was constructed and only awaited after `releaseLateFill` had awaited real I/O, so a fill that rejected in between reached node as an unhandled rejection and took the whole runner down instead of failing one trial. Worker discovery aborted the bootstrap on a single transient response from a worker still warming up, and dropped the agents it had already opened — they are unreachable from `ctx.agentsByNode`, which is never assigned when the `Promise.all` rejects, so `after` could not close them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Making a failed worker probe non-fatal last round removed the only thing bounding discovery: 40 probes each waiting out a 20s request timeout is ~13 minutes per node. Discovery now shares the bootstrap phase budget, which drops to 60s so all four phases together stay inside the suite's own 300s as well as the cluster job cap. `startHarper` REPLACES `nodeCtx.harper` rather than filling it in, so a node whose startup threw was never recorded and `after` could not tear it down — the orphaned child then held its loopback ports against the next run. The handle is now captured in a `finally`. Pinned agents are likewise assigned per node instead of from the `Promise.all` result, so one node failing no longer strands the sockets the other already opened, and the barrier origin rejects a `listen` error instead of letting it reach the runner unhandled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Promise.all` rejects on the first failure, so `after` could run while a sibling was still starting a node or pinning workers; that task then published its handle into the suite context after cleanup had already walked it, leaving a Harper child holding its loopback ports for the rest of the run. Both setup fan-outs now settle fully and rethrow the first rejection afterwards, so every resource is registered before teardown looks for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
releaseLateFill returned early when no second fill showed up inside the barrier window, leaving the origin trial unreleased. A second fill that was only slow then arrived to a state that armed a fresh 10s timer on top of the time it had already spent, pushing its own request past the 20s socket timeout — so a merely-delayed race threw out of the trial instead of restaging. The release now happens in a finally covering both waits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A node whose worker pinning threw leaves its slot unassigned, so the cleanup loop read `undefined.values()` and threw out of `after` before reaching teardownHarper — leaking both Harper children and the origin server on exactly the failure it was there to clean up after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wait for the parked fill only yielded when fewer than two origin calls had arrived. Once two had, an unrecognized hostname left `findIndex` at -1 and the loop spun synchronously for the whole barrier window — on the same event loop the barrier origin answers from, so nothing could make progress out of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@heskew — the two blockers you named are discharged, both by the rebase and by this push. Summarising so you don't have to re-derive it: "this head advances core to "the added regression has never run in CI against this core/dependency combination." It has now, twice over — the suite is parametrized across storage engines, and both legs are green on this head: I also acted on @cb1kenobi's four coverage threads rather than carrying them as follow-ups, since each is a gap in the test that is this PR. Each thread now has an evidence-backed ruling and is resolved. Two things worth your attention before approving, both in the PR description:
Re-review is yours — — Claude Opus 5 |
cb1kenobi
left a comment
There was a problem hiding this comment.
The new commits add tied-late coverage, LMDB, restaging, and teardown hardening on a test-only regression. Existing review threads on lastModified, engine coverage, inconclusive barriers, and node orphans are addressed at this head. Cluster CI is green on this commit. No new blocking defect was confirmed on the changed lines.
—
Reviewed 9f292b8
cb1kenobi
left a comment
There was a problem hiding this comment.
This is a test-only change — a two-node cache-fill race suite plus its fixture — with no production code and no core submodule movement, so Harper's security and data-integrity domains have no changed surface. I traced the barrier state machine, the late-fill release path, the restage budget, the raw-store probes, and the teardown path and found no defect that would make the suite pass when it should fail or leak a Harper process. The concerns still worth a human decision (the pre-fix mutation that survives, and the concurrent equal-version tie that fails on main today) are already stated in the PR body, and the older inline threads on ESM fallback, signal forwarding, and time budget are unchanged, so I did not repeat them.
—
Reviewed 9f292b8
|
@cb1kenobi — answering the one open question from your
It is forwarded. Good catch to flag it rather than assume it — a Nothing else changed on this head: all 14 review threads are resolved with rulings, all 31 checks are green at — Claude Opus 5 Addendum — the mutation evidence in the description was re-run from scratch, independently of the session that wrote it, because it is what @heskew is being asked to merge on. Same worktree,
Both mutated failures are the divergence itself, not a timeout artifact — the two nodes report the same One detail worth recording, because it shows which assertion is actually load-bearing: the failure surfaces in The third row of the description's table (a verbatim pre-#2065 guard restoration surviving) was not re-run here; it is stated as a known limit under For the human reviewer and remains the open reviewer decision. |
cb1kenobi
left a comment
There was a problem hiding this comment.
This is a test-only regression for competing sourcedFrom blob fills across two nodes. Earlier blockers on the core pin, equal-version coverage, both storage engines, and teardown leaks are addressed at this head. Nothing new on the changed lines would let the suite pass when metadata and blob diverge, or leave a Harper child running. The pre-fix guard restoration and the concurrent equal-version tie remain human decisions already stated in the PR body.
—
Reviewed 9f292b8
|
@heskew @cb1kenobi — the one qualification gap this PR was still carrying is now closed, with a red-then-green measured on this head. No code changed; the description's mutation table and its For the human reviewer section are updated. What was open. The description claimed the suite could not be shown red against a genuine pre-#2065 core, because building harper-pro against core at What replaces it. Reverting #2065's
The failures are the defect, not a timeout — two nodes holding each other's record and never converging: Two things worth knowing beyond "it's red now":
The one item still stated as out of scope is unchanged and still needs its own issue: two concurrent fills at a shared — Claude Opus 5 |
cb1kenobi
left a comment
There was a problem hiding this comment.
The new cluster regression covers both race shapes and both storage engines, and the barrier and teardown hardening closes the process-leak paths from earlier rounds. Remaining judgment calls are already in the existing discussion, including the concurrent equal-version tie this suite deliberately does not assert. Nothing on the changed lines would let the test pass while metadata and blob stay split, or leave a Harper child running.
—
Reviewed 9f292b8
ldt1996
left a comment
There was a problem hiding this comment.
Read the full suite at 9f292b8. Approving: the mutation table is the strongest verification I have seen on a regression PR in this repo, the red-then-green against the reverted #2065 hunks settles the qualification question that was open earlier, and the engine-leg asymmetry note (each leg catches a different mutation class) is exactly the documentation the matrix needs to survive future CI-cost trimming.
I traced the test logic independently: the barrier's stagger/release accounting, the suite-wide restage budget, the trailing calls.length check that catches a probe accidentally triggering a third source fill, the settleAll bootstrap so a slow sibling cannot publish a node after teardown walked the list, and the agents-then-nodes-then-origin teardown ordering. No defects found; the deferred items list matches what I would have flagged and none of them blocks.
One ask before merge, matching the posture I have taken on 815: the concurrent equal-version tie that this suite deliberately does not assert is described as a genuine open product defect, reproducible about 4 in 10 runs with the documented one-line SHAPES addition. Please file it as its own issue before this lands and cross-link it from the SHAPES comment, so the "deliberately absent" note points at a tracked number rather than only prose; without that, the shape is one comment-trim away from being forgotten. The forward-only residual for already-diverged rows is also worth a line in the next release notes given the operator action it implies, but I leave that call to you.
Lavinia, via Claude
Problem
Concurrent independent
sourcedFromfills can settle on opposite winners across replicated nodes. The original four-node cached-blob test exposed this as metadata from one fill paired with a blob endpoint response from another, with no self-healing.The smaller harness isolates the mechanism:
payload: Blobattribute is what makes a metadata/blob split observable at all, and 16 KiB keeps it above core's 8 KiB inline-storage threshold;The records were not torn internally. Each node could retain a different complete winner after both local fills encountered a peer fill. Routing metadata and blob reads across those divergent nodes produced the apparent split.
Change
This PR adds the regression validating the fix in HarperFast/harper#2065 — Fix sourcedFrom cache-fill conflict convergence (merged into core
main2026-08-29 asba81e7b04;corehere is bit-identical toorigin/main's pointer, so this PR moves no submodule).The core fix reloads commit-time state, applies deterministic ordering to competing positive first fills, preserves strict revalidation/deletion safety, and updates indices/created-time metadata against the actual winner.
The test drives that fix through two race shapes, because they are settled by different arbiters and a regression in one is invisible to the other:
distinct— no originlastModified, so each node mints its own monotonic version and ordinary version ordering settles the race. This is the original harper-pro#645 shape.tied-late— the origin reports one sharedlastModifiedto both nodes, so both fills carry the same candidate version, and the losing fill is parked at the origin until its peer's record has replicated in. Its commit therefore lands against a raced record it cannot order by version, which is the branch core's own comment atresources/Table.ts:7209singles out: "a fill from a shared source has no node identity of its own, so two replicas resolving the same tie could keep different values at the same version."Both shapes now run on rocksdb and lmdb (parametrized the way
fullyConnectedReplication.test.mjsalready does), because the resolution branches onisRocksDBin two places that decide the stored version.A barrier that never staged two concurrent fills no longer fails the trial — it restages on a suite-wide budget, so a scheduling accident (one node learning the key by replication before issuing its own fill) cannot red-build a run that demonstrated nothing, while a race that can never be staged still fails inside the suite timeout.
Four follow-up commits came out of the pre-push review and are all about this file failing as a test rather than as a process: per-phase bootstrap budgets (an unanswering operations socket could otherwise outlast the 25-minute cluster job without any phase reporting its own diagnostic), settling both setup fan-outs so a sibling cannot publish a Harper node after
afterhas already walked the list, releasing the barrier on every exit path, and tolerating a hole in the pinned-agent array during teardown. Each of those leaked a Harper child or killed the runner outright rather than failing a trial.Verification
Executed on this branch,
coreateec839ee,@harperfast/rocksdb-js2.9.0:npm run build— clean, 0 errorsnpm run test:unit— 1068 passingnpm run lint:required— clean;prettier --checkclean;git diff --checkcleanTRIALS=10,WORKERS=2, both engines) — 3/3 clean runs, plus 4 more atTRIALS=9corerestored toeec839eeanddistrebuilt between every stateCluster Integration Tests 3/6on61e72b50ran this suite —10 two-node cache-fill races ... (31911ms)passingMutation evidence, including a red-then-green against a pre-fix core
@cb1kenobi measured that mutating the convergence guard survives harper-pro's unit suite, so the integration test is the only guard. Every row is a mutation of
core/resources/Table.tswithdistrebuilt against it. Rows 2-3 were measured earlier in this PR's history; rows 1, 4 and 5 were measured on the current head, each bracketed by an unmutated control run:eec839ee— control, run twiceconst replacesRacedRecord = truetied-late-1tied-late-1sourceVersion >= racedVersion(tie only)tied-late-1/-3tied-late-1/-3entry: existingEntry), rest of #2065 left in placedistinct-0/distinct-2— 2/2 runsTable.tshalf reverted onto current coreThe mutated runs fail exactly as harper-pro#645 predicts — two nodes holding each other's record and never converging, not a timeout:
The last row is the red-then-green demonstration, and it replaces the caveat this description previously carried. Building harper-pro against core at
ba81e7b04^is genuinely impossible — currentreplication/replicationConnection.tsneeds six core APIs that only exist after it (createBlobFromStoredBody,openStoredBlobBody,blobFileMissingOrIncompleteAsync, twoREPLICATION_BLOBGAP*config keys,AuditRecord.txnLogKey), sotscfails before a node can start. Reverting #2065'sTable.tshunks onto current core isolates the fix better anyway: the other 280 core commits stay fixed, so a red is attributable to #2065 and nothing else. Two conflicts had to be hand-resolved, both mechanical —recordVersionbecomestxnTime(its pre-fix value) to keep the later-addedtxnLogKeyline compiling, andVERSION_REUSEDstays exported because a later feature (PrimaryRocksDatabase.ts:171) now consumes it.Against that pre-fix core the suite is red on both engines, twice over:
tied-late-1fails its tie precondition (pre-fix code storestxnTime, not the source-reported version, so the two fills cannot tie at all) anddistinct-0fails as outright divergence.The third row is also a correction: the verbatim guard restoration is caught, on the lmdb leg, at
distinct-0on the very first trial in one of the two runs. It was recorded as surviving earlier in this PR's history; it does not survive the engine parametrization @cb1kenobi asked for, which is that thread's concrete payoff.For the human reviewer
How much of harper-pro#645 this suite actually guards — no longer an open question, but read what each leg guards. The suite is red against a pre-fix core and green against the fixed one (table above), so it does discriminate a #645 reintroduction. The two engine legs do not discriminate it the same way, and that asymmetry is worth knowing before anyone trims the matrix: on lmdb the
distinctshape diverges from the restored CAS guard alone, while on rocksdb nothing goes red until #2065's version derivation is reverted as well, and then it is thetied-latetie precondition that catches it. Deleting either leg halves the guard — which is the argument against the CI-cost item below.A shape this test deliberately does NOT assert, because it is red on
maintoday. The natural reading of the equal-version suggestion is two concurrent fills at a sharedlastModified— each node commits its own record at that identical version before its peer's arrives. That does not converge, on either storage engine, against unmodified core: the nodes end up holding each other's record at the same version permanently. Reproduce by adding{ id: 'tied', stagger: false, tied: true }toSHAPESand running the suite; it fails asTimed out waiting for tied-N convergencewith matching versions and differing tokens, roughly 4 runs in 10. This looks like a genuine product defect rather than a test artifact, but it is out of scope for a #645 regression PR and needs its own issue and core fix. This PR should not be read as evidence that the concurrent equal-version tie converges.Residual for rows that already diverged (raised by @cb1kenobi). The core fix is forward-only. Two replicas already sitting on different values at the same version are not repaired by it, and a fill only returns to the source when the record is missing, invalidated/evicted, or expired (
core/resources/Table.ts:6685) — so an already-filled divergent row is never naturally revisited. Operators upgrading past this fix should invalidate or evict affected caching tables if they suspect divergence; otherwise normal TTL expiry is what clears it.tied-latestaging is verified, not assumed, but it is still timing-dependent. The trial only asserts convergence after confirming the peer's record is visible on the parked node; if it never arrives within the barrier window the trial restages rather than asserting. That was measured to engage — with core instrumented, the parked node's commit reportsracedVersion == sourceVersionandreplacesRacedRecord: false— but the confirmation is a store probe, not a hook into the commit, so a sufficiently adverse interleaving could still fall back to the ordinary shape without saying so.Deferred, not fixed (judgment calls about CI cost vs. diagnostics, carried forward from earlier rounds):
test:integration:clusterpass — the dominant recurring cost of this PR, roughly 90s on an idle host. AHARPER_RUN_STRESS_TESTS-style gate with a 1-trial default, or the full matrix only nightly, would cut it; both are reachable through the existingHARPER_645_TRIALS/HARPER_645_WORKERSenv vars. Cut trials or frequency, not an engine: the mutation table shows each leg catching a different mutation.PairPointProbe/PairScanProbereadprimaryStore.getRangedirectly rather than only through public read paths; that is the only surface that can show a metadata/blob split the cached path hides, but it couples the test to store internals.clusterShared.mjs'ssendOperation/waitForCondition; the bespoke path is what buys per-worker socket pinning, at the cost of fixing every timeout bug once per file. The phase budgets added this round are the second such fix.waitForConvergence/waitForAllWorkers(both pre-existing) usePromise.all, so one probe rejecting fast abandons its siblings in flight and the next poll queues behind them on the samemaxSockets: 1agent. Bounded by the 30s convergence deadline, so it degrades to a slow poll rather than a hang, butallSettledwould be strictly better.pinWorkerskeeps its pre-existing 40-iteration cap alongside the new phase deadline; at 100ms per warmup miss the cap binds at ~4s, well before the 60s budget, so a slow-to-warm worker fails the assertion rather than using the budget.Dependency
Core dependency resolved: HarperFast/harper#2065 merged 2026-08-29. This PR no longer needs a
Depends-on:gate or a pinnedcorepointer —corehere matchesorigin/main, and@harperfast/rocksdb-js2.9.0 (which harper-pro already pins) exports theVERSION_NOT_UNIQUE_FLAGthat coremainconsumes atcore/resources/RecordEncoder.ts:131.Fixes #645
Authored by GPT-5 Codex; this round by Claude Opus 5.
Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=10; full=2 @ 9f292b8
Human-Review-Need: 3 @ 9f292b8