hostclaim: delete (CC11 over its write set, keeps exec), parking chores, completion and free
Co-Authored-By: Codex (gpt-6-astra) <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+192
−0
internal/hostclaim/delete.go+97 −0internal/hostclaim/delete_test.go+95 −0
diff --git a/internal/hostclaim/delete.go b/internal/hostclaim/delete.go| new file mode 100644 |
| index 000000000..ff11f39e4 |
| --- /dev/null |
| +++ b/internal/hostclaim/delete.go |
| @@ -0,0 +1,97 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "fmt" |
| + "time" |
| +) |
| + |
| +// WriteSet is where a delete parks: the touched set, or the whole |
| +// deployment while discovery is incomplete (§5.2 step 3, §9). |
| +func WriteSet(c *Claim, allRegions, allBoxes []string) (regions, boxes []string) { |
| + if c.Touched.Complete { |
| + return c.Touched.Regions, c.Touched.Boxes |
| + } |
| + return allRegions, allBoxes |
| +} |
| + |
| +func BeginDelete(opID, by, recordETag string, now time.Time, allRegions, allBoxes []string) func(*Claim) error { |
| + return func(c *Claim) error { |
| + regions, boxes := WriteSet(c, allRegions, allBoxes) |
| + if err := StartGuard(c, regions, boxes); err != nil { |
| + return err |
| + } |
| + if opID == "" || recordETag == "" { |
| + return fmt.Errorf("%w: delete needs an op id and the record's etag", ErrMalformed) |
| + } |
| + exec, parts := c.Op.Exec, c.Op.Participants // execution authority is kept (R6-4) |
| + c.Op = Op{Kind: KindDeleting, ID: opID, By: by, StartedAt: now.UTC(), Exec: exec, Participants: parts, |
| + Intent: map[string]string{"key": c.Holder.Key, "etag": recordETag}} |
| + return nil |
| + } |
| +} |
| + |
| +func ToParking(opID string, allRegions, allBoxes []string, newID func() string) func(*Claim) error { |
| + return func(c *Claim) error { |
| + if err := RequireOp(opID)(c); err != nil { |
| + return err |
| + } |
| + // purging: §5.3 step 4 retires records under the purge's own claim |
| + // (R4-4); Plan 5 adds the purge journal's preconditions around it. |
| + if c.Op.Kind != KindDeleting && c.Op.Kind != KindPurging { |
| + return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind) |
| + } |
| + if c.Holder == nil { // a holderless purge fence (CC21) has nothing to park |
| + return fmt.Errorf("%w: no holder to park", ErrWrongState) |
| + } |
| + regions, boxes := WriteSet(c, allRegions, allBoxes) |
| + for _, r := range regions { |
| + c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-replica", Region: r, Retirement: newID(), Owner: "region:" + r}) |
| + } |
| + for _, b := range boxes { |
| + c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-local", Box: b, Retirement: newID(), Owner: "box:" + b}) |
| + } |
| + retire(c, c.Holder.Incarnation) |
| + c.Op.Kind = KindParking |
| + return nil |
| + } |
| +} |
| + |
| +// CompleteObligation removes one chore, matched by its OWN op id and id. |
| +// Discovery chores outlive the adoption that made them (§9). |
| +func CompleteObligation(obligationOp, obligationID string) func(*Claim) error { |
| + return func(c *Claim) error { |
| + idx := -1 |
| + for i, o := range c.Obligations { |
| + if o.ID == obligationID && o.Op == obligationOp { |
| + idx = i |
| + break |
| + } |
| + } |
| + if idx < 0 { |
| + return fmt.Errorf("%w: no obligation %q of op %q", ErrWrongState, obligationID, obligationOp) |
| + } |
| + c.Obligations = append(c.Obligations[:idx], c.Obligations[idx+1:]...) |
| + if c.Touched.DiscoveryOp != "" && c.Touched.DiscoveryOp == obligationOp { |
| + for _, o := range c.Obligations { |
| + if o.Op == obligationOp { |
| + return nil |
| + } |
| + } |
| + c.Touched.Complete, c.Touched.DiscoveryOp = true, "" |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +func FreeAfterPark(opID string) func(*Claim) error { |
| + return func(c *Claim) error { |
| + if err := RequireOp(opID)(c); err != nil { |
| + return err |
| + } |
| + if c.Op.Kind != KindParking || len(c.Obligations) > 0 { |
| + return fmt.Errorf("%w: kind %q, %d obligations left", ErrWrongState, c.Op.Kind, len(c.Obligations)) |
| + } |
| + c.Holder, c.Op, c.Touched, c.Inventory = nil, Op{}, Touched{}, nil |
| + return nil |
| + } |
| +} |
diff --git a/internal/hostclaim/delete_test.go b/internal/hostclaim/delete_test.go| new file mode 100644 |
| index 000000000..62ea15ecc |
| --- /dev/null |
| +++ b/internal/hostclaim/delete_test.go |
| @@ -0,0 +1,95 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func counter() func() string { |
| + n := 0 |
| + return func() string { n++; return fmt.Sprintf("id_%d", n) } |
| +} |
| + |
| +func servingClaim(t *testing.T, ctx context.Context, s release.Store) Observed { |
| + t.Helper() |
| + now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| + reg, _ := Read(ctx, s, testHost) |
| + o, err := Update(ctx, s, reg, BeginCreate(newHolder(), record, "op_c1", "console", now)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if o, err = Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting")); err != nil { |
| + t.Fatal(err) |
| + } |
| + return o // no fixture repair: CompleteCreate itself records adi-knitting in touched.boxes |
| +} |
| + |
| +func TestDeleteParkFree(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 10, 4, 2, 0, 0, 0, time.UTC) |
| + o := servingClaim(t, ctx, s) |
| + inc, stamp := o.Claim.Holder.Incarnation, o.Claim.Holder.Stamp |
| + o, err := Update(ctx, s, o, BeginDelete("op_d1", "console", `"etag1"`, now, nil, nil)) |
| + if err != nil || o.Claim.Op.Kind != KindDeleting || o.Claim.Op.Intent["etag"] != `"etag1"` { |
| + t.Fatalf("begin delete = %+v, %v", o.Claim, err) |
| + } |
| + if o.Claim.Op.Exec != "adi-knitting" || o.Claim.Holder.Stamp != stamp { |
| + t.Fatalf("delete dropped execution authority: exec %q stamp %d", o.Claim.Op.Exec, o.Claim.Holder.Stamp) |
| + } |
| + o, err = Update(ctx, s, o, ToParking("op_d1", nil, nil, counter())) |
| + if err != nil || o.Claim.Op.Kind != KindParking || len(o.Claim.Obligations) != 2 { |
| + t.Fatalf("to parking = %+v, %v", o.Claim, err) |
| + } |
| + if _, err := Update(ctx, s, o, FreeAfterPark("op_d1")); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("free with chores left = %v", err) |
| + } |
| + for _, ob := range o.Claim.Obligations { |
| + if o, err = Update(ctx, s, o, CompleteObligation(ob.Op, ob.ID)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, err := Update(ctx, s, o, CompleteObligation("op_d1", "id_1")); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("completing a chore twice = %v", err) |
| + } |
| + o, err = Update(ctx, s, o, FreeAfterPark("op_d1")) |
| + if err != nil || !o.Claim.Free() || o.Claim.RetiredIncarnations[0] != inc { |
| + t.Fatalf("free = %+v, %v", o.Claim, err) |
| + } |
| +} |
| + |
| +func TestIncompleteTouchedParksEverywhere(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 10, 4, 2, 0, 0, 0, time.UTC) |
| + o := servingClaim(t, ctx, s) |
| + o, _ = Update(ctx, s, o, func(c *Claim) error { c.Touched.Complete = false; return nil }) |
| + regions, boxes := []string{"ie", "uk", "us"}, []string{"b1", "b2"} |
| + o, _ = Update(ctx, s, o, BeginDelete("op_d1", "console", `"e"`, now, regions, boxes)) |
| + o, err := Update(ctx, s, o, ToParking("op_d1", regions, boxes, counter())) |
| + if err != nil || len(o.Claim.Obligations) != 5 { |
| + t.Fatalf("incomplete touched = %d obligations, %v; want 3 regions + 2 boxes", len(o.Claim.Obligations), err) |
| + } |
| +} |
| + |
| +func TestToParkingRefusesAPurgeFence(t *testing.T) { |
| + fence := &Claim{V: 2, Host: testHost, Gen: 3, Op: Op{Kind: KindPurging, ID: "op_p"}} |
| + if err := ToParking("op_p", nil, nil, counter())(fence); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("purge fence = %v, want ErrWrongState", err) |
| + } |
| +} |
| + |
| +func TestRetiredCapKeepsNewest32(t *testing.T) { |
| + c := &Claim{} |
| + for i := 0; i < 33; i++ { |
| + retire(c, fmt.Sprintf("inc%02d", i)) |
| + } |
| + if len(c.RetiredIncarnations) != 32 || c.RetiredIncarnations[0] != "inc32" || c.RetiredIncarnations[31] != "inc01" { |
| + t.Fatalf("retired = %v", c.RetiredIncarnations) |
| + } |
| +} |