carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The box half of a sidecar wake request: when a sidecar's wake object
2// (release.SidecarWakeKey) MOVES, wake exactly that sidecar. Pull-based like
3// the restart signal it is modelled on (restart.go) — no push channel, no
4// box access, and the box need not even be reachable from anywhere for this
5// to work.
6//
7// It exists because a sidecar has no socket and nothing dials it: an
8// instance wakes on its next inbound request, but a hibernating sidecar has
9// no "next request" to wake on, so waking one from outside the box needs an
10// explicit signal a pull-based box can notice on its own.
11//
12// Split into the same two shapes restart.go uses for exactly the same
13// reason: sidecarWakeChanges is the cheap, synchronous etag comparison
14// (restartGenPass's counterpart), while dispatchSidecarWakes hands the
15// WakeWorker fan-out to restart.go's shared dispatchBoundedWakes — which
16// can block up to wakeWaitBound (30s, activator.go) per sidecar waiting
17// out the settle window, and runs the WHOLE bounded fan-out inside one
18// goroutine it launches itself, so the caller returns immediately
19// regardless of how many sidecars are queued (review round 2: an app
20// shaped like Eleven's, a sidecar per enrolled member, is routinely more
21// than workerBootConcurrency). sidecarWakeLeg is the two glued together and
22// wired into fastConvergeLoop beside the restart leg (agent.go's
23// fastDeps.wakeLeg), so it rides the same 2s serial-driven convergence
24// `carlos restart` already gets.
25package host
26
27import (
28 "context"
29 "encoding/json"
30 "errors"
31 "fmt"
32 "log"
33 "os"
34
35 "github.com/carlosframework/platform/internal/registry"
36 "github.com/carlosframework/platform/internal/release"
37)
38
39// sidecarsServed maps every sidecar route this box serves to the (account,
40// app, name) triple its wake object lives at — name comes from Slug, the
41// field a sidecar route carries its short name on (registry.Route's own
42// doc comment; provision's reconciler stamps it from the sidecar record).
43//
44// Gated on rt.IsSidecar() explicitly, not just a non-empty Slug: Slug is
45// NOT sidecar-exclusive (it is also an ordinary app's root-domain short
46// name), so an instance route that happens to carry the same Slug as a real
47// sidecar must never be mistaken for that sidecar.
48func sidecarsServed(routes []registry.Route) map[[3]string]registry.Route {
49 out := make(map[[3]string]registry.Route)
50 for _, rt := range routes {
51 if !rt.IsSidecar() || rt.Slug == "" {
52 continue
53 }
54 out[[3]string{release.RouteAccount(rt), rt.App, rt.Slug}] = rt
55 }
56 return out
57}
58
59// sidecarWakeMemory is the wake leg's memory across ticks: the etag last
60// seen for each served sidecar, plus the one bit that says whether the
61// arming BASELINE has been established.
62//
63// The two live together deliberately. "First sight" means two completely
64// different things either side of that baseline — history a booting box must
65// not replay, versus a route that has just appeared under a running agent —
66// and a bare map cannot tell them apart, which is exactly how the
67// create-then-wake flow came to swallow its own first wake.
68type sidecarWakeMemory struct {
69 claimsEnabled bool
70 identities map[string]release.SidecarWake
71 etags map[[3]string]string
72 // seeded is set by the first completed pass (the arming one in
73 // production, sidecarWakeSeed; the first leg pass when arming's route
74 // list could not be read). Before it, first sight is the baseline; after
75 // it, first sight is a NEW route.
76 seeded bool
77}
78
79func newSidecarWakeMemory(claimsEnabled bool) *sidecarWakeMemory {
80 return &sidecarWakeMemory{claimsEnabled: claimsEnabled, identities: map[string]release.SidecarWake{}, etags: map[[3]string]string{}}
81}
82
83// sidecarWakeChanges is sidecar-wake's counterpart to restartGenPass: read
84// the wake-object etag of every sidecar this box serves and report which
85// ones MOVED, as their routes. It performs no wake itself — see
86// dispatchSidecarWakes for the half that can block.
87//
88// - First sight BEFORE the baseline exists records silently and is never
89// reported as moved. A box (or an agent process) that has just started
90// must not wake every sidecar it serves because it has never seen these
91// etags before — that is the seed-don't-replay rule the serial watcher
92// already applies (agent.go's fastTick). Without it, an agent restart
93// would replay every wake this app's sidecars have ever received — a
94// thundering herd on boot, and for a hibernating fleet a real cost, not
95// a cosmetic one. sidecarWakeSeed runs one such silent pass at arming so
96// the FIRST request after a boot is acted on rather than swallowed.
97// - First sight AFTER it, with a wake object already present, is reported
98// as MOVED. Such a route did not exist when this agent last looked, so
99// it has just been minted — and a wake object standing at a route this
100// very pass minted is a wake somebody already asked for and the CLI
101// already reported as accepted. That is the create-then-wake flow, and
102// it is the feature's PRIMARY one: an operator wake is a hibernating
103// sidecar's only first-run mechanism (the timer needs a next-due that
104// only a running child can set), and `carlos sidecars create` followed
105// by `carlos sidecars wake` routinely lands both inside a single tick —
106// the tick that mints the route would otherwise seed the wake's etag and
107// silently drop the wake. A spurious extra wake is idempotent and cheap;
108// a lost one is a bot that never runs. (A route that vanished and came
109// back is a first sight too, and is woken once for the same reason: it
110// has just been re-minted, and its wake object outlived it.)
111// - An unchanged etag does nothing.
112// - Absent that stays absent does nothing, forever: a sidecar nobody has
113// ever woken this way has no object and never will until someone asks.
114// A brand-new route with no wake object is therefore silent too — only a
115// PRESENT object makes a first sight a wake.
116// - Any movement counts: the etag changed, or absent -> present (the
117// sidecar's first ever wake request).
118// - A wake object naming a sidecar this box does not serve is never even
119// read: this function only ever builds keys from sidecarsServed, which
120// comes from THIS box's own route table. Such an object, however it
121// moves in the bucket, is simply never asked about.
122// - A read error for one sidecar leaves its cache entry untouched and the
123// rest of the pass keeps going; the error comes back for the caller to
124// aggregate into its own log line, same discipline as restartGenPass.
125// After the baseline that means its next sighting is a first sight, so
126// it may be woken once — the cheap direction of an unknown.
127//
128// The memory is mutated in place and is expected to live for the process
129// lifetime, exactly like restartGenPass's cache.
130func sidecarWakeChanges(ctx context.Context, routes []registry.Route, store release.Store, mem *sidecarWakeMemory) (moved []registry.Route, readErrs []error) {
131 served := sidecarsServed(routes)
132 live := make(map[[3]string]bool, len(served))
133 baseline := !mem.seeded
134 for k, rt := range served {
135 live[k] = true
136 raw, etag, err := store.Get(ctx, release.SidecarWakeKey(k[0], k[1], k[2]))
137 switch {
138 case errors.Is(err, os.ErrNotExist):
139 etag = genAbsent
140 case err != nil:
141 readErrs = append(readErrs, fmt.Errorf("%s/%s/%s: %w", k[0], k[1], k[2], err))
142 continue // cache entry untouched: unknown this tick, not changed
143 }
144 if mem.claimsEnabled {
145 var signal release.SidecarWake
146 if etag == genAbsent || json.Unmarshal(raw, &signal) != nil {
147 continue
148 }
149 if err := verifySidecarWake(ctx, store, rt, signal); err != nil {
150 readErrs = append(readErrs, err)
151 continue
152 }
153 if mem.identities == nil {
154 mem.identities = map[string]release.SidecarWake{}
155 }
156 mem.identities[rt.Host] = signal
157 etag = signal.AppID + "/" + signal.Incarnation + "/" + signal.RequestID
158 }
159 prev, seen := mem.etags[k]
160 mem.etags[k] = etag
161 switch {
162 case seen:
163 if prev != etag {
164 moved = append(moved, rt)
165 }
166 case baseline:
167 // The agent-restart protection: history, recorded silently.
168 case etag != genAbsent:
169 // A route minted since the last pass, with a wake already
170 // standing at it — the create-then-wake flow.
171 moved = append(moved, rt)
172 }
173 }
174 for k := range mem.etags {
175 if !live[k] {
176 delete(mem.etags, k)
177 }
178 }
179 // One completed pass IS the baseline, whichever caller ran it: arming's
180 // silent seed in production, or the first leg pass on a box whose arming
181 // route list could not be read.
182 mem.seeded = true
183 return moved, readErrs
184}
185
186// dispatchSidecarWakes wakes every route in moved through the shared
187// dispatchBoundedWakes helper (restart.go) — the SAME idiom Task 8's
188// runWorkerBootPass and cycleWorkers' own wake half use, deliberately not a
189// third shape. WakeWorker can block up to wakeWaitBound (30s) per sidecar
190// waiting out the settle window, and this function's only caller
191// (sidecarWakeLeg) runs synchronously on the box's single per-tick
192// convergence goroutine (fastTick) — dispatchBoundedWakes' own doc comment
193// covers why the WHOLE bounded fan-out, not just each individual wake,
194// must be off that goroutine (review round 2: an app shaped like Eleven's,
195// a sidecar per enrolled member, routinely queues more than
196// workerBootConcurrency sidecars behind one wake burst). Safe because
197// WakeWorker is idempotent and a wake request's contract is "wake the
198// process", not "return once it's up" — a sidecar that comes up a moment
199// after this tick has still been woken. A wake that errors is logged and
200// does not stop, delay, or fail the others.
201//
202// wake may be nil (no activator armed on this box): a working no-op.
203func dispatchSidecarWakes(moved []registry.Route, wake func(rt registry.Route) error) {
204 dispatchBoundedWakes(moved, wake, func(rt registry.Route, err error) {
205 if err != nil {
206 log.Printf("agent: sidecar wake requested: wake %s: %v", rt.Host, err)
207 return
208 }
209 log.Printf("agent: sidecar wake requested: woke %s", rt.Host)
210 })
211}
212
213// sidecarWakeLeg is sidecarWakeChanges and dispatchSidecarWakes glued
214// together — the leg fastConvergeLoop wires into fastDeps.wakeLeg beside
215// the restart leg, so a wake request rides the same 2s serial-driven
216// convergence `carlos restart` already gets. Reports whether anything
217// moved (for logging/tests); the actual wake calls happen off this
218// function's return path (dispatchSidecarWakes), so it never blocks on a
219// slow or unresponsive sidecar.
220func sidecarWakeLeg(ctx context.Context, routes []registry.Route, store release.Store, mem *sidecarWakeMemory, wake func(rt registry.Route) error) (moved []registry.Route, readErrs []error) {
221 moved, readErrs = sidecarWakeChanges(ctx, routes, store, mem)
222 if len(readErrs) > 0 {
223 log.Printf("agent: sidecar wake check: %d sidecars unreadable: %v", len(readErrs), readErrs[0])
224 }
225 if len(moved) == 0 {
226 return moved, readErrs
227 }
228 if mem.claimsEnabled {
229 captured := make(map[string]release.SidecarWake, len(moved))
230 for _, rt := range moved {
231 captured[rt.Host] = mem.identities[rt.Host]
232 }
233 original := wake
234 wake = func(rt registry.Route) error {
235 if err := verifySidecarWake(ctx, store, rt, captured[rt.Host]); err != nil {
236 return err
237 }
238 rt.WakeIncarnation = captured[rt.Host].Incarnation
239 if original == nil {
240 return nil
241 }
242 return original(rt)
243 }
244 }
245 dispatchSidecarWakes(moved, wake)
246 log.Printf("agent: sidecar wake requested for %d sidecar(s) on this box", len(moved))
247 return moved, readErrs
248}
249
250// sidecarWakeSeed is sidecarWakeChanges' counterpart to restartSeed: at
251// arming, read each served sidecar's wake-object etag once, silently, so
252// the memory sidecarWakeLeg compares against exists before the first serial
253// move. Without it the first wake request after a boot is the sweep that
254// establishes the baseline, and is therefore the one request that does
255// nothing.
256//
257// This pass IS the agent-restart protection: only a sidecar this box already
258// served when the agent armed has its history recorded silently. Everything
259// that appears afterwards is a route minted under a running agent, and a
260// wake object already standing at one of those is acted on (see
261// sidecarWakeChanges).
262//
263// A LIST failure leaves the memory unseeded on purpose: the first leg pass
264// then establishes the baseline itself rather than reading an empty cache as
265// "every sidecar on this box is brand new" and waking the lot. A per-sidecar
266// read error is narrower — that one sidecar is left out of the cache and may
267// be woken once on its next sighting.
268func sidecarWakeSeed(ctx context.Context, reg *registry.Registry, store release.Store, mem *sidecarWakeMemory) {
269 routes, err := reg.List()
270 if err != nil {
271 log.Printf("agent: sidecar wake baseline: list routes: %v (the first request may need a second try)", err)
272 return
273 }
274 _, readErrs := sidecarWakeChanges(ctx, routes, store, mem)
275 if len(readErrs) > 0 {
276 log.Printf("agent: sidecar wake baseline: %d sidecars unreadable: %v", len(readErrs), readErrs[0])
277 }
278}
279