Repository navigation
Stop re-logging a replicated delete that is re-delivered onto its own tombstone - #2761
Conversation
A replicated delete whose key already held the tombstone it left (same origin node and log key) re-applied and appended a fresh audit entry on every delivery. In a mesh each appended copy is new log tail that every peer forwards and re-logs in turn, so one re-delivered delete echoed into millions of copies under a single log key (harper-pro#826). The delete commit now returns without writing when isAuditEntryWrite matches the key's current no-record state — the stored tombstone, or an earlier copy's stagedEntry in the same transaction — and still stages the removal so later writes keep program order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An invalidate or relocate earlier in the same transaction writes a null stub that carries the transaction's identity but is not published to the staged-write chain, so the delete read it as its own tombstone and was dropped. Only the immediately preceding write vouches for the key's state once the key has one, and an INVALIDATED stored entry is never a removal. The no-op path also marks the write skipped, like every other commit-handler early return. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trusting only the immediately preceding write re-opened re-logging for a re-delivered [put, delete] transaction: the put is skipped as a duplicate and stages nothing, so the delete found no removal and applied again. The delete now walks back past writes marked skipped to the nearest staged state, and still stops at any unskipped write that staged nothing (an invalidate or relocate stub). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements a mechanism to ensure that a replicated delete re-delivered onto its own removal is treated as a no-op in Table._writeDelete, addressing harper-pro#826. When a key has no record because the same write (identified by origin node and log key) removed it, the delete will not write anything (avoiding redundant index changes, tombstone rewrites, or audit entries) while still staging the removal. This prevents infinite log tail growth in mesh environments. The changes include updates to DESIGN.md, logic in Table.ts (with a new removalBefore helper), a signature adjustment in auditStore.ts, and a comprehensive suite of unit tests in replicatedDeleteRedelivery.test.js. There are no review comments, and I have no feedback to provide.
|
Reviewed; no blockers in this diff. Follow-up (non-blocking, outside this diff)The PR states "deletes were the only write type with no identity-tie check," but |
|
Production evidence for this shape from a 2026-09-24 incident (10-node cluster on harper-pro 5.1.12; reproduced the same day on the customer's 5.1.28 staging cluster), collected by a read-only decode of the
That is millions of copies of one delete at one version, which is what filled the stores (16 MB segments every 1 to 2 min per node) and the worker heaps. This fix is merged to main only; the 5.1 patch the customer is waiting on should carry it, since the per-origin-log fix in #2793 stops the loop but not the flood. Full frame breakdown with origin ids is on #2793. |
… tombstone (#2761 → v5.1) (#2813) * Stop re-logging a replicated delete that is re-delivered onto its own tombstone (#2761 → v5.1) A replicated delete whose key already holds the tombstone it left (same origin, same version) re-applied and appended a fresh audit entry on every delivery; in a mesh each copy is new log tail that every peer forwards and re-logs in turn. The delete now returns without writing in that case. This branch passes each write the pre-transaction entry (no in-transaction staging), so the check applies only when every other write to the key in the transaction is the same origin's delete. (cherry picked from the intent of commit df188d9) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(resources): index a transaction's delete origins once, and keep a retried delete's first decision Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… tombstone (#2761 → v5.2) (#2815) * Stop re-logging a replicated delete that is re-delivered onto its own tombstone (#2761 → v5.2) A replicated delete whose key already holds the tombstone it left (same origin, same version) re-applied and appended a fresh audit entry on every delivery; in a mesh each copy is new log tail that every peer forwards and re-logs in turn. The delete now returns without writing, checking the nearest staged write in the transaction (or the stored entry) as main does. This line has no isAuditEntryWrite or separate log key, so identity is compared as version and origin. A retry round repeats its first round's decision. (ported from commit df188d9) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(resources): compare a local delete's origin as 0 without reading the node-id map Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A replicated delete that reaches a node already holding the tombstone it left no longer re-applies and appends a fresh audit entry. In a mesh each re-logged copy was new log tail that every peer forwarded and re-logged in turn, so one re-delivered
hdb_nodesdelete echoed into ~6.4M identical entries per node and wedged system-database replication (Mesh replication amplifies a re-deliveredhdb_nodesdelete into millions of duplicate audit entries).Refs HarperFast/harper-pro#826 — the harper-pro PR that bumps
coreto this branch carries the closing keyword and the sender-side half.For the human reviewer
replication_maxPayload) is handled for logs older releases already wrote by the companion harper-pro PR; a single legitimate transaction above the cap is a pre-existing limit left alone. As specified, yes.replication_leadingDuplicateSkip).isAuditEntryWrite— never the record version (the dual-clock rule inresources/DESIGN.md). A distinct delete that reuses the version under a new log key still applies. The no-op still stages the removal, so every later write in the transaction sees exactly what it saw before; state after a skip equals state after today's re-apply.[delete K, put K], or a[put K, delete K]whose put re-applies inside the harper#1137 keyed-lookup lag window, still re-logs, as today. Closing that needs transaction-level "already applied" knowledge (item 2's transport route).[put K, delete K]with a readable log is pinned at one entry each.write.skipped.removalBeforewalks back past writes markedskippedand stops at any other unstaged write (an invalidate or relocate stores a null stub without staging it; anINVALIDATEDstored entry never counts). A future handler that setsskippedafter storing something would let a delete be taken as already applied. Alternative: an explicit "published no state" marker on invalidate/relocate/publish writes.{ txnLogKey, nodeId }literal on absent-row deletes only (small next to the audit write it avoids); two local deletes of an absent key colliding on one timestamp merge (same log key, same state, nothing lost).Changes
resources/Table.ts— the delete commit returns without writing when the key's no-record state was produced by this same write, still staging the removal; every applied delete stages{ value, localTime, nodeId }so a later copy in the transaction can tie against it;removalBeforefinds that state; the handler now resets and setswrite.skippedlike the other commit-handler early returns. Look hardest atremovalBefore: its stopping rule is what keeps an invalidate/relocate stub from passing for a tombstone.resources/auditStore.ts—isAuditEntryWritetakes only the two identity fields it reads (type-only).resources/DESIGN.md— the invariant, where it is enforced, and its limits;DESIGN.md— its index line.Verification
unitTests/resources/replicatedDeleteRedelivery.test.js— 16 cases through a sourceApply transaction at the origin log key, one of them through the realsourcedFromreplication apply dispatcher. RocksDB 16 passing, LMDB 9 passing / 7 pending (engine-specific). On base 5 fail: 3 re-deliveries → 4 entries, one frame carrying the delete 50× → 51 entries, dispatcher 21 copies → 2. The two[invalidate, delete]stub cases and the[put, delete]count case each fail on the intermediate guard that review rounds 1 and 2 caught.npm run test:unit:resources:core2534 passing / 43 pending;test:unit:resources:indexes459 passing; LMDBtest:unit:resources:core2062 passing,:indexes244 passing.test:unit:main5771 passing, 32 failing — all environmental in this submodule checkout (nocore/node_modules: shrinkwrap-pin canaries and global-isolation tests look for installed packages there; one file-watcher test), none touchingresources/.integrationTests/cluster/deleteEchoRun.test.mjson the companion PR. With this core change reverted, the receiver logs 41 deletes for a record whose echoed run it receives in one frame; with it, 1.npm run check:design-docs, prettier, oxlint clean on the changed files.Complexity: complicated
🤖 Generated with Claude Code — Claude Opus 5.5
Review-Coverage: authored=claude; ran=gemini,cursor-muse,codex; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi; rounds=3; full=1 @ 93309f7
Human-Review-Need: 3 (decisions: receive-side-idempotence, skipped-marker-walk, stored-entry-type-blind, lmdb-partial) @ 93309f7