| index 2d1f935aa..109d9756c 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -261,6 +261,21 @@ type Route struct { |
| // different questions (SnapshotFingerprint's own doc comment). |
| Steering string |
| |
| + // ReplicaOf is the public host this route is a replica OF (#162): the |
| + // home record's host, carried from Record.Home by routeFor's mint and |
| + // held steady by convergeOwned. "" on every row that predates the |
| + // column and on every ordinary route = today's routing, byte for byte. |
| + // |
| + // It exists because two passes must know this row is an account's |
| + // replica and neither can read bucket records: |
| + // domains.ReconcileAliases derives the alias `ReplicaOf -> Host` from |
| + // it (internal/provision imports internal/domains, so the reverse is a |
| + // cycle), and the edges-file writer marks that alias role "replica". |
| + // Not in Fingerprint(), in Steering's shadow: it changes what the |
| + // ALIAS derivation produces, not what this box dials — and the alias |
| + // row it produces IS in the fingerprint. |
| + ReplicaOf string |
| + |
| CreatedAt int64 |
| } |
| |
| @@ -471,6 +486,7 @@ CREATE TABLE IF NOT EXISTS routes ( |
| `ALTER TABLE routes ADD COLUMN args TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN idle_seconds INTEGER NOT NULL DEFAULT 0`, |
| `ALTER TABLE routes ADD COLUMN max_run_seconds INTEGER NOT NULL DEFAULT 0`, |
| + `ALTER TABLE routes ADD COLUMN replica_of TEXT NOT NULL DEFAULT ''`, |
| } { |
| if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -714,7 +730,7 @@ func (r *Registry) Accounts() ([]Account, error) { |
| } |
| |
| // routeCols is the column list every route read and the one route write |
| -// share — 40 names, in this exact order. scanRoute's destinations, Upsert's |
| +// share — 41 names, in this exact order. scanRoute's destinations, Upsert's |
| // placeholder row and Upsert's argument list all count from here: adding a |
| // column means adding it in FOUR places, and a miscount is a silent column |
| // shift rather than an error (TestUpsertRoundTripsAllColumns is what catches |
| @@ -724,7 +740,7 @@ version, prev_version, app, pool, region, channel, hibernate, canary, |
| replication, next_due, backing, status_named, platform, dns_status, |
| dns_checked_at, cert_expires_at, account, environment, caps, tunnel, |
| provisioned, seed, unit, fleet_box, addrs, health, steering, gate, created_at, |
| -args, idle_seconds, max_run_seconds` |
| +args, idle_seconds, max_run_seconds, replica_of` |
| |
| func scanRoute(scan func(...any) error) (Route, error) { |
| var t Route |
| @@ -735,7 +751,7 @@ func scanRoute(scan func(...any) error) (Route, error) { |
| &sn, &plat, &t.DNSStatus, &t.DNSCheckedAt, &t.CertExpiresAt, &t.Account, |
| &t.Environment, &t.Caps, &t.Tunnel, &prov, &t.Seed, &t.Unit, &t.FleetBox, |
| &t.Addrs, &t.Health, &t.Steering, &t.Gate, &t.CreatedAt, |
| - &t.Args, &t.IdleSeconds, &t.MaxRunSeconds) |
| + &t.Args, &t.IdleSeconds, &t.MaxRunSeconds, &t.ReplicaOf) |
| t.Hibernate = hib != 0 |
| t.Canary = can != 0 |
| t.StatusNamed = sn != 0 |
| @@ -941,7 +957,7 @@ func (r *Registry) Upsert(t Route) error { |
| } |
| _, err := r.sql.Exec(` |
| INSERT INTO routes (`+routeCols+`) |
| -VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |
| +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, |
| @@ -963,6 +979,7 @@ ON CONFLICT(host) DO UPDATE SET |
| unit=CASE WHEN excluded.unit = '' THEN routes.unit ELSE excluded.unit END, |
| fleet_box=CASE WHEN excluded.fleet_box = '' THEN routes.fleet_box ELSE excluded.fleet_box END, |
| addrs=excluded.addrs, health=excluded.health, steering=excluded.steering, |
| + replica_of=excluded.replica_of, |
| gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END, |
| args=CASE WHEN excluded.args = '' THEN routes.args ELSE excluded.args END, |
| idle_seconds=CASE WHEN excluded.idle_seconds = 0 THEN routes.idle_seconds ELSE excluded.idle_seconds END, |
| @@ -973,7 +990,8 @@ ON CONFLICT(host) DO UPDATE SET |
| b2i(t.StatusNamed), b2i(t.Platform), t.DNSStatus, t.DNSCheckedAt, |
| t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel, |
| b2i(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health, |
| - t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds) |
| + t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds, |
| + t.ReplicaOf) |
| return err |
| } |
| |
| @@ -1313,6 +1331,28 @@ func (r *Registry) SetSteering(host, steering string) error { |
| return nil |
| } |
| |
| +// SetReplicaOf repoints a route's replica link (#162) as a narrow UPDATE. |
| +// The record's value is authoritative in BOTH directions, including down |
| +// to "": a row that stopped being a replica must lose the link, or the |
| +// domains pass goes on deriving an alias that serves an account from a |
| +// box its record no longer places it in. Errors on an unmatched host, |
| +// SetChannel's contract: the reconciler must notice a row vanishing |
| +// underneath rather than silently do nothing. |
| +func (r *Registry) SetReplicaOf(host, replicaOf string) error { |
| + res, err := r.sql.Exec(`UPDATE routes SET replica_of = ? WHERE host = ?`, replicaOf, host) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return fmt.Errorf("no route for host %q", host) |
| + } |
| + return nil |
| +} |
| + |
| // SetGate stamps or clears a route's password gate — the narrow UPDATE the |
| // provision converge uses (never a full-row Upsert, which preserves a set |
| // gate by design and so could not clear one). Errors on an unmatched host, |