carlos / platform Public

console: tables for pending allocations, retirements and migrations, classified for the gate

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e065b41a6293e83980cda71ecc4b2f802db047c5 parent 52c5fc2
7 files changed, +1012 −23
  • internal/console/lifetimes_store.go +400 −0
  • internal/console/lifetimes_store_test.go +487 −0
  • internal/console/store.go +83 −13
  • internal/console/store_gate.go +12 −0
  • internal/console/store_gate_test.go +21 −0
  • internal/console/tempstore.go +5 −10
  • internal/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) {