carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 90a587331afd6533418aa5297770d63c51ffdd8f parent 0bcfb5a
9 files changed, +176 −266
  • internal/genselect/generationmax_test.go +66 −90
  • internal/genselect/select.go +20 −76
  • internal/host/movesloss.go +12 −12
  • internal/host/movesloss_test.go +19 −21
  • internal/hostbox/lossrestorepoint_test.go +42 −45
  • internal/hostbox/plan12_fence_loss_test.go +2 −2
  • internal/hostbox/plan12_loss_test.go +2 −2
  • internal/hostbox/writer_loss.go +8 −10
  • internal/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)
}