carlos / platform Public

Merge branch 'move-ready-delta-threshold' into main

claude agent, operated by paul@keymail.dev 7edd4b33907a7bfa14394c3755b3e97eee06a5ed parent abe897eparent 167d721
6 files changed, +1379 −111
  • internal/activator/backend_exec.go +21 −4
  • internal/activator/backend_exec_test.go +79 −9
  • internal/host/movespass.go +86 −7
  • internal/host/movespass_test.go +456 −19
  • internal/lease/mirror.go +355 −39
  • internal/lease/mirror_test.go +382 −33

CI passed — run details

diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 615435540..114ee0987 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -284,7 +284,7 @@ func (b *ExecBackend) Restore(ctx context.Context, rt registry.Route) (bool, err
// and back must restore its own lineage, never regress to an old
// staged copy (the source's cut deleted the own prefix it left behind,
// so a non-empty own prefix is by construction the newer story).
- var promoted map[string]string
+ var promoted map[string]lease.MirrorObject
if b.Mirror != nil {
var err error
if promoted, err = b.promoteStagedReplica(ctx, host); err != nil {
@@ -1548,6 +1548,14 @@ func (p *childProc) Stop(ctx context.Context) error {
// package spell it identically.
func MovesPrefix(host string) string { return "moves/" + host + "/" }
+// MoveReceiptsKey is where a residence move's mirror keeps its copy
+// receipts (#175, lease.Receipts) in the SOURCE box's own data bucket. The
+// source cannot read the target's moves/ prefix (its grant there is
+// push-only), so the receipts cannot live beside the staged copy. This key
+// is under neither MovesPrefix nor the hibernate prefix, so no restore,
+// promotion or staging cleanup ever sees it.
+func MoveReceiptsKey(host string) string { return "move-receipts/" + host + ".json" }
+
// PurgeLocalDBArtifacts removes the local litestream footprint next to a
// DB — WAL, SHM and both meta dirs — and, when full is true, the DB file
// itself. Restore's pre-restore cleanup uses the partial form (the DB's
@@ -1569,7 +1577,7 @@ func PurgeLocalDBArtifacts(dbPath string, full bool) {
// promoteStagedReplica copies moves/<host>/ over instances/<host>/ in this
// box's OWN bucket when — and only when — the ordinary prefix is empty. A
// no-op (nil error) when there is nothing staged.
-func (b *ExecBackend) promoteStagedReplica(ctx context.Context, host string) (map[string]string, error) {
+func (b *ExecBackend) promoteStagedReplica(ctx context.Context, host string) (map[string]lease.MirrorObject, error) {
// STAGING WINS when present (review round 1). A staged replica exists
// only while an inbound move is landing or has just landed: it was
// mirrored from the then-live data at cut, so it is by construction
@@ -1590,9 +1598,18 @@ func (b *ExecBackend) promoteStagedReplica(ctx context.Context, host string) (ma
return nil, nil
}
log.Printf("activator: %s: promoting %d staged object(s) from %s (residence move landing)", host, len(staged), MovesPrefix(host))
- if _, err := lease.MirrorPrefix(ctx, b.Mirror, b.Cfg.Bucket, MovesPrefix(host), b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)); err != nil {
+ // No receipts: a staged object is the source box's single-part copy,
+ // and an in-bucket copy of it keeps its ETag, so plain ETag equality
+ // is exact here. A deferred object (staging overwritten mid-promote,
+ // which nothing should do once the move has cut) fails the wake
+ // rather than restoring from a promotion that is not byte-final.
+ round, err := lease.MirrorPrefix(ctx, b.Mirror, b.Cfg.Bucket, MovesPrefix(host), b.Cfg.Bucket, b.Cfg.hibernatePrefix(host), lease.Receipts{})
+ if err != nil {
return nil, err
}
+ if round.Deferred > 0 {
+ return nil, fmt.Errorf("promote staged replica for %s: %d staged object(s) changed during the copy; retrying on the next wake", host, round.Deferred)
+ }
return staged, nil
}
@@ -1600,7 +1617,7 @@ func (b *ExecBackend) promoteStagedReplica(ctx context.Context, host string) (ma
// third listing of a prefix this restore has already walked twice.
// Best-effort: a leftover staging copy is storage, not a correctness
// problem.
-func (b *ExecBackend) dropStagedReplica(ctx context.Context, staged map[string]string) {
+func (b *ExecBackend) dropStagedReplica(ctx context.Context, staged map[string]lease.MirrorObject) {
for k := range staged {
_ = b.Mirror.Delete(ctx, b.Cfg.Bucket, k)
}
diff --git a/internal/activator/backend_exec_test.go b/internal/activator/backend_exec_test.go
index 84f291c12..8b4c41ceb 100644
--- a/internal/activator/backend_exec_test.go
+++ b/internal/activator/backend_exec_test.go
@@ -13,6 +13,7 @@ import (
_ "modernc.org/sqlite"
"github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/registry"
)
@@ -197,26 +198,78 @@ func TestRestoreStagingFallback(t *testing.T) {
f.failList = false
}
-// fakeMirrorStore is the test double for lease.MirrorStore, bucket→key→etag.
+// A move's copy receipts live in the SOURCE box's own bucket at
+// MoveReceiptsKey, and this box may be that source for an earlier move of
+// the same host (a move back). Landing must never take them for data:
+// they are not under MovesPrefix, so they are never promoted, and not
+// under the hibernate prefix, so they are never restored from or swept.
+func TestRestoreIgnoresMoveReceipts(t *testing.T) {
+ ctx := context.Background()
+ b := testExecBackend(t)
+ f := newFakeMirrorStore()
+ b.Mirror = f
+ host := "moved.example.com"
+ rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")}
+
+ receipts := MoveReceiptsKey(host)
+ if strings.HasPrefix(receipts, MovesPrefix(host)) || strings.HasPrefix(receipts, b.Cfg.hibernatePrefix(host)+"/") ||
+ strings.HasPrefix(receipts, b.Cfg.Prefix+"/") {
+ t.Fatalf("receipts key %q sits under a data prefix", receipts)
+ }
+
+ var restoredFrom []string
+ oldRestore := restoreReplica
+ restoreReplica = func(_ context.Context, dbPath, bucket, prefix, region string) (bool, error) {
+ objs, _ := f.List(context.Background(), bucket, prefix)
+ if len(objs) == 0 {
+ return false, nil
+ }
+ restoredFrom = append(restoredFrom, bucket+"/"+prefix)
+ return true, writeMinimalSQLite(dbPath)
+ }
+ defer func() { restoreReplica = oldRestore }()
+
+ // Receipts alone: a fresh boot, nothing promoted, receipts untouched.
+ f.put(b.Cfg.Bucket, receipts, "r1")
+ fresh, err := b.Restore(ctx, rt)
+ if err != nil || !fresh || len(restoredFrom) != 0 {
+ t.Fatalf("receipts alone: fresh=%v err=%v restored=%v, want a fresh boot", fresh, err, restoredFrom)
+ }
+ // Beside a staged copy: only the staged data is promoted.
+ f.put(b.Cfg.Bucket, "moves/"+host+"/ltx/0/a.ltx", "e1")
+ _ = os.Remove(filepath.Join(b.DataDir, host+".db"))
+ if _, err := b.Restore(ctx, rt); err != nil {
+ t.Fatal(err)
+ }
+ own, _ := f.List(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host))
+ if len(own) != 1 {
+ t.Fatalf("own prefix after promotion = %v, want only the staged object", own)
+ }
+ if _, ok := f.buckets[b.Cfg.Bucket][receipts]; !ok {
+ t.Fatal("landing deleted the receipts of a move it did not make")
+ }
+}
+
+// fakeMirrorStore is the test double for lease.MirrorStore, bucket→key→object.
type fakeMirrorStore struct {
- buckets map[string]map[string]string
+ buckets map[string]map[string]lease.MirrorObject
failList bool
}
func newFakeMirrorStore() *fakeMirrorStore {
- return &fakeMirrorStore{buckets: map[string]map[string]string{}}
+ return &fakeMirrorStore{buckets: map[string]map[string]lease.MirrorObject{}}
}
func (f *fakeMirrorStore) put(bucket, key, etag string) {
if f.buckets[bucket] == nil {
- f.buckets[bucket] = map[string]string{}
+ f.buckets[bucket] = map[string]lease.MirrorObject{}
}
- f.buckets[bucket][key] = etag
+ f.buckets[bucket][key] = lease.MirrorObject{ETag: etag}
}
-func (f *fakeMirrorStore) List(_ context.Context, bucket, prefix string) (map[string]string, error) {
+func (f *fakeMirrorStore) List(_ context.Context, bucket, prefix string) (map[string]lease.MirrorObject, error) {
if f.failList {
return nil, context.DeadlineExceeded
}
- out := map[string]string{}
+ out := map[string]lease.MirrorObject{}
for k, v := range f.buckets[bucket] {
if strings.HasPrefix(k, prefix) {
out[k] = v
@@ -224,8 +277,25 @@ func (f *fakeMirrorStore) List(_ context.Context, bucket, prefix string) (map[st
}
return out, nil
}
-func (f *fakeMirrorStore) Copy(_ context.Context, srcBucket, srcKey, dstBucket, dstKey string) error {
- f.put(dstBucket, dstKey, f.buckets[srcBucket][srcKey])
+func (f *fakeMirrorStore) Copy(_ context.Context, srcBucket, srcKey, srcETag, dstBucket, dstKey string) (string, error) {
+ o, ok := f.buckets[srcBucket][srcKey]
+ if !ok || (srcETag != "" && o.ETag != srcETag) {
+ return "", lease.ErrCopySourceChanged
+ }
+ if f.buckets[dstBucket] == nil {
+ f.buckets[dstBucket] = map[string]lease.MirrorObject{}
+ }
+ f.buckets[dstBucket][dstKey] = o
+ return o.ETag, nil
+}
+func (f *fakeMirrorStore) Get(_ context.Context, bucket, key string) ([]byte, error) {
+ if _, ok := f.buckets[bucket][key]; !ok {
+ return nil, lease.ErrMirrorNotFound
+ }
+ return []byte("{}"), nil
+}
+func (f *fakeMirrorStore) Put(_ context.Context, bucket, key string, _ []byte) error {
+ f.put(bucket, key, "receipts")
return nil
}
func (f *fakeMirrorStore) Delete(_ context.Context, bucket, key string) error {
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index 6ec19b166..f64fe95ed 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -9,6 +9,7 @@ import (
"log"
"os"
"path/filepath"
+ "strconv"
"strings"
"sync"
"sync/atomic"
@@ -30,7 +31,14 @@ import (
// same trigger as the reconcile pass (serial bumps land in seconds via
// carlos-adopt.path), and prepare's convergence is the round-over-round
// shrinking of the delta, not a resident goroutine — restart-safe because
-// every step derives from the record and the buckets.
+// every step derives from the record and the buckets. "Converged" is a
+// round that moved (copied or deferred) at most moveCfg.ReadyDelta bytes,
+// 16 MiB by default, not a round that copied nothing: an awake instance
+// never produces the latter (#175). Ready therefore means "small gap", and
+// the cut's final round below is what makes staging exact. "Unchanged" is
+// exact: the copy receipts (lease.Receipts, kept in this box's own bucket
+// at activator.MoveReceiptsKey) are what let a multipart source, whose
+// copy never shares its ETag, count as unchanged at all.
//
// The cut ordering is the never-two-writers argument, mechanically:
//
@@ -89,7 +97,10 @@ func (w *movesWorker) mirror(ctx context.Context, dstRegion string) (lease.Mirro
// the service that answers whether a child is down. Called once, before
// svc.Start.
func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, svc *activator.Service) {
-
+ readyDelta, ok := moveReadyDeltaFromEnv(os.Getenv)
+ if !ok {
+ log.Printf("agent: %s=%q ignored: want a whole number of bytes, 0 or more — keeping the built-in default of %d", moveReadyDeltaEnv, os.Getenv(moveReadyDeltaEnv), defaultMoveReadyDelta)
+ }
cfg := moveCfg{
Region: strings.TrimSpace(os.Getenv("CARLOS_REGION")),
OwnBucket: hib.Bucket,
@@ -128,7 +139,8 @@ func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry,
defer rep.Close(ctx)
return rep.SyncAndWait(ctx)
},
- Now: time.Now,
+ Now: time.Now,
+ ReadyDelta: readyDelta,
}
armedMovesWorker.Store(&movesWorker{cfg: cfg, mirrors: map[string]lease.MirrorStore{}})
}
@@ -184,6 +196,42 @@ type moveCfg struct {
// replica really carries the tail. Nil skips (tests without S3).
Checkpoint func(ctx context.Context, dbPath string) error
Now func() time.Time
+ // ReadyDelta is the readiness bar (#175): a preparing round whose
+ // lease.MirrorRound.DeltaBytes (bytes copied plus bytes deferred) is at
+ // most this promotes the move to ready. Zero means zero bytes moved: a
+ // round that only deleted still qualifies. armMovesPass sets it from
+ // moveReadyDeltaFromEnv.
+ ReadyDelta int64
+}
+
+// defaultMoveReadyDelta is the readiness bar when
+// CARLOS_MOVE_READY_DELTA_BYTES is unset or rejected. A busy instance's
+// per-round churn of new L0 files sits well under 16 MiB, so it reaches
+// ready; a round in which compaction rewrote a large level file (138 MB
+// for the 293 MB database in #175) does not, and waits for a quieter one.
+// Ready keeps mirroring every pass until the cut, so the bar roughly
+// bounds what the cut's final round, run while the instance is down, has
+// left to copy.
+const defaultMoveReadyDelta int64 = 16 << 20
+
+// moveReadyDeltaEnv overrides defaultMoveReadyDelta with a whole number of
+// bytes. Zero is accepted and means a round must move zero bytes.
+const moveReadyDeltaEnv = "CARLOS_MOVE_READY_DELTA_BYTES"
+
+// moveReadyDeltaFromEnv reads the readiness bar. An unset, unparsable or
+// negative value keeps the default, so an operator's typo never stops the
+// agent; ok is false only when a value was SET and rejected, so the caller
+// can say so instead of leaving the operator to believe it took.
+func moveReadyDeltaFromEnv(getenv func(string) string) (delta int64, ok bool) {
+ v := strings.TrimSpace(getenv(moveReadyDeltaEnv))
+ if v == "" {
+ return defaultMoveReadyDelta, true
+ }
+ n, err := strconv.ParseInt(v, 10, 64)
+ if err != nil || n < 0 {
+ return defaultMoveReadyDelta, false
+ }
+ return n, true
}
// movesPass runs one round over every in-flight move this box owns.
@@ -315,6 +363,10 @@ func runMoveRound(ctx context.Context, s release.Store, w *movesWorker, rec prov
mirror := func() (lease.MirrorStore, error) { return w.mirror(ctx, tgt.DataBucketRegion) }
srcPrefix := activator.HibernatePrefix(cfg.OwnPrefix, rec.Host)
dstPrefix := activator.MovesPrefix(rec.Host)
+ // The copy receipts (#175) live in this box's OWN bucket: the staging
+ // grant on the target is push-only, so receipts there could be
+ // written but never read back.
+ receipts := lease.Receipts{Bucket: cfg.OwnBucket, Key: activator.MoveReceiptsKey(rec.Host)}
switch rec.Move.State {
case provision.MovePreparing, provision.MoveReady:
@@ -322,16 +374,22 @@ func runMoveRound(ctx context.Context, s release.Store, w *movesWorker, rec prov
if err != nil {
return err
}
- changed, err := lease.MirrorPrefix(ctx, m, cfg.OwnBucket, srcPrefix, tgt.DataBucket, dstPrefix)
+ round, err := lease.MirrorPrefix(ctx, m, cfg.OwnBucket, srcPrefix, tgt.DataBucket, dstPrefix, receipts)
if err != nil {
return err
}
- if rec.Move.State == provision.MovePreparing && changed == 0 {
+ // Ready means the gap is small, not that it is closed (#175): an
+ // awake instance writes new litestream files between any two
+ // listings, so a round that copied NOTHING never comes. The cut
+ // closes the gap itself (quiesce, fence, final checkpoint, a final
+ // round), so this bar only bounds how much that last round, and
+ // the downtime around it, has left to copy.
+ if rec.Move.State == provision.MovePreparing && round.DeltaBytes() <= cfg.ReadyDelta {
if _, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, provision.MovePromoteReady(cfg.Now())); err != nil && !errors.Is(err, provision.ErrMoveConflict) {
return err
}
nudgeSerial(ctx, s)
- log.Printf("moves: %s: staged replica caught up in %s/%s — ready to cut", rec.Host, tgt.DataBucket, dstPrefix)
+ log.Printf("moves: %s: staged replica caught up in %s/%s (last round copied %d bytes, deferred %d, in %d changes; bar %d) — ready to cut", rec.Host, tgt.DataBucket, dstPrefix, round.BytesCopied, round.BytesDeferred, round.Changed, cfg.ReadyDelta)
}
return nil
@@ -349,6 +407,7 @@ func runMoveRound(ctx context.Context, s release.Store, w *movesWorker, rec prov
if _, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, provision.MoveAbortComplete()); err != nil && !errors.Is(err, provision.ErrMoveConflict) {
return err
}
+ clearMoveReceipts(ctx, m, receipts, rec.Host)
nudgeSerial(ctx, s)
log.Printf("moves: %s: abort complete — staged copy deleted, instance stays in %q", rec.Host, rec.Region)
return nil
@@ -418,15 +477,24 @@ func runMoveRound(ctx context.Context, s release.Store, w *movesWorker, rec prov
if err != nil {
return err
}
- if _, err := lease.MirrorPrefix(ctx, m, cfg.OwnBucket, srcPrefix, tgt.DataBucket, dstPrefix); err != nil {
+ round, err := lease.MirrorPrefix(ctx, m, cfg.OwnBucket, srcPrefix, tgt.DataBucket, dstPrefix, receipts)
+ if err != nil {
return err
}
+ // A deferred object is one the source changed under this round, so
+ // staging is NOT byte-final. Nothing should write a quiesced,
+ // fenced, checkpointed source, but the purge below is
+ // irreversible, so it waits for a round that proves it.
+ if round.Deferred > 0 {
+ return fmt.Errorf("final mirror round: %d object(s) changed at the source during the copy; retrying the cut", round.Deferred)
+ }
// Step 5: the warm-cache purge.
activator.PurgeLocalDBArtifacts(dbPath, true)
// Step 6: the last act — Region flips, Move clears, one write.
if _, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, provision.MoveComplete()); err != nil && !errors.Is(err, provision.ErrMoveConflict) {
return err
}
+ clearMoveReceipts(ctx, m, receipts, rec.Host)
w.nudged.Delete(rec.Host)
// The flip's nudge is what turns the TARGET's mint from a
// 5-minute wait into a seconds-cadence converge.
@@ -437,6 +505,17 @@ func runMoveRound(ctx context.Context, s release.Store, w *movesWorker, rec prov
return nil
}
+// clearMoveReceipts deletes a finished or abandoned move's copy receipts.
+// Best-effort, after the record write that ended the move: receipts are
+// self-checking (a stale one can only fail to match, never vouch for the
+// wrong object), and some deployments' store policy denies DeleteObject on
+// the data bucket, so a failure costs one small object, never a move.
+func clearMoveReceipts(ctx context.Context, m lease.MirrorStore, rc lease.Receipts, host string) {
+ if err := m.Delete(ctx, rc.Bucket, rc.Key); err != nil {
+ log.Printf("moves: %s: could not delete copy receipts %s/%s: %v (harmless; they no longer match anything)", host, rc.Bucket, rc.Key, err)
+ }
+}
+
// nudgeSerial bumps control/serial after a box-side move write, so the
// NEXT actor — this box's own cut continuation, the target's mint, the
// tenant's poll — reacts on the fast-converge path instead of the 5-minute
diff --git a/internal/host/movespass_test.go b/internal/host/movespass_test.go
index 9fca637a1..5f8ed6dd8 100644
--- a/internal/host/movespass_test.go
+++ b/internal/host/movespass_test.go
@@ -3,52 +3,101 @@ package host
import (
"context"
"errors"
+ "fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
+ "github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
-// fakeMoveMirror: bucket→key→etag plus an op log for ordering assertions.
+// fakeMoveMirror keeps S3's ETag rules (lease's fakeMirror explains them):
+// a single-part object's ETag is its body, a multipart one's is
+// body+"-27", and a copy lands single-part with the body as its ETag. An
+// op log records ordering.
type fakeMoveMirror struct {
- buckets map[string]map[string]string
- ops []string
+ buckets map[string]map[string]fakeMoveObject
+ ops []string
+ beforeCopy func(srcKey string)
+}
+
+type fakeMoveObject struct {
+ lease.MirrorObject
+ body string
}
func newFakeMoveMirror() *fakeMoveMirror {
- return &fakeMoveMirror{buckets: map[string]map[string]string{}}
+ return &fakeMoveMirror{buckets: map[string]map[string]fakeMoveObject{}}
+}
+func (f *fakeMoveMirror) put(bucket, key, etag string) { f.putSized(bucket, key, etag, 0) }
+func (f *fakeMoveMirror) putSized(bucket, key, etag string, size int64) {
+ f.store(bucket, key, fakeMoveObject{lease.MirrorObject{ETag: etag, Size: size}, etag})
}
-func (f *fakeMoveMirror) put(bucket, key, etag string) {
+func (f *fakeMoveMirror) putMultipart(bucket, key, body string, size int64) {
+ f.store(bucket, key, fakeMoveObject{lease.MirrorObject{ETag: body + "-27", Size: size}, body})
+}
+func (f *fakeMoveMirror) store(bucket, key string, o fakeMoveObject) {
if f.buckets[bucket] == nil {
- f.buckets[bucket] = map[string]string{}
+ f.buckets[bucket] = map[string]fakeMoveObject{}
}
- f.buckets[bucket][key] = etag
+ f.buckets[bucket][key] = o
}
-func (f *fakeMoveMirror) List(_ context.Context, bucket, prefix string) (map[string]string, error) {
- out := map[string]string{}
+func (f *fakeMoveMirror) List(_ context.Context, bucket, prefix string) (map[string]lease.MirrorObject, error) {
+ out := map[string]lease.MirrorObject{}
for k, v := range f.buckets[bucket] {
if strings.HasPrefix(k, prefix) {
- out[k] = v
+ out[k] = v.MirrorObject
}
}
return out, nil
}
-func (f *fakeMoveMirror) Copy(_ context.Context, srcBucket, srcKey, dstBucket, dstKey string) error {
+func (f *fakeMoveMirror) Copy(_ context.Context, srcBucket, srcKey, srcETag, dstBucket, dstKey string) (string, error) {
+ if f.beforeCopy != nil {
+ f.beforeCopy(srcKey)
+ }
+ o, ok := f.buckets[srcBucket][srcKey]
+ if !ok || (srcETag != "" && o.ETag != srcETag) {
+ return "", lease.ErrCopySourceChanged
+ }
f.ops = append(f.ops, "copy "+dstBucket+"/"+dstKey)
- f.put(dstBucket, dstKey, f.buckets[srcBucket][srcKey])
- return nil
+ f.store(dstBucket, dstKey, fakeMoveObject{lease.MirrorObject{ETag: o.body, Size: o.Size}, o.body})
+ return o.body, nil
}
func (f *fakeMoveMirror) Delete(_ context.Context, bucket, key string) error {
f.ops = append(f.ops, "delete "+bucket+"/"+key)
delete(f.buckets[bucket], key)
return nil
}
+func (f *fakeMoveMirror) Get(_ context.Context, bucket, key string) ([]byte, error) {
+ o, ok := f.buckets[bucket][key]
+ if !ok {
+ return nil, lease.ErrMirrorNotFound
+ }
+ return []byte(o.body), nil
+}
+func (f *fakeMoveMirror) Put(_ context.Context, bucket, key string, body []byte) error {
+ f.ops = append(f.ops, "put "+bucket+"/"+key)
+ f.store(bucket, key, fakeMoveObject{lease.MirrorObject{ETag: "receipts", Size: int64(len(body))}, string(body)})
+ return nil
+}
+
+// bodies is a prefix's content by relative key: what a byte-exact
+// comparison compares (ETags differ across a multipart copy by design).
+func (f *fakeMoveMirror) bodies(bucket, prefix string) map[string]string {
+ out := map[string]string{}
+ for k, v := range f.buckets[bucket] {
+ if strings.HasPrefix(k, prefix) {
+ out[strings.TrimPrefix(k, prefix)] = v.body
+ }
+ }
+ return out
+}
type moveHarness struct {
s release.Store
@@ -78,7 +127,8 @@ func newMoveHarness(t *testing.T) *moveHarness {
h.f.ops = append(h.f.ops, "checkpoint "+filepath.Base(dbPath))
return nil
},
- Now: time.Now,
+ Now: time.Now,
+ ReadyDelta: defaultMoveReadyDelta, // what armMovesPass sets with the env unset
}
// The target's edges file: uk publishes a data bucket.
if _, err := h.s.Put(context.Background(), "control/edges/uk-box.json",
@@ -127,12 +177,12 @@ func TestMovesPassPrepareAndPromote(t *testing.T) {
h := newMoveHarness(t)
host := "a.go.example.com"
h.record(t, host, provision.MoveRequest("uk", time.Now()))
- h.f.put("data-ie", "instances/"+host+"/generations/g/snapshot", "e1")
+ h.f.putSized("data-ie", "instances/"+host+"/generations/g/snapshot", "e1", 64<<20)
h.f.put("data-ie", "instances/"+host+"/generations/g/wal/000", "e2")
- // Round 1: copies happen; the SAME pass does not promote (the round
- // that copied cannot also claim caught-up — a write may have landed
- // mid-copy; only a round that finds NOTHING to do promotes).
+ // Round 1: copies happen; the SAME pass does not promote, because the
+ // round copied more than the readiness bar (the 64 MiB snapshot): the
+ // cut's own round could have that much again to do.
if err := movesPass(context.Background(), h.s, h.worker()); err != nil {
t.Fatal(err)
}
@@ -143,7 +193,7 @@ func TestMovesPassPrepareAndPromote(t *testing.T) {
if len(staged) != 2 {
t.Fatalf("staging = %v", staged)
}
- // Round 2: nothing to do → ready.
+ // Round 2: nothing to do → ready (zero bytes is under any bar).
if err := movesPass(context.Background(), h.s, h.worker()); err != nil {
t.Fatal(err)
}
@@ -476,6 +526,366 @@ func TestMovesPassCutStopsWhenTheFenceFails(t *testing.T) {
}
}
+// moveRound runs one pass and returns the record's move state after it.
+func (h *moveHarness) moveRound(t *testing.T, host string) string {
+ t.Helper()
+ if err := movesPass(context.Background(), h.s, h.worker()); err != nil {
+ t.Fatal(err)
+ }
+ got := h.read(t, host)
+ if got.Move == nil {
+ t.Fatalf("move cleared unexpectedly: %+v", got)
+ }
+ return got.Move.State
+}
+
+// An AWAKE instance never stops writing: litestream lands a new L0 file
+// between every two listings, so no round ever copies nothing. Production
+// (#175, kpmg.bdn.oncarlos.com) sat in preparing for 80 minutes that way.
+// The bar is bytes, not zero: once the initial bulk has moved, a round
+// that copied only the small new files is ready.
+func TestMovesPassBusySourceReachesReady(t *testing.T) {
+ h := newMoveHarness(t)
+ host := "a.go.example.com"
+ src := "instances/" + host + "/generations/g/"
+ h.record(t, host, provision.MoveRequest("uk", time.Now()))
+ h.f.putSized("data-ie", src+"snapshots/0", "s0", 293<<20)
+ h.f.putSized("data-ie", "instances/"+host+"/lock.json", "renewal-0", 200)
+
+ // Round 1 moves the bulk: well over the bar, still preparing.
+ if st := h.moveRound(t, host); st != provision.MovePreparing {
+ t.Fatalf("after the bulk round: %s, want preparing", st)
+ }
+ // Every later round finds a fresh L0 file and a lease renewal: never a
+ // zero-change round, always a small one.
+ st := ""
+ for i := 1; i <= 3 && st != provision.MoveReady; i++ {
+ h.f.putSized("data-ie", src+fmt.Sprint("ltx/0/", i), fmt.Sprint("l0-", i), 8<<10)
+ h.f.putSized("data-ie", "instances/"+host+"/lock.json", fmt.Sprint("renewal-", i), 200)
+ st = h.moveRound(t, host)
+ }
+ if st != provision.MoveReady {
+ t.Fatalf("a busy source with small deltas never reached ready (state %s)", st)
+ }
+}
+
+// A round that copied more than the bar does not promote, however many
+// rounds it takes: compaction rewriting a 138 MiB level file every round
+// (the #175 shape) keeps the move preparing. The bar is inclusive.
+func TestMovesPassLargeDeltaDoesNotPromote(t *testing.T) {
+ h := newMoveHarness(t)
+ host := "a.go.example.com"
+ src := "instances/" + host + "/generations/g/"
+ h.record(t, host, provision.MoveRequest("uk", time.Now()))
+ h.f.putSized("data-ie", src+"snapshots/0", "s0", 293<<20)
+ for i := 0; i < 4; i++ {
+ // Compaction rewrites the level file under a new ETag each round.
+ h.f.putSized("data-ie", src+"ltx/1/compacted", fmt.Sprint("c", i), 138<<20)
+ if st := h.moveRound(t, host); st != provision.MovePreparing {
+ t.Fatalf("round %d copied 138 MiB and promoted to %s", i, st)
+ }
+ }
+
+ // The boundary, with a small bar: one byte over stays preparing, the
+ // bar exactly promotes.
+ h.cfg.ReadyDelta = 1000
+ h.f.putSized("data-ie", src+"ltx/0/a", "a", 1001)
+ if st := h.moveRound(t, host); st != provision.MovePreparing {
+ t.Fatalf("a 1001-byte round under a 1000-byte bar promoted to %s", st)
+ }
+ h.f.putSized("data-ie", src+"ltx/0/b", "b", 1000)
+ if st := h.moveRound(t, host); st != provision.MoveReady {
+ t.Fatalf("a 1000-byte round under a 1000-byte bar left the move %s", st)
+ }
+}
+
+// A zero bar means zero bytes moved: a round that copied (or deferred)
+// even one byte does not promote. A deletion-only round moves no bytes, so
+// it does, which the old Changed == 0 rule did not allow.
+func TestMovesPassZeroBarNeedsZeroCopiedBytes(t *testing.T) {
+ h := newMoveHarness(t)
+ h.cfg.ReadyDelta = 0
+ host := "a.go.example.com"
+ h.record(t, host, provision.MoveRequest("uk", time.Now()))
+ h.f.putSized("data-ie", "instances/"+host+"/generations/g/wal/000", "e1", 1)
+ if st := h.moveRound(t, host); st != provision.MovePreparing {
+ t.Fatalf("a 1-byte round under a zero bar promoted to %s", st)
+ }
+ if st := h.moveRound(t, host); st != provision.MoveReady {
+ t.Fatalf("a quiet round under a zero bar left the move %s", st)
+ }
+
+ // Deletion-only: retention pruned a staged object, nothing to copy.
+ other := "b.go.example.com"
+ h.record(t, other, provision.MoveRequest("uk", time.Now()))
+ h.f.putSized("data-ie", "instances/"+other+"/generations/g/wal/000", "e1", 1)
+ h.moveRound(t, other)
+ delete(h.f.buckets["data-ie"], "instances/"+other+"/generations/g/wal/000")
+ h.f.putSized("data-ie", "instances/"+other+"/generations/g/wal/001", "e2", 0)
+ if st := h.moveRound(t, other); st != provision.MoveReady {
+ t.Fatalf("a zero-byte round (one delete, one empty copy) under a zero bar left the move %s", st)
+ }
+}
+
+// Ready no longer means "staging matched the source". It means the gap was
+// small. The cut's final round still has to make staging exact: what was
+// written after the last ready round, what compaction rewrote, and what
+// retention pruned all reach the target, and the lease never does.
+func TestMovesPassCutAfterBusyReadyLandsByteExact(t *testing.T) {
+ h := newMoveHarness(t)
+ host := "a.go.example.com"
+ own := "instances/" + host + "/"
+ gen := own + "generations/g/"
+ h.record(t, host, provision.MoveRequest("uk", time.Now()))
+ h.f.putSized("data-ie", gen+"snapshots/0", "s0", 293<<20)
+ h.f.putSized("data-ie", gen+"ltx/1/compacted", "c0", 138<<20)
+ h.f.putSized("data-ie", gen+"ltx/0/0", "l0-0", 4<<10)
+ h.f.putSized("data-ie", own+"lock.json", "renewal-0", 200)
+ if st := h.moveRound(t, host); st != provision.MovePreparing {
+ t.Fatalf("after the bulk round: %s", st)
+ }
+ h.f.putSized("data-ie", gen+"ltx/0/1", "l0-1", 4<<10)
+ if st := h.moveRound(t, host); st != provision.MoveReady {
+ t.Fatalf("a 4 KiB round left the move %s, want ready", st)
+ }
+ staged, _ := h.f.List(context.Background(), "data-uk", "moves/"+host+"/")
+ if _, ok := staged["moves/"+host+"/generations/g/ltx/0/1"]; !ok {
+ t.Fatalf("the ready round's own delta was not staged: %v", staged)
+ }
+
+ // After ready and before the cut, the instance keeps writing: a new L0
+ // file, a compaction rewrite, retention pruning an old L0 file, and a
+ // lease renewal.
+ h.f.putSized("data-ie", gen+"ltx/0/2", "l0-2", 4<<10)
+ h.f.putSized("data-ie", gen+"ltx/1/compacted", "c1", 139<<20)
+ delete(h.f.buckets["data-ie"], gen+"ltx/0/0")
+ h.f.putSized("data-ie", own+"lock.json", "renewal-1", 200)
+ if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", host, provision.MoveMarkCutting()); err != nil {
+ t.Fatal(err)
+ }
+ h.quiet = true
+ if err := movesPass(context.Background(), h.s, h.worker()); err != nil {
+ t.Fatal(err)
+ }
+ if got := h.read(t, host); got.Region != "uk" || got.Move != nil {
+ t.Fatalf("after cut: region=%q move=%+v", got.Region, got.Move)
+ }
+
+ assertStagedExact(t, h, host)
+}
+
+// assertStagedExact compares CONTENT, source against staging, lease
+// excluded: a multipart source and its copy carry different ETags by
+// design, so metadata equality would prove nothing.
+func assertStagedExact(t *testing.T, h *moveHarness, host string) {
+ t.Helper()
+ want := h.f.bodies("data-ie", "instances/"+host+"/")
+ delete(want, "lock.json")
+ got := h.f.bodies("data-uk", "moves/"+host+"/")
+ if len(got) != len(want) {
+ t.Fatalf("staged %d objects, want %d:\nstaged %v\nwant %v", len(got), len(want), got, want)
+ }
+ for k, v := range want {
+ if got[k] != v {
+ t.Fatalf("staged %s = %q, want %q (the cut's final round must make staging exact)", k, got[k], v)
+ }
+ }
+}
+
+// kpmgShape stocks a source like kpmg.bdn.oncarlos.com's (#175): a
+// multipart snapshot and six multipart 138 MB level files, all of which
+// land in staging with a DIFFERENT ETag than the source lists.
+func kpmgShape(h *moveHarness, host string) {
+ gen := "instances/" + host + "/ltx/"
+ h.f.putMultipart("data-ie", gen+"9/snapshot.ltx", "snap", 293<<20)
+ for i := 0; i < 6; i++ {
+ h.f.putMultipart("data-ie", fmt.Sprint(gen, "1/", i, ".ltx"), fmt.Sprint("level-", i), 138<<20)
+ }
+ h.f.putSized("data-ie", "instances/"+host+"/lock.json", "renewal-0", 200)
+}
+
+// The production failure (#175): multipart sources never compare equal to
+// their copies, so before receipts every round recopied ~1 GB and the
+// move could never reach ready, busy or not. With receipts, a busy
+// instance whose rounds add only small L0 files reaches ready on the
+// first round after the bulk.
+func TestMovesPassBusyMultipartInstanceReachesReady(t *testing.T) {
+ h := newMoveHarness(t)
+ host := "kpmg.bdn.example.com"
+ h.record(t, host, provision.MoveRequest("uk", time.Now()))
+ kpmgShape(h, host)
+ if st := h.moveRound(t, host); st != provision.MovePreparing {
+ t.Fatalf("after the bulk round: %s, want preparing", st)
+ }
+ h.f.putSized("data-ie", "instances/"+host+"/ltx/0/1.ltx", "l0-1", 8<<10)
+ h.f.putSized("data-ie", "instances/"+host+"/lock.json", "renewal-1", 200)
+ before := len(h.f.ops)
+ if st := h.moveRound(t, host); st != provision.MoveReady {
+ t.Fatalf("a busy multipart instance with an 8 KiB delta left the move %s", st)
+ }
+ for _, op := range h.f.ops[before:] {
+ if strings.Contains(op, "/ltx/1/") || strings.Contains(op, "/ltx/9/") {
+ t.Fatalf("an unchanged multipart object was recopied: %s (ops %v)", op, h.f.ops[before:])
+ }
+ }
+ // The receipts are in the SOURCE's bucket, never in staging.
+ if _, ok := h.f.buckets["data-ie"][activator.MoveReceiptsKey(host)]; !ok {
+ t.Fatal("no receipts in the source's bucket")
+ }
+ for k := range h.f.buckets["data-uk"] {
+ if strings.Contains(k, "receipt") {
+ t.Fatalf("receipts staged as data: %s", k)
+ }
+ }
+}
+
+// A source object overwritten between the listing and the copy defers
+// that object. Its listed bytes still count against the bar, so a round
+// that deferred a large object does not promote.
+func TestMovesPassDeferredBytesCountAgainstTheBar(t *testing.T) {
+ h := newMoveHarness(t)
+ host := "a.go.example.com"
+ h.record(t, host, provision.MoveRequest("uk", time.Now()))
+ kpmgShape(h, host)
+ h.moveRound(t, host)
+ big := "instances/" + host + "/ltx/1/0.ltx"
+ h.f.putMultipart("data-ie", big, "level-0b", 138<<20)
+ h.f.beforeCopy = func(k string) {
+ if k == big {
+ h.f.putMultipart("data-ie", big, "level-0c", 138<<20)
+ h.f.beforeCopy = nil
+ }
+ }
+ if st := h.moveRound(t, host); st != provision.MovePreparing {
+ t.Fatalf("a round that deferred 138 MiB promoted to %s", st)
+ }
+ if st := h.moveRound(t, host); st != provision.MovePreparing {
+ t.Fatalf("the round that copied the deferred 138 MiB promoted to %s", st)
+ }
+ if st := h.moveRound(t, host); st != provision.MoveReady {
+ t.Fatalf("the quiet round after left the move %s", st)
+ }
+}
+
+// The cut's final round with multipart sources lands byte-exact, and the
+// move's receipts are cleared once the record has flipped.
+func TestMovesPassCutWithMultipartSourcesLandsExact(t *testing.T) {
+ h := newMoveHarness(t)
+ host := "kpmg.bdn.example.com"
+ h.record(t, host, provision.MoveRequest("uk", time.Now()))
+ kpmgShape(h, host)
+ h.moveRound(t, host)
+ if st := h.moveRound(t, host); st != provision.MoveReady {
+ t.Fatalf("state %s, want ready", st)
+ }
+ // Compaction after ready: one level file rewritten, one pruned.
+ h.f.putMultipart("data-ie", "instances/"+host+"/ltx/1/2.ltx", "level-2b", 140<<20)
+ delete(h.f.buckets["data-ie"], "instances/"+host+"/ltx/1/5.ltx")
+ if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", host, provision.MoveMarkCutting()); err != nil {
+ t.Fatal(err)
+ }
+ if err := movesPass(context.Background(), h.s, h.worker()); err != nil {
+ t.Fatal(err)
+ }
+ if got := h.read(t, host); got.Region != "uk" || got.Move != nil {
+ t.Fatalf("after cut: region=%q move=%+v", got.Region, got.Move)
+ }
+ assertStagedExact(t, h, host)
+ if _, ok := h.f.buckets["data-ie"][activator.MoveReceiptsKey(host)]; ok {
+ t.Fatal("the move's receipts outlived the cut")
+ }
+}
+
+// A source that changes under the cut's final round (it must not, the
+// child is down, but nothing else proves it) leaves staging short of
+// exact. The cut stops before the purge and the flip, and the next pass
+// finishes it exactly.
+func TestMovesPassCutStopsOnADeferredCopy(t *testing.T) {
+ h := newMoveHarness(t)
+ host := "a.go.example.com"
+ h.record(t, host, provision.MoveRequest("uk", time.Now()), provision.MovePromoteReady(time.Now()), provision.MoveMarkCutting())
+ kpmgShape(h, host)
+ db := filepath.Join(h.cfg.DataDir, host+".db")
+ if err := writeFileForTest(db, "warm"); err != nil {
+ t.Fatal(err)
+ }
+ big := "instances/" + host + "/ltx/1/3.ltx"
+ h.f.beforeCopy = func(k string) {
+ if k == big {
+ h.f.putMultipart("data-ie", big, "level-3b", 138<<20)
+ h.f.beforeCopy = nil
+ }
+ }
+ if err := movesPass(context.Background(), h.s, h.worker()); err != nil {
+ t.Fatal(err)
+ }
+ if got := h.read(t, host); got.Region != "ie" || got.Move == nil || got.Move.State != provision.MoveCutting {
+ t.Fatalf("the cut completed over a deferred copy: region=%q move=%+v", got.Region, got.Move)
+ }
+ if !fileExistsForTest(db) {
+ t.Fatal("the local DB was purged before staging was exact")
+ }
+ if err := movesPass(context.Background(), h.s, h.worker()); err != nil {
+ t.Fatal(err)
+ }
+ if got := h.read(t, host); got.Region != "uk" || got.Move != nil {
+ t.Fatalf("the retried cut: region=%q move=%+v", got.Region, got.Move)
+ }
+ assertStagedExact(t, h, host)
+}
+
+func TestMoveReadyDeltaFromEnv(t *testing.T) {
+ for _, tc := range []struct {
+ raw string
+ want int64
+ ok bool
+ }{
+ {"", defaultMoveReadyDelta, true}, // unset: the default, nothing to report
+ {"0", 0, true}, // zero copied bytes: only a round that moved no data promotes
+ {"1048576", 1 << 20, true},
+ {" 67108864 ", 64 << 20, true},
+ {"-1", defaultMoveReadyDelta, false},
+ {"16MiB", defaultMoveReadyDelta, false},
+ {"1.5", defaultMoveReadyDelta, false},
+ {"99999999999999999999", defaultMoveReadyDelta, false},
+ } {
+ got, ok := moveReadyDeltaFromEnv(func(k string) string {
+ if k != "CARLOS_MOVE_READY_DELTA_BYTES" {
+ t.Fatalf("read %s, want CARLOS_MOVE_READY_DELTA_BYTES", k)
+ }
+ return tc.raw
+ })
+ if got != tc.want || ok != tc.ok {
+ t.Errorf("%q: got %d, %v; want %d, %v", tc.raw, got, ok, tc.want, tc.ok)
+ }
+ }
+ if defaultMoveReadyDelta != 16<<20 {
+ t.Fatalf("default bar = %d, want 16 MiB", defaultMoveReadyDelta)
+ }
+}
+
+// armMovesPass is where the bar reaches the worker: the env override when
+// it parses, the default when it does not.
+func TestArmMovesPassReadsTheReadyDelta(t *testing.T) {
+ prev := armedMovesWorker.Load()
+ t.Cleanup(func() { armedMovesWorker.Store(prev) })
+ for _, tc := range []struct {
+ raw string
+ want int64
+ }{
+ {"", defaultMoveReadyDelta},
+ {"4096", 4096},
+ {"-5", defaultMoveReadyDelta},
+ {"lots", defaultMoveReadyDelta},
+ } {
+ t.Setenv("CARLOS_MOVE_READY_DELTA_BYTES", tc.raw)
+ armMovesPass(activator.Config{}, t.TempDir(), nil, nil)
+ if got := armedMovesWorker.Load().cfg.ReadyDelta; got != tc.want {
+ t.Errorf("CARLOS_MOVE_READY_DELTA_BYTES=%q armed a bar of %d, want %d", tc.raw, got, tc.want)
+ }
+ }
+}
+
func writeFileForTest(path, content string) error { return os.WriteFile(path, []byte(content), 0o600) }
func fileExistsForTest(path string) bool { _, err := os.Stat(path); return err == nil }
@@ -533,3 +943,30 @@ func TestMovesPassAbortPhase(t *testing.T) {
t.Fatalf("after abort phase: %+v region=%q", got.Move, got.Region)
}
}
+
+// Abort clears the move's receipts along with the staged copy.
+func TestMovesPassAbortClearsReceipts(t *testing.T) {
+ h := newMoveHarness(t)
+ host := "a.go.example.com"
+ h.record(t, host, provision.MoveRequest("uk", time.Now()))
+ kpmgShape(h, host)
+ h.moveRound(t, host)
+ if _, ok := h.f.buckets["data-ie"][activator.MoveReceiptsKey(host)]; !ok {
+ t.Fatal("no receipts after the bulk round")
+ }
+ if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", host, provision.MoveAbort()); err != nil {
+ t.Fatal(err)
+ }
+ if err := movesPass(context.Background(), h.s, h.worker()); err != nil {
+ t.Fatal(err)
+ }
+ if got := h.read(t, host); got.Move != nil {
+ t.Fatalf("abort did not complete: %+v", got.Move)
+ }
+ if staged := h.f.bodies("data-uk", "moves/"+host+"/"); len(staged) != 0 {
+ t.Fatalf("staging survived the abort: %v", staged)
+ }
+ if _, ok := h.f.buckets["data-ie"][activator.MoveReceiptsKey(host)]; ok {
+ t.Fatal("the receipts survived the abort")
+ }
+}
diff --git a/internal/lease/mirror.go b/internal/lease/mirror.go
index 2061a4c4e..cb6ed72b0 100644
--- a/internal/lease/mirror.go
+++ b/internal/lease/mirror.go
@@ -1,46 +1,148 @@
package lease
import (
+ "bytes"
"context"
+ "encoding/json"
+ "errors"
"fmt"
+ "io"
"net/url"
"sort"
"strings"
"sync"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+ s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "github.com/aws/smithy-go"
)
// The staging mirror (issue #331, instance-move spec): a residence move
// copies the instance's REPLICA prefix — the litestream generations the
// activator already maintains — never the database itself. MirrorPrefix is
// the one delta round; the move worker calls it repeatedly until a round
-// reports nothing changed, and once more after the child is down.
+// copies little enough that the cut's own round is cheap, and once more
+// after the child is down.
//
// The interface exists for the tests (an in-memory fake) and because the
// destination bucket lives in ANOTHER region: the implementation holds one
// client per side.
-// MirrorStore is the three S3 actions a delta round needs. List answers
-// key→ETag under a prefix (keys RELATIVE to the bucket root, not the
-// prefix); Copy is a server-side CopyObject; Delete removes one key.
+// MirrorStore is the S3 actions a delta round needs. List answers
+// key→object under a prefix (keys RELATIVE to the bucket root, not the
+// prefix). Copy is a server-side CopyObject, conditional on srcETag when it
+// is set: a source that no longer carries that ETag answers
+// ErrCopySourceChanged and copies nothing. It returns the ETag the
+// destination object landed with. Delete removes one key. Get and Put read
+// and write one small whole object (the copy receipts); Get answers
+// ErrMirrorNotFound for a missing key.
type MirrorStore interface {
- List(ctx context.Context, bucket, prefix string) (map[string]string, error)
- Copy(ctx context.Context, srcBucket, srcKey, dstBucket, dstKey string) error
+ List(ctx context.Context, bucket, prefix string) (map[string]MirrorObject, error)
+ Copy(ctx context.Context, srcBucket, srcKey, srcETag, dstBucket, dstKey string) (dstETag string, err error)
Delete(ctx context.Context, bucket, key string) error
+ Get(ctx context.Context, bucket, key string) ([]byte, error)
+ Put(ctx context.Context, bucket, key string, body []byte) error
+}
+
+// ErrCopySourceChanged is a conditional Copy whose source was overwritten
+// after the round listed it. The round defers that object to the next
+// round rather than failing: a source that keeps changing is a busy
+// source, not a broken one.
+var ErrCopySourceChanged = errors.New("mirror: copy source changed since it was listed")
+
+// ErrMirrorNotFound is Get's answer for a key that does not exist.
+var ErrMirrorNotFound = errors.New("mirror: no such object")
+
+// MirrorObject is one listed object: the ETag the sameness test compares,
+// and the size a round's byte accounting adds up.
+type MirrorObject struct {
+ ETag string
+ Size int64
+}
+
+// MirrorRound is what one delta round did.
+type MirrorRound struct {
+ // Changed counts objects copied, deferred or deleted; 0 means the
+ // sides were identical when listed.
+ Changed int
+ // BytesCopied sums the listed SOURCE size of every object copied.
+ BytesCopied int64
+ // Deferred counts objects whose conditional copy found the source
+ // overwritten since the listing (ErrCopySourceChanged); BytesDeferred
+ // sums their listed sizes. They are still to copy, so a round that
+ // has to be byte-final (the cut's) must not count as done while
+ // Deferred > 0.
+ Deferred int
+ BytesDeferred int64
+}
+
+// DeltaBytes is how much the round found to move: copied plus deferred.
+// The move worker's readiness bar measures this. An awake instance never
+// reaches Changed == 0 (litestream writes new files and compaction
+// rewrites old ones between any two listings), but the bytes a round had
+// to move bound the work left for the cut's round.
+func (r MirrorRound) DeltaBytes() int64 { return r.BytesCopied + r.BytesDeferred }
+
+// Receipts names the object where a mirror keeps its copy receipts. The
+// zero value keeps none, and every object whose ETags differ is copied.
+//
+// Why receipts exist (#175): S3's CopyObject always writes a single-part
+// object, so copying a MULTIPART source (litestream uploads anything over
+// one 5 MiB part that way) lands a destination whose ETag differs from the
+// source's. ETag equality then reports every multipart object as changed
+// on every round, forever: production recopied six 138 MB level files a
+// round. A receipt records what one copy did: the source ETag and size it
+// copied, and the ETag the destination landed with. An object is skipped
+// when the source still lists that ETag and size AND the destination still
+// lists that destination ETag, and copied otherwise.
+//
+// The receipts object must live where the mirroring box can READ it. A
+// move's destination is push-only to the source box (it may list, put and
+// delete under moves/*, never get), so the move worker keeps them in the
+// SOURCE's own bucket, outside every prefix a restore or a promotion
+// reads. They are never data.
+type Receipts struct {
+ Bucket, Key string
+}
+
+// receiptBook is the receipts object's shape. It names the pair it
+// describes, and a book written for another pair is not evidence about
+// this one.
+type receiptBook struct {
+ V int `json:"v"`
+ SrcBucket string `json:"src_bucket"`
+ SrcPrefix string `json:"src_prefix"`
+ DstBucket string `json:"dst_bucket"`
+ DstPrefix string `json:"dst_prefix"`
+ Objects map[string]copyReceipt `json:"objects"` // relative key → receipt
+}
+
+// copyReceipt is one completed copy whose destination ETag differs from
+// its source's. Only such copies get one: where the ETags already match,
+// the plain equality test needs no help.
+type copyReceipt struct {
+ SrcETag string `json:"src_etag"`
+ SrcSize int64 `json:"src_size"`
+ DstETag string `json:"dst_etag"`
}
// MirrorPrefix makes dstBucket/dstPrefix an exact mirror of
-// srcBucket/srcPrefix: copy keys that are new or whose ETag differs, delete
-// keys the source no longer has (litestream retention must hold in staging
-// too — a restore that replays pruned WAL segments is a restore nobody
-// tested). Returns how many objects changed; 0 means the sides were
-// identical when listed — the move worker's "caught up".
+// srcBucket/srcPrefix: copy keys that are new or changed, delete keys the
+// source no longer has (litestream retention must hold in staging too: a
+// restore that replays pruned WAL segments is a restore nobody tested).
+// Returns what the round did; on error, what it did before the failure.
//
-// ETag equality is a valid sameness test here even for multipart uploads:
-// both sides of a server-side COPY carry the copy's own single-part ETag,
-// and litestream's own objects are single PUTs.
-func MirrorPrefix(ctx context.Context, m MirrorStore, srcBucket, srcPrefix, dstBucket, dstPrefix string) (int, error) {
+// "Unchanged" is exact, never a guess from size: either the two listed
+// ETags are equal (a single-part source, whose copy keeps its ETag), or a
+// receipt (see Receipts) proves the destination is this mirror's copy of
+// exactly the source version now listed.
+//
+// Crash safety: a receipt is written only AFTER the copy it describes
+// returned, carrying the ETag that copy returned, once per round at the
+// end. A crash before that write loses receipts, and the next round copies
+// those objects once more. It can never produce a wrong skip, because a
+// skip needs the destination to still list the ETag the copy produced.
+func MirrorPrefix(ctx context.Context, m MirrorStore, srcBucket, srcPrefix, dstBucket, dstPrefix string, rc Receipts) (MirrorRound, error) {
// Both prefixes are DIRECTORY boundaries, normalized here to end in
// "/": activator.HibernatePrefix carries none (path.Join), MovesPrefix
// carries one, and relative-key math over a slashless prefix would both
@@ -54,37 +156,68 @@ func MirrorPrefix(ctx context.Context, m MirrorStore, srcBucket, srcPrefix, dstB
}
src, err := m.List(ctx, srcBucket, srcPrefix)
if err != nil {
- return 0, fmt.Errorf("mirror: list source %s/%s: %w", srcBucket, srcPrefix, err)
+ return MirrorRound{}, fmt.Errorf("mirror: list source %s/%s: %w", srcBucket, srcPrefix, err)
}
dst, err := m.List(ctx, dstBucket, dstPrefix)
if err != nil {
- return 0, fmt.Errorf("mirror: list staging %s/%s: %w", dstBucket, dstPrefix, err)
+ return MirrorRound{}, fmt.Errorf("mirror: list staging %s/%s: %w", dstBucket, dstPrefix, err)
}
+ book := receiptLedger{m: m, rc: rc, want: receiptBook{V: 1, SrcBucket: srcBucket, SrcPrefix: srcPrefix, DstBucket: dstBucket, DstPrefix: dstPrefix}}
// Deterministic order: a failed round resumes the same way everywhere.
rel := make([]string, 0, len(src))
for k := range src {
rel = append(rel, k[len(srcPrefix):])
}
sort.Strings(rel)
- changed := 0
+ var round MirrorRound
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
+ // TTL renewal, so mirroring it would put a change in every round
+ // 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 == leaseLockName {
continue
}
sk, dk := srcPrefix+r, dstPrefix+r
- if et, ok := dst[dk]; ok && et == src[sk] {
+ s := src[sk]
+ d, staged := dst[dk]
+ if staged && d.ETag == s.ETag {
continue
}
- if err := m.Copy(ctx, srcBucket, sk, dstBucket, dk); err != nil {
- return changed, fmt.Errorf("mirror: copy %s: %w", sk, err)
+ if staged {
+ rcpt, ok, err := book.get(ctx, r)
+ if err != nil {
+ return round, err
+ }
+ if ok && rcpt.SrcETag == s.ETag && rcpt.SrcSize == s.Size && rcpt.DstETag == d.ETag && d.Size == s.Size {
+ continue
+ }
+ }
+ etag, err := m.Copy(ctx, srcBucket, sk, s.ETag, dstBucket, dk)
+ if errors.Is(err, ErrCopySourceChanged) {
+ book.drop(r)
+ round.Changed++
+ round.Deferred++
+ round.BytesDeferred += s.Size
+ continue
+ }
+ if err != nil {
+ book.save(ctx) // best effort: what already landed keeps its receipts
+ return round, fmt.Errorf("mirror: copy %s: %w", sk, err)
+ }
+ round.Changed++
+ round.BytesCopied += s.Size
+ if etag != "" && etag != s.ETag {
+ // A failed read here loses this receipt (one recopy next
+ // round), never a wrong skip.
+ if err := book.set(ctx, r, copyReceipt{SrcETag: s.ETag, SrcSize: s.Size, DstETag: etag}); err != nil {
+ return round, err
+ }
+ } else {
+ book.drop(r)
}
- changed++
}
for dk := range dst {
r := dk[len(dstPrefix):]
@@ -93,12 +226,124 @@ func MirrorPrefix(ctx context.Context, m MirrorStore, srcBucket, srcPrefix, dstB
}
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)
+ book.save(ctx)
+ return round, fmt.Errorf("mirror: delete %s: %w", dk, err)
}
- changed++
+ book.drop(r)
+ round.Changed++
}
}
- return changed, nil
+ book.prune(func(r string) bool { _, ok := src[srcPrefix+r]; return ok })
+ if err := book.save(ctx); err != nil {
+ return round, err
+ }
+ return round, nil
+}
+
+// receiptLedger is one round's view of the receipts object. It reads the
+// object lazily, on the first object whose ETags differ, so a round over
+// single-part objects only costs no request it did not cost before.
+type receiptLedger struct {
+ m MirrorStore
+ rc Receipts
+ want receiptBook // the pair this round mirrors; Objects unused
+ loaded bool
+ book map[string]copyReceipt
+ dirty bool
+}
+
+func (l *receiptLedger) load(ctx context.Context) error {
+ if l.loaded || l.rc.Key == "" {
+ return nil
+ }
+ body, err := l.m.Get(ctx, l.rc.Bucket, l.rc.Key)
+ if err != nil && !errors.Is(err, ErrMirrorNotFound) {
+ // Not loaded: a failed read must never be saved over as if the
+ // book were empty.
+ return fmt.Errorf("mirror: read receipts %s/%s: %w", l.rc.Bucket, l.rc.Key, err)
+ }
+ l.loaded = true
+ l.book = map[string]copyReceipt{}
+ if err != nil {
+ return nil // no receipts yet
+ }
+ var b receiptBook
+ if json.Unmarshal(body, &b) != nil || b.V != 1 || b.SrcBucket != l.want.SrcBucket || b.SrcPrefix != l.want.SrcPrefix ||
+ b.DstBucket != l.want.DstBucket || b.DstPrefix != l.want.DstPrefix {
+ // Unreadable, or written for another pair: no evidence at all.
+ // Rewritten at the end of the round.
+ l.dirty = true
+ return nil
+ }
+ for k, v := range b.Objects {
+ l.book[k] = v
+ }
+ return nil
+}
+
+func (l *receiptLedger) get(ctx context.Context, rel string) (copyReceipt, bool, error) {
+ if err := l.load(ctx); err != nil {
+ return copyReceipt{}, false, err
+ }
+ r, ok := l.book[rel]
+ return r, ok, nil
+}
+
+// set records a completed copy. It reads the book first if this round
+// has not (a copy to a key the destination lacked needs no lookup), so the
+// write at the end of the round keeps every other receipt.
+func (l *receiptLedger) set(ctx context.Context, rel string, r copyReceipt) error {
+ if l.rc.Key == "" {
+ return nil
+ }
+ if err := l.load(ctx); err != nil {
+ return err
+ }
+ if old, ok := l.book[rel]; !ok || old != r {
+ l.book[rel] = r
+ l.dirty = true
+ }
+ return nil
+}
+
+// drop forgets rel's receipt. Only a book already read can hold one.
+func (l *receiptLedger) drop(rel string) {
+ if !l.loaded {
+ return
+ }
+ if _, ok := l.book[rel]; ok {
+ delete(l.book, rel)
+ l.dirty = true
+ }
+}
+
+func (l *receiptLedger) prune(keep func(rel string) bool) {
+ if !l.loaded {
+ return
+ }
+ for rel := range l.book {
+ if !keep(rel) {
+ delete(l.book, rel)
+ l.dirty = true
+ }
+ }
+}
+
+func (l *receiptLedger) save(ctx context.Context) error {
+ if !l.dirty || l.rc.Key == "" {
+ return nil
+ }
+ b := l.want
+ b.Objects = l.book
+ body, err := json.Marshal(b)
+ if err != nil {
+ return err
+ }
+ if err := l.m.Put(ctx, l.rc.Bucket, l.rc.Key, body); err != nil {
+ return fmt.Errorf("mirror: write receipts %s/%s: %w", l.rc.Bucket, l.rc.Key, err)
+ }
+ l.dirty = false
+ return nil
}
// leaseLockName is the coordination file this package's own leaser keeps
@@ -152,8 +397,8 @@ func NewS3Mirror(ctx context.Context, srcRegion, dstRegion string) (MirrorStore,
return &s3Mirror{src: src, dst: dst, byBucket: map[string]*awss3.Client{}}, nil
}
-func (m *s3Mirror) list(ctx context.Context, c *awss3.Client, bucket, prefix string) (map[string]string, error) {
- out := map[string]string{}
+func (m *s3Mirror) list(ctx context.Context, c *awss3.Client, bucket, prefix string) (map[string]MirrorObject, error) {
+ out := map[string]MirrorObject{}
pager := awss3.NewListObjectsV2Paginator(c, &awss3.ListObjectsV2Input{Bucket: &bucket, Prefix: &prefix})
for pager.HasMorePages() {
resp, err := pager.NextPage(ctx)
@@ -164,11 +409,14 @@ func (m *s3Mirror) list(ctx context.Context, c *awss3.Client, bucket, prefix str
if o.Key == nil {
continue
}
- et := ""
+ var obj MirrorObject
if o.ETag != nil {
- et = *o.ETag
+ obj.ETag = normalizeETag(*o.ETag)
}
- out[*o.Key] = et
+ if o.Size != nil {
+ obj.Size = *o.Size
+ }
+ out[*o.Key] = obj
}
}
return out, nil
@@ -178,7 +426,7 @@ func (m *s3Mirror) list(ctx context.Context, c *awss3.Client, bucket, prefix str
// never says which region a bucket lives in, so the FIRST touch of a bucket
// probes source-then-destination (a wrong-region List is an error, so the
// probe is unambiguous) and the winner is remembered.
-func (m *s3Mirror) List(ctx context.Context, bucket, prefix string) (map[string]string, error) {
+func (m *s3Mirror) List(ctx context.Context, bucket, prefix string) (map[string]MirrorObject, error) {
m.mu.Lock()
c := m.byBucket[bucket]
m.mu.Unlock()
@@ -199,15 +447,83 @@ func (m *s3Mirror) List(ctx context.Context, bucket, prefix string) (map[string]
return out, nil
}
-func (m *s3Mirror) Copy(ctx context.Context, srcBucket, srcKey, dstBucket, dstKey string) error {
+// normalizeETag strips the quotes S3 wraps an ETag in, so a listing and a
+// CopyObject result compare equal when they name the same object: a
+// receipt whose quoting differed from the next listing's would never match,
+// and the recopy-forever bug would come back silently.
+func normalizeETag(et string) string { return strings.Trim(et, `"`) }
+
+// clientFor is the client List bound bucket to, or fallback if this pair
+// has never listed it.
+func (m *s3Mirror) clientFor(bucket string, fallback *awss3.Client) *awss3.Client {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if c := m.byBucket[bucket]; c != nil {
+ return c
+ }
+ return fallback
+}
+
+func (m *s3Mirror) Copy(ctx context.Context, srcBucket, srcKey, srcETag, dstBucket, dstKey string) (string, error) {
source := url.PathEscape(srcBucket + "/" + srcKey)
- _, err := m.dst.CopyObject(ctx, &awss3.CopyObjectInput{
- Bucket: &dstBucket, Key: &dstKey, CopySource: &source,
- })
- return err
+ in := &awss3.CopyObjectInput{Bucket: &dstBucket, Key: &dstKey, CopySource: &source}
+ if srcETag != "" {
+ quoted := `"` + normalizeETag(srcETag) + `"`
+ in.CopySourceIfMatch = &quoted
+ }
+ out, err := m.dst.CopyObject(ctx, in)
+ if err != nil {
+ var ae smithy.APIError
+ if srcETag != "" && errors.As(err, &ae) && ae.ErrorCode() == "PreconditionFailed" {
+ return "", ErrCopySourceChanged
+ }
+ return "", err
+ }
+ if out.CopyObjectResult == nil || out.CopyObjectResult.ETag == nil {
+ return "", nil // no receipt possible; the next round recopies
+ }
+ return normalizeETag(*out.CopyObjectResult.ETag), nil
}
+// Delete goes to the client List bound the bucket to: the receipts object
+// lives in the SOURCE's bucket, which the destination's client cannot
+// reach. A bucket this pair never listed (an abort after an agent restart
+// lists only the target) tries the destination client, as before, then
+// the source's; if both fail, the destination's error is the one reported.
func (m *s3Mirror) Delete(ctx context.Context, bucket, key string) error {
- _, err := m.dst.DeleteObject(ctx, &awss3.DeleteObjectInput{Bucket: &bucket, Key: &key})
+ in := &awss3.DeleteObjectInput{Bucket: &bucket, Key: &key}
+ if c := m.clientFor(bucket, nil); c != nil {
+ _, err := c.DeleteObject(ctx, in)
+ return err
+ }
+ _, err := m.dst.DeleteObject(ctx, in)
+ if err != nil && m.src != m.dst {
+ if _, err2 := m.src.DeleteObject(ctx, in); err2 == nil {
+ return nil
+ }
+ }
+ return err
+}
+
+// Get and Put serve the receipts object, which lives in the source's own
+// bucket: the bound client, else the source's.
+func (m *s3Mirror) Get(ctx context.Context, bucket, key string) ([]byte, error) {
+ out, err := m.clientFor(bucket, m.src).GetObject(ctx, &awss3.GetObjectInput{Bucket: &bucket, Key: &key})
+ if err != nil {
+ var nsk *s3types.NoSuchKey
+ if errors.As(err, &nsk) {
+ return nil, ErrMirrorNotFound
+ }
+ return nil, err
+ }
+ defer out.Body.Close()
+ return io.ReadAll(out.Body)
+}
+
+func (m *s3Mirror) Put(ctx context.Context, bucket, key string, body []byte) error {
+ ct := "application/json"
+ _, err := m.clientFor(bucket, m.src).PutObject(ctx, &awss3.PutObjectInput{
+ Bucket: &bucket, Key: &key, Body: bytes.NewReader(body), ContentType: &ct,
+ })
return err
}
diff --git a/internal/lease/mirror_test.go b/internal/lease/mirror_test.go
index 124b3b896..35cff26b0 100644
--- a/internal/lease/mirror_test.go
+++ b/internal/lease/mirror_test.go
@@ -2,45 +2,85 @@ package lease
import (
"context"
+ "encoding/json"
"fmt"
"strings"
"testing"
)
-// fakeMirror is an in-memory MirrorStore: buckets of key→etag, plus an op
-// log so ordering tests can assert the sequence.
+// fakeMirror is an in-memory MirrorStore that keeps S3's ETag rules, plus
+// an op log so ordering tests can assert the sequence. Every object carries
+// a body (its content's identity). A single-part object's ETag IS its body
+// (S3's MD5), a multipart one's is body+"-27", and a copy always lands
+// single-part: the destination's ETag is the body, whatever the source's
+// was. The fakes before #175 kept the source ETag on copy, and hid the bug
+// that made every multipart object look changed on every round.
type fakeMirror struct {
- buckets map[string]map[string]string
+ buckets map[string]map[string]fakeObject
ops []string
failOn string
+ // beforeCopy runs at the top of Copy, before the precondition check:
+ // the window between a round's listing and its copy.
+ beforeCopy func(srcKey string)
+ failPut bool
+ failGet bool
+ puts int
}
-func newFakeMirror() *fakeMirror { return &fakeMirror{buckets: map[string]map[string]string{}} }
+type fakeObject struct {
+ MirrorObject
+ body string
+}
+
+func newFakeMirror() *fakeMirror { return &fakeMirror{buckets: map[string]map[string]fakeObject{}} }
+
+func (f *fakeMirror) put(bucket, key, etag string) { f.putSized(bucket, key, etag, 0) }
+
+// putSized stores a single-part object: its ETag is its body.
+func (f *fakeMirror) putSized(bucket, key, etag string, size int64) {
+ f.store(bucket, key, fakeObject{MirrorObject{ETag: etag, Size: size}, etag})
+}
+
+// putMultipart stores a multipart upload of body (litestream's uploader
+// for anything over one 5 MiB part).
+func (f *fakeMirror) putMultipart(bucket, key, body string, size int64) {
+ f.store(bucket, key, fakeObject{MirrorObject{ETag: body + "-27", Size: size}, body})
+}
-func (f *fakeMirror) put(bucket, key, etag string) {
+func (f *fakeMirror) store(bucket, key string, o fakeObject) {
if f.buckets[bucket] == nil {
- f.buckets[bucket] = map[string]string{}
+ f.buckets[bucket] = map[string]fakeObject{}
}
- f.buckets[bucket][key] = etag
+ f.buckets[bucket][key] = o
}
-func (f *fakeMirror) List(_ context.Context, bucket, prefix string) (map[string]string, error) {
- out := map[string]string{}
+func (f *fakeMirror) List(_ context.Context, bucket, prefix string) (map[string]MirrorObject, error) {
+ out := map[string]MirrorObject{}
for k, v := range f.buckets[bucket] {
if strings.HasPrefix(k, prefix) {
- out[k] = v
+ out[k] = v.MirrorObject
}
}
return out, nil
}
-func (f *fakeMirror) Copy(_ context.Context, srcBucket, srcKey, dstBucket, dstKey string) error {
+func (f *fakeMirror) Copy(_ context.Context, srcBucket, srcKey, srcETag, dstBucket, dstKey string) (string, error) {
if f.failOn == srcKey {
- return fmt.Errorf("injected copy failure on %s", srcKey)
+ return "", fmt.Errorf("injected copy failure on %s", srcKey)
+ }
+ if f.beforeCopy != nil {
+ f.beforeCopy(srcKey)
+ }
+ o, ok := f.buckets[srcBucket][srcKey]
+ if !ok {
+ return "", fmt.Errorf("no such key %s", srcKey)
+ }
+ if srcETag != "" && o.ETag != srcETag {
+ return "", ErrCopySourceChanged
}
f.ops = append(f.ops, "copy "+srcKey+" -> "+dstBucket+"/"+dstKey)
- f.put(dstBucket, dstKey, f.buckets[srcBucket][srcKey])
- return nil
+ f.store(dstBucket, dstKey, fakeObject{MirrorObject{ETag: o.body, Size: o.Size}, o.body})
+ return o.body, nil
}
func (f *fakeMirror) Delete(_ context.Context, bucket, key string) error {
@@ -49,6 +89,27 @@ func (f *fakeMirror) Delete(_ context.Context, bucket, key string) error {
return nil
}
+func (f *fakeMirror) Get(_ context.Context, bucket, key string) ([]byte, error) {
+ if f.failGet {
+ return nil, fmt.Errorf("injected get failure on %s", key)
+ }
+ o, ok := f.buckets[bucket][key]
+ if !ok {
+ return nil, ErrMirrorNotFound
+ }
+ return []byte(o.body), nil
+}
+
+func (f *fakeMirror) Put(_ context.Context, bucket, key string, body []byte) error {
+ if f.failPut {
+ return fmt.Errorf("injected put failure on %s", key)
+ }
+ f.puts++
+ f.ops = append(f.ops, "put "+bucket+"/"+key)
+ f.store(bucket, key, fakeObject{MirrorObject{ETag: fmt.Sprint("put-", f.puts), Size: int64(len(body))}, string(body)})
+ return nil
+}
+
func TestMirrorPrefix(t *testing.T) {
ctx := context.Background()
f := newFakeMirror()
@@ -56,21 +117,21 @@ func TestMirrorPrefix(t *testing.T) {
f.put("src", "instances/h/generations/a/wal/000", "e2")
// Initial round: full copy.
- n, err := MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/")
- if err != nil || n != 2 {
- t.Fatalf("initial round = %d, %v; want 2 copies", n, err)
+ r, err := MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/", Receipts{})
+ if err != nil || r.Changed != 2 {
+ t.Fatalf("initial round = %+v, %v; want 2 copies", r, err)
}
// Identical: zero.
- if n, err = MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/"); err != nil || n != 0 {
- t.Fatalf("caught-up round = %d, %v; want 0", n, err)
+ if r, err = MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/", Receipts{}); err != nil || r.Changed != 0 {
+ t.Fatalf("caught-up round = %+v, %v; want 0", r, err)
}
// Delta: one changed etag, one new, one pruned at source.
f.put("src", "instances/h/generations/a/wal/000", "e2b")
f.put("src", "instances/h/generations/a/wal/001", "e3")
delete(f.buckets["src"], "instances/h/generations/a/snapshot")
- n, err = MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/")
- if err != nil || n != 3 {
- t.Fatalf("delta round = %d, %v; want 2 copies + 1 delete", n, err)
+ r, err = MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/", Receipts{})
+ if err != nil || r.Changed != 3 {
+ t.Fatalf("delta round = %+v, %v; want 2 copies + 1 delete", r, err)
}
got, _ := f.List(ctx, "dst", "moves/h/")
want := map[string]string{
@@ -81,14 +142,14 @@ func TestMirrorPrefix(t *testing.T) {
t.Fatalf("staging = %v, want %v", got, want)
}
for k, v := range want {
- if got[k] != v {
- t.Fatalf("staging[%s] = %q, want %q", k, got[k], v)
+ if got[k].ETag != v {
+ t.Fatalf("staging[%s] = %q, want %q", k, got[k].ETag, v)
}
}
// A copy failure surfaces with the partial count.
f.put("src", "instances/h/generations/a/wal/002", "e4")
f.failOn = "instances/h/generations/a/wal/002"
- if _, err := MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/"); err == nil {
+ if _, err := MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/", Receipts{}); err == nil {
t.Fatal("injected failure did not surface")
}
}
@@ -101,9 +162,9 @@ func TestMirrorPrefixNormalizesBoundaries(t *testing.T) {
f := newFakeMirror()
f.put("src", "instances/h/generations/a/snapshot", "e1")
f.put("src", "instances/h2/generations/a/snapshot", "eX") // the sibling
- n, err := MirrorPrefix(ctx, f, "src", "instances/h", "dst", "moves/h")
- if err != nil || n != 1 {
- t.Fatalf("round = %d, %v; want exactly the one h object", n, err)
+ r, err := MirrorPrefix(ctx, f, "src", "instances/h", "dst", "moves/h", Receipts{})
+ if err != nil || r.Changed != 1 {
+ t.Fatalf("round = %+v, %v; want exactly the one h object", r, err)
}
got, _ := f.List(ctx, "dst", "")
if _, ok := got["moves/h/generations/a/snapshot"]; !ok || len(got) != 1 {
@@ -119,15 +180,303 @@ func TestMirrorPrefixSkipsTheLease(t *testing.T) {
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)
+ if r, err := MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/", Receipts{}); err != nil || r.Changed != 1 {
+ t.Fatalf("round = %+v, %v; want only the snapshot to move", r, 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)
+ if r, err := MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/", Receipts{}); err != nil || r.Changed != 0 {
+ t.Fatalf("churn round = %+v, %v; want caught-up despite the renewal", r, err)
}
got, _ := f.List(ctx, "dst", "moves/h/")
- if got["moves/h/lock.json"] != "old-foreign-lease" || len(got) != 2 {
+ if got["moves/h/lock.json"].ETag != "old-foreign-lease" || len(got) != 2 {
t.Fatalf("staging = %v; the dst lease must be untouched", got)
}
}
+
+// A round reports the bytes it copied: the listed source size of every
+// object it copied, and nothing for an object it skipped as unchanged, a
+// delete, or the lease. The move worker's readiness bar is this number.
+func TestMirrorPrefixCountsBytesCopied(t *testing.T) {
+ ctx := context.Background()
+ f := newFakeMirror()
+ f.putSized("src", "instances/h/generations/a/snapshot", "e1", 1000)
+ f.putSized("src", "instances/h/generations/a/wal/000", "e2", 30)
+ f.putSized("src", "instances/h/lock.json", "renewal-1", 500)
+
+ r, err := MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/", Receipts{})
+ if err != nil || r.Changed != 2 || r.BytesCopied != 1030 {
+ t.Fatalf("initial round = %+v, %v; want 2 changed, 1030 bytes", r, err)
+ }
+
+ // Unchanged objects are skipped and cost nothing; the lease's churn
+ // costs nothing; a rewritten object costs its NEW size; a new one its
+ // size; a pruned one is a change but no bytes.
+ f.putSized("src", "instances/h/lock.json", "renewal-2", 500)
+ f.putSized("src", "instances/h/generations/a/wal/000", "e2b", 70)
+ f.putSized("src", "instances/h/generations/a/wal/001", "e3", 5)
+ f.putSized("dst", "moves/h/generations/old/snapshot", "eOld", 9999)
+ r, err = MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/", Receipts{})
+ if err != nil || r.Changed != 3 || r.BytesCopied != 75 {
+ t.Fatalf("delta round = %+v, %v; want 3 changed (2 copies + 1 delete), 75 bytes", r, err)
+ }
+
+ r, err = MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/", Receipts{})
+ if err != nil || r.Changed != 0 || r.BytesCopied != 0 {
+ t.Fatalf("caught-up round = %+v, %v; want nothing", r, err)
+ }
+
+ // A failed round still reports what it copied before the failure.
+ f.putSized("src", "instances/h/generations/a/wal/002", "e4", 11)
+ f.putSized("src", "instances/h/generations/a/wal/003", "e5", 13)
+ f.failOn = "instances/h/generations/a/wal/003"
+ r, err = MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/", Receipts{})
+ if err == nil || r.Changed != 1 || r.BytesCopied != 11 {
+ t.Fatalf("failed round = %+v, %v; want an error with the partial 1 change, 11 bytes", r, err)
+ }
+}
+
+var testReceipts = Receipts{Bucket: "src", Key: "move-receipts/h.json"}
+
+func mirrorRound(t *testing.T, f *fakeMirror, rc Receipts) MirrorRound {
+ t.Helper()
+ r, err := MirrorPrefix(context.Background(), f, "src", "instances/h/", "dst", "moves/h/", rc)
+ if err != nil {
+ t.Fatalf("round: %v", err)
+ }
+ return r
+}
+
+func readReceipts(t *testing.T, f *fakeMirror) map[string]copyReceipt {
+ t.Helper()
+ body, err := f.Get(context.Background(), testReceipts.Bucket, testReceipts.Key)
+ if err != nil {
+ t.Fatalf("receipts: %v", err)
+ }
+ var b receiptBook
+ if err := json.Unmarshal(body, &b); err != nil {
+ t.Fatalf("receipts: %v", err)
+ }
+ return b.Objects
+}
+
+// #175 as production saw it: a multipart source's copy lands with a
+// different ETag, so ETag equality alone recopies every multipart object
+// on every round. Receipts let an unchanged one be copied once.
+func TestMirrorPrefixReceiptsSkipAnUnchangedMultipartObject(t *testing.T) {
+ const big = 138 << 20
+ fill := func(f *fakeMirror) {
+ f.putMultipart("src", "instances/h/ltx/1/a.ltx", "level1", big)
+ f.putSized("src", "instances/h/ltx/0/b.ltx", "l0", 4096)
+ }
+
+ // Without receipts: the bug, kept visible.
+ f := newFakeMirror()
+ fill(f)
+ mirrorRound(t, f, Receipts{})
+ if r := mirrorRound(t, f, Receipts{}); r.Changed != 1 || r.BytesCopied != big {
+ t.Fatalf("without receipts the unchanged multipart object should recopy: %+v", r)
+ }
+
+ f = newFakeMirror()
+ fill(f)
+ if r := mirrorRound(t, f, testReceipts); r.Changed != 2 || r.BytesCopied != big+4096 {
+ t.Fatalf("first round = %+v, want both copied", r)
+ }
+ if got := readReceipts(t, f); len(got) != 1 || got["ltx/1/a.ltx"] != (copyReceipt{SrcETag: "level1-27", SrcSize: big, DstETag: "level1"}) {
+ t.Fatalf("receipts = %+v, want exactly the multipart object's", got)
+ }
+ puts := f.puts
+ for i := 0; i < 3; i++ {
+ if r := mirrorRound(t, f, testReceipts); r != (MirrorRound{}) {
+ t.Fatalf("unchanged round %d = %+v, want nothing copied", i, r)
+ }
+ }
+ if f.puts != puts {
+ t.Fatalf("receipts rewritten on rounds that changed nothing (%d puts, want %d)", f.puts, puts)
+ }
+ // Receipts are never data: nothing beside the two objects is staged.
+ if staged, _ := f.List(context.Background(), "dst", ""); len(staged) != 2 {
+ t.Fatalf("staging = %v, want only the two data objects", staged)
+ }
+}
+
+// Single-part sources keep the plain ETag skip: no receipts are written,
+// so the existing behaviour and request count do not change.
+func TestMirrorPrefixSinglePartWritesNoReceipts(t *testing.T) {
+ f := newFakeMirror()
+ f.putSized("src", "instances/h/ltx/0/a.ltx", "a", 100)
+ mirrorRound(t, f, testReceipts)
+ f.putSized("src", "instances/h/ltx/0/b.ltx", "b", 100)
+ mirrorRound(t, f, testReceipts)
+ if r := mirrorRound(t, f, testReceipts); r.Changed != 0 {
+ t.Fatalf("caught-up round = %+v", r)
+ }
+ if f.puts != 0 {
+ t.Fatalf("%d receipt writes for single-part objects, want none", f.puts)
+ }
+}
+
+func TestMirrorPrefixReceiptRecopiesWhenTheSourceChanges(t *testing.T) {
+ f := newFakeMirror()
+ f.putMultipart("src", "instances/h/ltx/1/a.ltx", "v1", 100<<20)
+ mirrorRound(t, f, testReceipts)
+ // Compaction rewrote it under the same name.
+ f.putMultipart("src", "instances/h/ltx/1/a.ltx", "v2", 101<<20)
+ if r := mirrorRound(t, f, testReceipts); r.Changed != 1 || r.BytesCopied != 101<<20 {
+ t.Fatalf("rewritten source = %+v, want it recopied", r)
+ }
+ if got := f.buckets["dst"]["moves/h/ltx/1/a.ltx"].body; got != "v2" {
+ t.Fatalf("staged body = %q, want v2", got)
+ }
+ // The same size with different content is still a change: never
+ // skip on size alone.
+ f.putMultipart("src", "instances/h/ltx/1/a.ltx", "v3", 101<<20)
+ if r := mirrorRound(t, f, testReceipts); r.Changed != 1 {
+ t.Fatalf("same-size rewrite = %+v, want it recopied", r)
+ }
+ if r := mirrorRound(t, f, testReceipts); r.Changed != 0 {
+ t.Fatalf("after the recopy = %+v, want the new receipt to hold", r)
+ }
+}
+
+func TestMirrorPrefixReceiptRecopiesWhenTheDestinationChanges(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ touch func(f *fakeMirror)
+ }{
+ {"overwritten", func(f *fakeMirror) { f.putSized("dst", "moves/h/ltx/1/a.ltx", "intruder", 100<<20) }},
+ {"deleted", func(f *fakeMirror) { delete(f.buckets["dst"], "moves/h/ltx/1/a.ltx") }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newFakeMirror()
+ f.putMultipart("src", "instances/h/ltx/1/a.ltx", "v1", 100<<20)
+ mirrorRound(t, f, testReceipts)
+ tc.touch(f)
+ if r := mirrorRound(t, f, testReceipts); r.Changed != 1 || r.BytesCopied != 100<<20 {
+ t.Fatalf("destination %s behind the receipt = %+v, want a recopy", tc.name, r)
+ }
+ if got := f.buckets["dst"]["moves/h/ltx/1/a.ltx"].body; got != "v1" {
+ t.Fatalf("staged body = %q, want v1", got)
+ }
+ })
+ }
+}
+
+// A crash between the copy and the receipt write costs one extra copy on
+// the next round, never a wrong skip.
+func TestMirrorPrefixLostReceiptWriteRecopiesOnce(t *testing.T) {
+ f := newFakeMirror()
+ f.putMultipart("src", "instances/h/ltx/1/a.ltx", "v1", 100<<20)
+ f.failPut = true
+ r, err := MirrorPrefix(context.Background(), f, "src", "instances/h/", "dst", "moves/h/", testReceipts)
+ if err == nil || r.BytesCopied != 100<<20 {
+ t.Fatalf("round with a failed receipt write = %+v, %v; want the copy reported and the error surfaced", r, err)
+ }
+ f.failPut = false
+ if r := mirrorRound(t, f, testReceipts); r.Changed != 1 || r.BytesCopied != 100<<20 {
+ t.Fatalf("next round = %+v, want exactly one recopy", r)
+ }
+ if r := mirrorRound(t, f, testReceipts); r.Changed != 0 {
+ t.Fatalf("round after = %+v, want the receipt to hold", r)
+ }
+}
+
+// A source overwritten between the listing and the copy fails the
+// conditional copy. The round records no receipt for it and carries on;
+// the next round copies the new version.
+func TestMirrorPrefixSourceChangedMidRoundIsDeferred(t *testing.T) {
+ f := newFakeMirror()
+ f.putMultipart("src", "instances/h/ltx/1/a.ltx", "v1", 100<<20)
+ f.putSized("src", "instances/h/ltx/0/b.ltx", "b", 10)
+ f.beforeCopy = func(k string) {
+ if k == "instances/h/ltx/1/a.ltx" {
+ f.putMultipart("src", k, "v2", 120<<20)
+ f.beforeCopy = nil
+ }
+ }
+ r := mirrorRound(t, f, testReceipts)
+ if r.Changed != 2 || r.Deferred != 1 || r.BytesDeferred != 100<<20 || r.BytesCopied != 10 {
+ t.Fatalf("round = %+v, want b copied and a deferred at its listed size", r)
+ }
+ if r.DeltaBytes() != 100<<20+10 {
+ t.Fatalf("DeltaBytes = %d", r.DeltaBytes())
+ }
+ if _, ok := f.buckets["dst"]["moves/h/ltx/1/a.ltx"]; ok {
+ t.Fatal("a deferred copy landed anyway")
+ }
+ if _, err := f.Get(context.Background(), testReceipts.Bucket, testReceipts.Key); err == nil {
+ if _, ok := readReceipts(t, f)["ltx/1/a.ltx"]; ok {
+ t.Fatal("a receipt was recorded for a copy that failed its precondition")
+ }
+ }
+ if r := mirrorRound(t, f, testReceipts); r.Changed != 1 || r.BytesCopied != 120<<20 || r.Deferred != 0 {
+ t.Fatalf("next round = %+v, want the new version copied", r)
+ }
+ if got := f.buckets["dst"]["moves/h/ltx/1/a.ltx"].body; got != "v2" {
+ t.Fatalf("staged body = %q, want v2", got)
+ }
+}
+
+// Receipts name the pair they were written for. Another pair's receipts,
+// even with matching keys and ETags, are not evidence about this one.
+func TestMirrorPrefixIgnoresAnotherPairsReceipts(t *testing.T) {
+ ctx := context.Background()
+ f := newFakeMirror()
+ f.putMultipart("src", "instances/h/ltx/1/a.ltx", "v1", 100<<20)
+ mirrorRound(t, f, testReceipts)
+ // Another destination that happens to hold a lookalike object.
+ f.putSized("dst", "other/h/ltx/1/a.ltx", "v1", 100<<20)
+ r, err := MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "other/h/", testReceipts)
+ if err != nil || r.Changed != 1 {
+ t.Fatalf("round toward another destination = %+v, %v; want a copy", r, err)
+ }
+}
+
+// Unreadable receipts are no receipts: everything recopies once, and the
+// book is rewritten.
+func TestMirrorPrefixCorruptReceiptsRecopy(t *testing.T) {
+ f := newFakeMirror()
+ f.putMultipart("src", "instances/h/ltx/1/a.ltx", "v1", 100<<20)
+ mirrorRound(t, f, testReceipts)
+ f.store(testReceipts.Bucket, testReceipts.Key, fakeObject{body: "{not json"})
+ if r := mirrorRound(t, f, testReceipts); r.Changed != 1 {
+ t.Fatalf("round over corrupt receipts = %+v, want a recopy", r)
+ }
+ if r := mirrorRound(t, f, testReceipts); r.Changed != 0 {
+ t.Fatalf("round after = %+v, want the rewritten receipts to hold", r)
+ }
+}
+
+// A source object retention pruned takes its receipt with it.
+func TestMirrorPrefixPrunesReceiptsWithTheirObjects(t *testing.T) {
+ f := newFakeMirror()
+ f.putMultipart("src", "instances/h/ltx/1/a.ltx", "v1", 100<<20)
+ f.putMultipart("src", "instances/h/ltx/1/b.ltx", "w1", 100<<20)
+ mirrorRound(t, f, testReceipts)
+ delete(f.buckets["src"], "instances/h/ltx/1/a.ltx")
+ if r := mirrorRound(t, f, testReceipts); r.Changed != 1 || r.BytesCopied != 0 {
+ t.Fatalf("prune round = %+v, want one delete", r)
+ }
+ if got := readReceipts(t, f); len(got) != 1 || got["ltx/1/b.ltx"].SrcETag != "w1-27" {
+ t.Fatalf("receipts = %+v, want only b's", got)
+ }
+}
+
+// Receipts that cannot be READ (a failure that is not "missing") fail the
+// round: guessing "no receipts" would quietly bring back the recopy loop,
+// and saving over a book this round never read would destroy it.
+func TestMirrorPrefixReceiptReadFailureFailsTheRound(t *testing.T) {
+ f := newFakeMirror()
+ f.putMultipart("src", "instances/h/ltx/1/a.ltx", "v1", 100<<20)
+ mirrorRound(t, f, testReceipts)
+ before := readReceipts(t, f)
+ f.putMultipart("src", "instances/h/ltx/1/b.ltx", "w1", 100<<20)
+ f.failGet = true
+ if _, err := MirrorPrefix(context.Background(), f, "src", "instances/h/", "dst", "moves/h/", testReceipts); err == nil {
+ t.Fatal("an unreadable receipts object did not fail the round")
+ }
+ f.failGet = false
+ if got := readReceipts(t, f); len(got) != len(before) || got["ltx/1/a.ltx"] != before["ltx/1/a.ltx"] {
+ t.Fatalf("receipts after a failed read = %+v, want them untouched (%+v)", got, before)
+ }
+}