console: tables for pending allocations, retirements and migrations, classified for the gate
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 files changed,
+1012
−23
internal/console/lifetimes_store.go+400 −0internal/console/lifetimes_store_test.go+487 −0internal/console/store.go+83 −13internal/console/store_gate.go+12 −0internal/console/store_gate_test.go+21 −0internal/console/tempstore.go+5 −10internal/provision/lifetime.go+4 −0
diff --git a/internal/console/lifetimes_store.go b/internal/console/lifetimes_store.go| new file mode 100644 |
| index 000000000..7890ad2d0 |
| --- /dev/null |
| +++ b/internal/console/lifetimes_store.go |
| @@ -0,0 +1,400 @@ |
| +package console |
| + |
| +import ( |
| + "crypto/rand" |
| + "database/sql" |
| + "encoding/hex" |
| + "fmt" |
| + "math" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Pending lifetimes: the console's durable intents on a claims-off host's |
| +// incarnation slot. An intent is written before the bucket step it |
| +// describes, so a restart finds it and finishes or cancels it. account is the |
| +// account sqid throughout. |
| +// |
| +// States an intent moves through: pending until its bucket step is settled, |
| +// then committed, superseded (another operation took the slot), void |
| +// (cancelled before it took effect) or refused. |
| +const ( |
| + lifetimePending = "pending" |
| + lifetimeCommitted = "committed" |
| + lifetimeSuperseded = "superseded" |
| + lifetimeVoid = "void" |
| + lifetimeRefused = "refused" |
| +) |
| + |
| +// Retirement kinds. |
| +const ( |
| + retireNormal = "normal" |
| + retireOrphan = "orphan" |
| + retireCleanup = "cleanup" |
| +) |
| + |
| +func lifetimeKnownState(st string) bool { |
| + switch st { |
| + case lifetimePending, lifetimeCommitted, lifetimeSuperseded, lifetimeVoid, lifetimeRefused: |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// Allocation is one pending claim of a host incarnation for an app. |
| +// AppLifetime is the apps row's lifetime id at allocation time. |
| +type Allocation struct { |
| + OpID, Account, App, Host string |
| + Incarnation uint64 |
| + State string |
| + Body []byte |
| + PredecessorETag string |
| + StaleETag string |
| + AppLifetime string |
| +} |
| + |
| +// Retirement is one pending retirement of a record or a slot. Cancels names |
| +// the allocation an orphan retirement cancels, "" otherwise. |
| +type Retirement struct { |
| + OpID, Kind, Account, App, Host string |
| + Incarnation uint64 |
| + RecordETag, SlotETag string |
| + SlotIntent, Cancels, State string |
| +} |
| + |
| +// Migration is one pending migration of a record into the lifetime shape. |
| +type Migration struct { |
| + OpID, Account, App, Host string |
| + ExpectETag, MarkedETag string |
| + State string |
| +} |
| + |
| +// sqlInt turns an incarnation into the INTEGER the tables hold. A value |
| +// SQLite cannot store is refused here, before any write. |
| +func sqlInt(incarnation uint64) (int64, error) { |
| + if incarnation > math.MaxInt64 { |
| + return 0, fmt.Errorf("%w: incarnation %d does not fit the console store", provision.ErrLifetimeExhausted, incarnation) |
| + } |
| + return int64(incarnation), nil |
| +} |
| + |
| +func (s *Store) BeginAllocation(a Allocation) error { |
| + inc, err := sqlInt(a.Incarnation) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.db.Exec(`INSERT INTO host_allocations |
| + (op_id, account, app, host, incarnation, state, body, predecessor_etag, stale_etag, app_lifetime, created_at) |
| +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
| + a.OpID, a.Account, a.App, a.Host, inc, lifetimePending, a.Body, a.PredecessorETag, a.StaleETag, a.AppLifetime, time.Now().Unix()) |
| + return err |
| +} |
| + |
| +// setState moves one intent row to state; a missing row is an error, so a |
| +// recovery pass cannot believe it settled something that is not there. |
| +func (s *Store) setState(table, opID, state string) error { |
| + if !lifetimeKnownState(state) { |
| + return fmt.Errorf("console: unknown lifetime state %q", state) |
| + } |
| + res, err := s.db.Exec(`UPDATE `+table+` SET state = ? WHERE op_id = ?`, state, opID) |
| + return oneRow(res, err, table, opID) |
| +} |
| + |
| +func oneRow(res sql.Result, err error, table, opID string) error { |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n != 1 { |
| + return fmt.Errorf("console: no %s row for op %q", table, opID) |
| + } |
| + return nil |
| +} |
| + |
| +func (s *Store) SetAllocationState(opID, state string) error { |
| + return s.setState("host_allocations", opID, state) |
| +} |
| + |
| +const allocationCols = `op_id, account, app, host, incarnation, state, body, predecessor_etag, stale_etag, app_lifetime` |
| + |
| +func (s *Store) allocations(where string, args ...any) ([]Allocation, error) { |
| + rows, err := s.db.Query(`SELECT `+allocationCols+` FROM host_allocations WHERE `+where+` ORDER BY rowid`, args...) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []Allocation |
| + for rows.Next() { |
| + var a Allocation |
| + var inc int64 |
| + if err := rows.Scan(&a.OpID, &a.Account, &a.App, &a.Host, &inc, &a.State, &a.Body, &a.PredecessorETag, &a.StaleETag, &a.AppLifetime); err != nil { |
| + return nil, err |
| + } |
| + a.Incarnation = uint64(inc) |
| + out = append(out, a) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// PendingAllocations lists the allocations still pending, oldest first. |
| +func (s *Store) PendingAllocations() ([]Allocation, error) { |
| + return s.allocations(`state = ?`, lifetimePending) |
| +} |
| + |
| +// PendingLifetimesForApp lists the pending allocations of one app. |
| +func (s *Store) PendingLifetimesForApp(account, app string) ([]Allocation, error) { |
| + return s.allocations(`state = ? AND account = ? AND app = ?`, lifetimePending, account, app) |
| +} |
| + |
| +// LastIncarnation is the highest incarnation any allocation of host has held |
| +// in any state but void; 0 when there is none. A void allocation never took |
| +// effect, so its number may be used again. |
| +func (s *Store) LastIncarnation(host string) (uint64, error) { |
| + var inc int64 |
| + err := s.db.QueryRow(`SELECT COALESCE(MAX(incarnation), 0) FROM host_allocations WHERE host = ? AND state <> ?`, |
| + host, lifetimeVoid).Scan(&inc) |
| + return uint64(inc), err |
| +} |
| + |
| +func retirementKindKnown(k string) bool { |
| + return k == retireNormal || k == retireOrphan || k == retireCleanup |
| +} |
| + |
| +const retirementInsert = `INSERT INTO record_retirements |
| + (op_id, kind, account, app, host, incarnation, record_etag, slot_etag, slot_intent, cancels, state, created_at) |
| +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` |
| + |
| +// BeginRetirement records a retirement intent. It never sets Cancels: only |
| +// BeginCancellingRetirement does, together with voiding what it cancels. |
| +func (s *Store) BeginRetirement(r Retirement) error { |
| + if !retirementKindKnown(r.Kind) { |
| + return fmt.Errorf("console: unknown retirement kind %q", r.Kind) |
| + } |
| + if r.Cancels != "" { |
| + return fmt.Errorf("console: retirement %q names an allocation to cancel; use BeginCancellingRetirement", r.OpID) |
| + } |
| + inc, err := sqlInt(r.Incarnation) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.db.Exec(retirementInsert, r.OpID, r.Kind, r.Account, r.App, r.Host, inc, |
| + r.RecordETag, r.SlotETag, r.SlotIntent, "", lifetimePending, time.Now().Unix()) |
| + return err |
| +} |
| + |
| +// BeginCancellingRetirement is the atomic handover of a cancellation: in one |
| +// transaction it records the orphan retirement r with Cancels = |
| +// voidAllocation and moves that pending allocation to void. If the |
| +// allocation is not pending, nothing is written. |
| +func (s *Store) BeginCancellingRetirement(r Retirement, voidAllocation string) error { |
| + if r.Kind != retireOrphan { |
| + return fmt.Errorf("console: only an orphan retirement cancels an allocation, not %q", r.Kind) |
| + } |
| + if voidAllocation == "" { |
| + return fmt.Errorf("console: retirement %q names no allocation to cancel", r.OpID) |
| + } |
| + inc, err := sqlInt(r.Incarnation) |
| + if err != nil { |
| + return err |
| + } |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + if _, err := tx.Exec(retirementInsert, r.OpID, r.Kind, r.Account, r.App, r.Host, inc, |
| + r.RecordETag, r.SlotETag, r.SlotIntent, voidAllocation, lifetimePending, time.Now().Unix()); err != nil { |
| + return err |
| + } |
| + res, err := tx.Exec(`UPDATE host_allocations SET state = ? WHERE op_id = ? AND state = ?`, |
| + lifetimeVoid, voidAllocation, lifetimePending) |
| + if err := oneRow(res, err, "pending host_allocations", voidAllocation); err != nil { |
| + return err |
| + } |
| + return tx.Commit() |
| +} |
| + |
| +func (s *Store) SetRetirementState(opID, state string) error { |
| + return s.setState("record_retirements", opID, state) |
| +} |
| + |
| +func (s *Store) pendingRetirements(order string) ([]Retirement, error) { |
| + rows, err := s.db.Query(`SELECT op_id, kind, account, app, host, incarnation, record_etag, slot_etag, slot_intent, cancels, state |
| +FROM record_retirements WHERE state = ? ORDER BY `+order, lifetimePending) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []Retirement |
| + for rows.Next() { |
| + var r Retirement |
| + var inc int64 |
| + if err := rows.Scan(&r.OpID, &r.Kind, &r.Account, &r.App, &r.Host, &inc, &r.RecordETag, &r.SlotETag, &r.SlotIntent, &r.Cancels, &r.State); err != nil { |
| + return nil, err |
| + } |
| + r.Incarnation = uint64(inc) |
| + out = append(out, r) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// PendingRetirements lists the retirements still pending, oldest first. |
| +func (s *Store) PendingRetirements() ([]Retirement, error) { |
| + return s.pendingRetirements(`rowid`) |
| +} |
| + |
| +// PendingRetirementsCancellingFirst is the recovery order: retirements that |
| +// cancel an allocation come before the rest, each group oldest first. |
| +func (s *Store) PendingRetirementsCancellingFirst() ([]Retirement, error) { |
| + return s.pendingRetirements(`(cancels = '') , rowid`) |
| +} |
| + |
| +func (s *Store) BeginMigration(m Migration) error { |
| + _, err := s.db.Exec(`INSERT INTO record_migrations |
| + (op_id, account, app, host, expect_etag, marked_etag, state, created_at) |
| +VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, |
| + m.OpID, m.Account, m.App, m.Host, m.ExpectETag, m.MarkedETag, lifetimePending, time.Now().Unix()) |
| + return err |
| +} |
| + |
| +func (s *Store) SetMigrationMarkedETag(opID, etag string) error { |
| + res, err := s.db.Exec(`UPDATE record_migrations SET marked_etag = ? WHERE op_id = ?`, etag, opID) |
| + return oneRow(res, err, "record_migrations", opID) |
| +} |
| + |
| +func (s *Store) SetMigrationState(opID, state string) error { |
| + return s.setState("record_migrations", opID, state) |
| +} |
| + |
| +// PendingMigrations lists the migrations still pending, oldest first. |
| +func (s *Store) PendingMigrations() ([]Migration, error) { |
| + rows, err := s.db.Query(`SELECT op_id, account, app, host, expect_etag, marked_etag, state |
| +FROM record_migrations WHERE state = ? ORDER BY rowid`, lifetimePending) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []Migration |
| + for rows.Next() { |
| + var m Migration |
| + if err := rows.Scan(&m.OpID, &m.Account, &m.App, &m.Host, &m.ExpectETag, &m.MarkedETag, &m.State); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, m) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// PendingForHost counts the pending allocations, retirements and migrations |
| +// of host, leaving out the operation exceptOpID. |
| +func (s *Store) PendingForHost(host, exceptOpID string) (int, error) { |
| + var n int |
| + err := s.db.QueryRow(`SELECT |
| + (SELECT count(*) FROM host_allocations WHERE host = ?1 AND state = ?3 AND op_id <> ?2) + |
| + (SELECT count(*) FROM record_retirements WHERE host = ?1 AND state = ?3 AND op_id <> ?2) + |
| + (SELECT count(*) FROM record_migrations WHERE host = ?1 AND state = ?3 AND op_id <> ?2)`, |
| + host, exceptOpID, lifetimePending).Scan(&n) |
| + return n, err |
| +} |
| + |
| +// ---- the apps row lifetime --------------------------------------------------- |
| + |
| +// mintLifetime is a fresh random app lifetime id: 16 hex digits. |
| +func mintLifetime() (string, error) { |
| + var b [8]byte |
| + if _, err := rand.Read(b[:]); err != nil { |
| + return "", err |
| + } |
| + return hex.EncodeToString(b[:]), nil |
| +} |
| + |
| +// appExecer is what insertAppRow needs; a Store handle and a transaction both |
| +// have it. |
| +type appExecer interface { |
| + Exec(query string, args ...any) (sql.Result, error) |
| +} |
| + |
| +// newAppRow is the data of one new apps row. |
| +type newAppRow struct { |
| + AccountID int64 |
| + App string |
| + CreatedBy string |
| + CreatedAt int64 |
| + Region string |
| + RegionSource string |
| + ExpiresAt int64 |
| + // UnclaimedEverywhere inserts only when no account holds the app name at |
| + // all (the ops backfill's rule). |
| + UnclaimedEverywhere bool |
| +} |
| + |
| +// insertAppRow is the one place an apps row is inserted, so every row gets a |
| +// lifetime id: equal values mean the same app lifetime, and a purge followed |
| +// by a recreate mints a new one. It reports whether a row was inserted; a |
| +// name the account already holds is not an error. TestEveryAppInsertMintsALifetime |
| +// keeps every other apps insert out of this package. |
| +func insertAppRow(ex appExecer, r newAppRow) (bool, error) { |
| + id, err := mintLifetime() |
| + if err != nil { |
| + return false, err |
| + } |
| + unclaimed := 0 |
| + if r.UnclaimedEverywhere { |
| + unclaimed = 1 |
| + } |
| + res, err := ex.Exec(`INSERT INTO apps (app, account_id, created_by, created_at, region, region_source, expires_at, lifetime) |
| +SELECT ?, ?, ?, ?, ?, ?, ?, ? |
| +WHERE ? = 0 OR NOT EXISTS (SELECT 1 FROM apps WHERE app = ?) |
| +ON CONFLICT(account_id, app) DO NOTHING`, |
| + r.App, r.AccountID, r.CreatedBy, r.CreatedAt, r.Region, r.RegionSource, r.ExpiresAt, id, unclaimed, r.App) |
| + if err != nil { |
| + return false, err |
| + } |
| + n, err := res.RowsAffected() |
| + return n > 0, err |
| +} |
| + |
| +// backfillAppLifetimes mints a lifetime for every row still without one, at |
| +// store open. A row that has one is never rewritten. |
| +func (s *Store) backfillAppLifetimes() error { |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + rows, err := tx.Query(`SELECT account_id, app FROM apps WHERE lifetime = ''`) |
| + if err != nil { |
| + return err |
| + } |
| + type key struct { |
| + account int64 |
| + app string |
| + } |
| + var empty []key |
| + for rows.Next() { |
| + var k key |
| + if err := rows.Scan(&k.account, &k.app); err != nil { |
| + rows.Close() |
| + return err |
| + } |
| + empty = append(empty, k) |
| + } |
| + rows.Close() |
| + if err := rows.Err(); err != nil { |
| + return err |
| + } |
| + for _, k := range empty { |
| + id, err := mintLifetime() |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := tx.Exec(`UPDATE apps SET lifetime = ? WHERE account_id = ? AND app = ? AND lifetime = ''`, id, k.account, k.app); err != nil { |
| + return err |
| + } |
| + } |
| + return tx.Commit() |
| +} |
diff --git a/internal/console/lifetimes_store_test.go b/internal/console/lifetimes_store_test.go| new file mode 100644 |
| index 000000000..c35b1539c |
| --- /dev/null |
| +++ b/internal/console/lifetimes_store_test.go |
| @@ -0,0 +1,487 @@ |
| +package console |
| + |
| +import ( |
| + "database/sql" |
| + "errors" |
| + "math" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func testAllocation(op, host string, inc uint64) Allocation { |
| + return Allocation{ |
| + OpID: op, Account: "bab", App: "shop", Host: host, Incarnation: inc, |
| + Body: []byte(`{"app":"shop"}`), PredecessorETag: `"p-` + op + `"`, StaleETag: `"s-` + op + `"`, |
| + AppLifetime: "00112233aabbccdd", |
| + } |
| +} |
| + |
| +func TestLifetimeTablesRoundTrip(t *testing.T) { |
| + s := openTestStore(t) |
| + |
| + a := testAllocation("op-a", "shop.example.com", 2) |
| + if err := s.BeginAllocation(a); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.BeginAllocation(a); err == nil { |
| + t.Fatal("a second allocation under the same op id was accepted") |
| + } |
| + a.State = "pending" |
| + got, err := s.PendingAllocations() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(got, []Allocation{a}) { |
| + t.Fatalf("pending allocations = %+v, want %+v", got, []Allocation{a}) |
| + } |
| + if err := s.SetAllocationState("op-a", "committed"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _ = s.PendingAllocations(); len(got) != 0 { |
| + t.Fatalf("a committed allocation is still pending: %+v", got) |
| + } |
| + if err := s.SetAllocationState("op-a", "bogus"); err == nil { |
| + t.Fatal("an unknown state was accepted") |
| + } |
| + if err := s.SetAllocationState("op-missing", "void"); err == nil { |
| + t.Fatal("a state change on a missing op reported success") |
| + } |
| + |
| + r := Retirement{OpID: "op-r", Kind: "normal", Account: "bab", App: "shop", Host: "shop.example.com", |
| + Incarnation: 2, RecordETag: `"r"`, SlotETag: `"sl"`, SlotIntent: "retire"} |
| + if err := s.BeginRetirement(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + r.State = "pending" |
| + rs, err := s.PendingRetirements() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(rs, []Retirement{r}) { |
| + t.Fatalf("pending retirements = %+v, want %+v", rs, []Retirement{r}) |
| + } |
| + if err := s.BeginRetirement(Retirement{OpID: "op-bad", Kind: "weird", Account: "bab", App: "shop", Host: "h", Incarnation: 1}); err == nil { |
| + t.Fatal("an unknown retirement kind was accepted") |
| + } |
| + if err := s.BeginRetirement(Retirement{OpID: "op-bad2", Kind: "orphan", Account: "bab", App: "shop", Host: "h", Incarnation: 1, Cancels: "op-a"}); err == nil { |
| + t.Fatal("BeginRetirement accepted a Cancels; only BeginCancellingRetirement may set it") |
| + } |
| + if err := s.SetRetirementState("op-r", "committed"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rs, _ = s.PendingRetirements(); len(rs) != 0 { |
| + t.Fatalf("a committed retirement is still pending: %+v", rs) |
| + } |
| + |
| + m := Migration{OpID: "op-m", Account: "bab", App: "shop", Host: "shop.example.com", ExpectETag: `"e"`} |
| + if err := s.BeginMigration(m); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.SetMigrationMarkedETag("op-m", `"marked"`); err != nil { |
| + t.Fatal(err) |
| + } |
| + m.MarkedETag, m.State = `"marked"`, "pending" |
| + ms, err := s.PendingMigrations() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(ms, []Migration{m}) { |
| + t.Fatalf("pending migrations = %+v, want %+v", ms, []Migration{m}) |
| + } |
| + if err := s.SetMigrationMarkedETag("op-missing", "x"); err == nil { |
| + t.Fatal("a marked etag on a missing migration reported success") |
| + } |
| + if err := s.SetMigrationState("op-m", "superseded"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ms, _ = s.PendingMigrations(); len(ms) != 0 { |
| + t.Fatalf("a superseded migration is still pending: %+v", ms) |
| + } |
| +} |
| + |
| +func TestLifetimeIncarnationBeyondInt64IsRefusedBeforeAnyWrite(t *testing.T) { |
| + s := openTestStore(t) |
| + over := uint64(math.MaxInt64) + 1 |
| + if err := s.BeginAllocation(testAllocation("op-big", "shop.example.com", over)); !errors.Is(err, provision.ErrLifetimeExhausted) { |
| + t.Fatalf("allocation err = %v, want ErrLifetimeExhausted", err) |
| + } |
| + if err := s.BeginRetirement(Retirement{OpID: "op-big-r", Kind: "normal", Account: "bab", App: "shop", Host: "shop.example.com", Incarnation: over}); !errors.Is(err, provision.ErrLifetimeExhausted) { |
| + t.Fatalf("retirement err = %v, want ErrLifetimeExhausted", err) |
| + } |
| + var n int |
| + if err := s.db.QueryRow(`SELECT (SELECT count(*) FROM host_allocations) + (SELECT count(*) FROM record_retirements)`).Scan(&n); err != nil || n != 0 { |
| + t.Fatalf("rows written = %d (%v), want none", n, err) |
| + } |
| + // The largest representable value is accepted and read back whole. |
| + if err := s.BeginAllocation(testAllocation("op-max", "shop.example.com", math.MaxInt64)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := s.LastIncarnation("shop.example.com"); err != nil || got != math.MaxInt64 { |
| + t.Fatalf("LastIncarnation = %d, %v, want MaxInt64", got, err) |
| + } |
| +} |
| + |
| +func TestLastIncarnationSkipsVoid(t *testing.T) { |
| + s := openTestStore(t) |
| + h := "shop.example.com" |
| + if got, err := s.LastIncarnation(h); err != nil || got != 0 { |
| + t.Fatalf("LastIncarnation with nothing = %d, %v, want 0", got, err) |
| + } |
| + for _, a := range []Allocation{testAllocation("op-1", h, 1), testAllocation("op-2", h, 2), testAllocation("op-3", h, 3), |
| + testAllocation("op-other", "other.example.com", 9)} { |
| + if err := s.BeginAllocation(a); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := s.SetAllocationState("op-3", "void"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := s.LastIncarnation(h); err != nil || got != 2 { |
| + t.Fatalf("LastIncarnation = %d, %v, want 2 (pending 2 beats void 3)", got, err) |
| + } |
| + // Every other state counts. |
| + for _, st := range []string{"committed", "superseded", "refused"} { |
| + if err := s.SetAllocationState("op-2", st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _ := s.LastIncarnation(h); got != 2 { |
| + t.Fatalf("LastIncarnation with op-2 %s = %d, want 2", st, got) |
| + } |
| + } |
| +} |
| + |
| +func TestPendingForHostCountsAllThreeExceptOwn(t *testing.T) { |
| + s := openTestStore(t) |
| + h := "shop.example.com" |
| + if err := s.BeginAllocation(testAllocation("op-a", h, 1)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.BeginRetirement(Retirement{OpID: "op-r", Kind: "normal", Account: "bab", App: "shop", Host: h, Incarnation: 1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.BeginMigration(Migration{OpID: "op-m", Account: "bab", App: "shop", Host: h}); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Another host's intents never count. |
| + if err := s.BeginAllocation(testAllocation("op-x", "other.example.com", 1)); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, c := range []struct { |
| + except string |
| + want int |
| + }{{"", 3}, {"op-a", 2}, {"op-r", 2}, {"op-m", 2}, {"op-unknown", 3}} { |
| + if got, err := s.PendingForHost(h, c.except); err != nil || got != c.want { |
| + t.Fatalf("PendingForHost(except %q) = %d, %v, want %d", c.except, got, err, c.want) |
| + } |
| + } |
| + // Terminal rows stop counting. |
| + if err := s.SetAllocationState("op-a", "committed"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.SetRetirementState("op-r", "void"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.SetMigrationState("op-m", "refused"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := s.PendingForHost(h, ""); err != nil || got != 0 { |
| + t.Fatalf("PendingForHost after every row went terminal = %d, %v, want 0", got, err) |
| + } |
| +} |
| + |
| +func TestPendingLifetimesForApp(t *testing.T) { |
| + s := openTestStore(t) |
| + mk := func(op, acct, app string, inc uint64) Allocation { |
| + a := testAllocation(op, "shop.example.com", inc) |
| + a.Account, a.App = acct, app |
| + return a |
| + } |
| + for _, a := range []Allocation{mk("op-1", "bab", "shop", 1), mk("op-2", "bab", "shop", 2), mk("op-3", "bab", "blog", 3), mk("op-4", "xyz", "shop", 4)} { |
| + if err := s.BeginAllocation(a); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := s.SetAllocationState("op-1", "committed"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := s.PendingLifetimesForApp("bab", "shop") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 1 || got[0].OpID != "op-2" || got[0].State != "pending" { |
| + t.Fatalf("PendingLifetimesForApp = %+v, want only op-2", got) |
| + } |
| + if got, err = s.PendingLifetimesForApp("bab", "nothing"); err != nil || len(got) != 0 { |
| + t.Fatalf("PendingLifetimesForApp for an unknown app = %+v, %v", got, err) |
| + } |
| +} |
| + |
| +func TestBeginCancellingRetirementIsOneTransaction(t *testing.T) { |
| + s := openTestStore(t) |
| + h := "shop.example.com" |
| + if err := s.BeginAllocation(testAllocation("op-a", h, 2)); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := Retirement{OpID: "op-orphan", Kind: "orphan", Account: "bab", App: "shop", Host: h, Incarnation: 2, RecordETag: `"r"`} |
| + if err := s.BeginCancellingRetirement(r, "op-a"); err != nil { |
| + t.Fatal(err) |
| + } |
| + var state string |
| + if err := s.db.QueryRow(`SELECT state FROM host_allocations WHERE op_id = 'op-a'`).Scan(&state); err != nil || state != "void" { |
| + t.Fatalf("cancelled allocation state = %q, %v, want void", state, err) |
| + } |
| + var cancels string |
| + if err := s.db.QueryRow(`SELECT cancels FROM record_retirements WHERE op_id = 'op-orphan'`).Scan(&cancels); err != nil || cancels != "op-a" { |
| + t.Fatalf("retirement cancels = %q, %v, want op-a", cancels, err) |
| + } |
| + |
| + // Nothing to cancel: the retirement must not be left behind. |
| + if err := s.BeginCancellingRetirement(Retirement{OpID: "op-orphan2", Kind: "orphan", Account: "bab", App: "shop", Host: h, Incarnation: 3}, "op-missing"); err == nil { |
| + t.Fatal("cancelling a missing allocation reported success") |
| + } |
| + // A committed allocation is not cancellable. |
| + if err := s.BeginAllocation(testAllocation("op-b", h, 3)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.SetAllocationState("op-b", "committed"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.BeginCancellingRetirement(Retirement{OpID: "op-orphan3", Kind: "orphan", Account: "bab", App: "shop", Host: h, Incarnation: 3}, "op-b"); err == nil { |
| + t.Fatal("cancelling a committed allocation reported success") |
| + } |
| + if err := s.BeginCancellingRetirement(Retirement{OpID: "op-orphan4", Kind: "normal", Account: "bab", App: "shop", Host: h, Incarnation: 3}, "op-b"); err == nil { |
| + t.Fatal("a non-orphan retirement was allowed to cancel") |
| + } |
| + var n int |
| + if err := s.db.QueryRow(`SELECT count(*) FROM record_retirements WHERE op_id IN ('op-orphan2','op-orphan3','op-orphan4')`).Scan(&n); err != nil || n != 0 { |
| + t.Fatalf("%d retirements left behind by refused cancellations (%v)", n, err) |
| + } |
| + if err := s.db.QueryRow(`SELECT state FROM host_allocations WHERE op_id = 'op-b'`).Scan(&state); err != nil || state != "committed" { |
| + t.Fatalf("op-b state = %q, %v, want committed", state, err) |
| + } |
| +} |
| + |
| +func TestPendingRetirementsCancellingFirst(t *testing.T) { |
| + s := openTestStore(t) |
| + h := "shop.example.com" |
| + if err := s.BeginRetirement(Retirement{OpID: "op-1", Kind: "normal", Account: "bab", App: "shop", Host: h, Incarnation: 1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.BeginAllocation(testAllocation("op-a", h, 2)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.BeginCancellingRetirement(Retirement{OpID: "op-2", Kind: "orphan", Account: "bab", App: "shop", Host: h, Incarnation: 2}, "op-a"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.BeginRetirement(Retirement{OpID: "op-3", Kind: "cleanup", Account: "bab", App: "shop", Host: h, Incarnation: 1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + var ops []string |
| + rs, err := s.PendingRetirementsCancellingFirst() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, r := range rs { |
| + ops = append(ops, r.OpID) |
| + } |
| + if !reflect.DeepEqual(ops, []string{"op-2", "op-1", "op-3"}) { |
| + t.Fatalf("order = %v, want the cancelling retirement first, then insertion order", ops) |
| + } |
| +} |
| + |
| +// ---- the apps lifetime ------------------------------------------------------ |
| + |
| +var lifetimeIDForm = regexp.MustCompile(`^[0-9a-f]{16}$`) |
| + |
| +func appLifetimeOf(t *testing.T, s *Store, acct int64, app string) string { |
| + t.Helper() |
| + l, err := s.AppLife(acct, app) |
| + if err != nil || l == nil { |
| + t.Fatalf("AppLife(%d, %q) = %v, %v", acct, app, l, err) |
| + } |
| + return l.Lifetime |
| +} |
| + |
| +func TestEveryAppInsertMintsALifetime(t *testing.T) { |
| + // 1. No SQL in this package's non-test source inserts into apps except |
| + // insertAppRow and the apps_new migration copy. |
| + insert := regexp.MustCompile(`(?is)INSERT(\s+OR\s+(IGNORE|REPLACE))?\s+INTO\s+apps(_new)?\b`) |
| + files, err := filepath.Glob("*.go") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var found []string |
| + for _, name := range files { |
| + if strings.HasSuffix(name, "_test.go") { |
| + continue |
| + } |
| + src, err := os.ReadFile(name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, loc := range insert.FindAllIndex(src, -1) { |
| + found = append(found, name+":"+enclosingFunc(string(src), loc[0])) |
| + } |
| + } |
| + want := map[string]bool{"lifetimes_store.go:insertAppRow": true, "store.go:migrateAppsKey": true} |
| + for _, f := range found { |
| + if !want[f] { |
| + t.Errorf("INSERT INTO apps outside insertAppRow and the migration copy: %s", f) |
| + } |
| + delete(want, f) |
| + } |
| + for f := range want { |
| + t.Errorf("expected an apps insert in %s, found none", f) |
| + } |
| + |
| + // 2. Every constructor mints a non-empty, distinct id. |
| + s := openTestStore(t) |
| + acct := testAccountID() |
| + now := time.Now() |
| + if ok, err := s.CreateAppIn(acct, "shop", "p@x.dev", now); err != nil || !ok { |
| + t.Fatal(ok, err) |
| + } |
| + if ok, err := s.CreateAppInRegion(acct, "region-shop", "p@x.dev", "uk", "flag", now); err != nil || !ok { |
| + t.Fatal(ok, err) |
| + } |
| + if ok, err := s.CreateTemporaryAppIn(acct, "tmp-shop", "p@x.dev", now, now.Add(time.Hour)); err != nil || !ok { |
| + t.Fatal(ok, err) |
| + } |
| + if err := s.Grant("p@x.dev", "granted"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.BackfillOpsMemberships(now); err != nil { |
| + t.Fatal(err) |
| + } |
| + ids := map[string]string{ |
| + "CreateAppIn": appLifetimeOf(t, s, acct, "shop"), |
| + "CreateAppInRegion": appLifetimeOf(t, s, acct, "region-shop"), |
| + "CreateTemporaryAppIn": appLifetimeOf(t, s, acct, "tmp-shop"), |
| + "BackfillOpsMemberships (ops)": appLifetimeOf(t, s, OpsAccountID, "granted"), |
| + } |
| + seen := map[string]string{} |
| + for who, id := range ids { |
| + if !lifetimeIDForm.MatchString(id) { |
| + t.Errorf("%s minted lifetime %q, want 16 hex digits", who, id) |
| + } |
| + if prev, dup := seen[id]; dup { |
| + t.Errorf("%s and %s minted the same lifetime %q", who, prev, id) |
| + } |
| + seen[id] = who |
| + } |
| + // A refused (conflicting) insert leaves the first lifetime alone. |
| + if ok, err := s.CreateAppIn(acct, "shop", "q@x.dev", now); err != nil || ok { |
| + t.Fatalf("second create = %v, %v, want a plain conflict", ok, err) |
| + } |
| + if got := appLifetimeOf(t, s, acct, "shop"); got != ids["CreateAppIn"] { |
| + t.Fatalf("a conflicting create changed the lifetime: %q -> %q", ids["CreateAppIn"], got) |
| + } |
| +} |
| + |
| +// enclosingFunc names the top-level func whose body holds byte offset pos. |
| +func enclosingFunc(src string, pos int) string { |
| + re := regexp.MustCompile(`(?m)^func (?:\([^)]*\) )?(\w+)`) |
| + name := "" |
| + for _, m := range re.FindAllStringSubmatchIndex(src, -1) { |
| + if m[0] > pos { |
| + break |
| + } |
| + name = src[m[2]:m[3]] |
| + } |
| + return name |
| +} |
| + |
| +func TestAppLifetimeSurvivesRebuildAndBackfillsLegacyRows(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "console.db") |
| + raw, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The pre-multi-account shape, so the apps_new copy runs. |
| + if _, err := raw.Exec(`CREATE TABLE apps (app TEXT PRIMARY KEY, created_by TEXT NOT NULL, created_at INTEGER NOT NULL)`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := raw.Exec(`INSERT INTO apps (app, created_by, created_at) VALUES ('one', 'p@x.dev', 1), ('two', 'p@x.dev', 2)`); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw.Close() |
| + |
| + var first map[string]string |
| + for pass := 1; pass <= 2; pass++ { |
| + s, err := Open(path) |
| + if err != nil { |
| + t.Fatalf("pass %d: %v", pass, err) |
| + } |
| + got := map[string]string{ |
| + "one": appLifetimeOf(t, s, OpsAccountID, "one"), |
| + "two": appLifetimeOf(t, s, OpsAccountID, "two"), |
| + } |
| + s.Close() |
| + for app, id := range got { |
| + if !lifetimeIDForm.MatchString(id) { |
| + t.Fatalf("pass %d: %s lifetime %q, want 16 hex digits", pass, app, id) |
| + } |
| + } |
| + if got["one"] == got["two"] { |
| + t.Fatalf("pass %d: both legacy apps share lifetime %q", pass, got["one"]) |
| + } |
| + if pass == 1 { |
| + first = got |
| + } else if !reflect.DeepEqual(first, got) { |
| + t.Fatalf("a restart changed lifetimes: %v -> %v", first, got) |
| + } |
| + } |
| + |
| + // A row that is still '' after the rebuild (an added-column database |
| + // whose table is already keyed by account) is minted at open. |
| + s, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.db.Exec(`UPDATE apps SET lifetime = '' WHERE app = 'one'`); err != nil { |
| + t.Fatal(err) |
| + } |
| + s.Close() |
| + s, err = Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer s.Close() |
| + if id := appLifetimeOf(t, s, OpsAccountID, "one"); !lifetimeIDForm.MatchString(id) { |
| + t.Fatalf("an empty lifetime was not minted at open: %q", id) |
| + } |
| + if id := appLifetimeOf(t, s, OpsAccountID, "two"); id != first["two"] { |
| + t.Fatalf("an existing lifetime was rewritten: %q -> %q", first["two"], id) |
| + } |
| +} |
| + |
| +// On an armed store insertAppRow's INSERT ... SELECT is still an apps insert |
| +// the gate judges: a permit naming the app lets it land with a lifetime, and |
| +// no permit refuses it. |
| +func TestInsertAppRowThroughTheGate(t *testing.T) { |
| + s := openGatedStore(t) |
| + if _, err := insertAppRow(s.db, newAppRow{AccountID: 7, App: "shop", CreatedBy: "p@x.dev", CreatedAt: 1}); err == nil { |
| + t.Fatal("an apps insert without a permit was accepted") |
| + } |
| + scope := claimSQLScope{AccountID: 7, App: "shop", Mode: claimModeEffect} |
| + err := s.withClaimSQL(gateCtx(t), []claimSQLScope{scope}, allowAllClaimSQL, func(v *Store) error { |
| + ok, err := insertAppRow(v.db, newAppRow{AccountID: 7, App: "shop", CreatedBy: "p@x.dev", CreatedAt: 1}) |
| + if err != nil || !ok { |
| + t.Fatalf("permitted insert = %v, %v", ok, err) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := appLifetimeOf(t, s, 7, "shop"); !lifetimeIDForm.MatchString(got) { |
| + t.Fatalf("lifetime = %q, want 16 hex digits", got) |
| + } |
| +} |
diff --git a/internal/console/store.go b/internal/console/store.go| index 21523c90e..c985833dc 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -398,6 +398,48 @@ func (s *Store) migrate() error { |
| published_catalog TEXT NOT NULL DEFAULT '', |
| updated_by TEXT NOT NULL, |
| updated_at INTEGER NOT NULL |
| +);`, |
| + // Pending lifetimes (lifetimes_store.go): the console's intents on a |
| + // claims-off host's incarnation slot, written before the bucket step |
| + // they describe so a restart can finish or cancel each one. account |
| + // is the sqid. Incarnations are INTEGER (int64); the store refuses a |
| + // larger value before writing. |
| + `CREATE TABLE IF NOT EXISTS host_allocations ( |
| + op_id TEXT PRIMARY KEY, |
| + account TEXT, |
| + app TEXT, |
| + host TEXT, |
| + incarnation INTEGER, |
| + state TEXT, |
| + body BLOB, |
| + predecessor_etag TEXT, |
| + stale_etag TEXT, |
| + app_lifetime TEXT, |
| + created_at INTEGER |
| +);`, |
| + `CREATE TABLE IF NOT EXISTS record_retirements ( |
| + op_id TEXT PRIMARY KEY, |
| + kind TEXT, |
| + account TEXT, |
| + app TEXT, |
| + host TEXT, |
| + incarnation INTEGER, |
| + record_etag TEXT, |
| + slot_etag TEXT, |
| + slot_intent TEXT, |
| + cancels TEXT, |
| + state TEXT, |
| + created_at INTEGER |
| +);`, |
| + `CREATE TABLE IF NOT EXISTS record_migrations ( |
| + op_id TEXT PRIMARY KEY, |
| + account TEXT, |
| + app TEXT, |
| + host TEXT, |
| + expect_etag TEXT, |
| + marked_etag TEXT, |
| + state TEXT, |
| + created_at INTEGER |
| );`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil { |
| @@ -417,6 +459,9 @@ func (s *Store) migrate() error { |
| // else from what SES reviews on is a limiter that trips at the |
| // wrong time. |
| `ALTER TABLE mail_counts ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0;`, |
| + // An app row's lifetime id (lifetimes_store.go insertAppRow): equal |
| + // values mean the same app lifetime; a purge and recreate mints anew. |
| + `ALTER TABLE apps ADD COLUMN lifetime TEXT NOT NULL DEFAULT '';`, |
| // Where a returning user lands: the account they were last in. |
| `ALTER TABLE users ADD COLUMN last_account INTEGER NOT NULL DEFAULT 0;`, |
| // Apps belong to an account. The column lands with default 1 (ops), so |
| @@ -509,6 +554,10 @@ func (s *Store) migrate() error { |
| if err := s.migrateAppsKey(); err != nil { |
| return err |
| } |
| + // Rows from before the column, or from a raw fixture, still read ''. |
| + if err := s.backfillAppLifetimes(); err != nil { |
| + return err |
| + } |
| // CLI identity's own tables (authstore.go), kept out of the list above so |
| // the feature is one file to read and one file to remove. |
| if err := s.migrateCLIAuth(); err != nil { |
| @@ -606,6 +655,7 @@ func (s *Store) migrateAppsKey() error { |
| restoring INTEGER NOT NULL DEFAULT 0, |
| purge_after INTEGER NOT NULL DEFAULT 0, |
| purge_for INTEGER NOT NULL DEFAULT 0, |
| + lifetime TEXT NOT NULL DEFAULT '', |
| PRIMARY KEY (account_id, app) |
| );`, |
| // The ALTERs above run before migrateAppsKey in migrate(), so |
| @@ -616,8 +666,12 @@ func (s *Store) migrateAppsKey() error { |
| // carrying a build probe or an app region, or already temporary, |
| // when the rebuild runs must not have that state silently dropped |
| // (nearest-region spec §5.2, temporary static sites spec §2). |
| - `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source, expires_at, restoring, purge_after, purge_for) |
| - SELECT app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source, expires_at, restoring, purge_after, purge_for FROM apps;`, |
| + // lifetime is carried across, and minted for a row that has none (the |
| + // ALTER above adds it empty); randomblob(8) is 16 hex digits, the |
| + // same form insertAppRow mints. |
| + `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source, expires_at, restoring, purge_after, purge_for, lifetime) |
| + SELECT app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source, expires_at, restoring, purge_after, purge_for, |
| + CASE WHEN lifetime = '' THEN lower(hex(randomblob(8))) ELSE lifetime END FROM apps;`, |
| `DROP TABLE apps;`, |
| `ALTER TABLE apps_new RENAME TO apps;`, |
| } { |
| @@ -780,12 +834,34 @@ SELECT ?, address, ?, ? FROM users WHERE operator != 0`, |
| // which is what the backfill actually means: take the names nothing has |
| // placed yet. An app that already has a row somewhere has been placed, and |
| // the row that placed it is the authority — never this sweep. |
| - if _, err := tx.Exec(`INSERT OR IGNORE INTO apps (app, account_id, created_by, created_at) |
| -SELECT o.app, ?, MIN(o.address), ? FROM owners o |
| + // |
| + // The candidates are read first and each goes through insertAppRow, which |
| + // repeats the unclaimed-everywhere guard in its own statement, so every |
| + // row gets its own lifetime and the guard still decides atomically. |
| + unclaimed, err := tx.Query(`SELECT o.app, MIN(o.address) FROM owners o |
| WHERE o.app NOT IN (SELECT app FROM apps) AND o.app NOT LIKE 'tmp-%' |
| -GROUP BY o.app`, OpsAccountID, now.Unix()); err != nil { |
| +GROUP BY o.app ORDER BY o.app`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var claims []newAppRow |
| + for unclaimed.Next() { |
| + r := newAppRow{AccountID: OpsAccountID, CreatedAt: now.Unix(), UnclaimedEverywhere: true} |
| + if err := unclaimed.Scan(&r.App, &r.CreatedBy); err != nil { |
| + unclaimed.Close() |
| + return nil, err |
| + } |
| + claims = append(claims, r) |
| + } |
| + unclaimed.Close() |
| + if err := unclaimed.Err(); err != nil { |
| return nil, err |
| } |
| + for _, r := range claims { |
| + if _, err := insertAppRow(tx, r); err != nil { |
| + return nil, err |
| + } |
| + } |
| // A tmp- grant never becomes a row: the namespace is reserved for |
| // temporary apps, and only /new-temporary may create one (spec §3, |
| // astra 7.2). Said at every start, because only an operator can revoke it. |
| @@ -1527,14 +1603,8 @@ func (s *Store) CreateAppIn(accountID int64, app, createdBy string, now time.Tim |
| // an app region has no repair verb, so a separate best-effort write that |
| // failed would leave the app at the home for good, silently (spec §5.3). |
| func (s *Store) CreateAppInRegion(accountID int64, app, createdBy, region, source string, now time.Time) (created bool, err error) { |
| - res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at, region, region_source) |
| -VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`, |
| - app, accountID, createdBy, now.Unix(), region, source) |
| - if err != nil { |
| - return false, err |
| - } |
| - n, err := res.RowsAffected() |
| - return n > 0, err |
| + return insertAppRow(s.db, newAppRow{AccountID: accountID, App: app, CreatedBy: createdBy, CreatedAt: now.Unix(), |
| + Region: region, RegionSource: source}) |
| } |
| // AppRegion reads an app's region and its source label. A missing row |
diff --git a/internal/console/store_gate.go b/internal/console/store_gate.go| index 6fe9ae657..7a42bbe39 100644 |
| --- a/internal/console/store_gate.go |
| +++ b/internal/console/store_gate.go |
| @@ -198,6 +198,11 @@ var claimSQLTables = map[string]claimSQLTable{ |
| "mail_events": {Sqid: "account", App: "app"}, |
| "mail_event_hooks": {Sqid: "account", App: "app"}, |
| "mail_event_deliveries": {Sqid: "account", App: "app"}, |
| + // Pending lifetimes (lifetimes_store.go): intents on a claims-off host's |
| + // incarnation slot, keyed by the account sqid and app name. |
| + "host_allocations": {Sqid: "account", App: "app"}, |
| + "record_retirements": {Sqid: "account", App: "app"}, |
| + "record_migrations": {Sqid: "account", App: "app"}, |
| // ---- not app-scoped: each with its reason ---- |
| "sessions": {NonApp: "browser sessions, keyed by token hash and address"}, |
| @@ -229,6 +234,13 @@ func claimQ(name string) string { return `"` + strings.ReplaceAll(name, `"`, `"" |
| func claimAppish(col string) bool { |
| c := strings.ToLower(col) |
| + // app_lifetime is an app row's lifetime id (host_allocations), a label for |
| + // one lifetime of an app, never an app name or a second scope: it cannot |
| + // widen what a permit covers, so it is the one app_ column the census |
| + // does not read as an app reference. |
| + if c == "app_lifetime" { |
| + return false |
| + } |
| return c == "app" || c == "apps" || c == "app_id" || |
| strings.HasPrefix(c, "app_") || strings.HasSuffix(c, "_app") || strings.HasSuffix(c, "_app_id") |
| } |
diff --git a/internal/console/store_gate_test.go b/internal/console/store_gate_test.go| index 614d05979..ba41cf1d0 100644 |
| --- a/internal/console/store_gate_test.go |
| +++ b/internal/console/store_gate_test.go |
| @@ -737,6 +737,27 @@ func TestClaimSQLTableCensus(t *testing.T) { |
| } |
| } |
| +// The pending-lifetime tables carry an app_lifetime column, which the census |
| +// reads as an app-referencing column: it must open, classified by the same |
| +// (account sqid, app) scope as the mail tables, with no unenforced app column. |
| +func TestClaimStoreOpensWithLifetimeTables(t *testing.T) { |
| + s := openGatedStore(t) |
| + present, err := claimSQLCensus(s.bootDB(), claimSQLTables) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range []string{"host_allocations", "record_retirements", "record_migrations"} { |
| + sp, ok := present[name] |
| + if !ok { |
| + t.Errorf("%s is missing from the claim store", name) |
| + continue |
| + } |
| + if sp.NonApp != "" || sp.Sqid != "account" || sp.App != "app" || sp.Account != "" || sp.AppID != "" { |
| + t.Errorf("%s classified as %+v, want {Sqid: account, App: app}", name, sp) |
| + } |
| + } |
| +} |
| + |
| func TestClaimSQLCensusRefusesNewAppTable(t *testing.T) { |
| path := filepath.Join(t.TempDir(), "c.db") |
| _, err := openGated(path, path, claimSQLTables, func(s *Store) error { |
diff --git a/internal/console/tempstore.go b/internal/console/tempstore.go| index d357fed09..a9200211a 100644 |
| --- a/internal/console/tempstore.go |
| +++ b/internal/console/tempstore.go |
| @@ -32,17 +32,18 @@ type AppLife struct { |
| PurgeAfter int64 // 0 = no early purge |
| PurgeFor int64 // the marker deleted_at purge_after was computed from |
| ControlPlane bool |
| + Lifetime string // the row's lifetime id; equal values mean the same app lifetime |
| } |
| func (l *AppLife) Temporary() bool { return l.ExpiresAt > 0 } |
| func (l *AppLife) Trashed() bool { return l.DeletedAt != 0 } |
| -const appLifeCols = `account_id, app, created_at, deleted_at, expires_at, restoring, purge_after, purge_for, control_plane` |
| +const appLifeCols = `account_id, app, created_at, deleted_at, expires_at, restoring, purge_after, purge_for, control_plane, lifetime` |
| func scanAppLife(sc interface{ Scan(...any) error }) (AppLife, error) { |
| var l AppLife |
| err := sc.Scan(&l.AccountID, &l.App, &l.CreatedAt, &l.DeletedAt, &l.ExpiresAt, &l.Restoring, |
| - &l.PurgeAfter, &l.PurgeFor, &l.ControlPlane) |
| + &l.PurgeAfter, &l.PurgeFor, &l.ControlPlane, &l.Lifetime) |
| return l, err |
| } |
| @@ -105,14 +106,8 @@ func (s *Store) CreateTemporaryAppInRegion(accountID int64, app, createdBy, regi |
| if expiresAt.Unix() <= 0 { |
| return false, errors.New("console: a temporary app needs an expiry") |
| } |
| - res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at, region, region_source, expires_at) |
| -VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`, |
| - app, accountID, createdBy, now.Unix(), region, source, expiresAt.Unix()) |
| - if err != nil { |
| - return false, err |
| - } |
| - n, err := res.RowsAffected() |
| - return n > 0, err |
| + return insertAppRow(s.db, newAppRow{AccountID: accountID, App: app, CreatedBy: createdBy, CreatedAt: now.Unix(), |
| + Region: region, RegionSource: source, ExpiresAt: expiresAt.Unix()}) |
| } |
| // changed runs one conditional UPDATE and reports whether it won. Every |
diff --git a/internal/provision/lifetime.go b/internal/provision/lifetime.go| index 11cde9a7c..24781bbae 100644 |
| --- a/internal/provision/lifetime.go |
| +++ b/internal/provision/lifetime.go |
| @@ -54,6 +54,10 @@ func HostIncarnationKey(host string) string { |
| // ErrSlotMalformed marks a slot body that does not decode or is not Valid. |
| var ErrSlotMalformed = errors.New("provision: host incarnation slot is malformed") |
| +// ErrLifetimeExhausted marks an incarnation number that does not fit the |
| +// console's int64 counters. It is refused before anything is written. |
| +var ErrLifetimeExhausted = errors.New("provision: lifetime counter exhausted") |
| + |
| // ReadHostIncarnation returns the slot and the ETag it was read under, or |
| // nil, "", nil when no slot exists. |
| func ReadHostIncarnation(ctx context.Context, s release.Store, host string) (*HostIncarnation, string, error) { |