Skip to content

CIP-005: auto-repair - #26

Merged
ralyodio merged 1 commit into
feat/cip-003-placement-v2from
feat/cip-005-repair
Aug 29, 2026
Merged

CIP-005: auto-repair#26
ralyodio merged 1 commit into
feat/cip-003-placement-v2from
feat/cip-005-repair

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Implements CIP-005. Stacked on #25.

Until now nothing regenerated a lost shard: a degraded block stayed degraded, and every object trended toward unrecoverable on a schedule set by node churn. CIP-001 bought c0mpute's cost advantage by spending the durability margin Storj keeps — RS 10/14 tolerates four losses where RS 29/80 tolerates fifty-one — so fast repair is what makes that trade defensible rather than reckless. This is the piece that was missing.

Verified on a real 24-node testnet

=== round 1: kill 3 shard holders ===
  block 0 — Urgent, 3 shard(s) missing [0, 1, 2]
  repaired 1 block(s), 3 shard(s) regenerated
  block 0 shards [0, 1, 2] → node14, node15, node16

=== round 2: kill 3 of the NEW holders (6 total dead) ===
  repaired 1 block(s), 3 shard(s) regenerated
  block 0 shards [0, 1, 2] → node17, node18, node19

=== six holders dead across two rounds ===
OK: object intact and byte-identical

=== grace window: a briefly-unreachable node is NOT repaired away ===
  paused node17 (simulating a reboot)
  0 block(s) repaired

Six holders dead — past the parity budget of four — and the object survives, because repair restored redundancy between the rounds. Without it that block is gone.

What's in c0mpute-placement::repair

  • Rendezvous election so exactly one holder repairs a block, with no coordination (DIP-0011). Rotates by round, so a dead winner doesn't block a block forever.
  • Flap tolerance. A shard is presumed lost only after grace_probes failures spread over grace_window. Both conditions matter — the count alone condemns a peer from a burst of probes seconds apart. Repairing a rebooting node is how a flap becomes a storm, which is what kills p2p storage networks.
  • Verify before rebuilding. Reconstruct from k, check against the manifest's block hash, then re-encode. Repairing from unchecked bytes would launder a corrupt block into fresh shards that agree with each other and disagree with the manifest.
  • Regenerate only what was lost — rewriting healthy placements would multiply the repair traffic CIP-001 says the margin cannot absorb. A test pins that repair reads exactly k shards (the 10x amplification the cost model budgets for).
  • Priority ordering: Critical first; Lost sorts last despite being worst, because it can't be helped and would starve blocks that can still be saved.

Plus PlacementContext, so replacements are selected against the domains the survivors already occupy. Without it a block drifts into a single failure domain one repair at a time, each repair individually satisfying the cap.

CLI: c0mpute storage repair [hash] [--dry-run] [--now] — sweeps every object by default, writes the manifest back so later reads follow the new homes.

Three problems found by running it — all the same shape

The catalog has no liveness signal, and every layer that assumed otherwise failed in a way that looked like success:

  1. Election picks among a block's holders. An operator running repair holds nothing, so they could never win and every repair deferred forever. Election exists to stop fourteen nodes doing the same job, not to stop anyone doing it — it's now a mode, honoured by the daemon, bypassed on explicit request.
  2. A peer that just died still looks healthy. Reputation and uptime are periodic measurements. Repair selected the node that had just died as the destination for the replacement, reported success, and left the block exactly as degraded.
  3. A peer that died in an earlier round is never probed at all, because it holds none of this block's shards. First sign is the placement failing. Repair now carries spare candidates and fails over — and since any subset of a valid selection is valid (the per-domain cap is a maximum), skipping a dead candidate can't break diversity.

Found by the testnet, not the suite; all three now have regression tests.

Testing

32 new tests (14 unit, 18 integration). Workspace green: 29 suites. Clippy clean on the crate.

Deliberately not here

Documented in CIP-005's new "Still outstanding" section:

  • The scheduled daemon — repair runs on request; the rolling hourly scan needs to live in the worker supervisor.
  • Gossip repair leases — election prevents most duplicate work; leases close the race when two nodes disagree about who's healthy.
  • Signed attestations — the record exists and round-trips as JSON; signing needs CoinPay DIDs (CIP-006).
  • Bandwidth token bucket — fine unthrottled for an operator-invoked pass, not for a background loop on a consumer uplink.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LsQAuvXkmyHTgnvquLHrRx

Implements CIP-005. Until now nothing regenerated a lost shard: a degraded
block stayed degraded, and every object trended toward unrecoverable on a
schedule set by node churn. CIP-001 bought c0mpute's cost advantage by
spending the durability margin Storj keeps — RS 10/14 tolerates four losses
where RS 29/80 tolerates fifty-one — so fast repair is what makes that trade
defensible rather than reckless.

`c0mpute-placement::repair`:

  - Rendezvous election, so exactly one holder repairs a block with no
    coordination (DIP-0011). Rotates by round, so a dead winner does not block
    a block forever.
  - Flap tolerance: a shard is presumed lost only after grace_probes failures
    spread over grace_window. Both conditions matter — the count alone
    condemns a peer from a burst of probes seconds apart. Repairing a
    rebooting node is how a flap becomes a storm.
  - Reconstruct from k, verify against the manifest's block hash before
    re-encoding. Repairing from unchecked bytes would launder a corrupt block
    into fresh shards that agree with each other and not with the manifest.
  - Regenerate only the missing shards; rewriting healthy placements would
    multiply the repair traffic CIP-001 says the margin cannot absorb.
  - Priority ordering (Critical first, Lost last since it cannot be helped)
    and a per-pass cap under storm conditions.

`c0mpute storage repair [hash] [--dry-run] [--now]`, sweeping every object by
default and writing the manifest back so later reads follow the new homes.

Also extends selection with PlacementContext, so replacements are chosen
against the domains the survivors already occupy. Without it a block drifts
into one failure domain one repair at a time, each repair individually legal.

Three problems the tests and testnet found, all the same shape — the catalog
has no liveness signal, and every layer that assumes otherwise fails in a way
that looks like success:

  1. Election picks among a block's holders, so an operator running `repair` —
     who holds nothing — could never win and every repair deferred. Election
     is now a mode: honoured by the daemon, bypassed on explicit request.
  2. A peer that just died still looks healthy in the catalog, because
     reputation and uptime are periodic measurements. Repair selected it as
     the destination for the replacement, "succeeded", and left the block
     exactly as degraded.
  3. A peer that died in an earlier round is not probed at all, because it
     holds none of this block's shards. Repair now carries spare candidates
     and fails over. Any subset of a valid selection is valid — the per-domain
     cap is a maximum — so skipping a dead candidate cannot break diversity.

32 new tests (14 unit, 18 integration). Verified on a 24-node testnet: three
holders killed and repaired onto fresh peers, then three of the *new* holders
killed and repaired again — six dead across two rounds, past the parity budget
of four, with the object still byte-identical. A paused node inside its grace
window is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsQAuvXkmyHTgnvquLHrRx
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

8 finding(s)

HIGH/CRITICAL: 5 | MEDIUM: 3

Severity Rule Location
HIGH sh-remote-script-execution scripts/dev-setup.sh:25
HIGH sh-remote-script-execution scripts/install.sh:159
HIGH sh-remote-script-execution scripts/install.sh:184
HIGH sh-remote-script-execution scripts/install.sh:277
HIGH sh-remote-script-execution scripts/install.sh:294
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:53
MEDIUM js-unescaped-html-sink apps/web/src/app/layout.tsx:89
MEDIUM sh-eval-expansion scripts/dev-setup.sh:35

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit f6e3057 into feat/cip-003-placement-v2 Aug 29, 2026
5 checks passed
ralyodio added a commit that referenced this pull request Aug 29, 2026
…sh-merges) (#27)

* feat(storage): CIP-003 cross-node shard placement

Implements CIP-003. Until now every shard landed on one disk, which meant the
erasure coding was pure overhead with no durability behind it. Blocks are now
spread across n peers chosen for reputation and failure-domain diversity, and
read back from whichever k answer first.

New crate `c0mpute-placement`:

  peer       — PeerInfo, PeerCatalog, and FailureDomain (ASN, falling back to
               IP prefix, then Unknown).
  select     — choosing n peers under CIP-001's rules. Pure; no network I/O,
               because a slow peer lookup must not become a slow write.
  transport  — ShardTransport trait, with HTTP and in-memory implementations.
  distributed— DistributedStorage, composing the three.

Two decisions worth calling out.

**HTTP first, libp2p later.** CIP-003 assumed the libp2p protocol had to be
rewritten before placement could work. It didn't: CIP-002 already ships shard
PUT/GET/HEAD endpoints that verify what they are given, so placement was built
against a transport trait with an HTTP implementation over those. Cross-node
placement works today on a real testnet, and the streaming libp2p protocol
becomes a second implementation of an existing trait rather than a blocker.

**Placement fails loudly.** CIP-001's durability figures assume shard hosts
fail independently; fourteen shards behind one ISP are one sample wearing
fourteen hats, and nothing downstream can detect it. So a write that cannot
satisfy the diversity policy is an error naming the constraint, not a warning:

    failure-domain diversity unsatisfiable: 14 shards at most 2 per domain
    needs 7 distinct domains, but only 1 are available

Greedy selection under a per-domain cap is optimal rather than heuristic — the
cap is a partition matroid, so a refusal means no assignment would have worked.

Also in this change:

  - Write acknowledges at k + ceil(parity/2) (12 of 14 for standard), so two
    slow peers do not fail a write; reads request all n and reconstruct from
    the first k.
  - `c0mpute storage peer add|ls|rm|ping`, a peers.json registry, and put/get/
    info using the network when peers are configured.
  - CIP-003 sketched the peer score with a `1/(1+rtt/100)` latency term. That
    lets a fast flaky peer outrank a slow reliable one, which is the opposite
    of what CIP-001 says matters. Narrowed to a band that breaks ties without
    overturning a reputation gap; a test pins it.

Three bugs found by running it rather than by tests:

  - `c0mpute storage get` still used the local read path, so an object placed
    across the network was unreadable — placement worked and retrieval did not.
  - anyhow's Display drops the cause chain, so the HTTP layer turned "not
    enough eligible peers: need 14, found 3" into "placing block 0". The whole
    point of CIP-003 is failing loudly; six sites now format with `{:#}`.
  - The CLI panicked on SIGPIPE, so `c0mpute storage ls | head` crashed.

43 new tests (27 unit, 16 integration including a real multi-node HTTP test).
Verified on a 16-node testnet driven through the CLI: 14 shards on 14 distinct
peers, byte-identical read back, still readable with 4 holders killed, refused
with 5, and refused outright on a single-domain network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsQAuvXkmyHTgnvquLHrRx

* feat(storage): CIP-005 auto-repair (#26)

Implements CIP-005. Until now nothing regenerated a lost shard: a degraded
block stayed degraded, and every object trended toward unrecoverable on a
schedule set by node churn. CIP-001 bought c0mpute's cost advantage by
spending the durability margin Storj keeps — RS 10/14 tolerates four losses
where RS 29/80 tolerates fifty-one — so fast repair is what makes that trade
defensible rather than reckless.

`c0mpute-placement::repair`:

  - Rendezvous election, so exactly one holder repairs a block with no
    coordination (DIP-0011). Rotates by round, so a dead winner does not block
    a block forever.
  - Flap tolerance: a shard is presumed lost only after grace_probes failures
    spread over grace_window. Both conditions matter — the count alone
    condemns a peer from a burst of probes seconds apart. Repairing a
    rebooting node is how a flap becomes a storm.
  - Reconstruct from k, verify against the manifest's block hash before
    re-encoding. Repairing from unchecked bytes would launder a corrupt block
    into fresh shards that agree with each other and not with the manifest.
  - Regenerate only the missing shards; rewriting healthy placements would
    multiply the repair traffic CIP-001 says the margin cannot absorb.
  - Priority ordering (Critical first, Lost last since it cannot be helped)
    and a per-pass cap under storm conditions.

`c0mpute storage repair [hash] [--dry-run] [--now]`, sweeping every object by
default and writing the manifest back so later reads follow the new homes.

Also extends selection with PlacementContext, so replacements are chosen
against the domains the survivors already occupy. Without it a block drifts
into one failure domain one repair at a time, each repair individually legal.

Three problems the tests and testnet found, all the same shape — the catalog
has no liveness signal, and every layer that assumes otherwise fails in a way
that looks like success:

  1. Election picks among a block's holders, so an operator running `repair` —
     who holds nothing — could never win and every repair deferred. Election
     is now a mode: honoured by the daemon, bypassed on explicit request.
  2. A peer that just died still looks healthy in the catalog, because
     reputation and uptime are periodic measurements. Repair selected it as
     the destination for the replacement, "succeeded", and left the block
     exactly as degraded.
  3. A peer that died in an earlier round is not probed at all, because it
     holds none of this block's shards. Repair now carries spare candidates
     and fails over. Any subset of a valid selection is valid — the per-domain
     cap is a maximum — so skipping a dead candidate cannot break diversity.

32 new tests (14 unit, 18 integration). Verified on a 24-node testnet: three
holders killed and repaired onto fresh peers, then three of the *new* holders
killed and repaired again — six dead across two rounds, past the parity budget
of four, with the object still byte-identical. A paused node inside its grace
window is left alone.


Claude-Session: https://claude.ai/code/session_01LsQAuvXkmyHTgnvquLHrRx

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio deleted the feat/cip-005-repair branch August 29, 2026 14:06
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