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>
2 files changed,
+41
−8
internal/host/movesrecoveryowners.go+9 −7internal/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 |
| +} |