| 1 | package dbreads |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "io" |
| 11 | "os" |
| 12 | "reflect" |
| 13 | "strings" |
| 14 | "sync" |
| 15 | "sync/atomic" |
| 16 | "testing" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/benbjohnson/litestream" |
| 20 | lsfile "github.com/benbjohnson/litestream/file" |
| 21 | "github.com/superfly/ltx" |
| 22 | |
| 23 | "github.com/carlosframework/platform/internal/dbdoor" |
| 24 | "github.com/carlosframework/platform/internal/provision" |
| 25 | "github.com/carlosframework/platform/internal/release" |
| 26 | ) |
| 27 | |
| 28 | func serveOne(t *testing.T, a *Agent, fx *fixture, req Request) ([]byte, Summary, Result) { |
| 29 | t.Helper() |
| 30 | e := fx.submit(req) |
| 31 | a.consider(context.Background(), e) |
| 32 | a.Wait() |
| 33 | return fx.answer(req.ID) |
| 34 | } |
| 35 | |
| 36 | func TestReadServesAQuery(t *testing.T) { |
| 37 | t.Parallel() |
| 38 | fx := newFixture(t, 3) |
| 39 | a, w := fx.agent("uk-1") |
| 40 | rows, s, r := serveOne(t, a, fx, fx.request("SELECT count(*) AS n FROM t", "")) |
| 41 | if r.State != StateDone || r.Code != "" || string(rows) != "n\n30\n" || r.Box != "uk-1" { |
| 42 | t.Fatalf("result %+v, rows %q", r, rows) |
| 43 | } |
| 44 | plan, err := dbdoor.PlanFrom(context.Background(), lsfile.NewReplicaClient(fx.replica), time.Time{}) |
| 45 | if err != nil || s.TXID != plan.TXID || !s.At.Equal(plan.At) { |
| 46 | t.Fatalf("summary %+v, plan %+v %v", s, plan, err) |
| 47 | } |
| 48 | if w.dials.Load() != 1 { |
| 49 | t.Fatalf("%d worker connections for one read", w.dials.Load()) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // vanishing makes full-file opens of the first planned file answer |
| 54 | // os.ErrNotExist, `times` times: litestream's compaction removing an L0 |
| 55 | // file under the plan. |
| 56 | type vanishing struct { |
| 57 | dbdoor.ReplicaReader |
| 58 | mu sync.Mutex |
| 59 | times int |
| 60 | } |
| 61 | |
| 62 | func (v *vanishing) OpenLTXFile(ctx context.Context, level int, lo, hi ltx.TXID, off, size int64) (io.ReadCloser, error) { |
| 63 | v.mu.Lock() |
| 64 | gone := off == 0 && size == 0 && v.times > 0 |
| 65 | if gone { |
| 66 | v.times-- |
| 67 | } |
| 68 | v.mu.Unlock() |
| 69 | if gone { |
| 70 | return nil, os.ErrNotExist |
| 71 | } |
| 72 | return v.ReplicaReader.OpenLTXFile(ctx, level, lo, hi, off, size) |
| 73 | } |
| 74 | |
| 75 | // Review Focus 3: one quiet re-plan, then changed-twice, and no chunk. |
| 76 | func TestReadRePlansOnceThenSaysChangedTwice(t *testing.T) { |
| 77 | t.Parallel() |
| 78 | for _, c := range []struct { |
| 79 | times int |
| 80 | want string |
| 81 | }{{1, ""}, {2, CodeChangedTwice}} { |
| 82 | fx := newFixture(t, 2) |
| 83 | v := &vanishing{ReplicaReader: lsfile.NewReplicaClient(fx.replica), times: c.times} |
| 84 | a, _ := fx.agent("uk-1", func(cfg *AgentConfig, _ *inProcWorkers) { |
| 85 | cfg.OpenReplica = func(context.Context, string) (dbdoor.ReplicaReader, error) { return v, nil } |
| 86 | }) |
| 87 | req := fx.request("SELECT count(*) FROM t", "") |
| 88 | _, _, r := serveOne(t, a, fx, req) |
| 89 | if r.Code != c.want { |
| 90 | t.Fatalf("vanished %d times: %+v", c.times, r) |
| 91 | } |
| 92 | if c.want != "" && len(fx.chunkKeys(req.ID)) != 0 { |
| 93 | t.Fatal("a chunk exists for a read that never ran") |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | // Plan review finding 10: after a vanished file, the retry waits for the |
| 99 | // first worker's unit to stop before it dials again, and keeps that unit's |
| 100 | // reservation until then. 12 GiB of 20 fits one floor at a time. |
| 101 | // Mutations: drop the settle call in read (the second dial sees a live unit, |
| 102 | // and the retired floor makes the new one no-room); or drop retry's move of |
| 103 | // the old reservation (TestAdmissionRetryHoldsTheOldReservation goes red). |
| 104 | func TestRetryWaitsForTheFirstWorkerToStop(t *testing.T) { |
| 105 | t.Parallel() |
| 106 | fx := newFixture(t, 2) |
| 107 | v := &vanishing{ReplicaReader: lsfile.NewReplicaClient(fx.replica), times: 1} |
| 108 | a, w := fx.agent("uk-1", func(c *AgentConfig, w *inProcWorkers) { |
| 109 | c.OpenReplica = func(context.Context, string) (dbdoor.ReplicaReader, error) { return v, nil } |
| 110 | c.Free = func() (uint64, uint64, error) { return 12 << 30, 20 << 30, nil } |
| 111 | w.linger = 300 * time.Millisecond |
| 112 | }) |
| 113 | _, _, r := serveOne(t, a, fx, fx.request("SELECT count(*) FROM t", "")) |
| 114 | if r.State != StateDone { |
| 115 | t.Fatalf("result %+v", r) |
| 116 | } |
| 117 | w.mu.Lock() |
| 118 | defer w.mu.Unlock() |
| 119 | if len(w.liveAtDial) != 2 || w.liveAtDial[1] != 0 { |
| 120 | t.Fatalf("live units at each dial: %v; the retry dialed beside the first worker", w.liveAtDial) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // Plan review finding 15: a worker that ends before ready (its service |
| 125 | // failed after the socket accepted) asks for a probe at once. Mutation: |
| 126 | // drop the askProbe after a pre-ready sendFile or readFrame failure; |
| 127 | // probeAfter stays empty and this test is red. |
| 128 | func TestAWorkerThatNeverStartsAsksForAProbe(t *testing.T) { |
| 129 | t.Parallel() |
| 130 | fx := newFixture(t, 1) |
| 131 | a, _ := fx.agent("uk-1", func(_ *AgentConfig, w *inProcWorkers) { w.failStart.Store(true) }) |
| 132 | _, _, r := serveOne(t, a, fx, fx.request("SELECT 1", "")) |
| 133 | if r.Code != CodeInterrupted || len(a.probeAfter) != 1 { |
| 134 | t.Fatalf("result %+v, probe asked %d", r, len(a.probeAfter)) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // stageHook changes the record at one stage of the read: planning (the |
| 139 | // first listing), download (the first full-file open), or after the last |
| 140 | // byte has been read from the bucket (EOF of the last planned file). |
| 141 | type stageHook struct { |
| 142 | dbdoor.ReplicaReader |
| 143 | stage, lastSpan string |
| 144 | once sync.Once |
| 145 | change func() |
| 146 | } |
| 147 | |
| 148 | func (h *stageHook) LTXFiles(ctx context.Context, level int, seek ltx.TXID, m bool) (ltx.FileIterator, error) { |
| 149 | if h.stage == "planning" { |
| 150 | h.once.Do(h.change) |
| 151 | } |
| 152 | return h.ReplicaReader.LTXFiles(ctx, level, seek, m) |
| 153 | } |
| 154 | |
| 155 | func (h *stageHook) OpenLTXFile(ctx context.Context, level int, lo, hi ltx.TXID, off, size int64) (io.ReadCloser, error) { |
| 156 | rc, err := h.ReplicaReader.OpenLTXFile(ctx, level, lo, hi, off, size) |
| 157 | if err != nil || off != 0 || size != 0 { |
| 158 | return rc, err |
| 159 | } |
| 160 | switch { |
| 161 | case h.stage == "download": |
| 162 | h.once.Do(h.change) |
| 163 | case h.stage == "after the last byte" && dbdoor.Span(uint64(lo), uint64(hi)) == h.lastSpan: |
| 164 | return &eofHook{ReadCloser: rc, at: func() { h.once.Do(h.change) }}, nil |
| 165 | } |
| 166 | return rc, nil |
| 167 | } |
| 168 | |
| 169 | type eofHook struct { |
| 170 | io.ReadCloser |
| 171 | at func() |
| 172 | } |
| 173 | |
| 174 | func (e *eofHook) Read(p []byte) (int, error) { |
| 175 | n, err := e.ReadCloser.Read(p) |
| 176 | if err == io.EOF { |
| 177 | e.at() |
| 178 | } |
| 179 | return n, err |
| 180 | } |
| 181 | |
| 182 | // T9: a record change at every stage ends with its code, and no chunk is |
| 183 | // ever written (codex 1). |
| 184 | func TestRecordChangeAtEveryStageWritesNoChunk(t *testing.T) { |
| 185 | t.Parallel() |
| 186 | no := false |
| 187 | changes := map[string]struct { |
| 188 | change func(fx *fixture) |
| 189 | want string |
| 190 | }{ |
| 191 | "moved": {func(fx *fixture) { r := fx.record(); r.Region = "ie"; fx.putRecord(r) }, CodeRecordChanged}, |
| 192 | "moving": {func(fx *fixture) { |
| 193 | r := fx.record() |
| 194 | r.Move = &provision.MoveState{ID: "mv_x", To: "ie", State: "requested"} |
| 195 | fx.putRecord(r) |
| 196 | }, CodeMoving}, |
| 197 | "recreated in the account": {func(fx *fixture) { r := fx.record(); r.CreatedAt = r.CreatedAt.Add(time.Hour); fx.putRecord(r) }, CodeRecordChanged}, |
| 198 | "recreated in another account": {func(fx *fixture) { |
| 199 | created := fx.record().CreatedAt |
| 200 | fx.store.Delete(context.Background(), provision.RecordKey(fxAccount, fxApp, fxHost)) |
| 201 | fx.putRecordUnder("bac", provision.Record{Region: "uk", CreatedAt: created}) |
| 202 | }, CodeRecordChanged}, |
| 203 | "class changed": {func(fx *fixture) { r := fx.record(); r.Hibernate = &no; fx.putRecord(r) }, CodeRecordChanged}, |
| 204 | } |
| 205 | for _, stage := range []string{"before the pick", "planning", "download", "after the last byte"} { |
| 206 | for name, c := range changes { |
| 207 | t.Run(stage+"/"+name, func(t *testing.T) { |
| 208 | t.Parallel() |
| 209 | fx := newFixture(t, 2) |
| 210 | plan, err := dbdoor.PlanFrom(context.Background(), lsfile.NewReplicaClient(fx.replica), time.Time{}) |
| 211 | if err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | last := plan.Planned[len(plan.Planned)-1] |
| 215 | hook := &stageHook{ReplicaReader: lsfile.NewReplicaClient(fx.replica), stage: stage, |
| 216 | lastSpan: dbdoor.Span(last.MinTXID, last.MaxTXID), change: func() { c.change(fx) }} |
| 217 | a, _ := fx.agent("uk-1", func(cfg *AgentConfig, _ *inProcWorkers) { |
| 218 | cfg.OpenReplica = func(context.Context, string) (dbdoor.ReplicaReader, error) { return hook, nil } |
| 219 | }) |
| 220 | req := fx.request("SELECT count(*) FROM t", "") |
| 221 | if stage == "before the pick" { |
| 222 | c.change(fx) |
| 223 | } |
| 224 | _, _, r := serveOne(t, a, fx, req) |
| 225 | if r.State != StateRefused || r.Code != c.want { |
| 226 | t.Fatalf("result %+v, want refused %s", r, c.want) |
| 227 | } |
| 228 | if keys := fx.chunkKeys(req.ID); len(keys) != 0 { |
| 229 | t.Fatalf("chunks written: %v", keys) |
| 230 | } |
| 231 | }) |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | func TestReadTooLargeAndNoRoom(t *testing.T) { |
| 237 | t.Parallel() |
| 238 | fx := newFixture(t, 2) |
| 239 | a, _ := fx.agent("uk-1", func(c *AgentConfig, _ *inProcWorkers) { c.MaxRestored = 1024 }) |
| 240 | _, s, r := serveOne(t, a, fx, fx.request("SELECT 1", "")) |
| 241 | if r.Code != CodeTooLarge || s.Cap != 1024 || s.Restored <= 1024 { |
| 242 | t.Fatalf("too large: %+v %+v", r, s) |
| 243 | } |
| 244 | fx2 := newFixture(t, 2) |
| 245 | b, _ := fx2.agent("uk-1", func(c *AgentConfig, _ *inProcWorkers) { |
| 246 | calls := 0 |
| 247 | c.Free = func() (uint64, uint64, error) { |
| 248 | calls++ |
| 249 | if calls == 1 { // the floor fits; the plan's growth does not |
| 250 | return 11 << 30, 20 << 30, nil |
| 251 | } |
| 252 | return 5<<30 + 1, 20 << 30, nil |
| 253 | } |
| 254 | }) |
| 255 | _, _, r2 := serveOne(t, b, fx2, fx2.request("SELECT 1", "")) |
| 256 | if r2.Code != CodeNoRoom { |
| 257 | t.Fatalf("no room: %+v", r2) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | // Review Focus 4: the cap is exact, a row bigger than a chunk is split, and |
| 262 | // the summary covers exactly the kept bytes. |
| 263 | func TestChunkWriterCapIsExact(t *testing.T) { |
| 264 | t.Parallel() |
| 265 | for _, extra := range []int{0, 1} { |
| 266 | fx := newFixture(t, 0) |
| 267 | max := int64(3*ChunkBytes + 7) |
| 268 | cw := newChunkWriter(context.Background(), fx.store, "dbr_aaaaaaaaaaaaaaaaaaaaaaaaaa", fx.reply.PublicKey(), StreamRows, time.Hour, max, ChunkBytes) |
| 269 | big := bytes.Repeat([]byte("x"), 2*ChunkBytes+5) |
| 270 | rest := bytes.Repeat([]byte("y"), int(max)-len(big)+extra) |
| 271 | cw.Write(big) |
| 272 | _, err := cw.Write(rest) |
| 273 | if (extra == 1) != (err == errCapped) || cw.capped() != (extra == 1) { |
| 274 | t.Fatalf("extra %d: Write = %v, capped %v", extra, err, cw.capped()) |
| 275 | } |
| 276 | last, n, sum, err := cw.close() |
| 277 | if err != nil || n != max { |
| 278 | t.Fatalf("extra %d: kept %d of %d: %v", extra, n, max, err) |
| 279 | } |
| 280 | want := sha256.Sum256(append(append([]byte{}, big...), rest[:int(max)-len(big)]...)) |
| 281 | if sum != hex.EncodeToString(want[:]) { |
| 282 | t.Fatal("the summary hash is not the kept bytes'") |
| 283 | } |
| 284 | for seq := int64(1); seq <= last; seq++ { |
| 285 | sealed, _, err := fx.store.Get(context.Background(), ChunkKey("dbr_aaaaaaaaaaaaaaaaaaaaaaaaaa", seq)) |
| 286 | if err != nil || len(sealed)-SealOverhead > ChunkBytes { |
| 287 | t.Fatalf("chunk %d: %d bytes, %v", seq, len(sealed), err) |
| 288 | } |
| 289 | } |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | // The output cap end to end: done, output-cap, and exactly the cap kept. |
| 294 | func TestReadOutputCap(t *testing.T) { |
| 295 | t.Parallel() |
| 296 | fx := newFixture(t, 2) |
| 297 | a, _ := fx.agent("uk-1", func(c *AgentConfig, _ *inProcWorkers) { c.MaxOutput = 100 }) |
| 298 | rows, s, r := serveOne(t, a, fx, fx.request("SELECT body FROM t", "")) |
| 299 | if r.State != StateDone || r.Code != CodeOutputCap || len(rows) != 100 || s.Bytes != 100 { |
| 300 | t.Fatalf("result %+v, %d bytes", r, len(rows)) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | func TestCancelStopsTheOwner(t *testing.T) { |
| 305 | t.Parallel() |
| 306 | fx := newFixture(t, 2) |
| 307 | hang := make(chan struct{}) |
| 308 | t.Cleanup(func() { close(hang) }) |
| 309 | a, _ := fx.agent("uk-1", func(c *AgentConfig, w *inProcWorkers) { |
| 310 | c.CancelEvery = 20 * time.Millisecond |
| 311 | w.opt.Restore = func(ctx context.Context, _ litestream.ReplicaClient, _ string, _ uint64) error { |
| 312 | select { |
| 313 | case <-ctx.Done(): |
| 314 | case <-hang: |
| 315 | } |
| 316 | return ctx.Err() |
| 317 | } |
| 318 | }) |
| 319 | req := fx.request("SELECT 1", "") |
| 320 | e := fx.submit(req) |
| 321 | a.consider(context.Background(), e) |
| 322 | if _, err := fx.store.Put(context.Background(), CancelKey(req.ID), nil, ""); err != nil { |
| 323 | t.Fatal(err) |
| 324 | } |
| 325 | done := make(chan struct{}) |
| 326 | go func() { a.Wait(); close(done) }() |
| 327 | select { |
| 328 | case <-done: |
| 329 | case <-time.After(5 * time.Second): |
| 330 | t.Fatal("the owner did not stop within 5 s of cancel") |
| 331 | } |
| 332 | if _, _, r := fx.answer(req.ID); r.State != StateCancelled { |
| 333 | t.Fatalf("result %+v", r) |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | // refusedChunk never lets one chunk's create land: every attempt answers an |
| 338 | // error and the object stays absent. |
| 339 | type refusedChunk struct { |
| 340 | release.Store |
| 341 | key string |
| 342 | } |
| 343 | |
| 344 | func (s refusedChunk) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 345 | if key == s.key { |
| 346 | return "", errors.New("connection reset") |
| 347 | } |
| 348 | return s.Store.Put(ctx, key, body, ifMatch) |
| 349 | } |
| 350 | |
| 351 | // Plan review finding 9: a chunk that is never confirmed is never named by |
| 352 | // the result. The read ends interrupted, and every chunk through its |
| 353 | // last_seq exists. Mutation: flushLocked increments seq before the create is |
| 354 | // confirmed; the result then names chunk 2 and this test is red. |
| 355 | func TestResultNamesOnlyConfirmedChunks(t *testing.T) { |
| 356 | t.Parallel() |
| 357 | fx := newFixture(t, 1) |
| 358 | req := fx.request("SELECT hex(randomblob(300000))", "") // about 600 KB: three chunks |
| 359 | a, _ := fx.agent("uk-1", func(c *AgentConfig, _ *inProcWorkers) { |
| 360 | c.Store = refusedChunk{Store: fx.store, key: ChunkKey(req.ID, 2)} |
| 361 | c.OwnerDeadline = 2 * time.Second |
| 362 | }) |
| 363 | a.consider(context.Background(), fx.submit(req)) |
| 364 | a.Wait() |
| 365 | raw, _, err := fx.store.Get(context.Background(), ResultKey(req.ID)) |
| 366 | if err != nil { |
| 367 | t.Fatal(err) |
| 368 | } |
| 369 | var r Result |
| 370 | json.Unmarshal(raw, &r) |
| 371 | if r.State != StateFailed || r.Code != CodeInterrupted || r.LastSeq != 1 { |
| 372 | t.Fatalf("result %+v", r) |
| 373 | } |
| 374 | for seq := int64(1); seq <= r.LastSeq; seq++ { |
| 375 | if _, _, err := fx.store.Get(context.Background(), ChunkKey(req.ID, seq)); err != nil { |
| 376 | t.Fatalf("the result names chunk %d, which does not exist", seq) |
| 377 | } |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | // strictStore fails the test on any protocol delete or replacing write |
| 382 | // under db-reads/ (T16, the box's half). |
| 383 | type strictStore struct { |
| 384 | release.Store |
| 385 | t *testing.T |
| 386 | } |
| 387 | |
| 388 | func (s strictStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 389 | if strings.HasPrefix(key, Prefix) && ifMatch != "" { |
| 390 | s.t.Errorf("a replacing write of %s", key) |
| 391 | } |
| 392 | return s.Store.Put(ctx, key, body, ifMatch) |
| 393 | } |
| 394 | |
| 395 | func (s strictStore) Delete(ctx context.Context, key string) error { |
| 396 | if strings.HasPrefix(key, Prefix) { |
| 397 | s.t.Errorf("a delete of %s", key) |
| 398 | } |
| 399 | return s.Store.Delete(ctx, key) |
| 400 | } |
| 401 | |
| 402 | func TestReadNeverDeletesOrReplaces(t *testing.T) { |
| 403 | t.Parallel() |
| 404 | fx := newFixture(t, 2) |
| 405 | a, _ := fx.agent("uk-1", func(c *AgentConfig, _ *inProcWorkers) { c.Store = strictStore{Store: fx.store, t: t} }) |
| 406 | if _, _, r := serveOne(t, a, fx, fx.request("SELECT count(*) FROM t", "")); r.State != StateDone { |
| 407 | t.Fatalf("%+v", r) |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | func TestOwnerDeadlineEndsAHungRead(t *testing.T) { |
| 412 | t.Parallel() |
| 413 | fx := newFixture(t, 1) |
| 414 | var started atomic.Bool |
| 415 | a, _ := fx.agent("uk-1", func(c *AgentConfig, w *inProcWorkers) { |
| 416 | c.OwnerDeadline = 200 * time.Millisecond |
| 417 | w.opt.Restore = func(ctx context.Context, _ litestream.ReplicaClient, _ string, _ uint64) error { |
| 418 | started.Store(true) |
| 419 | <-ctx.Done() |
| 420 | return ctx.Err() |
| 421 | } |
| 422 | }) |
| 423 | _, _, r := serveOne(t, a, fx, fx.request("SELECT 1", "")) |
| 424 | if !started.Load() || r.State != StateFailed || r.Code != CodeInterrupted { |
| 425 | t.Fatalf("result %+v", r) |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | // Astra round 1, finding 2 (T9 extended): every replica-derived ending (a |
| 430 | // failed restore's error text, the restored size, the oldest point kept, |
| 431 | // "nothing replicated", "no room", "changed twice") waits for check B. When |
| 432 | // the record changed after check A, the read ends refused record-changed |
| 433 | // and its sealed summary carries none of that replica's details. |
| 434 | func TestReplicaDetailWaitsForCheckB(t *testing.T) { |
| 435 | t.Parallel() |
| 436 | recreate := func(fx *fixture) { r := fx.record(); r.CreatedAt = r.CreatedAt.Add(time.Hour); fx.putRecord(r) } |
| 437 | for name, c := range map[string]struct { |
| 438 | batches int |
| 439 | at string |
| 440 | vanish int |
| 441 | mut func(*AgentConfig, *inProcWorkers) |
| 442 | }{ |
| 443 | "a failing restore": {batches: 2, mut: func(_ *AgentConfig, w *inProcWorkers) { |
| 444 | w.opt.Restore = func(context.Context, litestream.ReplicaClient, string, uint64) error { |
| 445 | return errors.New("malformed database schema (secret_customer_ledger)") |
| 446 | } |
| 447 | }}, |
| 448 | "too large": {batches: 2, mut: func(c *AgentConfig, _ *inProcWorkers) { c.MaxRestored = 1024 }}, |
| 449 | "before the history": {batches: 2, at: "2000-01-01T00:00:00Z"}, |
| 450 | "nothing replicated": {batches: 0}, |
| 451 | "no room": {batches: 2, mut: func(c *AgentConfig, _ *inProcWorkers) { |
| 452 | calls := 0 |
| 453 | c.Free = func() (uint64, uint64, error) { |
| 454 | calls++ |
| 455 | if calls == 1 { |
| 456 | return 11 << 30, 20 << 30, nil |
| 457 | } |
| 458 | return 5<<30 + 1, 20 << 30, nil |
| 459 | } |
| 460 | }}, |
| 461 | "changed twice": {batches: 2, vanish: 2}, |
| 462 | } { |
| 463 | t.Run(name, func(t *testing.T) { |
| 464 | t.Parallel() |
| 465 | fx := newFixture(t, c.batches) |
| 466 | var rd dbdoor.ReplicaReader = &stageHook{ReplicaReader: lsfile.NewReplicaClient(fx.replica), stage: "planning", change: func() { recreate(fx) }} |
| 467 | if c.vanish > 0 { |
| 468 | // The record changes during the second attempt's download, |
| 469 | // after that attempt's check A: only a check before |
| 470 | // "changed twice" can see it. |
| 471 | rd = &vanishThenChange{ReplicaReader: &vanishing{ReplicaReader: lsfile.NewReplicaClient(fx.replica), times: c.vanish}, at: 2, change: func() { recreate(fx) }} |
| 472 | } |
| 473 | a, _ := fx.agent("uk-1", func(cfg *AgentConfig, w *inProcWorkers) { |
| 474 | cfg.OpenReplica = func(context.Context, string) (dbdoor.ReplicaReader, error) { return rd, nil } |
| 475 | if c.mut != nil { |
| 476 | c.mut(cfg, w) |
| 477 | } |
| 478 | }) |
| 479 | _, s, r := serveOne(t, a, fx, fx.request("SELECT 1", c.at)) |
| 480 | if r.State != StateRefused || r.Code != CodeRecordChanged { |
| 481 | t.Fatalf("result %+v, want refused %s", r, CodeRecordChanged) |
| 482 | } |
| 483 | if !reflect.DeepEqual(s, Summary{}) { |
| 484 | t.Fatalf("the refused read's summary carries the replica's details: %+v", s) |
| 485 | } |
| 486 | }) |
| 487 | } |
| 488 | } |
| 489 | |
| 490 | // vanishThenChange runs change on the at'th whole-file open, before the |
| 491 | // open answers; over vanishing, that open is one that vanishes. |
| 492 | type vanishThenChange struct { |
| 493 | dbdoor.ReplicaReader |
| 494 | mu sync.Mutex |
| 495 | n, at int |
| 496 | change func() |
| 497 | } |
| 498 | |
| 499 | func (v *vanishThenChange) OpenLTXFile(ctx context.Context, level int, lo, hi ltx.TXID, off, size int64) (io.ReadCloser, error) { |
| 500 | if off == 0 && size == 0 { |
| 501 | v.mu.Lock() |
| 502 | v.n++ |
| 503 | fire := v.n == v.at |
| 504 | v.mu.Unlock() |
| 505 | if fire { |
| 506 | v.change() |
| 507 | } |
| 508 | } |
| 509 | return v.ReplicaReader.OpenLTXFile(ctx, level, lo, hi, off, size) |
| 510 | } |
| 511 | |
| 512 | // slowPicked lands the picked create and then answers late: an election |
| 513 | // whose response takes delay (Astra round 1, finding 3). |
| 514 | type slowPicked struct { |
| 515 | release.Store |
| 516 | delay time.Duration |
| 517 | } |
| 518 | |
| 519 | func (s slowPicked) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 520 | etag, err := s.Store.Put(ctx, key, body, ifMatch) |
| 521 | if strings.HasSuffix(key, "/picked") { |
| 522 | time.Sleep(s.delay) |
| 523 | } |
| 524 | return etag, err |
| 525 | } |
| 526 | |
| 527 | // The owner works to the deadline it published in picked, not to a fresh |
| 528 | // one from whenever the election's answer arrived (Astra round 1, finding |
| 529 | // 3): a hung read ends by picked.Deadline, and a read whose election |
| 530 | // answered after its deadline starts no work at all. |
| 531 | func TestServeKeepsThePublishedDeadline(t *testing.T) { |
| 532 | t.Parallel() |
| 533 | for name, c := range map[string]struct { |
| 534 | deadline, delay time.Duration |
| 535 | dials int64 |
| 536 | }{ |
| 537 | "a late answer inside the deadline": {deadline: 600 * time.Millisecond, delay: 300 * time.Millisecond, dials: 1}, |
| 538 | "an answer after the deadline": {deadline: 200 * time.Millisecond, delay: 300 * time.Millisecond, dials: 0}, |
| 539 | } { |
| 540 | t.Run(name, func(t *testing.T) { |
| 541 | t.Parallel() |
| 542 | fx := newFixture(t, 1) |
| 543 | a, w := fx.agent("uk-1", func(cfg *AgentConfig, w *inProcWorkers) { |
| 544 | cfg.Store = slowPicked{Store: fx.store, delay: c.delay} |
| 545 | cfg.OwnerDeadline = c.deadline |
| 546 | w.opt.Restore = func(ctx context.Context, _ litestream.ReplicaClient, _ string, _ uint64) error { |
| 547 | <-ctx.Done() |
| 548 | return ctx.Err() |
| 549 | } |
| 550 | }) |
| 551 | req := fx.request("SELECT 1", "") |
| 552 | _, _, r := serveOne(t, a, fx, req) |
| 553 | raw, _, err := fx.store.Get(context.Background(), PickedKey(req.ID)) |
| 554 | if err != nil { |
| 555 | t.Fatal(err) |
| 556 | } |
| 557 | var p Picked |
| 558 | if err := json.Unmarshal(raw, &p); err != nil { |
| 559 | t.Fatal(err) |
| 560 | } |
| 561 | if r.State != StateFailed || r.Code != CodeInterrupted || w.dials.Load() != c.dials { |
| 562 | t.Fatalf("result %+v, %d worker connections", r, w.dials.Load()) |
| 563 | } |
| 564 | if over := r.Ended.Sub(p.Deadline); over > 150*time.Millisecond { |
| 565 | t.Fatalf("the read ran %s past the deadline it published", over) |
| 566 | } |
| 567 | }) |
| 568 | } |
| 569 | } |
| 570 | |