carlos / platform Public

moves: a loss admission that would declare a target generation this box cannot read is refused (Task 4 fix round 1, item 1, interim)

A fresh target generation restarts its TXIDs at [1, 1] (spec §5.1), so
the source baseline's maximum is no loss boundary for it. Until
platform-1e rules how such a generation is declared, the validator
refuses, typed (ErrLossTargetUnreadable), with a standing log line. The
cut session's journalled seal N is unchanged. Regression: a target
generation whose TXIDs reset is refused, not admitted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 96144320085266a3286f8d4572a84098d4840ba4 parent 71b0bb5
3 files changed, +171 −96
  • internal/host/movesloss.go +52 −26
  • internal/host/movesloss_test.go +116 −70
  • internal/host/movesprobeowners.go +3 −0
diff --git a/internal/host/movesloss.go b/internal/host/movesloss.go
index 92c23bcf4..45cb69aa0 100644
--- a/internal/host/movesloss.go
+++ b/internal/host/movesloss.go
@@ -3,9 +3,12 @@ package host
import (
"context"
"encoding/json"
+ "errors"
"fmt"
+ "log"
"reflect"
"strconv"
+ "time"
"github.com/carlosframework/platform/internal/genselect"
"github.com/carlosframework/platform/internal/hostbox"
@@ -36,18 +39,19 @@ import (
// maxima already recorded.
//
// - replica:<region>/<gen>, a target session since the cut: the target's
-// replica is in the target's region, which this box never reads. Its
-// maximum is the admission's frozen maximum: the restore point the
-// recovery admits from (the source baseline, src_gen, frozen in the
-// recovery journal once evidenced), whose maximum this box reads in its
-// own bucket by genselect.GenerationMax, the rule the claim's writer
-// reads too. Writes past it are the ones this admission declares lost.
+// replica is in the target's region, which this box never reads, and a
+// fresh target generation restarts its TXIDs at [1, 1] (spec §5.1), so
+// no maximum this box holds bounds its writes. Until platform-1e rules
+// how such a generation is declared, the admission is refused, typed
+// (ErrLossTargetUnreadable), with a standing log line (fix round 1).
// - 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.
//
-// An unreadable or moved 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).
+// 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
+// (hostbox.ErrChanged), within the probe's nine attempts; a frozen source
+// this recovery cannot be the owner of is a refusal (hostclaim.ErrLossRecord).
//
// AdmissionEvidence's loss contract (Q7). The writer substitutes the
// validated predecessor's maximum for RemoteMaxTXID and the other validated
@@ -57,6 +61,14 @@ import (
// check of the recovery's evidence, the lineage link and the source
// baseline's record before the CAS included, is unchanged.
+// ErrLossTargetUnreadable refuses a loss admission that would have to
+// record a remote maximum for a lost target's generation, which lives in
+// the target region's bucket this box cannot read (fix round 1, interim
+// until platform-1e rules how such a generation is declared). A fresh
+// target generation restarts its TXIDs at [1, 1] (spec §5.1), so no
+// maximum of another generation bounds its writes.
+var ErrLossTargetUnreadable = errors.New("moves: a lost target generation's maximum is in a region this box cannot read")
+
// claimLossRefs are the keys of the target's loss the claim journals under
// the op: the declaration's record key, and the fence record's key.
func claimLossRefs(c *hostclaim.Claim) (map[string]bool, error) {
@@ -145,8 +157,7 @@ func (r movesRecoveryOwners) movesLossSources(ctx context.Context, o hostclaim.O
if err := r.pendingLoss(c, v, loss); err != nil {
return nil, err
}
- frozen, err := r.frozenMaximum(ctx, v, baseline)
- if err != nil {
+ if err := r.frozenMaximum(ctx, v, baseline); err != nil {
return nil, err
}
cut, err := strconv.ParseUint(v.cut, 10, 64)
@@ -155,7 +166,7 @@ func (r movesRecoveryOwners) movesLossSources(ctx context.Context, o hostclaim.O
}
out := make([]hostclaim.ValidatedWriteSource, 0, len(loss.WriteSources))
for _, s := range loss.WriteSources {
- max, err := r.sourceMaximum(c, v, cut, frozen, s)
+ max, err := r.sourceMaximum(c, v, cut, s)
if err != nil {
return nil, err
}
@@ -194,36 +205,46 @@ func (r movesRecoveryOwners) pendingLoss(c *hostclaim.Claim, v recoveryProbe, lo
return nil
}
-// frozenMaximum is the admission's frozen maximum: the restore point's
-// maximum, the restore point being the recovery's source baseline (src_gen,
-// as the first admission selects it, and the recorded one once recorded),
-// read again in this box's own bucket by genselect.GenerationMax. A read
-// that fails or differs from the selection waits.
-func (r movesRecoveryOwners) frozenMaximum(ctx context.Context, v recoveryProbe, baseline *genselect.Plan) (uint64, error) {
+// 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
if baseline == nil || baseline.Generation != selectedGeneration(v.srcGen) {
- return 0, fmt.Errorf("%w: the restore point is not the source baseline", hostbox.ErrChanged)
+ return fmt.Errorf("%w: the restore point is not the source baseline", hostbox.ErrChanged)
}
recorded, ok, err := r.recordedSourceBaseline(v.rec.Host, v)
if err != nil {
- return 0, err
+ return err
}
if ok && (recorded.Generation != baseline.Generation || recorded.MaxTXID != baseline.MaxTXID) {
- return 0, fmt.Errorf("%w: the restore point is not the recorded source baseline", hostbox.ErrChanged)
+ 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)
if err != nil {
- return 0, fmt.Errorf("%w: the source baseline's maximum: %v", hostbox.ErrChanged, err)
+ return fmt.Errorf("%w: the source baseline's maximum: %v", hostbox.ErrChanged, err)
}
if max != baseline.MaxTXID {
- return 0, 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 maximum is %d, not the selection's %d", hostbox.ErrChanged, max, baseline.MaxTXID)
}
- return max, nil
+ return nil
+}
+
+// refusalNotice logs a standing refusal at most once a minute per key.
+func (r movesRecoveryOwners) refusalNotice(key, format string, args ...any) {
+ now := time.Now()
+ if last, ok := r.m.lossRefusals.Load(key); ok && now.Sub(last.(time.Time)) < time.Minute {
+ return
+ }
+ r.m.lossRefusals.Store(key, now)
+ log.Printf(format, args...)
}
// sourceMaximum vouches for one frozen source (see the file comment) and
// returns its maximum.
-func (r movesRecoveryOwners) sourceMaximum(c *hostclaim.Claim, v recoveryProbe, cut, frozen uint64, s hostclaim.WriteSource) (uint64, error) {
+func (r movesRecoveryOwners) sourceMaximum(c *hostclaim.Claim, v recoveryProbe, cut uint64, s hostclaim.WriteSource) (uint64, error) {
m := r.m
refused := func(why string) (uint64, error) {
return 0, fmt.Errorf("%w: frozen source %s/%s/%d: %s", hostclaim.ErrLossRecord, s.Box, s.Generation, s.Session, why)
@@ -240,7 +261,12 @@ func (r movesRecoveryOwners) sourceMaximum(c *hostclaim.Claim, v recoveryProbe,
if !park.SameRegion(v.homeRegion, region, s.Region) || !park.SameRegion(v.homeRegion, s.Region, c.Op.Intent["to"]) {
return refused("not the target region's replica")
}
- return frozen, nil
+ // The target's generation is in the target region's bucket, which
+ // this box never reads, and its TXIDs restart at [1, 1]: no
+ // maximum this box holds bounds its writes (fix round 1, interim
+ // until platform-1e rules the declaration).
+ r.refusalNotice(v.rec.Host+"#"+s.Generation, "moves: %s: the recovery of %s refuses its loss admission: the lost target's generation %s (session %d) is in %s, whose bucket this box cannot read", v.rec.Host, v.op, s.Generation, s.Session, s.Region)
+ return 0, fmt.Errorf("%w: generation %s, session %d, in %s", ErrLossTargetUnreadable, s.Generation, s.Session, s.Region)
case "seal":
if s.Reason != "source-cut" || identity != v.op || s.Box != m.Box || selectedGeneration(s.Generation) != selectedGeneration(v.srcGen) || s.Session != cut ||
!park.SameRegion(v.homeRegion, s.Region, m.Region) {
diff --git a/internal/host/movesloss_test.go b/internal/host/movesloss_test.go
index 61ab154b4..40ce7656f 100644
--- a/internal/host/movesloss_test.go
+++ b/internal/host/movesloss_test.go
@@ -23,6 +23,7 @@ import (
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
+ "github.com/superfly/ltx"
)
// The loss and fence rules on the box (moves-after-Plan-12 Task 4: owed (a)
@@ -66,6 +67,12 @@ func rcCutSource() hostclaim.WriteSource {
return hostclaim.WriteSource{Reason: "source-cut", Box: "ie-box", Generation: "src", Session: rcS, Region: "ie", Baseline: hostops.MoveLossSealBaseline + poOp}
}
+// rcBeforeTarget is a claim whose target was lost before its first
+// admission: the holder is still the source's cut session.
+func rcBeforeTarget(c *hostclaim.Claim) {
+ c.Holder.Session, c.Holder.WriterBox, c.Holder.Admissions = rcS, "ie-box", c.Holder.Admissions[:1]
+}
+
// declare turns f's clean return into the return after a declared-lost
// target (Plan 4 Task 10): the claim as DeclareLost and the lost target's
// release leave it, edit applied, the record's rollback citing the
@@ -203,8 +210,11 @@ func TestDeclaredLossNeverAdmitsCleanReturn(t *testing.T) {
}
})
t.Run("the owners admit it uncovered, through the loss owner", func(t *testing.T) {
+ // The target was lost before its first admission: the cut session
+ // is the one frozen source (a target generation is refused, fix
+ // round 1).
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)
if in.Admission.Coverage != "" || in.Admission.LossID != "" {
@@ -256,8 +266,9 @@ func rcSetLossRef(t *testing.T, h *recoverHarness, ref string) {
}
}
-// Q6: a return after the target's termination fence asks no coverage, and
-// its first admission takes the fenced target's frozen maximum.
+// Q6: a return after the target's termination fence asks no coverage. Its
+// first admission would declare the fenced target's generation, which this
+// box cannot read: it is refused, typed (fix round 1, interim).
func TestFencedTargetReturnAsksUncovered(t *testing.T) {
t.Run("the probing pass asks no coverage", func(t *testing.T) {
h := newRecoverHarness(t, false)
@@ -273,7 +284,7 @@ func TestFencedTargetReturnAsksUncovered(t *testing.T) {
t.Fatalf("probes %+v", h.probes)
}
})
- t.Run("the owners admit the frozen target", func(t *testing.T) {
+ t.Run("the owners refuse the frozen target's admission", func(t *testing.T) {
f := newRCFixture(t, rcClean)
f.fence()
o := f.read()
@@ -287,16 +298,12 @@ func TestFencedTargetReturnAsksUncovered(t *testing.T) {
if _, err := w.Admit(t.Context(), o, covered, in.Baseline); err == nil {
t.Fatal("a fenced return admitted clean-return")
}
- admitted, err := w.Admit(t.Context(), f.read(), in.Admission, in.Baseline)
- if err != nil {
- t.Fatal(err)
+ before := f.read()
+ if _, err := w.Admit(t.Context(), before, in.Admission, in.Baseline); !errors.Is(err, ErrLossTargetUnreadable) {
+ t.Fatalf("the fenced target's generation: %v", err)
}
- a := admitted.Claim.Holder.Admissions[len(admitted.Claim.Holder.Admissions)-1]
- // The target's replica is in its own region, which this box never
- // reads (platform-1e): the declaration names the admission's frozen
- // maximum, src_gen's 30 in this box's own bucket.
- if a.Coverage != "" || a.WritesLost == nil || *a.WritesLost != (hostclaim.WritesLost{Box: "uk-box", Generation: poOp, Session: 20, RemoteMaxTXID: 30}) || len(a.AdditionalWritesLost) != 0 {
- t.Fatalf("the fenced admission: %+v", a)
+ if after := f.read(); after.ETag != before.ETag {
+ t.Fatal("the claim moved")
}
})
}
@@ -323,23 +330,33 @@ func TestLaterSourceSessionStaysUncovered(t *testing.T) {
for _, path := range []string{"declared", "fenced"} {
t.Run("the owners: "+path, func(t *testing.T) {
f := newRCFixture(t, rcClean)
- var w hostbox.Writer
+ var (
+ w hostbox.Writer
+ first hostclaim.Observed
+ in ClaimLaunchInputs
+ )
if path == "declared" {
- w = f.lossWriter(f.declare(true, []hostclaim.WriteSource{rcTargetSource(poOp, 20)}, nil))
+ w = f.lossWriter(f.declare(false, []hostclaim.WriteSource{rcCutSource()}, rcBeforeTarget))
+ in = f.selectAdmission(f.read())
+ var err error
+ if first, err = w.Admit(t.Context(), f.read(), in.Admission, in.Baseline); err != nil {
+ t.Fatal(err)
+ }
} else {
- f.fence()
+ // A fenced target's first admission is refused for now (fix
+ // round 1); the fence's consumption is journalled by hand.
+ record := f.fence()
w = f.lossWriter(nil)
- }
- in := f.selectAdmission(f.read())
- first, err := w.Admit(t.Context(), f.read(), in.Admission, in.Baseline)
- if err != nil {
- t.Fatal(err)
+ c := f.read().Claim
+ c.Op.Journal["fence_admitted."+record.ID] = "true"
+ f.putClaim(c)
+ first = f.read()
}
if pending, err := recoveryLossPending(first.Claim); err != nil || pending {
t.Fatalf("the loss is still pending: %v %v", pending, err)
}
restart := f.selectAdmission(first)
- if restart.Admission.Coverage != "" || restart.Admission.ObservedGeneration != in.Admission.NewGeneration {
+ if restart.Admission.Coverage != "" || (path == "declared" && restart.Admission.ObservedGeneration != in.Admission.NewGeneration) {
t.Fatalf("the restart: %+v", restart.Admission)
}
covered := restart.Admission
@@ -431,9 +448,10 @@ func TestRecordAndClaimDisagreeWaits(t *testing.T) {
// Q5 as platform-1e answered it: the loss baseline validator vouches for
// each frozen source from the maxima already recorded, never reading
-// another region's bucket: a target replica's is the admission's frozen
-// maximum (the source baseline's, read in this box's own bucket by
-// genselect.GenerationMax), a seal's the journalled cut seal's.
+// another region's bucket: a seal's is the journalled cut seal's. A target
+// replica's generation is in a region this box cannot read and restarts its
+// TXIDs, so a well-formed one is refused, typed (fix round 1, interim); a
+// malformed one is refused as not the recovery's.
func TestLossSourcesValidatesEachFrozenBaseline(t *testing.T) {
ctx := context.Background()
t.Run("a declared loss's replica and seal", func(t *testing.T) {
@@ -446,14 +464,16 @@ func TestLossSourcesValidatesEachFrozenBaseline(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- got, err := f.owners.LossSources(ctx, o, loss.Record, in.Admission, in.Baseline)
+ if _, err := f.owners.LossSources(ctx, o, loss.Record, in.Admission, in.Baseline); !errors.Is(err, ErrLossTargetUnreadable) || errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("a target replica: %v", err)
+ }
+ cutOnly := loss.Record
+ cutOnly.WriteSources = []hostclaim.WriteSource{rcCutSource()}
+ got, err := f.owners.LossSources(ctx, o, cutOnly, in.Admission, in.Baseline)
if err != nil {
t.Fatal(err)
}
- want := []hostclaim.ValidatedWriteSource{
- {Reason: "target", Box: "uk-box", Generation: poOp, Session: 20, Region: "uk", Baseline: "replica:uk/" + poOp, RemoteMaxTXID: 30},
- {Reason: "source-cut", Box: "ie-box", Generation: "src", Session: rcS, Region: "ie", Baseline: "seal:" + poOp, RemoteMaxTXID: rcSealN},
- }
+ want := []hostclaim.ValidatedWriteSource{{Reason: "source-cut", Box: "ie-box", Generation: "src", Session: rcS, Region: "ie", Baseline: "seal:" + poOp, RemoteMaxTXID: rcSealN}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("validated %+v\nwant %+v", got, want)
}
@@ -485,8 +505,8 @@ func TestLossSourcesValidatesEachFrozenBaseline(t *testing.T) {
"a replica for the cut seal": func(s *hostclaim.WriteSource) { s.Baseline = "replica:ie/src" },
} {
bad := loss.Record
- bad.WriteSources = append([]hostclaim.WriteSource(nil), sources...)
- mutate(&bad.WriteSources[1])
+ bad.WriteSources = []hostclaim.WriteSource{rcCutSource()}
+ mutate(&bad.WriteSources[0])
if _, err := f.owners.LossSources(ctx, o, bad, in.Admission, in.Baseline); !errors.Is(err, hostclaim.ErrLossRecord) {
t.Fatalf("seal, %s: %v", name, err)
}
@@ -494,7 +514,7 @@ func TestLossSourcesValidatesEachFrozenBaseline(t *testing.T) {
// A seal that covered the source's replica holds no source-cut
// writes: a source-cut source under it is not the cut's.
covered := newRCFixture(t, rcClean)
- pub = covered.declare(true, sources, nil)
+ pub = covered.declare(true, []hostclaim.WriteSource{rcCutSource()}, nil)
o = covered.read()
in = covered.selectAdmission(o)
loss, err = hostclaim.ReadLossRecord(ctx, covered.claims, pub, rcLossID)
@@ -541,13 +561,9 @@ func TestLossSourcesValidatesEachFrozenBaseline(t *testing.T) {
record := f.fence()
o := f.read()
in := f.selectAdmission(o)
- got, err := f.owners.LossSources(ctx, o, record, in.Admission, in.Baseline)
- if err != nil {
- t.Fatal(err)
- }
- want := []hostclaim.ValidatedWriteSource{{Reason: "target", Box: "uk-box", Generation: poOp, Session: 20, Region: "uk", Baseline: "replica:uk/" + poOp, RemoteMaxTXID: 30}}
- if !reflect.DeepEqual(got, want) {
- t.Fatalf("validated %+v", got)
+ // Its one source is the fenced target's generation: refused, typed.
+ if _, err := f.owners.LossSources(ctx, o, record, in.Admission, in.Baseline); !errors.Is(err, ErrLossTargetUnreadable) {
+ t.Fatalf("the fenced target: %v", err)
}
bad := record
bad.ID = "auto-other"
@@ -583,11 +599,12 @@ func (failingList) ListVersions(context.Context, string, string) ([]lease.Object
return nil, errors.New("SlowDown")
}
-// An unreadable source maximum is a wait, never a refusal (Q5): with no
-// cross-region read the one read is the admission's frozen maximum in this
-// box's own bucket, and while it cannot be read, or reads otherwise than
-// the selection, the validator answers hostbox.ErrChanged, which the probe
-// adapter turns into another pass.
+// 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) {
ctx := context.Background()
f := newRCFixture(t, rcClean)
@@ -606,22 +623,27 @@ func TestLossSourcesWaitsOnAnUnreadableTarget(t *testing.T) {
}
f.owners.Data = data
// The remote moved past the selection: wait for the next selection.
- f.bucket.Put("data", "instances/"+poHost+"/g-src/0000/0000000000000031-0000000000000031.ltx", f.ltxHeader(31, 31))
+ 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)
}
+ 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)
+ }
}
// Q7's loss half: the recovery's AdmissionEvidence accepts the writer's
// loss substitution exactly: RemoteMaxTXID is the validated predecessor's
-// maximum and AdditionalWritesLost the other validated sources, from the
-// same LossSources call; anything else is refused. It journals the
+// maximum (here the cut session's frozen seal N, the target lost before its
+// first admission) and AdditionalWritesLost the other validated sources,
+// from the same LossSources call; anything else is refused. It journals the
// lineage link and the source baseline before the CAS, as every recovery
// admission does.
func TestRecoveryAdmissionEvidenceAcceptsTheFrozenMaximum(t *testing.T) {
ctx := context.Background()
f := newRCFixture(t, rcClean)
- pub := f.declare(false, []hostclaim.WriteSource{rcTargetSource(poOp, 20), rcCutSource()}, 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)
@@ -633,14 +655,12 @@ func TestRecoveryAdmissionEvidenceAcceptsTheFrozenMaximum(t *testing.T) {
t.Fatalf("no validated sources: %v", err)
}
substituted := in.Admission
- substituted.RemoteMaxTXID = 30
- substituted.AdditionalWritesLost = []hostclaim.WritesLost{{Box: "ie-box", Generation: "src", Session: rcS, RemoteMaxTXID: rcSealN}}
+ substituted.RemoteMaxTXID = rcSealN
for name, mutate := range map[string]func(*hostclaim.Admission){
- "Select's maximum": func(a *hostclaim.Admission) { a.RemoteMaxTXID = in.Baseline.MaxTXID + 1 },
- "no additional": func(a *hostclaim.Admission) { a.AdditionalWritesLost = nil },
- "another additional maximum": func(a *hostclaim.Admission) { a.AdditionalWritesLost[0].RemoteMaxTXID = 30 },
- "an extra additional": func(a *hostclaim.Admission) {
- a.AdditionalWritesLost = append(a.AdditionalWritesLost, hostclaim.WritesLost{Box: "uk-box", Generation: poOp, Session: 20, RemoteMaxTXID: 30})
+ "Select's maximum": func(a *hostclaim.Admission) { a.RemoteMaxTXID = in.Baseline.MaxTXID },
+ "another maximum": func(a *hostclaim.Admission) { a.RemoteMaxTXID = rcSealN + 1 },
+ "an additional": func(a *hostclaim.Admission) {
+ a.AdditionalWritesLost = []hostclaim.WritesLost{{Box: "ie-box", Generation: "src", Session: rcS, RemoteMaxTXID: rcSealN}}
},
"coverage": func(a *hostclaim.Admission) { a.Coverage = recoveryCoverageClean },
"a loss id": func(a *hostclaim.Admission) { a.LossID = rcLossID },
@@ -673,8 +693,8 @@ func TestRecoveryAdmissionEvidenceAcceptsTheFrozenMaximum(t *testing.T) {
t.Fatal(err)
}
a := admitted.Claim.Holder.Admissions[len(admitted.Claim.Holder.Admissions)-1]
- if a.WritesLost == nil || *a.WritesLost != (hostclaim.WritesLost{Box: "uk-box", Generation: poOp, Session: 20, RemoteMaxTXID: 30}) ||
- !reflect.DeepEqual(a.AdditionalWritesLost, substituted.AdditionalWritesLost) || a.LossID != rcLossID {
+ if a.WritesLost == nil || *a.WritesLost != (hostclaim.WritesLost{Box: "ie-box", Generation: "src", Session: rcS, RemoteMaxTXID: rcSealN}) ||
+ len(a.AdditionalWritesLost) != 0 || a.LossID != rcLossID {
t.Fatalf("the loss admission: %+v", a)
}
j, err := moves.ReadJournal(f.dataDir, poHost, rpRB)
@@ -701,7 +721,9 @@ func TestRecoveryAdmissionEvidenceAcceptsTheFrozenMaximum(t *testing.T) {
// wNoDuplicateDeclarationEarly/Late): behind a lost target the source's
// recovery declares every target session since the cut and, when the seal
// did not cover the source's replica, the cut session, each once: the
-// predecessor's as writes_lost, the rest as additional_writes_lost.
+// predecessor's as writes_lost, the rest as additional_writes_lost. Until
+// platform-1e rules how a target generation this box cannot read is
+// declared, a declaration that freezes one is refused whole (fix round 1).
func TestLostTargetReturnDeclaresEverySessionOnce(t *testing.T) {
ctx := context.Background()
t.Run("two target sessions and the cut", func(t *testing.T) {
@@ -712,22 +734,18 @@ func TestLostTargetReturnDeclaresEverySessionOnce(t *testing.T) {
c.Holder.Session = 25
c.Holder.Admissions = append(c.Holder.Admissions, hostclaim.AdmissionRecord{Session: 25, Box: "uk-box", Generation: "t2", Predecessor: poOp, Region: "uk", PredecessorRegion: "uk", Mode: "cold"})
})
- in := f.selectAdmission(f.read())
- admitted, err := f.lossWriter(pub).Admit(ctx, f.read(), in.Admission, in.Baseline)
- if err != nil {
- t.Fatal(err)
+ before := f.read()
+ in := f.selectAdmission(before)
+ if _, err := f.lossWriter(pub).Admit(ctx, before, in.Admission, in.Baseline); !errors.Is(err, ErrLossTargetUnreadable) {
+ t.Fatalf("two target generations this box cannot read: %v", err)
}
- a := admitted.Claim.Holder.Admissions[len(admitted.Claim.Holder.Admissions)-1]
- want := []hostclaim.WritesLost{{Box: "uk-box", Generation: poOp, Session: 20, RemoteMaxTXID: 30}, {Box: "ie-box", Generation: "src", Session: rcS, RemoteMaxTXID: rcSealN}}
- if a.WritesLost == nil || *a.WritesLost != (hostclaim.WritesLost{Box: "uk-box", Generation: "t2", Session: 25, RemoteMaxTXID: 30}) || !reflect.DeepEqual(a.AdditionalWritesLost, want) {
- t.Fatalf("declared %+v / %+v", a.WritesLost, a.AdditionalWritesLost)
+ if after := f.read(); after.ETag != before.ETag {
+ t.Fatal("the claim moved")
}
})
t.Run("a target lost before its first admission", func(t *testing.T) {
f := newRCFixture(t, rcClean)
- pub := f.declare(false, []hostclaim.WriteSource{rcCutSource()}, func(c *hostclaim.Claim) {
- c.Holder.Session, c.Holder.WriterBox, c.Holder.Admissions = rcS, "ie-box", c.Holder.Admissions[:1]
- })
+ pub := f.declare(false, []hostclaim.WriteSource{rcCutSource()}, rcBeforeTarget)
in := f.selectAdmission(f.read())
admitted, err := f.lossWriter(pub).Admit(ctx, f.read(), in.Admission, in.Baseline)
if err != nil {
@@ -756,3 +774,31 @@ func TestLostTargetReturnDeclaresEverySessionOnce(t *testing.T) {
}
})
}
+
+// Fix round 1 (Critical): a fresh target generation restarts its TXIDs at
+// [1, 1] (spec §5.1), so the source's maximum, 30, is no loss boundary for
+// it: the target's generation t2 acknowledged a write at TXID 2 that the
+// restore of src omits. With no read of the target's region, the loss
+// admission is refused, typed, never admitted with a borrowed maximum.
+func TestLostTargetGenerationResetIsRefused(t *testing.T) {
+ ctx := context.Background()
+ f := newRCFixture(t, rcClean)
+ // The target's own bucket, which the source never reads: t2 restarted
+ // at [1, 1] and holds TXID 2.
+ f.bucket.AddBucket("data-uk")
+ f.bucket.Put("data-uk", "instances/"+poHost+"/g-t2/0000/"+ltx.FormatFilename(1, 2), f.ltxHeader(1, 2))
+ sources := []hostclaim.WriteSource{rcTargetSource("t2", 25), rcTargetSource(poOp, 20)}
+ pub := f.declare(true, sources, func(c *hostclaim.Claim) {
+ c.Holder.Session = 25
+ c.Holder.Admissions = append(c.Holder.Admissions, hostclaim.AdmissionRecord{Session: 25, Box: "uk-box", Generation: "t2", Predecessor: poOp, Region: "uk", PredecessorRegion: "uk", Mode: "cold"})
+ })
+ before := f.read()
+ in := f.selectAdmission(before)
+ if _, err := f.lossWriter(pub).Admit(ctx, before, in.Admission, in.Baseline); !errors.Is(err, ErrLossTargetUnreadable) {
+ t.Fatalf("a target generation this box cannot read was admitted or refused untyped: %v", err)
+ }
+ after := f.read()
+ if after.ETag != before.ETag || len(after.Claim.Holder.Admissions) != len(before.Claim.Holder.Admissions) || hostclaim.OperationLossConsumed(after.Claim, rcLossID) {
+ t.Fatalf("the claim moved: %+v", after.Claim.Holder.Admissions)
+ }
+}
diff --git a/internal/host/movesprobeowners.go b/internal/host/movesprobeowners.go
index 7fda90e53..b8770c8e9 100644
--- a/internal/host/movesprobeowners.go
+++ b/internal/host/movesprobeowners.go
@@ -156,6 +156,9 @@ type movesProbeOwners struct {
// recovery's AdmissionEvidence of the same admission consumes
// (movesloss.go).
lossValidated map[string]lossValidation
+ // lossRefusals are the standing loss-admission refusals logged, by key
+ // and time (movesloss.go refusalNotice).
+ lossRefusals sync.Map
}
var (