carlos / platform Public

hostclaim: a relocation pin waits for every journaled fence's observation time, so a converted move pins only after FenceSource and the 30-second hold

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 03da9300f0810106a417e6581d4c42c2de95f0a0 parent 54bb6da
2 files changed, +50 −0
  • internal/hostclaim/relocation.go +11 −0
  • internal/hostclaim/relocation_test.go +39 −0
diff --git a/internal/hostclaim/relocation.go b/internal/hostclaim/relocation.go
index 25e89db79..c425d6350 100644
--- a/internal/hostclaim/relocation.go
+++ b/internal/hostclaim/relocation.go
@@ -9,6 +9,7 @@ import (
"fmt"
"slices"
"strconv"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/release"
@@ -714,6 +715,16 @@ func JournalPin(opID string, p RecoveryPin) func(*Claim) error {
if p.Empty && !((released && r.FinalTXID == 0) || (!released && c.Holder.Session == 0)) {
return fmt.Errorf("%w: an empty pin needs a release at txid 0 or a holder that never had a writer", ErrRelocation)
}
+ for key := range c.Op.Journal {
+ // A fence journaled without its observation time (a move
+ // converted to relocating, which binds fence.<box> alone) owes
+ // the hold too: absence is never evidence, so the pin waits
+ // until FenceSource has consumed the fence and journaled when
+ // it was observed (H3).
+ if strings.HasPrefix(key, "fence.") && !strings.HasSuffix(key, ".observed_at") && c.Op.Journal[key+".observed_at"] == "" {
+ return fmt.Errorf("%w: %s has no observation time; consume the fence through FenceSource before pinning", ErrRelocation, key)
+ }
+ }
for key, at := range c.Op.Journal {
if len(key) > len(".observed_at") && key[len(key)-len(".observed_at"):] == ".observed_at" {
t, err := time.Parse(time.RFC3339Nano, at)
diff --git a/internal/hostclaim/relocation_test.go b/internal/hostclaim/relocation_test.go
index 46a043141..8a1a41055 100644
--- a/internal/hostclaim/relocation_test.go
+++ b/internal/hostclaim/relocation_test.go
@@ -1096,3 +1096,42 @@ func TestRelocationParksAPDroppedFromProbeFailed(t *testing.T) {
t.Fatalf("P1 was not parked: %+v", o.Claim.Obligations)
}
}
+
+// Fix round 1: a move converted to relocating journals the source's fence
+// without its observation time. The claim refuses a pin until FenceSource
+// has consumed that fence, and then holds it FailoverPinDelay past the
+// fence's observed_at (H3), as it does for a failover.
+func TestRelocationConvertedMovePinsOnlyAfterFenceSourceAndTheHold(t *testing.T) {
+ ctx := context.Background()
+ s, o, ref, a := c7Fixture(t)
+ o, err := ConvertMovingToRelocating(ctx, s, o, "move-1", ref, a, &c7Guard{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ observed, err := time.Parse(time.RFC3339Nano, a.want.ObservedAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pin := func(o Observed, at time.Time) RecoveryPin {
+ return RecoveryPin{Key: RecoveryPinKey(o.Claim.Host, "move-1", o.Claim.Gen), SHA256: strings.Repeat("a", 64), Generation: "gen-current", MaxTXID: 40,
+ FilesManifest: "instances/x/files/gen-current/manifest.json", FilesSeq: 7, PinnedAt: at}
+ }
+ for _, late := range []time.Duration{time.Second, time.Hour} {
+ if _, err := Update(ctx, s, o, JournalPin("move-1", pin(o, observed.Add(late)))); !errors.Is(err, ErrRelocation) {
+ t.Fatalf("pinned %s after a fence FenceSource never consumed: %v", late, err)
+ }
+ }
+ if o, err = FenceSource(ctx, s, o, "move-1", ref, nil, a); err != nil {
+ t.Fatal(err)
+ }
+ if o.Claim.Op.Journal["fence.adi-knitting.observed_at"] != a.want.ObservedAt || !slices.Contains(o.Claim.Lost, "adi-knitting") {
+ t.Fatalf("FenceSource on the converted move: %+v lost %v", o.Claim.Op.Journal, o.Claim.Lost)
+ }
+ if _, err := Update(ctx, s, o, JournalPin("move-1", pin(o, observed.Add(FailoverPinDelay-time.Second)))); !errors.Is(err, ErrRelocation) {
+ t.Fatalf("pinned inside the 30-second window: %v", err)
+ }
+ o = step(t, s, o, JournalPin("move-1", pin(o, observed.Add(FailoverPinDelay))))
+ if !readJournal(o.Claim.Op, "pin", new(RecoveryPin)) {
+ t.Fatalf("pin not journaled: %+v", o.Claim.Op.Journal)
+ }
+}