| index cf8d7b33a..7b8a5eba0 100644 |
| --- a/internal/console/policylease.go |
| +++ b/internal/console/policylease.go |
| @@ -37,10 +37,15 @@ |
| // acquire before that expiry, so an attempt either reaches the store while |
| // this console still holds the lock or is cancelled. A request the network |
| // holds back past its own cancellation is the residual; a later holder's |
| -// conditional write then sees a changed ETag (Task 9). |
| +// conditional write then sees a changed ETag (Task 9). Ending a holding, for |
| +// any reason, cancels every attempt context issued under it before its lease |
| +// is released: an explicit release does not close the lease's Lost, so |
| +// without that an attempt could outlive the lock. |
| // |
| // Release is reachable: cmd/console calls ReleasePolicyWriter on SIGTERM, so |
| -// a rolled console's successor need not wait out the TTL. Where the console's |
| +// a rolled console's successor need not wait out the TTL. It stops the writer |
| +// for good, cancels an acquisition in flight and waits, inside its 5 s budget, |
| +// for that acquisition to give back a lease it won anyway. Where the console's |
| // role is denied s3:DeleteObject on the bucket (the flagship's store IAM Deny; |
| // plan 2026-10-21-instance-policy-1-foundation, "Boundary verification"), the |
| // release fails, is logged, and the successor waits up to lease.LeaseTTL, 15 |
| @@ -112,9 +117,27 @@ type policyHolding struct { |
| lease PolicyLease |
| } |
| |
| +// policyHold is the server's side of the current holding: the holding and |
| +// the cancel of every attempt context issued under it, so ending the holding |
| +// ends its attempts before its lease is released. Guarded by policyStateMu. |
| +type policyHold struct { |
| + h policyHolding |
| + attempts map[uint64]context.CancelFunc |
| + nextID uint64 |
| +} |
| + |
| +// policyAcquisition is an acquisition in flight: ReleasePolicyWriter cancels |
| +// it and waits for done, which closes only after a lease it won anyway has |
| +// been released. |
| +type policyAcquisition struct { |
| + cancel context.CancelFunc |
| + done chan struct{} |
| +} |
| + |
| // policyWriter returns the current holding, acquiring the lease if there is |
| // none or the last one was lost. The caller keeps the holding for its whole |
| // write and checks it with policyAttempt before every signing and publication. |
| +// After ReleasePolicyWriter it refuses for good: the console is stopping. |
| func (s *Server) policyWriter(ctx context.Context) (policyHolding, *doorRefusal) { |
| if s.cfg.Bucket == "" { |
| return policyHolding{n: 1, sole: true}, nil |
| @@ -122,72 +145,97 @@ func (s *Server) policyWriter(ctx context.Context) (policyHolding, *doorRefusal) |
| if s.cfg.PolicyLease == nil { |
| return policyHolding{}, &doorRefusal{http.StatusServiceUnavailable, policyLeaseMissingMsg} |
| } |
| + unavailable := &doorRefusal{http.StatusServiceUnavailable, policyLeaseUnavailableMsg} |
| s.policyLeaseMu.Lock() |
| defer s.policyLeaseMu.Unlock() |
| - if cur := s.policyCur.Load(); cur != nil { |
| - err := cur.lease.Err() |
| - if err == nil { |
| - return *cur, nil |
| + |
| + s.policyStateMu.Lock() |
| + if s.policyStopping { |
| + s.policyStateMu.Unlock() |
| + return policyHolding{}, unavailable |
| + } |
| + var ended *policyHold |
| + var why error |
| + if cur := s.policyCur; cur != nil { |
| + if why = cur.h.lease.Err(); why == nil { |
| + h := cur.h |
| + s.policyStateMu.Unlock() |
| + return h, nil |
| } |
| - s.endPolicyHolding(cur, err) |
| + ended = s.detachPolicyHoldLocked() |
| } |
| + actx, cancel := context.WithCancel(ctx) |
| + acq := &policyAcquisition{cancel: cancel, done: make(chan struct{})} |
| + s.policyAcquiring = acq |
| + s.policyStateMu.Unlock() |
| + defer func() { |
| + cancel() |
| + close(acq.done) |
| + }() |
| + s.endPolicyHold(ended, why) |
| + |
| ttl := s.cfg.PolicyLeaseTTL |
| if ttl == 0 { |
| ttl = lease.LeaseTTL |
| } |
| - l, err := s.cfg.PolicyLease(ctx, s.cfg.Bucket, appPolicyLeasePrefix, ttl) |
| + l, err := s.cfg.PolicyLease(actx, s.cfg.Bucket, appPolicyLeasePrefix, ttl) |
| if err == nil && l == nil { |
| err = errors.New("the leaser returned no lease") |
| } |
| - if err != nil { |
| - var held *lease.HeldError |
| - if errors.As(err, &held) { |
| - return policyHolding{}, &doorRefusal{http.StatusServiceUnavailable, |
| - fmt.Sprintf(policyLeaseHeldMsg, held.Owner, held.ExpiresAt.UTC().Format(time.RFC3339))} |
| - } |
| - log.Printf("console: policy writer lease: %v", err) |
| - return policyHolding{}, &doorRefusal{http.StatusServiceUnavailable, policyLeaseUnavailableMsg} |
| + |
| + s.policyStateMu.Lock() |
| + s.policyAcquiring = nil |
| + stopping := s.policyStopping |
| + if err == nil && !stopping { |
| + s.policyN++ |
| + s.policyCur = &policyHold{h: policyHolding{n: s.policyN, lease: l}} |
| + h := s.policyCur.h |
| + s.policyStateMu.Unlock() |
| + return h, nil |
| + } |
| + s.policyStateMu.Unlock() |
| + |
| + if err == nil { |
| + // Won while the console was stopping: give it straight back, before |
| + // done closes, so the stand-down never leaves it behind. |
| + s.policyLogf("console: policy writer lease: acquired while stopping; releasing it") |
| + releasePolicyLease(context.Background(), l, s.policyLogf) |
| + return policyHolding{}, unavailable |
| } |
| - s.policyN++ |
| - h := &policyHolding{n: s.policyN, lease: l} |
| - s.policyCur.Store(h) |
| - return *h, nil |
| + var held *lease.HeldError |
| + if errors.As(err, &held) { |
| + return policyHolding{}, &doorRefusal{http.StatusServiceUnavailable, |
| + fmt.Sprintf(policyLeaseHeldMsg, held.Owner, held.ExpiresAt.UTC().Format(time.RFC3339))} |
| + } |
| + s.policyLogf("console: policy writer lease: %v", err) |
| + return policyHolding{}, unavailable |
| } |
| |
| // policyAttempt is the check before each signing and each publication |
| // attempt (Astra plan R3-1). It refuses unless h is still the current holding |
| // and its lease is healthy with at least policyAttemptWindow left before its |
| // safe deadline; otherwise it returns the context the attempt must run under, |
| -// which ends at the window, at the safe deadline, or when the lease is lost. |
| -// An attempt cut off by that context is this same refusal, not a store |
| -// failure. A sole holding is never refused and its context is ctx itself. |
| +// which ends at the window, at the safe deadline, when the lease is lost, or |
| +// when the holding ends (before its lease is released). An attempt cut off by |
| +// that context is this same refusal, not a store failure. A sole holding is |
| +// never refused and its context is ctx itself. |
| func (s *Server) policyAttempt(ctx context.Context, h policyHolding) (context.Context, context.CancelFunc, *doorRefusal) { |
| if h.sole { |
| return ctx, func() {}, nil |
| } |
| lost := &doorRefusal{http.StatusServiceUnavailable, policyLeaseLostMsg} |
| - cur := s.policyCur.Load() |
| - if cur == nil || cur.n != h.n || h.lease == nil { |
| - return ctx, func() {}, lost |
| - } |
| - if err := h.lease.Err(); err != nil { |
| - s.endPolicyHolding(cur, err) |
| - return ctx, func() {}, lost |
| - } |
| - select { |
| - case <-h.lease.Lost(): |
| - s.endPolicyHolding(cur, errors.New("the lease reported lost")) |
| - return ctx, func() {}, lost |
| - default: |
| - } |
| - safe, err := h.lease.SafeUntil() |
| - if err != nil { |
| - s.endPolicyHolding(cur, err) |
| + s.policyStateMu.Lock() |
| + cur := s.policyCur |
| + if cur == nil || cur.h.n != h.n || h.lease == nil { |
| + s.policyStateMu.Unlock() |
| return ctx, func() {}, lost |
| } |
| now := time.Now() |
| - if safe.Sub(now) < policyAttemptWindow { |
| - s.endPolicyHolding(cur, fmt.Errorf("safe until %s, less than %s away", safe.UTC().Format(time.RFC3339), policyAttemptWindow)) |
| + safe, why := policyLeaseHealth(h.lease, now) |
| + if why != nil { |
| + ended := s.detachPolicyHoldLocked() |
| + s.policyStateMu.Unlock() |
| + s.endPolicyHold(ended, why) |
| return ctx, func() {}, lost |
| } |
| deadline := now.Add(policyAttemptWindow) |
| @@ -195,6 +243,13 @@ func (s *Server) policyAttempt(ctx context.Context, h policyHolding) (context.Co |
| deadline = safe |
| } |
| actx, cancel := context.WithDeadline(ctx, deadline) |
| + id := cur.nextID |
| + cur.nextID++ |
| + if cur.attempts == nil { |
| + cur.attempts = map[uint64]context.CancelFunc{} |
| + } |
| + cur.attempts[id] = cancel |
| + s.policyStateMu.Unlock() |
| go func() { |
| select { |
| case <-h.lease.Lost(): |
| @@ -202,11 +257,37 @@ func (s *Server) policyAttempt(ctx context.Context, h policyHolding) (context.Co |
| case <-actx.Done(): |
| } |
| }() |
| - return actx, cancel, nil |
| + return actx, func() { |
| + s.policyStateMu.Lock() |
| + delete(cur.attempts, id) |
| + s.policyStateMu.Unlock() |
| + cancel() |
| + }, nil |
| } |
| |
| -// policyHeld reports, without blocking, whether this console holds the |
| -// writer lease and its renewal is healthy. A sole console always does. |
| +// policyLeaseHealth is policyAttempt's liveness check: renewal healthy, not |
| +// reported lost, and the safe deadline at least policyAttemptWindow away. |
| +func policyLeaseHealth(l PolicyLease, now time.Time) (time.Time, error) { |
| + if err := l.Err(); err != nil { |
| + return time.Time{}, err |
| + } |
| + select { |
| + case <-l.Lost(): |
| + return time.Time{}, errors.New("the lease reported lost") |
| + default: |
| + } |
| + safe, err := l.SafeUntil() |
| + if err != nil { |
| + return time.Time{}, err |
| + } |
| + if safe.Sub(now) < policyAttemptWindow { |
| + return time.Time{}, fmt.Errorf("safe until %s, less than %s away", safe.UTC().Format(time.RFC3339), policyAttemptWindow) |
| + } |
| + return safe, nil |
| +} |
| + |
| +// policyHeld reports, without waiting on any I/O, whether this console holds |
| +// the writer lease and its renewal is healthy. A sole console always does. |
| func (s *Server) policyHeld() bool { |
| if s.cfg.Bucket == "" { |
| return true |
| @@ -214,36 +295,81 @@ func (s *Server) policyHeld() bool { |
| if s.cfg.PolicyLease == nil { |
| return false |
| } |
| - cur := s.policyCur.Load() |
| - return cur != nil && cur.lease.Err() == nil |
| + s.policyStateMu.Lock() |
| + cur := s.policyCur |
| + s.policyStateMu.Unlock() |
| + return cur != nil && cur.h.lease.Err() == nil |
| } |
| |
| -// ReleasePolicyWriter ends the holding and releases its lease within |
| -// policyLeaseReleaseBudget, so a successor console need not wait out the TTL. |
| -// Nothing held is a no-op. It does not take policyLeaseMu, so a stand-down |
| -// never waits on an acquisition in flight. |
| +// ReleasePolicyWriter is the stand-down: within policyLeaseReleaseBudget it |
| +// stops all further acquisition, ends the holding (cancelling its attempts |
| +// first) and releases its lease, and cancels any acquisition in flight, |
| +// waiting for it to give back a lease it won anyway. A successor console then |
| +// need not wait out the TTL. It does not take policyLeaseMu, so it never |
| +// queues behind an acquisition. Nothing held is a no-op apart from the stop. |
| func (s *Server) ReleasePolicyWriter(ctx context.Context) { |
| - cur := s.policyCur.Swap(nil) |
| + ctx, cancel := context.WithTimeout(ctx, policyLeaseReleaseBudget) |
| + defer cancel() |
| + s.policyStateMu.Lock() |
| + s.policyStopping = true |
| + cur := s.detachPolicyHoldLocked() |
| + acq := s.policyAcquiring |
| + s.policyStateMu.Unlock() |
| + if acq != nil { |
| + acq.cancel() |
| + } |
| + if cur != nil { |
| + releasePolicyLease(ctx, cur.h.lease, s.policyLogf) |
| + } |
| + if acq != nil { |
| + select { |
| + case <-acq.done: |
| + case <-ctx.Done(): |
| + s.policyLogf("console: policy writer lease: an acquisition in flight outlived the release budget") |
| + } |
| + } |
| +} |
| + |
| +// detachPolicyHoldLocked ends the current holding under policyStateMu: |
| +// nothing can bind to it from here on, and every attempt context issued |
| +// under it is cancelled now, before any release. The caller releases the |
| +// returned hold's lease outside the lock (endPolicyHold or ReleasePolicyWriter). |
| +func (s *Server) detachPolicyHoldLocked() *policyHold { |
| + cur := s.policyCur |
| if cur == nil { |
| - return |
| + return nil |
| + } |
| + s.policyCur = nil |
| + for _, cancel := range cur.attempts { |
| + cancel() |
| } |
| - releasePolicyLease(ctx, cur.lease) |
| + cur.attempts = nil |
| + return cur |
| } |
| |
| -// endPolicyHolding ends cur if it is still the current holding, logging why |
| -// and releasing its lease best-effort. Only the caller that ends it releases. |
| -func (s *Server) endPolicyHolding(cur *policyHolding, why error) { |
| - if !s.policyCur.CompareAndSwap(cur, nil) { |
| +// endPolicyHold logs why a detached holding ended and releases its lease |
| +// best-effort. nil is a no-op. |
| +func (s *Server) endPolicyHold(ended *policyHold, why error) { |
| + if ended == nil { |
| return |
| } |
| - log.Printf("console: policy writer lease lost: %v", why) |
| - releasePolicyLease(context.Background(), cur.lease) |
| + s.policyLogf("console: policy writer lease lost: %v", why) |
| + releasePolicyLease(context.Background(), ended.h.lease, s.policyLogf) |
| } |
| |
| -func releasePolicyLease(ctx context.Context, l PolicyLease) { |
| +func releasePolicyLease(ctx context.Context, l PolicyLease, logf func(string, ...any)) { |
| ctx, cancel := context.WithTimeout(ctx, policyLeaseReleaseBudget) |
| defer cancel() |
| if err := l.Release(ctx); err != nil { |
| - log.Printf("console: policy writer lease release: %v", err) |
| + logf("console: policy writer lease release: %v", err) |
| + } |
| +} |
| + |
| +// policyLogf is log.Printf unless a test set policyLog. |
| +func (s *Server) policyLogf(format string, args ...any) { |
| + if s.policyLog != nil { |
| + s.policyLog(format, args...) |
| + return |
| } |
| + log.Printf(format, args...) |
| } |