Clone sync verification now detects an in-progress base copy on RocksDB instead of marking the node Available mid-copy - #657
Clone sync verification now detects an in-progress base copy on RocksDB instead of marking the node Available mid-copy#657kriszyp wants to merge 13 commits into
Conversation
…atermark checkSyncStatus skipped any database whose leader target timestamp was falsy. On RocksDB the describe operations report last_updated_record for no table (harper#2091, getKeys TODO stub), so every target was 0, every socket was skipped, and the first poll declared "All databases synchronized" seconds into a multi-GB base copy — the clone marked itself Available and cloned with a fraction of the leader's data (#655; Large-Data stress run 31006315068). A no-target database now requires a positive received-version watermark instead of being skipped. The watermark is held at 0 for the whole bulk copy and only becomes positive via the final end_txn the sender always emits at copyStartTime — the copy's own completion signal — so this is correct against any leader version and for empty databases (their copy still emits the final end_txn). A database with a target but no socket yet also holds completion, so a lone early socket (the system DB's small copy finishes in seconds) cannot complete the check vacuously either. Fixes #655 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Code Review
This pull request updates the clone sync monitor to prevent premature sync completion when target timestamps are missing (such as with RocksDB leaders) or when replication sockets have not yet registered. It defaults missing target timestamps to 1 and ensures all target databases have active sockets. Unit tests are added to verify these behaviors. The reviewer identified a critical bug where selective replication of a subset of databases will cause a permanent stall because non-replicated databases in targetTimestamps will never have active sockets, and suggested filtering the targets as well as avoiding for...in loops.
|
Reviewed; no blockers found. |
…cannot wedge the clone A legacy (v4) leader never replicates the system database, but system is added to the sync targets unconditionally, so requiring every target to have a replication socket wedged the clone forever against a v4 leader (cloneFromLegacy CI failure: "data: Synchronized" + "system: no replication socket" repeating until the Available timeout). checkSyncStatus now takes the set of databases whose socket is required (cloneNode passes the user databases); a non-required database is still verified whenever its socket does exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… cannot complete the clone From the cross-model review of the v4-leader fix: making the system socket optional for v4 leaders also removed its socket-presence gate on v5, where a small user database's copy could complete before system's socket registers. Once a target database's socket has been seen it now stays required — its later loss holds completion instead of completing around it — while a socket that never appears (v4 leader) still cannot wedge the clone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Two follow-up commits pushed (plus a merge of latest main):
Also: the 10 GB Large-Data stress run on this branch passed — large-clone green in 24 min on a slow runner, the exact profile that failed on main. — Claude (Opus 5) |
|
Integration Tests 3/3 failure analyzed — The test passes locally against this branch in 10s (healthy resumes log — Claude (Opus 5) |
…ocket by leader version From PR #657 review threads: - gemini-code-assist: with replication.databases configured, non-replicated leader databases became sync targets whose sockets never exist, wedging the clone. getLastUpdatedRecord now applies the same isReplicatedDatabase gating cloneSchemas uses (helper hoisted to share it). - KrAIs: whether the system socket is required is a leader capability, not an ordering heuristic — probe it via registration_info (present on every leader version) and require system up front on v5+, closing the reverse race where a small user database completes before the system subscription registers. Unknown version leaves system optional with the seen-socket ratchet as the fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… sync targets From the cross-model review of the previous commit: - The registration_info probe failed open: a transient error or a missing/unparseable version left the system socket optional on a real v5 leader, reopening the premature-Available race. Only a positively-read legacy major version now exempts system; the probe retries and persistent failure requires the socket (a wedge is visible; a premature Available is not). - isReplicatedDatabase accepted sharded entries by name, but a sharded entry replicates only from same-shard peers (shouldReplicateFromNode) — the leader's shard is unknown here, so a differently-sharded database would have become a required target with no socket, wedging every sharded clone. Sync targets now exclude sharded entries; when one does replicate, its socket exists and the watermark rule plus the seen-socket ratchet still verify it. Schema pre-create keeps its original name-based gating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng sharded entries From the cross-model review: excluding every sharded entry from sync targets reopened the ordering race for a same-shard sharded database (replicating, copied, but never required — a small user database could complete the clone around it). The shard predicate is now evaluated exactly as shouldReplicateFromNode does — the leader's shard read from its configuration (get_configuration), compared to replication.shard — failing closed to keep sharded targets when the configuration is unreadable. The predicate logic moves to cloneNode/replicatedDatabases.ts (pure, unit-tested); schema pre-create keeps its original name-based gating via the fail-closed default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Review feedback addressed (commits @gemini-code-assist's selective-replication thread (resolved): On the KrAIs' system-socket ordering thread: Also added the regression test you asked for: data socket fully synchronized from poll one, system socket absent, then appearing pending on a later poll, then verifying — completion must wait through the whole sequence ( — Claude (Opus 5) Also from the follow-up cross-model rounds: the version probe tolerates prefixed version strings, and the sharded-entry target classification evaluates the real same-shard predicate instead of excluding sharded entries (a same-shard database stays a required, verified target). |
|
Thank you for the detailed breakdown and for addressing the edge cases regarding selective replication and the system socket ordering. The implementation of the version probe and the seen-socket ratchet effectively closes the identified races. The regression tests added to |
DavidCockerill
left a comment
There was a problem hiding this comment.
The detection design is sound and I verified the protocol invariants it rests on rather than trusting the body: the sender always emits the final end_txn at copyStartTime (replicationConnection.ts:3843-3852) and the receive side suppresses per-record advances while inCopyMode (:4077), so "watermark positive" really does mean "the copy emitted its completion frame". Every can't-tell branch fails closed, including the registration_info version probe, and #649's stall loop is preserved byte-for-byte. The 1 GB live and 10 GB CI evidence is the right kind of evidence and I'm not disputing it.
The blocker is that a resumed clone can never publish Available — detail and the log trace in the thread. It's in this PR's own CI: Integration Tests 3/3 failed 2 of 3 attempts at 3cf4cf14, with the same error, line and file as the blocker currently open on #650. Structural rather than flaky: on a resume the hdb_nodes leader row already exists, so restartWorkers() alone starts and finishes the copy before setNode, and the monitor then polls a watermark of 0 until the stall deadline. A fresh clone can't hit it because no row exists yet — which is exactly why the large-copy runs are green.
One correction worth making explicitly: the #658 attribution for those failures doesn't hold. The line #658 quotes (... armed leading-duplicate fast-skip 127.0.0.3 1 data direct resume cursor 1785944669174) is at line 14043 of the leader's log (…-127_0_0_2-…/hdb.log), describing the leader's reverse subscription back to the clone. The restarted clone's own log in that same run does log Requesting full copy of database data and bulk copy complete 0.4s later. By your own discriminator both failures are healthy resumes with complete data that simply couldn't announce it. The underlying mid-copy cursor hazard in #658 may still be real, but the repro direction needs redoing.
Also, in your favour: the || 1 sentinel is a much narrower concern than the body concedes. Two corrections — the sender discipline it depends on landed in #255 (v5.1.0), not #480; and more decisively, the sentinel only engages when the leader reports no target at all (the RocksDB harper#2091 case or a genuinely empty database). An LMDB leader old enough to emit mid-copy sequence updates reports real targets and takes the strict comparison, so it never sees the sentinel. The intersection is effectively "empty database on a pre-v5.1.0 leader", where an early pass costs nothing. The sentinel's real weakness isn't leader version — it's that 1 has no per-attempt anchor, which is what makes a stale watermark from a prior attempt indistinguishable from this attempt's completion.
On #657 vs #650, since both are open and red for the same reason: I'd converge on this one. It's the smaller, conflict-free base and the only branch with large-copy evidence. But it needs #650's restart watermark restoration ported onto it — scoped by "is this copy known complete", not "is a clone happening", since both prior guards proved unsound across #650's rounds. #650's onCommit watermark move (which this PR's body names as its own unresolved gap) is worth landing as its own PR either way. The airtight version is the one you already name: expose per-database copy-mode exit through cluster_status, which retires the sentinel, the seeding and the !copyCursor guard together.
Reviewed by Claude Opus 5 for @DavidCockerill.
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
DavidCockerill
left a comment
There was a problem hiding this comment.
Round 2 at 65ef2272. Both round-1 findings are closed, and the clone-completion design is better than the port I suggested — replies in the threads.
The short version on the blocker: a random per-clone-operation attemptId, persisted atomically and exported as HARPER_CLONE_ATTEMPT before restartWorkers() so respawned workers inherit it, and re-read rather than re-minted on a resume. Completion filed to on-disk per-database dbisDB at COPY_COMPLETE, invalidated at COPY_START, reseeded under Math.max. I pressed specifically on the stale-value trap and it's genuinely absent: reuse within one clone operation is correct — the copy did finish and the bytes are durable at copyStartTime — while a fresh random id across operations is what makes this sound where !copyCursor and a bare in-progress marker were not. cloneResume.test.mjs is green at head and actually ran: 17.8s against a 120s ceiling, on run_attempt 1 with no retries.
This supersedes my round-1 recommendation. 511bdcb8 is an independent design, not a port of #650's restart seeding — deriveCloneTargets, cloneSyncBaseline and cloneAvailabilityFinalization remain unique to #650, which is unmoved at c8da9d59. So #657 now subsumes #650's purpose, with one exception: #650's onCommit watermark move exists nowhere else and is still worth landing as its own small PR. Suggested order — fix the two below, land #657, land the onCommit move standalone, close the rest of #650.
Two things block the merge, neither in the completion design:
- A fresh clone from a v5 LMDB leader never publishes Available —
systemis required as a socket the clone never opens. Verified as a regression against main:cloneReadinessKeySet.test.mjsholds Unavailable for 158s withcloneCount=4000 missing=0, while the same test/shard/Node on main passes in 11.9s. Detail and my two caveats in the thread. runLinteris red on an unused parameter — one character, but it's a required check.
Worth merging main in before re-running CI: the merge-base is 61a1531b while main is at 3a213e6b, so the base has moved a long way (Sync Core #671 plus the replication setup-watchdog series).
One unrelated observation while I was in the logs: Cluster Integration Tests 5/6 is red on main itself across Node v22/v24/v26, same test and assertion, on every recent run. While it stays red it masks genuine cluster regressions on every PR that touches replication.
Reviewed by Claude Opus 5 for @DavidCockerill.
| checkIntervalMs: DEFAULT_SYNC_CHECK_INTERVAL_MS, | ||
| log, | ||
| requiredSocketDatabases: Object.keys(targetTimestamps).filter( | ||
| (database) => database !== 'system' || systemSocketRequired |
There was a problem hiding this comment.
Blocker — a fresh clone from a v5 LMDB leader never publishes Available. New at this head, and it's a verified regression rather than a flaky test.
getLastUpdatedRecord sets lastUpdated['system'] unconditionally (:613), and this filter keeps system for any leader not positively identified as v4 (systemSocketRequired, :533-545). So on every v5 leader the clone waits for a system replication socket. Against current main it never appears.
Cluster Integration Tests 2/6 at head, integrationTests/cluster/cloneReadinessKeySet.test.mjs:280:
AssertionError: clone never reported Available within 150000ms poll window
[qa762] SUMMARY becameAvailableAt=null predicateFirstSyncedAt=9067
lastSample={"tRel":158211,"availability":"Unavailable","cloneCount":4000,"missing":0,"extra":0}
Complete data at t=9s, still Unavailable at t=158s. The clone's own log says why, once every 3s:
[cloneNode]: Database data: Synchronized
[cloneNode]: Database system: no replication socket to the leader yet
And there's nothing to wait for — the clone opens exactly one subscription (Connecting to ws://127.0.0.2:9933, db: data), the leader likewise, and cluster_status carries one socket, not two. Same test, same shard, same Node version on main @ 3a213e6b: becameAvailableAt=11889, passes in 12s.
Two things I'll be straight about. I can't pin this to the three new commits — systemSocketRequired already existed at 752ebc1e, and this test only landed on main at 4775fda (2026-08-05 18:53Z), about two hours after 752ebc1e's CI ran, so it has never run against this branch before. And I didn't isolate why the system socket stopped appearing; the strongest lead is the newly-sent isLeader: true (:318, :331) flipping writeNodeLocalOnly at setNode.ts:219 so the leader's hdb_nodes row is persisted LOCAL_ONLY — but subscriptionManager.ts:865-867 shows isLeader was already being inferred true for a clone, so I'd rather flag that as unconfirmed than assert it.
Neither caveat changes the outcome: merged into the current base, this wedges a fresh clone that holds a complete, correct copy. It's the round-1 fail-closed wedge, moved from the resume path to the normal one. Your own comment at :587-592 states the invariant — "its socket never exists, so a target would wedge the sync monitor" — it just isn't applied to system.
Property the fix needs: system should only be a required socket when the clone will actually subscribe to it, derived from the same source that decides subscriptions — not asserted from the leader's major version. Mechanism is your call; a soft requirement with the strict version-probe gate kept for the ordering race would also do it.
| isReplicatedDatabase( | ||
| databaseReplications, | ||
| databaseName, | ||
| (entry) => node.shard === env.get(CONFIG_PARAMS.REPLICATION_SHARD), |
There was a problem hiding this comment.
runLinter is red on this line — trivial, but it's a required check so it blocks the merge:
##[error]Parameter 'entry' is declared but never used. Unused parameters should start with a '_'.
Found 57 warnings and 1 error.
_entry fixes it. This is the only new site in the diff that binds an unused parameter by that name — the (entry: any) => boolean occurrences are type annotations and don't trip the rule (they passed lint at 752ebc1e).
The sync monitor required the `system` replication socket on any v5+ leader. `shouldReplicateFromNode` runs every database through `replication.databases`, system included, so a node configured with e.g. `databases: ['data']` never opens a system socket — the clone then waited Unavailable until the stall window expired even though its data was fully copied. Gate the requirement on this node's own `replication.databases` first, and only probe the leader version when the local config actually subscribes to system. Also drops an unused callback parameter that failed oxlint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes #655. On RocksDB,
clone_node's sync verification was vacuous: the describe operations reportlast_updated_recordfor no table (harper#2091 —RocksTransactionLogStore.getKeys()is a TODO stub), so every database's sync target was 0,checkSyncStatusskipped every database, and the first poll declared "All databases synchronized" — the clone marked itself Available and cloned seconds into a multi-GB base copy. This is what failed the Large-Data clone stress run 31006315068 (Clone row count 31393 != leader 104858); green runs passed only because the copy happened to outrun the test's polling grace window.A database without a target is now verified instead of skipped: it requires a positive received-version watermark. The watermark is held at 0 for the whole bulk copy and only becomes positive via the final end_txn the sender always emits at
copyStartTime— the copy's own completion signal — so this works against leaders whose describe can't reportlast_updated_recordand for empty databases. A database with a target but no socket yet also holds completion, so a lone early socket (the system DB's small copy finishes in seconds) can't complete the check vacuously.Follow-up commits refine the socket-presence rule:
cloneFromLegacy): a legacy leader never replicates thesystemdatabase, so requiring every target's socket wedged the clone.checkSyncStatusnow takesrequiredSocketDatabases; a non-required database is still verified whenever its socket exists.system's socket is required is a leader capability, not an ordering heuristic —registration_info(present on every leader version) decides it, so v5+ leaders require the system socket up front, closing the reverse race where a small user database completes before the system subscription registers. The probe fails closed: only a positively-read legacy version exemptssystem.getLastUpdatedRecordnow filters targets byreplication.databases, so a database the clone doesn't subscribe to never becomes a sync target. Sharded entries evaluate the real same-shard predicate (shouldReplicateFromNodesemantics — the leader's shard read viaget_configurationvsreplication.shard), failing closed to keep the target when the leader's configuration is unreadable. The predicate is pure and unit-tested (cloneNode/replicatedDatabases.ts).Where to look
targetTime = targetTimestamps[dbName] || 1plus the missing-socket loop incheckSyncStatus(cloneNode/syncMonitor.ts). Targets and Clone sync monitoring times out only when replication stalls, so large clones no longer fail at a fixed 5-minute cap #649's stall detection are unchanged.replication/replicationConnection.ts(copy completion): the!currentTransaction.txnTimebranch forces a txn for a zero-record copy so the final end_txn is always emitted atcopyStartTime— the code calls it "the sole signal that the copy is synced". Per-record watermark advances are gated on!inCopyMode, mid-copy checkpoint flushes deliberately carry no sequence update, and nothing writes the1sentinel toRECEIVED_VERSION_POSITION(onlySENDING_TIME_POSITIONuses it). Verified live with instrumentation: the data DB's watermark stayed 0 through an entire 1 GB copy and jumped exactly at the final end_txn.Cross-model review — unresolved concerns (deliberate trade-offs)
The pre-push review (codex + gemini + domain adjudication) verdict was CHANGES on one merged major: completion keys on a sender invariant this diff doesn't control. Responses, left open for human judgment:
copyStartTime, which also satisfies the old target-based check (targets < copyStartTimealways), so old-leader clones are no worse than before this PR; new-leader clones become correct.--skip-sync-monitorremains the operator escape hatch. Fail-safe direction chosen deliberately.Verification
unitTests/cloneNode/syncMonitor.test.mjs— 9 new/updated cases (vacuous-pass regression, watermark completion, missing-socket hold, no-target pending slides the stall deadline, v4-leader socketless system, non-required-still-verified, ratchet-holds-on-drop, data-completes-before-system-socket ordering); the originals fail against the pre-fix build (fails-on-base) and 23/23 pass with the fix. Full harper-pro unit suite: 631 passing; the sole failure (injectedKeyCustody) reads/procand is Linux-only — pre-existing on macOS, unrelated.largeClone.test.mjson macOS. Before the fix: clone flippedAvailableat 9.1s mid-copy, failed with 3999/10486 rows (same signature as CI). With the fix: clone stayedUnavailablethrough the whole copy (including wedge/reconnect cycles from the unrelated Bulk-copy send loop wedges with event-loop starvation on macOS; progresses only via watchdog reconnect cycles #656) and the test passed with exact row-count match at 326.5s.cloneFromLegacy(v4 leader) in Cluster 1/6 is the gate for the follow-up commits — validated by the PR CI re-run.Generated by Claude (Opus 5). Root-cause analysis in #655; the macOS copy wedge observed during repro is #656.
Human-Review-Need: 4 @ 65ef227