fix: derive WAL pin from actual replay need, PlanRebuild fails closed

WAL pin tied to actual recovery contract:
- Truncation-only (replica ahead): no WAL pin acquired
- Real catch-up: pins from replicaFlushedLSN (actual replay start)
- Logs distinguish plan_truncate_only from plan_catchup

PlanRebuild precondition checks:
- Error on missing sender
- Error on no active session
- Error on non-rebuild session kind
- All fail closed with clear error messages

4 new tests:
- ReplicaAhead_NoWALPin: truncation-only, no WAL resources
- PlanRebuild_MissingSender: returns error
- PlanRebuild_NoSession: returns error
- PlanRebuild_NonRebuildSession: returns error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
pingqiuandClaude Opus 4.6 committed 2026-03-30 12:51:38 -07:00
1 parent d4f7697dd8
commit 45bf111ce8
2 files changed
+111 -11

No files matched your search

@@ -316,6 +316,86 @@ func TestDriver_PlanRecovery_ReplicaAhead_Truncation(t *testing.T) {
driver.ReleasePlan(plan)
}
// --- Truncation-only: no WAL pin needed ---
func TestDriver_PlanRecovery_ReplicaAhead_NoWALPin(t *testing.T) {
storage := newMockStorage(RetainedHistory{
HeadLSN: 100, TailLSN: 0, CommittedLSN: 100,
})
driver := NewRecoveryDriver(storage)
driver.Orchestrator.ProcessAssignment(AssignmentIntent{
Replicas: []ReplicaAssignment{
{ReplicaID: "r1", Endpoint: Endpoint{DataAddr: "r1:9333", Version: 1}},
},
Epoch: 1,
RecoveryTargets: map[string]SessionKind{"r1": SessionCatchUp},
})
// Replica ahead — truncation only, no WAL replay.
plan, err := driver.PlanRecovery("r1", 105)
if err != nil {
t.Fatal(err)
}
if plan.RetentionPin != nil {
t.Fatal("truncation-only should NOT pin WAL")
}
if plan.TruncateLSN != 100 {
t.Fatalf("truncate=%d, want 100", plan.TruncateLSN)
}
if len(storage.pinnedWAL) != 0 {
t.Fatal("no WAL pins should exist for truncation-only")
}
}
// --- PlanRebuild precondition checks ---
func TestDriver_PlanRebuild_MissingSender(t *testing.T) {
storage := newMockStorage(RetainedHistory{CommittedLSN: 100})
driver := NewRecoveryDriver(storage)
_, err := driver.PlanRebuild("nonexistent")
if err == nil {
t.Fatal("should fail for missing sender")
}
}
func TestDriver_PlanRebuild_NoSession(t *testing.T) {
storage := newMockStorage(RetainedHistory{CommittedLSN: 100})
driver := NewRecoveryDriver(storage)
driver.Orchestrator.ProcessAssignment(AssignmentIntent{
Replicas: []ReplicaAssignment{
{ReplicaID: "r1", Endpoint: Endpoint{DataAddr: "r1:9333", Version: 1}},
},
Epoch: 1,
// No recovery target → no session.
})
_, err := driver.PlanRebuild("r1")
if err == nil {
t.Fatal("should fail when no session exists")
}
}
func TestDriver_PlanRebuild_NonRebuildSession(t *testing.T) {
storage := newMockStorage(RetainedHistory{CommittedLSN: 100})
driver := NewRecoveryDriver(storage)
driver.Orchestrator.ProcessAssignment(AssignmentIntent{
Replicas: []ReplicaAssignment{
{ReplicaID: "r1", Endpoint: Endpoint{DataAddr: "r1:9333", Version: 1}},
},
Epoch: 1,
RecoveryTargets: map[string]SessionKind{"r1": SessionCatchUp}, // NOT rebuild
})
_, err := driver.PlanRebuild("r1")
if err == nil {
t.Fatal("should fail when session is not rebuild")
}
}
// --- Full-base rebuild pin ---
func TestDriver_PlanRebuild_FullBase_PinsBaseImage(t *testing.T) {