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>
4 files changed,
+534
−0
go.mod+14 −0go.sum+51 −0internal/registry/registry.go+288 −0internal/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) |
| + } |
| +} |