Single-attach generation fence (closes split-brain data-loss gap)#82
Merged
Conversation
…loss gap) Under a network partition, instd's node-death recovery can let two nodes attach and write the same GlideFS volume at once -> split-brain -> data loss. A node-lease TTL provably cannot prevent it (a partitioned-but-alive predecessor's lease lapses and a replacement re-forks and re-attaches); only the storage layer can authoritatively stop two writers. This is the GlideFS half of the recovery fix (formal spec 12-recovery-formal-spec.md S6.1, Inv1). The fence is a monotonic generation (the orchestrator's per-instance placement generation) checked at the resource with the rule `my_gen >= stored_gen => Grant` (mirrors instd recovery_kernel::attach_fence verbatim; `>=` admits an idempotent self re-attach, only a strictly newer generation fences an older holder out). gen 0 = back-compat bypass, so existing callers that send no generation are never fenced. Design (manifest-anchored, single source of truth, reuses the existing If-Match manifest CAS -- no new primitive): - block/fence.rs: the pure rule + gen-0 bypass wrapper, unit-tested. - volume_manifest.rs: generation carried as a zero-churn trailer (v7 = generation only, v8 = prefetch+generation), emitted only when > 0 so un-fenced manifests stay byte-identical to v5/v6. - router::enforce_attach_fence + api: ATTACH-time fence. On grant the winner SEIZES the volume by stamping its generation into the manifest via the If-Match CAS, before serving any I/O (a rejected attach -> HTTP 409 with zero packs uploaded, so nothing orphans). The load-bearing half. - write_cache flush/inner/write/error: SYNC-time fence. The generation is stamped on every manifest sync; on a precondition failure the writer re-reads and, if a strictly-newer generation owns the volume, returns a terminal CacheError::Fenced -- the flush scheduler stops and the write path latches closed so the guest stops getting ACKs for writes that can never durably commit. Tested (511 lib tests pass with --features ublk): the pure >= rule incl. equal-gen re-attach and the gen-0 bypass; v5/v7/v8 manifest round-trips (zero churn proven byte-for-byte); a two-router shared-object-store split-brain integration test (stale gen -> 409, newer gen wins, superseded owner's re-attach is fenced); and a write_cache test proving a superseded incumbent is rejected at sync and its write path latches closed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…revision)
Upgrade the single-attach fence from the orchestrator placement generation
alone to the full COMPOSITE token the formal model proves sound
(`token_composite_is_sound`; generation-only is unsound on a same-node
re-fork — `token_orch_only_double_writes`). This makes GlideFS's copy a
byte-for-byte mirror of instd `recovery_kernel::{attach_fence, compose_token}`,
so the spec's audit rule pins the two copies identically instead of GlideFS
enforcing only a subset.
The token is `(generation, lease_revision)` packed lexicographically into a
u128 by `compose_token`: the orchestrator generation dominates (high 64 bits,
fences cross-node re-placement); the node-lease claim revision orders
same-node incarnations (low 64 bits) — a node-death re-fork onto the SAME
node does not advance the generation, so without the lease revision two
incarnations carry equal tokens and neither fences the other.
- fence.rs: `compose_token` + composite `attach_fence`/`fence_attach`. The
back-compat bypass now requires BOTH halves zero; a caller sending only a
lease_revision (generation 0, lease > 0) participates.
- volume_manifest.rs: composite trailer as manifest v9 (composite only) / v10
(prefetch + composite), emitted ONLY when lease_revision > 0 — a
generation-only or un-fenced export stays v5/v7/v8 (zero churn).
- api/router/flush/inner/init: thread lease_revision alongside generation
through the attach fence, the seize, the sync stamp, and runtime state.
Behaviorally identical to the generation-only version until a caller supplies
a non-zero lease_revision (instd plumbing en route). 520 lib tests pass with
--features ublk, incl. new composite cases (generation dominates lease,
same-gen-higher-lease seizes, lease-only caller participates).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Problem
Under a network partition, instd's node-death recovery can let two nodes attach and write the same GlideFS volume at once → split-brain → data loss. A node-lease TTL provably cannot prevent this (a partitioned-but-alive predecessor's lease lapses, a replacement re-forks and re-attaches). Only the storage layer can authoritatively stop two writers. This is the GlideFS half of the recovery fix — the open Inv1 gap in
beyond/compute/docs/12-recovery-formal-spec.md §6.1.Today GlideFS enforces no single-attach: a second node's attach succeeds unconditionally, and the only cross-node guard (the manifest If-Match ETag) fires at sync, after both nodes already uploaded data packs → the loser's packs orphan and its in-memory state diverges. That's data loss, not fencing.
Fix — a monotonic fencing token, checked at the resource
A generation (the orchestrator's per-instance placement generation) checked with
my_gen >= stored_gen ⇒ Grant— mirrors instdrecovery_kernel::attach_fenceverbatim.>=admits an idempotent self re-attach; only a strictly newer generation fences an older holder out. gen 0 = back-compat bypass (callers that send no generation are never fenced), so this is inert until instd plumbs a token.Manifest-anchored, single source of truth, reusing the existing If-Match manifest CAS (no new primitive):
block/fence.rs— the pure rule + gen-0 bypass wrapper.volume_manifest.rs— generation as a zero-churn trailer (v7 = generation only, v8 = prefetch+generation), emitted only when> 0so un-fenced manifests stay byte-identical to v5/v6.router::enforce_attach_fence+api.rs— attach-time fence: on grant the winner seizes the volume by stamping its generation into the manifest via the If-Match CAS, before serving any I/O (a rejected attach → HTTP 409 with zero packs uploaded — nothing orphans).write_cache(flush/inner/write/error) — sync-time fence (the load-bearing half): the generation is stamped on every sync; on a precondition failure the writer re-reads and, if a strictly-newer generation owns the volume, returns a terminalCacheError::Fenced— the flush scheduler stops and the write path latches closed so the guest stops getting ACKs for writes that can never durably commit.Tests
cargo test -p glidefs --features ublk --lib→ 511 passed, 0 failed. New coverage:>=rule incl. equal-gen re-attach and the gen-0 bypass;write_cachetest proving a superseded incumbent is rejected at sync and its write path latches closed.Deployed and verified on the homelab (built
--features ublk; both existing exports re-attach un-fenced via the gen-0 bypass).Scope notes
InstanceAssignment.generationto the attach) is a separate change on the instd side.🤖 Generated with Claude Code