park, hostclaim: scopes stay in their own namespace; the legacy session-0 loss identity (joint round 16 R16-1, R16-3)
An instances/... scope never lists or parks under moves/ (sealed staging beside a legacy park is untouched; staging parks only under a moves/... scope). Session 0 with generation "" is the legacy identity and is valid in both writes_lost and additional_writes_lost. Claim spec rev 6.24.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+161
−18
internal/hostclaim/generation.go+1 −1internal/hostclaim/rev713_test.go+62 −2internal/park/moves_test.go+2 −2internal/park/namespace_test.go+87 −0internal/park/remote.go+9 −10internal/park/sweep.go+0 −3
CI passed — run details
diff --git a/internal/hostclaim/generation.go b/internal/hostclaim/generation.go| index 01710b0c8..9b4ade0ff 100644 |
| --- a/internal/hostclaim/generation.go |
| +++ b/internal/hostclaim/generation.go |
| @@ -81,7 +81,7 @@ func AdmitWriter(in Admission) func(*Claim) error { |
| } |
| for _, loss := range in.AdditionalWritesLost { |
| writer := writerSession{loss.Box, loss.Session} |
| - if loss.Box == "" || loss.Session == 0 || loss.Generation == "" || seen[writer] { |
| + if loss.Box == "" || (loss.Session == 0) != (loss.Generation == "") || seen[writer] { |
| return ErrWrongState |
| } |
| seen[writer] = true |
diff --git a/internal/hostclaim/rev713_test.go b/internal/hostclaim/rev713_test.go| index 930764dc0..49e705fbb 100644 |
| --- a/internal/hostclaim/rev713_test.go |
| +++ b/internal/hostclaim/rev713_test.go |
| @@ -354,8 +354,61 @@ func TestAdmitWriterAdditionalWritesLost(t *testing.T) { |
| assertAdmissionStored(t, ctx, s, o) |
| } |
| +func TestAdoptLostTargetReturnDeclaresLegacySource(t *testing.T) { |
| + ctx := t.Context() |
| + s := release.NewDirStore(t.TempDir()) |
| + o, err := Read(ctx, s, testHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o = updateGenerationTest(t, ctx, s, o, Adopt(newHolder(), "boxS", nil, nil, "adopt", counter())) |
| + cut := WritesLost{Box: o.Claim.Holder.WriterBox, Session: o.Claim.Holder.Session, Generation: o.Claim.Holder.Generation, RemoteMaxTXID: 17} |
| + if cut.Box != "boxS" || cut.Session != 0 || cut.Generation != "" { |
| + t.Fatalf("legacy cut = %+v", cut) |
| + } |
| + o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error { |
| + if err := StartGuard(c, []string{"ie", "uk"}, []string{"boxS", "boxT"}); err != nil { |
| + return err |
| + } |
| + c.Op = Op{Kind: KindMoving, ID: "move", Phase: "cut", Exec: "boxS", Participants: []string{"boxS", "boxT"}, Journal: map[string]string{"src_covered": "false"}} |
| + return nil |
| + }) |
| + o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error { |
| + c.Op.Kind, c.Op.Phase, c.Op.Exec = KindLanding, "probe", "boxT" |
| + return nil |
| + }) |
| + o = updateGenerationTest(t, ctx, s, o, AdmitWriter(Admission{ |
| + ObservedOpID: "move", ObservedPhase: "probe", ObservedSession: cut.Session, ObservedGeneration: cut.Generation, |
| + Region: "uk", PredecessorRegion: "ie", Box: "boxT", Mode: "cold", NewGeneration: "GT", RemoteMaxTXID: cut.RemoteMaxTXID, |
| + })) |
| + target := o.Claim.Holder.Admissions[0] |
| + if !reflect.DeepEqual(target.WritesLost, &cut) { |
| + t.Fatalf("primary legacy loss = %+v, want %+v", target.WritesLost, cut) |
| + } |
| + o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error { |
| + c.Holder.Generation = cut.Generation |
| + c.Lost = append(c.Lost, "boxT") |
| + c.Op.Kind, c.Op.Phase, c.Op.Exec = KindReverting, "return", "boxS" |
| + return nil |
| + }) |
| + in := Admission{ |
| + ObservedOpID: "move", ObservedPhase: "return", ObservedSession: target.Session, ObservedGeneration: cut.Generation, |
| + Region: "ie", Box: "boxS", Mode: "cold", NewGeneration: "GS2", RemoteMaxTXID: 42, AdditionalWritesLost: []WritesLost{cut}, |
| + } |
| + o = updateGenerationTest(t, ctx, s, o, AdmitWriter(in)) |
| + last := o.Claim.Holder.Admissions[len(o.Claim.Holder.Admissions)-1] |
| + wantPrimary := &WritesLost{Box: target.Box, Session: target.Session, Generation: target.Generation, RemoteMaxTXID: 42} |
| + if !reflect.DeepEqual(last.WritesLost, wantPrimary) || !reflect.DeepEqual(last.AdditionalWritesLost, []WritesLost{cut}) { |
| + t.Fatalf("return losses = %+v, %+v", last.WritesLost, last.AdditionalWritesLost) |
| + } |
| + if _, err := Update(ctx, s, o, AdmitWriter(in)); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("repeated admission = %v", err) |
| + } |
| + assertAdmissionStored(t, ctx, s, o) |
| +} |
| + |
| func TestAdmitWriterAdditionalWritesLostValidation(t *testing.T) { |
| - for _, name := range []string{"empty box", "zero session", "empty generation", "duplicate session", "duplicate predecessor", "same box different session", "same session different box", "root generation", "warm", "sealed"} { |
| + for _, name := range []string{"empty box", "zero session", "empty generation", "duplicate session", "duplicate predecessor", "same box different session", "same session different box", "root generation", "legacy identity", "legacy empty box", "duplicate legacy", "warm", "sealed"} { |
| t.Run(name, func(t *testing.T) { |
| ctx := context.Background() |
| s := release.NewDirStore(t.TempDir()) |
| @@ -374,6 +427,11 @@ func TestAdmitWriterAdditionalWritesLostValidation(t *testing.T) { |
| loss.Box, loss.Session = o.Claim.Holder.WriterBox, o.Claim.Holder.Session |
| case "root generation": |
| loss.Generation = RootGeneration |
| + case "legacy identity", "legacy empty box", "duplicate legacy": |
| + loss.Session, loss.Generation = 0, "" |
| + if name == "legacy empty box" { |
| + loss.Box = "" |
| + } |
| case "warm": |
| in.Mode, in.NewGeneration, in.PredecessorCertified = "warm", "", true |
| in.Region = "uk" |
| @@ -382,6 +440,8 @@ func TestAdmitWriterAdditionalWritesLostValidation(t *testing.T) { |
| } |
| in.AdditionalWritesLost = []WritesLost{loss} |
| switch name { |
| + case "duplicate legacy": |
| + in.AdditionalWritesLost = append(in.AdditionalWritesLost, loss) |
| case "duplicate session", "same box different session", "same session different box": |
| second := loss |
| second.Generation, second.RemoteMaxTXID = "other", 99 |
| @@ -395,7 +455,7 @@ func TestAdmitWriterAdditionalWritesLostValidation(t *testing.T) { |
| } |
| next, err := Update(ctx, s, o, AdmitWriter(in)) |
| switch name { |
| - case "empty box", "zero session", "empty generation", "duplicate session", "duplicate predecessor": |
| + case "empty box", "zero session", "empty generation", "duplicate session", "duplicate predecessor", "legacy empty box", "duplicate legacy": |
| if !errors.Is(err, ErrWrongState) { |
| t.Fatalf("invalid loss admission = %v", err) |
| } |
diff --git a/internal/park/moves_test.go b/internal/park/moves_test.go| index 92ba9d2ea..e5932e78e 100644 |
| --- a/internal/park/moves_test.go |
| +++ b/internal/park/moves_test.go |
| @@ -45,13 +45,13 @@ func TestMovesScopeParksExactPrefixAndResumes(t *testing.T) { |
| f := movesFixture(t, op) |
| p := f.ob.Park.Location + "/" |
| expected := map[Ref]string{} |
| - for _, name := range []string{"return/data", "other-op/data"} { |
| + for _, name := range []string{"replica/0000/data", "seal.json"} { |
| for _, body := range []string{"old", "new"} { |
| id := f.b.Put("data", p+name, []byte(body)) |
| expected[Ref{p + name, id}] = body |
| } |
| } |
| - f.b.BareDelete("data", p+"return/data") |
| + f.b.BareDelete("data", p+"replica/0000/data") |
| lock := Ref{p + "lock.json", f.b.Put("data", p+"lock.json", []byte("lease"))} |
| untouched := map[Ref]string{lock: "lease"} |
| keys := []string{"instances/" + f.w.Host + "/0000/data", "instances/" + f.w.Host + "/" + f.ob.Park.Incarnation + "/live/data", "moves/other.example/move-1/data", "moves/" + f.w.Host + "-other/move-1/data"} |
diff --git a/internal/park/namespace_test.go b/internal/park/namespace_test.go| new file mode 100644 |
| index 000000000..b0094621d |
| --- /dev/null |
| +++ b/internal/park/namespace_test.go |
| @@ -0,0 +1,87 @@ |
| +package park |
| + |
| +import ( |
| + "reflect" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +func TestInstanceParkPreservesStaging(t *testing.T) { |
| + for _, legacy := range []bool{true, false} { |
| + name := "incarnation" |
| + if legacy { |
| + name = "legacy" |
| + } |
| + t.Run(name, func(t *testing.T) { |
| + f := newFixture(t) |
| + if legacy { |
| + f.ob.Park.Location = "instances/" + f.w.Host |
| + } |
| + obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { |
| + c.Holder.Location = f.ob.Park.Location |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: "move-1", Phase: "parking", Exec: "target"} |
| + c.Obligations = []hostclaim.Obligation{f.ob} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + source := f.seed() |
| + for _, op := range []string{"move-1", f.ob.Park.Incarnation} { |
| + p := "moves/" + f.w.Host + "/" + op + "/" |
| + for _, key := range []string{"replica/0000/data", "seal.json"} { |
| + f.b.Put("data", p+key, []byte("old")) |
| + f.b.Put("data", p+key, []byte("sealed")) |
| + } |
| + f.b.BareDelete("data", p+"replica/0000/data") |
| + } |
| + child := "instances/" + f.w.Host + "/" + strings.Repeat("b", 64) + "/0000/data" |
| + childID := f.b.Put("data", child, []byte("other incarnation")) |
| + staging, err := f.b.ListVersions(t.Context(), "data", "moves/") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.b.FailOn = func(action, key string) error { |
| + if action == "list" && !strings.HasPrefix(key, "parked/") && key != f.ob.Park.Location+"/" { |
| + t.Errorf("instance park listed %q", key) |
| + return ErrUnsafe |
| + } |
| + return nil |
| + } |
| + if err := f.e.Run(t.Context(), f.w); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.done(t) |
| + m, err := f.e.manifest(t.Context(), f.w, f.ob) |
| + if err != nil || m == nil || !reflect.DeepEqual(m.Delete, source) { |
| + t.Fatalf("manifest = %+v, error = %v", m, err) |
| + } |
| + f.b.FailOn = nil |
| + after, err := f.b.ListVersions(t.Context(), "data", "moves/") |
| + if err != nil || !reflect.DeepEqual(after, staging) { |
| + t.Fatalf("staging changed: %+v, error = %v", after, err) |
| + } |
| + if body, err := f.b.GetVersion(t.Context(), "data", child, childID); err != nil || string(body) != "other incarnation" { |
| + t.Fatalf("child incarnation changed: %q, error = %v", body, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestInstanceScopeExcludesMovesKeys(t *testing.T) { |
| + f := newFixture(t) |
| + for _, location := range []string{"instances/" + f.w.Host, f.ob.Park.Location} { |
| + for _, op := range []string{"move-1", f.ob.Park.Incarnation} { |
| + key := "moves/" + f.w.Host + "/" + op + "/replica/0000/data" |
| + if inScope, _ := scopeContains(f.w.Host, location, key); inScope { |
| + t.Errorf("%q includes %q", location, key) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/park/remote.go b/internal/park/remote.go| index 983d584c0..d30560c92 100644 |
| --- a/internal/park/remote.go |
| +++ b/internal/park/remote.go |
| @@ -150,16 +150,12 @@ func isMovesScope(host, location string) bool { |
| base := "moves/" + host |
| return location == base || strings.HasPrefix(location, base+"/") |
| } |
| -func prefixes(host, location string) []string { |
| - if isMovesScope(host, location) { |
| - return []string{location + "/"} |
| - } |
| - suffix := strings.TrimPrefix(location, "instances/"+host) |
| - return []string{location + "/", "moves/" + host + suffix + "/"} |
| +func prefixes(location string) []string { |
| + return []string{location + "/"} |
| } |
| // locationOf partitions legacy level/generation children from hex incarnation |
| -// children, including objects in moves/. No retired-incarnations lookup. |
| +// children. No retired-incarnations lookup. |
| func locationOf(host, key string) (string, error) { |
| for _, root := range []string{"instances/", "moves/"} { |
| base := root + host + "/" |
| @@ -170,6 +166,9 @@ func locationOf(host, key string) (string, error) { |
| if tail == "" { |
| return "", ErrUnsafe |
| } |
| + if root == "moves/" { |
| + return root + host, nil |
| + } |
| first, _, child := strings.Cut(tail, "/") |
| if child && hex64(first) { |
| return "instances/" + host + "/" + first, nil |
| @@ -186,7 +185,7 @@ func scopeContains(host, location, key string) (bool, error) { |
| return loc == location, err |
| } |
| func isLock(host, location, key string) bool { |
| - for _, p := range prefixes(host, location) { |
| + for _, p := range prefixes(location) { |
| if key == p+"lock.json" { |
| return true |
| } |
| @@ -195,7 +194,7 @@ func isLock(host, location, key string) bool { |
| } |
| func (e Engine) list(ctx context.Context, host, location string) ([]lease.ObjectVersion, error) { |
| var out []lease.ObjectVersion |
| - for _, p := range prefixes(host, location) { |
| + for _, p := range prefixes(location) { |
| vs, err := e.Store.ListVersions(ctx, e.Bucket, p) |
| if err != nil { |
| return nil, err |
| @@ -227,7 +226,7 @@ func archiveBase(host string, o hostclaim.Obligation) string { |
| return "parked/" + o.Park.Incarnation + "/" + host + "/" + o.Retirement + "/" |
| } |
| func destination(host string, o hostclaim.Obligation, v lease.ObjectVersion) string { |
| - for _, p := range prefixes(host, o.Park.Location) { |
| + for _, p := range prefixes(o.Park.Location) { |
| if strings.HasPrefix(v.Key, p) { |
| kind := strings.SplitN(p, "/", 2)[0] |
| return archiveBase(host, o) + kind + "/" + strings.TrimPrefix(v.Key, p) + "@" + url.QueryEscape(v.VersionID) |
diff --git a/internal/park/sweep.go b/internal/park/sweep.go| index 8acf33fb7..8768139c1 100644 |
| --- a/internal/park/sweep.go |
| +++ b/internal/park/sweep.go |
| @@ -137,9 +137,6 @@ func Decide(obs hostclaim.Observed, v View, versions []lease.ObjectVersion, lega |
| if err != nil || x.VersionID == "" || seen[ref(x)] { |
| return Decision{}, ErrUnsafe |
| } |
| - if strings.HasPrefix(x.Key, "moves/"+c.Host+"/") { |
| - loc = "moves/" + c.Host |
| - } |
| seen[ref(x)] = true |
| if isLock(c.Host, loc, x.Key) { |
| continue |