host: handHold, the hold hand-over that requests a root stage pass (Task 1 fix round 1, ruling J3)
registry.HandHold cannot reach the stage-requested marker, so the box hands a move's hold to its rollback through host's handHold: under WithHostHoldLock, and the marker is touched only when the hand-over lands. A refusal, an absent row and a replay touch nothing. Later tasks call the wrapper, never registry.HandHold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+88
−2
internal/host/moveshold.go+35 −0internal/host/moveshold_test.go+50 −0internal/registry/registry.go+3 −2
diff --git a/internal/host/moveshold.go b/internal/host/moveshold.go| index 9128e77ea..3e791cf20 100644 |
| --- a/internal/host/moveshold.go |
| +++ b/internal/host/moveshold.go |
| @@ -8,6 +8,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/schedule" |
| ) |
| @@ -283,3 +284,37 @@ func holdStageRequester(dd string) func(host string) { |
| }) |
| return func(host string) { req(host, "", nil) } |
| } |
| + |
| +// handHold is the box's hand-over of a move's hold to its rollback (ruling |
| +// J3): registry.HandHold under WithHostHoldLock, then written (in production |
| +// holdStageRequester) only when the hand-over landed, so root re-judges what |
| +// it staged under the new hold. A refusal, an absent row or a replay touches |
| +// nothing. Every caller hands a hold over through this wrapper, never through |
| +// registry.HandHold directly, which cannot reach the marker. |
| +// |
| +// true means the row is held status under toOp: the hand-over landed, or a |
| +// replay found it already done. false with no error means nothing matched: |
| +// the row is held by some other op, unheld, or absent. |
| +func handHold(reg *registry.Registry, written func(host string)) func(host, status, toOp, fromOp string) (bool, error) { |
| + return func(host, status, toOp, fromOp string) (bool, error) { |
| + var handed bool |
| + var err error |
| + provision.WithHostHoldLock(host, func() { |
| + var rt *registry.Route |
| + if rt, err = reg.Get(host); err != nil || rt == nil { |
| + return |
| + } |
| + if rt.HoldOp == toOp && rt.Status == status && toOp != "" { |
| + handed = true |
| + return |
| + } |
| + if handed, err = reg.HandHold(host, status, toOp, fromOp); err != nil || !handed { |
| + return |
| + } |
| + if written != nil { |
| + written(host) |
| + } |
| + }) |
| + return handed, err |
| + } |
| +} |
diff --git a/internal/host/moveshold_test.go b/internal/host/moveshold_test.go| index 373c366d3..3ac4b21eb 100644 |
| --- a/internal/host/moveshold_test.go |
| +++ b/internal/host/moveshold_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "errors" |
| "net/http" |
| + "os" |
| "path/filepath" |
| "testing" |
| "time" |
| @@ -318,3 +319,52 @@ func TestMovesHoldsAreMainWithTheSwitchOff(t *testing.T) { |
| t.Fatal("no reconcile hooks with the switch on") |
| } |
| } |
| + |
| +// Ruling J3 (fix round 1): the hand-over of a move's hold to its rollback |
| +// touches the stage-requested marker when it lands, and only then. |
| +func TestHandHoldTouchesTheStageMarkerOnlyWhenItLands(t *testing.T) { |
| + const rb = "rb_abcdefghijklmnopqrstuvwxyz" |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + if err := reg.Upsert(registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", Status: "ready", Provisioned: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetHold(p1Host, "landing", p1ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + marker := func(dd string) bool { |
| + _, err := os.Stat(filepath.Join(dd, stageRequestMarker)) |
| + return err == nil |
| + } |
| + |
| + // A refusal: the row is held by p1ID, not p1ID2. Nothing moves, no marker. |
| + refused := t.TempDir() |
| + if ok, err := handHold(reg, holdStageRequester(refused))(p1Host, "releasing", rb, p1ID2); err != nil || ok || marker(refused) { |
| + t.Fatalf("refused hand-over: %v %v, marker %v", ok, err, marker(refused)) |
| + } |
| + if rt, _ := reg.Get(p1Host); rt.Status != "landing" || rt.HoldOp != p1ID { |
| + t.Fatalf("row changed by a refusal: %+v", rt) |
| + } |
| + // An absent row: no marker. |
| + if ok, err := handHold(reg, holdStageRequester(refused))("absent.example.com", "releasing", rb, p1ID); err != nil || ok || marker(refused) { |
| + t.Fatalf("absent row: %v %v, marker %v", ok, err, marker(refused)) |
| + } |
| + |
| + // The hand-over: the row is the rollback's, and the marker is written. |
| + landed := t.TempDir() |
| + if ok, err := handHold(reg, holdStageRequester(landed))(p1Host, "releasing", rb, p1ID); err != nil || !ok || !marker(landed) { |
| + t.Fatalf("hand-over: %v %v, marker %v", ok, err, marker(landed)) |
| + } |
| + if rt, _ := reg.Get(p1Host); rt.Status != "releasing" || rt.HoldOp != rb { |
| + t.Fatalf("after the hand-over: %+v", rt) |
| + } |
| + |
| + // A replay finds the row already the rollback's: done, nothing written. |
| + var touched []string |
| + if ok, err := handHold(reg, func(h string) { touched = append(touched, h) })(p1Host, "releasing", rb, p1ID); err != nil || !ok || len(touched) != 0 { |
| + t.Fatalf("replay: %v %v, marker touched %d times", ok, err, len(touched)) |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index ad3a2d270..73b5481f4 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -1127,8 +1127,9 @@ func (r *Registry) SetHoldIf(host, status, op, expectOp string) (bool, error) { |
| // with expectOp "" is that), and a row held by any other op is left alone. |
| // false with no error means nothing matched: the row's hold changed since |
| // the caller read it (a replay finds it already handed over), or there is |
| -// no row. Callers hold WithHostHoldLock over a fresh record read and, as |
| -// with SetHold, touch the stage-requested marker after a write that landed. |
| +// no row. The box never calls this directly: internal/host's handHold |
| +// wraps it under WithHostHoldLock and touches the stage-requested marker |
| +// after a hand-over that landed, which this package cannot reach. |
| func (r *Registry) HandHold(host, status, toOp, fromOp string) (bool, error) { |
| if !IsHold(status) || toOp == "" || fromOp == "" || toOp == fromOp { |
| return false, fmt.Errorf("registry: hand %s from %q to %q as %q is not a hand-over between two ops' holds", host, fromOp, toOp, status) |