package main import ( "database/sql" "fmt" "strings" "time" ) // Registry is the router's control DB — the set of hosts it serves and how to // reach each. It's deliberately separate from an instance's own SQLite: it holds // routing + operator config, never message data. See docs/instance-router.md. type Registry struct{ sql *sql.DB } // Route is one host the router fronts: a messenger instance it lifecycle-manages // (kind "instance") or a sibling service it only proxies (kind "service", e.g. // the home server or push relay). The instance-only fields hold per-instance // config that used to live in process env. type Route struct { Host string Kind string Socket string Status string LastError string DBPath string Version string PrevVersion string AppStoreURL string HomeOrigin string PushRelayURL string PushRelayToken string // HomeRegion + Provider are the geo and co-op seams (#550 Parts 7 + 10): // where a space lives and who runs it. Schema-only today — "" means the // single implicit region/provider (this box), and nothing keys behavior on // them yet; register() just records what a caller sets. HomeRegion string Provider string // Hibernate is the per-host opt-in to activator management (#550 Part 4 // v2): with the router's LCHAT_HIBERNATE_BUCKET set, a flagged instance // runs as an on-demand child — woken per request, slept when idle — instead // of an always-on systemd unit. Off (the default) keeps the classic systemd // shape even when the router has the env set, so one production canary can // hibernate while the rest of the box is untouched. Flipped by // `lchat hibernate --on/--off` via setHibernate. Hibernate bool // Canary marks a throwaway instance (a per-session test host) — the OPPOSITE // of member data. It's set deliberately (add-instance --canary, or // `lchat canary --on`), NEVER inferred from the hostname: test.luan.chat // and test-bots.luan.chat are long-lived data-holding instances despite the // prefix, so name-matching would be dangerously wrong. The signal exists so a // future scoped reset/wipe can refuse anything not flagged, and so fleet ops // and status can treat canaries distinctly. Canary bool // Replication is the host's durability class on the activator's S3 replica // — the per-plan ladder ("stream"/"batched"/"daily"; "" = stream, the // historical behavior). It sets how often litestream's replica syncs local // changes to S3 while the host is awake; every graceful path (sleep, // deploy, shutdown) still flushes everything regardless of class, so the // class only bounds what a HARD crash of the box can lose. Inert for // systemd-managed hosts (no activator replication to tune). Applied at // wake time — a change takes effect on the next wake. Flipped by // `lchat replication ` via setReplication. Replication string // 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 } // The replication classes — the durability ladder a plan buys (#853). Class // names describe the mechanism, not the plan, so billing can remap tiers to // classes without touching the router. All classes flush in full on every // graceful sleep/deploy/shutdown; the sync interval below only bounds the // hard-crash loss window (and its S3 Tier-1 request bill) while awake. const ( replicationStream = "stream" // change-driven ~1s sync — loss window seconds replicationBatched = "batched" // sync at most every 5m — loss window minutes replicationDaily = "daily" // sync at wake + every 24h — loss window up to a day ) // validReplication reports whether s names a replication class ("" counts: // it's the unset default, stream). func validReplication(s string) bool { switch s { case "", replicationStream, replicationBatched, replicationDaily: return true } return false } // replicationSyncInterval maps a class to the litestream replica sync // interval it buys. 0 means "keep litestream's default" (1s, change-driven — // idle ticks make zero S3 calls), which is what stream and the unset "" // deliberately share so pre-ladder registries keep today's behavior exactly. func replicationSyncInterval(class string) time.Duration { switch class { case replicationBatched: return 5 * time.Minute case replicationDaily: return 24 * time.Hour } return 0 } func openRegistry(path string) (*Registry, error) { d, err := sql.Open("sqlite", path) if err != nil { return nil, err } // One writer at a time keeps WAL happy (mirrors openDB). d.SetMaxOpenConns(1) // busy_timeout FIRST: two processes open this DB concurrently (the router // starting while add-instance or the tunnel gateway runs), and // journal_mode=WAL takes a lock — with no timeout set yet the loser fails // immediately with SQLITE_BUSY instead of waiting its turn. for _, p := range []string{ "PRAGMA busy_timeout=5000", "PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL", } { if _, err := d.Exec(p); err != nil { return nil, err } } r := &Registry{sql: d} if err := r.migrate(); err != nil { return nil, err } return r, nil } // migrate is additive (CREATE TABLE IF NOT EXISTS), like the instance schema, so // deploying new code over an existing registry is safe. func (r *Registry) migrate() error { if _, err := r.sql.Exec(` CREATE TABLE IF NOT EXISTS routes ( host TEXT PRIMARY KEY, kind TEXT NOT NULL, socket TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'ready', last_error TEXT NOT NULL DEFAULT '', db_path TEXT NOT NULL DEFAULT '', version TEXT NOT NULL DEFAULT '', prev_version TEXT NOT NULL DEFAULT '', app_store_url TEXT NOT NULL DEFAULT '', home_origin TEXT NOT NULL DEFAULT '', push_relay_url TEXT NOT NULL DEFAULT '', push_relay_token TEXT NOT NULL DEFAULT '', home_region TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', hibernate INTEGER NOT NULL DEFAULT 0, canary INTEGER NOT NULL DEFAULT 0, replication TEXT NOT NULL DEFAULT '', next_due INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL );`); err != nil { return err } // Additive column migrations for registries created before a column existed — // CREATE TABLE IF NOT EXISTS won't add columns to an existing table. Ignore the // "duplicate column" error so this stays idempotent. for _, stmt := range []string{ `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`, `ALTER TABLE routes ADD COLUMN hibernate INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE routes ADD COLUMN replication TEXT NOT NULL DEFAULT ''`, `ALTER TABLE routes ADD COLUMN canary INTEGER NOT NULL DEFAULT 0`, } { if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { return err } } return nil } // list returns every route, ordered by host. 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, hibernate, canary, replication, next_due, created_at FROM routes ORDER BY host`) if err != nil { return nil, err } defer rows.Close() var out []Route for rows.Next() { 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.Hibernate, &t.Canary, &t.Replication, &t.NextDue, &t.CreatedAt); err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } // get returns the route for host, or (nil, nil) if there is none. func (r *Registry) get(host string) (*Route, error) { var t Route 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, hibernate, canary, replication, 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.Hibernate, &t.Canary, &t.Replication, &t.NextDue, &t.CreatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &t, nil } // delete removes a route by host (data-safe: it stops routing the host but never // touches its DB or env file). func (r *Registry) delete(host string) error { _, err := r.sql.Exec(`DELETE FROM routes WHERE host = ?`, host) return err } // upsert inserts or updates a route by host. (add-instance / register will drive // this in a later step; it's here now so the router and its tests can seed // routes.) func (r *Registry) upsert(t Route) error { if t.CreatedAt == 0 { t.CreatedAt = time.Now().Unix() } if t.Status == "" { t.Status = "ready" } if t.Kind == "" { t.Kind = "instance" } _, 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, hibernate, canary, replication, 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, hibernate=excluded.hibernate, canary=excluded.canary, replication=excluded.replication, 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.Hibernate, t.Canary, t.Replication, t.NextDue, t.CreatedAt) return err } // setHibernate flips host's activator-managed flag without touching the rest of // the route — a narrow UPDATE (like setNextDue) so the flip can never clobber a // concurrent next_due write or a deploy's read-modify-write, and vice versa. An // unknown host is a quiet no-op, matching setNextDue. func (r *Registry) setHibernate(host string, on bool) error { _, err := r.sql.Exec(`UPDATE routes SET hibernate = ? WHERE host = ?`, on, host) return err } // setReplication records host's replication class, same narrow-UPDATE contract // as setHibernate. Validated here (not just at the CLI) so no LOCAL write path // — operator, controld, or a future billing push — can typo a class the wake // path would silently treat as stream. upsert deliberately does NOT validate: // a mesh peer on a newer build may replicate a class this build doesn't know, // and storing it verbatim (replicationSyncInterval fail-safes unknowns to // stream) keeps the space's failover working through the skew, converging when // this box updates. An unknown host is a quiet no-op. // setCanary flips host's throwaway-instance marker — a narrow UPDATE like // setHibernate, no other field touched. An unknown host is a quiet no-op. func (r *Registry) setCanary(host string, on bool) error { _, err := r.sql.Exec(`UPDATE routes SET canary = ? WHERE host = ?`, on, host) return err } func (r *Registry) setReplication(host, class string) error { if !validReplication(class) { return fmt.Errorf("unknown replication class %q (want %s, %s or %s)", class, replicationStream, replicationBatched, replicationDaily) } _, err := r.sql.Exec(`UPDATE routes SET replication = ? WHERE host = ?`, class, host) return err } // setHomeRegion records host's placement region (multi-edge, docs/multi-edge.md) // without touching the rest of the route — the same narrow-UPDATE contract as // setHibernate/setReplication, so `lchat relocate` can re-home a space onto a // new region without clobbering a concurrent next_due write or a deploy's // read-modify-write (and vice versa). The mesh reads this replicated column to // place the space on a box in the matching region (mesh.go's candidatesFor), so // flipping it IS the whole mechanic of relocation — the hash re-homes the space // and the single-writer lease still guarantees one writer through the move. An // unknown host is a quiet no-op, matching setHibernate; region "" means // region-agnostic (today's default, hashed across the whole fleet). func (r *Registry) setHomeRegion(host, region string) error { _, err := r.sql.Exec(`UPDATE routes SET home_region = ? WHERE host = ?`, region, host) return err } // setVersions records a host's current + previous version, same narrow-UPDATE // contract as setHibernate/setNextDue. Deploys and update passes hold their // route reads for a while (minutes, when a download runs) — a full upsert // would write back next_due/hibernate as they were at READ time, clobbering // the activator's mid-pass setNextDue (a silently missed scheduled wake) or // an operator's hibernate flip. func (r *Registry) setVersions(host, ver, prev string) error { _, err := r.sql.Exec(`UPDATE routes SET version = ?, prev_version = ? WHERE host = ?`, ver, prev, host) 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 { // Only hibernating hosts: the index exists to wake sleeping instances, // and a systemd-managed host is always on — spawning a child for it // would fight its unit over the socket. This also makes a leftover // next_due inert on a host flipped back to systemd (the flip-off's // release re-records one after the flag is already clear). if t.Kind == "instance" && t.Status == "ready" && t.Hibernate && t.NextDue > 0 && t.NextDue <= now { due = append(due, t) } } return due, nil }