Expose permanent lease loss notification across handoff
3 files changed,
+95
−7
internal/lease/checkpoint_test.go+71 −1internal/lease/health.go+15 −6internal/lease/lease.go+9 −0
diff --git a/internal/lease/checkpoint_test.go b/internal/lease/checkpoint_test.go| index 2fb063635..b3047e00b 100644 |
| --- a/internal/lease/checkpoint_test.go |
| +++ b/internal/lease/checkpoint_test.go |
| @@ -216,6 +216,14 @@ func TestHeldCheckpointVacuumRestoreAndTransfer(t *testing.T) { |
| t.Fatal(e) |
| } |
| defer r.Close(context.Background()) |
| + if r.Lost() != h.Lost() { |
| + t.Fatal("transfer replaced loss channel") |
| + } |
| + select { |
| + case <-r.Lost(): |
| + t.Fatal("healthy transfer signaled loss") |
| + default: |
| + } |
| if r.rn != rn { |
| t.Fatal("renewer replaced") |
| } |
| @@ -237,6 +245,11 @@ func TestHeldCheckpointVacuumRestoreAndTransfer(t *testing.T) { |
| if _, e = r.SafeUntil(); e == nil { |
| t.Fatal("stopped deadline accepted") |
| } |
| + select { |
| + case <-r.Lost(): |
| + t.Fatal("normal Close signaled loss") |
| + default: |
| + } |
| } |
| type blockedReplica struct { |
| @@ -335,7 +348,7 @@ func TestBlockedRenewalDeadlinePrecedesTakeover(t *testing.T) { |
| } |
| await(t, entered) |
| // A worker observes cancellation without waiting for the blocked remote call. |
| - await(t, h.rn.ctx.Done()) |
| + await(t, h.Lost()) |
| if e = h.Err(); e == nil { |
| t.Fatal("blocked renewal remained healthy") |
| } |
| @@ -612,3 +625,60 @@ func TestCheckpointChecksCloseAfterSuccessfulSync(t *testing.T) { |
| t.Fatalf("close failure stopped ownership: %v", e) |
| } |
| } |
| + |
| +func TestRenewalFailureSignalsLostBeforeDeadline(t *testing.T) { |
| + backend := &lockStore{beforePut: func(ctx context.Context, p *s3.PutObjectInput) error { |
| + if p.IfMatch != nil { |
| + return &smithy.GenericAPIError{Code: "PreconditionFailed"} |
| + } |
| + return nil |
| + }} |
| + h := localHeld(t, backend, 3*time.Second) |
| + until, e := h.SafeUntil() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + loss := h.Lost() |
| + await(t, loss) |
| + if !time.Now().Before(until) { |
| + t.Fatal("renewal refusal was signaled only at deadline") |
| + } |
| + if e = h.Err(); e == nil { |
| + t.Fatal("loss notification without failed health") |
| + } |
| + if _, e = h.SafeUntil(); e == nil { |
| + t.Fatal("loss notification left safe deadline usable") |
| + } |
| + if h.Lost() != loss { |
| + t.Fatal("loss channel replaced") |
| + } |
| +} |
| + |
| +func TestHealthyReleaseDoesNotSignalLost(t *testing.T) { |
| + h := localHeld(t, &lockStore{}, 300*time.Millisecond) |
| + until, e := h.SafeUntil() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + loss := h.Lost() |
| + if e = h.Release(context.Background()); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = h.SafeUntil(); !errors.Is(e, ErrSpent) { |
| + t.Fatal("released handle remained usable") |
| + } |
| + time.Sleep(time.Until(until) + 30*time.Millisecond) |
| + select { |
| + case <-loss: |
| + t.Fatal("normal release masqueraded as loss") |
| + default: |
| + } |
| + if h.Err() == nil { |
| + t.Fatal("spent handle remained healthy") |
| + } |
| + select { |
| + case <-loss: |
| + t.Fatal("late health check invented loss after release") |
| + default: |
| + } |
| +} |
diff --git a/internal/lease/health.go b/internal/lease/health.go| index dca775606..69814f15c 100644 |
| --- a/internal/lease/health.go |
| +++ b/internal/lease/health.go |
| @@ -40,29 +40,37 @@ type renewer struct { |
| done chan struct{} |
| watchDone chan struct{} |
| changed chan struct{} |
| + lost chan struct{} |
| } |
| func startRenewer(leaser *lss3.Leaser, lease *litestream.Lease, started time.Time, margin time.Duration) *renewer { |
| ctx, cancel := context.WithCancel(context.Background()) |
| - rn := &renewer{ctx: ctx, leaser: leaser, lease: lease, until: started.Add(leaser.TTL - margin), margin: margin, cancel: cancel, done: make(chan struct{}), watchDone: make(chan struct{}), changed: make(chan struct{}, 1)} |
| + rn := &renewer{ctx: ctx, leaser: leaser, lease: lease, until: started.Add(leaser.TTL - margin), margin: margin, cancel: cancel, done: make(chan struct{}), watchDone: make(chan struct{}), changed: make(chan struct{}, 1), lost: make(chan struct{})} |
| go rn.watch(ctx) |
| go rn.renew(ctx) |
| return rn |
| } |
| +func (rn *renewer) loseLocked(err error) { |
| + if rn.err == nil { |
| + rn.err = err |
| + close(rn.lost) |
| + } |
| +} |
| + |
| // checkLocked latches loss permanently; an acknowledgement arriving after a |
| // blocked renewal cannot resurrect ownership, even before the timer runs. |
| func (rn *renewer) checkLocked() error { |
| - if rn.err == nil && !time.Now().Before(rn.until) { |
| - rn.err = ErrLost |
| - } |
| if rn.err != nil { |
| return rn.err |
| } |
| if rn.stopped { |
| return ErrSpent |
| } |
| - return nil |
| + if !time.Now().Before(rn.until) { |
| + rn.loseLocked(ErrLost) |
| + } |
| + return rn.err |
| } |
| func (rn *renewer) Err() error { |
| rn.mu.Lock() |
| @@ -128,7 +136,7 @@ func (rn *renewer) renew(ctx context.Context) { |
| } |
| if health == nil { |
| if err != nil { |
| - rn.err = err |
| + rn.loseLocked(err) |
| } else { |
| rn.until = started.Add(rn.leaser.TTL - rn.margin) |
| rn.checkLocked() |
| @@ -150,6 +158,7 @@ func (rn *renewer) renew(ctx context.Context) { |
| func (rn *renewer) stop() *litestream.Lease { |
| rn.mu.Lock() |
| + rn.checkLocked() |
| rn.stopped = true |
| rn.mu.Unlock() |
| rn.cancel() |
diff --git a/internal/lease/lease.go b/internal/lease/lease.go| index 84ab47bdd..8755c2ed3 100644 |
| --- a/internal/lease/lease.go |
| +++ b/internal/lease/lease.go |
| @@ -178,6 +178,11 @@ func (h *Held) Err() error { |
| return h.rn.Err() |
| } |
| +// Lost closes once on renewal failure or the safety deadline. Healthy transfer |
| +// and explicit release do not close it. A worker must recheck Err/SafeUntil and |
| +// prove its process tree stopped before the deadline; notification is not proof. |
| +func (h *Held) Lost() <-chan struct{} { return h.rn.lost } |
| + |
| // SafeUntil retains Go's monotonic clock component. It must not be serialized |
| // and later treated as ownership evidence; callers must keep checking health. |
| func (h *Held) SafeUntil() (time.Time, error) { |
| @@ -340,6 +345,10 @@ func (r *Replicator) Err() error { return r.rn.Err() } |
| func (r *Replicator) SafeUntil() (time.Time, error) { return r.rn.SafeUntil() } |
| +// Lost is the same loss notification channel held before replication began. |
| +// Normal Close does not signal loss; SafeUntil refuses the stopped handle. |
| +func (r *Replicator) Lost() <-chan struct{} { return r.rn.lost } |
| + |
| // Abandon stops lease renewal and replication WITHOUT the final sync or the |
| // lease release — the wreckage a crashed box leaves behind: lock.json simply |
| // expires on its TTL, and the replica holds whatever was last synced. It |