Skip to content

fix(reconciliation): stop reporting replicated files as orphan storage on per-node clusters - #960

Merged
xe-nvdk merged 2 commits into
mainfrom
fix/reconciler-replica-orphans
Sep 28, 2026
Merged

xe-nvdk merged 2 commits into
mainfrom
fix/reconciler-replica-orphans

Conversation

@xe-nvdk

@xe-nvdk xe-nvdk commented Sep 28, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #957. On a per-node-storage cluster the reconciler scoped the manifest to this node's own-origin entries before both the storage walk and the diff, on the assumption that other nodes' files could not be on this disk. With file replication on (or storage.backend=local over a shared mount) every node holds every file, so each run reported the replicas of every other node as orphan storage: nearly the whole manifest on a writer, all of it on a reader. Nothing was deleted — the storage sweep re-checks each candidate against the manifest — but that re-check is a race guard doing a job it was never meant to do, the dry-run audit an operator reviews before turning the dry run off was unusable, and each run spent a manifest-sized FSM lookup pass rejecting replicas. The same scoping fed prefix derivation; the root walk skips every database this node has an entry in, so a measurement only other nodes write was never walked here and a genuine orphan under it was invisible (at the default root-walk setting, not only at zero — measured by the adversarial pass).

  • Membership set = every manifest entry. computeDiff builds its map from the full key set; a tracked path is never an orphan-storage candidate, whatever its origin.
  • Origin scoping only in the orphan-manifest direction, inside computeDiff (localNodeID, perNodeStorage): in per-node mode an entry another node originated that is missing from this disk is skipped — the puller's business with replication, another disk without it. Origin-less (pre-Phase-1) entries stay candidates on every node, as before. The invalid perNodeStorage=true, localNodeID="" fails safe (reports nothing); NewReconciler rejects it anyway.
  • Prefixes from the full manifest, so measurements other nodes write are walked too. Free on a per-node disk: the local backend lists a missing directory as empty, no WalkPartial, and foreign databases make the root walk cheaper.
  • manifestToKeys removed; the two comments that encoded the wrong assumption rewritten; the sweep re-check stays as defence in depth.
  • Wiring (cmd/arc/main.go): LocalNodeID: clusterCoordinator.LocalNodeID() instead of cfg.Cluster.NodeID. The coordinator generates an id when the key is unset and every producer of OriginNodeID uses LocalNodeID(); with the raw value a bare-metal node without an explicit id got "Failed to create reconciler — feature disabled" and the fix would be unreachable there. Helm always sets the id.
  • Release-notes entry under ## Bug fixes.

Pre-existing, named and not solved here: an entry whose origin node left or was renamed is foreign to every survivor, so a file lost everywhere is reported by nobody (the documented contract: the manifest sweep runs on the origin writer); origin-less entries may be proposed for deletion by several nodes (FSM no-op on the second, audit double-count); the snapshot-install gap (FSM.Restore never fires the delete callback); and, found by the review, a node restored from an empty disk under a stable cluster.node_id never re-acquires its own-origin files (the puller and catch-up skip self-origin), so an act-mode run there proposes a manifest delete for each — identical under the old filter, kept a report by the default dry run, filed as #959. Plan + matrix: docs/progress/2026-09-28-reconciler-replica-orphans-957.md (untracked).

Configuration matrix

Configuration Reaches changed code? Preconditions established?
reconciliation.enabled=false (default) no — scheduler and reconciler never built n/a
OSS / no cluster no — clusterCoordinator == nil at the same gate n/a
BackendStandalone test-only: reconciliationBackendKind never returns it; perNodeStorage=false → unchanged n/a
Shared bucket (Pattern 2) perNodeStorage=false — membership set was already the full set; orphan-manifest unchanged no new deref
Per-node, replication off membership now includes foreign entries absent here (a superset only removes false orphan-storage candidates); orphan-manifest still skips them; foreign prefixes list empty at no cost LocalNodeID != "" via NewReconciler, now the coordinator's id
Per-node, replication on (the bug) replicas no longer candidates; SkippedRecheck no longer inflated; foreign-only measurements walked same
Local backend on a shared mount same symptom pre-fix; correct post-fix same
Readers in local mode pre-fix ~100 % of the manifest reported; post-fix zero gate lets every node run both halves
Compacted files (compactor's origin) foreign replicas on every writer; covered by the same rule same
cluster.node_id unset (non-default) pre-fix: empty id rejected, reconciliation silently off; post-fix: the coordinator's generated id clusterCoordinator != nil at the gate
reconciliation.max_root_walk_databases=0 (non-default) foreign-only measurements now reached through derived prefixes unchanged budget
reconciliation.grace_window_seconds=1, clock_skew_allowance_seconds=0 (non-default, live-run values) replicas old enough to have been false candidates; post-fix none unchanged
reconciliation.manifest_only_dry_run=false (non-default) act mode receives no replica candidates at all MaxDeletesPerRun unaffected
max_manifest_size exceeded aborts before any changed code n/a
Renamed cluster.node_id old entries foreign everywhere: never orphan-manifest (as before); no longer false orphan-storage on the renamed node net improvement

Test plan

  • go build ./cmd/... ./internal/..., gofmt -l empty, go vet ./internal/reconciliation/ ./cmd/arc/
  • go test -race ./internal/reconciliation/ green (whole package)
  • New: TestReconcile_LocalModeReplicaOfForeignFileIsNotOrphanStorage (the reconciliation: local-mode origin filter reports every replicated foreign-origin file as orphan storage; only the sweep re-check prevents deletion #957 probe, dry-run and act mode, plus SkippedRecheck == 0), TestReconcile_LocalModeOwnEntryMissingIsOrphanManifest, TestReconcile_LocalModeWalksForeignPrefixes (default root-walk cap; same database, measurement only node-b writes, true orphan under it), TestComputeDiff_PerNodeStorage (scoped, unscoped, fail-safe). Existing TestReconcile_LocalModeFiltersForeignOriginFiles keeps passing.
  • Pre-fix proofs (revert-run-restore), split by half: with the old origin filter put back in front of the walk and the diff, the probe fails in both modes (OrphanStorageCount=1, SkippedRecheck=1) and the foreign-prefix test fails (StorageFileCount=1, want 3); with only the orphan-manifest scoping removed, the existing local-mode test and the own-entry test report the foreign entry (count=2, got 1 (sample: [db/m/foreign.parquet])) and the diff unit test fails
  • Review: one deep reviewer with the matrix (every row confirmed by trace, including a by-hand pre-fix trace of the probe; no code finding; verdict "correct and safe to merge"). Its three wording items are fixed in this PR: the LocalNodeID field doc and the BackendLocal/main.go comments described origin scoping of orphan-storage candidates, the opposite of the code; the note's "silently" was wrong (the empty id was refused with a startup Error and the feature disabled); and the never-walked claim now carries its qualifier (a database this node already had an entry in). Also from the review, stated in the note: the generated node id is host name plus PID, so on bare metal it changes per restart and entries from before the last restart count as another node's; set cluster.node_id explicitly.
  • Live (binary run, since cmd/arc/main.go changed): Pattern 1 rig built from this branch (enterprise-local, writer1-3 + reader1, replication on, tiering on every node) with reconciliation.enabled=true, grace_window_seconds=1 and clock_skew_allowance_seconds=1 (all non-default; dry run at its default). Every node built its reconciler (no "Failed to create reconciler" line; backend_kind=local). Six flushes on the primary → six replicas on reader1 and on writer2 within 2 s → dry-run reconciliation triggered on reader1, writer2 and the primary: each reports manifest_file_count=6 storage_file_count=6 orphan_storage_count=0 orphan_manifest_count=0 skipped_recheck=0 skipped_grace=0; re-run minutes later with the two-second window (grace 1 s + skew 1 s) over the same replicas: the same zeros on reader1 and writer2, where all six files are foreign-origin and would each have been an orphan-storage candidate before the fix. Aside found on the way, not changed here: clock_skew_allowance_seconds=0 is replaced by the 5-minute default (applyDefaults treats <= 0 as unset), so zero is not a settable value for that key; the run used 1.

…e on per-node clusters

On a per-node-storage cluster the reconciler scoped the manifest to the
entries this node had originated before both the storage walk and the
diff, on the assumption that other nodes' files could not be on this disk.
With file replication on (or a local backend over a shared mount) every
node holds every file, so each run reported the replicas of every other
node as orphan storage — nearly the whole manifest on a writer, all of it
on a reader. Nothing was deleted, because the storage sweep re-checks each
candidate against the manifest, but that re-check is a race guard doing a
job it was never meant to do, the dry-run audit was unusable, and each run
spent a manifest-sized FSM lookup pass rejecting replicas. The same scoping
fed prefix derivation, and the root walk skips every database this node
has an entry in, so a measurement only other nodes write was never walked
here and a genuine orphan under it was invisible.

The membership set is now every manifest entry — a tracked path is never
an orphan-storage candidate, whatever its origin — and the origin scoping
moves into computeDiff, applied to the orphan-manifest direction only: an
entry another node originated that is missing from this disk is left to
file replication. manifestToKeys is gone; the walk derives its prefixes
from the full manifest. In cmd/arc/main.go the reconciler gets the
coordinator's node id instead of the raw cluster.node_id: the coordinator
generates one when the key is unset and stamps it into every manifest
entry, while the raw value was empty there, refused by the reconciler at
startup with an error and the feature disabled.

Regression test: the #957 probe, now also asserting the sweep re-check is
never exercised for a replica; plus the orphan-manifest direction pinned
in both senses and the foreign-prefix walk at the default root-walk cap.

Fixes #957
@xe-nvdk
xe-nvdk merged commit 755da34 into main Sep 28, 2026
7 checks passed
@xe-nvdk
xe-nvdk deleted the fix/reconciler-replica-orphans branch September 29, 2026 00:42
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.

reconciliation: local-mode origin filter reports every replicated foreign-origin file as orphan storage; only the sweep re-check prevents deletion

1 participant