eleven / messenger Public

server: due-time index — hibernating instances wake for scheduled deliveries (#550 Part 4 v2) (#653)

The activator's biggest caveat was the named non-goal: a sleeping
instance's future-messages/reminders didn't fire until something else
woke it. Now they fire on time:

- Instances report their earliest pending delivery at GET /api/next-due
  (admin-gated like every data endpoint; MIN(deliver_at) over the
  scheduled_messages table, 0 = nothing scheduled).
- The activator's stop path probes it over the socket before killing the
  child and records the answer in the registry (routes.next_due, additive
  guarded-ALTER migration) — persisted, so due times survive router
  restarts; the registry is fleet-backed-up. Probe failure records 0 and
  the sleep proceeds: degrade to "sleeps until a request", never crash.
- The sweeper reads the index each tick and wakes any sleeping host whose
  time has arrived — a request-less wake through the same singleflight
  state machine. Once up, the instance's own 5s scheduler delivers; the
  normal idle window puts it back to sleep, which re-records the NEXT due
  time — the cycle is self-maintaining.
- A past-due time at sleep is recorded as-is and woken next tick; no
  minimum-sleep guard is needed because an awake instance only sleeps
  after a full idle window, so wake/sleep cycles can't thrash.

Unit tests cover the full cycle, the no-ping-pong property, probe-failure
fallback, restart survival, and the registry migration/filtering; the
live S3 test now also exercises the real probe + record path.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 79770ef98ca91aa34f52495c1b2687b50a4411fd parent 0c07ba8
10 files changed, +558 −30
  • activator.go +114 −6
  • activator_live_test.go +20 −1
  • activator_test.go +205 −13
  • control_test.go +70 −0
  • db.go +11 −0
  • main.go +3 −0
  • miniapps.go +20 −0
  • miniapps_test.go +60 −0
  • registry.go +51 −9
  • router.go +4 −1
diff --git a/activator.go b/activator.go
index 84c01f7..f91e509 100644
--- a/activator.go
+++ b/activator.go
@@ -15,18 +15,28 @@ package main
// Scope (this phase): ONE box. No gossip/HRW placement, no peer proxying —
// membership of one, hash picks self, leases uncontested except against a
// previous incarnation of ourselves. Explicit non-goals held for follow-ups:
-// - No due-time index yet: scheduled future-messages/reminders of a
-// HIBERNATED instance don't fire while it sleeps (they deliver late, on
-// the next wake). That gap is why the feature stays opt-in.
// - No bot sidecar co-spawn: a hibernating instance's sidecar isn't managed.
// - No migration of existing fleet instances: setting the env on a box whose
// instances run under systemd is unsupported (the units would fight the
// activator over sockets and DBs).
+//
+// The due-time index (a former non-goal, now in): scheduled future-messages/
+// reminders fire on time even while their instance sleeps. At sleep the stop
+// path asks the instance for its earliest pending delivery (GET /api/next-due
+// over the socket, admin-gated) and records it in the registry (routes.next_due
+// — persisted so it survives router restarts); the sweeper wakes any sleeping
+// host whose time has arrived, with no client request. Once up, the instance's
+// own scheduler (miniapps.go runScheduler, a 5s tick) delivers, and the normal
+// idle window puts it back to sleep — which re-records the NEXT due time, so
+// the cycle maintains itself. Worst-case lateness ≈ one activator sweep
+// interval (≤30s) + spawn + one scheduler tick (≤5s).
import (
"context"
+ "encoding/json"
"errors"
"fmt"
+ "io"
"log"
"net/http"
"os"
@@ -96,6 +106,18 @@ type actBackend interface {
restore(ctx context.Context, host, dbPath string) error
// start spawns the instance process and waits until its socket accepts.
start(ctx context.Context, rt Route) (actProc, error)
+ // nextDue asks a running instance for its earliest pending scheduled
+ // delivery (unix seconds; 0 = nothing scheduled). Queried at sleep time,
+ // while the socket still answers, to feed the due-time index.
+ nextDue(ctx context.Context, rt Route) (int64, error)
+}
+
+// actDueStore persists the due-time index — the registry in production
+// (routes.next_due), so a sleeping host's timer survives router restarts
+// instead of dying with the process.
+type actDueStore interface {
+ setNextDue(host string, due int64) error
+ dueInstances(now int64) ([]Route, error)
}
// actLease is a held-but-not-yet-replicating lease (wake in progress).
@@ -186,6 +208,7 @@ var errWakeTimeout = errors.New("timed out waiting for instance wake")
type activatorSvc struct {
cfg hibernateConfig
backend actBackend
+ due actDueStore
now func() time.Time // a seam for the unit tests' clock
mu sync.Mutex
@@ -195,10 +218,11 @@ type activatorSvc struct {
sweepDone chan struct{}
}
-func newActivator(cfg hibernateConfig, backend actBackend) *activatorSvc {
+func newActivator(cfg hibernateConfig, backend actBackend, due actDueStore) *activatorSvc {
return &activatorSvc{
cfg: cfg,
backend: backend,
+ due: due,
now: time.Now,
entries: make(map[string]*actEntry),
}
@@ -382,10 +406,33 @@ func (a *activatorSvc) beginStopLocked(e *actEntry) {
// the entry. The local DB stays in place — it's the warm cache for the next
// wake; S3 is the durable truth under the lease.
func (a *activatorSvc) stop(e *actEntry) {
+ // The due-time index: ask the instance for its earliest pending scheduled
+ // delivery BEFORE stopping it — the socket dies with the process. (When the
+ // stop was triggered by a dead child, the query just fails fast.) Any
+ // failure records 0, i.e. "sleeps until a request": a missed probe must
+ // degrade to pre-index behavior, never block or crash the sleep. A due time
+ // already in the past is recorded as-is and the sweeper wakes the host on
+ // its next tick — there is deliberately no minimum-sleep guard beyond that,
+ // because thrash is already impossible: an awake instance only sleeps after
+ // a full idle window, so wake/sleep cycles can never be spaced closer than
+ // cfg.Idle.
+ dueCtx, cancelDue := context.WithTimeout(context.Background(), 5*time.Second)
+ due, err := a.backend.nextDue(dueCtx, e.route)
+ cancelDue()
+ if err != nil {
+ log.Printf("activator: next-due probe %s: %v — recording none (it sleeps until a request)", e.host, err)
+ due = 0
+ }
+ if err := a.due.setNextDue(e.host, due); err != nil {
+ log.Printf("activator: record next-due %s: %v", e.host, err)
+ } else if due > 0 {
+ log.Printf("activator: %s next scheduled delivery at %s — will wake for it", e.host, time.Unix(due, 0).UTC().Format(time.RFC3339))
+ }
+
// Separate budgets: a SIGTERM drain that runs long must not eat the final
// checkpoint's time — the S3 sync is the part that makes sleep durable.
procCtx, cancelProc := context.WithTimeout(context.Background(), actStopTimeout)
- err := e.proc.stop(procCtx)
+ err = e.proc.stop(procCtx)
cancelProc()
if err != nil {
log.Printf("activator: stop %s: %v", e.host, err)
@@ -435,9 +482,17 @@ func (a *activatorSvc) start() {
}
// sweepOnce examines every awake instance once: idle ones hibernate; a lost
-// lease or dead child stands down immediately regardless of activity.
+// lease or dead child stands down immediately regardless of activity. It then
+// checks the due-time index and wakes any sleeping host whose scheduled
+// delivery has come due.
func (a *activatorSvc) sweepOnce() {
now := a.now()
+ // The due query runs before the lock — it's a registry read, and nothing
+ // under a.mu should wait on SQLite.
+ dueRoutes, err := a.due.dueInstances(now.Unix())
+ if err != nil {
+ log.Printf("activator: due-time sweep: %v", err)
+ }
a.mu.Lock()
for _, e := range a.entries {
if e.state != actAwake {
@@ -458,6 +513,24 @@ func (a *activatorSvc) sweepOnce() {
a.beginStopLocked(e)
}
}
+ // Due-time wakes: a request-less wake through the same singleflight state
+ // machine, so a client request arriving mid-wake just coalesces. Only hosts
+ // with NO live entry are woken — an awake host is already serving its
+ // schedule; a waking one is on its way; a stopping one will re-record its
+ // next_due when the stop completes and be picked up on a later tick. The
+ // registry's stale past value while the host is up is harmless for the same
+ // reason. A failed due wake (e.g. a lease still held by a crashed
+ // incarnation) removes the entry, and the index — only rewritten at the
+ // next successful sleep — naturally retries it every tick until it works.
+ for _, rt := range dueRoutes {
+ if _, live := a.entries[rt.Host]; live {
+ continue
+ }
+ log.Printf("activator: %s has a scheduled delivery due — waking it", rt.Host)
+ e := &actEntry{host: rt.Host, route: rt, state: actWaking, ready: make(chan struct{})}
+ a.entries[rt.Host] = e
+ go a.wake(e)
+ }
a.mu.Unlock()
}
@@ -585,6 +658,41 @@ func (b *s3Backend) restore(ctx context.Context, host, dbPath string) error {
return nil
}
+// nextDue asks the (still-running) instance for its earliest pending scheduled
+// delivery over its socket. The endpoint is admin-gated like every instance
+// data endpoint, so present the instance's own admin token — read from the same
+// env file the spawn path hands the child (the activator holds it either way).
+func (b *s3Backend) nextDue(ctx context.Context, rt Route) (int64, error) {
+ fileEnv, err := readEnvFile(filepath.Join(instanceDataDir, rt.Host+".env"))
+ if err != nil {
+ return 0, fmt.Errorf("read env file: %w", err)
+ }
+ token := fileEnv["LCHAT_ADMIN_TOKEN"]
+ if token == "" {
+ return 0, errors.New("no LCHAT_ADMIN_TOKEN in env file")
+ }
+ req, err := http.NewRequestWithContext(ctx, "GET", "http://lchat/api/next-due", nil)
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := socketClient(rt.Socket).Do(req)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return 0, fmt.Errorf("GET /api/next-due: HTTP %d", resp.StatusCode)
+ }
+ var body struct {
+ Due int64 `json:"due"`
+ }
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<12)).Decode(&body); err != nil {
+ return 0, fmt.Errorf("decode /api/next-due: %w", err)
+ }
+ return body.Due, nil
+}
+
// spawnHealthTimeout bounds how long a spawned instance may take to bind its
// socket and answer. A var so tests can shrink the failure path.
var spawnHealthTimeout = 30 * time.Second
diff --git a/activator_live_test.go b/activator_live_test.go
index 218d824..d7efbd0 100644
--- a/activator_live_test.go
+++ b/activator_live_test.go
@@ -101,7 +101,7 @@ func TestActivatorLiveHibernation(t *testing.T) {
}
cfg := hibernateConfig{Bucket: bucket, Region: region, Prefix: prefix, Idle: 3 * time.Second}
- act := newActivator(cfg, &s3Backend{cfg: cfg})
+ act := newActivator(cfg, &s3Backend{cfg: cfg}, reg)
act.start()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
@@ -155,8 +155,27 @@ func TestActivatorLiveHibernation(t *testing.T) {
resp.Body.Close()
t.Logf("wrote group name %q through the admin API", groupName)
+ // The due-time probe answers on the real instance (admin-gated; nothing
+ // scheduled → 0). The sleep below re-runs the same call over the socket.
+ nreq, _ := http.NewRequest("GET", front.URL+"/api/next-due", nil)
+ nreq.Host = host
+ nreq.Header.Set("Authorization", "Bearer "+adminToken)
+ nresp, err := http.DefaultClient.Do(nreq)
+ if err != nil {
+ t.Fatalf("GET /api/next-due: %v", err)
+ }
+ nbody, _ := io.ReadAll(nresp.Body)
+ nresp.Body.Close()
+ if nresp.StatusCode != 200 || !strings.Contains(string(nbody), `"due":0`) {
+ t.Fatalf("next-due probe: %d %s, want 200 with due 0", nresp.StatusCode, nbody)
+ }
+
// --- Act 2: idle out. Final checkpoint to S3, lease released, process gone. ---
waitForLive(t, "hibernation after idle", 90*time.Second, func() bool { return act.entryCount() == 0 })
+ // The sleep recorded the (empty) due-time index in the registry.
+ if rt, err := reg.get(host); err != nil || rt == nil || rt.NextDue != 0 {
+ t.Fatalf("registry next_due after sleep = %+v, %v; want 0", rt, err)
+ }
if _, err := net.DialTimeout("unix", route.Socket, 300*time.Millisecond); err == nil {
t.Fatal("instance socket still accepting after hibernation")
}
diff --git a/activator_test.go b/activator_test.go
index 04a0fcf..3973c79 100644
--- a/activator_test.go
+++ b/activator_test.go
@@ -96,6 +96,8 @@ type fakeBackend struct {
startGate chan struct{} // when non-nil, start() blocks until closed
reps []*fakeRep
procs []*fakeProc
+ dueVal int64 // what the instance reports at the sleep-time next-due probe
+ dueErr error // when non-nil, the probe fails (dead socket, missing token, …)
}
func (b *fakeBackend) acquire(context.Context, string) (actLease, error) {
@@ -131,6 +133,21 @@ func (b *fakeBackend) start(context.Context, Route) (actProc, error) {
return proc, nil
}
+func (b *fakeBackend) nextDue(context.Context, Route) (int64, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.dueErr != nil {
+ return 0, b.dueErr
+ }
+ return b.dueVal, nil
+}
+
+func (b *fakeBackend) setDue(v int64) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.dueVal = v
+}
+
func (b *fakeBackend) counts() (acquires, restores, starts int) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -166,13 +183,19 @@ func (c *fakeClock) advance(d time.Duration) {
c.t = c.t.Add(d)
}
-func testActivator(t *testing.T, idle time.Duration) (*activatorSvc, *fakeBackend, *fakeClock) {
+// testActivator wires the fakes to a REAL (temp-dir) registry as the due store,
+// so the due-time tests exercise the actual routes.next_due SQL, not a fake of it.
+func testActivator(t *testing.T, idle time.Duration) (*activatorSvc, *fakeBackend, *fakeClock, *Registry) {
t.Helper()
b := &fakeBackend{}
clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
- a := newActivator(hibernateConfig{Bucket: "test-bucket", Idle: idle}, b)
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ a := newActivator(hibernateConfig{Bucket: "test-bucket", Idle: idle}, b, reg)
a.now = clk.now
- return a, b, clk
+ return a, b, clk, reg
}
func testRoute(host string) Route {
@@ -195,7 +218,7 @@ func (a *activatorSvc) entryCount() int {
// Many concurrent requests during a wake coalesce into exactly one wake: one
// lease claim, one restore, one spawn.
func TestActivatorWakeCoalesces(t *testing.T) {
- a, b, _ := testActivator(t, time.Minute)
+ a, b, _, _ := testActivator(t, time.Minute)
b.startGate = make(chan struct{})
const n = 12
@@ -233,7 +256,7 @@ func TestActivatorWakeCoalesces(t *testing.T) {
// instance: process stopped, replication closed (final checkpoint + lease
// release), entry forgotten; the next request wakes it afresh.
func TestActivatorIdleSleep(t *testing.T) {
- a, b, clk := testActivator(t, 10*time.Second)
+ a, b, clk, _ := testActivator(t, 10*time.Second)
release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
if err != nil {
@@ -272,7 +295,7 @@ func TestActivatorIdleSleep(t *testing.T) {
// An in-flight request (an open WebSocket, in production) holds the instance
// awake past the idle window; only after it finishes does the idle clock run.
func TestActivatorInflightKeepsAwake(t *testing.T) {
- a, b, clk := testActivator(t, 10*time.Second)
+ a, b, clk, _ := testActivator(t, 10*time.Second)
release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
if err != nil {
@@ -298,7 +321,7 @@ func TestActivatorInflightKeepsAwake(t *testing.T) {
// A lost lease (failed renewal) stands the instance down immediately — no
// waiting for idle: someone else may own the S3 prefix now.
func TestActivatorLeaseLostStandsDown(t *testing.T) {
- a, b, _ := testActivator(t, time.Hour)
+ a, b, _, _ := testActivator(t, time.Hour)
release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
if err != nil {
@@ -320,7 +343,7 @@ func TestActivatorLeaseLostStandsDown(t *testing.T) {
// A dead child (crashed instance) is recycled by the next request: the stale
// entry is stopped and a fresh wake happens in the same beginRequest call.
func TestActivatorDeadChildRewakesOnRequest(t *testing.T) {
- a, b, _ := testActivator(t, time.Hour)
+ a, b, _, _ := testActivator(t, time.Hour)
release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
if err != nil {
@@ -345,7 +368,7 @@ func TestActivatorDeadChildRewakesOnRequest(t *testing.T) {
// A request that arrives mid-stop waits for the stop, then wakes fresh.
func TestActivatorRequestDuringStopRewakes(t *testing.T) {
- a, b, clk := testActivator(t, 10*time.Second)
+ a, b, clk, _ := testActivator(t, 10*time.Second)
release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
if err != nil {
@@ -384,7 +407,7 @@ func TestActivatorRequestDuringStopRewakes(t *testing.T) {
// A wake that exceeds the bound turns queued requests away with errWakeTimeout
// (503 at the router) rather than piling connections up behind it.
func TestActivatorWakeTimeout(t *testing.T) {
- a, b, _ := testActivator(t, time.Minute)
+ a, b, _, _ := testActivator(t, time.Minute)
old := wakeWaitBound
wakeWaitBound = 100 * time.Millisecond
t.Cleanup(func() { wakeWaitBound = old })
@@ -402,7 +425,7 @@ func TestActivatorWakeTimeout(t *testing.T) {
// Lease contention surfaces through the router as 503 + Retry-After, and a
// failed wake leaves no entry behind (the next request retries the claim).
func TestActivatorLeaseContention503(t *testing.T) {
- a, b, _ := testActivator(t, time.Minute)
+ a, b, _, _ := testActivator(t, time.Minute)
b.acquireErr = &errLeaseHeld{holder: "old-incarnation", retryAfter: 7 * time.Second}
reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
@@ -451,7 +474,7 @@ func TestActivatorServiceRoutesBypass(t *testing.T) {
go backend.Serve(ln)
t.Cleanup(func() { backend.Close() })
- a, b, _ := testActivator(t, time.Minute)
+ a, b, _, _ := testActivator(t, time.Minute)
reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
if err != nil {
t.Fatalf("open registry: %v", err)
@@ -484,7 +507,7 @@ func TestActivatorServiceRoutesBypass(t *testing.T) {
// shutdown checkpoints and stops every awake child before the router exits.
func TestActivatorShutdownStopsChildren(t *testing.T) {
- a, b, _ := testActivator(t, time.Hour)
+ a, b, _, _ := testActivator(t, time.Hour)
for _, h := range []string{"one.example.com", "two.example.com"} {
release, err := a.beginRequest(context.Background(), testRoute(h))
if err != nil {
@@ -514,6 +537,175 @@ func TestActivatorShutdownStopsChildren(t *testing.T) {
}
}
+// --- due-time index --------------------------------------------------------------
+
+// entryAwake reports whether host has a live entry in the awake state — how the
+// due tests observe an async due-time wake resolving.
+func (a *activatorSvc) entryAwake(host string) bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ e := a.entries[host]
+ return e != nil && e.state == actAwake
+}
+
+// The full self-maintaining cycle: sleep records the instance's next due time
+// in the registry; the sweeper wakes the host (no request) when it arrives; the
+// following sleep re-records — and once nothing is scheduled, no more due wakes.
+func TestActivatorDueTimeWakeCycle(t *testing.T) {
+ a, b, clk, reg := testActivator(t, 10*time.Second)
+ route := testRoute("due.example.com")
+ if err := reg.upsert(route); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+
+ // Wake by request; the instance has a delivery scheduled 30s out.
+ dueAt := clk.now().Add(30 * time.Second).Unix()
+ b.setDue(dueAt)
+ release, err := a.beginRequest(context.Background(), route)
+ if err != nil {
+ t.Fatalf("beginRequest: %v", err)
+ }
+ release()
+
+ // Idle out: the sleep probes the instance and records its due time.
+ clk.advance(11 * time.Second)
+ a.sweepOnce()
+ waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 })
+ if rt, err := reg.get(route.Host); err != nil || rt == nil || rt.NextDue != dueAt {
+ t.Fatalf("registry next_due = %+v, %v; want %d", rt, err, dueAt)
+ }
+
+ // Before the due time: sweeps leave it asleep.
+ a.sweepOnce()
+ if _, _, starts := b.counts(); starts != 1 {
+ t.Fatalf("woke before its due time (starts=%d)", starts)
+ }
+
+ // At the due time: the sweeper wakes it with no client request.
+ clk.advance(20 * time.Second) // now = wake+31s ≥ due
+ a.sweepOnce()
+ waitFor(t, "due-time wake", func() bool { return a.entryAwake(route.Host) })
+ if _, _, starts := b.counts(); starts != 2 {
+ t.Fatalf("starts=%d after due wake, want 2", starts)
+ }
+
+ // The woken instance stays up for the normal idle window, then sleeps and
+ // re-records — nothing scheduled now, so the index clears and stays quiet.
+ b.setDue(0)
+ clk.advance(11 * time.Second)
+ a.sweepOnce()
+ waitFor(t, "re-hibernation", func() bool { return a.entryCount() == 0 })
+ if rt, _ := reg.get(route.Host); rt.NextDue != 0 {
+ t.Fatalf("next_due = %d after empty-schedule sleep, want 0", rt.NextDue)
+ }
+ clk.advance(time.Hour)
+ a.sweepOnce()
+ if _, _, starts := b.counts(); starts != 2 {
+ t.Fatalf("starts=%d with nothing scheduled, want 2 (no phantom due wake)", starts)
+ }
+}
+
+// A due time already in the past at sleep time (a delivery that raced the stop)
+// is recorded as-is: the sweeper wakes the host on its next tick — once — and
+// there is no ping-pong, because a woken instance can't sleep again until a
+// full idle window passes.
+func TestActivatorDuePastWakesOnceNoPingPong(t *testing.T) {
+ a, b, clk, reg := testActivator(t, 10*time.Second)
+ route := testRoute("late.example.com")
+ if err := reg.upsert(route); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ b.setDue(clk.now().Add(-5 * time.Second).Unix()) // overdue before it even sleeps
+
+ release, err := a.beginRequest(context.Background(), route)
+ if err != nil {
+ t.Fatalf("beginRequest: %v", err)
+ }
+ release()
+ clk.advance(11 * time.Second)
+ a.sweepOnce()
+ waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 })
+
+ // Next tick: woken for the overdue delivery.
+ a.sweepOnce()
+ waitFor(t, "overdue wake", func() bool { return a.entryAwake(route.Host) })
+
+ // No thrash: with the wake fresh (clock unmoved), repeated sweeps neither
+ // re-sleep it nor double-wake it, even though next_due is still in the past.
+ for i := 0; i < 5; i++ {
+ a.sweepOnce()
+ }
+ if a.entryCount() != 1 || !a.entryAwake(route.Host) {
+ t.Fatal("due-woken instance did not stay awake through its idle window")
+ }
+ if _, _, starts := b.counts(); starts != 2 {
+ t.Fatalf("starts=%d, want 2 (request wake + one due wake)", starts)
+ }
+}
+
+// A failed next-due probe (dead socket, missing admin token, …) records 0 and
+// the sleep proceeds — the instance degrades to "sleeps until a request",
+// never a crash and never a stuck stop.
+func TestActivatorDueProbeFailureRecordsZero(t *testing.T) {
+ a, b, clk, reg := testActivator(t, 10*time.Second)
+ route := testRoute("deaf.example.com")
+ if err := reg.upsert(route); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ b.dueErr = errors.New("dial unix: no such file")
+
+ release, err := a.beginRequest(context.Background(), route)
+ if err != nil {
+ t.Fatalf("beginRequest: %v", err)
+ }
+ release()
+ clk.advance(11 * time.Second)
+ a.sweepOnce()
+ waitFor(t, "hibernation despite probe failure", func() bool { return a.entryCount() == 0 })
+
+ if rt, _ := reg.get(route.Host); rt.NextDue != 0 {
+ t.Fatalf("next_due = %d after failed probe, want 0", rt.NextDue)
+ }
+ clk.advance(time.Hour)
+ a.sweepOnce()
+ if _, _, starts := b.counts(); starts != 1 {
+ t.Fatalf("starts=%d, want 1 (a failed probe must not invent a wake)", starts)
+ }
+}
+
+// The index lives in the registry, so a due time recorded before a router
+// restart still fires: a brand-new activator over the same registry — no
+// in-memory state carried over — wakes the host when its time arrives.
+func TestActivatorDueSurvivesRestart(t *testing.T) {
+ a1, b1, clk, reg := testActivator(t, 10*time.Second)
+ route := testRoute("reborn.example.com")
+ if err := reg.upsert(route); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ b1.setDue(clk.now().Add(30 * time.Second).Unix())
+
+ release, err := a1.beginRequest(context.Background(), route)
+ if err != nil {
+ t.Fatalf("beginRequest: %v", err)
+ }
+ release()
+ clk.advance(11 * time.Second)
+ a1.sweepOnce()
+ waitFor(t, "hibernation", func() bool { return a1.entryCount() == 0 })
+
+ // "Restart": a fresh activator, same registry.
+ b2 := &fakeBackend{}
+ a2 := newActivator(hibernateConfig{Bucket: "test-bucket", Idle: 10 * time.Second}, b2, reg)
+ a2.now = clk.now
+
+ clk.advance(25 * time.Second) // past the due time
+ a2.sweepOnce()
+ waitFor(t, "post-restart due wake", func() bool { return a2.entryAwake(route.Host) })
+ if _, _, starts := b2.counts(); starts != 1 {
+ t.Fatalf("starts=%d on the new activator, want 1", starts)
+ }
+}
+
func TestHibernateConfigFromEnv(t *testing.T) {
t.Setenv("LCHAT_HIBERNATE_BUCKET", "")
if cfg, err := hibernateConfigFromEnv(); err != nil || cfg != nil {
diff --git a/control_test.go b/control_test.go
index b9ac305..1408efa 100644
--- a/control_test.go
+++ b/control_test.go
@@ -253,6 +253,10 @@ INSERT INTO routes (host, kind, socket, created_at) VALUES ('old.example.com', '
if rt.HomeRegion != "" || rt.Provider != "" {
t.Errorf("pre-existing row: home_region=%q provider=%q, want the implicit \"\"/\"\"", rt.HomeRegion, rt.Provider)
}
+ // The pre-seam schema predates next_due too — the same open must add it.
+ if rt.NextDue != 0 {
+ t.Errorf("pre-existing row: next_due=%d, want 0", rt.NextDue)
+ }
// New values round-trip through the migrated table.
rt.HomeRegion, rt.Provider = "us-east-1", "aws"
@@ -269,3 +273,69 @@ INSERT INTO routes (host, kind, socket, created_at) VALUES ('old.example.com', '
t.Errorf("reopen after migration: %v", err)
}
}
+
+// The due-time index's registry half (activator.go's actDueStore): setNextDue
+// is a narrow write, dueInstances returns exactly the ready instances whose
+// time has arrived, and a route rewrite (deploy's read-modify-write upsert)
+// must not clobber a sleeping host's due time.
+func TestRegistryDueTimeIndex(t *testing.T) {
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ seed := []Route{
+ {Host: "sleepy.example.com", Kind: "instance", Socket: "/run/a.sock"},
+ {Host: "quiet.example.com", Kind: "instance", Socket: "/run/b.sock"},
+ {Host: "svc.example.com", Kind: "service", Socket: "/run/c.sock"},
+ {Host: "broken.example.com", Kind: "instance", Socket: "/run/d.sock", Status: "failed"},
+ }
+ for _, rt := range seed {
+ if err := reg.upsert(rt); err != nil {
+ t.Fatalf("upsert %s: %v", rt.Host, err)
+ }
+ }
+ const now = int64(1_700_000_000)
+ for host, due := range map[string]int64{
+ "sleepy.example.com": now - 5, // due
+ "svc.example.com": now - 5, // due but a service — never woken
+ "broken.example.com": now - 5, // due but not ready — never woken
+ // quiet: next_due stays 0 — nothing scheduled, never due-woken
+ } {
+ if err := reg.setNextDue(host, due); err != nil {
+ t.Fatalf("setNextDue %s: %v", host, err)
+ }
+ }
+
+ due, err := reg.dueInstances(now)
+ if err != nil {
+ t.Fatalf("dueInstances: %v", err)
+ }
+ if len(due) != 1 || due[0].Host != "sleepy.example.com" {
+ t.Fatalf("dueInstances = %+v, want just sleepy.example.com", due)
+ }
+ // Not yet due: a future timestamp stays out of the set.
+ if err := reg.setNextDue("sleepy.example.com", now+60); err != nil {
+ t.Fatal(err)
+ }
+ if due, _ := reg.dueInstances(now); len(due) != 0 {
+ t.Fatalf("future due time returned early: %+v", due)
+ }
+
+ // Read-modify-write (how deploy/rollback update a route) carries next_due.
+ rt, err := reg.get("sleepy.example.com")
+ if err != nil || rt == nil {
+ t.Fatalf("get: %+v, %v", rt, err)
+ }
+ rt.Version = "v2"
+ if err := reg.upsert(*rt); err != nil {
+ t.Fatalf("upsert rewrite: %v", err)
+ }
+ if got, _ := reg.get("sleepy.example.com"); got.NextDue != now+60 {
+ t.Fatalf("route rewrite clobbered next_due: %d, want %d", got.NextDue, now+60)
+ }
+
+ // An unknown host is a quiet no-op — it can't be woken anyway.
+ if err := reg.setNextDue("ghost.example.com", now); err != nil {
+ t.Fatalf("setNextDue unknown host: %v", err)
+ }
+}
diff --git a/db.go b/db.go
index 1be5674..b07767d 100644
--- a/db.go
+++ b/db.go
@@ -2570,6 +2570,17 @@ func (db *DB) countScheduledMessages(threadID string) int {
return n
}
+// nextScheduledDeliverAt returns the earliest pending deliver_at (unix
+// seconds), or 0 when nothing is scheduled. Delivered schedules are deleted
+// (deliverScheduledMessage), so MIN over the whole table IS the next due time —
+// this feeds the router's due-time index so a hibernating instance is woken in
+// time to fire it (see getNextDue).
+func (db *DB) nextScheduledDeliverAt() (int64, error) {
+ var due int64
+ err := db.sql.QueryRow("SELECT COALESCE(MIN(deliver_at), 0) FROM scheduled_messages").Scan(&due)
+ return due, err
+}
+
// dueScheduledMessages returns schedules whose time has come, oldest first.
func (db *DB) dueScheduledMessages(now int64, limit int) ([]ScheduledMessage, error) {
rows, err := db.sql.Query(
diff --git a/main.go b/main.go
index da7e4df..57e88ae 100644
--- a/main.go
+++ b/main.go
@@ -395,6 +395,9 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/schedule", s.postSchedule)
mux.HandleFunc("GET /api/schedule", s.getSchedules)
mux.HandleFunc("DELETE /api/schedule/{id}", s.deleteSchedule)
+ // The activator's due-time probe (admin-gated; queried over the instance
+ // socket at sleep time so hibernated schedules still fire on time).
+ mux.HandleFunc("GET /api/next-due", s.getNextDue)
mux.HandleFunc("GET /api/threads/{id}/state", s.getThreadStateHandler)
mux.HandleFunc("PUT /api/threads/{id}/state", s.putThreadStateHandler)
diff --git a/miniapps.go b/miniapps.go
index 139ac25..354840a 100644
--- a/miniapps.go
+++ b/miniapps.go
@@ -140,6 +140,26 @@ func (s *Server) deleteSchedule(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+// getNextDue reports the instance's earliest pending scheduled delivery:
+// {due: <unix seconds>}, 0 when nothing is scheduled. It exists for the
+// router's activator: at sleep time it records this so a hibernating instance
+// is woken when the delivery comes due (activator.go's due-time index).
+// Admin-gated — the payload is a single timestamp the server already stores in
+// the clear (the schedule's (thread, time) metadata), but every data endpoint
+// carries auth, and the activator already holds the instance's admin token via
+// the env file it spawns instances with.
+func (s *Server) getNextDue(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ due, err := s.db.nextScheduledDeliverAt()
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"due": due})
+}
+
// getThreadStateHandler returns a slot's snapshot: {blob, version}. version 0 +
// empty blob means nothing saved yet (the client starts a fresh fold).
func (s *Server) getThreadStateHandler(w http.ResponseWriter, r *http.Request) {
diff --git a/miniapps_test.go b/miniapps_test.go
index a590dfe..9c53cb9 100644
--- a/miniapps_test.go
+++ b/miniapps_test.go
@@ -142,6 +142,66 @@ func TestScheduleDelivery(t *testing.T) {
}
}
+// The activator's due-time probe: /api/next-due is admin-gated and reports the
+// earliest pending deliver_at (0 when nothing is scheduled) so the router can
+// wake a hibernating instance in time to fire it.
+func TestNextDue(t *testing.T) {
+ ts, srv := newTestServer(t)
+ tok, _, tid := joinedMember(t, srv, "Sam")
+
+ getDue := func(bearer string) (int, int64) {
+ t.Helper()
+ req, _ := http.NewRequest("GET", ts.URL+"/api/next-due", nil)
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("GET /api/next-due: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ resp.Body.Close()
+ return resp.StatusCode, 0
+ }
+ var body struct {
+ Due int64 `json:"due"`
+ }
+ decodeJSON(t, resp, &body)
+ return resp.StatusCode, body.Due
+ }
+
+ // No auth → 401; a plain member's device token isn't admin either.
+ if code, _ := getDue(""); code != http.StatusUnauthorized {
+ t.Fatalf("unauthenticated: want 401, got %d", code)
+ }
+ if code, _ := getDue(tok); code != http.StatusUnauthorized {
+ t.Fatalf("member token: want 401, got %d", code)
+ }
+
+ // Nothing scheduled → 0.
+ if code, due := getDue("admintok"); code != 200 || due != 0 {
+ t.Fatalf("empty schedule: want 200/0, got %d/%d", code, due)
+ }
+
+ // The earliest of several pending schedules wins, regardless of post order.
+ now := time.Now().Unix()
+ later, sooner := now+7200, now+3600
+ postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"later"}`, tok, tid, later))
+ postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"sooner"}`, tok, tid, sooner))
+ if _, due := getDue("admintok"); due != sooner {
+ t.Fatalf("due = %d, want the earlier %d", due, sooner)
+ }
+
+ // Delivery consumes the row, so the next due time advances.
+ pending, _ := srv.db.listScheduledMessages(tid)
+ if err := srv.db.deleteScheduledMessage(pending[0].ID); err != nil { // pending is deliver_at-ordered: [0] = sooner
+ t.Fatalf("delete: %v", err)
+ }
+ if _, due := getDue("admintok"); due != later {
+ t.Fatalf("after consuming the sooner schedule: due = %d, want %d", due, later)
+ }
+}
+
// Cancellation is member-gated; cancelling an already-fired/unknown id is quiet.
func TestScheduleCancel(t *testing.T) {
ts, srv := newTestServer(t)
diff --git a/registry.go b/registry.go
index 330717b..c2333b3 100644
--- a/registry.go
+++ b/registry.go
@@ -34,7 +34,13 @@ type Route struct {
// them yet; register() just records what a caller sets.
HomeRegion string
Provider string
- CreatedAt int64
+ // NextDue is the due-time index (#550 Part 4 v2): the earliest pending
+ // scheduled delivery (unix seconds; 0 = none) recorded by the activator when
+ // the instance hibernates, so the sweeper can wake it in time to fire. In
+ // the registry — not activator memory — so due times survive router restarts
+ // (the registry is fleet-backed-up). Written via setNextDue only.
+ NextDue int64
+ CreatedAt int64
}
func openRegistry(path string) (*Registry, error) {
@@ -79,6 +85,7 @@ CREATE TABLE IF NOT EXISTS routes (
push_relay_token TEXT NOT NULL DEFAULT '',
home_region TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL DEFAULT '',
+ next_due INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);`); err != nil {
return err
@@ -90,6 +97,7 @@ CREATE TABLE IF NOT EXISTS routes (
`ALTER TABLE routes ADD COLUMN prev_version TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN home_region TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN provider TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN next_due INTEGER NOT NULL DEFAULT 0`,
} {
if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -103,7 +111,7 @@ func (r *Registry) list() ([]Route, error) {
rows, err := r.sql.Query(`
SELECT host, kind, socket, status, last_error, db_path, version, prev_version,
app_store_url, home_origin, push_relay_url, push_relay_token,
- home_region, provider, created_at
+ home_region, provider, next_due, created_at
FROM routes ORDER BY host`)
if err != nil {
return nil, err
@@ -114,7 +122,7 @@ FROM routes ORDER BY host`)
var t Route
if err := rows.Scan(&t.Host, &t.Kind, &t.Socket, &t.Status, &t.LastError,
&t.DBPath, &t.Version, &t.PrevVersion, &t.AppStoreURL, &t.HomeOrigin,
- &t.PushRelayURL, &t.PushRelayToken, &t.HomeRegion, &t.Provider, &t.CreatedAt); err != nil {
+ &t.PushRelayURL, &t.PushRelayToken, &t.HomeRegion, &t.Provider, &t.NextDue, &t.CreatedAt); err != nil {
return nil, err
}
out = append(out, t)
@@ -128,10 +136,10 @@ func (r *Registry) get(host string) (*Route, error) {
err := r.sql.QueryRow(`
SELECT host, kind, socket, status, last_error, db_path, version, prev_version,
app_store_url, home_origin, push_relay_url, push_relay_token,
- home_region, provider, created_at
+ home_region, provider, next_due, created_at
FROM routes WHERE host = ?`, host).Scan(&t.Host, &t.Kind, &t.Socket, &t.Status,
&t.LastError, &t.DBPath, &t.Version, &t.PrevVersion, &t.AppStoreURL, &t.HomeOrigin,
- &t.PushRelayURL, &t.PushRelayToken, &t.HomeRegion, &t.Provider, &t.CreatedAt)
+ &t.PushRelayURL, &t.PushRelayToken, &t.HomeRegion, &t.Provider, &t.NextDue, &t.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -164,17 +172,51 @@ func (r *Registry) upsert(t Route) error {
_, err := r.sql.Exec(`
INSERT INTO routes (host, kind, socket, status, last_error, db_path, version, prev_version,
app_store_url, home_origin, push_relay_url, push_relay_token,
- home_region, provider, created_at)
-VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ home_region, provider, next_due, created_at)
+VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(host) DO UPDATE SET
kind=excluded.kind, socket=excluded.socket, status=excluded.status,
last_error=excluded.last_error, db_path=excluded.db_path, version=excluded.version,
prev_version=excluded.prev_version,
app_store_url=excluded.app_store_url, home_origin=excluded.home_origin,
push_relay_url=excluded.push_relay_url, push_relay_token=excluded.push_relay_token,
- home_region=excluded.home_region, provider=excluded.provider`,
+ home_region=excluded.home_region, provider=excluded.provider,
+ next_due=excluded.next_due`,
t.Host, t.Kind, t.Socket, t.Status, t.LastError, t.DBPath, t.Version, t.PrevVersion,
t.AppStoreURL, t.HomeOrigin, t.PushRelayURL, t.PushRelayToken,
- t.HomeRegion, t.Provider, t.CreatedAt)
+ t.HomeRegion, t.Provider, t.NextDue, t.CreatedAt)
return err
}
+
+// ---- due-time index (#550 Part 4 v2 follow-up) --------------------------------
+//
+// The activator's persistence for scheduled deliveries on hibernating hosts:
+// setNextDue records at sleep time, dueInstances answers the sweeper's "who
+// needs waking". Together they implement actDueStore (activator.go).
+
+// setNextDue records host's next scheduled-delivery time (unix seconds; 0 =
+// nothing scheduled) without touching the rest of the route. A host not in the
+// registry is a quiet no-op — it can't be woken anyway.
+func (r *Registry) setNextDue(host string, due int64) error {
+ _, err := r.sql.Exec(`UPDATE routes SET next_due = ? WHERE host = ?`, due, host)
+ return err
+}
+
+// dueInstances returns the ready instance routes whose recorded next_due has
+// arrived (0 < next_due <= now) — the sweeper's due-time wake set. Reading the
+// registry (not activator memory) each sweep is what makes the index survive
+// router restarts and track route removals for free; the fleet is tens of
+// routes, so a filtered list per tick is nothing.
+func (r *Registry) dueInstances(now int64) ([]Route, error) {
+ routes, err := r.list()
+ if err != nil {
+ return nil, err
+ }
+ var due []Route
+ for _, t := range routes {
+ if t.Kind == "instance" && t.Status == "ready" && t.NextDue > 0 && t.NextDue <= now {
+ due = append(due, t)
+ }
+ }
+ return due, nil
+}
diff --git a/router.go b/router.go
index bedb186..78e94ae 100644
--- a/router.go
+++ b/router.go
@@ -212,7 +212,10 @@ func runRouter(args []string) {
if hc, err := hibernateConfigFromEnv(); err != nil {
log.Fatalf("router: %v", err)
} else if hc != nil {
- act = newActivator(*hc, &s3Backend{cfg: *hc})
+ // The registry doubles as the due-time index's store (routes.next_due),
+ // so scheduled deliveries survive router restarts and fire on time even
+ // for sleeping instances.
+ act = newActivator(*hc, &s3Backend{cfg: *hc}, reg)
act.start()
log.Printf("router: hibernation active (bucket %s, prefix %q, idle %s) — instances wake on demand",
hc.Bucket, hc.Prefix, hc.Idle)