Skip to content

Resharding: S3-mediated space migration between tree nodes#227

Open
cheggaaa wants to merge 17 commits into
mainfrom
resharding
Open

Resharding: S3-mediated space migration between tree nodes#227
cheggaaa wants to merge 17 commits into
mainfrom
resharding

Conversation

@cheggaaa

@cheggaaa cheggaaa commented Jul 4, 2026

Copy link
Copy Markdown
Member

Implements resharding — safely adding and removing tree nodes on a running network. Postponed for years; researched and designed in docs/resharding-plan.md, operated per docs/resharding-operations.md. Depends on anyproto/any-sync#721 and pairs with anyproto/any-sync-coordinator#132.

Design in one paragraph

Placement stays a pure function of the chash ring; the coordinator versions configs with a monotonic epoch and refuses transitions that would leave a partition without a live replica. On a config change each node drains the spaces it no longer owns through the shared archive bucket ("force-archive + rename"): archived spaces are handed over by server-side CopyObject + an AdoptArchive pointer RPC (zero restores); live spaces ship as a snapshot with the delta converging via normal tree sync. A local copy is deleted only after ≥2 current owners durably confirm exactly the advertised heads (read from the snapshot itself). The machinery is dormant unless s3Store.shared: true — self-hosted networks are unaffected.

Main pieces

  • archivestore: Exists (list-based — GCS interop serves stale HEAD after DELETE), CopyFrom (server-side copy), List, shared flag
  • archive: ForceArchive (snapshot without touching the live db, returns snapshot heads), restore-as-copy, eager-restore queue, daily prefix sweeper (age + fresh-disk guards)
  • archive/adopter: receiver side of AdoptArchive — head-aware responses (Ok / AlreadyHaveSame / AlreadyHaveDiverged), responsibility + node-peer checks (with config-history fallback so removed nodes can finish draining), tombstone awareness
  • resharder: the drain state machine — candidates on config change + periodic cycle, metadata-first verification, hotsync convergence for diverged owners, heads recheck before deletion, SpaceStatusMoved, legacy-orphan reconciliation
  • spacedeleter: archived spaces delete their S3 object directly; cancelled deletions keep archived status
  • nodestorage: .diskgen fresh-storage marker
  • Prometheus: node_resharder_state/epoch/draining/moved/parked/errors/last_cycle_unix, node_adopter_*

Review

A high-effort multi-agent review ran over the branch; 9 confirmed data-loss findings were fixed in dedicated commits (snapshot-derived handoff heads, cancellable-deletion handling, sweeper/cancel interactions, fresh-disk sweeper guard, repair-path restore trap, empty-owner-list guard, non-owner adoption, epoch races).

Testing

  • unit tests per package
  • scenario tests over real chash rings: TestScenario_AddNode, TestScenario_RemoveNode (resharder/scenarios_test.go)
  • integration test of drain → adopt → eager restore with real storage stacks; runs against a real bucket when ARCHIVE_TEST_S3_* env vars are set — verified against Google Cloud Storage S3 interop
  • manual full-network runbook: docs/resharding-e2e.md

Notes for merge

🤖 Generated with Claude Code

https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom

cheggaaa and others added 10 commits July 4, 2026 22:03
.diskgen marker in the storage root; DiskGen().Fresh() reports a node
started on an empty root (new node or replaced disk) so resharding
machinery can treat local absence as 'missing', never 'deleted'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
…er, AdoptArchive RPC

The data plane for resharding: spaces move between tree nodes as archive
snapshots inside a shared S3 bucket, never streamed node-to-node.

- archivestore: Exists (HEAD), Key, CopyFrom (server-side CopyObject),
  Shared config flag gating the migration machinery
- archive.ForceArchive: snapshot a live space to S3 without deleting the
  local DB or flipping its status (uses DumpStorage, works on open spaces)
- archive.Restore keeps the S3 object (copy, not move): idempotent restores;
  stale objects are cleaned by the next archive cycle / archive sweeper
- archive.QueueRestore + background worker: eager restore of adopted spaces
- AdoptArchive RPC (nodesyncproto): receiver copies the offered snapshot into
  its own prefix, verifies with HEAD, registers Archived+heads in its index
  and nodehead; node-only, rejects tombstoned spaces, repairs lost objects
- indexstorage.MarkArchivedRemote: register a never-opened space as archived

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
The draining side of resharding, gated on the shared archive store:

- resharder component: on nodeconf change (and periodically), finds local
  spaces this node is no longer responsible for and hands each off through
  the shared bucket: archived spaces via CopyObject+AdoptArchive with zero
  restores; live spaces via ForceArchive snapshot + eager adoption, with the
  post-snapshot delta converged by tree sync
- deletion safety: local copy removed only after >=2 current owners durably
  hold the same heads (adopt Ok / AlreadyHaveSame); diverged owners trigger
  hotsync convergence and the space parks for the next cycle; heads recheck
  before deletion catches writes landed mid-handoff
- AdoptArchive responses are head-aware (AlreadyHaveSame vs Diverged) so a
  stale owner copy can never justify deleting the newest replica
- SpaceStatusMoved terminal status; drained spaces leave ldiff/nodehead
- spacedeleter: archived spaces delete their S3 object directly instead of
  restoring first; live deletions clean up leftover archive objects

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
- archivestore.List: paginated iteration of this node's prefix
- daily archive sweep: deletes objects that no longer back an Archived index
  entry (restore leftovers, finished handoffs, deleted spaces, wiped-disk
  leaks); a 7-day age guard protects in-flight handoffs and fresh restores
- resharder reconciles space dirs without index entries before each drain
  cycle so legacy orphans become drainable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
…+ real-S3 tests

Verified against Google Cloud Storage S3 interop: HEAD responses can be
stale for seconds after a DELETE (observed 200 for a just-deleted object
while ListObjectsV2 already showed it gone). Exists backs the durable-ACK
check of the drain handoff, where a stale positive could acknowledge an
object that no longer exists — reimplemented over ListObjectsV2 with an
exact-key match, which proved read-after-write consistent.

- TestRealS3 (archivestore): opt-in conformance test for Put/Get/Exists/
  CopyFrom/List/Delete against a real bucket (ARCHIVE_TEST_S3_* env vars)
- the drain integration test now runs against the real bucket too when the
  same env vars are set; verified against GCS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
Findings from a high-effort multi-agent review, all confirmed and fixed:

- drain handoff: heads are now read from the snapshot itself (ForceArchive
  returns them) so ACKed hashes exactly describe the uploaded object; the
  drain parks when the index moves past the snapshot, and the pre-delete
  recheck compares against the snapshot heads
- adopter: RemovePrepare (cancellable) no longer reads as 'deleted' — new
  ErrSpacePendingDeletion parks the sender instead of dropping its copy;
  only the final Remove status returns ErrSpaceDeleted
- adopter: receivers reject AdoptArchive for spaces they are not responsible
  for (ErrNotResponsible) so a stale/misbehaving sender can't count a
  non-owner as a durable ACK
- resharder: empty owner list parks instead of deleting with zero handoffs
  (a force-applied broken config could otherwise wipe drained partitions)
- resharder: the archived-but-object-missing repair path flips the status to
  Ok before snapshotting — previously it triggered a doomed restore whose
  error path deleted the only remaining local db
- archive.Restore: no longer removes the space dir when the archive object
  is missing (nothing was extracted; the dir may hold a live db)
- sweeper: serialized with the archiver via a mutex, decisions re-read the
  index under the lock; Ok-status objects are only collected when the local
  db actually exists; unknown objects are kept until the storage root is 30
  days old (fresh-disk protection); Moved/Remove are collected as before
- spacedeleter: deletion-cancel keeps archived spaces Archived (previously
  flipped to Ok, presenting the only copy as sweeper garbage); final
  deletion also removes lingering space dirs of archived spaces
- coordinator: unique partial index on epoch + duplicate-key retry makes
  epoch assignment safe under concurrent confapply runs

Verified: full unit suites + drain integration test against real GCS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
- adopter recognizes senders that were tree nodes in recently retained
  config epochs (nodeconf history): a node removed from the configuration
  can finish draining after the removal — receivers no longer reject it
  as a non-node
- scenario tests over real chash rings (resharder/scenarios_test.go):
  TestScenario_AddNode — epoch bump adds a node; losers hand affected
  spaces off with 2-owner ACKs and delete their copies; unaffected spaces
  stay put. TestScenario_RemoveNode — removed node drains everything to
  the current owners (authenticated via config history) and ends up empty;
  every space remains available on all remaining owners
- docs/resharding-operations.md: operator guide for the two supported
  operations (prerequisites, steps, metrics to watch, safety properties);
  linked from README

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
- node_resharder_state (0 disabled / 1 idle / 2 draining),
  node_resharder_epoch (config epoch acted on — shows propagation),
  node_resharder_last_cycle_unix, node_resharder_errors
- adopter gets its own metrics: node_adopter_adopted / already_have_same /
  already_have_diverged / rejected (the archive-level 'adopted' gauge was
  registered but never incremented — removed)
- operator guide updated with the full metric list and idle-state 'done'
  definition

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
…fore merge)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
@socket-security

socket-security Bot commented Jul 4, 2026

Copy link
Copy Markdown

@socket-security

socket-security Bot commented Jul 4, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: golang github.com/libp2p/go-libp2p is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?golang/github.com/anyproto/any-sync@v0.12.15-0.20260704195630-ae5feaa3bd4dgolang/github.com/libp2p/go-libp2p@v0.48.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore golang/github.com/libp2p/go-libp2p@v0.48.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: golang golang.org/x/tools is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?golang/golang.org/x/exp@v0.0.0-20260603202125-055de637280bgolang/golang.org/x/net@v0.56.0golang/github.com/anyproto/any-store@v0.4.7golang/github.com/anyproto/any-sync@v0.12.15-0.20260704195630-ae5feaa3bd4dgolang/go.uber.org/mock@v0.6.0golang/golang.org/x/tools@v0.47.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore golang/golang.org/x/tools@v0.47.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown

New Coverage 51.7% of statements
Patch Coverage 56.1% of changed statements (381/679)

Coverage provided by https://github.com/seriousben/go-patch-cover-action

cheggaaa and others added 7 commits July 5, 2026 01:13
… client code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
…fig changes

A config publish that only changes addresses or non-tree nodes produces an
identical chash ring — no space changes hands. The change observer now
compares tree member sets and skips the (guaranteed-empty) full index scan
for such epochs; the periodic cycle remains as a backstop. Correctness was
already given (zero candidates); this removes the wasted work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
An Error entry needs an operator: the local db may hold data that failed to
archive. Adopting a snapshot over it would let the later restore overwrite
that data, and ACKing the sender could justify deleting the freshest copy.
Error owners now answer 'diverged' — no adoption, no ACK; the two healthy
owners carry the handoff. Operator guide gains an Error-state playbook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
Periodic per-node repair (default hourly) for spaces in Error status that
the node is responsible for:
- repair in place when the db opens fine (transient errors like a failed
  archive upload) — no data touched
- otherwise quarantine the corrupted db under <root>/.quarantine (preserved
  for the operator) and pull a valid copy from a responsible neighbor via
  the regular coldsync (works without a shared bucket), validating it
  before the Error status clears
- nodestorage.QuarantineSpace + IndexStorage.ReadSpacesByStatus
- metrics: node_repairer_errored/repaired/repaired_in_place/quarantined
- operator guide updated: manual playbook shrinks to the cases the repairer
  deliberately leaves alone

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
…anywhere

Tracing the all-replicas-broken scenarios surfaced a bricking bug: an Error
entry blocks space creation (checkStatus), so an orphaned entry of an
uncommitted space push — no data on any owner, no heads ever recorded —
would keep the space id unusable forever and alert forever.

- coldsync already returns coded ErrSpaceMissing when the source
  definitively lacks the space; the repairer now distinguishes that from
  unreachable/broken owners
- when every owner definitively lacks the space, we hold nothing and the
  entry has no recorded heads, the entry is deleted outright: the id becomes
  usable again and the alert clears (node_repairer_dropped_entries)
- if heads WERE recorded, the entry stays Error: that is a real data-loss
  signal that must keep alerting

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
Two real node stacks with an in-process drpc pair; node B holds a real
GenStorage space, node A repairs through the actual protocol:
- corrupted db: quarantined under .quarantine, valid copy pulled from B,
  status Ok, heads registered from the pulled state, storage opens as a
  valid space
- missing db file: pulled from B without quarantine
- space missing everywhere: B answers coded ErrSpaceMissing over the wire
  and the heads-less entry is garbage-collected

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDgytoFFikB7tY4oRreWom
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.

1 participant