provision: RetireRef states its expectation, and the judged record evidence and host listing the purge and restore settlement read
RetireExpectation{Absent, ETag, Incarnation} rides on RetireRef.Expect for
the claims-off lifetime retirement; the claims-on chain and the legacy
delete ignore it. RecordEvidenceJudged is the three-valued judged sibling
of ListRecordsComplete (valid records, and unknown for an unreadable or
corrupt key or more keys than the cap; a stale body, a tombstone or a key
gone since the listing counts for nothing). ListRecordHosts and
IsInstanceRecordKeyOf name an app's instance record keys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+228
−0
internal/provision/lifetime_judge.go+58 −0internal/provision/lifetime_judge_test.go+96 −0internal/provision/retirement.go+38 −0internal/provision/retirement_test.go+36 −0
diff --git a/internal/provision/lifetime_judge.go b/internal/provision/lifetime_judge.go| index ef194d510..49fa96525 100644 |
| --- a/internal/provision/lifetime_judge.go |
| +++ b/internal/provision/lifetime_judge.go |
| @@ -171,6 +171,64 @@ func ListRecordsCompleteJudged(ctx context.Context, s release.Store, j *Lifetime |
| return recs, truncated, skipped, nil |
| } |
| +// RecordEvidenceJudged is the judged sibling of ListRecordsComplete for a |
| +// caller deciding whether an app still lives from its instance records |
| +// (self-serve moves plan 1, Task 4): the records the slot vouches for, and |
| +// unknown when any key could not be judged. Three values per key: a valid |
| +// record is evidence; a stale body, a tombstone or a key gone since the |
| +// listing contributes nothing; an unreadable or corrupt observation, or more |
| +// keys than max, makes the whole answer unknown. A failed listing is an |
| +// error. j must not be nil. |
| +func RecordEvidenceJudged(ctx context.Context, s release.Store, j *LifetimeJudge, acct, app string, max int) (recs []Record, unknown bool, err error) { |
| + if j == nil { |
| + return nil, false, errors.New("provision: record evidence needs a lifetime judge") |
| + } |
| + if max < 0 { |
| + max = 0 |
| + } |
| + prefix := RecordsPrefix(acct, app) |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + hosts := recordHosts(keys, prefix) |
| + sort.Strings(hosts) |
| + if len(hosts) > max { |
| + unknown = true |
| + hosts = hosts[:max] |
| + } |
| + for _, host := range hosts { |
| + obs := j.Observe(ctx, s, acct, app, host) |
| + switch { |
| + case obs.Verdict == LifetimeUnreadable || obs.Verdict == LifetimeCorrupt: |
| + unknown = true |
| + case !obs.Present || obs.Verdict == LifetimeStale || obs.Rec == nil || obs.Rec.Retired: |
| + default: |
| + recs = append(recs, *obs.Rec) |
| + } |
| + } |
| + return recs, unknown, nil |
| +} |
| + |
| +// ListRecordHosts lists the hosts that have an instance record key under |
| +// acct/app, sorted. It reads no body. IsInstanceRecordKeyOf is its key test. |
| +func ListRecordHosts(ctx context.Context, s release.Store, acct, app string) ([]string, error) { |
| + prefix := RecordsPrefix(acct, app) |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + hosts := recordHosts(keys, prefix) |
| + sort.Strings(hosts) |
| + return hosts, nil |
| +} |
| + |
| +// IsInstanceRecordKeyOf reports whether key is an instance record key of |
| +// acct/app: <RecordsPrefix>/<host>.json, nothing nested. |
| +func IsInstanceRecordKeyOf(key, acct, app string) bool { |
| + return len(recordHosts([]string{key}, RecordsPrefix(acct, app))) == 1 |
| +} |
| + |
| // ListRecordsStrictJudged is ListRecordsStrict under a judge: stale records |
| // are dropped silently, a corrupt body is reported in unreadable, and an |
| // unreadable observation fails the listing. A nil judge is ListRecordsStrict. |
diff --git a/internal/provision/lifetime_judge_test.go b/internal/provision/lifetime_judge_test.go| index ed0224990..24fd37f79 100644 |
| --- a/internal/provision/lifetime_judge_test.go |
| +++ b/internal/provision/lifetime_judge_test.go |
| @@ -795,3 +795,99 @@ func TestJudgedTolerantListingCorruptBodyKeepsCompleteTrue(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// Restore evidence from instance records is three-valued (self-serve moves |
| +// plan 1, Task 4): a valid record is evidence, a stale body, a tombstone or |
| +// a key gone since the listing is nothing, and a corrupt or unreadable one, |
| +// or more keys than the cap, is unknown. |
| +func TestRecordEvidenceJudgedIsThreeValued(t *testing.T) { |
| + ctx := context.Background() |
| + j := NewLifetimeJudge() |
| + put := func(t *testing.T, s release.Store, host string, slot *HostIncarnation, body any) { |
| + t.Helper() |
| + rec := judgeRecord() |
| + rec.Host = host |
| + if body == nil { |
| + judgePut(t, s, RecordKey("bab", "shop", host), rec) |
| + } else if _, err := s.Put(ctx, RecordKey("bab", "shop", host), body.([]byte), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if slot != nil { |
| + judgePut(t, s, HostIncarnationKey(host), *slot) |
| + } |
| + } |
| + live, retired := judgeSlot(1, SlotLive), judgeSlot(1, SlotRetired) |
| + cases := []struct { |
| + name string |
| + arrange func(t *testing.T, s release.Store) |
| + getErr string |
| + max int |
| + valid int |
| + unknown bool |
| + }{ |
| + {"a valid record", func(t *testing.T, s release.Store) { put(t, s, "a.example.com", &live, nil) }, "", 10, 1, false}, |
| + {"a stale body", func(t *testing.T, s release.Store) { put(t, s, "a.example.com", &retired, nil) }, "", 10, 0, false}, |
| + {"a tombstone", func(t *testing.T, s release.Store) { |
| + rec := judgeRecord() |
| + rec.Host = "a.example.com" |
| + raw, _ := json.Marshal(rec) |
| + tomb, _ := TombstoneBody(raw) |
| + put(t, s, "a.example.com", &live, tomb) |
| + }, "", 10, 0, false}, |
| + {"a corrupt body", func(t *testing.T, s release.Store) { put(t, s, "a.example.com", nil, []byte("{")) }, "", 10, 0, true}, |
| + {"an unreadable key", func(t *testing.T, s release.Store) { put(t, s, "a.example.com", &live, nil) }, "a.example.com", 10, 0, true}, |
| + {"more keys than the cap", func(t *testing.T, s release.Store) { |
| + put(t, s, "a.example.com", &live, nil) |
| + put(t, s, "b.example.com", &retired, nil) |
| + }, "", 1, 1, true}, |
| + {"a valid record beside a stale one", func(t *testing.T, s release.Store) { |
| + put(t, s, "a.example.com", &live, nil) |
| + put(t, s, "b.example.com", &retired, nil) |
| + }, "", 10, 1, false}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + base := release.NewDirStore(t.TempDir()) |
| + c.arrange(t, base) |
| + s := &judgeStore{Store: base, getError: func(k string) error { |
| + if c.getErr != "" && k == RecordKey("bab", "shop", c.getErr) { |
| + return os.ErrPermission |
| + } |
| + return nil |
| + }} |
| + recs, unknown, err := RecordEvidenceJudged(ctx, s, j, "bab", "shop", c.max) |
| + if err != nil || len(recs) != c.valid || unknown != c.unknown { |
| + t.Fatalf("evidence = %d records, unknown %v, %v; want %d, %v", len(recs), unknown, err, c.valid, c.unknown) |
| + } |
| + }) |
| + } |
| + t.Run("a key gone since the listing", func(t *testing.T) { |
| + base := release.NewDirStore(t.TempDir()) |
| + put(t, base, "a.example.com", &live, nil) |
| + s := &judgeStore{Store: base, afterList: func(string) { |
| + _ = base.Delete(ctx, RecordKey("bab", "shop", "a.example.com")) |
| + }} |
| + recs, unknown, err := RecordEvidenceJudged(ctx, s, j, "bab", "shop", 10) |
| + if err != nil || len(recs) != 0 || unknown { |
| + t.Fatalf("evidence = %d, unknown %v, %v; want nothing, known", len(recs), unknown, err) |
| + } |
| + }) |
| + if _, _, err := RecordEvidenceJudged(ctx, release.NewDirStore(t.TempDir()), nil, "bab", "shop", 10); err == nil { |
| + t.Fatal("a nil judge gave evidence") |
| + } |
| +} |
| + |
| +func TestInstanceRecordKeyOfIsTheRecordKeyShape(t *testing.T) { |
| + for key, want := range map[string]bool{ |
| + RecordKey("bab", "shop", "a.example.com"): true, |
| + "apps/bab/shop/instances.json": false, |
| + "apps/bab/shop/instances/x/y.json": false, |
| + RecordKey("bab", "web", "a.example.com"): false, |
| + "apps/bab/shop/sidecars/job.json": false, |
| + "apps/bab/shop/channels/stable.json": false, |
| + } { |
| + if got := IsInstanceRecordKeyOf(key, "bab", "shop"); got != want { |
| + t.Fatalf("IsInstanceRecordKeyOf(%q) = %v, want %v", key, got, want) |
| + } |
| + } |
| +} |
diff --git a/internal/provision/retirement.go b/internal/provision/retirement.go| index aab7a2564..38baba68c 100644 |
| --- a/internal/provision/retirement.go |
| +++ b/internal/provision/retirement.go |
| @@ -12,7 +12,45 @@ type RetireRef struct { |
| Family, Account, App, Host, Name, By string |
| // Required by rollbacks: never retire a successor to the failed attempt. |
| Incarnation string |
| + // Expect is what a claims-off retirement admitted with lifetimes on |
| + // requires of the record before it acts (self-serve moves plan 1, Task |
| + // 4). The claims-on branch and the legacy delete ignore it. |
| + Expect RetireExpectation |
| } |
| + |
| +// RetireExpectation is a claims-off caller's statement of which record it |
| +// means to retire. At most one field is set; the zero value is "whatever is |
| +// current" (a detach). |
| +// |
| +// - Absent: the caller saw no record. A record present now is someone |
| +// else's and is left untouched; an absent key still has its owned live |
| +// slot retired. |
| +// - ETag: the caller read the record under this ETag. Any other body, or |
| +// none, is a CAS conflict. |
| +// - Incarnation: the caller made this lifetime. A record of any other |
| +// lifetime, or none, is left untouched. |
| +type RetireExpectation struct { |
| + Absent bool |
| + ETag string |
| + Incarnation uint64 |
| +} |
| + |
| +// Set reports how many of the expectation's fields are set; more than one |
| +// is a programming error. |
| +func (e RetireExpectation) Set() int { |
| + n := 0 |
| + if e.Absent { |
| + n++ |
| + } |
| + if e.ETag != "" { |
| + n++ |
| + } |
| + if e.Incarnation != 0 { |
| + n++ |
| + } |
| + return n |
| +} |
| + |
| type ClaimRetirer interface { |
| RetireRecord(context.Context, RetireRef) error |
| } |
diff --git a/internal/provision/retirement_test.go b/internal/provision/retirement_test.go| index e0a4f45f4..a31dee3ac 100644 |
| --- a/internal/provision/retirement_test.go |
| +++ b/internal/provision/retirement_test.go |
| @@ -39,3 +39,39 @@ func TestRetireRecordFlagOffAndRequiredProvider(t *testing.T) { |
| t.Fatal("claim path not required") |
| } |
| } |
| + |
| +// RetireExpectation states at most one thing; the claims-on and legacy |
| +// branches carry it without reading it (self-serve moves plan 1, Task 4). |
| +func TestRetireExpectationSetCountsEachField(t *testing.T) { |
| + for _, c := range []struct { |
| + e RetireExpectation |
| + want int |
| + }{ |
| + {RetireExpectation{}, 0}, |
| + {RetireExpectation{Absent: true}, 1}, |
| + {RetireExpectation{ETag: "e"}, 1}, |
| + {RetireExpectation{Incarnation: 2}, 1}, |
| + {RetireExpectation{Absent: true, ETag: "e"}, 2}, |
| + {RetireExpectation{Absent: true, ETag: "e", Incarnation: 3}, 3}, |
| + } { |
| + if got := c.e.Set(); got != c.want { |
| + t.Fatalf("%+v.Set() = %d, want %d", c.e, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestRetireRecordLegacyIgnoresTheExpectation(t *testing.T) { |
| + ctx := context.Background() |
| + store := release.NewDirStore(t.TempDir()) |
| + rec := Record{Schema: 1, Account: "bab", App: "shop", Host: "one.shop.example.com", Channel: "stable"} |
| + if err := CreateRecord(ctx, store, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + ref := RetireRef{Family: "instance", Account: "bab", App: "shop", Host: rec.Host, Expect: RetireExpectation{Incarnation: 9}} |
| + if err := RetireRecord(ctx, store, ref, RetireOptions{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := GetRecord(ctx, store, "bab", "shop", rec.Host); err == nil { |
| + t.Fatal("the legacy delete read the expectation and kept the record") |
| + } |
| +} |