carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package rollout
2
3import (
4 "context"
5 "sync"
6 "time"
7
8 "github.com/carlosframework/platform/internal/activator"
9 "github.com/carlosframework/platform/internal/appconfig"
10 "github.com/carlosframework/platform/internal/registry"
11 "github.com/carlosframework/platform/internal/release"
12)
13
14// appKey names one app: one scheduler per (account, app) per box (spec §6.1).
15type appKey struct{ acct, app string }
16
17// AppPolicy is the app's swap policy as this box applies it (spec §4.1).
18type AppPolicy struct {
19 Swap activator.SwapMode
20 Concurrency int
21}
22
23// DefaultPolicy is §4.1's defaults: overlap, one swap at a time.
24func DefaultPolicy() AppPolicy {
25 return AppPolicy{Swap: activator.ModeOverlap, Concurrency: 1}
26}
27
28// PolicyFromManifest reads the policy keys of an app's default bundle. A nil
29// manifest (never configured) is the defaults. A swap value this binary does
30// not know is hold: missing or unverifiable policy never permits an overlap
31// (spec §4.2).
32func PolicyFromManifest(m *appconfig.Manifest) AppPolicy {
33 p := DefaultPolicy()
34 if m == nil {
35 return p
36 }
37 switch m.Swap {
38 case "", appconfig.SwapOverlap:
39 default:
40 p.Swap = activator.ModeHold
41 }
42 if m.SwapConcurrency >= 1 {
43 p.Concurrency = m.SwapConcurrency
44 }
45 return p
46}
47
48// AttemptMode is an adoption attempt's mode (spec §4.2): hold if the app
49// policy or the build's canonical demand says hold. Any demand other than ""
50// is read as hold, so a future demand value cannot become an overlap here.
51func AttemptMode(p AppPolicy, demand string) activator.SwapMode {
52 if p.Swap == activator.ModeHold || demand != "" {
53 return activator.ModeHold
54 }
55 return activator.ModeOverlap
56}
57
58// RecoveryMode is a recovery attempt's mode (spec §4.2): the RETAINED release
59// demand, with the CURRENT app policy, so an app switched to hold after a
60// build passed still gets hold on its restarts and config changes.
61func RecoveryMode(p AppPolicy, retained string) activator.SwapMode {
62 return AttemptMode(p, retained)
63}
64
65// policyEntry is one app's cached policy. fills numbers the reads begun for
66// the app and stored is the number of the read whose result is held, so an
67// older read that finishes after a newer one has stored leaves the newer
68// value in place (Astra R1 Flag triage T3). failedAt is the last failed
69// read's time, zero once a read has succeeded since.
70type policyEntry struct {
71 p AppPolicy
72 ok bool // p was read, at at
73 at time.Time
74 failedAt time.Time
75 fills uint64
76 stored uint64
77}
78
79// policyCache reads each app's policy at most once per ttl. A failed read
80// keeps the last value it had; an app whose policy this box has never read
81// gets hold, because a guess of overlap could run a migration beside a build
82// that cannot share the database. A failed read is not tried again for
83// policyRetryAfter, so a store that is failing costs one read per app per
84// backoff rather than one per pass, wake and attempt (Task 11 rule 1).
85type policyCache struct {
86 load func(ctx context.Context, acct, app string) (*appconfig.Manifest, error)
87 ttl time.Duration
88 now func() time.Time
89 mu sync.Mutex
90 m map[appKey]*policyEntry
91}
92
93// policyRetryAfter is how long a failed policy read waits before the next.
94const policyRetryAfter = 5 * time.Second
95
96// readBound bounds each store read the scheduler makes in line (the policy,
97// the channel pointer, the canonical demand): a store that hangs costs a
98// pass, a wake plan or an attempt this long, not forever (Task 11 rule 2,
99// Astra R1 Flag triage T8 and T10).
100const readBound = 5 * time.Second
101
102// get is the app's policy. A missing bundle is not an error here:
103// appconfig.Get answers it with an empty manifest, which is the defaults. An
104// error is never the defaults.
105func (c *policyCache) get(ctx context.Context, acct, app string) AppPolicy {
106 k := appKey{acct, app}
107 c.mu.Lock()
108 e := c.m[k]
109 if e == nil {
110 e = &policyEntry{}
111 c.m[k] = e
112 }
113 now := c.now()
114 if e.ok && now.Sub(e.at) < c.ttl {
115 p := e.p
116 c.mu.Unlock()
117 return p
118 }
119 if !e.failedAt.IsZero() && now.Sub(e.failedAt) < policyRetryAfter {
120 p := e.lastOrHold()
121 c.mu.Unlock()
122 return p
123 }
124 e.fills++
125 fill := e.fills
126 c.mu.Unlock()
127 lctx, cancel := context.WithTimeout(ctx, readBound)
128 m, err := c.load(lctx, acct, app)
129 cancel()
130 c.mu.Lock()
131 defer c.mu.Unlock()
132 if fill > e.stored {
133 // No newer read has stored since this one began.
134 switch {
135 case err != nil && ctx.Err() != nil:
136 // The caller's own context ended (a wake or an attempt that
137 // stopped), not the read bound: that says nothing about the
138 // store, so it holds off no other caller (Task 11 fix round 1).
139 case err != nil:
140 e.failedAt = c.now()
141 default:
142 e.p, e.ok, e.at, e.failedAt, e.stored = PolicyFromManifest(m), true, c.now(), time.Time{}, fill
143 }
144 }
145 return e.lastOrHold()
146}
147
148// lastOrHold is the last policy read, or hold for an app never read.
149func (e *policyEntry) lastOrHold() AppPolicy {
150 if e.ok {
151 return e.p
152 }
153 return AppPolicy{Swap: activator.ModeHold, Concurrency: 1}
154}
155
156// Managed reports whether rt is an activator-managed exec route this
157// scheduler moves: a hibernating exec instance or a native sidecar of a known
158// app. Named-unit routes keep root's adoption and its privileged restart
159// (spec §13), unit-backed routes belong to systemd, and platform routes are
160// never adopted.
161func Managed(rt registry.Route) bool {
162 if rt.Unit != "" || rt.Backing == registry.BackingUnit {
163 return false
164 }
165 if rt.IsSidecar() {
166 return release.SidecarAdoptable(rt)
167 }
168 return release.Adoptable(rt) && rt.Hibernate
169}
170
171// Restrictable reports whether the failure book can restrict rt: Managed,
172// with whatever status the row passes through (draining, or a move's hold:
173// moving, landing, restoring, releasing) read as ready. A move holds the row
174// while its probe or landing launches the instance, and a restriction is a
175// fact about the instance and its database, not about the row's status, so
176// it must still apply there (final review round 1, finding 2, Astra).
177// Whether the move may launch at all is the moves package's own check.
178func Restrictable(rt registry.Route) bool {
179 rt.Status = "ready"
180 return Managed(rt)
181}
182