Skip to content

Single-attach generation fence (closes split-brain data-loss gap)#82

Merged
jaredLunde merged 2 commits into
mainfrom
jared/single-attach-fence
Jun 24, 2026
Merged

Single-attach generation fence (closes split-brain data-loss gap)#82
jaredLunde merged 2 commits into
mainfrom
jared/single-attach-fence

Conversation

@jaredLunde

Copy link
Copy Markdown
Contributor

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 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 (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 > 0 so un-fenced manifests stay byte-identical to v5/v6.
  • router::enforce_attach_fence + api.rsattach-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 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.

Tests

cargo test -p glidefs --features ublk --lib511 passed, 0 failed. New coverage:

  • 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 fenced, same-gen re-attach idempotent);
  • a write_cache test 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

  • The wired token is the placement-generation (cross-node) half of the formal model's composite token. The same-node half is already covered in GlideFS by the export map + manifest ETag CAS. See spec §6.1.
  • The matching instd plumbing (threading InstanceAssignment.generation to the attach) is a separate change on the instd side.

🤖 Generated with Claude Code

jaredLunde and others added 2 commits June 23, 2026 20:20
…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>
@jaredLunde jaredLunde merged commit 2181ca0 into main Jun 24, 2026
24 of 25 checks passed
@jaredLunde jaredLunde deleted the jared/single-attach-fence branch June 24, 2026 04:14
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