carlos / platform Public

lease: the staging mirror — one delta round over a replica prefix (#331)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEypt7de2X1G84aQ9rNkCf
Paul Campbell pushed by paul@keymail.dev 28249199d10b8776715edc4773b484adaf3af82b parent 0011557
2 files changed, +247 −0
  • internal/lease/mirror.go +153 −0
  • internal/lease/mirror_test.go +94 −0
diff --git a/internal/lease/mirror.go b/internal/lease/mirror.go
new file mode 100644
index 000000000..15dd29c20
--- /dev/null
+++ b/internal/lease/mirror.go
@@ -0,0 +1,153 @@
+package lease
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "sort"
+
+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+)
+
+// 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.
+//
+// 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.
+type MirrorStore interface {
+ List(ctx context.Context, bucket, prefix string) (map[string]string, error)
+ Copy(ctx context.Context, srcBucket, srcKey, dstBucket, dstKey string) error
+ Delete(ctx context.Context, bucket, key string) error
+}
+
+// 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".
+//
+// 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) {
+ src, err := m.List(ctx, srcBucket, srcPrefix)
+ if err != nil {
+ return 0, 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)
+ }
+ // 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
+ for _, r := range rel {
+ sk, dk := srcPrefix+r, dstPrefix+r
+ if et, ok := dst[dk]; ok && et == src[sk] {
+ continue
+ }
+ if err := m.Copy(ctx, srcBucket, sk, dstBucket, dk); err != nil {
+ return changed, fmt.Errorf("mirror: copy %s: %w", sk, err)
+ }
+ changed++
+ }
+ for dk := range dst {
+ r := dk[len(dstPrefix):]
+ 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)
+ }
+ changed++
+ }
+ }
+ return changed, nil
+}
+
+// s3Mirror implements MirrorStore over two regional clients: reads and the
+// copy call go to the DESTINATION region's client (CopyObject executes in
+// the destination bucket's region and pulls from the source), source
+// listings to the source's.
+type s3Mirror struct {
+ src, dst *awss3.Client
+}
+
+// NewS3Mirror builds the two-region pair with the same credential chain and
+// endpoint compatibility every other S3 consumer here uses (endpoint.go's
+// newLeaserClient).
+func NewS3Mirror(ctx context.Context, srcRegion, dstRegion string) (MirrorStore, error) {
+ src, _, err := newLeaserClient(ctx, srcRegion)
+ if err != nil {
+ return nil, err
+ }
+ dst, _, err := newLeaserClient(ctx, dstRegion)
+ if err != nil {
+ return nil, err
+ }
+ return &s3Mirror{src: src, dst: dst}, nil
+}
+
+func (m *s3Mirror) client(bucketSide *awss3.Client) *awss3.Client { return bucketSide }
+
+func (m *s3Mirror) list(ctx context.Context, c *awss3.Client, bucket, prefix string) (map[string]string, error) {
+ out := map[string]string{}
+ var token *string
+ for {
+ resp, err := c.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{
+ Bucket: &bucket, Prefix: &prefix, ContinuationToken: token,
+ })
+ if err != nil {
+ return nil, err
+ }
+ for _, o := range resp.Contents {
+ if o.Key == nil {
+ continue
+ }
+ et := ""
+ if o.ETag != nil {
+ et = *o.ETag
+ }
+ out[*o.Key] = et
+ }
+ if resp.IsTruncated == nil || !*resp.IsTruncated || resp.NextContinuationToken == nil {
+ return out, nil
+ }
+ token = resp.NextContinuationToken
+ }
+}
+
+// List tries the destination client first and falls back to the source's —
+// the two buckets live in different regions and the caller does not say
+// which side a bucket is on. A region-mismatch answer from S3 is an error,
+// so the fallback is unambiguous.
+func (m *s3Mirror) List(ctx context.Context, bucket, prefix string) (map[string]string, error) {
+ if out, err := m.list(ctx, m.src, bucket, prefix); err == nil {
+ return out, nil
+ }
+ return m.list(ctx, m.dst, bucket, prefix)
+}
+
+func (m *s3Mirror) Copy(ctx context.Context, srcBucket, srcKey, dstBucket, dstKey string) error {
+ source := url.PathEscape(srcBucket + "/" + srcKey)
+ _, err := m.dst.CopyObject(ctx, &awss3.CopyObjectInput{
+ Bucket: &dstBucket, Key: &dstKey, CopySource: &source,
+ })
+ return err
+}
+
+func (m *s3Mirror) Delete(ctx context.Context, bucket, key string) error {
+ _, err := m.dst.DeleteObject(ctx, &awss3.DeleteObjectInput{Bucket: &bucket, Key: &key})
+ return err
+}
diff --git a/internal/lease/mirror_test.go b/internal/lease/mirror_test.go
new file mode 100644
index 000000000..7ef40543c
--- /dev/null
+++ b/internal/lease/mirror_test.go
@@ -0,0 +1,94 @@
+package lease
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+)
+
+// fakeMirror is an in-memory MirrorStore: buckets of key→etag, plus an op
+// log so ordering tests can assert the sequence.
+type fakeMirror struct {
+ buckets map[string]map[string]string
+ ops []string
+ failOn string
+}
+
+func newFakeMirror() *fakeMirror { return &fakeMirror{buckets: map[string]map[string]string{}} }
+
+func (f *fakeMirror) put(bucket, key, etag string) {
+ if f.buckets[bucket] == nil {
+ f.buckets[bucket] = map[string]string{}
+ }
+ f.buckets[bucket][key] = etag
+}
+
+func (f *fakeMirror) List(_ context.Context, bucket, prefix string) (map[string]string, error) {
+ out := map[string]string{}
+ for k, v := range f.buckets[bucket] {
+ if strings.HasPrefix(k, prefix) {
+ out[k] = v
+ }
+ }
+ return out, nil
+}
+
+func (f *fakeMirror) Copy(_ context.Context, srcBucket, srcKey, dstBucket, dstKey string) error {
+ if f.failOn == srcKey {
+ return fmt.Errorf("injected copy failure on %s", srcKey)
+ }
+ f.ops = append(f.ops, "copy "+srcKey+" -> "+dstBucket+"/"+dstKey)
+ f.put(dstBucket, dstKey, f.buckets[srcBucket][srcKey])
+ return nil
+}
+
+func (f *fakeMirror) Delete(_ context.Context, bucket, key string) error {
+ f.ops = append(f.ops, "delete "+bucket+"/"+key)
+ delete(f.buckets[bucket], key)
+ return nil
+}
+
+func TestMirrorPrefix(t *testing.T) {
+ ctx := context.Background()
+ f := newFakeMirror()
+ f.put("src", "instances/h/generations/a/snapshot", "e1")
+ 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)
+ }
+ // 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)
+ }
+ // 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)
+ }
+ got, _ := f.List(ctx, "dst", "moves/h/")
+ want := map[string]string{
+ "moves/h/generations/a/wal/000": "e2b",
+ "moves/h/generations/a/wal/001": "e3",
+ }
+ if len(got) != len(want) {
+ 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)
+ }
+ }
+ // 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 {
+ t.Fatal("injected failure did not surface")
+ }
+}