carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The timers table: scheduled work materialized per (host, name) — spec
2// docs/superpowers/specs/2026-08-23-scheduled-work-design.md §5. Rows come
3// from two writers with disjoint columns: converge (schedule.Converge) and
4// the control socket own spec/path/kind; the runner owns next_at after the
5// first fire, last_*, attempts. UpsertTimer encodes that split so neither
6// writer can clobber the other. Deliberately NOT in Fingerprint(): a timer
7// changes nothing about routing.
8package registry
9
10import (
11 "context"
12 "crypto/rand"
13 "database/sql"
14 "encoding/hex"
15 "errors"
16 "fmt"
17 "github.com/carlosframework/platform/internal/hostwrite"
18 "os"
19 "sort"
20 "strings"
21)
22
23type Timer struct {
24 ClaimOwnership
25
26 Host, Name, Kind, Spec, Path string
27 NextAt, LastAt int64
28 LastStatus string
29 LastMs int64
30 Attempts int
31 CreatedAt int64
32 // DueAt is the natural occurrence the row is (or was last) delivering —
33 // the value X-Carlos-Schedule-At carries, and the key an app dedupes
34 // on. It is NOT next_at: after a retry next_at is the retry instant and
35 // after `run` it is "now", while the occurrence stays put. Moves only
36 // when the row advances to a new occurrence (MarkTimerFired with a new
37 // due, FireTimerNow, or an upsert that moves next_at). Zero on rows
38 // written before the column existed; readers fall back to NextAt.
39 DueAt int64
40 Registration string `json:"-"`
41}
42
43var ErrTimerKind = errors.New("registry: timer exists with a different kind")
44
45const timerCols = `host, name, kind, spec, path, next_at, last_at, last_status, last_ms, attempts, created_at, due_at, registration, claim_incarnation, claim_stamp, claim_owner, claim_row_id`
46
47func scanTimer(row interface{ Scan(...any) error }) (Timer, error) {
48 var t Timer
49 err := row.Scan(&t.Host, &t.Name, &t.Kind, &t.Spec, &t.Path, &t.NextAt, &t.LastAt, &t.LastStatus, &t.LastMs, &t.Attempts, &t.CreatedAt, &t.DueAt, &t.Registration, &t.ClaimIncarnation, &t.ClaimStamp, &t.ClaimOwner, &t.ClaimRowID)
50 return t, err
51}
52
53// UpsertTimer inserts t, or updates an existing row's spec/path. A changed spec/path or non-zero NextAt starts a new registration and resets attempts; unchanged declarations preserve delivery state.
54func (r *Registry) UpsertTimer(t Timer) error {
55 if r.remote != nil {
56 _, err := r.remote.call(nil, remoteRequest{Method: "UpsertTimer", T: t}, nil)
57 return err
58 }
59
60 if !hostwrite.Enabled(os.Getenv) {
61 return r.upsertLegacyTimer(t)
62 }
63 tx, err := r.beginClaimTransaction(context.Background(), false)
64 if err != nil {
65 return err
66 }
67 defer tx.Rollback()
68 rt, err := scanRoute(tx.QueryRow(`SELECT `+routeCols+` FROM routes WHERE host=?`, t.Host).Scan)
69 if err != nil && !errors.Is(err, sql.ErrNoRows) {
70 return err
71 }
72 if err == nil && rt.ClaimOwnership.Valid() {
73 if t.ClaimOwner != "" && (t.ClaimIncarnation != rt.ClaimIncarnation || t.ClaimStamp != rt.ClaimStamp || t.ClaimOwner != rt.ClaimOwner) {
74 return ErrClaimOwnership
75 }
76 t.ClaimOwnership = rt.ClaimOwnership
77 }
78 var id [16]byte
79 if _, err = rand.Read(id[:]); err != nil {
80 return err
81 }
82 t.Registration = hex.EncodeToString(id[:])
83 if t.ClaimOwnership.Valid() {
84 t.ClaimRowID = t.Registration
85 }
86 if err = upsertTimer(tx, t); err != nil {
87 return err
88 }
89 return tx.Commit()
90}
91func upsertTimer(tx interface {
92 Exec(string, ...any) (sql.Result, error)
93}, t Timer) error {
94 const rearm = `(timers.spec != excluded.spec OR timers.path != excluded.path OR excluded.next_at != 0)`
95 res, err := tx.Exec(`INSERT INTO timers (`+timerCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
96ON CONFLICT(host, name) DO UPDATE SET
97 spec=excluded.spec, path=excluded.path,
98 next_at=CASE WHEN `+rearm+` THEN excluded.next_at ELSE timers.next_at END,
99 due_at=CASE WHEN `+rearm+` THEN excluded.due_at ELSE timers.due_at END,
100 attempts=CASE WHEN `+rearm+` THEN 0 ELSE timers.attempts END,
101 registration=CASE WHEN `+rearm+` THEN excluded.registration ELSE timers.registration END,
102 claim_row_id=CASE WHEN `+rearm+` THEN excluded.claim_row_id ELSE timers.claim_row_id END
103WHERE timers.kind=excluded.kind AND timers.claim_incarnation=excluded.claim_incarnation
104AND timers.claim_stamp=excluded.claim_stamp AND timers.claim_owner=excluded.claim_owner`,
105 t.Host, t.Name, t.Kind, t.Spec, t.Path, t.NextAt, t.LastAt, t.LastStatus, t.LastMs, t.Attempts, t.CreatedAt, t.DueAt, t.Registration, t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID)
106 if err != nil {
107 return err
108 }
109 n, err := res.RowsAffected()
110 if err != nil {
111 return err
112 }
113 if n == 0 {
114 return ErrTimerKind
115 }
116 return nil
117}
118
119// ReplaceTimers makes host's timer rows exactly rows, in one transaction,
120// keeping every column as given: next_at, due_at, attempts and
121// registration included. It is how an installation imports the wake state
122// it was handed (region HA spec 1 §5.7, moves spec §9.4).
123//
124// UpsertTimer is the wrong tool for that: it mints a fresh registration
125// and resets the runner's bookkeeping on a rearm.
126func (r *Registry) ReplaceTimers(host string, rows []Timer) error {
127 if r.remote != nil {
128 _, err := r.remote.call(nil, remoteRequest{Method: "ReplaceTimers", Host: host, Rows: rows}, nil)
129 return err
130 }
131
132 if !hostwrite.Enabled(os.Getenv) {
133 return r.replaceLegacyTimers(host, rows)
134 }
135 for _, t := range rows {
136 if t.Host != host {
137 return fmt.Errorf("registry: timer %q is for host %q, not %q", t.Name, t.Host, host)
138 }
139 }
140 tx, err := r.beginClaimTransaction(context.Background(), false)
141 if err != nil {
142 return err
143 }
144 defer tx.Rollback()
145 rt, err := scanRoute(tx.QueryRow(`SELECT `+routeCols+` FROM routes WHERE host=?`, host).Scan)
146 if err != nil && !errors.Is(err, sql.ErrNoRows) {
147 return err
148 }
149 if err == nil && rt.ClaimOwnership.Valid() {
150 for i := range rows {
151 if rows[i].Registration == "" {
152 return ErrClaimOwnership
153 }
154 rows[i].ClaimOwnership = rt.ClaimOwnership
155 rows[i].ClaimRowID = rows[i].Registration
156 }
157 }
158 if _, err := tx.Exec(`DELETE FROM timers WHERE host = ?`, host); err != nil {
159 return err
160 }
161 for _, t := range rows {
162 if _, err := tx.Exec(`INSERT INTO timers (`+timerCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
163 t.Host, t.Name, t.Kind, t.Spec, t.Path, t.NextAt, t.LastAt, t.LastStatus, t.LastMs, t.Attempts, t.CreatedAt, t.DueAt, t.Registration, t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID); err != nil {
164 return fmt.Errorf("registry: insert timer %q: %w", t.Name, err)
165 }
166 }
167 return tx.Commit()
168}
169
170func (r *Registry) GetTimer(host, name string) (*Timer, error) {
171 if r.remote != nil {
172 out, err := r.remote.call(nil, remoteRequest{Method: "GetTimer", Host: host, Name: name}, nil)
173 return out.Timer, err
174 }
175
176 t, err := scanTimer(r.sql.QueryRow(`SELECT `+timerCols+` FROM timers WHERE host=? AND name=?`, host, name))
177 if errors.Is(err, sql.ErrNoRows) {
178 return nil, os.ErrNotExist
179 }
180 if err != nil {
181 return nil, err
182 }
183 return &t, nil
184}
185
186func (r *Registry) DeleteTimer(host, name string) error {
187 if r.remote != nil {
188 _, err := r.remote.call(nil, remoteRequest{Method: "DeleteTimer", Host: host, Name: name}, nil)
189 return err
190 }
191
192 _, err := r.sql.Exec(`DELETE FROM timers WHERE host=? AND name=?`, host, name)
193 return err
194}
195
196func (r *Registry) DeleteTimerRegistration(host, name, registration string) error {
197 if r.remote != nil {
198 _, err := r.remote.call(nil, remoteRequest{Method: "DeleteTimerRegistration", Host: host, Name: name, Registration: registration}, nil)
199 return err
200 }
201
202 _, err := r.sql.Exec(`DELETE FROM timers WHERE host=? AND name=? AND registration=?`, host, name, registration)
203 return err
204}
205
206func (r *Registry) queryTimers(q string, args ...any) ([]Timer, error) {
207 rows, err := r.sql.Query(q, args...)
208 if err != nil {
209 return nil, err
210 }
211 defer rows.Close()
212 var out []Timer
213 for rows.Next() {
214 t, err := scanTimer(rows)
215 if err != nil {
216 return nil, err
217 }
218 out = append(out, t)
219 }
220 return out, rows.Err()
221}
222
223func (r *Registry) TimersFor(host string) ([]Timer, error) {
224 if r.remote != nil {
225 out, err := r.remote.call(nil, remoteRequest{Method: "TimersFor", Host: host}, nil)
226 return out.Timers, err
227 }
228
229 return r.queryTimers(`SELECT `+timerCols+` FROM timers WHERE host=? ORDER BY name`, host)
230}
231
232// DueTimers is the runner's batch: rows due at or before now, earliest
233// first, at most limit of them.
234//
235// excludeHosts is what keeps the batch FAIR. The runner delivers one timer
236// at a time per host and skips a host whose delivery is still running, so a
237// host with more due rows than the batch holds — or one parked in a
238// 30-minute handler — would otherwise fill every slot of every batch with
239// rows the runner is about to skip, and another host's due timer would never
240// be seen at all. Passing the busy set here moves the filter into the query,
241// where the LIMIT is applied, instead of after it. An empty (or nil) slice
242// is no filter, which is the ordinary case: nothing in flight.
243func (r *Registry) DueTimers(now int64, limit int, excludeHosts []string) ([]Timer, error) {
244 if r.remote != nil {
245 out, err := r.remote.call(nil, remoteRequest{Method: "DueTimers", Now: now, Limit: limit, ExcludeHosts: excludeHosts}, nil)
246 return out.Timers, err
247 }
248
249 return r.dueTimers(now, limit, excludeHosts, false)
250}
251
252// DueUnheldTimers is DueTimers without the rows of held routes (moves spec
253// §5.2, HoldStatuses). The exclusion is in the query, before the LIMIT, so
254// a held host's rows cannot fill the batch with rows the runner would only
255// skip and hide another host's due timer behind them (moves Task 6 fix
256// round 1). A row whose host has no route is still returned, as DueTimers
257// returns it. The runner asks this only with its hold wiring armed;
258// DueTimers stays main's query.
259func (r *Registry) DueUnheldTimers(now int64, limit int, excludeHosts []string) ([]Timer, error) {
260 if r.remote != nil {
261 out, err := r.remote.call(nil, remoteRequest{Method: "DueUnheldTimers", Now: now, Limit: limit, ExcludeHosts: excludeHosts}, nil)
262 return out.Timers, err
263 }
264
265 return r.dueTimers(now, limit, excludeHosts, true)
266}
267
268// dueTimers is both queries. Neither returns the rows of a gated route (an
269// uncommitted import's, seeded-instances spec §5.4), and the exclusion is
270// in the query, before the LIMIT (final review F5): the runner skips such a
271// row without advancing it, so gated rows left in the batch would stay due
272// for ever and could fill every slot of it, hiding every other host's due
273// timer. The runner keeps its own check.
274func (r *Registry) dueTimers(now int64, limit int, excludeHosts []string, skipHeld bool) ([]Timer, error) {
275 q := `SELECT ` + timerCols + ` FROM timers WHERE next_at > 0 AND next_at <= ?` +
276 ` AND host NOT IN (SELECT host FROM routes WHERE import_gate != '')`
277 args := []any{now}
278 if skipHeld {
279 statuses := make([]string, 0, len(HoldStatuses))
280 for s := range HoldStatuses {
281 statuses = append(statuses, s)
282 }
283 sort.Strings(statuses)
284 q += ` AND host NOT IN (SELECT host FROM routes WHERE status IN (?` + strings.Repeat(",?", len(statuses)-1) + `))`
285 for _, s := range statuses {
286 args = append(args, s)
287 }
288 }
289 if len(excludeHosts) > 0 {
290 q += ` AND host NOT IN (?` + strings.Repeat(",?", len(excludeHosts)-1) + `)`
291 for _, h := range excludeHosts {
292 args = append(args, h)
293 }
294 }
295 q += ` ORDER BY next_at, host, name LIMIT ?`
296 return r.queryTimers(q, append(args, limit)...)
297}
298
299// FireTimerNow makes the row due at now, and now IS the occurrence being
300// delivered (a `carlos schedule run` is its own occurrence, not an early
301// delivery of the next natural one).
302func (r *Registry) FireTimerNow(host, name string, now int64) error {
303 if r.remote != nil {
304 _, err := r.remote.call(nil, remoteRequest{Method: "FireTimerNow", Host: host, Name: name, Now: now}, nil)
305 return err
306 }
307
308 var id [16]byte
309 if _, err := rand.Read(id[:]); err != nil {
310 return err
311 }
312 registration := hex.EncodeToString(id[:])
313 _, err := r.sql.Exec(`UPDATE timers SET next_at=?, due_at=?, attempts=0, registration=? , claim_row_id=CASE WHEN claim_owner='' THEN claim_row_id ELSE ? END WHERE host=? AND name=?`, now, now, registration, registration, host, name)
314 return err
315}
316
317// MarkTimerFired records one delivery. next is when the row is due again;
318// due is the occurrence THAT next delivery is for: the same value as next
319// when the row advances, and the row's current DueAt when next is a retry
320// of the occurrence just attempted — the runner decides which.
321func (r *Registry) MarkTimerFired(host, name, registration string, at int64, status string, ms int64, attempts int, next, due int64) (bool, error) {
322 if r.remote != nil {
323 out, err := r.remote.call(nil, remoteRequest{Method: "MarkTimerFired", Host: host, Name: name, Registration: registration, At: at, Status: status, Ms: ms, Attempts: attempts, NextAt: next, Due: due}, nil)
324 return out.Changed, err
325 }
326
327 res, err := r.sql.Exec(`UPDATE timers SET last_at=?, last_status=?, last_ms=?, attempts=?, next_at=?, due_at=? WHERE host=? AND name=? AND registration=?`,
328 at, status, ms, attempts, next, due, host, name, registration)
329 if err != nil {
330 return false, err
331 }
332 n, err := res.RowsAffected()
333 return n != 0, err
334}
335
336// SetTimerStatus writes last_status alone — converge's one word on a row
337// it parked (schedule.StatusNeverFires); last_at stays as it was, so the
338// status reads as a condition, not a fire.
339func (r *Registry) SetTimerStatus(host, name, status string) error {
340 if r.remote != nil {
341 _, err := r.remote.call(nil, remoteRequest{Method: "SetTimerStatus", Host: host, Name: name, Status: status}, nil)
342 return err
343 }
344
345 _, err := r.sql.Exec(`UPDATE timers SET last_status=? WHERE host=? AND name=?`, status, host, name)
346 return err
347}
348
349func (r *Registry) CountTimers(host, kind string) (int, error) {
350 if r.remote != nil {
351 out, err := r.remote.call(nil, remoteRequest{Method: "CountTimers", Host: host, Kind: kind}, nil)
352 return out.Number, err
353 }
354
355 var n int
356 err := r.sql.QueryRow(`SELECT COUNT(*) FROM timers WHERE host=? AND kind=?`, host, kind).Scan(&n)
357 return n, err
358}
359
360// PruneTimers: rows for hosts no longer routed here, and finished once-rows
361// older than doneBefore.
362//
363// An empty alive set deletes EVERY row. That is the right answer for a
364// genuinely empty registry — routes and timers live in the same file, so
365// every row really is orphaned — but it is a wipe, and there are two callers
366// now: schedule.Converger.Pass (every pass, on a box with a deploy store) and
367// schedule.Runner.prune (daily, on every box). Both build alive from the
368// registry's own route list; the runner declines the sweep outright when that
369// list is empty rather than hand the wipe over. A third caller should do the
370// same, or say in its own comment why the wipe is what it wants.
371func (r *Registry) PruneTimers(alive map[string]bool, doneBefore int64) (int, error) {
372 if r.remote != nil {
373 out, err := r.remote.call(nil, remoteRequest{Method: "PruneTimers", Alive: alive, DoneBefore: doneBefore}, nil)
374 return out.Number, err
375 }
376
377 hosts := make([]any, 0, len(alive))
378 for h := range alive {
379 hosts = append(hosts, h)
380 }
381 q := `DELETE FROM timers WHERE (kind='once' AND next_at=0 AND last_at < ?)`
382 args := []any{doneBefore}
383 if len(hosts) == 0 {
384 q += ` OR 1=1`
385 } else {
386 q += ` OR host NOT IN (?` + strings.Repeat(",?", len(hosts)-1) + `)`
387 args = append(args, hosts...)
388 }
389 res, err := r.sql.Exec(q, args...)
390 if err != nil {
391 return 0, err
392 }
393 n, err := res.RowsAffected()
394 if err != nil {
395 return 0, err
396 }
397 return int(n), nil
398}
399
400func (r *Registry) DeleteTimersExcept(host, kind string, keep map[string]bool) (int, error) {
401 if r.remote != nil {
402 out, err := r.remote.call(nil, remoteRequest{Method: "DeleteTimersExcept", Host: host, Kind: kind, Keep: keep}, nil)
403 return out.Number, err
404 }
405
406 names := make([]any, 0, len(keep))
407 for n := range keep {
408 names = append(names, n)
409 }
410 q := `DELETE FROM timers WHERE host=? AND kind=?`
411 args := []any{host, kind}
412 if len(names) > 0 {
413 q += ` AND name NOT IN (?` + strings.Repeat(",?", len(names)-1) + `)`
414 args = append(args, names...)
415 }
416 res, err := r.sql.Exec(q, args...)
417 if err != nil {
418 return 0, err
419 }
420 n, err := res.RowsAffected()
421 if err != nil {
422 return 0, err
423 }
424 return int(n), nil
425}
426
427// EarliestTimers is the hibernation guard's batch: for every host with at
428// least one armed row, the soonest next_at among them. One query, because
429// the activator's sweep reads it BEFORE taking its mutex (activator.go's
430// "nothing under a.mu should wait on SQLite") and a per-entry query inside
431// the sweep loop would put SQLite exactly where that comment forbids it.
432//
433// next_at = 0 is "not armed" — a parked cron, or a row converge wrote and
434// the runner has not yet dated — and is excluded here rather than by the
435// caller, matching DueTimers' own `next_at > 0` gate.
436func (r *Registry) EarliestTimers() (map[string]int64, error) {
437 if r.remote != nil {
438 out, err := r.remote.call(nil, remoteRequest{Method: "EarliestTimers"}, nil)
439 return out.Times, err
440 }
441
442 rows, err := r.sql.Query(`SELECT host, MIN(next_at) FROM timers WHERE next_at > 0 GROUP BY host`)
443 if err != nil {
444 return nil, err
445 }
446 defer rows.Close()
447 out := make(map[string]int64)
448 for rows.Next() {
449 var host string
450 var next int64
451 if err := rows.Scan(&host, &next); err != nil {
452 return nil, err
453 }
454 out[host] = next
455 }
456 return out, rows.Err()
457}
458
459const legacyTimerCols = `host, name, kind, spec, path, next_at, last_at, last_status, last_ms, attempts, created_at, due_at, registration`
460
461func (r *Registry) upsertLegacyTimer(t Timer) error {
462 const rearm = `(timers.spec != excluded.spec OR timers.path != excluded.path OR excluded.next_at != 0)`
463 res, err := r.sql.Exec(`INSERT INTO timers (`+legacyTimerCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,lower(hex(randomblob(16))))
464ON CONFLICT(host, name) DO UPDATE SET
465 spec=excluded.spec, path=excluded.path,
466 next_at=CASE WHEN `+rearm+` THEN excluded.next_at ELSE timers.next_at END,
467 due_at=CASE WHEN `+rearm+` THEN excluded.due_at ELSE timers.due_at END,
468 attempts=CASE WHEN `+rearm+` THEN 0 ELSE timers.attempts END,
469 registration=CASE WHEN `+rearm+` THEN excluded.registration ELSE timers.registration END
470WHERE timers.kind=excluded.kind`,
471 t.Host, t.Name, t.Kind, t.Spec, t.Path, t.NextAt, t.LastAt, t.LastStatus, t.LastMs, t.Attempts, t.CreatedAt, t.DueAt)
472 if err != nil {
473 return err
474 }
475 n, err := res.RowsAffected()
476 if err != nil {
477 return err
478 }
479 if n == 0 {
480 return ErrTimerKind
481 }
482 return nil
483}
484
485func (r *Registry) replaceLegacyTimers(host string, rows []Timer) error {
486 for _, t := range rows {
487 if t.Host != host {
488 return fmt.Errorf("registry: timer %q is for host %q, not %q", t.Name, t.Host, host)
489 }
490 }
491 tx, err := r.sql.Begin()
492 if err != nil {
493 return err
494 }
495 defer tx.Rollback()
496 if _, err := tx.Exec(`DELETE FROM timers WHERE host = ?`, host); err != nil {
497 return err
498 }
499 for _, t := range rows {
500 if _, err := tx.Exec(`INSERT INTO timers (`+legacyTimerCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
501 t.Host, t.Name, t.Kind, t.Spec, t.Path, t.NextAt, t.LastAt, t.LastStatus, t.LastMs, t.Attempts, t.CreatedAt, t.DueAt, t.Registration); err != nil {
502 return fmt.Errorf("registry: insert timer %q: %w", t.Name, err)
503 }
504 }
505 return tx.Commit()
506}
507