Skip to content

Count only a record's residents toward replicatedConfirmation, and refuse a count they cannot meet before writing - #3148

Merged
kriszyp merged 21 commits into
mainfrom
fix/residency-aware-replicated-confirmation
Oct 11, 2026
Merged

kriszyp merged 21 commits into
mainfrom
fix/residency-aware-replicated-confirmation

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

⊙ Problem

replicatedConfirmation: N resolves once N peers report an applied position for the database that is at or past the write. On a table with a residency list, that position is also passed by peers that do not hold the record: a non-resident peer receives only an invalidation stub, and a peer outside the list may receive nothing at all for that record. Either one still counts. A write can therefore report "confirmed by N" while no other node holds a complete copy (Count only peers that hold a complete copy toward replicatedConfirmation — harper-pro#1031). Core is the layer that knows each record's residency, but it never passed that to the replication layer.

❓ Your call: Is the requirement warranted? As specified, yes. The task owner ruled on 2026-10-11 that counting non-resident peers is a bug, not the contract. They also chose the per-record rule, fail fast when N exceeds the residents, and no protocol change. This PR implements those rulings without re-opening them.

💡 Solution

The commit now gives the confirmation callback a 4th argument, residentPeerLists: one list of other residents per distinct residency among the transaction's confirmable writes, with the writer and duplicate names removed. Harper-pro's waiter counts a peer only toward the lists it belongs to, and every list needs N confirmed members. A write with no residency adds no list, because N confirmations from any list already come from N distinct peers. When no write has a residency, the argument is undefined and the waiter counts as before.

  • What it waits for. The confirmation position is now the committed transaction's reserved log key, which is what peers acknowledge. Before, it was the version of the last write's stored entry. A record version can sit below the log key (an explicit timestamp under the origin floor) or above it (a remote-origin version kept by a merge), and the stored entry can belong to an older write. Under setResidencyById, a write that excludes the writer stores no entry at all, so it was never confirmed.
  • Which residents. The commit handler records the residencyId of the audit entry each write appended. A coordinated retry appends nothing and keeps it, so the residents counted are the ones the replicated entry names. Residents are resolved once per residency id. getResidencyId now never claims an id already in use, and it writes the id-to-list mapping before the set-to-id one, so an id that is discoverable always resolves to its one list.
  • Nothing to confirm. A delete on a table with neither audit nor delete tracking appends nothing, so it is exempt (skipReplicationConfirmation). A commit made only of exempt writes neither waits nor runs the feasibility check. A commit whose writes all lost to newer entries in every round falls back to the stored-entry target.
  • Refusals before anything lands. If N is larger than a record's other residents, the write's commit handler refuses it before staging anything (no record, no index entries, no blob reclamation), with a 400 such as Cannot confirm replication to 2 node(s) for record "x" in database "data": it is resident on 1 other node(s) (node-b). The refused write turns inert, so a caller that catches the error cannot commit it, even on a retry. Just before native submission, the commit rechecks the lists (confirmation may have been requested after a write was saved). It then runs a feasibility check the replication layer can now register with replicationConfirmation(callback, feasibility). Harper-pro uses it for its recipient bound, which used to answer 400 for a write that had already landed. An error after the native commit becomes a rejected completion, so the commit's bookkeeping (blob cleanup, lock and context release) still runs.
  • Count-based placement on update. replicateTo: N built an update's placement as [self, ...previousResidency], and the stored list already starts with the writer. Each update therefore repeated the writer and shed a replica: [S,A,B] became [S,S,A], then [S,S,S]. This was a pre-existing data-placement bug, which the new refusal surfaced as a 400. The carried-over list now leaves this node out.

⚖️ Alternatives

  • Framing rechecks: better-alternative-exists five times; each was adopted, and none cleared. The review CLI required a recheck after round 5. Each verdict named a smaller or safer variant inside the same design, and I adopted every one: target the committed log key, not record versions; claim residency ids atomically (the resolved lists are cached); refuse through a pre-submission feasibility hook on both submission paths; exempt entry-less deletes and marker-only commits; key the target off the sticky appendedAuditEntry; refuse and observe an asynchronous feasibility check; reuse the checked lists. The last verdict said its points "do not require replacing the design", so I adopted them without a sixth round. The original plan review cleared with Framing-Verdict: chosen-approach-sound.
  • Receiver-side, per-record acknowledgements. Rejected: this is a protocol change. Receivers on older versions would never send the acknowledgement, so confirmed writes would time out during a rolling upgrade. The writer already knows each record's residency.
  • Harper-pro re-reads residency from the committed entry. Rejected: that read happens after the native commit. A concurrent overwrite by another worker can replace the entry's residencyId by then. The entry also does not exist when the writer stored no local record.
  • Use the native transaction's logEntries. Rejected: RocksTransactionLogStore fills that array only when an aftercommit listener exists. It also skips appends on a retry, so it can be absent or describe a different attempt.

❓ Your call: No benchmarks. The new costs on writes that don't request confirmation are recording a residencyId on each placed, audited write and a synchronous atomic id claim when a new residency list is first minted. A confirmed commit builds its resident lists once and looks residents up through a cache that is cleared past 10,000 ids. The review flags these as unmeasured. I judged them negligible next to the encode, native put and audit append each write already does. A placement-churn workload that keeps crossing the cache bound would be the first thing to measure.

❓ Your call: A refused write is refused per write, and the rest of the transaction can still commit if the caller catches the error. The alternative is to poison the whole transaction on refusal, which is simpler and fails closed. The per-write choice is why the refused write has to stay inert on retry rounds. Switching later changes what callers see.

❓ Your call: A table source's before hook (source.put/patch) runs before the commit handler, so a write-through source can receive a write that Harper then refuses. Record-based placement of a patch needs the merged record, which only the commit handler has. A source hook also already precedes every other commit-handler failure (conflict abort, record-lock refusal), so the write-through contract is unchanged. Documented in resources/DESIGN.md.

❓ Your call: LMDB is untouched. harper-pro never registers LMDBTransaction's separate confirmation callback, so replicatedConfirmation on LMDB has never waited (fullyConnectedReplication.test.mjs says as much). LMDB parity belongs with fixing that registration, in a separate change.

🔧 Changes

✅ Verification

  • unitTests/resources/residencyConfirmation.test.js (19 tests, RocksDB) covers:

    • lists for record-based residency, replicateTo, and mixed/duplicate residencies;
    • pre-staging refusal with no record and no index entry;
    • caught refusal, including an existing key;
    • late activation refused before submission;
    • the committed-log-key target below the origin floor in both write orders, plus deletes;
    • the losing-delete and nothing-to-confirm fallbacks;
    • count-based placement across three updates;
    • the feasibility hook (called pre-submit; a refusal writes nothing; an async check is refused with no unhandled rejection);
    • BigInt key naming, omitted-writer writes, and late-requested omitted-writer updates.

    The new tests discriminate: they fail on the base sources (7 of 8 originally) and on each superseded intermediate target.

  • npm run test:unit:resources: 4151 passing. npm run test:unit:main (isolated HOME): 6877 passing. Under HARPER_STORAGE_ENGINE=lmdb, writerResidency and reloadMarker pass, covering the atomic id claim on LMDB.

  • End to end: harper-pro's new three-node integrationTests/cluster/residencyConfirmation.test.mjs against this core passes 3/3, and fails on base. The existing confirmation suites fullyConnectedReplication, replicationTopology, nonReplicatedDatabase, selectiveTableSubscription, removeNodeBlastRadius and replicationWorkerPool pass.

  • After the PR opened, two more fixes landed. The pre-submission check now runs only before a handle's first submission: a retry of that same handle (isRetry) is not re-checked, and a fresh handle committing while the outer commit is in flight is. Resident lists count only writes that appended to the submitted handle. The suites below were re-run on the final head.

  • A pre-existing flake, unitTests/security/userRecordLookups.test.js ("onUserChange re-subscribes…"), fails about 1 in 3–6 runs on base too.

Refs HarperFast/harper-pro#1031. Companion: harper-pro PR (bumps core to this branch). After merge, documentation#723 needs to describe this contract.

🤖 Generated by Claude (Anthropic, Opus 5.5); posted via @kriszyp.

Related PRs: #2918 overlaps (DatabaseTransaction save() arguments and the pre-submission abort block), #3092 overlaps (Table source-hook ordering before the commit handler), 3 others independent
Complexity: complicated

Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-muse; adjudicated=domain; declined=cursor-grok,cursor-kimi; rounds=11; full=5 @ a8c51af

Review-Attention: deep ~40m (critical: DatabaseTransaction.ts, Table.ts; decisions: do-less-alternative, caught-refusal-semantics, refuse-before-landing, sync-only-feasibility, lmdb-unchanged) @ a8c51af

kriszyp and others added 18 commits October 11, 2026 07:41
… and fail a count no residents can meet before committing

A peer acknowledges a database-wide applied position, so a non-resident peer that received only an
invalidation stub (or nothing) for a record was counted as confirming it. The commit now hands the
confirmation callback one list of other residents per distinct residency among its confirmable writes;
harper-pro's waiter counts a peer only for the lists it belongs to. A count larger than a record's other
residents fails before the native commit. The commit handler records the residency and version of the
audit entry it appended, kept across coordinated retries, and a write that stored no local record is
now confirmed on that version.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
…al from skipping commit bookkeeping

Pre-push review: the refusal ran in save() after the write had staged, so a caller that caught it
could still commit the write; it now runs in the write's commit handler before updateRecord, with the
committing transaction passed to the handler. A refusal or callback error after the native commit
becomes a rejected completion instead of a synchronous throw, so blob cleanup and lock/context release
still run. Residents per residency id are cached (the id-to-list mapping never changes), and only
writes placed by residency carry the stamped metadata, set after the audit entry is staged.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
The refusal ran in writeCommit, after updateIndices had already staged the write's index entries, so
a caller that caught it could commit a dangling index entry. It now runs right after the write's
residency is resolved, before anything is staged, and marks the write skipped so its saved blobs are
cleaned up.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
…red entry's

An update whose residency omits the writer appends its audit entry without replacing the local entry,
so the entry still held an older version and a resident's acknowledgement of that older version
confirmed the new write. The confirmation target now prefers the version the write recorded.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
…s inert, and confirm on the log position

Pre-push review round 2:
- confirmation requested after a write was saved is checked before native submission and aborts the
  transaction, so the refused write never lands;
- a refused write is made inert (a retry re-save stages nothing; confirmation ignores it), so a caught
  refusal can neither commit it nor lend its older entry's version to the target;
- the recorded position is the later of the record version and the log key, since peers acknowledge
  log positions, and is recorded only for audit-producing, non-copy writes;
- a residency id whose list is not yet published is not cached as empty;
- a post-commit refusal's rejected completion is handled at creation.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
… first one found

Pre-push review round 3: the target kept the first defined value scanning back from the last write, so
a trailing unplaced write's entry version (the record version) masked an earlier placed write's log
key, which is higher when an explicit timestamp falls below the origin floor. The target now covers
every recorded position.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
…onfirmation refusal

Pre-push review round 4: getResidencyId published the set-to-id mapping before the id-to-list one, so a
worker discovering a just-minted id could find no list and refuse a valid confirmation; the list is now
written first. Harper's BigInt.toJSON throws, so the refusal formats keys itself. The DESIGN note now
bounds the refusal's guarantee to Harper's stored data: a table source's before hook runs first.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
Pre-push review round 4 (pre-existing gap): an unplaced write with an explicit timestamp below the
origin floor was confirmed on its record version, which peers' acknowledged log positions can already
exceed. Writes of a transaction that requests confirmation now record their position too; other writes
pay nothing.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
…n the committed log key

Pre-push review round 5:
- getResidency built a count-based update's placement as [self, ...previousResidency], and the stored
  list already starts with the writer, so each update repeated it and shed a replica ([S,A,B] became
  [S,S,A], then [S,S,S]); the new refusal turned that into a 400. The carried-over list now excludes
  this node.
- The confirmation target is floored at the committed transaction's reserved log key, which covers
  delete-only, publish-only and late-confirmed writes whose record version sits below it.
- getResidencyId writes both mappings with putSync, list first.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
… the replication layer refuse before commit

Framing recheck (better-alternative-exists), adopted:
- The confirmation target is the committed transaction's reserved log key, the position peers
  acknowledge. A record version can sit below it (explicit timestamp under the origin floor) or above
  it (a remote-origin version kept by a merge, which residents would never acknowledge), so per-write
  position stamps are gone.
- getResidencyId never claims an id already in use, since readers cache an id's list for good.
- replicationConfirmation takes an optional feasibility check, run before native submission with the
  resident lists, so the replication layer's deterministic refusals (recipient bound) no longer arrive
  after the write landed.
- Count-based placement carries the previous residency over with a loop instead of filter+slice.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
Run inside the full resources suite, the 'test' database resolves under another name.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
…ep a no-op commit off its unused log key

Pre-push review round 6:
- A commit made while read iterators are open replays its writes onto a fresh handle and submits that;
  the pre-submission feasibility check now runs on both submission paths (refuseUnconfirmable).
- A commit whose writes all lost to newer entries appended nothing at its reserved key, which peers pass
  only when some later write replicates; such a commit falls back to the stored-entry target.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
…nd the residents cache

Framing recheck (better-alternative-exists), adopted:
- getResidencyId claims a new id in one dbis transaction (retryOnBusy on RocksDB; LMDB's sync
  transaction is exclusive), so two workers minting the same set or drawing the same candidate id
  conflict and retry instead of overwriting a list readers have cached.
- A delete that loses to a newer entry appends nothing; it is now marked skipped, so a commit made
  only of such writes confirms on the existing entry rather than on a log key nothing was written at.
- The per-database residents cache is cleared past 10,000 residencies.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
…feasibility check when nothing replicates

Framing recheck (better-alternative-exists), adopted: a delete on a table with neither audit nor delete
tracking removes the entry without an audit append, so it is marked skipReplicationConfirmation rather
than sending the commit to wait on a log key nothing was written at; a commit made only of exempt
writes (that, or a reload marker) no longer runs the replication layer's feasibility check.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
…synchronous feasibility check, and reuse the checked lists

Framing recheck (better-alternative-exists), adopted:
- A write that appended its audit entry in an earlier round and lost on a retry round is skipped, yet
  its retained log entry still replicates at the reserved key; the target now keys off the sticky
  appendedAuditEntry flag rather than the final round's skipped flag.
- A feasibility check that returns a promise is refused (TypeError) before submission instead of being
  silently left unawaited.
- The resident lists checked before submission are kept for the waiter instead of rebuilt after commit.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
…t, and keep a checked empty result

Framing recheck (better-alternative-exists, local corrections), adopted: a feasibility check that
returns a promise has it observed before the TypeError, so its rejection cannot go unhandled; a
checked commit with no resident lists no longer rebuilds them after commit.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
…the residency id claim, and freeze cached lists

Pre-push review round 7:
- The sticky appendedAuditEntry also covers an append on a handle an iterator replay discards, so the
  target now asks whether a confirmable write appended to the handle that was committed (appendedTo).
- getResidencyId throws when the claiming transaction yields no id instead of placing the write as
  unplaced.
- Cached resident lists are frozen, so a consumer cannot corrupt them for later commits.
- The DESIGN note states when the target falls back to an entry version, and the bounded cache.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
…ation targets the log key

Pre-push review: appendedTo was recorded only when confirmation was already requested at save, so
confirmation requested afterwards fell back to an entry version, which for an omitted-writer update is
the older entry it left behind.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements changes to ensure that replicatedConfirmation counts only the actual residents of each written record. It introduces caching of residents by residency ID, adds a feasibility check before native transaction submission to refuse unconfirmable writes early, and updates the transaction commit process to track the exact log keys and resident peer lists. A high-severity issue was identified in DatabaseTransaction.ts where accessing this.writes[0] directly could throw a TypeError if the first write was detached; it is recommended to safely find the first non-null write instead.

Comment thread resources/DatabaseTransaction.ts Outdated
kriszyp and others added 3 commits October 11, 2026 14:09
…he transaction

A detached first write leaves this.writes[0] null, which skipped the check.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
…ignore residency from a discarded replay round

Pre-push review:
- A coordinated or backoff retry re-enters the submission path after the first attempt's batch was
  written; re-running the feasibility check there could abort a submitted transaction. It now runs only
  before the first native submission of a commit.
- Resident lists count only writes that appended to the handle being submitted, so a residency recorded
  in a round an iterator replay discarded is not required.
- A delete resets its confirmation exemption each round, since a replay can now append.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
… handle

Pre-push review: skipping the check whenever the transaction had already submitted also skipped it for a
write that commits on its own fresh handle while the outer commit is in flight, and that write's waiter
reused the outer commit's lists. The skip now applies only to a retry of the same handle (isRetry), and
the checked lists are used only for the handle they were checked for. A delete's exemption reset skips
retry rounds, which re-append nothing.

Refs HarperFast/harper-pro#1031

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtkQzQLoohXK3gEMzGFRz8
Dispatch-Task: hp-1031-residency-aware-confirmation
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant