carlos / platform Public

feat: registry — routes table with activator and placement columns

Ported from titogo internal/carlos/registry.go (busy_timeout-first,
fresh-connection List) with Eleven's hibernate/canary/replication/
next_due columns and platform app/pool/region/channel/addr restored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9f98be5659ee304c92b6f58909ecdc7552d6abb0 parent d2d3ed5
4 files changed, +534 −0
  • go.mod +14 −0
  • go.sum +51 −0
  • internal/registry/registry.go +288 −0
  • internal/registry/registry_test.go +181 −0
diff --git a/go.mod b/go.mod
index 98da7c3f5..5f900efc2 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,17 @@
module github.com/carlosframework/platform
go 1.26
+
+require modernc.org/sqlite v1.55.0
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 000000000..8a1f4b4c9
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,51 @@
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
new file mode 100644
index 000000000..d6ab09c33
--- /dev/null
+++ b/internal/registry/registry.go
@@ -0,0 +1,288 @@
+// Package registry is the host's control DB — the set of routes a deployment
+// host serves and how to reach each. Deliberately separate from any app's own
+// database: it holds routing + lifecycle state, never app data.
+//
+// Ported from titogo internal/carlos/registry.go (which carries three fixes
+// over Eleven's registry.go: busy_timeout before WAL, List on a fresh
+// connection, and — in the edge — first-fingerprint reload), with Eleven's
+// activator columns (hibernate, canary, replication, next_due) and the
+// platform's placement columns (app, pool, region, channel, addr) restored.
+package registry
+
+import (
+ "database/sql"
+ "strings"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+type Registry struct {
+ sql *sql.DB
+ path string
+}
+
+// Route is one host a deployment serves: an app instance the platform
+// lifecycle-manages (kind "instance"), a sibling service it only proxies
+// (kind "service"), or a served file tree (kind "static").
+type Route struct {
+ Host string
+ // Slug is the app-account short name usable on a root domain. Empty =
+ // subdomain-only.
+ Slug string
+ Kind string // instance | service | static
+ Socket string // unix socket upstream; wins over Addr when both set
+ Addr string // TCP upstream host:port — whole-app tenants (Kass v1)
+ Status string // pending|creating|starting|issuing|ready|failed
+
+ LastError string
+ DBPath string
+ Version string
+ PrevVersion string
+
+ App string // which app's releases this route runs
+ Pool string // placement pool ("" = shared)
+ Region string // residency region code ("" = unconstrained)
+ Channel string // release channel this route follows ("" = stable)
+
+ Hibernate bool
+ Canary bool
+ Replication string // "" | "stream" | "batched" | "daily"
+ NextDue int64
+
+ CreatedAt int64
+}
+
+func Open(path string) (*Registry, error) {
+ d, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ // One writer at a time keeps WAL happy.
+ d.SetMaxOpenConns(1)
+ // busy_timeout FIRST: two processes open this DB concurrently (the edge
+ // starting while `carlos add` runs), and journal_mode=WAL takes a lock —
+ // with no timeout set yet it fails immediately with SQLITE_BUSY.
+ 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, path: path}
+ if err := r.migrate(); err != nil {
+ return nil, err
+ }
+ return r, nil
+}
+
+// Path returns the file the registry was opened from.
+func (r *Registry) Path() string { return r.path }
+
+// migrate is additive (CREATE TABLE IF NOT EXISTS), so deploying new code
+// over an existing registry is always safe. New columns go in the ALTER list —
+// the "duplicate column" error is swallowed so the list stays idempotent.
+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 DEFAULT '',
+ 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 '',
+ created_at INTEGER NOT NULL
+);`); err != nil {
+ return err
+ }
+ for _, stmt := range []string{
+ `ALTER TABLE routes ADD COLUMN slug TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN addr TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN app TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN pool TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN region TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN channel TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN hibernate INTEGER NOT NULL DEFAULT 0`,
+ `ALTER TABLE routes ADD COLUMN canary INTEGER NOT NULL DEFAULT 0`,
+ `ALTER TABLE routes ADD COLUMN replication 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
+ }
+ }
+ return nil
+}
+
+const routeCols = `host, slug, kind, socket, addr, status, last_error, db_path,
+version, prev_version, app, pool, region, channel, hibernate, canary,
+replication, next_due, created_at`
+
+func scanRoute(scan func(...any) error) (Route, error) {
+ var t Route
+ var hib, can int
+ err := scan(&t.Host, &t.Slug, &t.Kind, &t.Socket, &t.Addr, &t.Status,
+ &t.LastError, &t.DBPath, &t.Version, &t.PrevVersion, &t.App, &t.Pool,
+ &t.Region, &t.Channel, &hib, &can, &t.Replication, &t.NextDue, &t.CreatedAt)
+ t.Hibernate = hib != 0
+ t.Canary = can != 0
+ return t, err
+}
+
+// List returns every route, ordered by host, over a FRESH connection — not
+// the long-lived pool. modernc/sqlite's long-lived connections can serve a
+// stale WAL snapshot for commits made by OTHER processes; a Reload built on a
+// stale List sticks permanently because the fingerprint watcher, already
+// matching, never retriggers. (Observed live in titogo, 2026-07-25.) List is
+// reload-rare, so the ~1ms open cost is nothing.
+func (r *Registry) List() ([]Route, error) {
+ db, err := sql.Open("sqlite", r.path)
+ if err != nil {
+ return nil, err
+ }
+ defer db.Close()
+ // busy_timeout only: the DB is already WAL, and this connection only reads.
+ if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
+ return nil, err
+ }
+ rows, err := db.Query(`SELECT ` + routeCols + ` FROM routes ORDER BY host`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Route
+ for rows.Next() {
+ t, err := scanRoute(rows.Scan)
+ if 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) {
+ row := r.sql.QueryRow(`SELECT `+routeCols+` FROM routes WHERE host = ?`, host)
+ t, err := scanRoute(row.Scan)
+ 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).
+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.
+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"
+ }
+ b2i := func(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+ }
+ _, err := r.sql.Exec(`
+INSERT INTO routes (`+routeCols+`)
+VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ON CONFLICT(host) DO UPDATE SET
+ slug=excluded.slug, kind=excluded.kind, socket=excluded.socket,
+ addr=excluded.addr, status=excluded.status, last_error=excluded.last_error,
+ db_path=excluded.db_path, version=excluded.version,
+ prev_version=excluded.prev_version, app=excluded.app, pool=excluded.pool,
+ region=excluded.region, channel=excluded.channel,
+ hibernate=excluded.hibernate, canary=excluded.canary,
+ replication=excluded.replication, next_due=excluded.next_due`,
+ t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError,
+ t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel,
+ b2i(t.Hibernate), b2i(t.Canary), t.Replication, t.NextDue, t.CreatedAt)
+ return err
+}
+
+// SetVersions records a host's current + previous version as a narrow UPDATE,
+// so a slow deploy's read-modify-write can never clobber concurrent writes to
+// other columns.
+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
+}
+
+// SetHibernate flips the per-host hibernation opt-in as a narrow UPDATE.
+func (r *Registry) SetHibernate(host string, on bool) error {
+ v := 0
+ if on {
+ v = 1
+ }
+ _, err := r.sql.Exec(`UPDATE routes SET hibernate = ? WHERE host = ?`, v, host)
+ return err
+}
+
+// SetNextDue records the earliest server-side scheduled work for a host, so
+// the activator can wake a sleeping instance for its own schedule. 0 = none.
+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 hibernatable instance routes whose next_due has
+// arrived (0 means no schedule and never matches).
+func (r *Registry) DueInstances(now int64) ([]Route, error) {
+ rows, err := r.sql.Query(`SELECT `+routeCols+` FROM routes
+WHERE kind = 'instance' AND next_due > 0 AND next_due <= ? ORDER BY host`, now)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Route
+ for rows.Next() {
+ t, err := scanRoute(rows.Scan)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+// Fingerprint summarizes the route table over a fresh connection, for the
+// edge's cheap change poll. It must change on any row insert/delete and on
+// the column writes reloads care about; it must see other processes' commits
+// (fresh connection, same WAL-snapshot quirk as List).
+func (r *Registry) Fingerprint() (string, error) {
+ db, err := sql.Open("sqlite", r.path)
+ if err != nil {
+ return "", err
+ }
+ defer db.Close()
+ if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
+ return "", err
+ }
+ var fp string
+ err = db.QueryRow(`
+SELECT COUNT(*) || ':' || COALESCE(MAX(rowid),0) || ':' ||
+ COALESCE(SUM(rowid*17 + LENGTH(status)*3 + LENGTH(socket) + LENGTH(addr)
+ + LENGTH(version)*5 + hibernate*7 + LENGTH(channel)),0)
+FROM routes`).Scan(&fp)
+ return fp, err
+}
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
new file mode 100644
index 000000000..90dbd84c2
--- /dev/null
+++ b/internal/registry/registry_test.go
@@ -0,0 +1,181 @@
+package registry
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+// List must observe rows committed through a DIFFERENT Registry handle — the
+// in-process stand-in for `carlos add` writing while the edge holds its own
+// long-lived handle. Pins titogo's live failure: a route added while the
+// router ran never routed (through SIGHUP reloads), because List read a stale
+// WAL snapshot off the long-lived pool and the fingerprint watcher, already
+// matching, never retriggered.
+func TestListSeesOtherHandlesCommits(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ router, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ adder, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := adder.Upsert(Route{
+ Host: "a.example.com", Slug: "a", Kind: "instance",
+ Socket: "/tmp/a.sock", Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ routes, err := router.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(routes) != 1 || routes[0].Slug != "a" {
+ t.Fatalf("List through the first handle missed the second handle's commit: %+v", routes)
+ }
+
+ r := routes[0]
+ r.Status = "failed"
+ if err := adder.Upsert(r); err != nil {
+ t.Fatal(err)
+ }
+ routes, err = router.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(routes) != 1 || routes[0].Status != "failed" {
+ t.Fatalf("List missed a cross-handle status flip: %+v", routes)
+ }
+}
+
+func TestUpsertRoundTripsAllColumns(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := Route{
+ Host: "b.example.com", Slug: "b", Kind: "instance",
+ Socket: "", Addr: "127.0.0.1:8080",
+ Status: "ready", LastError: "prior fail",
+ DBPath: "/data/b.db", Version: "abc1234", PrevVersion: "def5678",
+ App: "kass", Pool: "shared", Region: "ie", Channel: "stable",
+ Hibernate: true, Canary: true, Replication: "batched",
+ NextDue: 4200, CreatedAt: 1700000000,
+ }
+ if err := reg.Upsert(want); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("b.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got == nil {
+ t.Fatal("Get returned nil for an upserted route")
+ }
+ if *got != want {
+ t.Fatalf("round trip mismatch:\n got %+v\n want %+v", *got, want)
+ }
+}
+
+func TestGetMissingIsNilNil(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("nope.example.com")
+ if err != nil || got != nil {
+ t.Fatalf("Get(missing) = %v, %v; want nil, nil", got, err)
+ }
+}
+
+func TestDueInstances(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, r := range []Route{
+ {Host: "due.example.com", Kind: "instance", NextDue: 100},
+ {Host: "future.example.com", Kind: "instance", NextDue: 900},
+ {Host: "never.example.com", Kind: "instance", NextDue: 0},
+ } {
+ if err := reg.Upsert(r); err != nil {
+ t.Fatal(err)
+ }
+ }
+ due, err := reg.DueInstances(500)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(due) != 1 || due[0].Host != "due.example.com" {
+ t.Fatalf("DueInstances(500) = %+v; want exactly due.example.com", due)
+ }
+}
+
+func TestFingerprintChangesOnWrite(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ before, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(Route{Host: "c.example.com", Kind: "instance", Addr: "127.0.0.1:9"}); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before == after {
+ t.Fatalf("fingerprint unchanged across a write: %q", after)
+ }
+ // A cross-handle write must change the fingerprint read here too (fresh
+ // connection semantics — same quirk as List).
+ other, err := Open(reg.Path())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := other.SetHibernate("c.example.com", true); err != nil {
+ t.Fatal(err)
+ }
+ final, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if final == after {
+ t.Fatal("fingerprint blind to a cross-handle column write")
+ }
+}
+
+func TestSetVersionsAndHibernateAreNarrow(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(Route{Host: "d.example.com", Kind: "instance", Slug: "d", Channel: "stable"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetVersions("d.example.com", "v2", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetHibernate("d.example.com", true); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetNextDue("d.example.com", 777); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("d.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Version != "v2" || got.PrevVersion != "v1" || !got.Hibernate || got.NextDue != 777 {
+ t.Fatalf("narrow updates lost: %+v", got)
+ }
+ if got.Slug != "d" || got.Channel != "stable" {
+ t.Fatalf("narrow update clobbered unrelated columns: %+v", got)
+ }
+}