Repository navigation
Cluster record locks: operator-agreed home map transport and successor-freshness barriers (harper-pro#825, harper#2542 inside #822) - #822
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements cluster-wide record locks over the replication stream. It introduces a protocol capability registry to negotiate features (such as record locks) between nodes, assigns database lock coordination to specific worker threads, and enhances cluster status reporting with record lock metrics. Comprehensive integration and unit tests are added to verify the locking behavior, capability negotiation, and connection metadata. Feedback on the changes suggests simplifying the condition in recordLockConfig.ts that checks for non-boolean truthy configuration values to improve readability.
|
Cost baseline for the enablement gate (measurement only; no protocol or core change,
One thing the harness found on the way, recorded in the baseline's caveat and left for core (harper#2498): — Claude Fable 5.1 |
…ol (static epoch)
harper#2498 replaced Ricart-Agrawala with amortized per-record ownership, and its
ClusterLockTransport contract changed with it: core now needs epoch(), requestDelegation()
and recallDelegation(), and registerClusterLockTransport throws on a transport without
them. Against that core this branch's transport did not register at all. This is the
harper-pro half, pinned to core 1deac506d.
epoch() is STATIC in this tranche - number 1, never advanced, not agreed. Members are the
database's replication group filtered to peers that advertised the delegation level of
recordLocks, plus this node, sorted; ringVersion hashes the sorted list so two nodes with
the same set agree without a deep compare. That is the design note's section 9 "static
owner" step: enough for one arbiter per key, not enough for section 4. What a static epoch
cannot do is advance across a restart to invalidate a previous incarnation's delegations,
so core's obligation on ClusterLockTransport.epoch is met the blunt way: epoch() returns
undefined for DELEGATION_LEASE_MS + LOCK_LEASE_SKEW_MS after process start, which blocks
every cluster lock on this node for that window. That is the cost of a static epoch, and
harper-pro#825 removes it. HARPER_TEST_RECORD_LOCK_RESTART_HOLD_MS lifts it for tests.
homeIncarnation is durable and monotonic - core orders fencing tokens on it, and a random
value is identifiable but not orderable. The main thread bumps recordLockIncarnation on
this node's own hdb_nodes row once per process start (merged via ensureNode); workers read
the mirror, and epoch() withholds while it still reads 0.
Request and recall are two registered operations, record_lock_delegate and
record_lock_recall (recordLockRpc.ts), sent over this worker's live outbound subscription
session to the home when it has one - its inbound end is on the home's coordinating
worker, so the request lands where the coordinator lives - and over sendOperationToNode
otherwise. An operation that arrives on a non-owner thread is relayed through main, which
mints its own hop id (worker-minted ids collide across workers), under a 5 s bound; a
timed-out relay answers not-home, never a grant. The requester is the authenticated node
principal of the connection, never the payload; a caller that is not a known node gets
403, so a super_user cannot mint or clear a delegation through the operations API.
The recordLocks capability is now level 2 and mutually exclusive: peerSupportsRecordLocks
requires the level exactly. Level 1 was Ricart-Agrawala and never shipped enabled; a peer
still advertising it is a different arbiter, not a slower one.
cluster_status.recordLocks reports { delegations, granted, admitted, droppedOffOwner,
members } per database; members is the epoch as the owner sees it, or absent while it is
withheld.
Sync-Core cost carried by the pointer bump, stated so it is not mistaken for a lock
change: four AuditRecord.localTime reads in replicationConnection.ts follow core's rename
to txnLogKey. The branch already pinned @harperfast/rocksdb-js 2.8.0, which core now
hard-requires at load (RecordEncoder throws below it); a checkout installed before that
pin has to reinstall before any core import loads.
The crash-recovery integration case is skipped with its reason: a crashed delegate holds
its keys for up to DELEGATION_LEASE_MS (six minutes), which does not fit a test, and
whether that lease is configurable is an open question on harper#2498. The property it
covered - a home never re-grants before the delegate's deadline plus skew, on independent
clocks - is asserted in core's coordinator suite.
Two defects the first cluster run caught, both now covered by tests that fail without
the fix: the transport read its home incarnation from server.nodes, which excludes the
local node on every path, so epoch() was withheld for the life of the process
(readOwnIncarnation reads the own hdb_nodes row); and a node whose bag was suppressed
still built a ring including itself while every peer excluded it - two arbiters for one
key. epoch() now withholds unless the bag this node actually sends claims the level. The
home-side half of that guard (refuse a requester outside the member set) is filed on
harper#2541 rather than reopened in harper#2498 mid-review.
The first pre-push round (full coverage: codex, gemini, cursor-grok, domain) returned BLOCK.
Its design-level finding stands and is put to the human on the PR: with a static epoch and
locally derived membership, two nodes can hold different rings for one key during a
membership transition and each self-home it - two arbiters - and nothing short of the
agreed epoch (harper-pro#825) closes that. Its concrete findings are fixed here, each
with a test where one applies: principalNodeName trusted a payload-supplied `user.name`
as a fallback (now hdb_user only); the main-thread rpc handler was unguarded; resolveLevel
min-clamped recordLocks so a future level-3 peer resolved to 2 and passed the equality
gate (now an exact, unclamped level); epoch() rebuilt the ring on every acquisition (now
memoized for 250 ms on the injected clock); a node that never joined a mesh had no self
row so the incarnation bump spun forever and every cluster lock 503'd for the life of
the process (the counter now goes on a LOCAL_ONLY self row); the bench omitted the
restart-hold override; executeRecall reported success after a timed-out relay (now a
503); the status-view cache was keyed on `auditStore && peer`; and three DESIGN.md
statements described the previous protocol.
Round 2 was degraded (Codex and the domain leg timed out on this box), but Gemini's two
majors were real and are fixed: the recall acknowledgement compared object identity across
a postMessage structured clone, so every relayed recall would have 503'd (structural check
now); and a worker that read its home incarnation from the table before main's bump landed
would cache the previous process's value for the life of the process. Workers now never
read the table: main broadcasts the bumped value (record-lock-incarnation) the way it
confers ownership, a late-registering worker asks for it, and the worker-side setter never
moves backwards (unit test). The 5830 audit-key fallback chain also matches its sibling.
Verification: 80 unit tests (recordLockTransport + protocolCapabilities); the 3-node
cluster integration suite 7 passing, 1 skipped as above - delegation request over the
live subscription session, recall handover, 24 concurrent increments landing exactly 24
on every node, ten repeat locks writing no release entries, the LWW/409 fence, and the
bag-less peer excluded from the ring and failing its own cluster lock closed with 503.
Typecheck: 31 errors, all pre-existing environment drift (harper-pro main has 32); none
in the changed files.
Refs #438, #822, #824, #825, HarperFast/harper#483, HarperFast/harper#2498,
HarperFast/harper#2541, HarperFast/harper#2542
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6aY2ERiYM8294P2f3aoUq
|
Decided: (a) land as gated-off scaffolding. The static epoch's two-rings-during-a-membership-transition hole stays documented here and in One merge-order note: this PR's — Claude Fable 5.1, recording Kris's ruling |
…ol (static epoch)
harper#2498 replaced Ricart-Agrawala with amortized per-record ownership, and its
ClusterLockTransport contract changed with it: core now needs epoch(), requestDelegation()
and recallDelegation(), and registerClusterLockTransport throws on a transport without
them. Against that core this branch's transport did not register at all. This is the
harper-pro half, pinned to core 1deac506d.
epoch() is STATIC in this tranche - number 1, never advanced, not agreed. Members are the
database's replication group filtered to peers that advertised the delegation level of
recordLocks, plus this node, sorted; ringVersion hashes the sorted list so two nodes with
the same set agree without a deep compare. That is the design note's section 9 "static
owner" step: enough for one arbiter per key, not enough for section 4. What a static epoch
cannot do is advance across a restart to invalidate a previous incarnation's delegations,
so core's obligation on ClusterLockTransport.epoch is met the blunt way: epoch() returns
undefined for DELEGATION_LEASE_MS + LOCK_LEASE_SKEW_MS after process start, which blocks
every cluster lock on this node for that window. That is the cost of a static epoch, and
harper-pro#825 removes it. HARPER_TEST_RECORD_LOCK_RESTART_HOLD_MS lifts it for tests.
homeIncarnation is durable and monotonic - core orders fencing tokens on it, and a random
value is identifiable but not orderable. The main thread bumps recordLockIncarnation on
this node's own hdb_nodes row once per process start (merged via ensureNode); workers read
the mirror, and epoch() withholds while it still reads 0.
Request and recall are two registered operations, record_lock_delegate and
record_lock_recall (recordLockRpc.ts), sent over this worker's live outbound subscription
session to the home when it has one - its inbound end is on the home's coordinating
worker, so the request lands where the coordinator lives - and over sendOperationToNode
otherwise. An operation that arrives on a non-owner thread is relayed through main, which
mints its own hop id (worker-minted ids collide across workers), under a 5 s bound; a
timed-out relay answers not-home, never a grant. The requester is the authenticated node
principal of the connection, never the payload; a caller that is not a known node gets
403, so a super_user cannot mint or clear a delegation through the operations API.
The recordLocks capability is now level 2 and mutually exclusive: peerSupportsRecordLocks
requires the level exactly. Level 1 was Ricart-Agrawala and never shipped enabled; a peer
still advertising it is a different arbiter, not a slower one.
cluster_status.recordLocks reports { delegations, granted, admitted, droppedOffOwner,
members } per database; members is the epoch as the owner sees it, or absent while it is
withheld.
Sync-Core cost carried by the pointer bump, stated so it is not mistaken for a lock
change: four AuditRecord.localTime reads in replicationConnection.ts follow core's rename
to txnLogKey. The branch already pinned @harperfast/rocksdb-js 2.8.0, which core now
hard-requires at load (RecordEncoder throws below it); a checkout installed before that
pin has to reinstall before any core import loads.
The crash-recovery integration case is skipped with its reason: a crashed delegate holds
its keys for up to DELEGATION_LEASE_MS (six minutes), which does not fit a test, and
whether that lease is configurable is an open question on harper#2498. The property it
covered - a home never re-grants before the delegate's deadline plus skew, on independent
clocks - is asserted in core's coordinator suite.
Two defects the first cluster run caught, both now covered by tests that fail without
the fix: the transport read its home incarnation from server.nodes, which excludes the
local node on every path, so epoch() was withheld for the life of the process
(readOwnIncarnation reads the own hdb_nodes row); and a node whose bag was suppressed
still built a ring including itself while every peer excluded it - two arbiters for one
key. epoch() now withholds unless the bag this node actually sends claims the level. The
home-side half of that guard (refuse a requester outside the member set) is filed on
harper#2541 rather than reopened in harper#2498 mid-review.
The first pre-push round (full coverage: codex, gemini, cursor-grok, domain) returned BLOCK.
Its design-level finding stands and is put to the human on the PR: with a static epoch and
locally derived membership, two nodes can hold different rings for one key during a
membership transition and each self-home it - two arbiters - and nothing short of the
agreed epoch (harper-pro#825) closes that. Its concrete findings are fixed here, each
with a test where one applies: principalNodeName trusted a payload-supplied `user.name`
as a fallback (now hdb_user only); the main-thread rpc handler was unguarded; resolveLevel
min-clamped recordLocks so a future level-3 peer resolved to 2 and passed the equality
gate (now an exact, unclamped level); epoch() rebuilt the ring on every acquisition (now
memoized for 250 ms on the injected clock); a node that never joined a mesh had no self
row so the incarnation bump spun forever and every cluster lock 503'd for the life of
the process (the counter now goes on a LOCAL_ONLY self row); the bench omitted the
restart-hold override; executeRecall reported success after a timed-out relay (now a
503); the status-view cache was keyed on `auditStore && peer`; and three DESIGN.md
statements described the previous protocol.
Round 2 was degraded (Codex and the domain leg timed out on this box), but Gemini's two
majors were real and are fixed: the recall acknowledgement compared object identity across
a postMessage structured clone, so every relayed recall would have 503'd (structural check
now); and a worker that read its home incarnation from the table before main's bump landed
would cache the previous process's value for the life of the process. Workers now never
read the table: main broadcasts the bumped value (record-lock-incarnation) the way it
confers ownership, a late-registering worker asks for it, and the worker-side setter never
moves backwards (unit test). The 5830 audit-key fallback chain also matches its sibling.
Verification: 80 unit tests (recordLockTransport + protocolCapabilities); the 3-node
cluster integration suite 7 passing, 1 skipped as above - delegation request over the
live subscription session, recall handover, 24 concurrent increments landing exactly 24
on every node, ten repeat locks writing no release entries, the LWW/409 fence, and the
bag-less peer excluded from the ring and failing its own cluster lock closed with 503.
Typecheck: 31 errors, all pre-existing environment drift (harper-pro main has 32); none
in the changed files.
Refs #438, #822, #824, #825, HarperFast/harper#483, HarperFast/harper#2498,
HarperFast/harper#2541, HarperFast/harper#2542
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6aY2ERiYM8294P2f3aoUq
4cd0b9d to
dd0a6ba
Compare
…e handoff gaps it exposes The AFTER half of the §10 measurement gate (harper-pro#824), against the Ricart-Agrawala baseline in RECORD_LOCK_COST_BASELINE.md. Three full runs plus a fourth past the lock timeout; raw JSON under replication/record-lock-cost-runs. Where §10's predictions hold, they hold clearly: a first lock splits into 0.51 ms with the home elsewhere and 0.05 ms when this node homes the key, repeat locks collapse from 0.68 ms to 0.01-0.02 ms, and control entries per uncontended acquisition go from 4 cluster-wide to zero. Two results do not fit the task's stated expectations, and both are about the handoff: - The counter no longer converges exactly. Auditing every written value shows the shortfall is entirely duplicate values written by two different nodes, with no holes and no failed requests - a successor reading a predecessor's unreplicated commit. That is the disclosed position: recordLockCoordinator.ts:43 states the §7 freshness fence is unimplemented (harper#2542), and §14 adds that §6 step 3 settlement is too. 0.03-0.12% of sections at three contenders. - A contended key is monopolized rather than shared. At two contenders the losing node completed one section in fifteen seconds in all three runs, and past the 30 s lock timeout it fails with 423. The bench therefore records convergence instead of asserting it: an assertion here would fail every run while testing a guarantee this phase deliberately does not offer, and the bench measures rather than gates. core moves to the current harper#2498 head. #822's pin was left unreachable by a force-push of that branch and is four commits behind, two of which change grant and delegation holding. Refs #824 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWmasaL9vLBSSKfGnG81kj
Temporary: re-bump to the merge commit once harper#2498 lands on main. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXhaW9MrWxeZr5HXysyFBM
…ordinator (harper-pro#438, W9 Phase 1) Core (harper#2498) owns the Ricart-Agrawala protocol, writes its control entries to the table's own transaction log and applies received ones from its replicated-event sink, in order with the data of the batch. This adds what core cannot know: - recordLocks capability level in the protocol registry, advertised only while replication.recordLocks is on; the send path skips lock control entries to a peer that has not advertised it (the registry's first gated frame). - The participant set: every member of the database's replication group (explicit subscriptions included, direction ignored), each with the capability its own NODE_NAME bag asserted, kept in slot 13 of the per-(database, peer) shared status buffer; never learned reads as not capable. - Per-database coordination ownership conferred by the main thread, moved only after the owner worker exits, with every subscription for the database placed on that worker while the feature is on so the coordinator applies the database's inbound entries. - replication.recordLocks (default off): placement unchanged when off, and a cluster-scoped lock() on a replicated database fails closed naming the switch. - cluster_status.recordLocks per database, with a correlated request/response so overlapping status calls cannot strand each other. Depends on harper#2498 (core pinned to its head) and harper-pro#813 (merged into this branch). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vcn5gxtbSvWXLGk4ZWFRNf
…elper body reads env.get resolves only keys registered in core's CONFIG_PARAM_MAP, so the harper-pro-only replication.recordLocks switch read as undefined and the feature never armed; read it from getConfigObj() instead (no core change). The cluster test's counter/controlEntries helpers consumed the response body in an assertion message and then again via json(), and a received control entry is applied to the coordinator rather than persisted in the receiver's log, so the bag-less-peer test now asserts only the grant this node wrote. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vcn5gxtbSvWXLGk4ZWFRNf
- recordLockCluster.test.mjs: start nodes with allSettled so one failed start does not orphan the nodes that came up; thread the wait deadline's abort signal into the mesh probe. - recordLockConfig.ts: warn when replication.recordLocks is a truthy non-boolean (a YAML 1, a quoted "true"), which leaves the node fail-closed, so an operator is not left believing the switch is on. - knownNodes.ts: record that shared-status slot 13 now holds the record-lock capability so a future slot taker does not overwrite it. - Drop one reviewer-addressing comment. The blob-gap/analytics/schema-merge findings the review surfaced are on code inherited through this branch's base (harper-pro#432), not this change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vcn5gxtbSvWXLGk4ZWFRNf
knownNodes -> replicator -> recordLockTransport is an import cycle; assigning recordLockTransport's downSinceReader while that module is mid-evaluation hit its temporal dead zone under the unit-test import order (adding the config module's imports shifted evaluation order enough to expose it). start() runs after every module has loaded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vcn5gxtbSvWXLGk4ZWFRNf
`npm run bench:record-locks` (integrationTests/cluster/recordLockCost.bench.mjs, not part of test:integration:cluster) boots the same 3-node mesh as recordLockCluster.test.mjs and measures uncontended and repeat-lock acquisition latency, hot-key handoff throughput with 2 and 3 contending nodes, control entries and bytes per acquisition from each node's transaction log, and unlocked write throughput with the feature off, unregistered, and on. Timing is in-process (fixture-record-lock-bench). replication/RECORD_LOCK_COST_BASELINE.md records one run with its distributions, sample counts, machine class, and which figures are noisy. No change to the lock protocol; core is not moved. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013sPwr2JJbAcbHxnhoNr5qQ
… distributions, release boundary The hot-key section time was the client's wall clock around fetch; LockedIncrement now reports the node's own lock and lock-through-save times, and the bench pools them across contenders beside the per-node distributions and the client round trip. Log-cost ratios divide by rounds started, so a timed-out round's request and withdraw cannot inflate them; the after-snapshot waits until every started round's release is in its node's log; the convergence probe threads the wait's signal. Baseline re-recorded from a run on the updated harness. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013sPwr2JJbAcbHxnhoNr5qQ
…erhead, not HTTP alone Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013sPwr2JJbAcbHxnhoNr5qQ
…ol (static epoch)
harper#2498 replaced Ricart-Agrawala with amortized per-record ownership, and its
ClusterLockTransport contract changed with it: core now needs epoch(), requestDelegation()
and recallDelegation(), and registerClusterLockTransport throws on a transport without
them. Against that core this branch's transport did not register at all. This is the
harper-pro half, pinned to core 1deac506d.
epoch() is STATIC in this tranche - number 1, never advanced, not agreed. Members are the
database's replication group filtered to peers that advertised the delegation level of
recordLocks, plus this node, sorted; ringVersion hashes the sorted list so two nodes with
the same set agree without a deep compare. That is the design note's section 9 "static
owner" step: enough for one arbiter per key, not enough for section 4. What a static epoch
cannot do is advance across a restart to invalidate a previous incarnation's delegations,
so core's obligation on ClusterLockTransport.epoch is met the blunt way: epoch() returns
undefined for DELEGATION_LEASE_MS + LOCK_LEASE_SKEW_MS after process start, which blocks
every cluster lock on this node for that window. That is the cost of a static epoch, and
harper-pro#825 removes it. HARPER_TEST_RECORD_LOCK_RESTART_HOLD_MS lifts it for tests.
homeIncarnation is durable and monotonic - core orders fencing tokens on it, and a random
value is identifiable but not orderable. The main thread bumps recordLockIncarnation on
this node's own hdb_nodes row once per process start (merged via ensureNode); workers read
the mirror, and epoch() withholds while it still reads 0.
Request and recall are two registered operations, record_lock_delegate and
record_lock_recall (recordLockRpc.ts), sent over this worker's live outbound subscription
session to the home when it has one - its inbound end is on the home's coordinating
worker, so the request lands where the coordinator lives - and over sendOperationToNode
otherwise. An operation that arrives on a non-owner thread is relayed through main, which
mints its own hop id (worker-minted ids collide across workers), under a 5 s bound; a
timed-out relay answers not-home, never a grant. The requester is the authenticated node
principal of the connection, never the payload; a caller that is not a known node gets
403, so a super_user cannot mint or clear a delegation through the operations API.
The recordLocks capability is now level 2 and mutually exclusive: peerSupportsRecordLocks
requires the level exactly. Level 1 was Ricart-Agrawala and never shipped enabled; a peer
still advertising it is a different arbiter, not a slower one.
cluster_status.recordLocks reports { delegations, granted, admitted, droppedOffOwner,
members } per database; members is the epoch as the owner sees it, or absent while it is
withheld.
Sync-Core cost carried by the pointer bump, stated so it is not mistaken for a lock
change: four AuditRecord.localTime reads in replicationConnection.ts follow core's rename
to txnLogKey. The branch already pinned @harperfast/rocksdb-js 2.8.0, which core now
hard-requires at load (RecordEncoder throws below it); a checkout installed before that
pin has to reinstall before any core import loads.
The crash-recovery integration case is skipped with its reason: a crashed delegate holds
its keys for up to DELEGATION_LEASE_MS (six minutes), which does not fit a test, and
whether that lease is configurable is an open question on harper#2498. The property it
covered - a home never re-grants before the delegate's deadline plus skew, on independent
clocks - is asserted in core's coordinator suite.
Two defects the first cluster run caught, both now covered by tests that fail without
the fix: the transport read its home incarnation from server.nodes, which excludes the
local node on every path, so epoch() was withheld for the life of the process
(readOwnIncarnation reads the own hdb_nodes row); and a node whose bag was suppressed
still built a ring including itself while every peer excluded it - two arbiters for one
key. epoch() now withholds unless the bag this node actually sends claims the level. The
home-side half of that guard (refuse a requester outside the member set) is filed on
harper#2541 rather than reopened in harper#2498 mid-review.
The first pre-push round (full coverage: codex, gemini, cursor-grok, domain) returned BLOCK.
Its design-level finding stands and is put to the human on the PR: with a static epoch and
locally derived membership, two nodes can hold different rings for one key during a
membership transition and each self-home it - two arbiters - and nothing short of the
agreed epoch (harper-pro#825) closes that. Its concrete findings are fixed here, each
with a test where one applies: principalNodeName trusted a payload-supplied `user.name`
as a fallback (now hdb_user only); the main-thread rpc handler was unguarded; resolveLevel
min-clamped recordLocks so a future level-3 peer resolved to 2 and passed the equality
gate (now an exact, unclamped level); epoch() rebuilt the ring on every acquisition (now
memoized for 250 ms on the injected clock); a node that never joined a mesh had no self
row so the incarnation bump spun forever and every cluster lock 503'd for the life of
the process (the counter now goes on a LOCAL_ONLY self row); the bench omitted the
restart-hold override; executeRecall reported success after a timed-out relay (now a
503); the status-view cache was keyed on `auditStore && peer`; and three DESIGN.md
statements described the previous protocol.
Round 2 was degraded (Codex and the domain leg timed out on this box), but Gemini's two
majors were real and are fixed: the recall acknowledgement compared object identity across
a postMessage structured clone, so every relayed recall would have 503'd (structural check
now); and a worker that read its home incarnation from the table before main's bump landed
would cache the previous process's value for the life of the process. Workers now never
read the table: main broadcasts the bumped value (record-lock-incarnation) the way it
confers ownership, a late-registering worker asks for it, and the worker-side setter never
moves backwards (unit test). The 5830 audit-key fallback chain also matches its sibling.
Verification: 80 unit tests (recordLockTransport + protocolCapabilities); the 3-node
cluster integration suite 7 passing, 1 skipped as above - delegation request over the
live subscription session, recall handover, 24 concurrent increments landing exactly 24
on every node, ten repeat locks writing no release entries, the LWW/409 fence, and the
bag-less peer excluded from the ring and failing its own cluster lock closed with 503.
Typecheck: 31 errors, all pre-existing environment drift (harper-pro main has 32); none
in the changed files.
Refs #438, #822, #824, #825, HarperFast/harper#483, HarperFast/harper#2498,
HarperFast/harper#2541, HarperFast/harper#2542
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6aY2ERiYM8294P2f3aoUq
…ESIGN.md The epoch() paragraph still said the recordLocks capability is read from slot 13 (moved to 29 during the main rebase); homeIncarnation was described as something workers read off their own hdb_nodes row, when they only ever adopt what main pushes over record-lock-incarnation. Also note that recordLockIncarnation is written via ensureNode without being a declared table attribute, so the schema list right below doesn't omit it by mistake. Surfaced by the independent pre-push review (Cursor Grok + Harper domain adjudication) on 3729b81. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
getConfigObj() throws when no boot properties file exists yet. Every other getConfigObj() call site in the codebase defers the call into a function body for exactly this reason; recordLockConfig.ts read it as a module-scoped constant at import time, so a bare mocha process (no harperdb boot, unlike CI's own server-driven tests) crashed the whole unit-test run the moment anything imported replicator.ts. This branch's Unit Tests workflow never ran on GitHub Actions before this rebase (the PR was mergeable_state: dirty, so CI skipped it) and local runs on this box succeed only because an inherited HDB_ROOT happens to point at a real properties file (dispatch-session leakage), masking the crash. Catch the throw and treat it the same as "not configured": fail closed, matching this module's own stated default. Verified with `env -u HDB_ROOT npm run test:unit` (1094 passing) to reproduce a from-scratch environment with no boot properties file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Companion PR HarperFast/harper#2498 is open at 71d32bf6, per the dispatch's explicit companion-PR instruction. Not a rebase merge of "both sides" of the gitlink -- the instruction is to take neither side and set the pointer to this exact sha. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
69a8c4f to
2908e08
Compare
…e handoff gaps it exposes The AFTER half of the §10 measurement gate (harper-pro#824), against the Ricart-Agrawala baseline in RECORD_LOCK_COST_BASELINE.md. Three full runs plus a fourth past the lock timeout; raw JSON under replication/record-lock-cost-runs. Where §10's predictions hold, they hold clearly: a first lock splits into 0.51 ms with the home elsewhere and 0.05 ms when this node homes the key, repeat locks collapse from 0.68 ms to 0.01-0.02 ms, and control entries per uncontended acquisition go from 4 cluster-wide to zero. Two results do not fit the task's stated expectations, and both are about the handoff: - The counter no longer converges exactly. Auditing every written value shows the shortfall is entirely duplicate values written by two different nodes, with no holes and no failed requests - a successor reading a predecessor's unreplicated commit. That is the disclosed position: recordLockCoordinator.ts:43 states the §7 freshness fence is unimplemented (harper#2542), and §14 adds that §6 step 3 settlement is too. 0.03-0.12% of sections at three contenders. - A contended key is monopolized rather than shared. At two contenders the losing node completed one section in fifteen seconds in all three runs, and past the 30 s lock timeout it fails with 423. The bench therefore records convergence instead of asserting it: an assertion here would fail every run while testing a guarantee this phase deliberately does not offer, and the bench measures rather than gates. core moves to the current harper#2498 head. #822's pin was left unreachable by a force-push of that branch and is four commits behind, two of which change grant and delegation holding. Refs #824 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWmasaL9vLBSSKfGnG81kj
…ps it exposes (#837) * Measure record-lock cost under the delegation protocol, and record the handoff gaps it exposes The AFTER half of the §10 measurement gate (harper-pro#824), against the Ricart-Agrawala baseline in RECORD_LOCK_COST_BASELINE.md. Three full runs plus a fourth past the lock timeout; raw JSON under replication/record-lock-cost-runs. Where §10's predictions hold, they hold clearly: a first lock splits into 0.51 ms with the home elsewhere and 0.05 ms when this node homes the key, repeat locks collapse from 0.68 ms to 0.01-0.02 ms, and control entries per uncontended acquisition go from 4 cluster-wide to zero. Two results do not fit the task's stated expectations, and both are about the handoff: - The counter no longer converges exactly. Auditing every written value shows the shortfall is entirely duplicate values written by two different nodes, with no holes and no failed requests - a successor reading a predecessor's unreplicated commit. That is the disclosed position: recordLockCoordinator.ts:43 states the §7 freshness fence is unimplemented (harper#2542), and §14 adds that §6 step 3 settlement is too. 0.03-0.12% of sections at three contenders. - A contended key is monopolized rather than shared. At two contenders the losing node completed one section in fifteen seconds in all three runs, and past the 30 s lock timeout it fails with 423. The bench therefore records convergence instead of asserting it: an assertion here would fail every run while testing a guarantee this phase deliberately does not offer, and the bench measures rather than gates. core moves to the current harper#2498 head. #822's pin was left unreachable by a force-push of that branch and is four commits behind, two of which change grant and delegation holding. Refs #824 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWmasaL9vLBSSKfGnG81kj * Correct the starvation finding: the starved contender got zero sections, not one The pre-push review traced `lastN` in the committed runs and found the write-up had the order backwards. The loser's single recorded section carries the cluster's MAXIMUM written value, so it landed after the winner's loop ended and dropped the key - not during the contended window. In the 40 s run its first request had already failed with 423 at DEFAULT_LOCK_TIMEOUT_MS while the holder was still running. So over the contended window the starved contender completed zero critical sections and one user-visible failure. That is worse than what the document claimed, and the document now says it with the evidence. Two harness bugs found in the same round: - waitForAgreedCounter returned the agreed value straight to waitForCondition, which discards a falsy probe, so a round where every lock() answered 423 would settle on 0, be discarded, time out after 90 s and record agreedCounter: undefined for a cluster that did agree. - distribution([]) produced NaN percentiles that serialize as null. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWmasaL9vLBSSKfGnG81kj * Claim only what the audit measures, and guard two paths it could crash on The written-value audit shows two nodes computing n+1 from the same n. That rules out a lost commit, but it does not by itself separate a successor admitted before applying its predecessor's write from two nodes admitted at once - both produce the same signature, and telling them apart needs holder intervals this bench does not record. The document now says so and attributes the reading to core's own statement that the freshness fence is unimplemented, rather than to these numbers. Also: measurement 6 asserts it has a remote-home reference instead of dereferencing an absent one, and LockStats reports unavailable coordinator stats as such rather than as an empty object. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWmasaL9vLBSSKfGnG81kj * Clarify delegation benchmark conclusions Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Address the post-rebase review: fix a real audit bug, correct three factual overclaims - writtenValueAudit: track every node that has written a value, not just the first, so a node repeating its own write after another node already wrote it is no longer misattributed as a cross-node duplicate. Verified against all eight committed hot-key rounds: every one already had repeatedCount === repeatedAcrossNodes (no value was ever written by the same node twice), so this does not change any reported number. - Note the threshold/delta unit mismatch in measurement 6 (an absolute latency floor compared against a delta with the local lock already subtracted) and the ~500ms convergence-poll window's limits, without changing either's behavior blind (the currently-pinned core is incompatible with harper-pro's transport, so the cluster bench cannot actually be run right now to validate a behavior change - see below). - RECORD_LOCK_COST_DELEGATIONS.md: record the core sha the numbers were actually measured at (729aefd2) and disclose that the base's own further core re-pin (71d32bf6) removed `epoch()` in favor of `homeMap()`, which harper-pro's transport does not yet implement - the committed numbers are not currently re-runnable. Narrow the 120s-window "real rounds (0.35ms+)" claim: runs 2 and 3's off-window lapses (0.196-0.239ms) are far closer to their own local-reference noise than run 1's clean case. Replace the "abandoned instrument" paragraph with a home-per-round table derived from lockStats snapshots already in the committed JSON (no new instrumentation) - it settles most of the "why does the holder win" question. - DESIGN.md: the disabled-transport 503 claim is wrong (a plain Error, so 500) and the repeat-lock range was narrower than the document it now points to actually measured. - README.md: note that run 4's committed JSON has only one of the two expected measurement-6 entries. Cosmetic, from the same round: fix the quiet-poll count in a docblock (two vs three), drop a no-op multiplier constant, trim narrated history from two docblocks, remove an orphaned comment, and de-duplicate a restated comment in the fixture. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Trim two new comments to Harper's zero-narration default, record the harper-pro sha - Move the reacquisition threshold's absolute-vs-delta bias explanation into the results document's §6 (where the other measurement-6 methodology notes already live) instead of narrating it in code; same for the agreed-counter wait's convergence caveat. - Record the harper-pro sha the runs were measured at (4cd0b9d), not just core's; the prior commit only fixed the moving core reference. - Drop the "dispatch task" tracker reference in the document - that addresses this session's tooling, not a reader of the PR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Fix the §6 undercount rationale: reclassification is offline, not a re-run The prior commit said fixing the threshold would need the bench re-run against the now-unstartable core pin. Wrong: every tick's deltaMs is already in the committed JSON, so reclassifying is an offline check. Verified that check against run 1's 300s row - the naive fix (floor minus local reference) pulls two known-local ticks across the cut as false lapses (neither on a window multiple, both inside that row's own local-reference range) - so the real obstacle is that the floor needs a better basis than a lower number, not that it can't be checked without a cluster. Also trims a comment that narrated the fix it sits next to. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: GPT-5 Codex <noreply@openai.com>
… from an explicit node list (#863) * Design note: record_lock_apply_homes, one call to apply a home map cluster-wide Refs #862 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq Dispatch-Task: harper-pro-862-apply-homes * Add record_lock_apply_homes: one call applies a home map across the cluster Survey every node in the operator's explicit list, refuse before staging on an unreachable node, an unlisted ring member, a digest disagreement or a stage the node would reject; stage everywhere over a node-principal hop that re-validates locally; activate immediately when every node proves its drain, otherwise report per node with a relative wait for an attested second call that covers only what was already staged. The stage persists the quiesce set so a retry cannot lose the old ring once active is retracted. Refs #862 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq Dispatch-Task: harper-pro-862-apply-homes * Cluster test: judge an untouched node by its row, not by a lock a staged peer homes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq Dispatch-Task: harper-pro-862-apply-homes * Cluster test: open generation 2 explicitly before injecting the failure Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq Dispatch-Task: harper-pro-862-apply-homes * Apply review round 1: required quiesce, initiator row, staged-race recovery, hop cancellation - record_lock_stage_generation now requires quiesce and persists it; a matching re-stage backfills a row that lacks one, and the survey refuses a staged row with none, so a manually staged node cannot hide the ring it stopped serving. - The node taking the call reads its own row too when it is not in quiesce; a ring it serves that the list omits is refused like a peer's. - Only an active disagreement is fatal; two sets staged by racing operators are judged per node against the target, so an explicit higher generation proceeds. - Every hop's deadline retires the request on the wire: the live session drops its pending entry and sendOperationToNode closes its socket. - The per-database apply queue releases its entry when idle. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq Dispatch-Task: harper-pro-862-apply-homes * Apply review round 2: a stage must name every ring its row remembers; cancel the one-shot hop planStage now refuses a quiesce that omits a member of the active ring, the staged ring or the previous staged transition's participants, since the write erases them from the row and a later survey could not see the node still serving them. sendOperationToNode passes its timeout into the session so the socket closes when a peer accepts and never answers. DESIGN.md index updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq Dispatch-Task: harper-pro-862-apply-homes * DESIGN.md: four operations, not three Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq Dispatch-Task: harper-pro-862-apply-homes * Clear the operation timeout timer when the response arrives Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq Dispatch-Task: harper-pro-862-apply-homes --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… 1 (#865) * Cluster record locks: serve lock() on every worker at threads.count > 1 (#852) Cluster-scoped lock() only worked with one http worker: a request landing on a worker that does not coordinate the database answered 503, and the row that drives the operator-agreed home map was guarded per worker isolate. This makes the feature usable at the default worker count. - recordLockRpc.ts: relay a local lock() acquire/release to the coordinating worker over the worker-to-worker port mesh (main only broadcasts which thread owns each database). The admission crosses the boundary, not the handle; a recall on the owner fences the caller's handle over the mesh and the owner awaits that ack (or the handle's lease) before writing the release. Admissions are bound to an owner-session nonce and the harness-stamped origin thread, so a stale release after an ownership handoff cannot address another handle. Caller identity is the sender port, never a payload field; the messages are internal, never registered operations. - recordLockHomes.ts: withRow now takes a process-wide node-scoped lock on the hdb_record_lock_homes row and writes through that locked handle, so a stage racing a fence_external across workers can no longer restore a retracted generation, and a lease lost to a storage stall fails the write. - recordLockTransport.ts: wire acquireOnOwner/releaseOnOwner, broadcast the owner thread id to every worker, sum relayedAdmissions across workers (and main) in cluster_status, and remove the "run one http worker" warning. The restart-quarantine waiver (which lets a genuinely fresh node grant a key without waiting out a departed incarnation's lease) is cleared on the first handoff bump, so a successor coordinator built after ownership has already changed hands cannot grant while a departed worker's relayed handle can still commit. - A caller worker that EXITS during an ownerless handoff counts as fenced. The restart quarantine does not back that up (it is read only on the home's grant path, so a peer-homed key renews straight back here); the process-wide native key lock does, since lock() takes it before the cluster admission and both the departed worker and any new caller are on this node. The residual teardown question is tracked as HarperFast/rocksdb-js#865. Recorded at the call site and in replication/DESIGN.md. - Bump the core submodule to the matching harper change. Test: new threads.count: 3 integration suite proves a lock() served on a non-owner worker relays and succeeds, and concurrent increments stay exclusive; core unit tests cover the remote-admission lifecycle including fence-before-release; a transport unit test covers the waiver clearing on the first handoff bump. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Pin the relay handoff guards, and drop a retracted safety claim Review adjudication on #865. - `broadcastOwnerlessAndWait`'s JSDoc still credited the successor's restart quarantine for making a worker exit safe to treat as fenced. The inline comment in the same function, DESIGN.md and the PR ledger all retract that in favour of the process-wide native key lock; a later change that trusted the JSDoc would reopen the two-writer window believing the gate still covered it. - `recordLockRpc.ts` had no unit coverage at all, so the caller-side relay lifecycle is now pinned: an in-flight acquire fails retryably when the coordinating thread goes away, a grant that lands after that is handed back to the thread that minted it, and a release carries the session its admission was minted under. Both guards were verified to fail without the code that provides them. Naming the ACQUIRE_REPLY handler is what lets a test deliver one. - The existing `owner-c` assertion depended on the round-robin counter starting at zero, which only held while this file was the first to assign an owner; it now asserts the invariant it is named for. - `key` is `unknown` throughout harper-pro's relay signatures, matching `recordLockRpc.ts` and `establishLockFreshness` in the same file. Dispatch-Task: fix-kriszyp_harper-pro_865-1f3b384a Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Do not confer record lock ownership on a successor that exited Pre-push review finding on the fence wait this PR adds. `recordLockOwnerFor` picks the successor before awaiting the incarnation bump and `broadcastOwnerlessAndWait`, which runs as long as OWNER_FENCE_ACK_TIMEOUT_MS and resolves a worker's own exit as a completed fence. So the wait can resolve *because* the successor died, and `assignOwner` then confers on it. Nothing recovers from that: `watchOwnerExit` attaches its listener inside `assignOwner`, after the exit event it needs has already fired, so the entry is never cleared and every relayed `lock()` for the database is routed to a dead thread until the process restarts. Fail closed into the existing retry instead, which re-derives over a fresh live set once the worker is back. Before this PR the same window existed but spanned only the durable bump; the fence wait widened it to ten seconds and made the successor's own death one of the ways it completes. Dispatch-Task: fix-kriszyp_harper-pro_865-1f3b384a Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Check the successor by its captured thread id, not its post-exit one Round-2 pre-push review, confirmed here on Node v26.2.0: a Worker reports `threadId` -1 from before its `exit` listener runs, so the previous check asked the thread tombstone about -1, never matched, and still conferred ownership on the dead successor. `manageThreads.addPort` captures the id for the same reason. The unit test masked it — its fake worker kept its id after exiting. It now models a real Worker (tombstone keyed by the live id, `threadId` already -1) and fails against the previous check. Also corrects the `recordLocks` capability level in DESIGN.md, which still documented 3 while `protocolCapabilities.ts` advertises 4. Dispatch-Task: fix-kriszyp_harper-pro_865-1f3b384a Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * The caller relay's exit is not a fence; say so where the claim was made cb1kenobi and the review bot both flagged the header comment added in 73fc424: it says the owner writes the delegation release when "this worker exits", which the same file contradicts twice. `revokeRemoteHandle` settles only on the caller's REVOKE_ACK or the handle's lease timer, and `onThreadExit` keeps a departed caller's admission to its lease precisely so a write already handed to the engine cannot be overtaken. The borrowed native-key-lock justification does not reach this path either: the next holder after a recall is a PEER node, so a process-wide key lock on this node proves nothing. Exit-counts-as-fenced belongs only to main's ownerless handoff, where both threads are on this node — the sibling claim dropped from `broadcastOwnerlessAndWait` earlier in this branch. Dispatch-Task: fix-kriszyp_harper-pro_865-1f3b384a Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Prove withRow serializes on the row lock, not on its per-isolate queue The review bot's remaining thread was right that nothing exercised the property `withRow` was changed for: `recordLockHomes.test.mjs` scopes itself to pure decision logic, and the threads.count: 3 cluster suite only drives the relay. It asked for an integration case racing stage/fence/activate across workers, which is not what this proves -- that race is probabilistic, and a test that cannot be shown to fail against the old code is not coverage. The discriminating fact IS testable in one isolate: hold the same node-scoped lock on the `hdb_record_lock_homes` row that `withRow` takes, from outside `withRow`'s own queue, and a stage must wait for it. Verified to fail against the pre-#852 implementation (the per-isolate promise queue with a `transaction()` write), where the stage runs straight through and settles while the row is held. That the lock ALSO excludes across threads is core's property; one isolate cannot demonstrate it, and the test does not claim to. The end-to-end multi-worker race remains a follow-up, alongside the operator operations' integration coverage generally. Dispatch-Task: fix-kriszyp_harper-pro_865-c2d9fa46 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Keep the handoff's two claims honest: the fence ack, and the attempt it belongs to Three things the pre-push review found, all on the ownership handoff path. A worker that could not fence one of its tables still acked the fence to main. The ack meant "every relayed handle here is dead", main confers the successor on it, and the successor may grant a key whose old handle can still commit. `fenceRelayedAdmissionsForDatabase` now answers whether every table fenced, and the worker withholds the ack when one did not, so main's wait times out and the handoff fails closed -- which is the behaviour the gate was already built for. Main's own fence is held to the same rule inside `broadcastOwnerlessAndWait`. Nothing reachable throws there today (the resolver is a field read and core swallows a resolver throw before this code sees it), so this enforces the invariant the comment was arguing for rather than fixing a live defect. A handoff settling late could act on another attempt's state. PENDING_BUMP is not an identity: `releaseRecordLockOwner` clears it and the next attempt re-sets it, so a rejection arriving after a release deleted the NEW attempt's marker and scheduled a retry that re-assigned an owner to a database ownership had been given up on. Each attempt now carries a token, checked on both settlement paths, and a release bumps it. Covered by a test proven to fail without it. The relayed acquire reserved a flat 250ms of the caller's wait for the two thread hops, so `lock(id, { timeout: 200 })` -- or any lock that spent most of a longer timeout on the native key first -- reached the owner with `waitMs: 0` and failed on the first contention it met. Off-owner only, which is the uniformity the relay exists to provide. The margin is now capped at a quarter of the budget; too small a margin costs nothing new, since the caller's own timer settles it as the same retryable 503 and a late grant is handed back. Also: the row lock's lease comment claimed a sub-millisecond critical section while the `notifyChanged` fan-out runs inside the lock; the cluster suite header claimed a cross-node recall fences a relayed handle, which neither test forces; the transport fixture's ack comment understated that the live ack path has no coverage; and one comment described a warning that no longer exists. Dispatch-Task: fix-kriszyp_harper-pro_865-c2d9fa46 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Fence where the ack is given, and let a release cancel an armed retry Two holes cursor-grok found in the previous commit's own fixes. An owner's exit broadcasts ownerless TWICE: `watchOwnerExit` posts it with no request id, then `broadcastOwnerlessAndWait` posts it again carrying the ack request. By that second message the thread is already unowned, so the moved-owner check fenced nothing and the worker acked a fence that never ran -- masking a failure the first message hit, which is precisely the case the ack was just made conditional for. The handler now fences when the ack is requested, rather than reporting on whatever the owner update happened to do; the fence is idempotent, so the honest answer is the one from a fence that just ran. Main's own self-fence in `broadcastOwnerlessAndWait` is explicit for the same reason. A release could not cancel a retry that was already armed. `releaseRecordLockOwner` returned early when no owner was recorded, which is exactly the state a FAILED handoff leaves behind with its 10s retry pending; the timer then fired, saw "unowned but everHadOwner", and conferred an owner on a database ownership had been given up on. The attempt counter is now bumped before that early return, and the timer checks it. Neither is reachable from the unit harness: the worker handler runs only under `parentPort`, and main's message dispatch has no test entry point, so the fence ack has no unit coverage in either direction. Dispatch-Task: fix-kriszyp_harper-pro_865-c2d9fa46 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Cover the fence ack's live arm, the one a running worker actually uses The handoff gate's only coverage was the EXIT arm: `fakeWorker` resolved `broadcastOwnerlessAndWait` by firing `exit` on every fence request, so a regression that dropped the `record-lock-owner-thread-ack` route — leaving every handoff to time out — passed the suite. The PR called that structural, on the grounds that main's message dispatch has no test entry point. It has one: `recordLockRpc` already names and exports `handleAcquireReply` for exactly this, and the ack handler is the same shape. `handleOwnerThreadAck` is now named and exported, and a test drives the gate in both directions: a live worker that has not acked is not conferred on, and the ack is what releases the handoff. Proven against two mutations — removing the wait fails the first assertion, making the ack ignore its requestId fails the second. Proving the second one surfaced a fixture defect. `fakeWorker.once` was last-registration-wins, so the two overlapping handoff attempts in the superseded-handoff test registered `exit` on the same worker and the first listener was silently dropped, leaving its arm of the wait pending on the 10s timeout for the rest of the run. Listeners are a list now, as EventEmitter gives. The worker side of the gate — acking only after a COMPLETE fence — stays uncovered: that handler registers only under `parentPort`, and failing a fence needs a table registry the unit environment does not have. Refs #852. Dispatch-Task: fix-kriszyp_harper-pro_865-2c61b518 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Bind a fence ack to the thread that was asked to fence `handleOwnerThreadAck` resolved a handoff arm on the request id alone. Request ids are a plain sequence and any thread that can post to main reaches its own `parentPort`, so a guessed id released the gate for a worker that never fenced — main then conferred the successor over a still-committable relayed handle, which is the exact two-writer the gate exists to prevent. The acker's identity now comes from the port the harness stamped, which is the rule `recordLockRpc`'s acquire handler already states and follows; this handler was the one place the admission path took a caller's word for it. The pending entry carries the thread id captured while the worker is live, for the same reason the successor's id is captured before the wait. Raised by the round-6 planning review (finding 5). The test now also asserts a correct request id from the wrong thread does not settle the arm, proven to fail without the binding. Refs #852. Dispatch-Task: fix-kriszyp_harper-pro_865-2c61b518 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Bind a release and a revoke ack to the worker they belong to The same rule the acquire handler states — identity comes from the port the harness stamped, never a payload field — was unenforced at the two other owner-side handlers, which is where it matters most. A release carried only `OWNER_SESSION`, which is shared across every admission this owner minted, and admission ids are a plain sequence. Any thread that learned the session could drop a SIBLING's admission while that sibling's handle could still commit, admitting a peer node concurrently. `OwnerAdmission` already recorded the origin; the handler now requires it to match. A revoke ack was worse: it settles the fence core awaits before writing `lockRelease`, so an ack from anyone but the holder made the owner release while the real holder could still commit — the cross-node two-writer the fence exists to close. Pending revokes now carry the origin captured at acquire, not a late read of the port (a caller that has exited reports -1). Raised by the review bot on a5ebff3, which generalized the fence-ack finding to these two sites correctly. Neither has unit coverage, for the same structural reason the worker-side fence does not: reaching them needs the owner-side acquire path, which needs a table registry the unit environment does not have. Refs #852. Dispatch-Task: fix-kriszyp_harper-pro_865-2c61b518 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Pin both owner-side deny paths, which were not structural after all I called the two identity gates untestable because reaching them needs `acquireForRelay` to mint a real admission. The review bot pointed out that the coordinator can simply be stubbed, and it is right — the same correction I made to this PR's earlier "main's dispatch has no test entry point" claim. `handleAcquireRequest`, `handleRelease` and `handleRevokeAck` are named exports now, the way `handleAcquireReply` already was, and `acquireForRelay` is replaced with a stub that invokes the grant callback and hands back a synthetic round. No table registry, no storage. Two tests then assert what the fix is for: a sibling holding the shared `OWNER_SESSION` cannot drop another worker's admission, and a thread that does not hold the handle cannot settle the fence core awaits before writing `lockRelease`. Both proven to fail with either gate removed. The revoke id comes from the request the owner actually posted rather than a literal, since `nextRevokeId` is module-global and shared with earlier tests, and each test claims its own database — once one has had an owner, re-claiming it takes the handoff path and this thread would not coordinate it synchronously. Refs #852. Dispatch-Task: fix-kriszyp_harper-pro_865-2c61b518 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Anchor the stubbed round to now, so the fence has one way to settle `revokeRemoteHandle` derives the fence's fallback timer from `mintedMono + leaseMs - performance.now()`, and `performance.now()` is ms since process start — so the stub's `mintedMono: 0` armed that timer at 0ms as soon as the suite had run longer than `leaseMs`. That is a second way for the fence to settle, and not the one the wrong-thread assertion is testing. Raised by round 9. Its specific failure does not reproduce — from a promise continuation `setImmediate` runs in the current iteration's check phase, ahead of a 0ms timer in the next iteration's timers phase, and the test still passed with `leaseMs: 5` — but the dependency on process uptime is real and there is no reason for the test to carry it. Refs #852. Dispatch-Task: fix-kriszyp_harper-pro_865-2c61b518 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Bind an acquire reply to the thread the request was sent to `handleAcquireReply` stored `pending.ownerThreadId` but never consulted it, and authenticated the sender against `message.database` — a field the sender controls. Any worker holding a port to the caller could name a database it does legitimately coordinate, guess the sequential request id, and settle someone else's pending acquire with a round no cluster admission backs. This is the same invariant already stated in the acquire, release and revoke-ack handlers (identity comes from the port the harness stamped, never the payload), applied at the fourth site. The ownership check now reads the database off the pending request, and a reply from any thread other than the one the request addressed is handed back to its sender and leaves the acquire live to fail closed on its own timer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8 * Retire an owner's relayed-admission bookkeeping when it loses the database `ownerAdmissions` was collected on release, revoke ack, an undeliverable reply and a caller thread's exit, but not when the owner thread simply stops coordinating a database while still running. The callers have already fenced their handles and dropped their owner sessions by then (`clearRelaySessionsForDatabase`), so no release can ever arrive to collect those entries and they are retained for the life of the process, one per concurrently-relayed lock per ownership cycle. Forget them where this thread learns it lost ownership, on the same rule as the exit path: drop the bookkeeping, do not release — the coordinator's lease is what retires the admission itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8 * Deny a relayed acquire whose ownership was given up while it was minting `handleAcquireRequest` checked `ownsDatabase` before awaiting `acquireForRelay`, so a grant could be minted and handed out by a thread that stopped coordinating the database during the await — after the ownership-loss sweep had already run, re-inserting the entry it just retired, and with the successor's coordinator starting empty behind it. Re-check after the mint and dispose exactly as an undeliverable reply does: release the admission and answer the caller a retryable 503. Nothing was installed on the caller side, so the release is safe here in a way it is not on the exit path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8 * Tell one coordinator's grants from its successor's with a generation The post-mint ownership re-check was a boolean, so a lose->regain cycle across the await passed it: the round-robin can hand the database straight back to this thread, which builds a fresh coordinator with empty delegation state, and the grant minted under the old one is then replied with the same thread id and the same process-wide OWNER_SESSION. The caller's staleOwner check cannot discriminate those, so it installs a handle no live coordinator will revoke while the new coordinator is free to grant the same key elsewhere. Bump a per-database generation where this thread loses coordination, capture it at the start of the acquire, and compare after the mint. The test now drives the lose->regain cycle, which the boolean guard does not survive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8 * Deny an unstamped port at the caller-side gates, as every other one does `wrongSender` and `staleOwner` both short-circuited to false when the sender port carried no `threadId`, so an unauthenticated message fell through to install a LockRound. The `REVOKE_REQUEST` handler had the same shape and fenced a live handle on the same basis. `pending.ownerThreadId` is a non-optional number, so the `!== undefined` clause could never be the difference between a match and a mismatch — it only suppressed the deny. Every other identity check in this file already denies outright on an unstamped port (`handleAcquireRequest`, `handleRelease`, `handleRevokeAck`); these three now match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8 * Reject an unstamped revoke outright, not by a comparison that can match Folding the unstamped-port reject into the owner comparison left the revoke path half-guarded: during a handoff's ownerless window the expected owner is `undefined` too, so an unstamped port compared equal and the handler proceeded. That drives `revokeRelayedAdmission` for an arbitrary id, and because it latches a revoke that raced the handle's install, an admission the successor grants moments later is pre-fenced and its holder loses the lock under it. Explicit reject first, comparison second. The handler is a named export now so its gate has a test, the same reason `handleAcquireReply` is one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8 * Give the unstamped-revoke test a positive control The assertion was a negative against a stub installed on the CommonJS exports object, which would also pass if the stub were never the thing the handler calls. The same message from the stamped owner port now has to reach the fence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8 * Measure what the off-owner relay costs, and what shared state would replace The relay's cost is the coordinating worker's event loop, not the message: an off-owner lock() is 0.0164 ms with that worker idle and 1.008 ms at 1 ms of work per turn, which is worse than the 0.68 ms cluster round trip delegations were built to remove, for (N-1)/N of locks. notify() measures identical to postMessage both idle and under load, so it is not the fix. A local admission against a getUserSharedBuffer slot is 0.0002 ms and does not move with owner load. It stays a follow-up rather than a replacement: a handle is revoked after unlock() has already returned the native key, so the fence cannot use that key lock and the revoke/ack machinery survives in full. That same reading removes the narrowing ledger item 5 rested on, so probe it rather than infer it. A commit handed to the engine is NOT cancelled by its thread's termination (40 000/40 000 landed), but it keeps submission order (0/40 000 inversions against a successor writing the same key). What is left for rocksdb-js#865 is whether that ordering holds across threads, not whether an abandoned commit survives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdEmQwus4kj8YQVsJbWNAm * Stop the exit-as-fence comments giving an argument that does not hold Two of them were wrong and both were load-bearing. The restart quarantine is read only where this node is the key's home. The native key lock only stops two callers being inside a critical section at once: a caller that staged a write and then unlocked has already returned the key while its write can still commit, which is why revokeLease() fences capability rather than admission. So the window needs no teardown anomaly, and the comments describing one understate it. What holds is the engine's submission ordering, which the new probe measures. Say that instead, and narrow rocksdb-js#865 to whether the ordering is guaranteed across threads rather than whether an abandoned commit survives. Comments only; no behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdEmQwus4kj8YQVsJbWNAm --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
A matching re-stage ignores newly discovered participants, so an interrupted retry can forget an old-ring node and activate while it still grants. Widen or reject the stored quiesce set before treating the stage as idempotent.
—
Reviewed b67f797
…2667
Two conflicts, both mechanical:
- `core`: the branch pinned `cd56ca5f9`, the pre-merge head of harper#2667
("Record locks: relay a local lock() to the owner worker for threads.count > 1").
That PR squash-merged as `1023c7d85` and the pin was left on a commit that is on
no remote branch. Took main's pin, `72367d406` (harper main head), which contains
the squash plus the review revisions #2667 took before merging; no harper-pro
source change is needed for it (build clean).
- `replication/DESIGN.md`: main's #839 rewrote the clone-source-gate paragraph;
the branch side was the older text with no record-lock content. Took main's.
Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven unresolved threads, each verified against the current code before acting. Bounded the barrier RPC by the lock's own deadline (cb1kenobi, major). `sendOperation`/`sendOperationToNode` only retire a response waiter — and only close the per-call fallback socket — when they are given a `timeoutMs`, and the barrier call site passed none. The deadline sweep settled the WAIT, so a member that accepted the connection and never answered pinned a waiter (and a socket on the fallback path) for the life of that connection, one per failed handoff. `requestBarrier` now takes the remaining deadline and threads it through, floored at 1ms because the fallback path reads 0 as "no bound". Widened a recorded `quiesce` set on a matching re-stage (cb1kenobi, blocker as filed). A re-stage naming MORE of the participant set than the first call took the idempotent-noop path and left the narrower set durable. That set is the only record of the ring this node stopped serving, and both later coverage checks — `planStage`'s `uncovered` and `planSurvey`'s `unlisted` — read it, so an omitted node could go unnamed by every later survey while it still granted under the old generation. `planStage` now persists the union and never shrinks. Reachability is narrow (the first record has to have been truncated, which needs a row that lost its old ring), but the guard is a safety net and the fix is three lines. Made the one-barrier-per-wait contract real (kriszyp). `RECORD_LOCK_FRESHNESS_DESIGN.md` promised concurrent callers for one `(database, table, origin)` would share a request and a nonce; the implementation issues one per wait, so `BARRIER_BURST = 400` could refuse a legitimate wave of cold handoffs with a 429 that surfaces as a 503. Taking the reviewer's second option: safe joining is only possible before the request is dispatched, and dispatch is synchronous with registration, so a sound join window means delaying every cold handoff by an event-loop turn to save on bursts. The doc now states the implemented contract and why batching is left open, and `BARRIER_BURST` is derived from `MAX_OUTSTANDING_BARRIERS` — a peer is refused only past what it can even have in flight. Replaced the stale enablement warning (kriszyp). It still told operators a handoff loses updates pending harper#2542; level 4 establishes freshness before core admits. It now names what is actually still required: an applied home map per database, and every peer at the same level. Bootstrapped the home map in the cost bench (kriszyp). Nothing locks until a generation is staged and activated, so the three-node suite 503'd before measuring anything and the standalone `on` arm asserted an acquire it could not get. Both now bootstrap as `recordLockCluster.test.mjs` does; the pre-barrier and static-epoch comments are updated. Simplified the non-boolean config check (gemini-code-assist). The per-isolate `rowQueues` thread is already answered by the cross-isolate row lock in `withRow` (harper-pro#852, covered by `recordLockHomes.test.mjs`); no code change. Also in this commit: unit coverage for the request bound and the quiesce union. Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y pays Running the bench for the first time since the home-map bootstrap fix showed measurement 4 reporting zero log cost for every uncontended acquisition and, under contention, only `lockRelease`. `LOCK_ENTRY_TYPES` still named the level-1 `lockRequest`/`lockGrant` pair, which no longer exists, and omitted `lockBarrier`, which is the per-cold-handoff entry the successor-freshness fence writes. That understated the enablement cost the bench exists to measure, and let `logSnapshotAfter`'s quiet detector call a window finished while barriers were still landing. Measured after the fix, 3 contenders, 3s: 294 `lockBarrier` entries at 29 B each alongside 173 `lockRelease` at ~84 B; counters converged exactly on every run (`lostUpdates: 0`, no repeated written value). Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… node's own generation Two findings from the pre-push round that land inside this task's own delta. `HARPER_TEST_RECORD_LOCK_RESTART_HOLD_MS` is read by nothing in this tree or in the pinned core, and the comment I had just rewritten still claimed it lifted a hold. The waiver a fresh bench node actually gets is `isFirstIncarnation`'s, which needs no env var. Removed both. `record_lock_propose_homes`'s leaving-node warning told the operator a departing node "stays unable to lock until it is given its own generation". Following that advice is the two-arbiter bug §4.1 exists to prevent: a singleton generation on the leaver makes its own `homeMap()` home every key it is asked for, alongside the ring that just took those keys over — `homeMap()` never requires `self` to be in `homes`. The warning now says the leaver rejoins only through a later generation agreed and activated on every node, and names why its own is wrong. The test asserts that clause rather than the old phrase. The wider contradiction the round raised with it — the per-node runbook activating `homes(g) ∪ homes(g+1)` while `record_lock_apply_homes` skips a departing node — is a contract decision, not a wording one, and stays for the human reviewer. Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed; no blockers found. |
… leaver The design note said two different things about the same manual operation. §2 issued `record_lock_activate_generation` "on every node named in `homes(g) ∪ homes(g+1)`"; the `record_lock_apply_homes` section described the loop it replaced as activate on "every node in `homes(g+1)`", and the `record_lock_propose_homes` section told the operator to pass its list to both operations "on every node". The union belongs to the drain evidence — the stage responses the operator must collect before activating — not to the set being activated. All three now say the same thing: stage on `quiesce`, activate on `homes`. Round 20 had left the underlying question for the human reviewer as a contract decision. It is not one, and the deciding fact came out of this round's review. Activating a departing node is mechanically accepted — `planActivate` never looks at membership, `homeMap()` does not require `self ∈ homes` — so I first wrote it up as a usability trade: a leaver with the agreed `g+1` would route its own `lock()` to the new ring instead of answering 503. That is wrong. `record_lock_barrier` admits only callers in the *answering* node's home map (`executeBarrier`'s `isMember`), and a generation change puts the leaver's next acquire on the recovery path, which asks every member for a barrier (`establish` with `dependencies === null`). Both new members refuse it 403, so the lock still fails 503 — activation only moves the refusal from "no agreed home map" to a barrier the peers reject. There is no upside to trade, so §2 now says plainly not to send one, with the reason. The review comment that raised the contradiction also claimed an operator following §2 "always" gets refused, citing `recordLockApply.ts:510`. That guard belongs to `record_lock_transition`, the internal relay of `record_lock_apply_homes`, not to the manual operation §2 documents — the manual call succeeds. The conclusion is the same; the mechanism is not, and the note records the one that is real. Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review round 31 (codex, kept by the adjudicator). `awaitRelay` bounded every `record-lock-rpc` hop at a flat `RELAY_TIMEOUT_MS = 5s`, but one kind carries work with its own budget: a `quiesce` hop asks the owner worker to sweep for up to `STAGE_DRAIN_BUDGET_MS` (10s). A drain that legitimately took longer than 5s had its relay resolve the `undefined` fallback first, so `quiesceOnOwner` threw 503, `drainForStage` recorded the drain as unknown, and the operator fell back to the full `DELEGATION_DRAIN_MS` wait — the wait harper-pro#856's drain exists to avoid — on a drain that had in fact succeeded. Only reachable above one http worker, where the operation lands off the owner. `relayTimeoutFor` gives a `quiesce` hop its own `deadlineMs` plus slack, capped so an absurd budget cannot pin a relay entry, and leaves every other kind on the flat bound. Both hops use it: the worker→main relay and main's own forward to the owner. Also in this commit, from the same round: `DESIGN.md` called the home-map operations "Four" while listing five (`record_lock_propose_homes` was missing entirely), omitted that `stage` returns a `quiesced` drain result — the only thing that lets an operator skip the drain interval — and stated the relay bound as a flat 5s. Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nested relay timers (codex, minor, against the fix one commit earlier). Both `quiesce` hops got `deadlineMs + slack`, but they are nested rather than parallel: the caller's wait starts before main forwards, so an equal bound makes the OUTER one fire first by however long main took to route — answering the fallback while the owner's sweep is still inside its own budget. That is the same discarded drain the previous commit fixed, moved one hop out. Only the forward hop is sized to the sweep now; the caller's wait carries a second slack term, and the cap keeps that ordering rather than clamping both to the same number. `record_lock_propose_homes` told the operator to "compare digests across nodes before activating". The digest is taken over `(generation, homes)` and every node proposes its own floor plus one, so two nodes that agree on membership still differ whenever their floors do — following that advice reads a real agreement as a mismatch. The warning now says to compare `homes`, to pass the highest generation seen to every node, and that the digest they agree on is the one computed from that single list. `STAGE_DRAIN_BUDGET_MS` accepted zero and negative values: `Number.isFinite` admits both, and an empty environment variable reads as `0`. The failure is fail-closed, not unsafe — core's `withDeadline` rejects immediately at `ms <= 0`, so every delegation and grant lands in `outstanding` and the stage reports a drain it could not do — but it silently costs the operator the full `DELEGATION_DRAIN_MS` wait on every transition, which is exactly what harper-pro#856's drain exists to avoid. Any value at or below zero now takes the default. Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 33, against the previous commit's own fix: `> 0` alone still admits `Infinity`, which would give `stage` an unbounded sweep rather than the 10s reporting bound it advertises. Both guards now apply. Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ling `Number.isFinite` alone admits a negative, which would make `now - stagedAt < minDrainMs` never hold and silently retire the "activated implausibly soon after staging" backstop. `>= 0` rather than `> 0`, unlike `STAGE_DRAIN_BUDGET_MS`: zero is the value every cluster fixture sets, so that a freshly started node can stage and activate back to back. Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y bench's clock domain Round 35, both documentation. The `record_lock_propose_homes` warning was corrected in the previous round but its design-note paragraph still told a script to confirm agreement by comparing the returned digest — the exact advice that reads a valid list as a mismatch whenever two nodes' generation floors differ. The note now says what the code says. `transport.bench.mjs`'s simulated shared-state admission compares a shared `expiry` word against `performance.now()`, which is per-thread: every worker has its own `timeOrigin`. It is sound as a cost measurement inside one origin, and this bench is the evidence for the shared-slot fast path the relay writeup recommends as a follow-up — so it is exactly the code someone will copy. Labeled with what a production slot has to carry instead. Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… ruling on it "Auth for the hop" recorded the mechanism — a node principal relays, each node re-validates — and noted in passing that the replication dispatcher already bypasses `verifyPerms` for node identities. It never said the consequence out loud: a home-map transition is authorized by node identity ALONE, so any principal `principalNodeName` resolves can stage and then activate a map of its choosing on a peer, and the map is what decides which node arbitrates which key. The digest check narrows that rather than closing it, because `homeMap()` iterates its own `active.homes` and a node rewritten to a singleton never consults a peer. Ruled by the task owner on #822: accepted for this release within the existing node-trust model. The note now states the consequence, the three facts the ruling rests on (a node principal is already trusted to write replicated data everywhere; this relay does not widen the authority, since the per-node operations were already reachable that way before harper-pro#862 and the relay only adds re-validation; nothing shipped is exposed because the feature is off by default and inert until a generation is activated), and what would close it — an operator-delegated proof carried on the transition, filed as harper-pro#869 and a prerequisite for recommending the feature in production. Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu8MPiSKga5Cv4E4jQVhmk
…ed auth caveat The ruling on the node-principal transition relay was to accept it and document it. Documenting it only in `RECORD_LOCK_HOMES_DESIGN.md` is the weaker half: the note names an operator-delegated proof as a prerequisite for production use, and an operator who sets `replication.recordLocks: true` would never see that unless they read the markdown first. The startup warning right above already exists for exactly this reason — it says what else the switch needs before a lock succeeds. This adds the caveat as a second line rather than lengthening the first, because the two are different kinds of thing: one is a runbook step the operator is missing, the other is a known limitation they are accepting. Names harper-pro#869 so there is somewhere to go. Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu8MPiSKga5Cv4E4jQVhmk
Cluster record locks over the operator-agreed home map (harper-pro#825 inside #822), now with successor freshness: a handoff carries exclusion and proof that the successor has applied its predecessors' writes. Core's half is harper#2613 (lineage on the release entry), harper#2625 / PR harper#2627 (the
lockBarrierfence,writeLockBarrier,deadlineMs) and harper#2628 / PR harper#2630 (the awaited apply-failure listener) — all merged; this PR'scoresubmodule is pinned at harpermain1c312feb4. Default off, unchanged.Why the scope changed again
harper#2613 made
ClusterLockTransport.establishLockFreshness()required, so this branch stopped compiling against core. The transport half of §7 (docs/record-lock-ownership.md) is what this round adds. Six planning rounds (replication/RECORD_LOCK_FRESHNESS_DESIGN.md, revision history) each found a real hole in a numeric-watermark design; the mechanism that survived is the reviewer's own do-less from round 5: prove every unsatisfied cross-origin dependency with an exact, same-table, nonce-bearinglockBarrierentry and nothing else.What this round adds: successor freshness
replication/recordLockFreshness.ts— the barrier. For each inherited(origin, position): refuse outright a non-member, a peer at another capability level, an unreplicated table, a poisoned(origin, table), an invalid position, or this node's own lineage after a reclone; otherwise register a nonce, ask the origin for a barrier, and resolve only when the exact(origin, position, nonce)entry has committed here. Recovery (null) does the same for every home-map member and returns the pairs. Every wait is bounded bymin(deadlineMs, MAX_LOCK_LEASE_MS), capped per database, settled exactly once, and closed with the transport.replication/recordLockRpc.ts—record_lock_barrier: node principal → current member at the exact level → table replicates → nonce → per-caller token bucket → onewriteLockBarrierper request, never merged across callers.replication/replicationConnection.ts— alockBarrierrecord is captured at decode and reported from its frame'sonCommit(relayed to the coordinating thread when applied elsewhere); every record this node drops poisons(origin, table)durably before the drop completes, and a poison write that fails holds the frame; a clone attempt writes the ever-recloned flag before its first row; the peer's exact level is recorded at handshake.replication/recordLockPoison.ts— poison and the reclone flag in the database'sdbisstore; poison is permanent (a base copy is aputsnapshot and cannot re-deliver a dropped delete).replication/recordLockTransport.ts— both transports implement the method; slot 31 carries the exact level;cluster_status.recordLocksreports barrier counters and poisoned pairs; core's apply-failure listener (harper#2628) is registered per database and unregistered on release.replication.recordLocksresolves tofalseon LMDB with one error line.The home map (harper-pro#825): why that scope changed
harper#2498 merged with
ClusterLockTransport.epoch()replaced byhomeMap(database)returning an operator-agreedLockHomeMap { generation, homes[], homeIncarnation }— core's own design doc for it says the map is "supplied by harper-pro; core never computes it and never advances it." There is noepoch()shape left to build the old static-epoch scaffolding against, and the static epoch was itself the design's open blocker (two nodes independently deriving different rings for the same key — two arbiters). Rather than improvise an interim shape, this branch wentneeds-input; the task owner's answer was to build harper-pro#825's real mechanism now, inside this PR, trackingcoreat currentorigin/main.Because homes are now operator-agreed (never derived from
hdb_nodesmembership or liveness), the earlier "two rings" hazard is structurally gone: every node reads the same durably-staged/activated generation instead of computing its own view of the ring.What the home-map transport is
Core (merged,
origin/main) owns the coordinator — the home ring, the delegation table, recall-and-drain, fencing tokens, and the one control entry (lockRelease) still on the transaction log. This PR supplies everything core cannot know: the durable operator-agreed generation state, the wire, and the transport.hdb_record_lock_homes(replication/recordLockHomes.ts) — a dedicatedLOCAL_ONLYsystem table (getRecordLockHomesTable, never replicated or LWW-merged), one row per database holding{active, staged, highestActedOn, fenced[]}. Threesuper_user-gated operations transition it:record_lock_stage_generationatomically retractsactivein the same durable write that stagesg+1(so a successful response IS quiescence evidence, not a race against a later refresh),record_lock_fence_externalrecords an operator's external attestation that an unreachable node was stopped, andrecord_lock_activate_generationpromotesstaged → activeafter the operator has externally collected a stage response from every affected node and waitedDELEGATION_LEASE_MS + LOCK_LEASE_SKEW_MS— no node ever measures that elapsed time itself.digestOfis a length-prefixed SHA-256 over(generation, homes[]), not delimiter-joined (so['A','B']and['A\0B']can't collide) and not a truncatedgeneration >>> 0(so generation1and2**32+1can't collide). Same-database stage/fence/activate calls serialize behind an in-process queue (withRow) so a read this call's decision is based on can't go stale before the write.homeMap(database)(replication/recordLockTransport.ts) — a frozen, per-thread cache of this node's active generation, refreshed only on change, returningundefineduntil this node has an active generation AND every named peer's advertised digest agrees (a mismatch fails the whole map closed, never shrinks the ring). Three independent triggers rebuild the transport object so core gets a fresh lazily-built coordinator at the instant each becomes true: the first-ever known home incarnation, ownership newly conferred, and an active generation first appearing or changing — each is a distinct restart-quarantine waiver (grantableAfterMono) or fencing-seed gap in core's coordinator that only closes if the coordinator is built after the fact, not before.replication/replicationConnection.ts,replication/protocolCapabilities.ts) —recordLockscapability bumped to level3(the old static-epoch level2never shipped enabled); a newRECORD_LOCK_HOMES_DIGESTframe and a second shared-status-buffer slot (RECORD_LOCK_HOMES_AGREEMENT_POSITION) carry per-peer digest agreement, sent only after capability discovery so a pre-upgrade peer never receives a frame it hasn't advertised support for. Agreement is reconciled centrally by database (lastPeerDigest), independent of which of the mesh's separate per-direction connection objects last touched it.replication/recordLockTransport.ts) — a stage/activate response is real quiescence evidence only if every HTTP-worker thread (not just the durable write) has applied the change; a missing or negative acknowledgement now fails the operation (503) rather than being logged and treated as success, and the origin-to-main relay carries a longer budget than the leaf fan-out it waits on. An idempotent retry (thenoopdecision branch) still re-notifies, so a retry after a failed relay actually reconciles the gap it exists to close.requestDelegation/recallDelegation/ owner relay (replication/recordLockRpc.ts) — unchanged in shape from the prior tranche: the requester's identity is the authenticated node principal, never a payload field; a relay through main that times out answersnot-home, never a grant.ownsCoordination(), per-database owner-worker assignment,cluster_status.recordLocks— unchanged in role;recordLockOwnerFor's async-handoff/PENDING_BUMPbehavior andsetRecordLockOwnership's transport-recreate-on-first-ownership are both from the prior tranche.503, not a generic500(replication/recordLockTransport.ts) —createDisabledRecordLockTransportthrows aClientError(..., 503), matching the status code the rest of the transport uses for "not available yet."replication/recordLockTransport.ts) fires whenreplication.recordLocksis enabled, naming harper#2542 and the measured 0.05–0.13% silent-lost-update rate — operator-visible at the point the switch takes effect, not only in this PR's own docs.replication/RECORD_LOCK_HOMES_DESIGN.md(new) — the full design note, including the revision history of two rejected planning-review rounds (see below) and the "For the human reviewer" items carried into this PR body.replication/RECORD_LOCK_COST_DELEGATIONS.md,replication/RECORD_LOCK_COST_BASELINE.md, raw runs underREADME,run 1,run 2,run 3,run 4 (starvation)) landed on this branch from a prior measurement PR and predates the home-map redesign; kept as-is rather than re-run (it measures against acorepin that has since moved, so its numbers are stale relative to this PR's current pin — a re-measurement is future work, not something this PR redoes).The home-map design path (two rejected planning-review rounds)
The §4.3 stage→quiesce→drain→activate transition went through mandatory planning review three times before landing:
better-alternative-exists): rejected a "trusted orchestrator + canonical artifact" design — 4 blockers.better-alternative-exists): rejected the local-timer reduction that followed — a purely node-timed mechanism is unsound (4 concrete counterexamples); round 2's own stated recommendation — stage retracts immediately, activate is a separate operator-timed call — is what shipped, verbatim, not a third new mechanism, so no third planning round applied.The chosen design deliberately does not build a machine-verified barrier for the drain wait: every node checks consistency (does this activation match what I staged), never elapsed time, because a self-measured wait is unsound across a restart or clock correction. The operator's own external wait (
DELEGATION_LEASE_MS + LOCK_LEASE_SKEW_MS) is what makes activation safe — nothing in the code enforces it. Full history inRECORD_LOCK_HOMES_DESIGN.md.For the human reviewer
Decided, not open: a home-map transition is authorized by node identity alone, and that is accepted for this release.
record_lock_apply_homesisrequiresSuperUser; the per-node hop it fans out on is not —executeTransitionregisters without it and admits any principalprincipalNodeNameresolves. The replication socket compounds it:replicationConnection.tsdispatches asserver.operation(data, {user}, !isAuthorizedNode), sorunWithOperationAuthorizationBypassis on and the nominallyrequiresSuperUseroperations are reachable that way too. One compromised node — or onesuper_useron any single node, through the node-identity gap listed below — can stage and then activate a map of its choosing on a peer, and the digest check narrows that rather than closing it (homeMap()iterates its ownactive.homes, so a node rewritten to a singleton never consults a peer, and a peer fails closed only once the changed digest reaches it). The task owner ruled to accept it here, on three facts now written intoRECORD_LOCK_HOMES_DESIGN.md→ "Auth for the hop": a node principal is already trusted to write replicated data on every peer; this relay does not widen the authority, because the per-node operations were already reachable that way before harper-pro#862 and the relay only adds local re-validation; and nothing shipped is exposed, since the feature is off by default and inert until a generation is activated. What would close it — an operator-delegated proof carried on the transition — is harper-pro#869, a prerequisite for recommending this feature in production and for Record locks: decide what it would take to enable replication.recordLocks by default #853's default-on question. The dispatcher's node-wideverifyPermsbypass is wider than record locks and wants its own assessment; Record locks: a home-map transition is authorized by node identity alone, with no proof an operator asked for it #869 says so rather than folding it in. An operator who setsreplication.recordLocks: trueis now told the caveat at startup, next to the existing enablement warning, rather than only in the design note.Planning-gate recheck, round 32:
Framing-Verdict: better-alternative-exists, carried rather than adopted. The pre-push CLI required a--mode planrecheck; the artifact isRECORD_LOCK_HOMES_DESIGN.md. It affirms the operator-stated map over failure-driven consensus and affirms keeping topology in harper-pro rather than core, but argues the option set missed a better implementation layer: a main-thread control-plane API with explicit topology and owner-lifecycle state, rather than HTTP workers reconstructing either. That is the same root cause as two of the carried majors (recordLockOwnerForcollapsingmain/pending/nonetoundefined, and the proposal reader scanning rawhdb_nodesoff the main thread). It is adoptable and would remove that class of finding rather than patch it — but it is multi-day work acrosssubscriptionManager,recordLockTransportand the proposal path, so it is recorded here rather than started under a review round.Round 6 of the planning gate was resolved by overrule, not cleared. Its blocker claimed replay reorders a log by key so a post-rollback barrier (key 101) precedes an inherited write (key 200). A probe on the pinned rocksdb-js 2.9.0 shows per-log range reads are append-ordered (
100, 200, 101, 300appended →start>50yields exactly that). The framing's incarnation-qualified append sequence (a wire/storage migration) is therefore not required. What the probe did surface is filed as harper#2629: on a resume, entries below the subscriber's cursor are never delivered — a pre-existing replication hazard on clock rollback; for locks it fails closed.Every hole class the design names is now recorded: harper#2628 merged (PR harper#2630) and
listenForApplyFailuresregisters core's awaited listener per database, so a transaction core skips after a terminal apply failure poisons(origin, table)before the next event is consumed. The feature stays default-off pending the carried items below.Rollback: disable
replication.recordLockseverywhere and restart before downgrading; retainedlockBarrierentries replay into a level-3 sink as "malformed control entry" warnings.Coalescing is client-side and same-turn only, by design: a caller must match its own nonce, and a request sent before a caller's dependency was formed cannot prove it.
Pre-push review, this round (
codex + gemini + cursor-composer + domain, full, then delta): five majors in the freshness delta were found and fixed before push — a per-frame closure allocation on ordinary replication, a failed poison write cached as recorded, per-worker poison caches invisible to the coordinating thread, no poison recheck when a barrier settled, and the old barrier outliving a transport replacement. Majors that predate this round and are carried, not fixed: a transient storage error in the one startuprefreshCache(or a rejectedbumpHomeIncarnation) latcheshomeMap()undefined until an operator re-stages;homeMap()is on the per-acquisition path and walks the home set with agetDatabases()lookup (the design's "zero-allocation hit" is the cached delegation, not the map read); alockReleasemet beforeNODE_NAMEon a fresh socket is skipped and its cursor advanced.Carried from before: RPC node identity is "authenticated user named like a node in
server.nodes" (see Known gaps). Two items that were carried here are now closed rather than carried —withRowtakes a node-scoped hold lock on thehdb_record_lock_homesrow itself, so the per-isolate queue is no longer what excludes concurrent transitions, and harper-pro#865 / harper#2667 relay an off-ownerlock()to the coordinating worker, sothreads.count > 1is served rather than 503'd.Carried from the home-map rounds:
Decisions that are yours to make, not mine to have picked silently (full reasoning in the design note's decision ledger):
active) has landed. The operator gets an error on an operation that half-succeeded and must retry to converge. Correctness is right; whether that's the operability you want is open — making the write itself conditional on the fan-out is a protocol change, not a constant.stagedrains and reports, so an operator with a proven-clean drain everywhere activates immediately (below) — but the window is still database-wide rather than scoped to the relocating keys. Narrowing that is a protocol change, not a tuning knob; it is the remaining lever on harper-pro#856.withRowtakes a node-scoped hold lock on the row and writes back through that same locked handle, so astageand afence_externalon two worker isolates serialize at the storage layer rather than at a per-isolate map (unitTests/replication/recordLockHomes.test.mjs, "a transition waits for a node-scoped hold on its row taken outsidewithRow"); and harper-pro#865 with core harper#2667 relays an off-ownerlock()acquire/release to the coordinating worker, so a non-owner worker serves rather than 503s. Every cluster fixture still runsthreads.count: 1except the harper-pro#852 suite, so the multi-worker path has unit and targeted-integration coverage, not the full matrix.quiescebut nothomes(g+1)is leaving the ring.record_lock_activate_generationaccepts an activate on it — nothing inplanActivaterequiresself ∈ homes, and neither doeshomeMap()— which leaves the leaver routing its ownlock()to the new ring instead of answering 503.record_lock_apply_homesnever sends it one (role: 'departing',activate: 'skipped'), so under that path the leaver cannot lock at all. Both are safe (a node outsidehomesis home to no key either way), so this is a usability contract, andRECORD_LOCK_HOMES_DESIGN.md§2 now states both rather than implying one. Picking one and making the other match is yours.homeMap()re-derives peer capability and digest agreement on every call rather than reading an invalidated pointer updated on change — O(homes) shared-buffer reads plus one allocation per lock acquisition, against a design that otherwise advertises the delegation hit as zero-message and zero-allocation. Buys freshness; reversible, but touches the core-facing interface shape.Known gaps, not fixed in this PR, code-traced not demonstrated:
quiesceDelegationsswept one up-front snapshot of its live coordinators. The sweep awaits network recalls, so a transport replacement lands inside it: the successor adopts the predecessor's grants viahandOffTo, the predecessor then closes as handed-off (which deliberately parks nothing inretiredCoordinators, because the grants are the successor's now), and the sweep sees an emptied predecessor and never the successor —{ complete: true, outstanding: [] }on a database still admitting. That is exactly the resultprovesQuiescence()treats as authority to activate immediately, so it would become a double grant across a membership change. Found by this PR's final-artifact review. The pinned core still has it —quiesceDelegations(core/resources/recordLockCoordinator.ts:2789) still filters one up-front snapshot ofliveCoordinators, andclose()on a handed-off coordinator returns before parking anything inretiredCoordinators, so a successor created mid-sweep is in neither structure. No core change for it is open; it needs one (a fixpoint over the live registry). Until then, use the timed drain interval, not the drain result —provesQuiescence()is the only thing that licenses skipping the interval, and this is the way an empty sweep can still lie.replication/replicationConnection.ts:5436-5441— alockReleaseskipped becausepeerCapabilitiesLearnedis still false on a fresh socket takesskipAuditRecord(), advancing the peer's cursor past it, so it's never redelivered — the home can't re-grant untilDELEGATION_LEASE_MSelapses, worse than the neighboring comment's stated "costs a requester a 423."replication/subscriptionManager.ts:771-777(viarecordLockOwnerFor) — an in-flight ownership handoff and "main owns" both surface asundefinedfromrecordLockOwnerFor, so a subscription in flight during a handoff briefly places itself on the main thread and applies off-owner untilreconcileWorkerscorrects it.replication/recordLockRpc.ts:142-150— node identity for the delegation RPC is "the authenticated username appears inserver.nodes," so asuper_userwho creates a standard user named after a peer can mint or drain that peer's delegations through the operations API.replication/clusterStatus.ts:40-50— the worker-sidecluster_statuswait has no deadline; a throw inside the now-async main-side status resolver leaves it pending forever and leaks the resolver entry.integrationTests/cluster/recordLockCluster.test.mjs— the "§4.3 stage/activate transition" suite's inline cleanup after a503assertion isn't in afinally, so one real assertion failure can mask itself as two; andstartNode/several fetch helpers are duplicated near-identically againstrecordLockCost.bench.mjs(clusterShared.mjsis where the suite already keeps shared helpers).record_lock_fence_external, and restart/incarnation-ordering are unit-level only, not exercised against real IPC or a real cluster yet.Changes
replication/recordLockFreshness.ts(new): refusals, one nonce-registered request per dependency, exact(origin, position, nonce)matching in both arrival orders, poison rechecked at settle, recovery across every member, deadline/cap/close lifecycle;unitTests/replication/recordLockFreshness.test.mjs(new) covers each.replication/recordLockPoison.ts(new): store-backed, fail-closed reads on the cold path; a failed write is retried, never cached as recorded.replication/recordLockRpc.ts:record_lock_barrier, checks before state, onewriteLockBarrierper request, per-caller token bucket.replication/recordLockTransport.ts: both transports implementestablishLockFreshness; slot 31 exact level; applied-barrier relay to the coordinating thread; core's apply-failure listener consumed when present; status counters and poisoned pairs; barrier closed on recreate/release.replication/replicationConnection.ts(recordReplicationHole, one per connection),barrier capture at decode,report from onCommit,ever-recloned at COPY_START, exact level recorded at handshake.replication/protocolCapabilities.ts(level 4),replication/recordLockConfig.ts(LMDB refusal).coresubmodule: harpermain1c312feb4(harper#2627lockBarrier/writeLockBarrier/deadlineMs; harper#2630registerReplicatedApplyFailureListener).replication/RECORD_LOCK_FRESHNESS_DESIGN.md(new: the design, six planning rounds, the probe, the overrule),replication/DESIGN.md,replication/RECORD_LOCK_HOMES_DESIGN.md,replication/RECORD_LOCK_COST_DELEGATIONS.md(convergence and recovery rows).integrationTests/cluster/recordLockCluster.test.mjs(exact1..N, barrier-applied and no-poison checks),unitTests/replication/recordLockTransport.test.mjs(slot 31, disabled transport, barrier wiring).From the home-map rounds:
replication/recordLockHomes.ts(new): thehdb_record_lock_homestable, canonicalization/digest,planStage/planActivatepure decisions,withRowserialization, and the three registered operations.replication/recordLockTransport.ts:homeMap(), the three transport-recreate triggers, the fail-closed ack protocol, digest reconciliation, and the 503-not-500 disabled-transport fix.replication/replicationConnection.ts(theRECORD_LOCK_HOMES_DIGESTframe, sent only post-capability-discovery),replication/protocolCapabilities.ts(level bump to 3),replication/knownNodes.ts(the second shared-status-buffer slot).replication/recordLockRpc.ts(agenerationrename from the oldepochfield, a guarded worker-sidepostMessage),replication/recordLockConfig.ts,replication/clusterStatus.ts,replication/subscriptionManager.ts,replication/replicator.ts.replication/DESIGN.md(the capability-level-3 correction and the test-index correction above),replication/RECORD_LOCK_HOMES_DESIGN.md(new), the pre-existing cost-measurement docsreplication/RECORD_LOCK_COST_DELEGATIONS.md,replication/RECORD_LOCK_COST_BASELINE.md, and their raw runs underreplication/record-lock-cost-runs/README.md.package.json,.oxlintrc.json, and thecoresubmodule pointer, re-pinned to currentorigin/mainper the task owner's instruction.unitTests/replication/recordLockHomes.test.mjs(new),unitTests/replication/recordLockTransport.test.mjs(rewritten forhomeMap()),unitTests/replication/protocolCapabilities.test.mjs,integrationTests/cluster/recordLockCluster.test.mjs(rewritten for the operator-agreed model, plus the new §4.3 transition suite),integrationTests/cluster/recordLockCost.bench.mjsand its fixtures —fixture-record-locks/resources.jswith itsconfig.yamlandschema.graphql, andfixture-record-lock-bench/resources.jswith itsconfig.yamlandschema.graphql— all carried over from the prior measurement PR.record_lock_propose_homes: read-only. Returns the home set this node'shdb_nodesview suggests, the next generation, the digest, §4.3'squiesceunion (homes(g) ∪ homes(g+1), so a shrink still drains the node leaving) and warnings — including thatquiesceis only as complete as the node asked, and that a staged departing node stays unable to lock. It writes nothing: a mutating bootstrap was rejected in planning review becausehomeMap()iterates its ownactive.homes, so a node deriving[A]checks no peers and serves immediately, and because no local state proves the absence of prior authority. Both counterexamples are recorded inRECORD_LOCK_HOMES_DESIGN.md.stageGeneration drains: after the durable write that stops new grants, core'squiesceDelegationssurrenders every delegation this node holds and recalls every grant it issued, and the result rides back on the stage response.quiesceOnOwnerrelays it to the coordinating thread — a relay that does not arrive is an error, never a clean drain — andprovesQuiescenceis the single predicate an operator may skip the drain interval on: reached the owner,complete, and empty.completeis core's statement that the sweep could have seen everything; seven review rounds each found a distinct way an empty sweep could otherwise lie — the seventh is still open, see Known gaps.Verification
npm run test:unit— 1152 passing; newunitTests/replication/recordLockFreshness.test.mjs(refusals, exact-entry matching in both arrival orders, wrong nonce/origin/position, recovery across members, deadline/cap/close, 1,000 abandoned waits vanish at their deadline);recordLockTransport.test.mjs(slot 31, disabled transport rejects, real transport builds the barrier once over its ownhomeMap());protocolCapabilities.test.mjsat level 4.integrationTests/cluster/recordLockCluster.test.mjs—pass 10 / fail 0on eleven consecutive local runs, the last against harpermain's core pin after merging origin/main (last against the harpermainpin), with the concurrent-increment assertion restored to exact1..Nand every node atN, plus a new check that at least one barrier was applied and no pair is poisoned. Run locally with a privateTMPDIR(the shared loopback-pool file race) andHARPER_INTEGRATION_TEST_STARTUP_TIMEOUT_MS=240000.recordLockCost.bench.mjs— the cost doc's convergence and recovery rows now say what changed and that a re-measure is pending.From the home-map rounds:
npx mocha --require unitTests/unitTestSetup.cjs 'unitTests/**/*.test.mjs'), rebuilt from a cleannpm ci+npm run build(0 TypeScript errors) at the pushed head.grantableAfterMonoon first incarnation, peer-digest reconciliation only working through one of the mesh's per-direction connection objects, and a coordinator lazily built before an active generation exists losing its restart waiver permanently — were each root-caused against a real 3-node cluster and confirmed fixed, with the hardest suite (the operator-agreed transition + N-concurrent-increment + capability-exclusion tests) passing. A full clean integration re-run against the latest pushed commits did not complete: this shared dev box's harness kills long-running background integration runs under memory-pressure policy despitefree -hshowing headroom, and the integration harness's shared/tmploopback-address pool corrupts under concurrent agents on the box. Both are documented, environment-caused, not code-caused. CI is the source of truth for full integration coverage on this PR, and it is green: at132dfcb9every check passed, including all six Cluster Integration Tests shards, all three Integration Tests shards, unit on Node 22/24/26, and the three builds.sendRecordLockOperation, so a member that accepts a connection and never answers no longer pins a response waiter and a fallback socket per failed handoff; a matching re-stage now persists the union of the recorded and requestedquiescesets instead of keeping a narrower one on the idempotent-noop path; the one-barrier-per-wait contract is now what the freshness design note says, withBARRIER_BURSTderived fromMAX_OUTSTANDING_BARRIERSrather than a constant a legitimate wave of cold handoffs could cross; the stale pre-level-4 enablement warning is replaced with what is actually still required; andrecordLockCost.bench.mjsbootstraps a home map, without which everyBenchLockanswered 503. Declined with evidence: the per-isolaterowQueuesfinding (the row's own node-scoped hold lock is what excludes now, not the queue). A later thread onRECORD_LOCK_HOMES_DESIGN.md§2 was half right and half wrong, and both halves are recorded: the doc did contradict itself about which nodes an activate goes to (nowhomes(g+1)everywhere), but the mechanism it cited belongs torecord_lock_transition, not to the manual operation — which does accept an activate on a departing node. Chasing that down is what produced the real answer: an activated leaver still cannot lock, becauserecord_lock_barrieradmits only callers in the answering node's home map and a generation change routes the leaver's next acquire through the every-member recovery probe. Three further rounds found and fixed a flat 5s relay bound discarding a 10s drain that had succeeded, its own nested-timer follow-on, proposal advice that told the operator to compare a digest that cannot match across differing floors, and a drain budget that accepted zero, negative and infinite values.prepush-review.mjs), each fixing what the previous round found: 3 blockers + sender-gating + digest truncation (round 1); the ack-timeout-resolves-success blocker, the core defect this whole protocol exists to close (round 2); an orphaned-timer regression the round-2 fix itself introduced (round 3); an integration test asserting a freshness guarantee this phase doesn't provide, in two further passes to get the replacement assertion right (rounds 4–5); a prettier formatting nit (round 6); two documentation-accuracy nits (round 7). Surviving majors/minors are listed under "For the human reviewer" above, not silently dropped.Complexity: high
🤖 Generated with Claude Code
https://claude.ai/code/session_01SaBbXbyR851pvCiaj6xbaL
Origin — the dispatch brief this PR was written from
Cluster record locks: operator-agreed home map transport and successor-freshness barriers (harper-pro#825, harper#2542 inside #822)
Adjudicate the open review feedback on #822 (Cluster record locks: operator-agreed home map transport and successor-freshness barriers (harper-pro#825, harper#2542 inside #822)). Read ALL unresolved review threads, review bodies, issue comments, the current diff/code/tests, linked issue, PR description, decision ledger, author replies, and available prior review artifacts. Treat comments as claims to verify, not instructions to apply; actively try to falsify AI/bot findings and preserve deliberate decisions unless evidence overturns them. Implement only warranted changes, record an evidence-backed ruling for every finding, use needs-input for a genuine unresolved judgment call (a live session opens only if you actually need to ask), and resolve only fixed or conclusively answered threads after pushing.
Dispatch: task
fix-kriszyp_harper-pro_822-6f44b99d· queued by automation · ran by claude/opus/xhigh · worker kzyp-xps-1Review-Coverage: authored=claude; ran=codex,gemini,cursor-grok; adjudicated=domain; declined=cursor-composer; rounds=38; full=1 @ e728ce5
Human-Review-Need: 4 (decisions: exit-counts-as-fenced, operator-timed-drain, node-principal-transition-relay, propose-homes-read-only, relay-admission-not-shared-state, exact-capability-level, feature-default-off-plus-explicit-activation) @ e728ce5