carlos / platform Public

registry: hold_op, SetHold and the conditional ReleaseHold (moves §5.2)

Adds Route.HoldOp (idempotent additive migration, routeCols, scanRoute, Upsert),
HoldStatuses/IsHold, SetHold, ReleaseHold, HoldRows and DeleteIfHold. Upsert keeps
an existing hold and its status when the incoming row carries no hold_op (F29).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8437004cc40ef393596088550666d7367f231b58 parent 32509bb
2 files changed, +341 −7
  • internal/registry/hold_test.go +229 −0
  • internal/registry/registry.go +112 −7
diff --git a/internal/registry/hold_test.go b/internal/registry/hold_test.go
new file mode 100644
index 000000000..670844901
--- /dev/null
+++ b/internal/registry/hold_test.go
@@ -0,0 +1,229 @@
+package registry
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+const (
+ holdOpA = "mv_abcdefghijklmnopqrstuvwxyz"
+ holdOpB = "mv_other0000000000000000000000"
+)
+
+func holdFixture(t *testing.T, host string) *Registry {
+ t.Helper()
+ r := openTestRegistry(t)
+ if err := r.Upsert(Route{Host: host, Kind: "instance", Socket: "/s", App: "a", Hibernate: true}); err != nil {
+ t.Fatal(err)
+ }
+ return r
+}
+
+func TestHoldIsConditional(t *testing.T) {
+ r := holdFixture(t, "h")
+ if err := r.SetHold("h", "moving", holdOpA); err != nil {
+ t.Fatal(err)
+ }
+ rt, _ := r.Get("h")
+ if rt.Status != "moving" || rt.HoldOp != holdOpA || !IsHold(rt.Status) {
+ t.Fatalf("held row: %+v", rt)
+ }
+ if err := r.SetHold("h", "ready", "x"); err == nil {
+ t.Fatal("ready is not a hold")
+ }
+ // §5.2: a late write from a finished operation matches nothing.
+ if changed, err := r.ReleaseHold("h", holdOpB); err != nil || changed {
+ t.Fatalf("release under another op: %v %v", changed, err)
+ }
+ if changed, err := r.ReleaseHold("h", holdOpA); err != nil || !changed {
+ t.Fatalf("release: %v %v", changed, err)
+ }
+ rt, _ = r.Get("h")
+ if rt.Status != "ready" || rt.HoldOp != "" {
+ t.Fatalf("after release: %+v", rt)
+ }
+ // Upsert preserves hold_op through routeCols.
+ if err := r.SetHold("h", "landing", holdOpA); err != nil {
+ t.Fatal(err)
+ }
+ rt, _ = r.Get("h")
+ if err := r.Upsert(*rt); err != nil {
+ t.Fatal(err)
+ }
+ rt, _ = r.Get("h")
+ if rt.HoldOp == "" {
+ t.Fatal("Upsert dropped hold_op")
+ }
+}
+
+func TestSetHoldRefusals(t *testing.T) {
+ r := holdFixture(t, "h")
+ for _, status := range []string{"moving", "landing", "restoring", "releasing"} {
+ if err := r.SetHold("h", status, holdOpA); err != nil {
+ t.Fatalf("%s: %v", status, err)
+ }
+ if rt, _ := r.Get("h"); rt.Status != status || rt.HoldOp != holdOpA {
+ t.Fatalf("%s: %+v", status, rt)
+ }
+ }
+ for _, status := range []string{"", "ready", "pending", "failed", "Moving"} {
+ if err := r.SetHold("h", status, holdOpA); err == nil {
+ t.Fatalf("status %q accepted as a hold", status)
+ }
+ }
+ if err := r.SetHold("h", "moving", ""); err == nil {
+ t.Fatal("a hold with no op was accepted")
+ }
+ if err := r.SetHold("nope", "moving", holdOpA); err == nil {
+ t.Fatal("SetHold on an unknown host did not error")
+ }
+ if IsHold("ready") || !IsHold("releasing") {
+ t.Fatal("IsHold disagrees with HoldStatuses")
+ }
+}
+
+// An Upsert from a writer that knows nothing of holds (carlos add, the
+// reconciler's mint paths) must not release one: the incoming row has an empty
+// hold_op and a ready status, and the held row keeps both (F29).
+func TestUpsertDoesNotClearHold(t *testing.T) {
+ r := holdFixture(t, "h")
+ if err := r.SetHold("h", "moving", holdOpA); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.Upsert(Route{Host: "h", Kind: "instance", Socket: "/s2", App: "a", Hibernate: true}); err != nil {
+ t.Fatal(err)
+ }
+ rt, _ := r.Get("h")
+ if rt.Status != "moving" || rt.HoldOp != holdOpA {
+ t.Fatalf("Upsert released the hold: %+v", rt)
+ }
+ if rt.Socket != "/s2" {
+ t.Fatalf("the rest of the row did not update: %+v", rt)
+ }
+ // Only ReleaseHold ends it, after which Upsert is an ordinary write again.
+ if ok, err := r.ReleaseHold("h", holdOpA); err != nil || !ok {
+ t.Fatalf("release: %v %v", ok, err)
+ }
+ if err := r.Upsert(Route{Host: "h", Kind: "instance", Socket: "/s3", Status: "starting"}); err != nil {
+ t.Fatal(err)
+ }
+ if rt, _ = r.Get("h"); rt.Status != "starting" || rt.HoldOp != "" {
+ t.Fatalf("Upsert on an unheld row: %+v", rt)
+ }
+}
+
+func TestHoldRows(t *testing.T) {
+ r := openTestRegistry(t)
+ if rows, err := r.HoldRows(); err != nil || len(rows) != 0 {
+ t.Fatalf("empty registry: %v %v", rows, err)
+ }
+ for _, h := range []string{"a", "b", "c"} {
+ if err := r.Upsert(Route{Host: h, Kind: "instance", Socket: "/s"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := r.SetHold("c", "restoring", holdOpA); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.SetHold("a", "landing", holdOpB); err != nil {
+ t.Fatal(err)
+ }
+ rows, err := r.HoldRows()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 2 || rows[0].Host != "a" || rows[0].HoldOp != holdOpB || rows[0].Status != "landing" ||
+ rows[1].Host != "c" || rows[1].HoldOp != holdOpA || rows[1].Status != "restoring" {
+ t.Fatalf("HoldRows = %+v", rows)
+ }
+ // A bare "moving" status written by SetStatus has no op, so it is not a
+ // held row in this sense.
+ if err := r.SetStatus("b", "moving"); err != nil {
+ t.Fatal(err)
+ }
+ if rows, _ = r.HoldRows(); len(rows) != 2 {
+ t.Fatalf("a status without an op counted as a hold: %+v", rows)
+ }
+}
+
+func TestDeleteIfHold(t *testing.T) {
+ r := holdFixture(t, "h")
+ // Not held: nothing is deleted.
+ if ok, err := r.DeleteIfHold("h", holdOpA); err != nil || ok {
+ t.Fatalf("unheld row: %v %v", ok, err)
+ }
+ if err := r.SetHold("h", "releasing", holdOpA); err != nil {
+ t.Fatal(err)
+ }
+ // Held by another op: nothing is deleted.
+ if ok, err := r.DeleteIfHold("h", holdOpB); err != nil || ok {
+ t.Fatalf("row held by another op: %v %v", ok, err)
+ }
+ // An empty op must not match unheld rows' empty hold_op.
+ if err := r.Upsert(Route{Host: "plain", Kind: "instance", Socket: "/s"}); err != nil {
+ t.Fatal(err)
+ }
+ if ok, err := r.DeleteIfHold("plain", ""); err != nil || ok {
+ t.Fatalf("empty op deleted an unheld row: %v %v", ok, err)
+ }
+ if rt, _ := r.Get("plain"); rt == nil {
+ t.Fatal("unheld row was deleted")
+ }
+ if rt, _ := r.Get("h"); rt == nil || rt.HoldOp != holdOpA {
+ t.Fatalf("held row changed by the refusals: %+v", rt)
+ }
+ if ok, err := r.DeleteIfHold("h", holdOpA); err != nil || !ok {
+ t.Fatalf("delete under the holding op: %v %v", ok, err)
+ }
+ if rt, _ := r.Get("h"); rt != nil {
+ t.Fatalf("row survived: %+v", rt)
+ }
+ if ok, err := r.DeleteIfHold("h", holdOpA); err != nil || ok {
+ t.Fatalf("second delete: %v %v", ok, err)
+ }
+}
+
+func TestReleaseHoldEmptyOpMatchesNothing(t *testing.T) {
+ r := holdFixture(t, "h")
+ if ok, err := r.ReleaseHold("h", ""); err != nil || ok {
+ t.Fatalf("release with empty op on an unheld row: %v %v", ok, err)
+ }
+ if err := r.SetStatus("h", "starting"); err != nil {
+ t.Fatal(err)
+ }
+ if ok, _ := r.ReleaseHold("h", ""); ok {
+ t.Fatal("an empty op released an unheld row and reset its status")
+ }
+ if rt, _ := r.Get("h"); rt.Status != "starting" {
+ t.Fatalf("status moved: %+v", rt)
+ }
+}
+
+// The migration is additive and idempotent: opening a registry that predates
+// the column adds it, and opening again is a no-op.
+func TestHoldOpMigrationIdempotentOnLegacyDB(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ r, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := r.Upsert(Route{Host: "h", Kind: "instance", Socket: "/s"}); err != nil {
+ t.Fatal(err)
+ }
+ // Strip the column to look like a pre-hold registry.
+ if _, err := r.sql.Exec(`ALTER TABLE routes DROP COLUMN hold_op`); err != nil {
+ t.Fatal(err)
+ }
+ r.Close()
+ for i := 0; i < 2; i++ {
+ r, err = Open(path)
+ if err != nil {
+ t.Fatalf("open %d: %v", i, err)
+ }
+ rt, err := r.Get("h")
+ if err != nil || rt == nil || rt.HoldOp != "" || rt.Status != "ready" {
+ t.Fatalf("open %d: %+v %v", i, rt, err)
+ }
+ r.Close()
+ }
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index ba96979df..b7b330950 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -56,6 +56,13 @@ type Route struct {
// failing probe.
Health string
Status string // pending|creating|starting|issuing|ready|failed
+ // HoldOp is the operation holding Status (moves spec §5.2); "" when not
+ // held. A held row (Status in HoldStatuses) belongs to that operation: the
+ // edge never serves it, ticks refuse it and no ordinary wake reaches it.
+ // Only SetHold and ReleaseHold change a hold; Upsert keeps an existing one
+ // when the incoming row carries none. Deliberately NOT in Fingerprint():
+ // every hold also moves Status, which is.
+ HoldOp string
LastError string
DBPath string
@@ -508,6 +515,7 @@ CREATE TABLE IF NOT EXISTS routes (
`ALTER TABLE routes ADD COLUMN max_run_seconds INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE routes ADD COLUMN replicas TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN replica INTEGER NOT NULL DEFAULT 0`,
+ `ALTER TABLE routes ADD COLUMN hold_op TEXT NOT NULL DEFAULT ''`,
} {
if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -751,7 +759,7 @@ func (r *Registry) Accounts() ([]Account, error) {
}
// routeCols is the column list every route read and the one route write
-// share — 42 names, in this exact order. scanRoute's destinations, Upsert's
+// share — 43 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
@@ -761,7 +769,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, replicas, replica`
+args, idle_seconds, max_run_seconds, replicas, replica, hold_op`
func scanRoute(scan func(...any) error) (Route, error) {
var t Route
@@ -772,7 +780,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.Replicas, &rep)
+ &t.Args, &t.IdleSeconds, &t.MaxRunSeconds, &t.Replicas, &rep, &t.HoldOp)
t.Hibernate = hib != 0
t.Canary = can != 0
t.StatusNamed = sn != 0
@@ -834,7 +842,11 @@ func (r *Registry) Delete(host string) error {
return err
}
-// Upsert inserts or updates a route by host. Twelve columns are deliberately
+// Upsert inserts or updates a route by host. A held row (a non-empty hold_op) is
+// not released by it: when the incoming Route carries no HoldOp, the existing
+// row keeps its hold_op AND its status (only SetHold and ReleaseHold change a
+// hold; moves spec §5.2, defence in depth beside the reconciler's own skip).
+// Twelve columns are deliberately
// not plain overwrites — `environment`, `caps`, `tunnel`, `provisioned`,
// `seed`, `unit`, `fleet_box`, `replication`, `gate`, `args`, `idle_seconds`
// and `max_run_seconds` all keep the existing row's value when the incoming
@@ -987,10 +999,13 @@ 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,
+ addr=excluded.addr,
+ status=CASE WHEN excluded.hold_op = '' AND routes.hold_op != '' THEN routes.status ELSE excluded.status END,
+ hold_op=CASE WHEN excluded.hold_op = '' THEN routes.hold_op ELSE excluded.hold_op END,
+ 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,
@@ -1021,10 +1036,100 @@ ON CONFLICT(host) DO UPDATE SET
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.Replicas, b2i(t.Replica))
+ t.Replicas, b2i(t.Replica), t.HoldOp)
return err
}
+// HoldStatuses are the row statuses the edge never serves, ticks refuse and
+// no ordinary wake reaches (moves spec §5.2).
+var HoldStatuses = map[string]bool{"moving": true, "landing": true, "restoring": true, "releasing": true}
+
+// IsHold reports whether status is one of HoldStatuses.
+func IsHold(status string) bool { return HoldStatuses[status] }
+
+// SetHold writes a hold status with the operation holding it. It refuses a
+// status that is not in HoldStatuses and an empty op, and errors when no row
+// matched.
+//
+// Every SetHold caller also touches the stage-requested marker (moves Task
+// 13's rollback hold rule relies on it); SetHold itself does not, because the
+// marker lives outside this table.
+func (r *Registry) SetHold(host, status, op string) error {
+ if !IsHold(status) || op == "" {
+ return fmt.Errorf("registry: %q with op %q is not a hold", status, op)
+ }
+ res, err := r.sql.Exec(`UPDATE routes SET status = ?, hold_op = ? WHERE host = ?`, status, op, 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
+}
+
+// ReleaseHold is §5.2's one conditional statement: a row leaves a hold only
+// under the op that holds it. false means nothing matched (a late write from a
+// finished operation, an unheld row, or an empty op).
+func (r *Registry) ReleaseHold(host, op string) (bool, error) {
+ if op == "" {
+ return false, nil
+ }
+ res, err := r.sql.Exec(`UPDATE routes SET status = 'ready', hold_op = '' WHERE host = ? AND hold_op = ?`, host, op)
+ if err != nil {
+ return false, err
+ }
+ n, err := res.RowsAffected()
+ return n > 0, err
+}
+
+// DeleteIfHold deletes host's row only while op holds it: the source's row
+// deletion at finalization (moves spec §7.9 step 3). A row held by another
+// op, or not held, is not deleted and the answer is false.
+func (r *Registry) DeleteIfHold(host, op string) (bool, error) {
+ if op == "" {
+ return false, nil
+ }
+ res, err := r.sql.Exec(`DELETE FROM routes WHERE host = ? AND hold_op = ?`, host, op)
+ if err != nil {
+ return false, err
+ }
+ n, err := res.RowsAffected()
+ return n > 0, err
+}
+
+// HoldRows returns every row held by an operation (a non-empty hold_op), ordered by
+// host. Over a fresh connection, like List, so a reader in another process
+// sees the latest commit.
+func (r *Registry) HoldRows() ([]Route, error) {
+ db, err := sql.Open("sqlite", r.path)
+ if err != nil {
+ return nil, err
+ }
+ defer db.Close()
+ if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
+ return nil, err
+ }
+ rows, err := db.Query(`SELECT ` + routeCols + ` FROM routes WHERE hold_op != '' 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()
+}
+
// SetAccount stamps a route's app→account linkage as a narrow UPDATE — the
// reconcile pass re-stamps this every tick from the bucket manifests, and a
// read-modify-write Upsert racing a deploy's SetVersions (or the activator's