hostclaim: cold recovery installs a new generation and stamp (§6.4a, R7-1)
Co-Authored-By: Codex (gpt-6-astra) <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+54
−0
internal/hostclaim/generation.go+24 −0internal/hostclaim/generation_test.go+30 −0
diff --git a/internal/hostclaim/generation.go b/internal/hostclaim/generation.go| new file mode 100644 |
| index 000000000..0a82cd8c1 |
| --- /dev/null |
| +++ b/internal/hostclaim/generation.go |
| @@ -0,0 +1,24 @@ |
| +package hostclaim |
| + |
| +import "fmt" |
| + |
| +// NewGeneration is §6.4a's cold recovery: an installing CAS that gives a |
| +// writer whose predecessor may still have uploads in flight a fresh |
| +// generation, and with it a fresh stamp (§2, R6-4). It is never a |
| +// same-generation restart. |
| +func NewGeneration(observedGeneration, newGeneration, execBox string) func(*Claim) error { |
| + return func(c *Claim) error { |
| + if c.Holder == nil || c.Op.Kind != KindServing || c.Op.Exec == "" || c.Op.Exec != execBox { |
| + return fmt.Errorf("%w: only the serving executor may start a generation", ErrWrongState) |
| + } |
| + if c.Holder.Generation != observedGeneration { |
| + return fmt.Errorf("%w: generation moved since it was observed", ErrWrongState) |
| + } |
| + if newGeneration == "" || newGeneration == c.Holder.Generation { |
| + return fmt.Errorf("%w: a cold recovery needs a new generation", ErrWrongState) |
| + } |
| + c.Holder.Generation = newGeneration |
| + c.Holder.Stamp = c.Gen + 1 // the resulting gen |
| + return nil |
| + } |
| +} |
diff --git a/internal/hostclaim/generation_test.go b/internal/hostclaim/generation_test.go| new file mode 100644 |
| index 000000000..9b3a320b3 |
| --- /dev/null |
| +++ b/internal/hostclaim/generation_test.go |
| @@ -0,0 +1,30 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestNewGenerationInstallsOnce(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + o := servingClaim(t, ctx, s) |
| + before := o.Claim.Holder.Stamp |
| + o2, err := Update(ctx, s, o, NewGeneration("", "gen_a", "adi-knitting")) |
| + if err != nil || o2.Claim.Holder.Generation != "gen_a" || o2.Claim.Holder.Stamp != o2.Claim.Gen || o2.Claim.Holder.Stamp <= before { |
| + t.Fatalf("new generation = %+v, %v", o2.Claim.Holder, err) |
| + } |
| + // A second recovery that observed the old generation must not install. |
| + if _, err := Update(ctx, s, o2, NewGeneration("", "gen_b", "adi-knitting")); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("stale recovery = %v, want ErrWrongState", err) |
| + } |
| + if _, err := Update(ctx, s, o2, NewGeneration("gen_a", "gen_c", "some-other-box")); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("recovery by a non-executor = %v, want ErrWrongState", err) |
| + } |
| + if _, err := Update(ctx, s, o2, NewGeneration("gen_a", "gen_a", "adi-knitting")); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("same-generation restart = %v, want ErrWrongState", err) |
| + } |
| +} |