genselect: GenerationMax reads the restore plan, not the bucket; the restore point and frozenMaximum switch to it (Task 4 loss-boundary rule, platform-1e items 1 and 2)
GenerationMax(plan, location, gen) is the highest TXID of that exact generation among the plan's selected files: 0 and nil for a generation the plan holds none of, the legacy root spelling normalised, a selected historical pin counted because the plan holds it, no listing. Writer.restorePoint and movesRecoveryOwners.frozenMaximum keep the restore-input check and the recorded-baseline binding and read the plan; neither reads a bucket. Fixtures that supplied only Plan.MaxTXID now carry selected files. One commit because the signature couples items 1 and 2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+176
−266
internal/genselect/generationmax_test.go+66 −90internal/genselect/select.go+20 −76internal/host/movesloss.go+12 −12internal/host/movesloss_test.go+19 −21internal/hostbox/lossrestorepoint_test.go+42 −45internal/hostbox/plan12_fence_loss_test.go+2 −2internal/hostbox/plan12_loss_test.go+2 −2internal/hostbox/writer_loss.go+8 −10internal/hostops/plan12_fence_test.go+5 −8
diff --git a/internal/genselect/generationmax_test.go b/internal/genselect/generationmax_test.go| index a60bcfef4..74b786dbb 100644 |
| --- a/internal/genselect/generationmax_test.go |
| +++ b/internal/genselect/generationmax_test.go |
| @@ -7,16 +7,15 @@ import ( |
| "testing" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| - "github.com/carlosframework/platform/internal/lease" |
| ) |
| -// GenerationMax is the one rule for a generation's remote maximum in a box's |
| -// own bucket (moves Task 4, platform-1e's answer on #236): the highest |
| -// maximum among the generation's current level files, read as Select reads |
| -// them, so a selectable generation's maximum is Select's. |
| +// GenerationMax is the loss boundary of one generation in a restore |
| +// (platform-1e's ruling on #236): the highest TXID of that exact generation |
| +// among the restore plan's selected files. It never lists the bucket; a |
| +// generation the plan does not contain is 0, every write in it lost. |
| func TestGenerationMax(t *testing.T) { |
| ctx := context.Background() |
| - t.Run("the marker's generation is Select's maximum", func(t *testing.T) { |
| + t.Run("the plan's own generation is Select's maximum", func(t *testing.T) { |
| f := newFixture(t) |
| f.file("g0", 1, 1, 14) |
| f.file("g0", 0, 12, 14) |
| @@ -24,125 +23,102 @@ func TestGenerationMax(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - got, err := GenerationMax(ctx, f.store, "data", f.req.Holder.Location, "g0", nil) |
| + got, err := GenerationMax(plan, f.req.Holder.Location, "g0") |
| if err != nil || got != 14 || got != plan.MaxTXID { |
| t.Fatalf("GenerationMax %d %v, Select %d", got, err, plan.MaxTXID) |
| } |
| + if got, err := GenerationMax(plan, f.req.Holder.Location+"/", "g0"); err != nil || got != 14 { |
| + t.Fatalf("a trailing slash: %d %v", got, err) |
| + } |
| }) |
| - t.Run("one generation's files only", func(t *testing.T) { |
| + t.Run("a generation the plan does not contain is 0", func(t *testing.T) { |
| f := newFixture(t) |
| - f.file("g1", 0, 1, 99) |
| - got, err := GenerationMax(ctx, f.store, "data", f.req.Holder.Location+"/", "g0", nil) |
| - if err != nil || got != 11 { |
| - t.Fatalf("g0 %d %v", got, err) |
| + f.file("g1", 0, 1, 99) // in the bucket, not in the restore |
| + plan, err := Select(ctx, f.store, f.req) |
| + if err != nil { |
| + t.Fatal(err) |
| } |
| - if got, err = GenerationMax(ctx, f.store, "data", f.req.Holder.Location, "g1", nil); err != nil || got != 99 { |
| - t.Fatalf("g1 %d %v", got, err) |
| + for _, gen := range []string{"g1", "t2", hostclaim.RootGeneration, ""} { |
| + if got, err := GenerationMax(plan, f.req.Holder.Location, gen); err != nil || got != 0 { |
| + t.Fatalf("%q: %d %v", gen, got, err) |
| + } |
| } |
| }) |
| - t.Run("the origin is the location's own level files", func(t *testing.T) { |
| + t.Run("never a fresh listing", func(t *testing.T) { |
| f := newFixture(t) |
| + plan, err := Select(ctx, f.store, f.req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.file("g0", 0, 12, 40) // written after the selection |
| + if got, err := GenerationMax(plan, f.req.Holder.Location, "g0"); err != nil || got != 11 { |
| + t.Fatalf("%d %v", got, err) |
| + } |
| + }) |
| + t.Run("the legacy root spelling", func(t *testing.T) { |
| + f := newFixture(t) |
| + f.origin() |
| f.file(hostclaim.RootGeneration, 9, 1, 7) |
| f.file(hostclaim.RootGeneration, 0, 8, 8) |
| + plan, err := Select(ctx, f.store, f.req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if plan.Generation != hostclaim.RootGeneration { |
| + t.Fatalf("selected %q", plan.Generation) |
| + } |
| for _, gen := range []string{hostclaim.RootGeneration, ""} { |
| - if got, err := GenerationMax(ctx, f.store, "data", f.req.Holder.Location, gen, nil); err != nil || got != 8 { |
| + if got, err := GenerationMax(plan, f.req.Holder.Location, gen); err != nil || got != 8 { |
| t.Fatalf("%q: %d %v", gen, got, err) |
| } |
| } |
| - }) |
| - t.Run("a retention tombstone is not a current file", func(t *testing.T) { |
| - f := newFixture(t) |
| - late := f.file("g0", 0, 12, 12) |
| - f.b.BareDelete("data", late.Key) |
| - if got, err := GenerationMax(ctx, f.store, "data", f.req.Holder.Location, "g0", nil); err != nil || got != 11 { |
| - t.Fatalf("%d %v", got, err) |
| + if got, err := GenerationMax(plan, f.req.Holder.Location, "g0"); err != nil || got != 0 { |
| + t.Fatalf("a named generation under the origin: %d %v", got, err) |
| } |
| }) |
| - t.Run("an authorized pin whose root survives only as a historical version", func(t *testing.T) { |
| + t.Run("a selected historical pinned version counts", func(t *testing.T) { |
| f := newFixture(t) |
| - late := f.file("g0", 0, 12, 12) |
| - for _, key := range []string{f.pin.Key, late.Key, f.req.Holder.Location + "/g-g0/0000/" + ltxName(11, 11)} { |
| + for _, key := range []string{f.pin.Key, f.req.Holder.Location + "/g-g0/0000/" + ltxName(11, 11)} { |
| f.b.BareDelete("data", key) |
| } |
| plan, err := Select(ctx, f.store, f.req) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if plan.Root == nil || plan.Root.VersionID != f.pin.VersionID || plan.MaxTXID != 11 || plan.Pin == nil { |
| + if plan.Root == nil || plan.Root.VersionID != f.pin.VersionID || plan.MaxTXID != 11 { |
| t.Fatalf("the selection is not the pinned historical root: %+v", plan) |
| } |
| - got, err := GenerationMax(ctx, f.store, "data", f.req.Holder.Location, "g0", plan.Pin) |
| - if err != nil || got != plan.MaxTXID { |
| - t.Fatalf("GenerationMax %d %v, Select %d", got, err, plan.MaxTXID) |
| - } |
| - if _, err := GenerationMax(ctx, f.store, "data", f.req.Holder.Location, "g0", nil); !errors.Is(err, ErrEmpty) { |
| - t.Fatalf("without the pin: %v", err) |
| - } |
| - missing := *plan.Pin |
| - missing.SnapshotVersion = "not-a-version" |
| - if _, err := GenerationMax(ctx, f.store, "data", f.req.Holder.Location, "g0", &missing); !errors.Is(err, ErrPinnedMissing) { |
| - t.Fatalf("a pin whose version is gone: %v", err) |
| - } |
| - elsewhere := *plan.Pin |
| - elsewhere.SnapshotKey = f.req.Holder.Location + "/g-g1/0009/" + ltxName(1, 11) |
| - if _, err := GenerationMax(ctx, f.store, "data", f.req.Holder.Location, "g0", &elsewhere); !errors.Is(err, ErrPinnedMismatch) { |
| - t.Fatalf("a pin of another generation: %v", err) |
| - } |
| - }) |
| - t.Run("a pin below a current root is not needed", func(t *testing.T) { |
| - f := newFixture(t) |
| - f.file("g0", 9, 1, 13) |
| - got, err := GenerationMax(ctx, f.store, "data", f.req.Holder.Location, "g0", &f.marker.Pin) |
| - if err != nil || got != 13 { |
| + if got, err := GenerationMax(plan, f.req.Holder.Location, "g0"); err != nil || got != 11 { |
| t.Fatalf("%d %v", got, err) |
| } |
| }) |
| t.Run("refusals", func(t *testing.T) { |
| f := newFixture(t) |
| - if _, err := GenerationMax(ctx, f.store, "data", f.req.Holder.Location, "none", nil); !errors.Is(err, ErrEmpty) { |
| - t.Fatalf("no files: %v", err) |
| - } |
| - for name, in := range map[string][3]string{ |
| - "no bucket": {"", f.req.Holder.Location, "g0"}, |
| - "no location": {"data", "", "g0"}, |
| - "a climbing path": {"data", "instances/../h", "g0"}, |
| - "a nested gen": {"data", f.req.Holder.Location, "g0/x"}, |
| - "a dot generation": {"data", f.req.Holder.Location, ".."}, |
| - "an absolute path": {"data", "/instances/h", "g0"}, |
| - "an empty element": {"data", "instances//h", "g0"}, |
| - "a trailing double": {"data", "instances/h//", "g0"}, |
| + plan, err := Select(ctx, f.store, f.req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := GenerationMax(nil, f.req.Holder.Location, "g0"); !errors.Is(err, ErrInput) { |
| + t.Fatalf("no plan: %v", err) |
| + } |
| + for name, in := range map[string][2]string{ |
| + "no location": {"", "g0"}, |
| + "a climbing path": {"instances/../h", "g0"}, |
| + "a nested gen": {f.req.Holder.Location, "g0/x"}, |
| + "a dot generation": {f.req.Holder.Location, ".."}, |
| + "an absolute path": {"/instances/h", "g0"}, |
| + "an empty element": {"instances//h", "g0"}, |
| + "a trailing double": {"instances/h//", "g0"}, |
| } { |
| - if _, err := GenerationMax(ctx, f.store, in[0], in[1], in[2], nil); !errors.Is(err, ErrInput) { |
| + if _, err := GenerationMax(plan, in[0], in[1]); !errors.Is(err, ErrInput) { |
| t.Fatalf("%s: %v", name, err) |
| } |
| } |
| - if _, err := GenerationMax(ctx, nil, "data", f.req.Holder.Location, "g0", nil); !errors.Is(err, ErrInput) { |
| - t.Fatalf("no reader: %v", err) |
| - } |
| - listing := altered{Reader: f.store, list: func([]lease.ObjectVersion) ([]lease.ObjectVersion, error) { |
| - return nil, errors.New("access denied") |
| - }} |
| - if _, err := GenerationMax(ctx, listing, "data", f.req.Holder.Location, "g0", nil); !errors.Is(err, ErrListing) { |
| - t.Fatalf("an unreadable listing: %v", err) |
| - } |
| - malformed := altered{Reader: f.store, list: func(vs []lease.ObjectVersion) ([]lease.ObjectVersion, error) { |
| - return append(vs, lease.ObjectVersion{Key: f.req.Holder.Location + "/g-g0/0000/bogus.ltx", VersionID: "v", IsLatest: true}), nil |
| - }} |
| - if _, err := GenerationMax(ctx, malformed, "data", f.req.Holder.Location, "g0", nil); !errors.Is(err, ErrListing) { |
| - t.Fatalf("a malformed level file: %v", err) |
| - } |
| - unversioned := altered{Reader: f.store, list: func(vs []lease.ObjectVersion) ([]lease.ObjectVersion, error) { |
| - for i := range vs { |
| - vs[i].VersionID = "null" |
| - } |
| - return vs, nil |
| - }} |
| - if _, err := GenerationMax(ctx, unversioned, "data", f.req.Holder.Location, "g0", nil); !errors.Is(err, ErrVersionID) { |
| - t.Fatalf("no version ids: %v", err) |
| - } |
| - f.file("g0", 9, 2, 20) |
| - if _, err := GenerationMax(ctx, f.store, "data", f.req.Holder.Location, "g0", nil); !errors.Is(err, ErrRoot) { |
| - t.Fatalf("a snapshot not from 1: %v", err) |
| + lying := *plan |
| + lying.Files = append([]File(nil), plan.Files...) |
| + lying.Files[0].MaxTXID = 99 |
| + if _, err := GenerationMax(&lying, f.req.Holder.Location, "g0"); !errors.Is(err, ErrInput) { |
| + t.Fatalf("a file whose coordinates are not its key's: %v", err) |
| } |
| }) |
| } |
diff --git a/internal/genselect/select.go b/internal/genselect/select.go| index 8eacb0185..645491257 100644 |
| --- a/internal/genselect/select.go |
| +++ b/internal/genselect/select.go |
| @@ -298,21 +298,19 @@ func SelectPrepared(ctx context.Context, s Reader, req Request, prepared Prepare |
| return selectFiles(ctx, s, req.Bucket, base, versions, latest, p, pin) |
| } |
| -// GenerationMax is generation gen's remote maximum under location in |
| -// bucket: the highest maximum TXID among its current level files (the |
| -// latest version of each key, never a retention tombstone) and, when pin |
| -// is the selection's authorized pin and no current snapshot reaches it, |
| -// the pinned snapshot's exact historical version, as Select restores it. |
| -// It reads with the coordinates, version and pin rules Select reads, so a |
| -// generation Select would choose under pin has Select's maximum. It is the |
| -// one rule for a generation's maximum where a box reads its own bucket: |
| -// the claim writer's loss restore point (hostbox.Writer) and moves' loss |
| -// baseline validator read it alike. "" or hostclaim.RootGeneration is the |
| -// location's own level files. A generation with no current level file and |
| -// no pinned root is ErrEmpty. |
| -func GenerationMax(ctx context.Context, s Reader, bucket, location, gen string, pin *Pin) (uint64, error) { |
| +// GenerationMax is the loss boundary of generation gen in a restore |
| +// (platform-1e's ruling on #236): the highest TXID of that exact generation |
| +// under location among the restore plan's selected files (a selected |
| +// historical pinned version counts, since the plan holds it). It never |
| +// lists the bucket. A generation the plan holds no file of, which is every |
| +// generation but the plan's own, is 0 and nil: every write in it is lost. |
| +// "" and hostclaim.RootGeneration are the location's own level files, the |
| +// legacy root. A plan file whose recorded coordinates are not its key's is |
| +// ErrInput. The claim writer's loss restore point (hostbox.Writer) and |
| +// moves' loss baseline validator read it alike. |
| +func GenerationMax(p *Plan, location, gen string) (uint64, error) { |
| location = strings.TrimSuffix(location, "/") |
| - if s == nil || bucket == "" || location == "" || (gen != "" && gen != hostclaim.RootGeneration && !generation(gen)) { |
| + if p == nil || location == "" || (gen != "" && gen != hostclaim.RootGeneration && !generation(gen)) { |
| return 0, ErrInput |
| } |
| for _, part := range strings.Split(location, "/") { |
| @@ -321,73 +319,19 @@ func GenerationMax(ctx context.Context, s Reader, bucket, location, gen string, |
| } |
| } |
| base := prefix(location+"/", origin(gen)) |
| - floor := uint64(0) |
| - if pin != nil { |
| - // The pin's shape, as selectFiles checks it. |
| - if pin.SnapshotTXID == 0 { |
| - if pin.SnapshotKey != "" || pin.SnapshotVersion != "" || pin.SnapshotCRC64 != 0 { |
| - return 0, ErrPinnedMismatch |
| - } |
| - } else { |
| - f, ok := coordinates(pin.SnapshotKey, base) |
| - if !ok || f.Level != 9 || f.MinTXID != 1 || f.MaxTXID != pin.SnapshotTXID { |
| - return 0, ErrPinnedMismatch |
| - } |
| - if !versionID(pin.SnapshotVersion) { |
| - return 0, ErrVersionID |
| - } |
| - floor = pin.SnapshotTXID |
| - } |
| - } |
| - versions, latest, err := listVersions(ctx, s, bucket, base) |
| - if err != nil { |
| - return 0, err |
| - } |
| - max, found, rooted := uint64(0), false, false |
| - for _, v := range latest { |
| - f, ok, err := currentFile(v, base) |
| - if err != nil { |
| - return 0, err |
| - } |
| + max := uint64(0) |
| + for _, f := range p.Files { |
| + c, ok := coordinates(f.Key, base) |
| if !ok { |
| - continue |
| - } |
| - if f.Level == 9 && f.MinTXID != 1 { |
| - return 0, ErrRoot |
| - } |
| - // A current snapshot at or past the pin is a root Select can take; |
| - // a late duplicate at the pinned key never is. |
| - if f.Level == 9 && f.MaxTXID >= floor && (floor == 0 || f.Key != pin.SnapshotKey) { |
| - rooted = true |
| + continue // another generation's file |
| } |
| - if !found || f.MaxTXID > max { |
| - max, found = f.MaxTXID, true |
| - } |
| - } |
| - if floor > 0 && !rooted { |
| - // Select restores the pinned snapshot's exact version. |
| - var pinned *lease.ObjectVersion |
| - for _, v := range versions { |
| - if v.Key == pin.SnapshotKey && v.VersionID == pin.SnapshotVersion { |
| - cp := v |
| - pinned = &cp |
| - break |
| - } |
| - } |
| - if pinned == nil { |
| - return 0, ErrPinnedMissing |
| - } |
| - f, err := exactFile(*pinned, base) |
| - if err != nil { |
| - return 0, err |
| + if c.Level != f.Level || c.MinTXID != f.MinTXID || c.MaxTXID != f.MaxTXID { |
| + return 0, ErrInput |
| } |
| - if !found || f.MaxTXID > max { |
| - max, found = f.MaxTXID, true |
| + if f.MaxTXID > max { |
| + max = f.MaxTXID |
| } |
| } |
| - if !found { |
| - return 0, ErrEmpty |
| - } |
| return max, nil |
| } |
diff --git a/internal/host/movesloss.go b/internal/host/movesloss.go| index 3a483f25f..f88a16489 100644 |
| --- a/internal/host/movesloss.go |
| +++ b/internal/host/movesloss.go |
| @@ -47,9 +47,10 @@ import ( |
| // - seal:<move>, the source's cut session when the seal did not cover the |
| // source's replica: the cut seal the claim journals, its maximum N. |
| // |
| -// The restore point is checked too: the source baseline's maximum, read in |
| -// this box's own bucket by genselect.GenerationMax, the rule the claim's |
| -// writer reads. An unreadable or moved restore point is a wait |
| +// The restore point is checked too: the source baseline's maximum, read |
| +// from its own selected files by genselect.GenerationMax, the rule the |
| +// claim's writer reads. A restore point that is not the recorded source |
| +// baseline, or whose files do not reach its maximum, is a wait |
| // (hostbox.ErrChanged), within the probe's nine attempts; a frozen source |
| // this recovery cannot be the owner of is a refusal (hostclaim.ErrLossRecord). |
| // |
| @@ -157,7 +158,7 @@ func (r movesRecoveryOwners) movesLossSources(ctx context.Context, o hostclaim.O |
| if err := r.pendingLoss(c, v, loss); err != nil { |
| return nil, err |
| } |
| - if err := r.frozenMaximum(ctx, v, baseline); err != nil { |
| + if err := r.frozenMaximum(v, baseline); err != nil { |
| return nil, err |
| } |
| cut, err := strconv.ParseUint(v.cut, 10, 64) |
| @@ -207,11 +208,10 @@ func (r movesRecoveryOwners) pendingLoss(c *hostclaim.Claim, v recoveryProbe, lo |
| // frozenMaximum checks the admission's restore point: the recovery's source |
| // baseline (src_gen, as the first admission selects it, and the recorded |
| -// one once recorded), its maximum read again in this box's own bucket by |
| -// genselect.GenerationMax under the selection's pin. A read that fails or |
| -// differs from the selection waits. |
| -func (r movesRecoveryOwners) frozenMaximum(ctx context.Context, v recoveryProbe, baseline *genselect.Plan) error { |
| - m := r.m |
| +// one once recorded), whose selected files of its own generation reach its |
| +// maximum (genselect.GenerationMax over the plan, never a listing). A plan |
| +// that disagrees waits. |
| +func (r movesRecoveryOwners) frozenMaximum(v recoveryProbe, baseline *genselect.Plan) error { |
| if baseline == nil || baseline.Generation != selectedGeneration(v.srcGen) { |
| return fmt.Errorf("%w: the restore point is not the source baseline", hostbox.ErrChanged) |
| } |
| @@ -222,12 +222,12 @@ func (r movesRecoveryOwners) frozenMaximum(ctx context.Context, v recoveryProbe, |
| if ok && (recorded.Generation != baseline.Generation || recorded.MaxTXID != baseline.MaxTXID) { |
| return fmt.Errorf("%w: the restore point is not the recorded source baseline", hostbox.ErrChanged) |
| } |
| - max, err := genselect.GenerationMax(ctx, m.Data, m.Bucket, v.holder.Location, baseline.Generation, baseline.Pin) |
| + max, err := genselect.GenerationMax(baseline, v.holder.Location, baseline.Generation) |
| if err != nil { |
| - return fmt.Errorf("%w: the source baseline's maximum: %v", hostbox.ErrChanged, err) |
| + return fmt.Errorf("%w: the source baseline's plan: %v", hostbox.ErrChanged, err) |
| } |
| if max != baseline.MaxTXID { |
| - return fmt.Errorf("%w: the source baseline's maximum is %d, not the selection's %d", hostbox.ErrChanged, max, baseline.MaxTXID) |
| + return fmt.Errorf("%w: the source baseline's files reach %d, not the selection's %d", hostbox.ErrChanged, max, baseline.MaxTXID) |
| } |
| return nil |
| } |
diff --git a/internal/host/movesloss_test.go b/internal/host/movesloss_test.go| index 40ce7656f..81e479764 100644 |
| --- a/internal/host/movesloss_test.go |
| +++ b/internal/host/movesloss_test.go |
| @@ -599,37 +599,35 @@ func (failingList) ListVersions(context.Context, string, string) ([]lease.Object |
| return nil, errors.New("SlowDown") |
| } |
| -// What this box can read waits, what it cannot is refused (Q5, fix round |
| -// 1). Its one read is the restore point's maximum in its own bucket: while |
| -// that cannot be read, or reads otherwise than the selection, the validator |
| -// answers hostbox.ErrChanged, which the probe adapter turns into another |
| -// pass. Once it reads, the target's generation, which no read of this box |
| -// can reach, is refused, typed, not waited on. |
| -func TestLossSourcesWaitsOnAnUnreadableTarget(t *testing.T) { |
| +// The validator answers from the restore plan alone (platform-1e's ruling |
| +// on #236): it lists no bucket, so an unreadable own bucket changes |
| +// nothing; a restore plan whose selected files do not reach its maximum, |
| +// or that is not the source baseline, waits (hostbox.ErrChanged), which the |
| +// probe adapter turns into another pass. |
| +func TestLossSourcesReadOnlyTheRestorePlan(t *testing.T) { |
| ctx := context.Background() |
| f := newRCFixture(t, rcClean) |
| - pub := f.declare(true, []hostclaim.WriteSource{rcTargetSource(poOp, 20)}, nil) |
| + pub := f.declare(false, []hostclaim.WriteSource{rcCutSource()}, rcBeforeTarget) |
| o := f.read() |
| in := f.selectAdmission(o) |
| loss, err := hostclaim.ReadLossRecord(ctx, f.claims, pub, rcLossID) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - data := f.owners.Data |
| - f.owners.Data = failingList{data} |
| - _, err = f.owners.LossSources(ctx, o, loss.Record, in.Admission, in.Baseline) |
| - if !errors.Is(err, hostbox.ErrChanged) || errors.Is(err, hostclaim.ErrLossRecord) { |
| - t.Fatalf("an unreadable bucket: %v", err) |
| + f.owners.Data = failingList{f.owners.Data} |
| + got, err := f.owners.LossSources(ctx, o, loss.Record, in.Admission, in.Baseline) |
| + if err != nil || len(got) != 1 || got[0].RemoteMaxTXID != rcSealN { |
| + t.Fatalf("with an unreadable bucket: %+v %v", got, err) |
| } |
| - f.owners.Data = data |
| - // The remote moved past the selection: wait for the next selection. |
| - f.bucket.Put("data", "instances/"+poHost+"/g-src/0000/"+ltx.FormatFilename(31, 31), f.ltxHeader(31, 31)) |
| - if _, err := f.owners.LossSources(ctx, o, loss.Record, in.Admission, in.Baseline); !errors.Is(err, hostbox.ErrChanged) { |
| - t.Fatalf("a moved remote: %v", err) |
| + short := *in.Baseline |
| + short.MaxTXID++ |
| + if _, err := f.owners.LossSources(ctx, o, loss.Record, in.Admission, &short); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("a plan whose files fall short of its maximum: %v", err) |
| } |
| - in = f.selectAdmission(f.read()) |
| - if _, err := f.owners.LossSources(ctx, o, loss.Record, in.Admission, in.Baseline); !errors.Is(err, ErrLossTargetUnreadable) || errors.Is(err, hostbox.ErrChanged) { |
| - t.Fatalf("the target's generation once the restore point reads: %v", err) |
| + bare := *in.Baseline |
| + bare.Files, bare.Root = nil, nil |
| + if _, err := f.owners.LossSources(ctx, o, loss.Record, in.Admission, &bare); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("a plan with only a maximum: %v", err) |
| } |
| } |
diff --git a/internal/hostbox/lossrestorepoint_test.go b/internal/hostbox/lossrestorepoint_test.go| index 7a834a10e..6caaf8f86 100644 |
| --- a/internal/hostbox/lossrestorepoint_test.go |
| +++ b/internal/hostbox/lossrestorepoint_test.go |
| @@ -12,40 +12,36 @@ import ( |
| "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") |
| +// restorePlan is a restore plan whose one selected file is the snapshot |
| +// [1, max] of generation gen ("" is the location's own files) under |
| +// location: what Select hands a loss admission. |
| +func restorePlan(location, gen string, max uint64) *genselect.Plan { |
| 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") |
| + f := genselect.File{Key: base + "0009/" + ltx.FormatFilename(1, ltx.TXID(max)), VersionID: "v1", Level: 9, MinTXID: 1, MaxTXID: max, Size: 100} |
| + g := gen |
| + if g == "" { |
| + g = hostclaim.RootGeneration |
| + } |
| + return &genselect.Plan{Generation: g, Files: []genselect.File{f}, Root: &f, MaxTXID: max} |
| } |
| -// 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. |
| +// moves Task 4 (platform-1e's ruling on #236): a loss admission's restore |
| +// point is consistent with its own plan: the admission's RemoteMaxTXID is |
| +// the plan's maximum, which genselect.GenerationMax reads from the plan's |
| +// selected files of its own generation, never a fresh listing. A plan that |
| +// carries only a maximum, or whose files do not reach it, is refused before |
| +// the loss sources run and before the claim CAS; no bucket is read, so a |
| +// writer with none still admits a consistent plan. |
| func TestLossRestorePointIsTheGenerationMax(t *testing.T) { |
| for _, path := range []string{"declared", "fenced"} { |
| - for _, mode := range []string{"moved", "unreadable", "no-data"} { |
| + for _, mode := range []string{"only a maximum", "files short of it", "another input", "consistent, no bucket"} { |
| 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 { |
| @@ -55,39 +51,40 @@ func TestLossRestorePointIsTheGenerationMax(t *testing.T) { |
| 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 |
| - }} |
| + w := Writer{Claims: store, Box: "box", Region: "ie"} |
| + in := hostclaim.Admission{Box: "box", Mode: "cold", Region: "ie", RemoteMaxTXID: 3} |
| + baseline := restorePlan(o.Claim.Holder.Location, "src", 3) |
| + switch mode { |
| + case "only a maximum": |
| + baseline = &genselect.Plan{Generation: "src", MaxTXID: 3} |
| + case "files short of it": |
| + baseline = restorePlan(o.Claim.Holder.Location, "src", 2) |
| + baseline.MaxTXID = 3 |
| + case "another input": |
| + in.RemoteMaxTXID = 4 |
| + } |
| + err = w.restorePoint(t.Context(), o, in, baseline) |
| + if mode == "consistent, no bucket" { |
| + if err != nil { |
| + t.Fatalf("a consistent plan was refused: %v", err) |
| + } |
| + return |
| + } |
| + if !errors.Is(err, ErrChanged) { |
| + t.Fatalf("refused with %v", err) |
| + } |
| 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") |
| + if !errors.Is(err, ErrChanged) || sources != 0 { |
| + t.Fatalf("%s: %v, the loss sources ran %d times", path, err, sources) |
| } |
| }) |
| } |
diff --git a/internal/hostbox/plan12_fence_loss_test.go b/internal/hostbox/plan12_fence_loss_test.go| index ed6ee9da6..d0480f73f 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", Bucket: "data", Data: restorePointData(o.Claim.Holder.Location, "", 3), AdmissionEvidence: func(_ context.Context, _ hostclaim.Observed, a hostclaim.Admission, p *genselect.Plan) error { |
| + w := Writer{Claims: s, Box: "box", Region: "ie", 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) |
| } |
| @@ -98,7 +98,7 @@ func TestPlan12FencedTargetLossMaximum(t *testing.T) { |
| if mode == "bypass" { |
| got, err = hostclaim.Update(t.Context(), s, o, hostclaim.AdmitWriter(in)) |
| } else { |
| - got, err = w.Admit(t.Context(), o, in, &genselect.Plan{MaxTXID: 3}) |
| + got, err = w.Admit(t.Context(), o, in, restorePlan(o.Claim.Holder.Location, "", 3)) |
| } |
| if mode != "ok" { |
| if err == nil { |
diff --git a/internal/hostbox/plan12_loss_test.go b/internal/hostbox/plan12_loss_test.go| index 20fa88936..ca614edcc 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", Bucket: "data", Data: restorePointData(o.Claim.Holder.Location, "", 3), AdmissionEvidence: func(_ context.Context, obs hostclaim.Observed, admission hostclaim.Admission, baseline *genselect.Plan) error { |
| + w := Writer{Claims: store, Box: "box", Region: "ie", 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) |
| } |
| @@ -129,7 +129,7 @@ func plan12LossAdmissionCases(t *testing.T, modes []string) { |
| if mode == "bypass" { |
| admitted, err = hostclaim.Update(t.Context(), store, o, hostclaim.AdmitWriter(in)) |
| } else { |
| - admitted, err = w.Admit(t.Context(), o, in, &genselect.Plan{MaxTXID: 3}) |
| + admitted, err = w.Admit(t.Context(), o, in, restorePlan(o.Claim.Holder.Location, "", 3)) |
| } |
| if mode != "ok" && mode != "source-cut" && mode != "covered-source" && mode != "no-source" { |
| if err == nil { |
diff --git a/internal/hostbox/writer_loss.go b/internal/hostbox/writer_loss.go| index bec9de09d..eeb5d2bd4 100644 |
| --- a/internal/hostbox/writer_loss.go |
| +++ b/internal/hostbox/writer_loss.go |
| @@ -82,23 +82,21 @@ func (w Writer) admitFencedLoss(ctx context.Context, o hostclaim.Observed, in ho |
| } |
| // 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. |
| +// maximum must be the admission's RemoteMaxTXID and, read from the plan's |
| +// own selected files by genselect.GenerationMax (the rule moves' loss |
| +// baseline validator reads, platform-1e on #236), the maximum of the plan's |
| +// own generation. It reads no bucket. The predecessor's loss boundary, which |
| +// replaces RemoteMaxTXID after the loss sources, is a separate value. |
| 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, baseline.Pin) |
| + max, err := genselect.GenerationMax(baseline, o.Claim.Holder.Location, baseline.Generation) |
| if err != nil { |
| - return fmt.Errorf("%w: the restore point's generation: %w", ErrChanged, err) |
| + return fmt.Errorf("%w: the restore plan: %w", ErrChanged, err) |
| } |
| if max != in.RemoteMaxTXID { |
| - return fmt.Errorf("%w: the restore point is not the generation's maximum %d", ErrChanged, max) |
| + return fmt.Errorf("%w: the restore plan's files reach %d, not its maximum", ErrChanged, max) |
| } |
| return nil |
| } |
diff --git a/internal/hostops/plan12_fence_test.go b/internal/hostops/plan12_fence_test.go| index a505d57a4..bf3093e55 100644 |
| --- a/internal/hostops/plan12_fence_test.go |
| +++ b/internal/hostops/plan12_fence_test.go |
| @@ -8,7 +8,6 @@ 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" |
| @@ -312,12 +311,7 @@ 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 |
| - // 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 { |
| + writer := hostbox.Writer{Claims: w.raw, Box: source, Region: "uk", 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) |
| @@ -330,7 +324,10 @@ func plan12GrantLossMaximum(t *testing.T, w *c7World) { |
| s := l.WriteSources[0] |
| return []hostclaim.ValidatedWriteSource{{Reason: s.Reason, Box: s.Box, Session: s.Session, Generation: s.Generation, Region: s.Region, Baseline: s.Baseline, RemoteMaxTXID: 7}}, nil |
| }}} |
| - admitted, err := writer.Admit(t.Context(), o, in, &genselect.Plan{Generation: "src", MaxTXID: 3}) |
| + // The restore plan's selected files reach its maximum |
| + // (genselect.GenerationMax, moves Task 4). |
| + root := genselect.File{Key: strings.TrimSuffix(o.Claim.Holder.Location, "/") + "/g-src/0009/" + ltx.FormatFilename(1, 3), VersionID: "v1", Level: 9, MinTXID: 1, MaxTXID: 3, Size: 100} |
| + admitted, err := writer.Admit(t.Context(), o, in, &genselect.Plan{Generation: "src", Files: []genselect.File{root}, Root: &root, MaxTXID: 3}) |
| if err != nil { |
| t.Fatal(err) |
| } |