| 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) |
| + } |
| +} |