Repository navigation
Stop replicated deletes from echoing across the mesh, with an offline repair for logs already affected - #895
Conversation
There was a problem hiding this comment.
Code Review
This pull request addresses an issue where echoed delete runs could wedge the replication sender or cause excessive re-logging on the receiver (harper-pro#826). It introduces a mechanism to detect and drop adjacent byte-identical deletes within the same frame using a new repeatsQueuedDelete helper. The changes are accompanied by comprehensive integration and unit tests, as well as updated design documentation. The review feedback suggests a minor improvement in replicationConnection.ts to use the standard and more idiomatic TypedArray.prototype.set method instead of Buffer.prototype.copy.call for copying bytes.
4f26312 to
3ada4ca
Compare
|
Reviewed; no blockers found. |
3ada4ca to
12a5171
Compare
| databasePath, | ||
| JSON.stringify(Object.keys(expected)), | ||
| ], | ||
| { encoding: 'utf8' } |
There was a problem hiding this comment.
Suggestion (non-blocking): This spawnSync has no timeout, unlike the integration test's own CLI-invocation helper (runRepairTool), which passes timeout: 120000. If the read-back child hangs — e.g. the rocksdb-js iterator stalls on an unusual database — withDatabaseHeld's action() never returns, so the writable RocksDB handle (and its LOCK) stay held until an operator notices and kills the process by hand. A timeout here would turn that silent hang into the same clear, bounded failure this tool gives for every other verification step.
| { encoding: 'utf8' } | |
| { encoding: 'utf8', timeout: 120_000 } |
Releases before the core fix could echo one replicated delete into millions of byte-identical copies under a single log key (harper-pro#826). The sender frames everything under one key together, so that run outgrew replication_maxPayload and the leg closed and resumed into the same frame forever — and a receiver-side fix cannot repair a log that already holds the run. sendAuditRecord now drops a delete whose sent bytes repeat the delete written immediately before it in the current frame. The integration test plants both run shapes on a sender while its peer is offline: a byte-identical run above the cap (needs this change) and an alternating one the sender cannot collapse (needs the core change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Buffer#slice returns a view, so the held delete could track a reused read buffer and match a different delete. The comparison now uses the bytes actually framed (the invalidation entry for a residency-excluded peer), and the entry's encoding is read once for both the check and the write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Holding a fresh copy per sent delete allocated on every delete the sender ships, not just inside an echoed run. The previous delete's framed bytes now live in one per-subscription scratch buffer and are compared by offset, so the default path allocates nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… send The sender-side collapse cost every replication stream a per-entry branch and buffer copy, permanently, to repair logs only pre-fix releases write, and it missed interleaved runs. Remove it, and add an operator-run tool that compacts those runs out of a stopped node's RocksDB transaction logs: node dist/bin/repairDeleteEchoRuns.js <harper-root> [--apply] node dist/bin/repairDeleteEchoRuns.js --restore <backup-dir> A delete is dropped only when its replicated bytes repeat a delete of the same record kept earlier in the same one-timestamp run, with no other entry for that record in between. Outputs are staged and originals hard-linked into a backup with a manifest before the store is touched; txn.state is rewound while its file is swapped, then remapped; a rewritten newest file gets a successor stamped with the latest timestamp so queries and derived-index coverage stay correct. Files with positions the tool does not understand are refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecoverable Review of the offline repair found gaps in its execution, not its approach: - "Stopped" is now the database's RocksDB LOCK as /proc/locks reports it, rechecked before every rename; hdb.pid is only the fallback where that cannot be read. A pid is wrong across pid namespaces: in Docker, Harper is pid 1 and the pid file survives the container. - Restore finds the database from the backup directory's own location, and rebuilds txn.state from the position the manifest records, owned like the live file. - A backup directory without a manifest is removed: the manifest precedes every store mutation, so such a directory only holds staged output and links to live originals. An unfinished repair names both ways out: restore, or keep a store Harper has since run on by deleting the backup. - Staged files are fsynced after their ownership changes; hashes stream; span state is bounded by bytes; the CLI exits non-zero when any file is refused; a file that disappears during a dry run of a live node is reported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ge to compare - A repair or restore holds an exclusive lock on the database for its whole run. - A manifest in state `preparing` is written before any staging, so only a backup this tool recorded, and that never reached the store, is removed automatically; an unrecorded `transaction_logs.repair-*` directory is refused, not deleted. - A run whose comparison state exceeds the budget, or an entry above 512 MiB, refuses the file: partial deduplication could leave the run above the cap. - Verification hashes each backup original, which is the replaced inode, so a process that opened the database mid-repair and appended there is detected. - Restore validates the manifest's names and positions and refuses symlinked targets, since it may run as root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… across files Review found that watching the lock through /proc/locks misses a Harper in another pid namespace (Linux 4.9+ filters the list), so apply and restore now take the lock instead: they hold a writable handle whose transaction logs point at an empty directory, which shuts Harper and other repairs out from any namespace and never loads a store over the real logs (one would purge files when it closes, and every handle in the process shares it). The reader read-back runs after the handle closes. This replaces the /proc/locks and hdb.pid checks and the separate repair lock. - A run the previous file ends in carries into the next when that file closes its last transaction, as the sender frames it, so it is compacted and sized as one. - Restore hashes and copies each original through one O_NOFOLLOW descriptor. - Post-apply validation judges only the files this repair wrote, so a torn file it refused no longer fails a correct repair; one database's failure is reported without hiding the others; a symlinked database directory is refused, not skipped. - An unexpected end of file is a refusal, not a crash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The reader read-back runs in a child process while the database is still held (every handle in this process shares the holder's empty log directory; a read-only open takes no lock), so completion no longer races a supervisor. - Started as root, the CLI first becomes the database's owner; the library then never chowns, sets modes through descriptors, and refuses files it does not own. RocksDB's own MANIFEST, CURRENT and OPTIONS from the holder's open stay readable. - A carried map is released as soon as its successor is scanned, and on every chain reset; a refused file's part of a run keeps its original size in the report, and every scanned file reports its post-repair run. - "Stop Harper" advice only for a lock error; a symlinked database is repaired at its target; unexpected errors keep their stack. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es up front - Started as root, the CLI drops to the common owner of the resolved databases, and refuses when they belong to different users. - A database entry that cannot be resolved (a dangling link) is refused on its own instead of aborting the scan of the others. - Restore checks every source it will copy is a singly linked regular file before its first change, so a refused restore leaves the repaired store as it was. - A read-only open purges nothing; a test keeps an untouched file older than the retention through a repair, which guards that against a rocksdb-js upgrade. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…state first - Restore hashes every backup original it will copy before changing anything, so a changed, missing or linked backup refuses the restore with the store intact. - Restore rewinds txn.state to the file header before swapping files, as the repair did, so no swap in between can leave its offset mid-entry. - Started as root, the CLI keys owners by uid and gid; a missing path or a `<root>/database` that is not a directory is a refusal, not a stack trace. - A file retention deletes mid-listing on a live node refuses that database's report instead of failing it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12a5171 to
c207249
Compare
Stops a mesh from echoing a re-delivered replicated delete into millions of audit entries, and adds an offline, operator-run tool that compacts runs older releases already wrote into RocksDB transaction logs, so a sender wedged on
replication_maxPayloadcan ship again. The root cause is fixed in core by the merged Stop re-logging a replicated delete that is re-delivered onto its own tombstone, pinned here at its core-main commit. The repair isrepairDeleteEchoRuns.tswith its CLIbin/repairDeleteEchoRuns.ts. No Pro runtime code changes: the earlier sender-side collapse is removed, andreplicationConnection.tsis identical tomain.Closes #826
Depends-on: HarperFast/harper#2761
For the human reviewer
Requirement. Per the thread, this PR swaps the permanent sender-side collapse (a per-entry branch and buffer copy in every replication stream, adjacent copies only) for an isolated tool that is run once where needed. With the core fix on every node, new runs stop, and the tool exists only for logs written before then. The alternatives cost more:
Temporarily raising
replication.maxPayloadon both ends works for a modest run (a reviewer suggestion). It does not scale to a million-copy run, because the whole frame is built in memory.Size. The tool is about 1,140 lines plus 725 lines of unit tests. Most of it is safety, not the dedup rule (about 60 lines at the drop decision):
--restore;A much smaller tool would rewrite in place and leave backups to the operator (
cp -a transaction_logs); that trades crash safety and restore for about 300 lines.Exclusion is RocksDB's own lock, held for the whole run.
withDatabaseHeldopens the database writable, which takes the kernelfcntllock on<db>/LOCK, so Harper cannot open the database until the repair ends, from any process or pid namespace. The handle's transaction logs point at an empty directory: a store over the real logs purges old files on close, and every handle in the process shares it. That is also why the read-back runs in a child process. Earlier rounds used/proc/locks(Linux 4.9+ filters it by pid namespace, so a sibling container is invisible) andhdb.pid(wrong across namespaces). Cost: the writable open rewrites RocksDB's MANIFEST, CURRENT and OPTIONS and runs WAL recovery, exactly as a Harper start does; the logs are untouched (probed).What counts as a copy. A delete is dropped only when both hold:
[delete K, put K, delete K]in one transaction is therefore kept whole. A run continues into the next file when the previous file closes its last transaction, which is how the sender frames it (scanOptions). After a transaction split across files by an older writer, dedup restarts, so the transaction keeps an entry in each file to carry its last flag. A run whose comparison state exceeds 256 MiB refuses its file rather than being partly repaired.Positions other readers depend on:
txn.stateis rewound to the file header while that file is swapped, then remapped (publishStore). A crash between the two replays already-flushed writes; it never skips unflushed ones.zdelete: 2/2 failed without the fix, 5/5 passed with it. The new sequence also means no stale derived-indexcoverageposition can equal the tail.Format coupling.
decodeEntryand the file walker re-implement two layouts: rocksdb-js's v1 log layout, and Harper's prelude and audit-entry field order. The unit tests write their entries through core's ownRocksTransactionLogStore.put, and the tool refuses unknown format versions and side files. Moving the decoder into rocksdb-js or core would mean a release there for a one-off tool; a layout change needs a tool change.Operator contract. The tool only discovers
<root>/database/*; customstorage.pathlayouts are not supported. Started as root, the CLI becomes the databases' common owner, and the library never re-owns files. Originals stay hard-linked in<db>/transaction_logs.repair-*until someone deletes them, so a clone or backup of the database dir carries them along. Rollout:--apply, restart;Declined.
{txnLogKey, nodeId}object per re-delivered delete of an absent record) is in the merged harper#2761; it's a core follow-up.test:integration:alldid not complete locally (see Verification); CI runs it.Changes
core— submodule bump todf188d961, the merged harper#2761, a direct descendant ofmain's pin.replication/repairDeleteEchoRuns.ts:preparingmanifest, auto-removed only when it's the tool's own (cleanup);O_NOFOLLOWdescriptor, after a validated manifest;bin/repairDeleteEchoRuns.ts— dry run by default,--apply,--restore <backup-dir>; exit 1 when any file is refused.replication/DESIGN.mditem 20 records the invariants and the key-files row; the sender-side entry it replaces is gone.Verification
unitTests/replication/repairDeleteEchoRuns.test.mjs, 40 passing. Real logs are written through rocksdb-js and core'sRocksTransactionLogStore.put. It covers:[delete, put, delete]kept whole, last-flag relocation;txn.stateremap plusstartFromLastFlushedreplay;--restore, and a successful retry;integrationTests/cluster/deleteEchoRun.test.mjs, with the fixture writing raw clones of the real delete:z× 20,000 run.zdelete andzis still deleted.[x, y]× 40 is then planted below the cap, and B must log one delete per record.--applyremoved, it fails: the marker never reaches B within 90 s, and the sender loggedtoo large to send38 times.config.yamlandschema.graphqldeclare the fixture's REST table.npm run test:unit1284 passing / 1 pending.npm run typecheckclean. oxlint and prettier clean on every changed file.test:integration:allin 4 local shards did not complete. The shards were starved of loopback addresses by other Harper nodes on a shared box (load average about 24), so I stopped them; CI runs the suite. Pro runtime code is identical tomain; the only runtime change is the core pin, one commit, which core CI covered in harper#2761.Framing-Verdict: chosen-approach-sound, including the round-5 recheck). Implementation review of this change ran 2 full and 5 delta rounds (codex, gemini, cursor-muse, plus Harper domain adjudication), until it converged on a nit-only round. Every finding was fixed or ruled on in item 8 above. One decision was overruled and later adopted: holding RocksDB's lock for the whole run, once a writable open with a private logs path proved to leave the logs untouched.Complexity: complicated
🤖 Generated with Claude Code — Claude Opus 5.5
Origin — the dispatch brief this PR was written from
Stop replicated deletes from echoing across the mesh, with an offline repair for logs already affected
Maintain #895 (Stop replicated deletes from echoing across the mesh, with an offline repair for logs already affected) on branch fix/replicated-delete-relog-storm. Read the current PR head, mergeability, and latest check runs before acting; the dispatch observation may be stale. First read the remote head for refs/heads/fix/replicated-delete-relog-storm without updating local refs and require it to equal this task's observed head 3ada4ca; if it differs, stop with needs-input because remote history changed after dispatch. Then fetch only refs/heads/main from origin into refs/remotes/origin/main. If Git reports a paused rebase for this task's exact generation branch, require that rebase's recorded original head to equal 3ada4ca and its recorded onto commit to equal the fetched origin/main tip; stop with needs-input on either mismatch. Only then resume it without repeating the local-HEAD ancestry check or starting another rebase. Otherwise record the remote SHA, verify it is an ancestor of local HEAD, and rebase onto the fetched base. If Context carries companion instructions, follow them exactly for every named gitlink; they override ordinary conflict handling. Preserve both sides' intent for every other conflict. Stop with needs-input on semantic conflicts. Force-with-lease is authorized only for this rebase. Immediately before pushing, re-read the PR's base ref and body plus every companion PR head/state named in Context. The base must still be main, and the declarations and submodule bindings must still match Context. If an open companion advanced, update its named gitlink to the new exact head and rerun relevant tests. If it merged, point the named gitlink at the companion repository's current default-branch tip after verifying that tip contains the merge, then rerun relevant tests. If it closed without merging, stop with needs-input and do not publish its abandoned head. If the base, declaration, binding or any other companion state changed ambiguously, stop with needs-input. Then push the rebased head with
git push --force-with-lease=refs/heads/fix/replicated-delete-relog-storm:EXPECTED_HEAD_SHA origin HEAD:refs/heads/fix/replicated-delete-relog-storm, substituting the recorded SHA. If the remote head changes, stop with needs-input; never overwrite intervening remote work. Do this before evaluating CI. Then inspect CI for the resulting current PR head, not old-head failures. Wait for relevant pending checks, diagnose remaining failures, fix them, run relevant tests, and push in THIS task. Do not create a separate CI-fix task. Stop with needs-input for judgment calls or an unavailable CI result; report exactly what was verified. No merge is authorized.COMPANION PRs — this PR declares
Depends-on:and its diff moves a submodule pointer, so that pointer is NOT an ordinary conflict: it has no "both sides" to preserve. Take neither side of the gitlink conflict; set each pointer named below to exactly the sha named with it, and say in # Log that you did. Immediately before push, re-read the PR body and each companion's state/head as the task requires; these SHAs are a dispatch-time snapshot, not a lease on another repository.core-> Stop re-logging a replicated delete that is re-delivered onto its own tombstone harper#2761 is MERGED. Its commits are on that repo's default branch now, so pointcoreat the default branch rather than at the companion's branch commit.Dispatch: task
pr-maint-eb7cb0115f26bdbce455d91d36ab1ce7· queued by automation · ran by claude/sonnet/xhigh · worker kzyp-xps-1Review-Coverage: authored=claude; ran=cursor-muse,gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi; rounds=12; full=3 @ 12a5171
Human-Review-Need: 4 (decisions: offline-byte-rewrite, format-knowledge-in-pro, writable-open-as-lock, coverage-invalidation-via-tail-file, no-cluster-version-gate) @ 12a5171