fix(cluster): re-pull a node's own files after an empty-disk restore; hold the manifest sweep until then - #961
Merged
Merged
Conversation
… hold the manifest sweep until then On per-node storage with file replication the puller assumed a node still holds every file it once wrote: enqueue returned SkippedSelf for any entry whose origin was this node, and the startup catch-up walk fast-pathed the same entries. A node restored with an empty data disk under a stable cluster.node_id pulled every other node's files back and never its own, and its reads of those partitions returned fewer rows with no error, for good. An act-mode reconciliation on that node then found each of those entries missing locally and proposed its deletion, which since #958 every other node carries out on its replica. The walks now let the disk decide on per-node storage (a puller flag the coordinator sets for a local backend): a self-origin entry is checked inline at the manifest's size with the same rule the worker's pre-pull check uses, skipped when present as before, and otherwise catch-up-tagged and pulled from a peer that holds a replica like any other entry. The reactive register of an own file — this node just wrote it — is still never pulled, and on a shared bucket nothing changes, since a missing own object there is not on any peer either. The reconciler checks the manifest-sweep gate before step 4 and, when it is withheld, records manifest_sweep_held on the run and goes on to the storage half; going through the sweep's chunk-boundary check instead aborted the whole run as a lost lease. In main, the local-storage gate withholds the manifest sweep until ReplicationReady() when replication and its catch-up walker are both enabled, and warns at startup when the walker is off, since readiness would never be reached then. Known: an own-origin file no peer holds any more keeps the node from converging and its manifest sweep held until an operator clears it; with the walker disabled neither the re-pull nor the hold applies. Fixes #959
xe-nvdk
added a commit
to efegokdemir/arc
that referenced
this pull request
Oct 1, 2026
Resolves puller.go: keep the Basekick-Labs#961 self-origin fast-path condition (RepullMissingSelfOrigin) and the Basekick-Labs#965 statLocal/presentAtSize pre-pull check, adding this branch's !request.force to the latter.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #959, found during the #957 review. On per-node storage with file replication the puller assumed a node still holds every file it once wrote:
enqueuereturnedSkippedSelffor any entry whose origin was this node, and the startup catch-up walk fast-pathed the same entries. A node restored with an empty data disk under a stablecluster.node_id(the StatefulSet shape) pulled every other node's files back and never its own, and its reads of those partitions returned fewer rows with no error, for good. An act-mode reconciliation on that node then found each of those entries missing locally and proposed its deletion, which since #958 every other node carries out on its replica.RepullMissingSelfOrigin, set by the coordinator when the backend is local. The startup and periodic walks check a self-origin entry inline (selfOriginPresent→statLocal+presentAtSize, the one presence rule the worker's pre-pull check now shares): present at the manifest's size → skipped as before with the same counters; missing, short, or un-stat-able → catch-up-tagged on the startup walk and pulled from a peer's replica like any foreign entry (the resolver already excludes this node as origin and fallback;FetchFileserves any manifest-listed file a peer holds; SHA verified). A short file is fully re-pulled.enqueue's self-origin fast path now applies to the reactive source only (this node just wrote the file) or when the flag is off. Inline rather than through the queue so a writer with many own files does one cheap stat per entry per walk with no queue or inflight churn and no change in whatcatchup_skipped_local/catchup_enqueuedmean. Shared backends keep the old fast paths: the bundledenterprise-sharedrig runs the puller on a shared bucket, where a missing own object is not on any peer either and the check would be one HEAD per own entry per walk.ReconcilechecksShouldRunManifestSweepbefore step 4 and, when withheld, setsrun.ManifestSweepHeld(in the run's JSON, log and audit maps) and goes on to the storage half. The adversarial pass caught that routing a false gate through the sweep's chunk-boundary check instead would abort every run on a restoring node aslease_lost, skip the storage sweep and log an Error. Incmd/arc/main.gothe local-storage gate returnscoordinator.ReplicationReady()for the manifest half when replication and its catch-up walker are both enabled (the gate now takes a three-method interface so it is unit-tested), and warns once at startup when the walker is off, mirroring the query gate's rule, since readiness would never be reached.FullyCaughtUphas a livelock: an own-origin file no peer holds any more keepscatchup_failedup and the node's manifest sweep held — for all its own orphans — until an operator clears it. The narrower predicate would re-open reconciliation: a node restored from an empty disk never re-pulls its own-origin files, and an act-mode run then proposes manifest deletes for them #959 whenever the only replica holder is down during the restore. Stated in the notes with the remedy.## Bug fixes; docs in docs(arc-enterprise): catch-up re-pulls a node's own files; the manifest sweep waits for it docs.basekick.net#89 (clustering page: catch-up re-pulls own files, the manifest sweep waits, the walker-disabled callout).Pre-existing and out of scope, named in the plan: with the walker disabled neither the re-pull nor the hold applies (Warn at startup; keep reconciliation in dry run after a restore); if the startup manifest barrier times out, own files replayed by Raft after that point are recovered by the next periodic pass; a puller that failed to start leaves
ReplicationReady()true; a full-size.partleft by a crash between write and rename reads as present to both checks and is never finalised (to file); the snapshot-install gap. Plan + matrix:docs/progress/2026-09-28-empty-disk-restore-own-origin-959.md(untracked).Configuration matrix
replication_enabled=falsepuller == nilsnapshot inReplicationReadyenterprise-sharedrig)storage.Type() != "local"): walks keep the old fast paths; gate keepsIsActiveCompactorfor both halvesc.storageset before Startmanifest_sweep_held=trueSelfNodeID = c.localNode.ID; resolver excludes self; catch-up tags own entries like foreign onescluster.replication_catchup_enabled=false(non-default)cluster.query_gate_on_catchup=true(non-default)reconciliation.enabled=true, dry run (default), restoring nodemanifest_sweep_held=true;Aborted=false; no Errorreconciliation.manifest_only_dry_run=false(non-default), restoring nodecatchup_failed→ gate red and manifest sweep held on this node until the operator remedycatchup_failed; the periodic walk heals it; the held sweep prevents the act-mode delete meanwhilerunCatchUpOnceorderingReplicationReady()true: no re-pull, no holdprocessEntryclassifies (ErrInvalidPathquarantine)Test plan
go build ./cmd/... ./internal/...,gofmt -lempty,go vetonfilereplication,reconciliation,cmd/arcgo test -race ./internal/cluster/filereplication/ ./internal/reconciliation/green (whole packages);go test -tags=duckdb_arrow ./cmd/arc/ -run TestReconciliationGategreenTestCatchUpPullsMissingSelfOriginFile(pulled,catchup_enqueued=1, converges after),TestCatchUpPullsShortSelfOriginFile(a short.partis fully re-pulled),TestCatchUpSkipsPresentSelfOriginFile(skipped_self,catchup_skipped_local, no fetch, converged),TestReconciliationWalkPullsMissingSelfOriginFile(periodic walk pulls, then skips once present),TestCatchUpKeepsSelfOriginFastPathWithoutRepull(flag off keeps the old behaviour),TestReconcile_HeldManifestSweepStillRunsStorageSweep(held:Aborted=false,ManifestSweepHeld=true,ManifestDeletes=0,StorageDeletes=1),TestReconciliationGate_LocalHoldsManifestSweepUntilCaughtUp(six cases). ExistingTestPullerSkipsSelfOriginpins the reactive skip.manifest sweep: reconciliation: gate revoked mid-run; with the gate's hold bypassed the "local, replicating, not caught up" case returns truecmd/arcdirectory and had to be force-added; H2 the release note's livelock remedy was wrong and unsafe (with the walker off nothing is pulled and the sweep is not held, so an act-mode run deletes every own-origin entry still missing; the restart clears the in-process failure, not the delete) — rewritten to confirm from a dry run first, then two restarts; M1 the reactive exemption with the re-pull on was untested —TestPullerSkipsSelfOriginReactiveWithRepulladded; M2/M3 comments that overstated the stat's bound or described the old self-check fixed; style: step-5 comment, the query gate is opt-in, the startup Warn fires only with reconciliation enabled, a nil-context guard instatLocal.cmd/arc/main.gochanged): Pattern 1 rig built from this branch (enterprise-local: writer1-3 + reader1, replication on, catch-up on, tiering and reconciliation on every node, reconciliation in its default dry run). Six flushes written directly to writer2 so it originates them; six replicas on writer1, writer3 and reader1 within 2 s. Restore 1, data files wiped, Raft and WAL kept: writer2's catch-up completed 4 s after the start, the log shows sixFile pulled from peerlines for the wiped hour (peerarc-writer1:9100), the six files are back on disk, and a dry-run reconciliation on writer2 reportsmanifest_file_count=6 storage_file_count=6 orphan_manifest_count=0 aborted=false manifest_sweep_held=false. Restore 2, the whole volume wiped (Raft, WAL, SQLite, files, license cache): writer2 rejoined as a follower, replayed the log, pulled the same six files back (twelve pull lines for the hour over the two restores), and the dry run reports the same zeros. Before this change the same node came back with every other node's files and none of its own. Aside, pre-existing and unrelated: on both restarts the auth FSM logs Error for replayed token-create entries it refuses as duplicates (token name "..." already exists) — worth its own issue. The hold itself (manifest_sweep_held=true) was not observable live because the six pulls settle within the first seconds; it is covered by the held-sweep test.