package rollout import ( "context" "sync" "time" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // appKey names one app: one scheduler per (account, app) per box (spec §6.1). type appKey struct{ acct, app string } // AppPolicy is the app's swap policy as this box applies it (spec §4.1). type AppPolicy struct { Swap activator.SwapMode Concurrency int } // DefaultPolicy is §4.1's defaults: overlap, one swap at a time. func DefaultPolicy() AppPolicy { return AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1} } // PolicyFromManifest reads the policy keys of an app's default bundle. A nil // manifest (never configured) is the defaults. A swap value this binary does // not know is hold: missing or unverifiable policy never permits an overlap // (spec §4.2). func PolicyFromManifest(m *appconfig.Manifest) AppPolicy { p := DefaultPolicy() if m == nil { return p } switch m.Swap { case "", appconfig.SwapOverlap: default: p.Swap = activator.ModeHold } if m.SwapConcurrency >= 1 { p.Concurrency = m.SwapConcurrency } return p } // AttemptMode is an adoption attempt's mode (spec §4.2): hold if the app // policy or the build's canonical demand says hold. Any demand other than "" // is read as hold, so a future demand value cannot become an overlap here. func AttemptMode(p AppPolicy, demand string) activator.SwapMode { if p.Swap == activator.ModeHold || demand != "" { return activator.ModeHold } return activator.ModeOverlap } // RecoveryMode is a recovery attempt's mode (spec §4.2): the RETAINED release // demand, with the CURRENT app policy, so an app switched to hold after a // build passed still gets hold on its restarts and config changes. func RecoveryMode(p AppPolicy, retained string) activator.SwapMode { return AttemptMode(p, retained) } // policyEntry is one app's cached policy. fills numbers the reads begun for // the app and stored is the number of the read whose result is held, so an // older read that finishes after a newer one has stored leaves the newer // value in place (Astra R1 Flag triage T3). failedAt is the last failed // read's time, zero once a read has succeeded since. type policyEntry struct { p AppPolicy ok bool // p was read, at at at time.Time failedAt time.Time fills uint64 stored uint64 } // policyCache reads each app's policy at most once per ttl. A failed read // keeps the last value it had; an app whose policy this box has never read // gets hold, because a guess of overlap could run a migration beside a build // that cannot share the database. A failed read is not tried again for // policyRetryAfter, so a store that is failing costs one read per app per // backoff rather than one per pass, wake and attempt (Task 11 rule 1). type policyCache struct { load func(ctx context.Context, acct, app string) (*appconfig.Manifest, error) ttl time.Duration now func() time.Time mu sync.Mutex m map[appKey]*policyEntry } // policyRetryAfter is how long a failed policy read waits before the next. const policyRetryAfter = 5 * time.Second // readBound bounds each store read the scheduler makes in line (the policy, // the channel pointer, the canonical demand): a store that hangs costs a // pass, a wake plan or an attempt this long, not forever (Task 11 rule 2, // Astra R1 Flag triage T8 and T10). const readBound = 5 * time.Second // get is the app's policy. A missing bundle is not an error here: // appconfig.Get answers it with an empty manifest, which is the defaults. An // error is never the defaults. func (c *policyCache) get(ctx context.Context, acct, app string) AppPolicy { k := appKey{acct, app} c.mu.Lock() e := c.m[k] if e == nil { e = &policyEntry{} c.m[k] = e } now := c.now() if e.ok && now.Sub(e.at) < c.ttl { p := e.p c.mu.Unlock() return p } if !e.failedAt.IsZero() && now.Sub(e.failedAt) < policyRetryAfter { p := e.lastOrHold() c.mu.Unlock() return p } e.fills++ fill := e.fills c.mu.Unlock() lctx, cancel := context.WithTimeout(ctx, readBound) m, err := c.load(lctx, acct, app) cancel() c.mu.Lock() defer c.mu.Unlock() if fill > e.stored { // No newer read has stored since this one began. switch { case err != nil && ctx.Err() != nil: // The caller's own context ended (a wake or an attempt that // stopped), not the read bound: that says nothing about the // store, so it holds off no other caller (Task 11 fix round 1). case err != nil: e.failedAt = c.now() default: e.p, e.ok, e.at, e.failedAt, e.stored = PolicyFromManifest(m), true, c.now(), time.Time{}, fill } } return e.lastOrHold() } // lastOrHold is the last policy read, or hold for an app never read. func (e *policyEntry) lastOrHold() AppPolicy { if e.ok { return e.p } return AppPolicy{Swap: activator.ModeHold, Concurrency: 1} } // Managed reports whether rt is an activator-managed exec route this // scheduler moves: a hibernating exec instance or a native sidecar of a known // app. Named-unit routes keep root's adoption and its privileged restart // (spec §13), unit-backed routes belong to systemd, and platform routes are // never adopted. func Managed(rt registry.Route) bool { if rt.Unit != "" || rt.Backing == registry.BackingUnit { return false } if rt.IsSidecar() { return release.SidecarAdoptable(rt) } return release.Adoptable(rt) && rt.Hibernate } // Restrictable reports whether the failure book can restrict rt: Managed, // with whatever status the row passes through (draining, or a move's hold: // moving, landing, restoring, releasing) read as ready. A move holds the row // while its probe or landing launches the instance, and a restriction is a // fact about the instance and its database, not about the row's status, so // it must still apply there (final review round 1, finding 2, Astra). // Whether the move may launch at all is the moves package's own check. func Restrictable(rt registry.Route) bool { rt.Status = "ready" return Managed(rt) }