Repository navigation
Temporarily take the derived-index runner lock without an unlock callback (rocksdb-js#848 workaround) - #2574
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the locking mechanism in DerivedIndexRunner to avoid using an unlock callback, which can cause process aborts on Node 22 when workers are terminated. Instead, peers are woken up via notifications on the readiness buffer, commit wakes, and a fallback 5-second retry timer. The review feedback points out a potential issue where #skipNextNotify could remain true if the readiness buffer's notify method is undefined or throws an error, which would cause the runner to incorrectly skip the next legitimate notification. A code suggestion is provided to safely guard the notification call and reset the flag on failure.
|
Reviewed; no blockers found. |
Every Unit Test run on main since #2430 merged aborts on Node 22 (`Aborted (core dumped)`, exit 134) right after the plane suite terminates a worker thread and the next test calls `resetDatabases()`. Node 24 and 26 pass. Root cause is in rocksdb-js, reproducible with no Harper code: a worker calls `tryLock(key, callback)` while another thread holds the lock, the worker is `terminate()`d, and the holder calls `unlock()`. `DBDescriptor::lockReleaseByKey` then calls the queued callback's thread-safe function for an env that no longer exists; on Node 22 that aborts the process, on Node 24 it returns `napi_closing` and is skipped. Lock callbacks have no per-env cleanup (the park-timeout registry does: `releaseByEnv`). The shared runtime made this deterministic because every non-owner worker keeps a standing lock waiter, and `manageThreads.js` terminates workers on restart, so this is a production path on Node 22 — including for `recordLock.ts`, which uses the same primitive. The runner now takes the lock with no callback. Successors are woken by the releasing owner's `notify()` on the readiness buffer (whose listeners survive a dead env; proven by the same repro), by commit wakes, and by a 5 s retry timer that covers an owner that died without releasing. The releasing runner ignores the one notification it caused so an idle release does not re-acquire itself. A lock attempt that throws still backs off and ignores commit wakes meanwhile. The Stage 1 fake store now models `notify()` the way the native binding does; the test for a synchronous unlock callback goes with the callback. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ed54a2f to
13a19ba
Compare
`#skipNextNotify` was set before `notify?.()` was attempted, so an absent or throwing `notify` left it standing and the next legitimate wake — a peer's release or a rebuild request — was swallowed. Set it only when a notification is actually on its way, and clear it if the call throws. Cover the wake path by behavior rather than by the fake's shape: the two-runner test now requires the waiting runtime to mint an owner epoch (it takes the lock) inside waitFor's 2 s budget, well under LOCK_RETRY_MILLISECONDS, and the idle-release test counts lock attempts so a runner that re-acquires on its own release notification fails. That test previously passed its first wait at t=0, before the runner had acquired anything, so it observed neither the release nor the reacquire. Measured against the pinned rocksdb-js 2.9.0: `getUserSharedBuffer(...).notify()` delivers to the calling env's own registration exactly once and to another thread's registration once. `#notified()` now records that premise for #2576. Refs #2574 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…commit Dropping `#waitingForLock` with the unlock callback left `#acquire()` reachable from every commit wake, so each non-owner ran a native `tryLock` per event-loop turn for every index it does not own — a perpetual poll in place of the standing native waiter this change set out to remove. Restore the guard; the readiness notification and the retry timer are what clear it, so a freed lock is still picked up promptly and a dead owner is still bounded by the retry cadence. Two further fixes from the same review: - a `tryLock` that throws now replaces an armed contention retry instead of inheriting its deadline, so `rebuildBackoffMilliseconds` is the delay that actually applies (it was silently rounded up to the remainder of the 5 s wait); - the retry cadence is a runner option (`lockRetryMilliseconds`, default 5000) rather than a module constant, so the dead-owner path is testable without waiting it out. It had been the one wake source with no coverage. Tests: a dead owner whose lock frees with nothing to notify resumes on the retry timer and does not re-probe under commit wakes; a throwing attempt recovers on the configured backoff; and the real binding — not the fake — now asserts that `getUserSharedBuffer(...).notify()` reaches the caller's own registration exactly once, which is the premise `#skipNextNotify` rests on. Refs #2574 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #2574 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The release notification is a broadcast, so a dormant ex-owner acted on the peer's idle release: it re-acquired, found no work, idled out and handed the lock back, rotating ownership — with a backend shutdown, a quiesce and a fresh owner epoch per index — every idle grace period on a system with nothing to do. Before this branch the ex-owner held no waiter registration and the handoff terminated after one hop; restore that by acting on a notification only when this runner owns the index (the shared rebuild request is aimed at the owner) or is parked on the lock. Restore the `wake()` try/catch the removed unlock callback had: `#notified()` runs as the binding's thread-safe-function callback, where an escaping throw is an uncaught exception rather than one failed runner. Refs #2574 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
This workaround stops using the native unlock callback that aborts Node 22 after a worker is terminated. Waiters now wake from the readiness notify, and a 5 second timer covers an owner that dies without notifying. The follow-up commits fix skip-arming, stop commit-wake polling, and ignore notifies on dormant ex-owners so idle systems do not rotate ownership. Nothing left on the changed lines is a confirmed blocker; earlier skip-flag and fake-coverage threads are already fixed.
—
Reviewed 37b46a2
|
CI note: the red Unit Test is The three failing Unit Test legs fail in The part that matters for this PR: Locally at 37b46a2, under Node 26.2.0: — Claude Opus 5 |
cb1kenobi
left a comment
There was a problem hiding this comment.
The runner now takes the lock without an unlock callback and wakes waiters from the readiness notify plus a retry timer. Earlier skip-flag, commit-wake polling, and idle-ownership-rotation issues are already fixed on this commit. The remaining judgement calls are documented rather than confirmed defects, and nothing on the changed lines is a blocker.
—
Reviewed 37b46a2
cb1kenobi
left a comment
There was a problem hiding this comment.
This PR stops passing an unlock callback to the derived-index runner lock, because a callback left behind by a terminated worker crashes Node 22 when another thread unlocks. Waiters now wake from the readiness-buffer notification plus a 5 second retry timer for an owner that died without releasing. The earlier skip-flag and fake-coverage findings were fixed in later commits, and nothing else on the changed lines is a confirmed blocker. The red CI legs are pre-existing failures on main, not this branch.
—
Reviewed 37b46a2
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Bind deferred update changes when save selects a generation, while leaving framework-created no-op updates lazy until validation. Preserve computed-attribute errors before tracked-instance lifecycle checks. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
This PR stops passing an unlock callback to the derived-index runner lock, because a callback left behind by a terminated worker aborts Node 22 when another thread unlocks. Waiters now wake from the readiness-buffer notification plus a 5 second retry timer, and the merged-in compatibility repair keeps lazy updates bound to the generation that saved them. I traced the skip-flag interleavings, the acquire/backoff state machine, and the shared-memory test fake and found no blocking defect on the changed lines. Earlier skip-arming and fake-coverage threads were already fixed in later commits.
—
Reviewed 68f7358
|
Independent adjudication pass over all open review feedback at Status of the review surfaceAll three inline threads are resolved and answered. The four most recent reviews ( Rulings1. The earlier ruling rested on a claim about rocksdb-js internals, so I read the pinned binary rather than trusting it. In buffer.notify = (...args) => this.db.notify(this.encodeKey(key), args);
buffer.cancel = () => { if (options?.callback) this.db.removeListener(this.encodeKey(key), options.callback); };
2. LMDB Both I also checked the RocksDB half, since "already dropped there" was the load-bearing part of the no-behaviour-change claim. It holds: 3. Added comments narrate rather than inform — declined, house-style judgement, unchanged. The four the finding itself marked "worth keeping" are kept. Of the rest, the 4. 5. Handoff verified only by the fake (cb1kenobi) — half upheld, both halves landed. The delivery-contract half is refuted by finding 1's source reading; the coverage half was real and is now covered by behaviour, including the native-binding assertion at One thing I checked that nobody raised
CI note — supersedes my earlier comment on this threadMy 23:17 comment said the red Unit Test was
So this PR is both the Node 22 abort fix and the fix for — Claude Opus 5 |
cb1kenobi
left a comment
There was a problem hiding this comment.
This PR stops passing an unlock callback to the derived-index runner lock, because a callback left behind by a terminated worker aborts Node 22 when another thread unlocks. Waiters now wake from the readiness-buffer notification plus a 5 second retry timer, and the merged compatibility repair keeps a lazy update bound to the generation that saved it. I traced the acquire/park/backoff state machine, the skip-flag interleavings, the lazy-capture latch, and the test fake, and found no blocking defect on the changed lines. The earlier skip-arming and fake-coverage threads are fixed at this head, and the three remaining concerns were already raised and adjudicated in the PR.
—
Reviewed 68f7358
|
Independent second adjudication at 1.
|
cb1kenobi
left a comment
There was a problem hiding this comment.
The derived-index runner now takes the lock without an unlock callback and wakes waiters from the readiness notify plus a retry timer. The skip-flag, park-on-timer, and idle-rotation paths match the tests and earlier adjudication, and the lazy save-generation latch keeps an empty write from picking up a later update. Nothing on the changed lines is a confirmed blocker.
—
Reviewed 68f7358
|
Third adjudication at
I also traced two things nobody had: One new finding, now recorded in the PR descriptionThe compatibility repair's
The No code change made: the new behaviour is the safer one and the path is not reachable from a request. It is written up under For the human reviewer so the decision is visible at promotion time. The description footers were left untouched — the head sha is unchanged, so the existing review receipt and — Claude Opus 5 |
cb1kenobi
left a comment
There was a problem hiding this comment.
The derived-index runner now takes the lock without an unlock callback and wakes successors from the readiness notify plus a retry timer, working around a Node 22 abort in the pinned rocksdb-js. I walked the acquire/park/backoff state machine, the skip-flag interleavings, and the lazy save-generation latch in Table.update, and found no state that strands a runner with work or drops a saved change. The earlier skip-arming and fake-coverage threads are fixed at this head, and the remaining concerns are already raised and adjudicated in the PR. Nothing on the changed lines is a confirmed blocker.
—
Reviewed 68f7358
The legacy full-update fall-through in update() discards the body the caller supplied and hands _writeUpdate whatever the instance had already staged. Two consequences, both independent of #2561 and present before it: - `record.put(0)` on an instance with a staged update commits that update as a full record replacement. No error is raised and unrelated attributes are dropped — a record holding {status, metadata} keeps only the staged status. #2574's captureChanges binds lazily only when no root was supplied, so a falsy root still reaches validation as the staged object rather than as itself. - A non-object root is rejected, but the #1298 message names no value ("received undefined"), pointing at a missing body rather than a wrong-shaped one. Forward the supplied root and fall back to the staged changes only when the caller passed nothing at all. This is a no-op for every object root: both the fullUpdate and directInstance branches above already assign `this.#changes = updates`, so the forwarded value is the same object that was passed before, and #2574's captureChanges arms exactly as it did. `false` needs one extra exemption: it is the sentinel that cancels a pending patch, so it keeps that meaning on the incremental path and is rejected only as a full-update root, where `put(false)` otherwise returned silently and let the staged patch commit. All four sameKeySaveOrder cases fail on origin/main. Verification: - unitTests/resources: 2495 passing / 33 pending (rocksdb), 1946 passing / 377 pending (lmdb) - unitTests/apiTests: 201 passing / 1 pending (rocksdb), 200 passing / 2 pending (lmdb) - integrationTests/resources/bytes-scan-crash.test.ts: 6/6 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#2578) The legacy full-update fall-through in update() discards the body the caller supplied and hands _writeUpdate whatever the instance had already staged. Two consequences, both independent of #2561 and present before it: - `record.put(0)` on an instance with a staged update commits that update as a full record replacement. No error is raised and unrelated attributes are dropped — a record holding {status, metadata} keeps only the staged status. #2574's captureChanges binds lazily only when no root was supplied, so a falsy root still reaches validation as the staged object rather than as itself. - A non-object root is rejected, but the #1298 message names no value ("received undefined"), pointing at a missing body rather than a wrong-shaped one. Forward the supplied root and fall back to the staged changes only when the caller passed nothing at all. This is a no-op for every object root: both the fullUpdate and directInstance branches above already assign `this.#changes = updates`, so the forwarded value is the same object that was passed before, and #2574's captureChanges arms exactly as it did. `false` needs one extra exemption: it is the sentinel that cancels a pending patch, so it keeps that meaning on the incremental path and is rejected only as a full-update root, where `put(false)` otherwise returned silently and let the staged patch commit. All four sameKeySaveOrder cases fail on origin/main. Verification: - unitTests/resources: 2495 passing / 33 pending (rocksdb), 1946 passing / 377 pending (lmdb) - unitTests/apiTests: 201 passing / 1 pending (rocksdb), 200 passing / 2 pending (lmdb) - integrationTests/resources/bytes-scan-crash.test.ts: 6/6 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Temporary workaround — revert tracked in #2576 once a rocksdb-js release with #849 is pinned. The lock's own unlock callback is the right primitive; this PR sidesteps it only because the pinned rocksdb-js aborts Node 22 on it.
Fixes the Node 22
Aborted (core dumped)in Unit Test onmainsince #2430 merged (runs 34637871368, 34637971903): the process dies aftervectorIndexPlane.test.jsterminates a worker thread and the next test callsresetDatabases(). Node 24 and 26 pass.Root cause — rocksdb-js, not Harper
Reproducible with a 30-line script and no Harper code: a worker calls
db.tryLock(key, callback)while the main thread holds the lock, the worker isterminate()d, the main thread callsdb.unlock(key)→ SIGABRT on Node 22.23.1; Node 24.18.0 survives.DBDescriptor::lockReleaseByKey(db_descriptor.cpp:1185) calls the queued callback's thread-safe function for an env that no longer exists; Node 24 returnsnapi_closing(which the loop skips), Node 22 aborts. Lock callbacks have no per-env cleanup — the park-timeout registry does (releaseByEnv). The shared-buffernotify()listener path survives the same scenario on both versions.The shared runtime made this deterministic: every non-owner worker keeps a standing
tryLockwaiter for each index (the old #2430 runtime only waited during a drain, which is why its Node 22 run passed).manageThreads.jsusesworker.terminate()on restart, so this is a production path on Node 22 — andrecordLock.ts(harper#483) uses the sametryLock(key, onUnlocked)primitive, so it is exposed independently of derived indexes.The root-cause fix is HarperFast/rocksdb-js#849 (issue rocksdb-js#848): a dying env's queued lock callbacks are released from its cleanup hook, and the release paths hold
locksMutexacross the tsfn calls. Against that binary,main's runtime unchanged passesvectorIndexPlane.test.js22/22 on Node 22 — so this PR is not required for correctness once rocksdb-js ships. It is still worth landing: a standing native waiter per worker per index is a resource the runtime does not need, and it unblocksmainnow without waiting on a rocksdb-js release and pin bump.Fix
The runner takes the lock with no callback. A successor is woken by:
notify()on the readiness buffer — listeners survive a dead env, proven by the repro's buffer variant;lockRetryMilliseconds(5 s), for an owner that died without releasing — its lock is released natively and nothing notifies.A readiness notification is acted on by an owner or a runner parked on the lock, and the releasing runner consumes the one it caused so an idle release does not re-acquire itself. That skip rests on a binding property, measured here against the pinned rocksdb-js 2.9.0: a
notify()reaches the caller's own registration exactly once, and another thread's registration once.derivedIndexRuntimeNativeBackend.test.jsnow asserts that count against the real binding rather than leaving it to the unit-test fake.A parked runner stays parked rather than re-probing the native lock on each commit wake, and a
tryLockthat throws replaces an armed contention retry instead of inheriting its deadline, sorebuildBackoffMillisecondsis the delay that actually applies.DESIGN.md § Derived-index runtime › Ownership and wake-uprecords the change and why.Compatibility repair from the merged base
After this branch was opened,
mainmerged #2561 (c90c61c3), whose eager allocation of an empty update payload caused the broad unit and integration failures now shown on the old PR head. This branch merges that exact tested base without rewriting history, then repairs the regression:Table.update()preserves the distinction between an absent update and an explicit empty patch. A lazy write binds its changes exactly once whensave()selects its generation, so LMDB cannot rebind an earlier empty save to a later CRDT update at commit.That regression is still live on
main: its Unit Test workflow fails on Node 22, 24 and 26 at the tip (bba808e7, run 34657558850) with sixtest:unit:apitestsfailures — the four cache/query REST cases,assigning a computed attribute on a resource instance throws a client error, andcan publish non-JSON. This PR's merge ref turns all six green, so it also unblocksmain's CI.For the human reviewer
Please merge rather than squash. The planned #2576 rollback must revert only the derived-index workaround and preserve compatibility commits
7eec8f7and68f7358; squashing would erase the boundary that makes that safe.The wake path still has three explicit judgement calls:
The skip flag rests on a measured binding property, not a contract.
#skipNextNotifyis correct only if anotify()self-delivers exactly once. That is now measured (probe against 2.9.0) and asserted in CI against the real binding, but it is not a documented rocksdb-js guarantee. The alternative — stamping the releasing owner's identity in the shared buffer so no runner has to guess whether an echo is its own — is correct under 0, 1 or N deliveries. It was not taken because Revert #2574 (notify-based runner-lock wake) once rocksdb-js with #849 is pinned #2576 deletes this path entirely; if that revert slips, the stamp is the durable form.A dead owner now costs up to 5 s of indexing lag, where the unlock callback picked it up immediately.
lockRetryMillisecondsis an option, so the number is retunable, but the default is a behaviour change on worker restarts.Two review findings were declined. A floor on
lockRetryMilliseconds/rebuildBackoffMilliseconds(a0would spin): neither option is reachable from user config —hnswDerivedIndex.tspasses onlymaxLagMilliseconds, which is the one already clamped. And wrappingwake(true)in the retry timer and in the#releasingcontinuation: both call sites are unchanged fromorigin/main, and thetry/catchthat was restored is on#notified()specifically because that one runs as the binding's thread-safe-function callback.The compatibility repair turns one silent record wipe into a
ClientError. Dropping the#changes ??= Object.create(null)synthesis inTable.update()means a full update with an absent payload no longer invents an empty record. Measured on this head against adist/revert of those two lines:update(id, undefined, ctx)followed by a bareput()(and equallyupdate(null, true)+save()) replaced a stored{ id, status: 'kept' }with{ id }before, and now aborts the transaction withA record must be an object, but received undefined. It is not reachable over REST —server/REST.ts:308always passes an objecttarget, so an empty-bodied PUT takes the legacy branch with a truthy#changesand is unchanged — so this is a programmatic-only path, and refusing the write is the safer outcome than erasing the record. Thevalidatefallback narrowed with it (if (!recordUpdate)→ a latch onrecordUpdate === undefined); every other falsy payload —nullfrom a replicationpatchapply,0,''— ends in the same no-op or the sameClientErroron both sides of the diff.The final review's two behavioral questions were also resolved without code changes, and both refutations were re-checked against source in a later adjudication pass:
stop()cancelling the readiness registration before the release notifies. In the pinned rocksdb-js 2.9.0,getUserSharedBufferbuilds the wrapper's two methods independently (dist/index.mjs:2011-2017):notify()isdb.notify(encodeKey(key), args)— a database-level dispatch that consults the key's listener list at call time — whilecancel()is onlydb.removeListener(encodeKey(key), options.callback). Cancelling this wrapper therefore removes this runner's own callback and nothing else, so#release()'snotify()still reaches every peer. Harper reaches that exact wrapper:readinessBuffer()callsRocksTransactionLogStore.getUserSharedBuffer, which delegates straight to the rocksdb-js store (resources/RocksTransactionLogStore.ts:614-615).lock()→ emptysave()→set()drop. Unreachable: bothTable.lock()entry points throwClientError('Record locks are not supported on LMDB', 501)when the primary store is not aRocksDatabase(resources/Table.ts:2602andresources/Table.ts:2628), and every#reloadLockedcall site — the only thing that sets#lockWritable— sits behind one of them. On RocksDB the same sequence is unchanged by this PR:DatabaseTransaction.save()already validates inline (resources/DatabaseTransaction.ts:1199-1206), so the write was resolved against an undefined#changesbefore and after.The remaining comment-only suggestions are left where they record cross-file invariants of this temporary workaround.
Verification
terminate()→resetDatabases()), Node 22: aborted before, survives after.Merge 68f73588065b into bba808e749c4(this head againstmain's current tip) and passed Unit Test on Node 22, 24 and 26. On Node 22 the chain reachedtest:unit:resources— the leg carryingvectorIndexPlane.test.jsand the derived suites, and the one that used to abort — with 2491 passing, 33 pending, thentest:unit:lmdb(1942 passing) andtest:unit:bin(277 passing). Integration, Windows, Bun and uWS legs are green too.vectorIndexPlane.test.js+ all derived suites on Node 22.23.1 locally: 102 passing, 1 pending (was: abort after "rebuilds after a whole-table snapshot reload marker"). Same on Node 24.18.0.vectorIndexPlane.test.js, Node 26.2.0: 105 passing, 1 pending. Full RocksDB resources: 2491 passing, 33 pending. Full LMDB resources: 1942 passing, 377 pending. API tests: 201 passing, 1 pending. Windows gate: 3736 passing across all nine groups.npm run build, full Prettier, and lint on every changed file pass. Repository-wide lint still reports 15 pre-existing warnings outside the compatibility repair; the full aggregate unit command also exposes two environment-only baselines (dispatch-injectedGIT_*variables and this worktree's 151-byte physical path). The isolated git-credentials test passes with those injected variables removed.68f735880: Claude + Gemini + Harper domain, two full-review passes, independent coverage, Human-Review-Need 3.dist/and the suite re-run — so none of these tests pass vacuously: the self-notify skip, the peer handoff, the idle-rotation guard, the dead-owner retry timer and the throwing-attempt backoff.Diff guide
Refs #2430, #2567.
🤖 Generated with Claude Code
Review-Coverage: authored=codex; ran=gemini,claude; adjudicated=domain; declined=cursor-grok,cursor-composer; rounds=5; full=2 @ 68f7358
Human-Review-Need: 3 (decisions: lock-handoff-workaround, lazy-generation-binding, temporary-retry-option, handoff-proof-boundary, computed-error-precedence) @ 68f7358