carlos / platform Public

moves: a clean return restarts from src_gen until its first root (Task 3 fix 1, ruling 4)

AdmitWriter records the target's last admission as the first recovery
admission's predecessor although a clean return restores src_gen, so with
that admission covered a restart that published no root selected through
the chain to ErrCovered. While no generation the recovery admitted (this
box, this region, after the cut's session) has an object, a clean
return's Select restores the source baseline: src_gen as the first
admission selected it, and once recorded (rc_source_baseline in the
rollback journal, before the first start) exactly those files by version.
After the recovery's first root ordinary selection resumes. hostclaim is
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 105d95cf631a33e64de95301d429b9ff5918e6ad parent b23cd3c
2 files changed, +180 −2
  • internal/host/movesrecoveryowners.go +123 −2
  • internal/host/movesrecoveryowners_test.go +57 −0
diff --git a/internal/host/movesrecoveryowners.go b/internal/host/movesrecoveryowners.go
index fa6e582e9..ec5eadfc3 100644
--- a/internal/host/movesrecoveryowners.go
+++ b/internal/host/movesrecoveryowners.go
@@ -9,6 +9,7 @@ import (
"path/filepath"
"reflect"
"strconv"
+ "strings"
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/appconfig"
@@ -105,6 +106,8 @@ type recoveryProbe struct {
dirty, uncovered bool
srcGen string
predecessor string
+ // cut is the cut's journalled session (op.journal.cut_session).
+ cut string
// A dirty return's: the rollback id (its generation), the return seal
// and the install manifest its install journalled.
rb string
@@ -169,7 +172,7 @@ func (r movesRecoveryOwners) recovery(ctx context.Context, o hostclaim.Observed)
if err != nil || stamp == 0 || !journalled {
return v, fmt.Errorf("%w: the source's stamp and generation are not journalled", hostbox.ErrChanged)
}
- v.first, v.srcGen = v.holder.Stamp == stamp, srcGen
+ v.first, v.srcGen, v.cut = v.holder.Stamp == stamp, srcGen, c.Op.Journal[hostclaim.JournalCutSession]
rec, err := m.Records(ctx, v.holder.Account, v.holder.App, o.Host)
if err != nil {
return v, err
@@ -318,9 +321,20 @@ func (r movesRecoveryOwners) admission(ctx context.Context, o hostclaim.Observed
// baseline is a fresh generation's baseline: the certified remote; for a
// dirty return's restart while the location's marker is not yet the return
// generation's (nor a generation descending from it on this box), the
-// return generation's pin (H14).
+// return generation's pin (H14); for a clean return while the recovery has
+// no root, the recorded source baseline (sourceBaseline, fix round 1 ruling
+// 4).
func (r movesRecoveryOwners) baseline(ctx context.Context, host string, v recoveryProbe) (*genselect.Plan, error) {
m := r.m
+ if v.kind == hostclaim.KindRestoring && !v.dirty {
+ rooted, err := r.recoveryRooted(ctx, host, v)
+ if err != nil {
+ return nil, err
+ }
+ if !rooted {
+ return r.sourceBaseline(ctx, host, v)
+ }
+ }
if v.dirty {
digest, err := manifestDigest(v.manifest)
if err != nil {
@@ -492,6 +506,9 @@ func (r movesRecoveryOwners) Materialize(ctx context.Context, o hostclaim.Observ
if err := r.recordPlacement(ctx, o.Host, v, work, in.Identity); err != nil {
return err
}
+ if err := r.recordSourceBaseline(o.Host, v, in.Baseline); err != nil {
+ return err
+ }
if v.dirty {
receipt, err := m.preparedMarker(ctx, o.Host, v.prepared(), fence)
if err != nil {
@@ -776,3 +793,107 @@ func (r movesRecoveryOwners) verifyContent(host string, p recoveryPlacement, wan
}
return nil
}
+
+// rcStepSourceBaseline is the rollback journal's record of a clean return's
+// source baseline (JSON genselect.Plan): src_gen's files by exact version,
+// as the recovery's first start restored them.
+const rcStepSourceBaseline = "rc_source_baseline"
+
+// recoveryRooted reports whether the clean return's recovery has a root: an
+// object under a generation one of its admissions made (this box, this
+// region, a session after the cut's). Before it has, ordinary selection
+// would follow the claim's predecessor chain through the target's admission
+// (AdmitWriter records it as the first recovery admission's predecessor,
+// though the restore was from src_gen) and can end in a covered baseline.
+func (r movesRecoveryOwners) recoveryRooted(ctx context.Context, host string, v recoveryProbe) (bool, error) {
+ m := r.m
+ h := v.holder
+ cut, err := strconv.ParseUint(v.cut, 10, 64)
+ if err != nil {
+ return false, fmt.Errorf("%w: the cut's session is not journalled", hostbox.ErrChanged)
+ }
+ location := strings.TrimSuffix(h.Location, "/")
+ for _, a := range h.Admissions {
+ if a.Box != m.Box || a.Session <= cut || a.Generation == "" || !park.SameRegion(m.HomeRegion, a.Region, m.Region) {
+ continue
+ }
+ prefix := location + "/g-" + a.Generation + "/"
+ versions, err := m.Data.ListVersions(ctx, m.Bucket, prefix)
+ if err != nil {
+ return false, err
+ }
+ for _, ver := range versions {
+ if strings.HasPrefix(ver.Key, prefix) && ver.IsLatest && !ver.DeleteMarker {
+ return true, nil
+ }
+ }
+ }
+ return false, nil
+}
+
+// sourceBaseline is a clean return's restore point before the recovery has
+// a root: src_gen in this region, selected as the first admission selected
+// it (the holder viewed at src_gen, whose marker names it), and, once
+// recorded, exactly the recorded files by version.
+func (r movesRecoveryOwners) sourceBaseline(ctx context.Context, host string, v recoveryProbe) (*genselect.Plan, error) {
+ m := r.m
+ view := *v.holder
+ view.Generation = v.srcGen
+ plan, err := genselect.Select(ctx, m.Data, m.request(host, &view))
+ if err != nil {
+ return nil, err
+ }
+ if plan.Generation != v.srcGen {
+ return nil, fmt.Errorf("%w: the source baseline is not src_gen", hostbox.ErrChanged)
+ }
+ recorded, ok, err := r.recordedSourceBaseline(host, v)
+ if err != nil {
+ return nil, err
+ }
+ if ok && (recorded.Generation != plan.Generation || recorded.MaxTXID != plan.MaxTXID || !reflect.DeepEqual(recorded.Files, plan.Files) || !reflect.DeepEqual(recorded.Pin, plan.Pin)) {
+ return nil, fmt.Errorf("%w: src_gen is not the recorded source baseline", hostbox.ErrChanged)
+ }
+ return plan, nil
+}
+
+func (r movesRecoveryOwners) recordedSourceBaseline(host string, v recoveryProbe) (genselect.Plan, bool, error) {
+ j, err := moves.ReadJournal(r.m.DataDir, host, v.journalOp())
+ if err != nil {
+ return genselect.Plan{}, false, err
+ }
+ raw, ok := j.Steps[rcStepSourceBaseline]
+ if !ok {
+ return genselect.Plan{}, false, nil
+ }
+ var p genselect.Plan
+ if err := json.Unmarshal([]byte(raw), &p); err != nil || p.Generation == "" || len(p.Files) == 0 {
+ return genselect.Plan{}, false, fmt.Errorf("%w: the recorded source baseline does not read (%v)", hostbox.ErrChanged, err)
+ }
+ return p, true, nil
+}
+
+// recordSourceBaseline journals a clean return's source baseline before the
+// first start that restores it; once recorded it is never rewritten.
+func (r movesRecoveryOwners) recordSourceBaseline(host string, v recoveryProbe, plan *genselect.Plan) error {
+ if v.kind != hostclaim.KindRestoring || v.dirty || plan == nil || plan.Generation != v.srcGen {
+ return nil
+ }
+ m := r.m
+ body, err := json.Marshal(genselect.Plan{Generation: plan.Generation, Files: plan.Files, Root: plan.Root, Pin: plan.Pin, MaxTXID: plan.MaxTXID, MaxLevel0TXID: plan.MaxLevel0TXID})
+ if err != nil {
+ return err
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ err = moves.UpdateJournal(m.DataDir, host, v.journalOp(), func(j *moves.Journal) error {
+ if _, ok := j.Steps[rcStepSourceBaseline]; ok {
+ return errJournalUnchanged
+ }
+ j.Mark(rcStepSourceBaseline, string(body))
+ return nil
+ })
+ if errors.Is(err, errJournalUnchanged) {
+ return nil
+ }
+ return err
+}
diff --git a/internal/host/movesrecoveryowners_test.go b/internal/host/movesrecoveryowners_test.go
index 88a9caec7..470cb8532 100644
--- a/internal/host/movesrecoveryowners_test.go
+++ b/internal/host/movesrecoveryowners_test.go
@@ -335,6 +335,63 @@ func TestCleanReturnProbeIsCoveredCleanReturn(t *testing.T) {
t.Fatal(err)
}
})
+ // Fix round 1, ruling (4): AdmitWriter records the target's last
+ // admission as the recovery admission's predecessor, so with that
+ // admission covered the ordinary selection of a restart that published
+ // no root ends in ErrCovered. Until the recovery has a root, a clean
+ // return's restart restores the recorded source baseline (src_gen, by
+ // exact version); after its first root ordinary selection resumes.
+ restart := func(t *testing.T) (*rcFixture, hostclaim.Observed, ClaimLaunchInputs) {
+ f := newRCFixture(t, rcClean)
+ c := f.recoveryClaim()
+ c.Holder.Admissions[1].Covered = true
+ f.putClaim(c)
+ o := f.read()
+ in := f.selectAdmission(o)
+ admitted := f.admit(o, in)
+ if err := hostbox.WriteGenerationMarker(f.route.DBPath+".gen", "fresh1"); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.owners.Materialize(t.Context(), admitted, in, poWork{action: "spawn", f: f.poFixture}); err != nil {
+ t.Fatal(err)
+ }
+ return f, admitted, in
+ }
+ t.Run("a restart before the first root restores src_gen", func(t *testing.T) {
+ f, admitted, first := restart(t)
+ ctx := t.Context()
+ again, err := f.owners.Select(ctx, f.route, admitted, f.policy())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a := again.Admission; a.NewGeneration != "fresh2" || a.Coverage != "" || a.ObservedGeneration != "fresh1" || a.RemoteMaxTXID != 30 || again.Prepared ||
+ again.Baseline.Generation != "src" || !reflect.DeepEqual(again.Baseline.Files, first.Baseline.Files) {
+ t.Fatalf("restart: %+v baseline %+v", a, again.Baseline)
+ }
+ if err := f.owners.AdmissionEvidence(ctx, admitted, again.Admission, again.Baseline); err != nil {
+ t.Fatal(err)
+ }
+ restarted := f.admit(admitted, again)
+ // The recovery's first root: ordinary selection resumes.
+ f.bucket.Put("data", "instances/"+poHost+"/g-fresh2/0009/"+ltx.FormatFilename(1, 31), f.ltxHeader(1, 31))
+ third, err := f.owners.Select(ctx, f.route, restarted, f.policy())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if third.Baseline.Generation != "fresh2" || third.Admission.RemoteMaxTXID != 31 || third.Admission.NewGeneration != "fresh3" {
+ t.Fatalf("after the first root: %+v baseline %+v", third.Admission, third.Baseline)
+ }
+ if err := f.owners.AdmissionEvidence(ctx, restarted, third.Admission, third.Baseline); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Run("the source baseline is the recorded one, by exact version", func(t *testing.T) {
+ f, admitted, _ := restart(t)
+ f.bucket.Put("data", "instances/"+poHost+"/g-src/0009/"+ltx.FormatFilename(1, 30), f.ltxHeader(1, 30))
+ if _, err := f.owners.Select(t.Context(), f.route, admitted, f.policy()); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("another version of src_gen: %v", err)
+ }
+ })
// A declared or fenced return (Q6) asks no coverage: the claim journals
// the fence and the record carries it as its loss.
t.Run("a fenced return asks no coverage", func(t *testing.T) {