carlos / platform Public

moves: a clean return's source pin is journalled before the restore (Task 3 fix 2, item 4)

The activator restores before Materialize (claimwake.go), so the pin
recorded there left a crash window. The recovery's AdmissionEvidence,
the owner's last call before the admission's CAS and the restore, now
journals rc_source_baseline once its checks pass. A crash between the
restore and Materialize leaves the pin, and a restart that sees another
version of src_gen is refused. The restart tests admit through
hostbox.Writer as the wake does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9c3e2bf19031da08bab32b212588bc4180a624c1 parent 31d5b5c
2 files changed, +41 −8
  • internal/host/movesrecoveryowners.go +9 −7
  • internal/host/movesrecoveryowners_test.go +32 −1
diff --git a/internal/host/movesrecoveryowners.go b/internal/host/movesrecoveryowners.go
index c5f103503..34b0daa3d 100644
--- a/internal/host/movesrecoveryowners.go
+++ b/internal/host/movesrecoveryowners.go
@@ -440,7 +440,11 @@ func (r movesRecoveryOwners) AdmissionEvidence(ctx context.Context, o hostclaim.
if fresh.Generation != baseline.Generation || fresh.MaxTXID != baseline.MaxTXID || !reflect.DeepEqual(fresh.Files, baseline.Files) || !reflect.DeepEqual(fresh.Pin, baseline.Pin) {
return hostbox.ErrChanged
}
- return nil
+ // A clean return's source baseline is pinned here, the owner's last
+ // call before the admission's CAS and so before the activator restores
+ // it (claimwake.go Restore precedes Materialize): a crash after the
+ // restore leaves the pin a restart must match (fix round 2).
+ return r.recordSourceBaseline(o.Host, v, baseline)
}
// Materialize runs before every start of the admitted recovery probe, in the
@@ -506,9 +510,6 @@ 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 {
@@ -804,7 +805,7 @@ func (r movesRecoveryOwners) verifyContent(host string, p recoveryPlacement, wan
// 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.
+// as the first admission that restores them was evidenced.
const rcStepSourceBaseline = "rc_source_baseline"
// recoveryRooted reports whether the clean return's recovery has a root: an
@@ -880,8 +881,9 @@ func (r movesRecoveryOwners) recordedSourceBaseline(host string, v recoveryProbe
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.
+// recordSourceBaseline journals a clean return's source baseline from the
+// admission's evidence, before the admission's CAS and the restore; 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
diff --git a/internal/host/movesrecoveryowners_test.go b/internal/host/movesrecoveryowners_test.go
index ed1e62c80..766d87775 100644
--- a/internal/host/movesrecoveryowners_test.go
+++ b/internal/host/movesrecoveryowners_test.go
@@ -348,7 +348,7 @@ func TestCleanReturnProbeIsCoveredCleanReturn(t *testing.T) {
f.putClaim(c)
o := f.read()
in := f.selectAdmission(o)
- admitted := f.admit(o, in)
+ admitted := f.admitThroughWriter(o, in)
if err := hostbox.WriteGenerationMarker(f.route.DBPath+".gen", "fresh1"); err != nil {
t.Fatal(err)
}
@@ -392,6 +392,25 @@ func TestCleanReturnProbeIsCoveredCleanReturn(t *testing.T) {
t.Fatalf("another version of src_gen: %v", err)
}
})
+ // Fix round 2: the source baseline is persisted before the restore (the
+ // activator restores before Materialize), at the admission's evidence,
+ // so a crash between the restore and Materialize still pins it.
+ t.Run("a crash between the restore and Materialize keeps the pin", func(t *testing.T) {
+ f := newRCFixture(t, rcClean)
+ c := f.recoveryClaim()
+ c.Holder.Admissions[1].Covered = true
+ f.putClaim(c)
+ ctx := t.Context()
+ o := f.read()
+ in := f.selectAdmission(o)
+ admitted := f.admitThroughWriter(o, in)
+ // The activator restores in.Baseline here; the box crashes before
+ // Materialize. src_gen then shows another version.
+ f.bucket.Put("data", "instances/"+poHost+"/g-src/0009/"+ltx.FormatFilename(1, 30), f.ltxHeader(1, 30))
+ if _, err := f.owners.Select(ctx, f.route, admitted, f.policy()); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("the restart after the crash: %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) {
@@ -820,3 +839,15 @@ func TestRecoveryPublicationVerifiesThePlacedTreeBeforeRelabelling(t *testing.T)
})
}
}
+
+// admitThroughWriter admits through the claim's writer, as the wake does:
+// the owners' AdmissionEvidence, then the CAS.
+func (f *rcFixture) admitThroughWriter(o hostclaim.Observed, in ClaimLaunchInputs) hostclaim.Observed {
+ f.t.Helper()
+ w := hostbox.Writer{Claims: f.claims, Data: f.bucket, Bucket: "data", Region: "ie", Box: "ie-box", AdmissionEvidence: f.owners.AdmissionEvidence}
+ admitted, err := w.Admit(f.t.Context(), o, in.Admission, in.Baseline)
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ return admitted
+}