carlos / platform Public

host: the relocation pass says a claim it cannot read rather than pass it over, a full pass that could not be built at startup is tried again every pass behind the completion-only runtime rather than read as no relocation on the box, and the agent says why it starts no completion pass in claim mode; hostrelocate: a converted landing's wait with no readable fence observation is refused with the chooser's answer rather than left unbounded

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 4a2cbfb38e42509d886b69b85cb0e88cc440edd0 parent 210ce9d
4 files changed, +290 −23
  • internal/host/relocatepass.go +81 −18
  • internal/host/relocatepass_absence_test.go +140 −0
  • internal/hostrelocate/worker.go +20 −5
  • internal/hostrelocate/worker_bounds_test.go +49 −0
diff --git a/internal/host/relocatepass.go b/internal/host/relocatepass.go
index 5ab01a894..eda18ecd5 100644
--- a/internal/host/relocatepass.go
+++ b/internal/host/relocatepass.go
@@ -58,6 +58,12 @@ type RelocationRuntime struct {
Self func(context.Context) (string, error)
DataDir string // the managed data root: relocate/ journals (N9)
alerted sync.Map // host\x00cause: one alert per host and cause (rulings 35, 36)
+ // upgrade, when set, builds the full pass this completion-only runtime
+ // stands in for: region HA is on, but the build failed (a roster, an
+ // enrollment or a key that could not be read). Each pass tries it
+ // again until it builds, or answers that the owners are not bound. A
+ // read that failed once is never taken as "no relocation on this box".
+ upgrade func(context.Context) (*RelocationRuntime, error)
}
// relocationStepper is hostrelocate.Worker's and Participant's Step.
@@ -385,6 +391,7 @@ func (r *RelocationRuntime) Run(ctx context.Context, store release.Store, serial
// found from the claim's record (ruling 80). A landing whose target is
// fenced is converted when its move committed (J3).
func (r *RelocationRuntime) once(ctx context.Context, store release.Store) {
+ r.tryUpgrade(ctx)
hosts, err := r.hosts(ctx, store)
if err != nil {
log.Printf("relocate: list hosts: %v", err)
@@ -400,7 +407,13 @@ func (r *RelocationRuntime) once(ctx context.Context, store release.Store) {
}
for _, host := range hosts {
obs, err := hostclaim.Read(ctx, store, host)
- if err != nil || obs.Absent {
+ if err != nil {
+ // A claim that cannot be read is not "nothing to do": said once,
+ // and read again next pass.
+ r.alert(host, "read", err)
+ continue
+ }
+ if obs.Absent {
continue
}
switch obs.Claim.Op.Kind {
@@ -434,6 +447,27 @@ func (r *RelocationRuntime) once(ctx context.Context, store release.Store) {
}
}
+// tryUpgrade replaces a completion-only stand-in with the full pass once
+// it builds (upgrade). A build that answers no relocation owners ends the
+// tries: completions are then all this box runs, as NewRelocationRuntime
+// would have answered at startup.
+func (r *RelocationRuntime) tryUpgrade(ctx context.Context) {
+ if r.upgrade == nil {
+ return
+ }
+ full, err := r.upgrade(ctx)
+ if err != nil {
+ r.alert("", "region HA: relocation pass not built, finishing completions only", err)
+ return
+ }
+ r.upgrade = nil
+ if full == nil || full.Worker == nil {
+ return
+ }
+ r.Worker, r.Participant, r.Box, r.Convert, r.Finish, r.Hosts, r.Self, r.DataDir = full.Worker, full.Participant, full.Box, full.Convert, full.Finish, full.Hosts, full.Self, full.DataDir
+ log.Printf("agent: region HA: relocation pass started")
+}
+
// hosts is every claim in the bucket, or Hosts when it is set.
func (r *RelocationRuntime) hosts(ctx context.Context, store release.Store) ([]string, error) {
if r.Hosts != nil {
@@ -519,6 +553,10 @@ func convertFencedLanding(w hostrelocate.Worker, store release.Store) func(conte
func (r *RelocationRuntime) alert(host, who string, err error) {
if _, said := r.alerted.LoadOrStore(host+"\x00"+err.Error(), struct{}{}); !said {
+ if host == "" {
+ log.Printf("relocate: %s: %v", who, err)
+ return
+ }
log.Printf("relocate %s: %s: %v", host, who, err)
}
}
@@ -549,11 +587,14 @@ func registerRelocationSource(claimCtx context.Context, dataDir string) error {
// activator, or when not even the completion pass can be built (logged).
func startRelocationPass(agentCtx, claimCtx context.Context, claims *ClaimWiring, reg *registry.Registry, svc *activator.Service) *RelocationRuntime {
getenv := claimEntryEnv(claimCtx)
- if !hostwrite.Enabled(getenv) || claims == nil || svc == nil {
+ if !hostwrite.Enabled(getenv) {
return nil
}
entry, _ := claimCtx.Value(claimEntryKey{}).(claimEntry)
- if entry.Assembly == nil {
+ // Claim mode is on, so a completion recorded on this box must be
+ // finished here (ruling 82): every way of not starting the pass is said.
+ if claims == nil || svc == nil || entry.Assembly == nil {
+ log.Printf("agent: relocation completion pass not started: claim mode is on but the claim wiring, the activator or the claim assembly is missing")
return nil
}
local, ok := entry.Assembly.Local.(*productionClaimLocal)
@@ -561,25 +602,47 @@ func startRelocationPass(agentCtx, claimCtx context.Context, claims *ClaimWiring
log.Printf("agent: region HA: relocation pass not started: the assembly has no production participation owner")
return nil
}
- var runtime *RelocationRuntime
- var err error
+ var full func(context.Context) (*RelocationRuntime, error)
if regionHAEnabled(getenv) {
- var in relocationRuntimeInputs
- if in, err = productionRelocationRuntimeInputs(os.Getenv, release.OptDir()); err == nil {
- runtime, err = NewRelocationRuntime(agentCtx, getenv, entry.Store, local, claims.Providers, reg, svc, in)
- }
- if err != nil {
- log.Printf("agent: region HA: relocation pass not started, finishing completions only: %v", err)
+ full = func(ctx context.Context) (*RelocationRuntime, error) {
+ in, err := productionRelocationRuntimeInputs(os.Getenv, release.OptDir())
+ if err != nil {
+ return nil, err
+ }
+ return NewRelocationRuntime(ctx, getenv, entry.Store, local, claims.Providers, reg, svc, in)
}
}
- if runtime == nil {
- // Ruling 82: with claim mode on, a relocation that completed here
- // is finished whatever CARLOS_REGION_HA says.
- if runtime, err = newCompletionRuntime(local, reg, svc); err != nil {
- log.Printf("agent: relocation completion pass not started: %v", err)
- return nil
- }
+ runtime, err := relocationPassFor(agentCtx, local, reg, svc, full)
+ if err != nil {
+ log.Printf("agent: relocation completion pass not started: %v", err)
+ return nil
}
go runtime.Run(agentCtx, entry.Store, nil)
return runtime
}
+
+// relocationPassFor is the runtime the agent runs in claim mode: the full
+// pass when full (region HA on) builds it, otherwise the completion-only
+// one (ruling 82). A full build that fails is not "no relocation on this
+// box": the completion-only runtime stands in and tries the build again
+// every pass (upgrade) until it builds or answers that the owners are not
+// bound.
+func relocationPassFor(ctx context.Context, local *productionClaimLocal, reg *registry.Registry, act RelocationActivator, full func(context.Context) (*RelocationRuntime, error)) (*RelocationRuntime, error) {
+ var failed error
+ if full != nil {
+ r, err := full(ctx)
+ if err == nil && r != nil {
+ return r, nil
+ }
+ failed = err
+ }
+ r, err := newCompletionRuntime(local, reg, act)
+ if err != nil {
+ return nil, err
+ }
+ if failed != nil {
+ r.alert("", "region HA: relocation pass not built, finishing completions only", failed)
+ r.upgrade = full
+ }
+ return r, nil
+}
diff --git a/internal/host/relocatepass_absence_test.go b/internal/host/relocatepass_absence_test.go
new file mode 100644
index 000000000..b94ebc7e6
--- /dev/null
+++ b/internal/host/relocatepass_absence_test.go
@@ -0,0 +1,140 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// unreadableClaim is a control bucket whose reads of one claim key fail.
+type unreadableClaim struct {
+ release.Store
+ key string
+}
+
+func (s unreadableClaim) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == s.key {
+ return nil, "", errors.New("the claim could not be read")
+ }
+ return s.Store.Get(ctx, key)
+}
+
+// Absence is never evidence: a claim the pass cannot read is said, once,
+// and read again next pass; it is never passed over as nothing to do. The
+// other hosts' steps run as before.
+func TestRelocatePassSaysAClaimItCannotRead(t *testing.T) {
+ ctx := t.Context()
+ dir := release.NewDirStore(t.TempDir())
+ passClaim(t, dir, "a.example", relocatingOp("rel-a"))
+ passClaim(t, dir, "b.example", relocatingOp("rel-b"))
+ key, _ := hostclaim.Key("b.example")
+ store := unreadableClaim{Store: dir, key: key}
+ buf := captureRacyLog(t)
+ var order []string
+ r := &RelocationRuntime{Worker: stepRecorder{name: "worker", order: &order}, Participant: stepRecorder{name: "participant", order: &order}, Box: "dst"}
+ r.once(ctx, store)
+ r.once(ctx, store)
+ if want := []string{"participant:a.example", "worker:a.example", "participant:a.example", "worker:a.example"}; !slices.Equal(order, want) {
+ t.Fatalf("stepped %v, want %v", order, want)
+ }
+ if n := strings.Count(buf.String(), "relocate b.example: read: the claim could not be read"); n != 1 {
+ t.Fatalf("an unreadable claim was said %d times, want once:\n%s", n, buf.String())
+ }
+}
+
+// Absence is never evidence: with region HA on, a full pass that could not
+// be built at startup (the roster, the enrollment or the release key not
+// readable then) is not "no relocation on this box". The completion-only
+// runtime stands in, finishing completions, and tries the build again each
+// pass until it builds; then the same Run is the full pass. A build that
+// answers the owners are not bound ends the tries.
+func TestRelocationPassBuildsTheFullPassOnceItCan(t *testing.T) {
+ ctx := t.Context()
+ store := release.NewDirStore(t.TempDir())
+ passClaim(t, store, "a.example", relocatingOp("rel-a"))
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ local := &productionClaimLocal{assembly: &ClaimAssembly{Claims: store, Guard: hostguard.Guard{DataDir: t.TempDir()}, stopLocal: func(context.Context) (claimStopLocal, error) {
+ return claimStopLocal{Actor: hostbox.Actor{Box: "dst"}}, nil
+ }}}
+ buf := captureRacyLog(t)
+ var order []string
+ builds := 0
+ unreadable := errors.New("the roster could not be read")
+ full := func(context.Context) (*RelocationRuntime, error) {
+ builds++
+ if builds <= 2 {
+ return nil, unreadable
+ }
+ return &RelocationRuntime{Worker: stepRecorder{name: "worker", order: &order}, Participant: stepRecorder{name: "participant", order: &order}, Box: "dst",
+ Finish: func(context.Context, string, string) error { return nil }}, nil
+ }
+ r, err := relocationPassFor(ctx, local, reg, &activator.Service{}, full)
+ if err != nil || r == nil || r.Worker != nil || r.Finish == nil || r.Hosts == nil {
+ t.Fatalf("not the completion-only stand-in: %+v %v", r, err)
+ }
+ r.once(ctx, store) // the second build fails too
+ if len(order) != 0 || builds != 2 {
+ t.Fatalf("stepped %v after %d builds", order, builds)
+ }
+ r.once(ctx, store) // the third builds
+ if want := []string{"participant:a.example", "worker:a.example"}; !slices.Equal(order, want) || r.Box != "dst" || r.Hosts != nil {
+ t.Fatalf("not upgraded: stepped %v, %+v", order, r)
+ }
+ r.once(ctx, store)
+ if builds != 3 {
+ t.Fatalf("built %d times, want no more once built", builds)
+ }
+ if n := strings.Count(buf.String(), unreadable.Error()); n != 1 {
+ t.Fatalf("the failed build was said %d times, want once:\n%s", n, buf.String())
+ }
+ // Owners not bound: the build answers the completion-only runtime, and
+ // the tries end.
+ builds = 0
+ r, err = relocationPassFor(ctx, local, reg, &activator.Service{}, func(context.Context) (*RelocationRuntime, error) {
+ builds++
+ if builds == 1 {
+ return nil, unreadable
+ }
+ return newCompletionRuntime(local, reg, &activator.Service{})
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.once(ctx, store)
+ r.once(ctx, store)
+ if builds != 2 || r.Worker != nil {
+ t.Fatalf("built %d times (want 2), worker %v", builds, r.Worker)
+ }
+}
+
+// Claim mode is on, so a completion recorded here must be finished here:
+// the agent never leaves the completion pass unstarted without saying so.
+func TestStartRelocationPassSaysWhyItDidNotStart(t *testing.T) {
+ buf := captureRacyLog(t)
+ claimCtx := context.WithValue(t.Context(), claimEntryKey{}, claimEntry{Enabled: true, FlagRead: true})
+ if r := startRelocationPass(t.Context(), claimCtx, &ClaimWiring{}, nil, &activator.Service{}); r != nil {
+ t.Fatalf("started over no assembly: %+v", r)
+ }
+ if !strings.Contains(buf.String(), "relocation completion pass not started") {
+ t.Fatalf("not said:\n%s", buf.String())
+ }
+ off := context.WithValue(t.Context(), claimEntryKey{}, claimEntry{FlagRead: true})
+ before := buf.String()
+ if r := startRelocationPass(t.Context(), off, &ClaimWiring{}, nil, &activator.Service{}); r != nil || buf.String() != before {
+ t.Fatalf("claim mode off: %+v %q", r, strings.TrimPrefix(buf.String(), before))
+ }
+}
diff --git a/internal/hostrelocate/worker.go b/internal/hostrelocate/worker.go
index 97718804a..669261f7b 100644
--- a/internal/hostrelocate/worker.go
+++ b/internal/hostrelocate/worker.go
@@ -231,18 +231,29 @@ func (w Worker) ConvertLanding(ctx context.Context, obs hostclaim.Observed, guar
}
// newestFenceObservation is the latest fence.<box>.observed_at the op
-// journals.
-func newestFenceObservation(c *hostclaim.Claim) (time.Time, bool) {
+// journals. An observation that does not parse, or none at all, is an
+// error, never "no bound" (absence is never evidence): the conversion
+// journals its target's fence observation in the same CAS that leaves the
+// op waiting, so a wait with nothing to bound it is a defect, refused as
+// an unreadable installed_at is.
+func newestFenceObservation(c *hostclaim.Claim) (time.Time, error) {
var newest time.Time
for k, v := range c.Op.Journal {
if !strings.HasPrefix(k, "fence.") || !strings.HasSuffix(k, ".observed_at") {
continue
}
- if at, err := time.Parse(time.RFC3339Nano, v); err == nil && at.After(newest) {
+ at, err := time.Parse(time.RFC3339Nano, v)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("%w: %s of %s: %v", ErrRefused, k, c.Op.ID, err)
+ }
+ if at.After(newest) {
newest = at
}
}
- return newest, !newest.IsZero()
+ if newest.IsZero() {
+ return time.Time{}, fmt.Errorf("%w: %s journals no fence observation to bound its wait for a target", ErrRefused, c.Op.ID)
+ }
+ return newest, nil
}
// Start writes the start CAS for req (§5.8 "The start CAS", §5.10). It
@@ -398,7 +409,11 @@ func (w Worker) Step(ctx context.Context, host string) (hostclaim.Observed, erro
// target is stuck ReleaseDeadline after its target's
// fence was observed; before that the chooser's refusal
// is returned and the pass alerts once.
- if at, ok := newestFenceObservation(c); ok && w.Now().Sub(at) > ReleaseDeadline {
+ at, ferr := newestFenceObservation(c)
+ if ferr != nil {
+ return obs, errors.Join(err, ferr)
+ }
+ if w.Now().Sub(at) > ReleaseDeadline {
return w.update(ctx, obs, hostclaim.MarkStuck(id, fmt.Sprintf("no eligible box for the converted landing after %s: %v", ReleaseDeadline, err)))
}
return obs, err
diff --git a/internal/hostrelocate/worker_bounds_test.go b/internal/hostrelocate/worker_bounds_test.go
index 8dc09b658..848005eb6 100644
--- a/internal/hostrelocate/worker_bounds_test.go
+++ b/internal/hostrelocate/worker_bounds_test.go
@@ -215,3 +215,52 @@ func TestWorkerBoundsAConversionWaitingForATarget(t *testing.T) {
t.Fatalf("not stuck for an operator: %+v", o.Claim.Op)
}
}
+
+// Absence is never evidence: a converted landing whose fence observation
+// does not parse, or is gone, has nothing to bound its wait. The chooser's
+// refusal comes back with that defect named (ErrRefused), long after the
+// bound would have passed, and nothing is written: it is neither waited on
+// silently for ever nor stuck on a guess.
+func TestWorkerRefusesAConversionWaitWithNoReadableFenceObservation(t *testing.T) {
+ for name, change := range map[string]func(map[string]string){
+ "unparseable": func(j map[string]string) {
+ for k := range j {
+ if strings.HasPrefix(k, "fence.") && strings.HasSuffix(k, ".observed_at") {
+ j[k] = "not a time"
+ }
+ }
+ },
+ "unparseable beside a readable one": func(j map[string]string) {
+ for k := range j {
+ if strings.HasPrefix(k, "fence.") && strings.HasSuffix(k, ".observed_at") {
+ j[k] = "not a time"
+ }
+ }
+ j["fence.spa.observed_at"] = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
+ },
+ "missing": func(j map[string]string) {
+ for k := range j {
+ if strings.HasPrefix(k, "fence.") && strings.HasSuffix(k, ".observed_at") {
+ delete(j, k)
+ }
+ }
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ f := newFixture(t)
+ converted := f.step(f.convertedLanding(), func(c *hostclaim.Claim) error { change(c.Op.Journal); return nil })
+ none := errors.New("no eligible box")
+ f.w.Choose = func(context.Context, hostclaim.Observed, []string) (hostclaim.Target, error) {
+ return hostclaim.Target{}, none
+ }
+ f.now = f.now.Add(10 * ReleaseDeadline)
+ o, err := f.w.Step(context.Background(), host)
+ if !errors.Is(err, none) || !errors.Is(err, ErrRefused) {
+ t.Fatalf("step: %v, want the chooser's refusal and the defect", err)
+ }
+ if o.ETag != converted.ETag || f.claim().ETag != converted.ETag {
+ t.Fatalf("the op was written: %+v", f.claim().Claim.Op)
+ }
+ })
+ }
+}