registry: HandHold, the move's hold handed to its rollback in one statement (ruling J3)
One UPDATE ... WHERE hold_op = fromOp: the row is never unheld between the move id and the rollback id. An unheld row, a row held by another op and an absent row match nothing. Callers come in later tasks, under WithHostHoldLock, and touch the stage-requested marker after a write that landed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+79
−0
internal/registry/hold_test.go+56 −0internal/registry/registry.go+23 −0
diff --git a/internal/registry/hold_test.go b/internal/registry/hold_test.go| index 34d016f56..6a4422504 100644 |
| --- a/internal/registry/hold_test.go |
| +++ b/internal/registry/hold_test.go |
| @@ -468,3 +468,59 @@ func TestSetHoldIfIsConditionalOnTheCurrentOp(t *testing.T) { |
| t.Fatalf("hold after release: %v %v", ok, err) |
| } |
| } |
| + |
| +// Ruling J3: a rollback takes over the move's hold in one statement, so the |
| +// row is never unheld between the two ops. |
| +func TestHandHold(t *testing.T) { |
| + const rb = "rb_abcdefghijklmnopqrstuvwxyz" |
| + r := holdFixture(t, "h") |
| + if err := r.SetHold("h", "landing", holdOpA); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Held by another op: nothing moves. |
| + if ok, err := r.HandHold("h", "releasing", rb, holdOpB); err != nil || ok { |
| + t.Fatalf("hand-over from an op that does not hold the row: %v %v", ok, err) |
| + } |
| + if rt, _ := r.Get("h"); rt.Status != "landing" || rt.HoldOp != holdOpA { |
| + t.Fatalf("row changed by the refusal: %+v", rt) |
| + } |
| + // The hand-over: one write, the new op and status. |
| + if ok, err := r.HandHold("h", "releasing", rb, holdOpA); err != nil || !ok { |
| + t.Fatalf("hand-over: %v %v", ok, err) |
| + } |
| + if rt, _ := r.Get("h"); rt.Status != "releasing" || rt.HoldOp != rb { |
| + t.Fatalf("after the hand-over: %+v", rt) |
| + } |
| + // A replay finds the row already handed over and matches nothing. |
| + if ok, err := r.HandHold("h", "releasing", rb, holdOpA); err != nil || ok { |
| + t.Fatalf("replay: %v %v", ok, err) |
| + } |
| + // An unheld row is not taken: that is SetHoldIf's, with expectOp "". |
| + if err := r.Upsert(Route{Host: "plain", Kind: "instance", Socket: "/s"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ok, err := r.HandHold("plain", "restoring", rb, holdOpA); err != nil || ok { |
| + t.Fatalf("unheld row: %v %v", ok, err) |
| + } |
| + if rt, _ := r.Get("plain"); rt.HoldOp != "" || IsHold(rt.Status) { |
| + t.Fatalf("unheld row taken: %+v", rt) |
| + } |
| + // An absent row matches nothing. |
| + if ok, err := r.HandHold("absent", "restoring", rb, holdOpA); err != nil || ok { |
| + t.Fatalf("absent row: %v %v", ok, err) |
| + } |
| + // Malformed: not a hold status, an empty op on either side, the same op. |
| + for name, c := range map[string][3]string{ |
| + "ready": {"ready", rb, rb}, |
| + "no to": {"restoring", "", holdOpA}, |
| + "no from": {"restoring", rb, ""}, |
| + "same op": {"restoring", rb, rb}, |
| + } { |
| + if ok, err := r.HandHold("h", c[0], c[1], c[2]); err == nil || ok { |
| + t.Fatalf("%s: %v %v", name, ok, err) |
| + } |
| + } |
| + if rt, _ := r.Get("h"); rt.Status != "releasing" || rt.HoldOp != rb { |
| + t.Fatalf("row changed by a malformed hand-over: %+v", rt) |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index f8642d645..ad3a2d270 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -1118,6 +1118,29 @@ func (r *Registry) SetHoldIf(host, status, op, expectOp string) (bool, error) { |
| return n > 0, err |
| } |
| +// HandHold hands a held row from one op to another in one statement: the |
| +// row is held status under toOp only while its hold_op is still fromOp |
| +// (moves Plan 4, ruling J3). It is the one takeover there is, between the |
| +// two ops of one move: the move id hands its landing or moving hold to the |
| +// rollback id. Releasing first and holding after would leave the row |
| +// unheld between the two writes. An unheld row is never taken (SetHoldIf |
| +// 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. |
| +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) |
| + } |
| + res, err := r.sql.Exec(`UPDATE routes SET status = ?, hold_op = ? WHERE host = ? AND hold_op = ?`, status, toOp, host, fromOp) |
| + if err != nil { |
| + return false, err |
| + } |
| + n, err := res.RowsAffected() |
| + return n > 0, err |
| +} |
| + |
| // ReleaseHold is §5.2's one conditional statement: a row leaves a hold only |
| // under the op that holds it. false means nothing matched (a late write from a |
| // finished operation, an unheld row, or an empty op). |