eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "database/sql"
5 "fmt"
6 "strings"
7 "time"
8)
9
10// Registry is the router's control DB — the set of hosts it serves and how to
11// reach each. It's deliberately separate from an instance's own SQLite: it holds
12// routing + operator config, never message data. See docs/instance-router.md.
13type Registry struct{ sql *sql.DB }
14
15// Route is one host the router fronts: a messenger instance it lifecycle-manages
16// (kind "instance") or a sibling service it only proxies (kind "service", e.g.
17// the home server or push relay). The instance-only fields hold per-instance
18// config that used to live in process env.
19type Route struct {
20 Host string
21 Kind string
22 Socket string
23 Status string
24 LastError string
25 DBPath string
26 Version string
27 PrevVersion string
28 AppStoreURL string
29 HomeOrigin string
30 PushRelayURL string
31 PushRelayToken string
32 // HomeRegion + Provider are the geo and co-op seams (#550 Parts 7 + 10):
33 // where a space lives and who runs it. Schema-only today — "" means the
34 // single implicit region/provider (this box), and nothing keys behavior on
35 // them yet; register() just records what a caller sets.
36 HomeRegion string
37 Provider string
38 // Hibernate is the per-host opt-in to activator management (#550 Part 4
39 // v2): with the router's LCHAT_HIBERNATE_BUCKET set, a flagged instance
40 // runs as an on-demand child — woken per request, slept when idle — instead
41 // of an always-on systemd unit. Off (the default) keeps the classic systemd
42 // shape even when the router has the env set, so one production canary can
43 // hibernate while the rest of the box is untouched. Flipped by
44 // `lchat hibernate <host> --on/--off` via setHibernate.
45 Hibernate bool
46 // Canary marks a throwaway instance (a per-session test host) — the OPPOSITE
47 // of member data. It's set deliberately (add-instance --canary, or
48 // `lchat canary <host> --on`), NEVER inferred from the hostname: test.luan.chat
49 // and test-bots.luan.chat are long-lived data-holding instances despite the
50 // prefix, so name-matching would be dangerously wrong. The signal exists so a
51 // future scoped reset/wipe can refuse anything not flagged, and so fleet ops
52 // and status can treat canaries distinctly.
53 Canary bool
54 // Replication is the host's durability class on the activator's S3 replica
55 // — the per-plan ladder ("stream"/"batched"/"daily"; "" = stream, the
56 // historical behavior). It sets how often litestream's replica syncs local
57 // changes to S3 while the host is awake; every graceful path (sleep,
58 // deploy, shutdown) still flushes everything regardless of class, so the
59 // class only bounds what a HARD crash of the box can lose. Inert for
60 // systemd-managed hosts (no activator replication to tune). Applied at
61 // wake time — a change takes effect on the next wake. Flipped by
62 // `lchat replication <host> <class>` via setReplication.
63 Replication string
64 // NextDue is the due-time index (#550 Part 4 v2): the earliest pending
65 // scheduled delivery (unix seconds; 0 = none) recorded by the activator when
66 // the instance hibernates, so the sweeper can wake it in time to fire. In
67 // the registry — not activator memory — so due times survive router restarts
68 // (the registry is fleet-backed-up). Written via setNextDue only.
69 NextDue int64
70 CreatedAt int64
71}
72
73// The replication classes — the durability ladder a plan buys (#853). Class
74// names describe the mechanism, not the plan, so billing can remap tiers to
75// classes without touching the router. All classes flush in full on every
76// graceful sleep/deploy/shutdown; the sync interval below only bounds the
77// hard-crash loss window (and its S3 Tier-1 request bill) while awake.
78const (
79 replicationStream = "stream" // change-driven ~1s sync — loss window seconds
80 replicationBatched = "batched" // sync at most every 5m — loss window minutes
81 replicationDaily = "daily" // sync at wake + every 24h — loss window up to a day
82)
83
84// validReplication reports whether s names a replication class ("" counts:
85// it's the unset default, stream).
86func validReplication(s string) bool {
87 switch s {
88 case "", replicationStream, replicationBatched, replicationDaily:
89 return true
90 }
91 return false
92}
93
94// replicationSyncInterval maps a class to the litestream replica sync
95// interval it buys. 0 means "keep litestream's default" (1s, change-driven —
96// idle ticks make zero S3 calls), which is what stream and the unset ""
97// deliberately share so pre-ladder registries keep today's behavior exactly.
98func replicationSyncInterval(class string) time.Duration {
99 switch class {
100 case replicationBatched:
101 return 5 * time.Minute
102 case replicationDaily:
103 return 24 * time.Hour
104 }
105 return 0
106}
107
108func openRegistry(path string) (*Registry, error) {
109 d, err := sql.Open("sqlite", path)
110 if err != nil {
111 return nil, err
112 }
113 // One writer at a time keeps WAL happy (mirrors openDB).
114 d.SetMaxOpenConns(1)
115 // busy_timeout FIRST: two processes open this DB concurrently (the router
116 // starting while add-instance or the tunnel gateway runs), and
117 // journal_mode=WAL takes a lock — with no timeout set yet the loser fails
118 // immediately with SQLITE_BUSY instead of waiting its turn.
119 for _, p := range []string{
120 "PRAGMA busy_timeout=5000",
121 "PRAGMA journal_mode=WAL",
122 "PRAGMA synchronous=NORMAL",
123 } {
124 if _, err := d.Exec(p); err != nil {
125 return nil, err
126 }
127 }
128 r := &Registry{sql: d}
129 if err := r.migrate(); err != nil {
130 return nil, err
131 }
132 return r, nil
133}
134
135// migrate is additive (CREATE TABLE IF NOT EXISTS), like the instance schema, so
136// deploying new code over an existing registry is safe.
137func (r *Registry) migrate() error {
138 if _, err := r.sql.Exec(`
139CREATE TABLE IF NOT EXISTS routes (
140 host TEXT PRIMARY KEY,
141 kind TEXT NOT NULL,
142 socket TEXT NOT NULL,
143 status TEXT NOT NULL DEFAULT 'ready',
144 last_error TEXT NOT NULL DEFAULT '',
145 db_path TEXT NOT NULL DEFAULT '',
146 version TEXT NOT NULL DEFAULT '',
147 prev_version TEXT NOT NULL DEFAULT '',
148 app_store_url TEXT NOT NULL DEFAULT '',
149 home_origin TEXT NOT NULL DEFAULT '',
150 push_relay_url TEXT NOT NULL DEFAULT '',
151 push_relay_token TEXT NOT NULL DEFAULT '',
152 home_region TEXT NOT NULL DEFAULT '',
153 provider TEXT NOT NULL DEFAULT '',
154 hibernate INTEGER NOT NULL DEFAULT 0,
155 canary INTEGER NOT NULL DEFAULT 0,
156 replication TEXT NOT NULL DEFAULT '',
157 next_due INTEGER NOT NULL DEFAULT 0,
158 created_at INTEGER NOT NULL
159);`); err != nil {
160 return err
161 }
162 // Additive column migrations for registries created before a column existed —
163 // CREATE TABLE IF NOT EXISTS won't add columns to an existing table. Ignore the
164 // "duplicate column" error so this stays idempotent.
165 for _, stmt := range []string{
166 `ALTER TABLE routes ADD COLUMN prev_version TEXT NOT NULL DEFAULT ''`,
167 `ALTER TABLE routes ADD COLUMN home_region TEXT NOT NULL DEFAULT ''`,
168 `ALTER TABLE routes ADD COLUMN provider TEXT NOT NULL DEFAULT ''`,
169 `ALTER TABLE routes ADD COLUMN next_due INTEGER NOT NULL DEFAULT 0`,
170 `ALTER TABLE routes ADD COLUMN hibernate INTEGER NOT NULL DEFAULT 0`,
171 `ALTER TABLE routes ADD COLUMN replication TEXT NOT NULL DEFAULT ''`,
172 `ALTER TABLE routes ADD COLUMN canary INTEGER NOT NULL DEFAULT 0`,
173 } {
174 if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
175 return err
176 }
177 }
178 return nil
179}
180
181// list returns every route, ordered by host.
182func (r *Registry) list() ([]Route, error) {
183 rows, err := r.sql.Query(`
184SELECT host, kind, socket, status, last_error, db_path, version, prev_version,
185 app_store_url, home_origin, push_relay_url, push_relay_token,
186 home_region, provider, hibernate, canary, replication, next_due, created_at
187FROM routes ORDER BY host`)
188 if err != nil {
189 return nil, err
190 }
191 defer rows.Close()
192 var out []Route
193 for rows.Next() {
194 var t Route
195 if err := rows.Scan(&t.Host, &t.Kind, &t.Socket, &t.Status, &t.LastError,
196 &t.DBPath, &t.Version, &t.PrevVersion, &t.AppStoreURL, &t.HomeOrigin,
197 &t.PushRelayURL, &t.PushRelayToken, &t.HomeRegion, &t.Provider, &t.Hibernate, &t.Canary, &t.Replication, &t.NextDue, &t.CreatedAt); err != nil {
198 return nil, err
199 }
200 out = append(out, t)
201 }
202 return out, rows.Err()
203}
204
205// get returns the route for host, or (nil, nil) if there is none.
206func (r *Registry) get(host string) (*Route, error) {
207 var t Route
208 err := r.sql.QueryRow(`
209SELECT host, kind, socket, status, last_error, db_path, version, prev_version,
210 app_store_url, home_origin, push_relay_url, push_relay_token,
211 home_region, provider, hibernate, canary, replication, next_due, created_at
212FROM routes WHERE host = ?`, host).Scan(&t.Host, &t.Kind, &t.Socket, &t.Status,
213 &t.LastError, &t.DBPath, &t.Version, &t.PrevVersion, &t.AppStoreURL, &t.HomeOrigin,
214 &t.PushRelayURL, &t.PushRelayToken, &t.HomeRegion, &t.Provider, &t.Hibernate, &t.Canary, &t.Replication, &t.NextDue, &t.CreatedAt)
215 if err == sql.ErrNoRows {
216 return nil, nil
217 }
218 if err != nil {
219 return nil, err
220 }
221 return &t, nil
222}
223
224// delete removes a route by host (data-safe: it stops routing the host but never
225// touches its DB or env file).
226func (r *Registry) delete(host string) error {
227 _, err := r.sql.Exec(`DELETE FROM routes WHERE host = ?`, host)
228 return err
229}
230
231// upsert inserts or updates a route by host. (add-instance / register will drive
232// this in a later step; it's here now so the router and its tests can seed
233// routes.)
234func (r *Registry) upsert(t Route) error {
235 if t.CreatedAt == 0 {
236 t.CreatedAt = time.Now().Unix()
237 }
238 if t.Status == "" {
239 t.Status = "ready"
240 }
241 if t.Kind == "" {
242 t.Kind = "instance"
243 }
244 _, err := r.sql.Exec(`
245INSERT INTO routes (host, kind, socket, status, last_error, db_path, version, prev_version,
246 app_store_url, home_origin, push_relay_url, push_relay_token,
247 home_region, provider, hibernate, canary, replication, next_due, created_at)
248VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
249ON CONFLICT(host) DO UPDATE SET
250 kind=excluded.kind, socket=excluded.socket, status=excluded.status,
251 last_error=excluded.last_error, db_path=excluded.db_path, version=excluded.version,
252 prev_version=excluded.prev_version,
253 app_store_url=excluded.app_store_url, home_origin=excluded.home_origin,
254 push_relay_url=excluded.push_relay_url, push_relay_token=excluded.push_relay_token,
255 home_region=excluded.home_region, provider=excluded.provider,
256 hibernate=excluded.hibernate, canary=excluded.canary, replication=excluded.replication, next_due=excluded.next_due`,
257 t.Host, t.Kind, t.Socket, t.Status, t.LastError, t.DBPath, t.Version, t.PrevVersion,
258 t.AppStoreURL, t.HomeOrigin, t.PushRelayURL, t.PushRelayToken,
259 t.HomeRegion, t.Provider, t.Hibernate, t.Canary, t.Replication, t.NextDue, t.CreatedAt)
260 return err
261}
262
263// setHibernate flips host's activator-managed flag without touching the rest of
264// the route — a narrow UPDATE (like setNextDue) so the flip can never clobber a
265// concurrent next_due write or a deploy's read-modify-write, and vice versa. An
266// unknown host is a quiet no-op, matching setNextDue.
267func (r *Registry) setHibernate(host string, on bool) error {
268 _, err := r.sql.Exec(`UPDATE routes SET hibernate = ? WHERE host = ?`, on, host)
269 return err
270}
271
272// setReplication records host's replication class, same narrow-UPDATE contract
273// as setHibernate. Validated here (not just at the CLI) so no LOCAL write path
274// — operator, controld, or a future billing push — can typo a class the wake
275// path would silently treat as stream. upsert deliberately does NOT validate:
276// a mesh peer on a newer build may replicate a class this build doesn't know,
277// and storing it verbatim (replicationSyncInterval fail-safes unknowns to
278// stream) keeps the space's failover working through the skew, converging when
279// this box updates. An unknown host is a quiet no-op.
280// setCanary flips host's throwaway-instance marker — a narrow UPDATE like
281// setHibernate, no other field touched. An unknown host is a quiet no-op.
282func (r *Registry) setCanary(host string, on bool) error {
283 _, err := r.sql.Exec(`UPDATE routes SET canary = ? WHERE host = ?`, on, host)
284 return err
285}
286
287func (r *Registry) setReplication(host, class string) error {
288 if !validReplication(class) {
289 return fmt.Errorf("unknown replication class %q (want %s, %s or %s)",
290 class, replicationStream, replicationBatched, replicationDaily)
291 }
292 _, err := r.sql.Exec(`UPDATE routes SET replication = ? WHERE host = ?`, class, host)
293 return err
294}
295
296// setHomeRegion records host's placement region (multi-edge, docs/multi-edge.md)
297// without touching the rest of the route — the same narrow-UPDATE contract as
298// setHibernate/setReplication, so `lchat relocate` can re-home a space onto a
299// new region without clobbering a concurrent next_due write or a deploy's
300// read-modify-write (and vice versa). The mesh reads this replicated column to
301// place the space on a box in the matching region (mesh.go's candidatesFor), so
302// flipping it IS the whole mechanic of relocation — the hash re-homes the space
303// and the single-writer lease still guarantees one writer through the move. An
304// unknown host is a quiet no-op, matching setHibernate; region "" means
305// region-agnostic (today's default, hashed across the whole fleet).
306func (r *Registry) setHomeRegion(host, region string) error {
307 _, err := r.sql.Exec(`UPDATE routes SET home_region = ? WHERE host = ?`, region, host)
308 return err
309}
310
311// setVersions records a host's current + previous version, same narrow-UPDATE
312// contract as setHibernate/setNextDue. Deploys and update passes hold their
313// route reads for a while (minutes, when a download runs) — a full upsert
314// would write back next_due/hibernate as they were at READ time, clobbering
315// the activator's mid-pass setNextDue (a silently missed scheduled wake) or
316// an operator's hibernate flip.
317func (r *Registry) setVersions(host, ver, prev string) error {
318 _, err := r.sql.Exec(`UPDATE routes SET version = ?, prev_version = ? WHERE host = ?`, ver, prev, host)
319 return err
320}
321
322// ---- due-time index (#550 Part 4 v2 follow-up) --------------------------------
323//
324// The activator's persistence for scheduled deliveries on hibernating hosts:
325// setNextDue records at sleep time, dueInstances answers the sweeper's "who
326// needs waking". Together they implement actDueStore (activator.go).
327
328// setNextDue records host's next scheduled-delivery time (unix seconds; 0 =
329// nothing scheduled) without touching the rest of the route. A host not in the
330// registry is a quiet no-op — it can't be woken anyway.
331func (r *Registry) setNextDue(host string, due int64) error {
332 _, err := r.sql.Exec(`UPDATE routes SET next_due = ? WHERE host = ?`, due, host)
333 return err
334}
335
336// dueInstances returns the ready instance routes whose recorded next_due has
337// arrived (0 < next_due <= now) — the sweeper's due-time wake set. Reading the
338// registry (not activator memory) each sweep is what makes the index survive
339// router restarts and track route removals for free; the fleet is tens of
340// routes, so a filtered list per tick is nothing.
341func (r *Registry) dueInstances(now int64) ([]Route, error) {
342 routes, err := r.list()
343 if err != nil {
344 return nil, err
345 }
346 var due []Route
347 for _, t := range routes {
348 // Only hibernating hosts: the index exists to wake sleeping instances,
349 // and a systemd-managed host is always on — spawning a child for it
350 // would fight its unit over the socket. This also makes a leftover
351 // next_due inert on a host flipped back to systemd (the flip-off's
352 // release re-records one after the flag is already clear).
353 if t.Kind == "instance" && t.Status == "ready" && t.Hibernate && t.NextDue > 0 && t.NextDue <= now {
354 due = append(due, t)
355 }
356 }
357 return due, nil
358}
359