mirror: lock.json is coordination state — never copied, never deleted (#331)
Live rehearsal catch: the lease renewer rewrites lock.json every TTL, so mirroring it makes changed==0 unreachable for any awake instance — the move wedged in preparing forever. And promoting it would hand the target a stale foreign lease. Skipped on both sides of every round.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEypt7de2X1G84aQ9rNkCf
2 files changed,
+33
−0
internal/lease/mirror.go+12 −0internal/lease/mirror_test.go+21 −0
diff --git a/internal/lease/mirror.go b/internal/lease/mirror.go| index b0cda7f7d..684bdf312 100644 |
| --- a/internal/lease/mirror.go |
| +++ b/internal/lease/mirror.go |
| @@ -67,6 +67,15 @@ func MirrorPrefix(ctx context.Context, m MirrorStore, srcBucket, srcPrefix, dstB |
| sort.Strings(rel) |
| changed := 0 |
| for _, r := range rel { |
| + // The lease's lock.json is COORDINATION state, not data (live |
| + // rehearsal catch, 2026-08-29): the renewer rewrites it on every |
| + // TTL renewal, so mirroring it makes "changed==0" unreachable for |
| + // any awake instance — and promoting it would hand the target a |
| + // stale foreign lease. Skipped on both sides: never copied, and |
| + // the destination's own copy (if any) is never deleted. |
| + if r == "lock.json" { |
| + continue |
| + } |
| sk, dk := srcPrefix+r, dstPrefix+r |
| if et, ok := dst[dk]; ok && et == src[sk] { |
| continue |
| @@ -78,6 +87,9 @@ func MirrorPrefix(ctx context.Context, m MirrorStore, srcBucket, srcPrefix, dstB |
| } |
| for dk := range dst { |
| r := dk[len(dstPrefix):] |
| + if r == "lock.json" { |
| + continue |
| + } |
| if _, ok := src[srcPrefix+r]; !ok { |
| if err := m.Delete(ctx, dstBucket, dk); err != nil { |
| return changed, fmt.Errorf("mirror: delete %s: %w", dk, err) |
diff --git a/internal/lease/mirror_test.go b/internal/lease/mirror_test.go| index 3ab8f11b0..124b3b896 100644 |
| --- a/internal/lease/mirror_test.go |
| +++ b/internal/lease/mirror_test.go |
| @@ -110,3 +110,24 @@ func TestMirrorPrefixNormalizesBoundaries(t *testing.T) { |
| t.Fatalf("staging keys = %v, want the one properly-joined key", got) |
| } |
| } |
| + |
| +// lock.json is coordination state: never copied, never deleted, and its |
| +// churn (the renewer rewrites it every TTL) must not keep changed above 0. |
| +func TestMirrorPrefixSkipsTheLease(t *testing.T) { |
| + ctx := context.Background() |
| + f := newFakeMirror() |
| + f.put("src", "instances/h/lock.json", "renewal-1") |
| + f.put("src", "instances/h/generations/a/snapshot", "e1") |
| + f.put("dst", "moves/h/lock.json", "old-foreign-lease") |
| + if n, err := MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/"); err != nil || n != 1 { |
| + t.Fatalf("round = %d, %v; want only the snapshot to move", n, err) |
| + } |
| + f.put("src", "instances/h/lock.json", "renewal-2") // the churn |
| + if n, err := MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/"); err != nil || n != 0 { |
| + t.Fatalf("churn round = %d, %v; want caught-up despite the renewal", n, err) |
| + } |
| + got, _ := f.List(ctx, "dst", "moves/h/") |
| + if got["moves/h/lock.json"] != "old-foreign-lease" || len(got) != 2 { |
| + t.Fatalf("staging = %v; the dst lease must be untouched", got) |
| + } |
| +} |