carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package lease
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "sync/atomic"
9 "testing"
10 "time"
11)
12
13// The parallel round (offline-move speedup): copies and deletes run up to
14// mirrorWorkers(m) at once. These tests hold the round to what the serial
15// one promised: the same end state, receipts for exactly what landed, the
16// first failure stopping the round, and no skip a receipt cannot prove.
17// Run them under -race: the round's own state is shared by its workers.
18
19// fillReplica stores n objects under instances/h/: every third one
20// multipart (the copy lands with a new ETag, so it needs a receipt), the
21// rest single-part. Sizes differ so byte totals catch a double count.
22func fillReplica(f *fakeMirror, n int, gen string) (keys []string, bytes int64) {
23 for i := 0; i < n; i++ {
24 key := fmt.Sprintf("instances/h/ltx/0/%s-%04d.ltx", gen, i)
25 size := int64(1000 + i)
26 if i%3 == 0 {
27 f.putMultipart("src", key, fmt.Sprintf("%s-body-%d", gen, i), size)
28 } else {
29 f.putSized("src", key, fmt.Sprintf("%s-etag-%d", gen, i), size)
30 }
31 keys = append(keys, key)
32 bytes += size
33 }
34 return keys, bytes
35}
36
37// stagedBodies is moves/h/ by key relative to it, body for body.
38func stagedBodies(f *fakeMirror) map[string]string {
39 f.mu.Lock()
40 defer f.mu.Unlock()
41 out := map[string]string{}
42 for k, v := range f.buckets["dst"] {
43 if strings.HasPrefix(k, "moves/h/") {
44 out[strings.TrimPrefix(k, "moves/h/")] = v.body
45 }
46 }
47 return out
48}
49
50// sourceBodies is instances/h/ the same way, lock.json aside.
51func sourceBodies(f *fakeMirror) map[string]string {
52 f.mu.Lock()
53 defer f.mu.Unlock()
54 out := map[string]string{}
55 for k, v := range f.buckets["src"] {
56 if rel, ok := strings.CutPrefix(k, "instances/h/"); ok && rel != "lock.json" {
57 out[rel] = v.body
58 }
59 }
60 return out
61}
62
63func sameBodies(t *testing.T, got, want map[string]string) {
64 t.Helper()
65 if len(got) != len(want) {
66 t.Fatalf("staged %d objects, source has %d", len(got), len(want))
67 }
68 for k, v := range want {
69 if got[k] != v {
70 t.Fatalf("staged %s = %q, source %q", k, got[k], v)
71 }
72 }
73}
74
75func copiesIn(ops []string) int {
76 n := 0
77 for _, op := range ops {
78 if strings.HasPrefix(op, "copy ") {
79 n++
80 }
81 }
82 return n
83}
84
85// A parallel round ends where a serial one does: every object staged body
86// for body, the same counts and bytes, receipts for exactly the multipart
87// objects, and a caught-up round after it. It really runs side by side,
88// and never more than the store allows.
89func TestMirrorPrefixParallelMatchesSerial(t *testing.T) {
90 const n = 120
91 var rounds [2]MirrorRound
92 var books [2]map[string]copyReceipt
93 for i, workers := range []int{1, 6} {
94 f := newFakeMirror()
95 f.workers = workers
96 _, bytes := fillReplica(f, n, "a")
97 f.put("src", "instances/h/lock.json", "renewal")
98 // Slow copies, so the workers overlap.
99 f.onCopy = func(context.Context, string) error { time.Sleep(time.Millisecond); return nil }
100 r := mirrorRound(t, f, testReceipts)
101 if r.Changed != n || r.BytesCopied != bytes || r.Deferred != 0 {
102 t.Fatalf("workers=%d: round = %+v, want %d copies of %d bytes", workers, r, n, bytes)
103 }
104 sameBodies(t, stagedBodies(f), sourceBodies(f))
105 if f.peak > workers || (workers > 1 && f.peak < 2) {
106 t.Fatalf("workers=%d: peak concurrency %d", workers, f.peak)
107 }
108 book := readReceipts(t, f)
109 if len(book) != n/3 {
110 t.Fatalf("workers=%d: %d receipts, want one per multipart object (%d)", workers, len(book), n/3)
111 }
112 if again := mirrorRound(t, f, testReceipts); again.Changed != 0 {
113 t.Fatalf("workers=%d: caught-up round = %+v", workers, again)
114 }
115 rounds[i], books[i] = r, book
116 }
117 if rounds[0].Changed != rounds[1].Changed || rounds[0].BytesCopied != rounds[1].BytesCopied {
118 t.Fatalf("serial %+v and parallel %+v rounds differ", rounds[0], rounds[1])
119 }
120 for k, v := range books[0] {
121 if books[1][k] != v {
122 t.Fatalf("receipt %s: serial %+v, parallel %+v", k, v, books[1][k])
123 }
124 }
125}
126
127// The versioned mode under workers: every copied object's version lands in
128// Versions, and the receipts carry them, so the next round names them all
129// without copying.
130func TestMirrorPrefixParallelVersionedRound(t *testing.T) {
131 f := &fakeVersionedMirror{fakeMirror: newFakeMirror()}
132 f.workers = 8
133 keys, _ := fillReplica(f.fakeMirror, 64, "a")
134 r1, err := MirrorPrefix(context.Background(), f, "src", "instances/h/", "dst", "moves/h/", testReceipts)
135 if err != nil || len(r1.Versions) != len(keys) {
136 t.Fatalf("round 1 = %d versions, %v; want %d", len(r1.Versions), err, len(keys))
137 }
138 seen := map[string]bool{}
139 for _, v := range r1.Versions {
140 if seen[v] {
141 t.Fatalf("version %s handed to two copies", v)
142 }
143 seen[v] = true
144 }
145 r2, err := MirrorPrefix(context.Background(), f, "src", "instances/h/", "dst", "moves/h/", testReceipts)
146 if err != nil || r2.Changed != 0 || len(r2.Versions) != len(keys) {
147 t.Fatalf("round 2 = %+v, %v; want nothing copied and every version named", r2, err)
148 }
149 for k, v := range r1.Versions {
150 if r2.Versions[k] != v {
151 t.Fatalf("%s: round 2 names %q, round 1 copied %q", k, r2.Versions[k], v)
152 }
153 }
154}
155
156// The first failure stops the round: it is the error, copies still in
157// flight see their context cancelled, nothing new starts, and every copy
158// that landed keeps its receipt, so the next round copies only what did
159// not land.
160func TestMirrorPrefixParallelFailureMidRound(t *testing.T) {
161 f := newFakeMirror()
162 f.workers = 4
163 // All multipart: a landed copy is skipped next round only by its receipt.
164 for i := 0; i < 40; i++ {
165 f.putMultipart("src", fmt.Sprintf("instances/h/ltx/1/%04d.ltx", i), fmt.Sprintf("b%d", i), 100)
166 }
167 bad := "instances/h/ltx/1/0010.ltx"
168 var cancelled atomic.Int32
169 later := make(chan struct{}, 64) // a copy after the bad one has started
170 f.onCopy = func(ctx context.Context, key string) error {
171 switch {
172 case key == bad:
173 // Fail only once a later copy is in flight, so the cancel
174 // has something to reach.
175 select {
176 case <-later:
177 case <-time.After(5 * time.Second):
178 }
179 return errors.New("injected copy failure")
180 case key > bad:
181 later <- struct{}{}
182 select {
183 case <-ctx.Done():
184 cancelled.Add(1)
185 return ctx.Err()
186 case <-time.After(5 * time.Second):
187 return errors.New("the round did not cancel a copy in flight")
188 }
189 }
190 return nil
191 }
192 r, err := MirrorPrefix(context.Background(), f, "src", "instances/h/", "dst", "moves/h/", testReceipts)
193 if err == nil || !strings.Contains(err.Error(), bad) || errors.Is(err, context.Canceled) {
194 t.Fatalf("round error = %v, want the injected failure on %s", err, bad)
195 }
196 landed := stagedBodies(f)
197 if r.Changed != len(landed) || r.BytesCopied != int64(len(landed))*100 {
198 t.Fatalf("round = %+v, but %d objects landed", r, len(landed))
199 }
200 if len(landed) == 0 || len(landed) > 10 {
201 t.Fatalf("%d objects landed, want those before the failure only", len(landed))
202 }
203 if cancelled.Load() == 0 {
204 t.Fatal("no copy in flight was cancelled")
205 }
206 // Nothing past the workers in flight at the failure ever started.
207 if started := copiesIn(f.ops) + int(cancelled.Load()) + 1; started > 10+1+f.workers {
208 t.Fatalf("%d copies started, want the round to stop dispatching at the failure", started)
209 }
210 book := readReceipts(t, f)
211 for rel := range landed {
212 if _, ok := book[rel]; !ok {
213 t.Fatalf("%s landed without a receipt", rel)
214 }
215 }
216 if len(book) != len(landed) {
217 t.Fatalf("%d receipts for %d landed copies", len(book), len(landed))
218 }
219 f.onCopy = nil
220 before := len(f.ops)
221 next := mirrorRound(t, f, testReceipts)
222 if next.Changed != 40-len(landed) {
223 t.Fatalf("next round = %+v, want exactly the %d that did not land", next, 40-len(landed))
224 }
225 for _, op := range f.ops[before:] {
226 for rel := range landed {
227 if strings.HasPrefix(op, "copy instances/h/"+rel+" ") {
228 t.Fatalf("a landed copy was recopied: %s", op)
229 }
230 }
231 }
232 sameBodies(t, stagedBodies(f), sourceBodies(f))
233}
234
235// A caller's context that ends mid-round is the round's error, never a
236// silent partial success.
237func TestMirrorPrefixParallelContextEnds(t *testing.T) {
238 f := newFakeMirror()
239 f.workers = 2
240 fillReplica(f, 30, "a")
241 ctx, cancel := context.WithCancel(context.Background())
242 var n atomic.Int32
243 f.onCopy = func(context.Context, string) error {
244 if n.Add(1) == 3 {
245 cancel()
246 }
247 return nil
248 }
249 if _, err := MirrorPrefix(ctx, f, "src", "instances/h/", "dst", "moves/h/", testReceipts); !errors.Is(err, context.Canceled) {
250 t.Fatalf("round error = %v, want the context's", err)
251 }
252 if len(stagedBodies(f)) == 30 {
253 t.Fatal("every copy ran after the context ended")
254 }
255}
256
257// The pre-copy (offline move stage): rounds while the instance serves, then
258// the seal's round. The seal finds what stage copied and copies only what is
259// new since; its receipts prove every multipart skip.
260func TestMirrorPrefixReceiptsSkipAfterAPreCopy(t *testing.T) {
261 f := newFakeMirror()
262 _, staged := fillReplica(f, 90, "a")
263 if r := mirrorRound(t, f, testReceipts); r.BytesCopied != staged {
264 t.Fatalf("stage round = %+v, want %d bytes", r, staged)
265 }
266 // The instance kept writing: new LTX files.
267 _, fresh := fillReplica(f, 6, "b")
268 before := len(f.ops)
269 seal := mirrorRound(t, f, testReceipts)
270 if seal.BytesCopied != fresh || seal.Changed != 6 || seal.Deferred != 0 {
271 t.Fatalf("seal round = %+v, want only the 6 new objects (%d bytes)", seal, fresh)
272 }
273 for _, op := range f.ops[before:] {
274 if strings.HasPrefix(op, "copy ") && !strings.Contains(op, "/b-") {
275 t.Fatalf("the seal recopied a pre-copied object: %s", op)
276 }
277 }
278 sameBodies(t, stagedBodies(f), sourceBodies(f))
279}
280
281// Between stage and seal, litestream's retention and compaction delete
282// replica files and write new ones. The seal's round deletes what the source
283// no longer has and drops those receipts, so the staged listing is the
284// source's again (the seal's sameListing).
285func TestMirrorPrefixSourceDeletionsBetweenStageAndSeal(t *testing.T) {
286 f := newFakeMirror()
287 keys, _ := fillReplica(f, 30, "a")
288 mirrorRound(t, f, testReceipts)
289 // Compaction: twelve level-0 files folded into one level-1 file.
290 for _, k := range keys[:12] {
291 f.remove("src", k)
292 }
293 f.putMultipart("src", "instances/h/ltx/1/compacted.ltx", "compacted", 12000)
294 r := mirrorRound(t, f, testReceipts)
295 if r.Changed != 13 || r.BytesCopied != 12000 {
296 t.Fatalf("seal round = %+v, want 12 deletes and the one copy", r)
297 }
298 sameBodies(t, stagedBodies(f), sourceBodies(f))
299 for rel := range readReceipts(t, f) {
300 if _, ok := sourceBodies(f)[rel]; !ok {
301 t.Fatalf("a receipt outlived its source object: %s", rel)
302 }
303 }
304 if again := mirrorRound(t, f, testReceipts); again.Changed != 0 {
305 t.Fatalf("round after = %+v", again)
306 }
307}
308
309// A staged prefix left by an earlier move is never trusted as such. A stale
310// object skips only when its ETag equals the source's (the same bytes) or a
311// receipt for this exact pair still matches both sides; anything else is
312// copied again, and every key the source lacks is deleted.
313func TestMirrorPrefixStaleStagedPrefixIsNotTrusted(t *testing.T) {
314 f := newFakeMirror()
315 f.putMultipart("src", "instances/h/ltx/1/a.ltx", "new-a", 500)
316 f.putMultipart("src", "instances/h/ltx/1/b.ltx", "new-b", 500)
317 f.putSized("src", "instances/h/ltx/0/c.ltx", "new-c", 50)
318 f.putSized("src", "instances/h/ltx/0/d.ltx", "same-d", 50)
319 // The stale prefix: a under the old move's copy of other bytes, b with
320 // the same size as the source and a stale receipt, c changed, d byte
321 // identical, and an extra the source never had.
322 f.putSized("dst", "moves/h/ltx/1/a.ltx", "old-a", 500)
323 f.putSized("dst", "moves/h/ltx/1/b.ltx", "old-b", 500)
324 f.putSized("dst", "moves/h/ltx/0/c.ltx", "old-c", 50)
325 f.putSized("dst", "moves/h/ltx/0/d.ltx", "same-d", 50)
326 f.putSized("dst", "moves/h/ltx/0/extra.ltx", "old-x", 50)
327 // An old book for this pair whose receipt for b names the source as it
328 // is now but a destination ETag the staged object does not carry.
329 stale := (`{"v":1,"src_bucket":"src","src_prefix":"instances/h/","dst_bucket":"dst","dst_prefix":"moves/h/",` +
330 `"objects":{"ltx/1/b.ltx":{"src_etag":"new-b-27","src_size":500,"dst_etag":"new-b"},"ltx/0/extra.ltx":{"src_etag":"x","src_size":50,"dst_etag":"old-x"}}}`)
331 f.store("src", testReceipts.Key, fakeObject{MirrorObject{ETag: "book", Size: int64(len(stale))}, stale})
332
333 r := mirrorRound(t, f, testReceipts)
334 if r.Changed != 4 || r.BytesCopied != 1050 {
335 t.Fatalf("round = %+v, want a, b and c copied and the extra deleted", r)
336 }
337 sameBodies(t, stagedBodies(f), sourceBodies(f))
338 book := readReceipts(t, f)
339 if _, ok := book["ltx/0/extra.ltx"]; ok {
340 t.Fatal("the stale receipt of a deleted key survived")
341 }
342 if got := book["ltx/1/b.ltx"]; got.DstETag != "new-b" || got.SrcETag != "new-b-27" {
343 t.Fatalf("b's receipt = %+v", got)
344 }
345
346 // A book written for another pair (another move's target) is no
347 // evidence at all, even when its receipt would match.
348 g := newFakeMirror()
349 g.putMultipart("src", "instances/h/ltx/1/a.ltx", "a", 500)
350 g.putSized("dst", "moves/h/ltx/1/a.ltx", "a", 500)
351 other := `{"v":1,"src_bucket":"src","src_prefix":"instances/h/","dst_bucket":"other-dst","dst_prefix":"moves/h/",` +
352 `"objects":{"ltx/1/a.ltx":{"src_etag":"a-27","src_size":500,"dst_etag":"a"}}}`
353 g.store("src", testReceipts.Key, fakeObject{MirrorObject{ETag: "book", Size: int64(len(other))}, other})
354 if r := mirrorRound(t, g, testReceipts); r.Changed != 1 {
355 t.Fatalf("round over another pair's book = %+v, want the object copied again", r)
356 }
357}
358
359// forEachParallel on its own: an error stops dispatch, and an already-ended
360// context starts nothing.
361func TestForEachParallel(t *testing.T) {
362 var ran atomic.Int32
363 err := forEachParallel(context.Background(), 1, 10, func(_ context.Context, i int) error {
364 ran.Add(1)
365 if i == 3 {
366 return errors.New("boom")
367 }
368 return nil
369 })
370 if err == nil || err.Error() != "boom" || ran.Load() > 5 {
371 t.Fatalf("err %v after %d calls, want boom after at most 5", err, ran.Load())
372 }
373 ctx, cancel := context.WithCancel(context.Background())
374 cancel()
375 ran.Store(0)
376 err = forEachParallel(ctx, 4, 10, func(context.Context, int) error { ran.Add(1); return nil })
377 if !errors.Is(err, context.Canceled) || ran.Load() > 4 {
378 t.Fatalf("err %v after %d calls on an ended context", err, ran.Load())
379 }
380 if err := forEachParallel(context.Background(), 8, 0, nil); err != nil {
381 t.Fatal(err)
382 }
383}
384
385// Astra round 1, finding 3: no network request is made while the round's
386// accounting is locked. A receipts read that stalls once a copy has landed
387// (the lazy load a receipt write used to make under the lock) must not keep
388// another copy's failure from stopping the round: the book is read before
389// any copy starts.
390func TestMirrorPrefixAReceiptsReadNeverBlocksTheFirstFailure(t *testing.T) {
391 f := newFakeMirror()
392 f.workers = 2
393 f.putMultipart("src", "instances/h/ltx/1/a.ltx", "a", 100) // lands, needs a receipt
394 f.putSized("src", "instances/h/ltx/0/b.ltx", "b", 10) // fails
395 var landed atomic.Bool
396 release := make(chan struct{})
397 t.Cleanup(func() { close(release) })
398 f.onCopy = func(_ context.Context, key string) error {
399 if strings.HasSuffix(key, "b.ltx") {
400 // Fail only once a's copy has landed and its receipt is due.
401 for deadline := time.Now().Add(5 * time.Second); !landed.Load() && time.Now().Before(deadline); time.Sleep(time.Millisecond) {
402 }
403 time.Sleep(20 * time.Millisecond)
404 return errors.New("injected copy failure")
405 }
406 landed.Store(true)
407 return nil
408 }
409 f.onGet = func(string) {
410 if landed.Load() {
411 <-release // a receipts read that stalls after a copy
412 }
413 }
414 done := make(chan error, 1)
415 go func() {
416 _, err := MirrorPrefix(context.Background(), f, "src", "instances/h/", "dst", "moves/h/", testReceipts)
417 done <- err
418 }()
419 select {
420 case err := <-done:
421 if err == nil || !strings.Contains(err.Error(), "injected copy failure") {
422 t.Fatalf("round error = %v, want the injected failure", err)
423 }
424 case <-time.After(3 * time.Second):
425 t.Fatal("a receipts read made under the round's lock kept the first failure from stopping the round")
426 }
427}
428