hostclaim: op-id guard (C2) and the start guard (one op, CC11)
Co-Authored-By: Codex (gpt-6-astra) <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+77
−0
internal/hostclaim/ops.go+44 −0internal/hostclaim/ops_test.go+33 −0
diff --git a/internal/hostclaim/ops.go b/internal/hostclaim/ops.go| new file mode 100644 |
| index 000000000..85b3e740c |
| --- /dev/null |
| +++ b/internal/hostclaim/ops.go |
| @@ -0,0 +1,44 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| +) |
| + |
| +var ( |
| + ErrStaleOp = errors.New("hostclaim: the claim is under a different operation") |
| + ErrWrongState = errors.New("hostclaim: transition not legal from the claim's state") |
| + ErrPendingChore = errors.New("hostclaim: an obligation is pending in a location this operation writes") |
| +) |
| + |
| +func RequireOp(id string) func(*Claim) error { |
| + return func(c *Claim) error { |
| + if id == "" || c.Op.ID != id { |
| + return fmt.Errorf("%w: have %q, want %q", ErrStaleOp, c.Op.ID, id) |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +// StartGuard is §4: one operation at a time, started from serving, and |
| +// CC11: never over a chore in a region or box the operation will write. |
| +func StartGuard(c *Claim, regions, boxes []string) error { |
| + if c.Holder == nil || c.Op.Kind != KindServing { |
| + return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind) |
| + } |
| + inR, inB := set(regions), set(boxes) |
| + for _, o := range c.Obligations { |
| + if (o.Region != "" && inR[o.Region]) || (o.Box != "" && inB[o.Box]) { |
| + return fmt.Errorf("%w: %s %s%s", ErrPendingChore, o.Kind, o.Region, o.Box) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func set(xs []string) map[string]bool { |
| + m := make(map[string]bool, len(xs)) |
| + for _, x := range xs { |
| + m[x] = true |
| + } |
| + return m |
| +} |
diff --git a/internal/hostclaim/ops_test.go b/internal/hostclaim/ops_test.go| new file mode 100644 |
| index 000000000..91c8396ee |
| --- /dev/null |
| +++ b/internal/hostclaim/ops_test.go |
| @@ -0,0 +1,33 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestRequireOpRefusesStale(t *testing.T) { |
| + c := &Claim{Op: Op{Kind: KindDeleting, ID: "op_new"}} |
| + if err := RequireOp("op_old")(c); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("stale op = %v", err) |
| + } |
| + if err := RequireOp("op_new")(c); err != nil { |
| + t.Fatalf("current op = %v", err) |
| + } |
| + if err := RequireOp("")(c); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("empty id must never match: %v", err) |
| + } |
| +} |
| + |
| +func TestStartGuard(t *testing.T) { |
| + h := &Holder{Incarnation: "i"} |
| + if err := StartGuard(&Claim{Holder: h, Op: Op{Kind: KindDeleting, ID: "x"}}, nil, nil); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("start over a running op = %v", err) |
| + } |
| + busy := &Claim{Holder: h, Op: Op{Kind: KindServing}, Obligations: []Obligation{{Kind: "park-replica", Region: "uk"}}} |
| + if err := StartGuard(busy, []string{"uk"}, nil); !errors.Is(err, ErrPendingChore) { |
| + t.Fatalf("start over a pending chore = %v", err) |
| + } |
| + if err := StartGuard(busy, []string{"ie"}, nil); err != nil { |
| + t.Fatalf("start elsewhere = %v", err) |
| + } |
| +} |