hostbox: a loss admission's restore point is the generation's maximum in the writer's own bucket (moves Task 4)
admitLoss and admitFencedLoss read the restored generation's maximum with genselect.GenerationMax, the rule moves' loss baseline validator reads, before the signed record and the loss sources. For platform-1e's review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+133
−9
internal/hostbox/lossrestorepoint_test.go+93 −0internal/hostbox/plan12_fence_loss_test.go+1 −1internal/hostbox/plan12_loss_test.go+1 −1internal/hostbox/writer_loss.go+30 −6internal/hostops/plan12_fence_test.go+8 −1
diff --git a/internal/hostbox/lossrestorepoint_test.go b/internal/hostbox/lossrestorepoint_test.go| new file mode 100644 |
| index 000000000..7a98719b6 |
| --- /dev/null |
| +++ b/internal/hostbox/lossrestorepoint_test.go |
| @@ -0,0 +1,93 @@ |
| +//go:build unix |
| + |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/superfly/ltx" |
| +) |
| + |
| +// restorePointData is a writer's own data bucket, "data", holding one |
| +// snapshot [1, max] of generation gen ("" is the location's own files) under |
| +// location: the restore point a loss admission's Select chose. |
| +func restorePointData(location, gen string, max uint64) *leasetest.VersionedBucket { |
| + b := leasetest.NewVersionedBucket() |
| + b.AddBucket("data") |
| + base := strings.TrimSuffix(location, "/") + "/" |
| + if gen != "" && gen != hostclaim.RootGeneration { |
| + base += "g-" + gen + "/" |
| + } |
| + b.Put("data", base+"0009/"+ltx.FormatFilename(1, ltx.TXID(max)), []byte("snapshot")) |
| + return b |
| +} |
| + |
| +type unreadableData struct{ genselect.Reader } |
| + |
| +func (unreadableData) ListVersions(context.Context, string, string) ([]lease.ObjectVersion, error) { |
| + return nil, errors.New("access denied") |
| +} |
| + |
| +// moves Task 4 (platform-1e's answer on #236, Q5): the loss admission's |
| +// restore point is the restored generation's maximum in the writer's own |
| +// bucket, read by genselect.GenerationMax, the rule moves' loss baseline |
| +// validator reads too. Select's plan alone no longer vouches for it: a |
| +// remote that moved past the plan, or one the writer cannot read, refuses |
| +// the admission before the loss sources run and before the claim CAS. |
| +func TestLossRestorePointIsTheGenerationMax(t *testing.T) { |
| + for _, path := range []string{"declared", "fenced"} { |
| + for _, mode := range []string{"moved", "unreadable", "no-data"} { |
| + t.Run(path+"/"+mode, func(t *testing.T) { |
| + _, store, o := workFixture(t) |
| + o, err := hostclaim.Update(t.Context(), store, o, func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindRestoring, ID: "op", Phase: hostclaim.PhaseProbing, Exec: "box", Participants: []string{"box"}, Intent: map[string]string{"target": "target"}} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + location := o.Claim.Holder.Location |
| + w := Writer{Claims: store, Box: "box", Region: "ie", Bucket: "data", Data: restorePointData(location, "src", 5), |
| + AdmissionEvidence: func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error { |
| + t.Fatal("the evidence ran") |
| + return nil |
| + }} |
| + sources := 0 |
| + w.Loss = &LossAdmission{PublicKey: make([]byte, 32), Owner: &plan12LossOwner{}, Sources: func(context.Context, hostclaim.Observed, hostclaim.LossRecord, hostclaim.Admission, *genselect.Plan) ([]hostclaim.ValidatedWriteSource, error) { |
| + sources++ |
| + return nil, nil |
| + }} |
| + switch mode { |
| + case "unreadable": |
| + w.Data = unreadableData{w.Data} |
| + case "no-data": |
| + w.Data = nil |
| + } |
| + in := hostclaim.Admission{Box: "box", Mode: "cold", Region: "ie", RemoteMaxTXID: 3} |
| + baseline := &genselect.Plan{Generation: "src", MaxTXID: 3} |
| + if path == "declared" { |
| + _, err = w.admitLoss(t.Context(), o, in, baseline, "loss_1") |
| + } else { |
| + _, err = w.admitFencedLoss(t.Context(), o, in, baseline, hostclaim.LossRecord{}) |
| + } |
| + if mode == "no-data" { |
| + if !errors.Is(err, ErrUnsupported) { |
| + t.Fatalf("no own bucket: %v", err) |
| + } |
| + } else if !errors.Is(err, ErrChanged) { |
| + t.Fatalf("refused with %v", err) |
| + } |
| + if sources != 0 { |
| + t.Fatal("the loss sources ran before the restore point held") |
| + } |
| + }) |
| + } |
| + } |
| +} |
diff --git a/internal/hostbox/plan12_fence_loss_test.go b/internal/hostbox/plan12_fence_loss_test.go| index 6e1c7c0e1..ed6ee9da6 100644 |
| --- a/internal/hostbox/plan12_fence_loss_test.go |
| +++ b/internal/hostbox/plan12_fence_loss_test.go |
| @@ -76,7 +76,7 @@ func TestPlan12FencedTargetLossMaximum(t *testing.T) { |
| t.Fatal(err) |
| } |
| in := hostclaim.Admission{ObservedOpID: "move", ObservedPhase: o.Claim.Op.Phase, ObservedSession: 2, ObservedGeneration: o.Claim.Holder.Generation, Box: "box", Mode: "cold", NewGeneration: "return", Region: "ie", PredecessorRegion: "uk", RemoteMaxTXID: 3} |
| - w := Writer{Claims: s, Box: "box", Region: "ie", AdmissionEvidence: func(_ context.Context, _ hostclaim.Observed, a hostclaim.Admission, p *genselect.Plan) error { |
| + w := Writer{Claims: s, Box: "box", Region: "ie", Bucket: "data", Data: restorePointData(o.Claim.Holder.Location, "", 3), AdmissionEvidence: func(_ context.Context, _ hostclaim.Observed, a hostclaim.Admission, p *genselect.Plan) error { |
| if a.RemoteMaxTXID != 7 || p.MaxTXID != 3 { |
| t.Fatalf("loss maximum %d restore maximum %d", a.RemoteMaxTXID, p.MaxTXID) |
| } |
diff --git a/internal/hostbox/plan12_loss_test.go b/internal/hostbox/plan12_loss_test.go| index 5fcaf8a0e..20fa88936 100644 |
| --- a/internal/hostbox/plan12_loss_test.go |
| +++ b/internal/hostbox/plan12_loss_test.go |
| @@ -76,7 +76,7 @@ func plan12LossAdmissionCases(t *testing.T, modes []string) { |
| t.Fatal(err) |
| } |
| owner := &plan12LossOwner{} |
| - w := Writer{Claims: store, Box: "box", Region: "ie", AdmissionEvidence: func(_ context.Context, obs hostclaim.Observed, admission hostclaim.Admission, baseline *genselect.Plan) error { |
| + w := Writer{Claims: store, Box: "box", Region: "ie", Bucket: "data", Data: restorePointData(o.Claim.Holder.Location, "", 3), AdmissionEvidence: func(_ context.Context, obs hostclaim.Observed, admission hostclaim.Admission, baseline *genselect.Plan) error { |
| if baseline == nil || baseline.MaxTXID != 3 { |
| t.Fatal("restore baseline changed", baseline) |
| } |
diff --git a/internal/hostbox/writer_loss.go b/internal/hostbox/writer_loss.go| index 5e6317fc1..60bf96bc4 100644 |
| --- a/internal/hostbox/writer_loss.go |
| +++ b/internal/hostbox/writer_loss.go |
| @@ -3,6 +3,8 @@ package hostbox |
| import ( |
| "context" |
| "crypto/ed25519" |
| + "fmt" |
| + |
| "github.com/carlosframework/platform/internal/genselect" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| ) |
| @@ -24,6 +26,10 @@ func (w Writer) admitLoss(ctx context.Context, o hostclaim.Observed, in hostclai |
| if o.Claim.Op.Kind != hostclaim.KindRestoring || o.Claim.Op.Phase != hostclaim.PhaseProbing || in.Coverage != "" || in.KeepStamp || (in.LossID != "" && in.LossID != id) { |
| return hostclaim.Observed{}, hostclaim.ErrLossRecord |
| } |
| + // Select validates the restore point; loss sources describe independent baselines. |
| + if err := w.restorePoint(ctx, o, in, baseline); err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| loss, err := hostclaim.ReadLossRecord(ctx, w.Claims, w.Loss.PublicKey, id) |
| if err != nil { |
| return hostclaim.Observed{}, err |
| @@ -40,10 +46,6 @@ func (w Writer) admitLoss(ctx context.Context, o hostclaim.Observed, in hostclai |
| if a := o.Claim.Holder.Admissions; len(a) > 0 { |
| p = a[len(a)-1] |
| } |
| - // Select validates the restore point; loss sources describe independent baselines. |
| - if baseline == nil || baseline.MaxTXID != in.RemoteMaxTXID { |
| - return hostclaim.Observed{}, ErrChanged |
| - } |
| in.AdditionalWritesLost = nil |
| for _, s := range sources { |
| if s.Box == p.Box && s.Session == p.Session && s.Generation == p.Generation { |
| @@ -62,8 +64,8 @@ func (w Writer) admitFencedLoss(ctx context.Context, o hostclaim.Observed, in ho |
| if w.Loss == nil || w.Loss.Sources == nil { |
| return hostclaim.Observed{}, hostclaim.ErrLossOwnerRequired |
| } |
| - if baseline == nil || baseline.MaxTXID != in.RemoteMaxTXID { |
| - return hostclaim.Observed{}, ErrChanged |
| + if err := w.restorePoint(ctx, o, in, baseline); err != nil { |
| + return hostclaim.Observed{}, err |
| } |
| sources, err := w.Loss.Sources(ctx, o, record, in, baseline) |
| if err != nil { |
| @@ -78,3 +80,25 @@ func (w Writer) admitFencedLoss(ctx context.Context, o hostclaim.Observed, in ho |
| } |
| return hostclaim.AdmitWriterWithFencedTargetLoss(ctx, w.Claims, o, in, sources) |
| } |
| + |
| +// restorePoint is a loss admission's restore point: Select's plan, whose |
| +// maximum must be the restored generation's maximum in this writer's own |
| +// bucket, read by genselect.GenerationMax, the one rule moves' loss baseline |
| +// validator reads (moves Task 4, platform-1e on #236). No other region's |
| +// bucket is read. |
| +func (w Writer) restorePoint(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, baseline *genselect.Plan) error { |
| + if baseline == nil || baseline.MaxTXID != in.RemoteMaxTXID { |
| + return ErrChanged |
| + } |
| + if w.Data == nil || w.Bucket == "" { |
| + return ErrUnsupported |
| + } |
| + max, err := genselect.GenerationMax(ctx, w.Data, w.Bucket, o.Claim.Holder.Location, baseline.Generation) |
| + if err != nil { |
| + return fmt.Errorf("%w: the restore point's generation: %w", ErrChanged, err) |
| + } |
| + if max != in.RemoteMaxTXID { |
| + return fmt.Errorf("%w: the restore point is not the generation's maximum %d", ErrChanged, max) |
| + } |
| + return nil |
| +} |
diff --git a/internal/hostops/plan12_fence_test.go b/internal/hostops/plan12_fence_test.go| index 145040314..cf2d2850e 100644 |
| --- a/internal/hostops/plan12_fence_test.go |
| +++ b/internal/hostops/plan12_fence_test.go |
| @@ -8,10 +8,12 @@ import ( |
| "github.com/carlosframework/platform/internal/genselect" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/termfence" |
| + "github.com/superfly/ltx" |
| "strings" |
| "testing" |
| ) |
| @@ -280,7 +282,12 @@ func plan12GrantLossMaximum(t *testing.T, w *c7World) { |
| } |
| in := hostclaim.Admission{ObservedOpID: o.Claim.Op.ID, ObservedPhase: o.Claim.Op.Phase, ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, Box: source, Mode: "cold", NewGeneration: "recovered", Region: "uk", PredecessorRegion: "ie", RemoteMaxTXID: 3} |
| checked := false |
| - writer := hostbox.Writer{Claims: w.raw, Box: source, Region: "uk", AdmissionEvidence: func(_ context.Context, _ hostclaim.Observed, a hostclaim.Admission, p *genselect.Plan) error { |
| + // The restore point is src's maximum in the source's own bucket |
| + // (genselect.GenerationMax, moves Task 4). |
| + data := leasetest.NewVersionedBucket() |
| + data.AddBucket("data") |
| + data.Put("data", strings.TrimSuffix(o.Claim.Holder.Location, "/")+"/g-src/0009/"+ltx.FormatFilename(1, 3), []byte("snapshot")) |
| + writer := hostbox.Writer{Claims: w.raw, Box: source, Region: "uk", Bucket: "data", Data: data, AdmissionEvidence: func(_ context.Context, _ hostclaim.Observed, a hostclaim.Admission, p *genselect.Plan) error { |
| checked = true |
| if a.RemoteMaxTXID != 7 || p.MaxTXID != 3 { |
| t.Fatalf("loss=%d restore=%d", a.RemoteMaxTXID, p.MaxTXID) |