Repository navigation
Record what a worker-exit truth stamp did not land, and which writer saw it - #843
Merged
Merged
Conversation
`stampWorkerExitTruth` discarded `stampWorkerExitDown`'s result, so when the main thread declines to stamp a dead worker's (database, peer) link, nothing says what it saw. That is the one moment the buffer's state is decidable, and `truthResiduals.test.mjs` R1 fails on CI without leaving any server-side record of it. Record the slots, not a verdict. A predicate over the error code would be wrong in both directions: the watchdog teardown writes CONNECTION_STATE_DOWN with no code, and no reconnect path clears the code, so a stale one survives into the next session. And CONNECTION_STATE_DOWN is 0, the value an untouched buffer already holds, so only the error time separates "an owner wrote here" from "no owner ever reached this buffer" — which is why the ages, not the raw stamps, are what the line carries. One debug line per worker exit, allocated only if something refused: a refusal is usually benign, and a worker owning many (database, peer) pairs would otherwise emit one line each across a crash loop. Refs #431 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review round 3 found two gaps in the previous commit. Silence was ambiguous: a refused stamp, a database with no audit store, a missing peer name and a throw all returned undefined, the same value a successful stamp returns. During shutdown `getAuditStoreForDatabase` can return undefined for a torn-down database, and the buffer then keeps its CONNECTED stamp for up to LIVENESS_STALE_MS with nothing logged — an operator reading no refusal line would conclude the stamp landed. Each non-stamp outcome now names itself, so an absent line means one thing. Only half the writers were covered. The reconcile sweep is the other one, and it is the backstop for a worker dropped from the pool without ever firing 'exit' (harper-pro#357) — exactly the case where the exit handler produces nothing. It records only successes, so its refusals are now collected the same way, latched per (database, peer) like reportedNonMemberStatus because a dead owner is a level rather than an edge and the 5s sweep would otherwise repeat it forever. The join and the log call also moved inside a contained reporter: both callers run without an outer catch, and the invariant that this diagnostic cannot abort recovery has to cover the whole of it. Refs #431 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both writers emitted the same string with the same payload, so a grep could not tell an exit-handler refusal — an edge, at the moment of exit — from a reconcile-sweep one, which is a latched level for a worker that may have died minutes earlier. In a change about attribution that distinction is the point. Refs #431 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request improves diagnostics for replication connection tracking by introducing detailed logging for worker exit stamps that fail to land. It adds a helper function describeRefusedWorkerExitStamp to format status information and uses a new reportedUnstampedDeadOwner set to prevent redundant logging during the reconciliation sweep. However, a potential memory leak was identified: keys in reportedUnstampedDeadOwner and reportedNonMemberStatus can accumulate indefinitely when nodes are deleted or databases are removed, as the cleanup path in reconcileWorkers is bypassed. Explicit cleanup during these events is recommended.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
mainwent red at16377b8a6on one job —Cluster Integration Tests 1/6 (Node.js v26.5.0)— and re-running it did not clear it. The failing test istruthResiduals.test.mjs:201R1, which kills the HTTP worker that owns a subscription and waits forcluster_statusto report that link down with the worker-exit code100001. It reports the link down, with no code at all.Chasing it, the thing that was missing was not a fix but a record. The main thread decides, at exactly one moment, whether to stamp that code — and when it declines, nothing anywhere says what it saw. This PR adds that record. It is not the fix for that failure — the root cause was found in parallel and is fixed elsewhere (see below); this is the observability that would have shortened getting there, and that still applies to every other way the stamp can decline.
Changes
stampWorkerExitTruthdiscardedstampWorkerExitDown's result, so a declined stamp was invisible. It now returns a reason, and every way of not stamping names itself —'no peer name','no audit store','threw', or the buffer's own slots. That distinction is load-bearing: silence has to mean "stamped" and nothing else, or an operator reading no line during a shutdown concludes a stamp landed that never did.What a refusal reports is raw slots, not a verdict. Any predicate over the close code would be wrong in both directions — the watchdog teardown writes
CONNECTION_STATE_DOWNwith no code, and no reconnect path clears the code, so a stale one survives into the next session.CONNECTION_STATE_DOWNis also0, the value an untouched buffer already holds, so only the error time separates "an owner wrote here" from "no owner ever reached this buffer" — which is why the line carries ages rather than epoch stamps.stampWorkerExitDownhas two callers, and both now report. The reconcile sweep is the backstop for a worker dropped from the pool without ever firingexit— precisely the case where the exit handler produces nothing — so covering only the handler would have left the gap the next investigation walks into. The sweep's refusals are latched per(database, peer)like thereportedNonMemberStatusset beside them, because a dead owner is a level rather than an edge and a 5s sweep would otherwise repeat it forever.Both go through one contained reporter that names its writer, at
debug, one line per event: a refusal is usually benign, neither caller has an outer catch, and a worker owning many(database, peer)pairs would otherwise emit one line each through a crash loop.What actually broke
main— correctedI originally CI-bisected this to harper#2524 (
feat(threads): run an isolated application in a dedicated worker thread) onworkflow_dispatchbranches with only thecorepointer moved. That attribution is wrong and is withdrawn. It was root-caused in parallel bytask:main-red-kriszyp_harper-pro_056ecfd01, reproduced locally, and fixed on branchfix/replication-shared-status-gc-eviction.The real cause is buffer lifetime, and it is present on every head since harper-pro#814 — the head-specificity my bisect saw was runner load. The per-
(database, peer)shared-status buffer is engine-owned memory, and@harperfast/rocksdb-jsdocuments that "once allArrayBufferinstances have gone out of scope and garbage collected, the underlying memory and notify callback will be freed".getReplicationSharedStatusbuilds a throwawayFloat64Arrayon every call and nothing retains one, so after the owning HTTP worker's thread is gone the allocation is reclaimed and the next resolution hands back freshly zeroed memory. The stamp is written correctly and then vanishes. That is also why it is RocksDB-only: lmdb-js keeps user buffers in a permanent per-env map and never frees them.My bisect was not a controlled experiment, and it is worth saying why so the next person does not repeat it: the "fails with #2524, passes without" split rested on a single green run at the pre-#2524 pointer, against a failure that depends on GC timing; and the rate I quoted compared five days of
mainpush runs against an hour of my own back-to-back dispatches on a loaded shared runner pool. Two incomparable populations, one lucky run.What survives from that investigation is the evidence in this PR's favour, not the attribution: the main thread's own reconcile logged
state=0, liveness 1388ms ago, last close code 100001while a read moments later saw all 32 slots zero. That contradiction is what a refused-stamp record makes visible without a local reproduction.For the human reviewer
fix/replication-shared-status-gc-eviction, and this is modest observability on a path that will be less mysterious once that lands. I think the durable half stands on its own — silence from the exit handler must mean "stamped" and cannot also mean "the database was torn down and nobody looked" — but closing this as redundant is a legitimate call and I would not argue it.fix/replication-shared-status-gc-evictionfixes the buffer lifetime inreplication/knownNodes.ts; this touchessubscriptionManager.tsandreplicationConnection.ts, so they should be independent, but they are the same subsystem and that branch landed while this one was in review.if (!nodeName) continue/if (!auditStore) continueshould report too. Those skip the whole entry — the non-member clearing, the truth derivation, the metrics bridge — not just the stamp, so instrumenting them is a different and wider diagnostic with its own latch and noise profile, and on a tick that hits them far more than the stamp is already lost. Left as-is.logger.trace?.instampWorkerExitTruth's owncatchcan throw and escape. It predates this branch and is the same shape as ~87 other unguardedlogger.*?.calls acrossreplication/; guarding one of them is inconsistent, guarding all is a different PR.reportedUnstampedDeadOwnerhas no removal path for a dropped database, so the set can retain(db, peer)strings for process lifetime. Kept as-is: the pre-existingreportedNonMemberStatusbeside it has the identical shape, and fixing one without the other would be worse than fixing neither.startOnMainThread, so the unit tests reach the formatter only. In every local reproduction attempt the stamp succeeded, so nothing here proves the log emits on the case it targets.replication/replicationConnection.tsis not Prettier-clean onmain, so the review CLI's format self-check flags it. Left alone on purpose — reformatting that line is churn in a 7600-line file that concurrent replication work also touches. CI's gate islint:required(oxlint), which passes.Verification
npm run test:unit— 1071 passing (1068 on base + 3 new).npm run test:integration:cluster -- --shard=1/6on Node 26.5.0,HARPER_INTEGRATION_TEST_CONCURRENCY=2(the CI shape) — 25/25, run four times. One run showed 2 failures inconnectedBitRestartChurn.test.mjs; re-run in isolation and again as a full shard with the box quiet, both green — load on a shared box, not this change.npm run test:integration -- integrationTests/cluster/truthResiduals.test.mjs— 4/4.npx oxlint --quiet .(lint:required) — clean. Prettier clean on both changed paths.Refs #431
Review-Coverage: authored=claude; ran=gemini,codex,cursor-grok; adjudicated=domain; declined=cursor-composer; rounds=5; full=2 @ f393e94
Human-Review-Need: 4 (decisions: fix-trace-escape-here-vs-separate-issue, latch-cadence-for-a-refused-stamp, debug-level-for-a-non-landing-stamp, comment-density-under-iterative-review, second-formatter-vs-reuse) @ f393e94