carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Scheduled ticks must not hold an instance awake
2
3**Date:** 2026-08-30
4**Status:** approved in principle (Paul, 2026-08-30, after the flagship
5measurement below). Two decisions were taken in the brainstorm and are marked
6**(ruling)**; everything else is marked **(decision)** and can be overturned
7cheaply.
8
9## 1. The problem, as found
10
11Asked whether one member was hogging resources on the flagship, the answer was
12no — and the way the answer was reached turned up a bug underneath it.
13
14`james@keymail.dev` (account `bac`, app `jam`) declared one schedule on
152026-08-29: `sweep`, every 5 minutes, hitting `/jobs/sweep`. The handler
16answers `204` in **1 millisecond**. From the moment that schedule landed
17(2026-08-29 21:25Z) the instance has been resident **continuously** — present
18in every 60-second metrics sample since, 24 hours and counting, where the
19comparable tenant `seapointish` sleeps and wakes ten times a day.
20
21It is not the app's fault and it is not the schedule's fault. It is one line
22of accounting.
23
24Scheduled delivery is otherwise exactly right. `activatorDeliverer` sends the
25tick through the activator into the instance's unix socket, never through the
26public edge, and its doc comment states the property it was built for
27(`internal/host/schedule_wiring.go:216`):
28
29> a hibernating instance WAKES for its tick, exactly as it would for a
30> visitor, and stays awake for the tick's duration because the activator's
31> in-flight count holds it open. No separate keep-awake to get wrong.
32
33The tick reaches the instance through `Service.ServeHTTP`
34(`internal/activator/activator.go:1141`) — the same door the edge uses — which
35calls `beginRequest`, whose release stamps `e.lastActive = a.now()`
36(`activator.go:594`). The sweeper hibernates on
37`e.inflight == 0 && now.Sub(e.lastActive) >= a.cfg.Idle` (`activator.go:933`).
38
39`CARLOS_HIBERNATE_IDLE` defaults to 15m. The spec is 5m. The clock is reset
40three times before it can ever expire, so the sweeper's condition is
41unreachable for the life of the schedule. **Every tick buys fifteen minutes of
42residency for one millisecond of work.**
43
44### Why it is worth fixing rather than tolerating
45
46Three reasons, in ascending order of weight.
47
48**It bills.** `instance_hours` is a metered, priced quantity, and in
49rate-sheet mode it is the one term with no free-tier subtraction applied —
50compare `billable(...)` on egress, storage, apps and memory against the raw
51`meters[usage.MeterInstanceHours]` in `internal/economics/compute.go:120`, at
52`InstanceHourUSD` = $0.01. A 5-minute schedule accrues ~730 instance-hours a
53month: **$7.30 billed for roughly four seconds of work**.
54
55**It defeats the capacity model.** The 2026-08-30 sizing measurement put peak
56concurrent awake instances at 74 of the top 300 accounts on a 90s window. That
57number assumes instances sleep between requests. An app with any schedule
58tighter than the idle window never sleeps at all, so as schedules spread the
59model degrades toward "everything is always awake" — which is the assumption
60the r8g.2xlarge recommendation was chosen against.
61
62**It is invisible.** Nothing tells the member. `carlos schedule ls` reports
63what will fire and what fired last; nothing says "and this interval means your
64instance never hibernates". James could not have known, and neither could
65anyone reading the console.
66
67## 2. What is already right, and must stay right
68
69Worth stating, because two of these were nearly re-engineered before the code
70was read properly.
71
72**There are already two wake paths for scheduled work, and they are enough.**
73The runner polls `DueTimers` every 15 seconds and delivers, waking the
74instance through the activator; separately the sweeper reads `DueInstances`
75and wakes any sleeping host whose recorded due time has arrived
76(`activator.go:909-914`). Platform timers are covered by the first.
77
78**`NextDue` is therefore not the mechanism to change.** `GET /api/next-due`
79over the instance socket (`internal/activator/backend_exec.go:506`) asks the
80*app* about work the platform cannot see — its own in-process schedule. It is
81not the platform's timer index and should not become one. This spec adds no
82column, no `SetNextDue` change and no second waker. **(decision)**
83
84**The sidecar precedent already solves the accounting half.**
85`BeginSidecarRequest` (`activator.go:526`) passes traffic to an awake instance
86without registering activity — "no in-flight count, no lastActive stamp (the
87sidecar's own long-lived WebSocket must not hold its instance awake; that
88non-count is the whole reason the pair can sleep)". The shape needed here is
89that shape, differing in one respect: a tick must *wake* a sleeping instance,
90where a sidecar request is refused.
91
92## 3. The change
93
94Three parts. Together they are perhaps a hundred lines.
95
96### 3.1 A non-stamping, waking entry point
97
98Add `Service.ServeScheduled(w, r, rt, proxy) bool`, sibling to `ServeHTTP`,
99and a `beginScheduledRequest` behind it that:
100
101- wakes a sleeping instance, exactly as `beginRequest` does — the tick is the
102 wake source, and that must not change;
103- takes the in-flight count for the duration, so a tick is never cut off
104 mid-delivery by a concurrent sweep;
105- on release, decrements **without** stamping `lastActive`.
106
107`activatorDeliverer.Deliver` calls it instead of `ServeHTTP`
108(`schedule_wiring.go:317`).
109
110**No wire marker. (decision)** The sidecar identifies itself with
111`SidecarHeader` because it has to: it dials its instance's public URL and its
112traffic arrives through the edge, so the only place to carry the signal is the
113request. A scheduled tick never leaves the process — "the tick never leaves
114the box: no DNS, no TLS, no edge auth, nothing an outsider could aim at the
115same path" (`schedule_wiring.go:219`) — so the call site itself is the signal.
116A dedicated method has no spoofing surface at all, where a header, even one
117that is safe by construction, would have to be argued about.
118
119`X-Carlos-Schedule-At` is untouched. It is the app-facing half of the tick
120contract and continues to reach the handler.
121
122### 3.2 The guard: don't sleep into an imminent tick
123
124Without a guard, part 3.1 alone converts `jam` from one resident process into
125**288 wake/sleep cycles a day**. Each cycle is a full SQLite
126checkpoint-and-restore traversal, and the wake path is where
127`2026-08-15-sqlite-corruption-defence-design.md` (issue #200) records an
128unclosed integrity gap. Multiplying a scheduled app's traversals by ~28× to
129save 26 MB is a bad trade.
130
131So: in `sweepOnce`, before hibernating an otherwise-idle host, skip the sleep
132when this box holds a timer for that host due sooner than `cfg.Idle` away.
133
134```
135hibernate when: inflight == 0
136 && now - lastActive >= cfg.Idle
137 && (no timer for this host, or nextAt - now > cfg.Idle)
138```
139
140**The threshold is `cfg.Idle` itself, not a new constant. (ruling — Paul,
1412026-08-30)** If the gap to the next tick is shorter than the window we would
142have stayed awake for anyway, staying awake *is* the cheaper answer. It adds
143no second tuning number to drift out of step with the first, and it composes
144with the idle window rather than fighting it:
145
146| Idle window | Schedule | Behaviour |
147|---|---|---|
148| 15m | every 5m | stays resident (today's behaviour, unchanged) |
149| 90s | every 5m | sleeps ~3.5 of every 5 minutes |
150| 90s | every 60s | stays resident |
151| 15m | every 1h | sleeps ~45 minutes |
152
153Residency is measured from each WAKE, not from each tick: a tick that wakes a
154sleeping instance stamps `lastActive` via the wake path's own stamp, so the
155instance gets one full idle window of residency starting at that wake before
156it becomes eligible to sleep again. That is why the 15m/1h row sleeps ~45
157minutes (60 minus the 15-minute window it earns on waking), not 59 (60 minus
158the one-minute tick that would apply if the clock started at delivery). Rows
1592 and 3 were already computed this way; anyone extending this table — or the
160§1 billing arithmetic that reads off it — should keep using the wake, not the
161tick, as the start of the window.
162
163**Implementation constraint, from the code as it stands:** `sweepOnce` does
164its `DueInstances` read deliberately before taking `a.mu` — "it's a registry
165read, and nothing under a.mu should wait on SQLite" (`activator.go:911`). The
166earliest-due lookup joins that pre-lock batch as one query returning
167`host → earliest next_at`, never a per-entry query inside the loop.
168
169A host with no timers is unaffected: no row, no guard, sleep as today.
170
171**A tick-held host still converges.** The guard only skips the *sleep*; it
172must not skip the build-convergence check that runs after it. A host held
173awake by the guard never reaches a "next wake" to converge on — that is what
174the guard guarantees — so it is now in the same class as an always-awake
175sidecar host, and reaches the convergence arm on every sweep exactly as a
176sidecar does. Without this, a `carlos ship` behind a tight enough schedule
177would be served stale indefinitely. **(ruling — Paul, review round 1,
1782026-08-31, overruling an earlier "converges at its next genuine sleep"
179draft of this section that assumed a sleep the guard makes impossible.)**
180
181### 3.3 Tell the member
182
183The box is the only place that knows both numbers — it holds the timer rows
184and it constructed the activator with `cfg.Idle`. So the box decides and
185reports.
186
187Add `HoldsAwake bool \`json:"holds_awake"\`` to `schedule.TimerStatus`
188(`internal/schedule/status.go:24`) — true when this row's interval is shorter
189than the box's idle window, so the row's instance stays resident between
190ticks. Written
191wherever the status object is already written (after every fire and every
192converge that changed a host's rows). Additive, per the repo's wire-shape
193rule: anything written before this change must still parse, and a status
194object from an older box simply lacks the field.
195
196`carlos schedule ls` renders it — one line, in the member's own vocabulary:
197that this schedule fires more often than the deployment's idle window, so the
198instance stays awake between ticks and accrues instance-hours accordingly.
199`carlos schedule set` shows the same line once the converger has written the
200status.
201
202**Warn, never refuse. (decision)** A tight schedule is a legitimate thing to
203want, and the platform serves it correctly today. The defect is that it was
204silent, not that it was allowed.
205
206## 4. The consequence to be explicit about
207
208**Not every host is inert under this change — it depends which side of the
209idle window the host's schedule sits on.**
210
211A host whose schedule fires *more often* than the idle window — `jam`, 5m
212against the flagship's 15m window — is unaffected. The guard holds it
213resident for the same reason the accounting bug used to: its next tick is
214always inside the window, so the sweeper's answer is "stay awake" today and
215after this lands. `jam` will not begin sleeping when this ships.
216
217A host whose schedule fires *less often* than the idle window is not inert,
218if that host also takes visitor traffic. Before this branch, every tick —
219regardless of how far apart — reset `lastActive`, so a tick landing between
220two visits kept pushing hibernation out. After this branch a tick no longer
221resets the clock, so that host loses the extra runway its ticks used to buy
222it: it can now hibernate up to one full idle window earlier than before,
223measured from its last real visitor request rather than from whichever tick
224last happened to land. That is the fix working as designed — a tick was
225never meant to count as activity — but it is a real, observable behaviour
226change on *this* branch, not a case of "provably alters nothing". Whether the
227flagship carries any host in this shape today is exactly what §7's rollout
228check exists to establish before rolling.
229
230The sequencing that remains deliberate is narrower than "inert, then
231behaviour change": this change runs under every tenant instance on every
232box, so shipping it and confirming its effect matches §7's expectations
233before separately tightening `CARLOS_HIBERNATE_IDLE` 15m → 90s keeps the two
234changes distinguishable if something needs reverting. The idle-window
235tightening is the change that turns `jam` from "stays resident" into
236"sleeps and wakes"; this branch is the change that stops loose-schedule
237hosts getting undeserved extra runway from their ticks.
238
239**(ruling — Paul, 2026-08-30)** The two land as separate branches.
240
241## 5. What is not built
242
243- **No `NextDue` rework, no new registry column, no second waker.** §2.
244- **No per-route idle override.** One knob per deployment, plus a guard that
245 reads it, covers every case found. A per-route window is a product decision
246 nobody has asked for.
247- **No new tuning constant.** §3.2.
248- **No special case for `carlos schedule run`.** A manual fire is
249 `FireTimerNow`, which moves `next_at` and nothing else
250 (`internal/registry/timers.go:132`); the runner cannot tell it from a
251 recurring fire without a new column. A schema change to buy "the instance
252 stays up 15 minutes after you press run" is not worth it. Manual runs are
253 ticks. **(decision — reversed during the brainstorm, after reading the
254 code: the first instinct was to treat a human's press as visitor activity.)**
255- **No use of `routes.next_due` in the guard.** It looks like free extra
256 coverage — the app's own declared due time, already in the registry, already
257 read by `DueInstances` — and it is a trap. That column is written only at
258 sleep and is deliberately not cleared on wake: "the registry's stale past
259 value while the host is up is harmless" (`activator.go:1007`). A guard
260 reading it on an awake host would compare against a timestamp in the past,
261 conclude the next delivery is imminent forever, and wedge itself open. The
262 value could only be made current by probing the app on every sweep pass,
263 which is a socket round trip per idle host per tick. The guard covers
264 platform timers; app-declared schedules stay uncovered, and are fine there
265 because their intervals (hourly and up, in every case on the fleet today)
266 are far wider than any plausible idle window.
267- **No change to sidecar accounting.** It is already correct and is the
268 precedent this follows.
269
270## 6. Testing
271
272All of it runs without a box.
273
274`internal/activator`:
275
276- a scheduled delivery leaves `lastActive` untouched; a visitor request to the
277 same host moves it — the two asserted against each other in one test, since
278 the bug is precisely that they were the same path;
279- a tick against a *sleeping* host wakes it, and the in-flight count holds it
280 through delivery (a sweep racing the tick must not stop it mid-flight);
281- the guard: a host idle past the window with a timer due inside `cfg.Idle`
282 stays resident; the same host with the timer beyond `cfg.Idle` hibernates;
283 a host with no timer rows hibernates as before;
284- the guard's query runs before `a.mu` — asserted structurally, the way the
285 existing sweep tests pin their ordering.
286
287`internal/host`:
288
289- `activatorDeliverer.Deliver` routes through `ServeScheduled`, not
290 `ServeHTTP`, and a delivery to a host that was asleep when the tick came due
291 still returns the handler's status;
292- `X-Carlos-Schedule-At` still reaches the handler unchanged.
293
294`internal/schedule`:
295
296- a status object written by a box that computed the verdict round-trips, and
297 one written without the field still parses.
298
299## 7. Rollout
300
301Ordinary: merge, roll the fleet, confirm the change lands where it should
302and nowhere else — see §4, this is not a blanket "confirm inert". Two checks:
303
304- **`jam`-shaped hosts — schedule interval shorter than the box's idle
305 window — must not move at all.** `jam` itself must still be resident
306 afterwards, and `seapointish` (no schedule) must still be doing its ten
307 wake/sleep cycles a day. A change to either here is a bug, not a win.
308- **Before rolling, enumerate hosts whose schedule interval is longer than
309 the idle window.** Expect their wake/sleep cycle counts to rise slightly —
310 this branch stops their ticks buying free runway between real visits, so a
311 small rise there is the change working as designed. Only a large or
312 otherwise unexplained jump is worth investigating.
313
314The separate idle-window tightening (15m → 90s) is verified on the *next*
315branch, against the same apps.
316