lease: BindRoot, the root layout's per-request authority (Task 5 fix round 1, ruling 2)
A root-generation checkpoint checked its authority once and then ran every replica request through an ordinary client. BindRoot wraps the client at the lease's own path in the same checking client BindGeneration installs, so an authority withdrawn between two requests refuses the second before it reaches storage. Immutable, exclusive with BindGeneration, retention off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+82
−0
internal/lease/claimgeneration.go+33 −0internal/lease/claimgeneration_test.go+49 −0
diff --git a/internal/lease/claimgeneration.go b/internal/lease/claimgeneration.go| index a2f987404..d02f72154 100644 |
| --- a/internal/lease/claimgeneration.go |
| +++ b/internal/lease/claimgeneration.go |
| @@ -46,6 +46,39 @@ func (h *Held) BindGeneration(prefix string, check func(context.Context) error) |
| return nil |
| } |
| +// BindRoot is BindGeneration for the root layout (hostclaim.RootGeneration): |
| +// the replica data sits directly below the held lease's location, so no |
| +// prefix changes, but every replica request still first passes check, the |
| +// authority read before each one (moves §5.3 step 5, ruling G3; Task 5 fix |
| +// round 1, ruling 2). Without it a root-generation checkpoint would check |
| +// its authority once and then run every request unchecked. As immutable as |
| +// BindGeneration, and exclusive with it. Retention stays off under it, as |
| +// under a bound generation: the checking client refuses DeleteLTXFiles. |
| +func (h *Held) BindRoot(check func(context.Context) error) error { |
| + if h == nil || h.leaser == nil || check == nil { |
| + return ErrGenerationBinding |
| + } |
| + base := strings.TrimSuffix(path.Clean(h.leaser.Path), "/") |
| + if base == "." || base == "" { |
| + return ErrGenerationBinding |
| + } |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + if h.busy || h.spent || h.generationPrefix != "" { |
| + return ErrGenerationBinding |
| + } |
| + client := h.replicaClient |
| + if client == nil { |
| + client = newReplicaClient(h.leaser.Bucket, base, h.region) |
| + } |
| + h.replicaClient = &generationClient{client: client, check: check} |
| + // The lease's own path marks the binding, so neither BindGeneration nor |
| + // a second BindRoot can change the client under a running checkpoint, |
| + // and newStore keeps retention off. |
| + h.generationPrefix = base |
| + return nil |
| +} |
| + |
| type generationClient struct { |
| client litestream.ReplicaClient |
| check func(context.Context) error |
diff --git a/internal/lease/claimgeneration_test.go b/internal/lease/claimgeneration_test.go| index 805fa7942..68b87a421 100644 |
| --- a/internal/lease/claimgeneration_test.go |
| +++ b/internal/lease/claimgeneration_test.go |
| @@ -194,3 +194,52 @@ func TestGenerationClientRealRetentionMonitorsDoNotDelete(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| + |
| +// BindRoot gives the root layout the same per-request authority a bound |
| +// generation has (moves Task 5 fix round 1, ruling 2): every replica request |
| +// below the lease's own path first passes check, so an authority withdrawn |
| +// between two requests refuses the second before it reaches storage. It is |
| +// as immutable as BindGeneration, and the two exclude each other. |
| +func TestBindRootChecksEveryRequest(t *testing.T) { |
| + leaser := lss3.NewLeaser() |
| + leaser.Path = "instances/shop.example" |
| + f := &generationFake{} |
| + h := &Held{leaser: leaser, replicaClient: f} |
| + withdrawn := errors.New("the claim moved") |
| + calls := 0 |
| + check := func(context.Context) error { |
| + calls++ |
| + if calls > 1 { |
| + return withdrawn |
| + } |
| + return nil |
| + } |
| + if err := h.BindRoot(check); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.replicaClient.Init(t.Context()); err != nil { |
| + t.Fatalf("first request: %v", err) |
| + } |
| + if _, err := h.replicaClient.LTXFiles(t.Context(), 0, 0, false); !errors.Is(err, withdrawn) { |
| + t.Fatalf("second request after the authority was withdrawn: %v", err) |
| + } |
| + if f.calls != 1 { |
| + t.Fatalf("storage saw %d requests, want the first only", f.calls) |
| + } |
| + if err := h.BindRoot(check); err != ErrGenerationBinding { |
| + t.Fatalf("rebind=%v", err) |
| + } |
| + if err := h.BindGeneration("instances/shop.example/g-a", check); err != ErrGenerationBinding { |
| + t.Fatalf("a generation over the root binding=%v", err) |
| + } |
| + h2 := &Held{leaser: leaser, replicaClient: &generationFake{}} |
| + if err := h2.BindGeneration("instances/shop.example/g-a", check); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h2.BindRoot(check); err != ErrGenerationBinding { |
| + t.Fatalf("the root over a generation binding=%v", err) |
| + } |
| + if err := (&Held{leaser: leaser}).BindRoot(nil); err != ErrGenerationBinding { |
| + t.Fatalf("nil check=%v", err) |
| + } |
| +} |