| new file mode 100644 |
| index 000000000..6a170895d |
| --- /dev/null |
| +++ b/internal/console/selfserve_lease.go |
| @@ -0,0 +1,190 @@ |
| +package console |
| + |
| +// The console lease (self-serve moves plan 2, Task 4). One console runs the |
| +// self-serve moves pass: the one holding control/offline-moves/console.json, |
| +// a 60 s lease renewed every 20 s during a tick. The SQLite journal is the |
| +// console's, so a replacement console takes over by opening the same |
| +// database file once the lease has run out; the lease guards the day there |
| +// are two. |
| +// |
| +// Every record write the pass makes runs under selfServeWriteCtx: the |
| +// lease's deadline bounds its context, and a write guard checks the lease |
| +// with a fresh clock immediately before every Put attempt. A cancelled |
| +// context does not prove a write cannot commit; that is why every write is |
| +// a persisted request (Task 7). |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const ( |
| + selfServeLeaseKey = "control/offline-moves/console.json" |
| + selfServeLeaseTTL = 60 * time.Second |
| +) |
| + |
| +// selfServeLeaseRenewEvery is how often a tick renews the lease it holds. |
| +// A var so a test can shorten it. |
| +var selfServeLeaseRenewEvery = 20 * time.Second |
| + |
| +// errSelfServeLeaseLost is a write or renewal refused because this console |
| +// does not hold the lease (it ran out, or another console took it). |
| +var errSelfServeLeaseLost = errors.New("console: self-serve moves: this console does not hold the moves lease") |
| + |
| +// consoleLease is console.json's body: which console holds the lease, and |
| +// until when. |
| +type consoleLease struct { |
| + Console string `json:"console"` |
| + Until time.Time `json:"until"` |
| +} |
| + |
| +// leaseToken is this console's hold on the lease: the object's ETag as last |
| +// written, and the time it runs out. |
| +type leaseToken struct { |
| + s *Server |
| + console string |
| + mu sync.Mutex |
| + etag string |
| + until time.Time |
| +} |
| + |
| +// selfServeNow is the pass's clock (selfServeClock, a TEST SEAM, else |
| +// time.Now). |
| +func (s *Server) selfServeNow() time.Time { |
| + if s.selfServeClock != nil { |
| + return s.selfServeClock() |
| + } |
| + return time.Now() |
| +} |
| + |
| +// selfServeConsoleID names this console process in the lease: random, so |
| +// two consoles never share a name, and stable for the process. |
| +func (s *Server) selfServeConsoleID() string { |
| + s.selfServeIDOnce.Do(func() { |
| + var b [12]byte |
| + if _, err := rand.Read(b[:]); err != nil { |
| + panic(err) // crypto/rand does not fail on a supported platform |
| + } |
| + s.selfServeConsole = "console-" + hex.EncodeToString(b[:]) |
| + }) |
| + return s.selfServeConsole |
| +} |
| + |
| +// acquireSelfServeLease takes the lease at now when it is absent, has run |
| +// out, or is already this console's: one conditional write against the |
| +// ETag read (create-only when absent). Held by another console, or a lost |
| +// race, is not acquired and no error. An unreadable or malformed lease is |
| +// an error: it is never overwritten blind. |
| +func (s *Server) acquireSelfServeLease(ctx context.Context, now time.Time) (*leaseToken, bool, error) { |
| + if s.cfg.Releases == nil { |
| + return nil, false, errors.New("console: self-serve moves: no deployment bucket for the lease") |
| + } |
| + me := s.selfServeConsoleID() |
| + raw, etag, err := s.cfg.Releases.Get(ctx, selfServeLeaseKey) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = "" |
| + case err != nil: |
| + return nil, false, err |
| + default: |
| + var cur consoleLease |
| + if err := json.Unmarshal(raw, &cur); err != nil { |
| + return nil, false, fmt.Errorf("%s: %w", selfServeLeaseKey, err) |
| + } |
| + if cur.Console == "" || cur.Until.IsZero() { |
| + return nil, false, fmt.Errorf("%s: no console or no time", selfServeLeaseKey) |
| + } |
| + if cur.Console != me && now.Before(cur.Until) { |
| + return nil, false, nil |
| + } |
| + } |
| + t := &leaseToken{s: s, console: me} |
| + if err := t.put(ctx, etag, now); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + return nil, false, nil |
| + } |
| + return nil, false, err |
| + } |
| + return t, true, nil |
| +} |
| + |
| +// put writes the lease until now + TTL against ifMatch and records the new |
| +// ETag. The caller holds t.mu or owns t alone. |
| +func (t *leaseToken) put(ctx context.Context, ifMatch string, now time.Time) error { |
| + until := now.Add(selfServeLeaseTTL).UTC() |
| + body, err := json.Marshal(consoleLease{Console: t.console, Until: until}) |
| + if err != nil { |
| + return err |
| + } |
| + tag, err := t.s.cfg.Releases.Put(ctx, selfServeLeaseKey, body, ifMatch) |
| + if err != nil { |
| + return err |
| + } |
| + t.etag, t.until = tag, until |
| + return nil |
| +} |
| + |
| +// renew extends the lease to now + TTL with one conditional write against |
| +// the ETag this console last wrote. A lost race means another console took |
| +// it (or a renewal whose reply was lost moved the ETag): the token is no |
| +// longer valid and the error is errSelfServeLeaseLost; acquiring again |
| +// takes back a lease that is still this console's. Any other failure |
| +// leaves the token as it was, running out on its own. |
| +func (t *leaseToken) renew(ctx context.Context, now time.Time) error { |
| + t.mu.Lock() |
| + defer t.mu.Unlock() |
| + err := t.put(ctx, t.etag, now) |
| + if errors.Is(err, release.ErrCASConflict) { |
| + t.until = time.Time{} |
| + return errSelfServeLeaseLost |
| + } |
| + return err |
| +} |
| + |
| +// valid reports whether the lease is still this console's at now. |
| +func (t *leaseToken) valid(now time.Time) bool { |
| + if t == nil { |
| + return false |
| + } |
| + t.mu.Lock() |
| + defer t.mu.Unlock() |
| + return now.Before(t.until) |
| +} |
| + |
| +// deadline is when the lease runs out unless renewed. |
| +func (t *leaseToken) deadline() time.Time { |
| + t.mu.Lock() |
| + defer t.mu.Unlock() |
| + return t.until |
| +} |
| + |
| +// renewalDue reports whether a tick starting at now should renew: every |
| +// selfServeLeaseRenewEvery since the last write. |
| +func (t *leaseToken) renewalDue(now time.Time) bool { |
| + t.mu.Lock() |
| + defer t.mu.Unlock() |
| + return !now.Before(t.until.Add(-selfServeLeaseTTL).Add(selfServeLeaseRenewEvery)) |
| +} |
| + |
| +// selfServeWriteCtx is the context every record write of the pass runs |
| +// under: bounded by the lease's deadline, with a write guard that refuses |
| +// any Put attempt once the lease is no longer valid by a fresh clock. |
| +func (s *Server) selfServeWriteCtx(ctx context.Context, t *leaseToken) (context.Context, context.CancelFunc) { |
| + ctx, cancel := context.WithDeadline(ctx, t.deadline()) |
| + return provision.WithWriteGuard(ctx, func() error { |
| + if !t.valid(s.selfServeNow()) { |
| + return errSelfServeLeaseLost |
| + } |
| + return nil |
| + }), cancel |
| +} |