| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "net/http/httptest" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "sort" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/activator" |
| 16 | "github.com/carlosframework/platform/internal/hostclaim" |
| 17 | "github.com/carlosframework/platform/internal/lease" |
| 18 | "github.com/carlosframework/platform/internal/lease/leasetest" |
| 19 | "github.com/carlosframework/platform/internal/moves" |
| 20 | "github.com/carlosframework/platform/internal/moves/archive" |
| 21 | "github.com/carlosframework/platform/internal/provision" |
| 22 | "github.com/carlosframework/platform/internal/registry" |
| 23 | "github.com/carlosframework/platform/internal/release" |
| 24 | ) |
| 25 | |
| 26 | // The cut harness: the Phase 1 harness driven to ready (manual cut) and |
| 27 | // cut as the door does, with the cut's hooks faked. fakeEvidence answers a |
| 28 | // configurable report; the final checkpoint writes the stopped database's |
| 29 | // last transaction (TXID 3) into the source's replica; cutFault is the |
| 30 | // crash seam, crashing once at the point named. |
| 31 | |
| 32 | var errCrash = errors.New("injected crash") |
| 33 | |
| 34 | type fakeEvidence struct { |
| 35 | calls int |
| 36 | stopped bool |
| 37 | err error |
| 38 | onCall func() |
| 39 | } |
| 40 | |
| 41 | func (e *fakeEvidence) report() moves.StoppedReport { |
| 42 | r := moves.StoppedReport{V: 1, Host: p1Host, Steps: map[string]moves.Check{}, CheckedAt: time.Date(2026, 10, 7, 9, 1, 0, 0, time.UTC)} |
| 43 | for _, id := range []string{moves.StepTree, moves.StepStray, moves.StepFDs, moves.StepLease} { |
| 44 | r.Steps[id] = moves.Check{OK: true} |
| 45 | } |
| 46 | if !e.stopped { |
| 47 | r.Steps[moves.StepFDs] = moves.Check{Reason: "1 process(es) hold the database open"} |
| 48 | } |
| 49 | return r |
| 50 | } |
| 51 | |
| 52 | type cutHarness struct { |
| 53 | *phase1Harness |
| 54 | ev *fakeEvidence |
| 55 | holds []string |
| 56 | fences int |
| 57 | drains int |
| 58 | checkpoints int |
| 59 | locations []string |
| 60 | bundle map[string]string |
| 61 | localPos uint64 |
| 62 | timers []registry.Timer |
| 63 | crashAt string |
| 64 | crashed bool |
| 65 | hits []string |
| 66 | // scoped keeps the Phase 1 harness's scoped holder |
| 67 | // (instances/<host>/<incarnation>), which the cut refuses (ruling |
| 68 | // T5-lease). By default ready moves the claim to the root location the |
| 69 | // activator's lease lives at, instances/<host>, generation cur. |
| 70 | scoped bool |
| 71 | } |
| 72 | |
| 73 | // cutRoot is the activator's lease prefix for the harness's host, the one |
| 74 | // location Phase 1 cuts (ruling T5-lease); cutLineage is the claim |
| 75 | // generation's replica under it, the prefix the lane mirrors. |
| 76 | var ( |
| 77 | cutRoot = "instances/" + p1Host |
| 78 | cutLineage = cutRoot + "/g-cur/" |
| 79 | ) |
| 80 | |
| 81 | // PutFileIfAbsent makes the fake a lease.FilePutIfAbsent. |
| 82 | func (v *fakeVersioned) PutFileIfAbsent(ctx context.Context, bucket, key, path string) (string, error) { |
| 83 | body, err := os.ReadFile(path) |
| 84 | if err != nil { |
| 85 | return "", err |
| 86 | } |
| 87 | return v.PutIfAbsent(ctx, bucket, key, body) |
| 88 | } |
| 89 | |
| 90 | var _ lease.FilePutIfAbsent = (*fakeVersioned)(nil) |
| 91 | |
| 92 | const lastTXKey = "0000/0000000000000003-0000000000000003.ltx" |
| 93 | |
| 94 | func newCutHarness(t *testing.T, opts ...p1Option) *cutHarness { |
| 95 | t.Helper() |
| 96 | h := &cutHarness{phase1Harness: newPhase1Harness(t, opts...), ev: &fakeEvidence{stopped: true}, localPos: 3, |
| 97 | bundle: map[string]string{"default": "e1", "environment": ""}} |
| 98 | h.timers = []registry.Timer{{Host: p1Host, Name: "nightly", Kind: "cron", Spec: "0 3 * * *", NextAt: 1000}} |
| 99 | last := ltxForTest(t, 4096, 4, 3, 3) |
| 100 | if err := os.WriteFile(h.route.DBPath, []byte("the stopped database"), 0o600); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | h.cfg.HoldMoving = func(host, op string) error { h.holds = append(h.holds, host+"="+op); return nil } |
| 104 | h.cfg.Fence = func(context.Context, string, uint64) error { h.fences++; return nil } |
| 105 | h.cfg.TimersDrained = func(context.Context, string) error { h.drains++; return nil } |
| 106 | h.cfg.Evidence = func(_ context.Context, host, location string, _ []string) (moves.StoppedReport, error) { |
| 107 | h.ev.calls++ |
| 108 | h.locations = append(h.locations, location) |
| 109 | if h.ev.onCall != nil { |
| 110 | h.ev.onCall() |
| 111 | } |
| 112 | return h.ev.report(), h.ev.err |
| 113 | } |
| 114 | h.cfg.FinalCheckpoint = func(ctx context.Context, host, _ string, holder hostclaim.Holder, check func(context.Context) error) error { |
| 115 | if err := check(ctx); err != nil { |
| 116 | return err |
| 117 | } |
| 118 | h.checkpoints++ |
| 119 | prefix, _ := moves.ReplicaSource(holder) |
| 120 | if host != p1Host || prefix != cutLineage { |
| 121 | t.Errorf("checkpoint of %s at %s, want %s at the claim's lineage %s", host, prefix, p1Host, cutLineage) |
| 122 | } |
| 123 | h.f.store(h.srcBucket, prefix+lastTXKey, fakeMoveObject{lease.MirrorObject{ETag: "cur-c", Size: int64(len(last))}, string(last)}) |
| 124 | return nil |
| 125 | } |
| 126 | h.cfg.LocalPosition = func(string) (uint64, error) { return h.localPos, nil } |
| 127 | h.cfg.Timers = func(string) ([]registry.Timer, error) { return h.timers, nil } |
| 128 | h.cfg.InputVersions = func(context.Context, release.Store, provision.Record) (map[string]string, string, error) { |
| 129 | out := map[string]string{} |
| 130 | for k, v := range h.bundle { |
| 131 | out[k] = v |
| 132 | } |
| 133 | return out, "", nil |
| 134 | } |
| 135 | h.cfg.cutFault = func(point string) error { |
| 136 | h.hits = append(h.hits, point) |
| 137 | if point == h.crashAt && !h.crashed { |
| 138 | h.crashed = true |
| 139 | return errCrash |
| 140 | } |
| 141 | return nil |
| 142 | } |
| 143 | // The cut guard: the source can recover a failed cut, as every box |
| 144 | // will once the wake-source gate opens (armStandIns). |
| 145 | armStandIns(&h.cfg, h.s, roleSource) |
| 146 | return h |
| 147 | } |
| 148 | |
| 149 | // targetCfg composes with the harness's current bundle versions. |
| 150 | func (h *cutHarness) target() moveCfg { |
| 151 | c := h.targetCfg() |
| 152 | c.ComposeHeldEnv = func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, map[string]bool, []string, provision.MoveInputs, error) { |
| 153 | env, instance, err := h.heldEnv(ctx, s, rec, carried) |
| 154 | if err != nil { |
| 155 | return nil, nil, nil, provision.MoveInputs{}, err |
| 156 | } |
| 157 | b := map[string]string{} |
| 158 | for k, v := range h.bundle { |
| 159 | b[k] = v |
| 160 | } |
| 161 | return env, instance, nil, provision.MoveInputs{BundleETags: b}, nil |
| 162 | } |
| 163 | return c |
| 164 | } |
| 165 | |
| 166 | // ready drives the move to a persisted ready with its inputs pinned. The |
| 167 | // claim holder is at the root location unless the harness is scoped. |
| 168 | func (h *cutHarness) ready(t *testing.T) { |
| 169 | t.Helper() |
| 170 | h.start(t, "manual") |
| 171 | if !h.scoped { |
| 172 | rootHolder(t, h.phase1Harness) |
| 173 | } |
| 174 | h.pass(t) |
| 175 | h.targetPass(t, h.target()) |
| 176 | h.pass(t) |
| 177 | if got := h.read(t, p1Host).Move; got.State != provision.MoveReady || got.Inputs == nil { |
| 178 | t.Fatalf("the fixture did not reach ready: %+v", got) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | // rootHolder moves a started Phase 1 harness to the root location: the |
| 183 | // Phase 1 harness keeps the replica under the scoped location, the cut |
| 184 | // accepts only the root one (ruling T5-lease), so the same objects move |
| 185 | // there and the claim follows. |
| 186 | func rootHolder(t *testing.T, h *phase1Harness) { |
| 187 | t.Helper() |
| 188 | scoped := "instances/" + p1Host + "/" + h.inc + "/" |
| 189 | for k, o := range h.f.buckets[h.srcBucket] { |
| 190 | if rel, ok := strings.CutPrefix(k, scoped); ok { |
| 191 | delete(h.f.buckets[h.srcBucket], k) |
| 192 | h.f.store(h.srcBucket, cutRoot+"/"+rel, o) |
| 193 | } |
| 194 | } |
| 195 | rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) { c.Holder.Location = cutRoot }) |
| 196 | } |
| 197 | |
| 198 | // cut is the console's cut door: ready to cutting/requested. |
| 199 | func (h *cutHarness) cut(t *testing.T) { |
| 200 | t.Helper() |
| 201 | g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc} |
| 202 | if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, provision.MoveGuardedCut(g)); err != nil { |
| 203 | t.Fatal(err) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | func (h *cutHarness) move(t *testing.T) *provision.MoveState { |
| 208 | t.Helper() |
| 209 | return h.read(t, p1Host).Move |
| 210 | } |
| 211 | |
| 212 | // crashPass runs one pass that must crash at h.crashAt. |
| 213 | func (h *cutHarness) crashPass(t *testing.T) { |
| 214 | t.Helper() |
| 215 | h.pass(t) |
| 216 | if !h.crashed { |
| 217 | t.Fatalf("the pass never reached %s (points reached: %v)", h.crashAt, h.hits) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | // freshWorker drops the source worker: the next pass starts with no |
| 222 | // in-memory state, as a restarted agent does. |
| 223 | func (h *cutHarness) freshWorker() { h.worker1 = nil } |
| 224 | |
| 225 | func (h *cutHarness) stoppedKey() string { |
| 226 | return moves.StoppedKey(moves.ReportPrefix("bab", "app", p1Host, p1ID), "source") |
| 227 | } |
| 228 | |
| 229 | func (h *cutHarness) seal(t *testing.T) moves.Seal { |
| 230 | t.Helper() |
| 231 | s, err := journalledSeal(h.claim(t)) |
| 232 | if err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | return s |
| 236 | } |
| 237 | |
| 238 | func (h *cutHarness) journalArchive(t *testing.T) cutArchiveStep { |
| 239 | t.Helper() |
| 240 | j, err := moves.ReadJournal(h.cfg.DataDir, p1Host, p1ID) |
| 241 | if err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | var step cutArchiveStep |
| 245 | if err := json.Unmarshal([]byte(j.Steps[cutStepArchive]), &step); err != nil { |
| 246 | t.Fatalf("no archive identity journalled: %v", err) |
| 247 | } |
| 248 | step.dir = archiveDir(h.cfg.DataDir, p1Host, p1ID) |
| 249 | return step |
| 250 | } |
| 251 | |
| 252 | // Admission needs this round's own passing source report, a fresh passing |
| 253 | // target report, and the pin to match (G1, G13). With all of them the |
| 254 | // record is admitted with cut_at, the budget and the deadline; a stale |
| 255 | // target report or a failing source sends it back to ready with the pin |
| 256 | // cleared, and a passing round pins again. |
| 257 | func TestCutAdmitsOnlyFreshPassingReports(t *testing.T) { |
| 258 | t.Parallel() |
| 259 | h := newCutHarness(t) |
| 260 | h.ready(t) |
| 261 | h.ev.stopped = false // stop at stopping, to look at the admission |
| 262 | h.cut(t) |
| 263 | h.pass(t) |
| 264 | m := h.move(t) |
| 265 | if m.State != provision.MoveCutting || m.CutPhase != provision.CutStopping || m.CutAt == nil || m.CutBudgetS != int(moves.CutWindowDefault) || m.CutDeadline == nil { |
| 266 | t.Fatalf("after the admission: %+v", m) |
| 267 | } |
| 268 | if want := moves.CutDeadline(*m.CutAt, moves.CutWindowDefault); !m.CutDeadline.Equal(want) { |
| 269 | t.Fatalf("deadline %v, want %v", m.CutDeadline, want) |
| 270 | } |
| 271 | |
| 272 | for name, spoil := range map[string]func(h *cutHarness){ |
| 273 | "the target's report is stale": func(h *cutHarness) { h.clock.Advance(moves.ReportFreshness + time.Minute) }, |
| 274 | "the source does not answer": func(h *cutHarness) { h.probe = moves.Check{Reason: s1NoAnswer(p1Host)} }, |
| 275 | "a second box in the target region": func(h *cutHarness) { |
| 276 | if _, err := h.s.Put(context.Background(), "control/edges/uk-box2.json", |
| 277 | []byte(`{"v":1,"box":"uk-box2","eip":"1.2.3.9","placement":"uk","data_bucket":"data-uk","data_bucket_region":"eu-west-2","hosts":{}}`), ""); err != nil { |
| 278 | panic(err) |
| 279 | } |
| 280 | }, |
| 281 | } { |
| 282 | t.Run(name, func(t *testing.T) { |
| 283 | h := newCutHarness(t) |
| 284 | h.ready(t) |
| 285 | h.cut(t) |
| 286 | spoil(h) |
| 287 | h.pass(t) |
| 288 | m := h.move(t) |
| 289 | if m.State != provision.MoveReady || m.CutPhase != "" || m.Inputs != nil || m.CutAt != nil { |
| 290 | t.Fatalf("not back to ready: %+v", m) |
| 291 | } |
| 292 | if h.ev.calls != 0 || len(h.holds) != 0 { |
| 293 | t.Fatalf("something stopped: %d evidence calls, holds %v", h.ev.calls, h.holds) |
| 294 | } |
| 295 | }) |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | // The inputs pinned at readiness are compared at requested and again at |
| 300 | // admitted (§7.4). A drift goes back to ready; the target reports the new |
| 301 | // versions, a passing round pins them (MoveGuardedRepin), and the next cut |
| 302 | // admits: no loop. |
| 303 | func TestCutGoesBackToReadyWhenInputsMove(t *testing.T) { |
| 304 | t.Parallel() |
| 305 | ctx := context.Background() |
| 306 | h := newCutHarness(t) |
| 307 | h.ready(t) |
| 308 | h.ev.stopped = false |
| 309 | h.bundle["environment"] = "e2" |
| 310 | h.cut(t) |
| 311 | h.pass(t) |
| 312 | if m := h.move(t); m.State != provision.MoveReady || m.Inputs != nil { |
| 313 | t.Fatalf("a drift at requested did not go back to ready: %+v", m) |
| 314 | } |
| 315 | // Ready without a pin is never cut: the door refuses. |
| 316 | g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc} |
| 317 | if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveGuardedCut(g)); !errors.Is(err, provision.ErrMoveConflict) { |
| 318 | t.Fatalf("cut without a pin: %v", err) |
| 319 | } |
| 320 | h.targetPass(t, h.target()) |
| 321 | h.pass(t) |
| 322 | m := h.move(t) |
| 323 | if m.State != provision.MoveReady || m.Inputs == nil || m.Inputs.BundleETags["environment"] != "e2" { |
| 324 | t.Fatalf("not pinned again: %+v", m) |
| 325 | } |
| 326 | h.cut(t) |
| 327 | h.pass(t) |
| 328 | if m := h.move(t); m.State != provision.MoveCutting || m.CutPhase != provision.CutStopping { |
| 329 | t.Fatalf("the repinned cut did not admit: %+v", m) |
| 330 | } |
| 331 | |
| 332 | // At admitted: a record admitted before the drift goes back to ready |
| 333 | // on the replay of admitted. |
| 334 | h2 := newCutHarness(t) |
| 335 | h2.ready(t) |
| 336 | h2.cut(t) |
| 337 | now := h2.clock.Now() |
| 338 | if _, err := provision.SetRecordMove(ctx, h2.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120), 3)); err != nil { |
| 339 | t.Fatal(err) |
| 340 | } |
| 341 | h2.bundle["default"] = "e9" |
| 342 | h2.pass(t) |
| 343 | if m := h2.move(t); m.State != provision.MoveReady || m.Inputs != nil || h2.ev.calls != 0 { |
| 344 | t.Fatalf("a drift at admitted: %+v, %d evidence calls", m, h2.ev.calls) |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | // Review Focus 1: a fresh worker on the same store, with the record at |
| 349 | // stopping and nothing in memory, holds, fences, drains, asks for the |
| 350 | // evidence and advances; the cut then runs to the flip in one pass. |
| 351 | func TestCutResumesStoppingFromEvidenceAlone(t *testing.T) { |
| 352 | h := newCutHarness(t) |
| 353 | h.ready(t) |
| 354 | h.ev.stopped = false |
| 355 | h.cut(t) |
| 356 | h.pass(t) |
| 357 | h.pass(t) |
| 358 | if m := h.move(t); m.CutPhase != provision.CutStopping || h.ev.calls != 2 { |
| 359 | t.Fatalf("an unproven stop advanced: %+v after %d calls", m, h.ev.calls) |
| 360 | } |
| 361 | if h.claim(t).Op.Exec != "ie-box" { |
| 362 | t.Fatal("execution moved before the evidence") |
| 363 | } |
| 364 | h.freshWorker() |
| 365 | h.ev.stopped = true |
| 366 | h.pass(t) |
| 367 | if m := h.move(t); m.State != provision.MoveLanding || m.CutPhase != provision.CutFlipped { |
| 368 | t.Fatalf("the resumed cut: %+v", m) |
| 369 | } |
| 370 | if h.ev.calls != 3 || h.fences != 3 || h.drains != 3 || len(h.holds) != 3 || h.holds[0] != p1Host+"="+p1ID { |
| 371 | t.Fatalf("evidence %d, fences %d, drains %d, holds %v", h.ev.calls, h.fences, h.drains, h.holds) |
| 372 | } |
| 373 | for _, loc := range h.locations { |
| 374 | if loc != cutRoot { |
| 375 | t.Fatalf("the lease step read %s, not the activator's lease location %s", loc, cutRoot) |
| 376 | } |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | // Ruling T5-lease: Phase 1 cuts only a holder whose location is the |
| 381 | // activator's own lease prefix, activator.HibernatePrefix(prefix, host), |
| 382 | // because the final checkpoint and the stop evidence take that very lease |
| 383 | // object. A scoped holder (instances/<host>/<incarnation>) goes back to |
| 384 | // ready at requested and at admitted, with the reason recorded, and |
| 385 | // nothing stops. The refusal stands: a passing round pins again and the |
| 386 | // next cut is refused the same way. |
| 387 | func TestCutRefusesAScopedHolder(t *testing.T) { |
| 388 | t.Parallel() |
| 389 | ctx := context.Background() |
| 390 | check := func(t *testing.T, h *cutHarness, when string) { |
| 391 | t.Helper() |
| 392 | m := h.move(t) |
| 393 | if m.State != provision.MoveReady || m.CutPhase != "" || m.Inputs != nil || m.Reason != provision.RefusedReplicaLayout { |
| 394 | t.Fatalf("%s: not refused back to ready with the reason: %+v", when, m) |
| 395 | } |
| 396 | if h.ev.calls != 0 || len(h.holds) != 0 || h.fences != 0 || h.checkpoints != 0 { |
| 397 | t.Fatalf("%s: something stopped: evidence %d, holds %v, fences %d, checkpoints %d", when, h.ev.calls, h.holds, h.fences, h.checkpoints) |
| 398 | } |
| 399 | if c := h.claim(t); c.Op.Exec != "ie-box" || c.Op.Kind != hostclaim.KindMoving { |
| 400 | t.Fatalf("%s: the claim moved: %+v", when, c.Op) |
| 401 | } |
| 402 | } |
| 403 | h := newCutHarness(t) |
| 404 | h.scoped = true |
| 405 | h.ready(t) |
| 406 | if loc := h.claim(t).Holder.Location; loc != "instances/"+p1Host+"/"+h.inc { |
| 407 | t.Fatalf("fixture: holder at %s", loc) |
| 408 | } |
| 409 | h.cut(t) |
| 410 | h.pass(t) |
| 411 | check(t, h, "at requested") |
| 412 | // Fix round 2: the refusal stands and nothing loops. decideReady never |
| 413 | // repins a scoped holder, so later passes write nothing, and the door |
| 414 | // refuses a cut with no pin. |
| 415 | refused := h.move(t) |
| 416 | h.pass(t) |
| 417 | h.pass(t) |
| 418 | if m := h.move(t); m.State != provision.MoveReady || m.Inputs != nil || m.Reason != provision.RefusedReplicaLayout || m.UpdatedAt != refused.UpdatedAt { |
| 419 | t.Fatalf("passes after the refusal wrote the record: %+v", m) |
| 420 | } |
| 421 | g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc} |
| 422 | if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveGuardedCut(g)); !errors.Is(err, provision.ErrMoveConflict) { |
| 423 | t.Fatalf("a cut of the refused move: %v", err) |
| 424 | } |
| 425 | |
| 426 | h2 := newCutHarness(t) |
| 427 | h2.scoped = true |
| 428 | h2.ready(t) |
| 429 | h2.cut(t) |
| 430 | now := h2.clock.Now() |
| 431 | if _, err := provision.SetRecordMove(ctx, h2.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120), 3)); err != nil { |
| 432 | t.Fatal(err) |
| 433 | } |
| 434 | h2.pass(t) |
| 435 | check(t, h2, "at admitted") |
| 436 | } |
| 437 | |
| 438 | // Ruling T5-lease: the cut's final checkpoint takes the activator's own |
| 439 | // lease object, with the real lease package against a fake S3. While the |
| 440 | // activator's writer holds it the cut cannot checkpoint: execution stays |
| 441 | // with the source, no report is published and nothing but the lock is |
| 442 | // written. The production hook refuses a holder at any other location |
| 443 | // before it asks for a lease at all. |
| 444 | func TestCutCheckpointWaitsForTheActivatorsLease(t *testing.T) { |
| 445 | ctx := context.Background() |
| 446 | fake := leasetest.NewVersionedBucket() |
| 447 | fake.AddBucket("data-ie") |
| 448 | handler, requests := leasetest.NewS3Server(fake, "data-ie") |
| 449 | srv := httptest.NewServer(handler) // 127.0.0.1 only |
| 450 | defer srv.Close() |
| 451 | t.Setenv("CARLOS_S3_ENDPOINT", srv.URL) |
| 452 | t.Setenv("AWS_ACCESS_KEY_ID", "test-key") |
| 453 | t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") |
| 454 | t.Setenv("AWS_SESSION_TOKEN", "") |
| 455 | t.Setenv("AWS_PROFILE", "") |
| 456 | t.Setenv("AWS_REGION", "") |
| 457 | |
| 458 | h := newCutHarness(t) |
| 459 | h.cfg.FinalCheckpoint = finalCheckpoint("data-ie", "instances", "eu-west-1") |
| 460 | h.ready(t) |
| 461 | // The activator's writer, as ExecBackend.Acquire takes it. |
| 462 | writer, err := lease.Acquire(ctx, "data-ie", activator.HibernatePrefix("instances", p1Host), "eu-west-1") |
| 463 | if err != nil { |
| 464 | t.Fatal(err) |
| 465 | } |
| 466 | defer writer.Release(ctx) |
| 467 | h.cut(t) |
| 468 | h.pass(t) |
| 469 | m := h.move(t) |
| 470 | if m.State != provision.MoveCutting || m.CutPhase != provision.CutStopped { |
| 471 | t.Fatalf("with the writer's lease held: %+v", m) |
| 472 | } |
| 473 | if exec := h.claim(t).Op.Exec; exec != "ie-box" { |
| 474 | t.Fatalf("execution left the source: %q", exec) |
| 475 | } |
| 476 | if _, _, err := h.s.Get(ctx, h.stoppedKey()); !errors.Is(err, os.ErrNotExist) { |
| 477 | t.Fatalf("a stopped report was published: %v", err) |
| 478 | } |
| 479 | vers, err := fake.ListVersions(ctx, "data-ie", "") |
| 480 | if err != nil { |
| 481 | t.Fatal(err) |
| 482 | } |
| 483 | for _, v := range vers { |
| 484 | if v.Key != activator.HibernatePrefix("instances", p1Host)+"/lock.json" { |
| 485 | t.Fatalf("the cut wrote %s while the writer held the lease", v.Key) |
| 486 | } |
| 487 | } |
| 488 | meta := filepath.Join(filepath.Dir(h.route.DBPath), ".activator-"+filepath.Base(h.route.DBPath)+"-litestream") |
| 489 | if _, err := os.Stat(meta); !errors.Is(err, os.ErrNotExist) { |
| 490 | t.Fatalf("a checkpoint ran: %s exists (%v)", meta, err) |
| 491 | } |
| 492 | holder := *h.claim(t).Holder |
| 493 | var he *lease.HeldError |
| 494 | if err := h.cfg.FinalCheckpoint(ctx, p1Host, h.route.DBPath, holder, func(context.Context) error { return nil }); !errors.As(err, &he) { |
| 495 | t.Fatalf("the hook against the held lease: %v", err) |
| 496 | } |
| 497 | // A holder away from the activator's prefix: refused with no request. |
| 498 | before := requests() |
| 499 | foreign := holder |
| 500 | foreign.Location = "instances/" + p1Host + "/" + h.inc |
| 501 | err = h.cfg.FinalCheckpoint(ctx, p1Host, h.route.DBPath, foreign, func(context.Context) error { return nil }) |
| 502 | if !errors.Is(err, errHolderLocation) || requests() != before { |
| 503 | t.Fatalf("a scoped holder: %v (%d requests)", err, requests()-before) |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | // K3 and K13: the stopped report exists, complete with the replica step |
| 508 | // and N, before the claim's execution is none. A crash after the |
| 509 | // checkpoint leaves execution with the source and no report; a crash after |
| 510 | // the report leaves one report, which the replay reuses as it is. |
| 511 | func TestStoppedReportIsCompleteBeforeExecToNone(t *testing.T) { |
| 512 | t.Parallel() |
| 513 | ctx := context.Background() |
| 514 | for _, point := range []string{"after-checkpoint", "after-report", "after-record-source", "after-exec-none"} { |
| 515 | t.Run(point, func(t *testing.T) { |
| 516 | h := newCutHarness(t) |
| 517 | h.ready(t) |
| 518 | h.crashAt = point |
| 519 | h.cut(t) |
| 520 | h.crashPass(t) |
| 521 | if m := h.move(t); m.CutPhase != provision.CutStopped { |
| 522 | t.Fatalf("after the crash: %+v", m) |
| 523 | } |
| 524 | body, _, err := h.s.Get(ctx, h.stoppedKey()) |
| 525 | exec := h.claim(t).Op.Exec |
| 526 | switch point { |
| 527 | case "after-checkpoint": |
| 528 | if !errors.Is(err, os.ErrNotExist) || exec != "ie-box" { |
| 529 | t.Fatalf("report %v, exec %q", err, exec) |
| 530 | } |
| 531 | default: |
| 532 | var r moves.StoppedReport |
| 533 | if err != nil || json.Unmarshal(body, &r) != nil || !r.Complete() || r.FinalTXID != 3 || r.DBSHA256 == "" || r.TimersSHA256 == "" || r.FilesPlain == "" { |
| 534 | t.Fatalf("report %v: %+v", err, r) |
| 535 | } |
| 536 | if want := point == "after-exec-none"; (exec == "") != want { |
| 537 | t.Fatalf("exec %q at %s", exec, point) |
| 538 | } |
| 539 | } |
| 540 | h.freshWorker() |
| 541 | h.pass(t) |
| 542 | if m := h.move(t); m.State != provision.MoveLanding { |
| 543 | t.Fatalf("the replay did not finish: %+v", m) |
| 544 | } |
| 545 | after, _, err := h.s.Get(ctx, h.stoppedKey()) |
| 546 | if err != nil { |
| 547 | t.Fatal(err) |
| 548 | } |
| 549 | if point != "after-checkpoint" && string(after) != string(body) { |
| 550 | t.Fatalf("the replay published a second report:\n%s\n%s", body, after) |
| 551 | } |
| 552 | keys, _ := h.s.List(ctx, moves.ReportPrefix("bab", "app", p1Host, p1ID)) |
| 553 | n := 0 |
| 554 | for _, k := range keys { |
| 555 | if strings.Contains(k, "stopped-") { |
| 556 | n++ |
| 557 | } |
| 558 | } |
| 559 | if n != 1 { |
| 560 | t.Fatalf("%d stopped reports: %v", n, keys) |
| 561 | } |
| 562 | src := h.move(t).Source |
| 563 | if src == nil || src.FinalTXID != 3 || src.Evidence != h.stoppedKey() || src.Files == nil || src.Files.Plain == "" || src.Files.Sealed == "" { |
| 564 | t.Fatalf("source baselines: %+v", src) |
| 565 | } |
| 566 | c := h.claim(t) |
| 567 | if c.Op.Journal[hostclaim.JournalEvidenceSource] != h.stoppedKey() || c.Op.Journal[hostclaim.JournalCutSession] == "" { |
| 568 | t.Fatalf("claim journal: %v", c.Op.Journal) |
| 569 | } |
| 570 | }) |
| 571 | } |
| 572 | } |
| 573 | |
| 574 | // Review Focus 2: three final rounds that defer revert the cut, and the |
| 575 | // claim follows once execution is none: reverting under the source, the |
| 576 | // lane's park queued with the writers' horizon. |
| 577 | func TestCutRevertsAfterThreeDeferredRounds(t *testing.T) { |
| 578 | h := newCutHarness(t) |
| 579 | h.ready(t) |
| 580 | n := 0 |
| 581 | h.f.beforeCopy = func(srcKey string) { |
| 582 | if strings.HasSuffix(srcKey, lastTXKey) { |
| 583 | // The source changes under the copy: the conditional copy is |
| 584 | // refused and the object deferred. |
| 585 | h.f.bmu.Lock() |
| 586 | n++ |
| 587 | o := h.f.buckets["data-ie"][srcKey] |
| 588 | o.ETag = "changed-" + string(rune('a'+n)) |
| 589 | h.f.buckets["data-ie"][srcKey] = o |
| 590 | h.f.bmu.Unlock() |
| 591 | } |
| 592 | } |
| 593 | h.cut(t) |
| 594 | for i := 1; i <= 2; i++ { |
| 595 | h.pass(t) |
| 596 | if m := h.move(t); m.State != provision.MoveCutting || m.CutPhase != provision.CutStopped { |
| 597 | t.Fatalf("after deferred round %d: %+v", i, m) |
| 598 | } |
| 599 | if got := h.ledger(t).DeferredRounds; got != i { |
| 600 | t.Fatalf("ledger counts %d deferred rounds, want %d", got, i) |
| 601 | } |
| 602 | } |
| 603 | h.pass(t) |
| 604 | m := h.move(t) |
| 605 | if m.State != provision.MoveReverting || m.Reason != provision.RevertDeferredRounds { |
| 606 | t.Fatalf("after three deferred rounds: %+v", m) |
| 607 | } |
| 608 | c := h.claim(t) |
| 609 | if c.Op.Kind != hostclaim.KindReverting || c.Op.Exec != "ie-box" || c.Op.Journal[hostclaim.JournalRevPhase] != "evidence" { |
| 610 | t.Fatalf("claim: %+v", c.Op) |
| 611 | } |
| 612 | var park *hostclaim.ParkScope |
| 613 | for _, o := range c.Obligations { |
| 614 | if o.Op == p1ID && o.Kind == "park-replica" && o.Region == "uk" { |
| 615 | park = o.Park |
| 616 | } |
| 617 | } |
| 618 | if park == nil || park.Location != moves.ScopeLocation(p1Host, p1ID) || park.Incarnation != h.inc || park.RequestLimit != cutRequestLimit { |
| 619 | t.Fatalf("staging park: %+v", park) |
| 620 | } |
| 621 | if !park.EvidenceAt.After(h.clock.now.Add(moves.SignatureLife)) { |
| 622 | t.Fatalf("evidence_at %v is not past every signature's life", park.EvidenceAt) |
| 623 | } |
| 624 | // A replay changes nothing: the claim is reverting already. |
| 625 | h.pass(t) |
| 626 | if c2 := h.claim(t); c2.Gen != c.Gen { |
| 627 | t.Fatalf("a replay wrote the claim again (%d to %d)", c.Gen, c2.Gen) |
| 628 | } |
| 629 | } |
| 630 | |
| 631 | // §7.3 seal by version: the seal names N and the exact version of every |
| 632 | // current replica object, of the timers and of both archive objects. The |
| 633 | // witness (wSealByVersion): a stale copy that lands after the seal makes a |
| 634 | // version the seal does not name, and the seal still names the earlier one. |
| 635 | func TestSealNamesEveryCurrentVersionAndN(t *testing.T) { |
| 636 | h := newCutHarness(t) |
| 637 | h.ready(t) |
| 638 | h.cut(t) |
| 639 | h.pass(t) |
| 640 | if m := h.move(t); m.State != provision.MoveLanding { |
| 641 | t.Fatalf("cut did not finish: %+v", m) |
| 642 | } |
| 643 | seal := h.seal(t) |
| 644 | if seal.N != 3 || !seal.ReachesN() || !seal.FromDB || !seal.SrcCovered || len(seal.Timers) != 1 || len(seal.Files) != 2 { |
| 645 | t.Fatalf("seal: %+v", seal) |
| 646 | } |
| 647 | lane := moves.ReplicaPrefix(p1Host, p1ID) |
| 648 | want := []string{lane + "0000/0000000000000001-0000000000000002.ltx", lane + "0001/0000000000000001-0000000000000002.ltx", lane + lastTXKey} |
| 649 | sort.Strings(want) |
| 650 | var got []string |
| 651 | for _, o := range seal.Replica { |
| 652 | got = append(got, o.Key) |
| 653 | if cur := h.f.versionOf("data-uk", o.Key); cur != o.VersionID { |
| 654 | t.Fatalf("%s: sealed %s, current %s", o.Key, o.VersionID, cur) |
| 655 | } |
| 656 | } |
| 657 | sort.Strings(got) |
| 658 | if strings.Join(got, ",") != strings.Join(want, ",") { |
| 659 | t.Fatalf("sealed %v, want %v", got, want) |
| 660 | } |
| 661 | for _, o := range append(append([]moves.SealedObject{}, seal.Timers...), seal.Files...) { |
| 662 | if cur := h.f.versionOf("data-uk", o.Key); cur != o.VersionID || !lease.UsableVersionID(o.VersionID) { |
| 663 | t.Fatalf("%s: sealed %q, current %q", o.Key, o.VersionID, cur) |
| 664 | } |
| 665 | } |
| 666 | if !seal.Bound().After(seal.SealedAt) { |
| 667 | t.Fatalf("bound %v is not after the seal %v", seal.Bound(), seal.SealedAt) |
| 668 | } |
| 669 | // The witness. |
| 670 | stale := seal.Replica[0] |
| 671 | if _, _, err := h.f.CopyVersionedAt(context.Background(), h.clock.Now(), "data-ie", |
| 672 | cutLineage+"0000/0000000000000001-0000000000000002.ltx", "", "data-uk", stale.Key); err != nil { |
| 673 | t.Fatal(err) |
| 674 | } |
| 675 | if cur := h.f.versionOf("data-uk", stale.Key); cur == stale.VersionID { |
| 676 | t.Fatal("the stale copy did not make a new version") |
| 677 | } |
| 678 | if again := h.seal(t); again.Replica[0].VersionID != stale.VersionID { |
| 679 | t.Fatal("the seal moved with the stale copy") |
| 680 | } |
| 681 | // Every staging write of the cut went through the guard. |
| 682 | for _, op := range h.f.ops { |
| 683 | if strings.Contains(op, "data-uk/") && !strings.HasPrefix(op, "copy ") { |
| 684 | t.Fatalf("an unguarded write reached the target bucket: %s", op) |
| 685 | } |
| 686 | } |
| 687 | for _, c := range h.f.stagingCalls() { |
| 688 | if c.SignAt.IsZero() || c.Bucket != "data-uk" { |
| 689 | t.Fatalf("unguarded or misdirected request: %+v", c) |
| 690 | } |
| 691 | } |
| 692 | } |
| 693 | |
| 694 | // writeOnStop makes the instance write a file just before it is stopped: |
| 695 | // the stopped files differ from the archive the target verified, so the |
| 696 | // cut builds its own. |
| 697 | func (h *cutHarness) writeOnStop(t *testing.T) { |
| 698 | h.ev.onCall = func() { |
| 699 | if h.ev.calls == 1 { |
| 700 | if err := os.WriteFile(filepath.Join(h.cfg.DataDir, p1Host, "late.txt"), []byte("written before the stop"), 0o644); err != nil { |
| 701 | t.Error(err) |
| 702 | } |
| 703 | } |
| 704 | } |
| 705 | } |
| 706 | |
| 707 | func snapshotSealed(h *cutHarness) []string { |
| 708 | var out []string |
| 709 | for k := range h.f.buckets[h.srcBucket] { |
| 710 | if strings.HasPrefix(k, moves.SnapshotsPrefix(p1Host, p1ID)) && strings.HasSuffix(k, ".sealed") { |
| 711 | out = append(out, k) |
| 712 | } |
| 713 | } |
| 714 | sort.Strings(out) |
| 715 | return out |
| 716 | } |
| 717 | |
| 718 | // codex 19, with the real randomized sealer: the archive's identity is |
| 719 | // journalled before any PUT, and every replay publishes that same key, |
| 720 | // whatever it crashed after: the journal entry, the first snapshot PUT, |
| 721 | // the second, the first copy. Once journalled the identity is final |
| 722 | // (fix round 1, ruling 4): a replay with the sealed file gone finishes it |
| 723 | // from what was published (the manifest from the journalled bytes if only |
| 724 | // the sealed object landed), and one that finds nothing published cannot, |
| 725 | // so the cut reverts rather than reseal under a new name. |
| 726 | func TestCutArchiveIsCreateOnlyAndReused(t *testing.T) { |
| 727 | t.Parallel() |
| 728 | for _, point := range []string{"archive-journaled", "archive-sealed-snapshot", "archive-own-put", "archive-first-copy"} { |
| 729 | t.Run("crash "+point, func(t *testing.T) { |
| 730 | h := newCutHarness(t) |
| 731 | h.ready(t) |
| 732 | h.writeOnStop(t) |
| 733 | h.crashAt = point |
| 734 | h.cut(t) |
| 735 | h.crashPass(t) |
| 736 | if m := h.move(t); m.CutPhase != provision.CutStaged { |
| 737 | t.Fatalf("after the crash: %+v", m) |
| 738 | } |
| 739 | id := h.journalArchive(t) |
| 740 | if id.Reuse { |
| 741 | t.Fatal("the stopped files changed, yet the preparing archive was reused") |
| 742 | } |
| 743 | h.freshWorker() |
| 744 | h.pass(t) |
| 745 | m := h.move(t) |
| 746 | if m.State != provision.MoveLanding || m.Source.Files.Sealed != id.Sealed { |
| 747 | t.Fatalf("the replay: %+v, files %+v, journalled %s", m, m.Source.Files, id.Sealed) |
| 748 | } |
| 749 | if got := snapshotSealed(h); len(got) != 1 || !strings.HasSuffix(got[0], id.Name+".sealed") { |
| 750 | t.Fatalf("snapshots %v, want only %s", got, id.Name) |
| 751 | } |
| 752 | if !sealNamesArchive(h.seal(t), id.Sealed) { |
| 753 | t.Fatalf("the seal names %+v, not %s", h.seal(t).Files, id.Name) |
| 754 | } |
| 755 | }) |
| 756 | } |
| 757 | t.Run("file gone, nothing published", func(t *testing.T) { |
| 758 | h := newCutHarness(t) |
| 759 | h.ready(t) |
| 760 | h.writeOnStop(t) |
| 761 | h.crashAt = "archive-journaled" |
| 762 | h.cut(t) |
| 763 | h.crashPass(t) |
| 764 | first := h.journalArchive(t) |
| 765 | if err := os.RemoveAll(archiveDir(h.cfg.DataDir, p1Host, p1ID)); err != nil { |
| 766 | t.Fatal(err) |
| 767 | } |
| 768 | h.pass(t) |
| 769 | m := h.move(t) |
| 770 | if m.State != provision.MoveReverting || m.Reason != provision.RevertArchiveRefused { |
| 771 | t.Fatalf("an identity nobody can finish did not revert: %+v", m) |
| 772 | } |
| 773 | if again := h.journalArchive(t); again.Sealed != first.Sealed { |
| 774 | t.Fatalf("the journalled identity changed from %s to %s", first.Name, again.Name) |
| 775 | } |
| 776 | if got := snapshotSealed(h); len(got) != 0 { |
| 777 | t.Fatalf("snapshots %v, want none", got) |
| 778 | } |
| 779 | if h.seal(t).HasFiles() { |
| 780 | t.Fatal("the seal names files") |
| 781 | } |
| 782 | }) |
| 783 | t.Run("file gone, sealed snapshot only", func(t *testing.T) { |
| 784 | h := newCutHarness(t) |
| 785 | h.ready(t) |
| 786 | h.writeOnStop(t) |
| 787 | h.crashAt = "archive-sealed-snapshot" |
| 788 | h.cut(t) |
| 789 | h.crashPass(t) |
| 790 | first := h.journalArchive(t) |
| 791 | snap := moves.SnapshotsPrefix(p1Host, p1ID) |
| 792 | if _, ok := h.f.buckets["data-ie"][snap+first.Name+".sealed"]; !ok { |
| 793 | t.Fatal("fixture: the sealed object did not land") |
| 794 | } |
| 795 | if _, ok := h.f.buckets["data-ie"][snap+first.Name+".json"]; ok { |
| 796 | t.Fatal("fixture: the manifest landed too") |
| 797 | } |
| 798 | if err := os.RemoveAll(archiveDir(h.cfg.DataDir, p1Host, p1ID)); err != nil { |
| 799 | t.Fatal(err) |
| 800 | } |
| 801 | h.freshWorker() |
| 802 | h.pass(t) |
| 803 | m := h.move(t) |
| 804 | if m.State != provision.MoveLanding || m.Source.Files.Sealed != first.Sealed || !sealNamesArchive(h.seal(t), first.Sealed) { |
| 805 | t.Fatalf("the identity was not finished: %+v files %+v", m, m.Source.Files) |
| 806 | } |
| 807 | manifest, ok := h.f.buckets["data-ie"][snap+first.Name+".json"] |
| 808 | if !ok { |
| 809 | t.Fatal("the manifest was not written from the journal") |
| 810 | } |
| 811 | var man archive.Manifest |
| 812 | if err := json.Unmarshal([]byte(manifest.body), &man); err != nil || man.Sealed != first.Sealed || man.Plain != first.Plain { |
| 813 | t.Fatalf("the written manifest: %v %+v", err, man) |
| 814 | } |
| 815 | copied := 0 |
| 816 | for _, c := range h.f.stagingCalls() { |
| 817 | if c.Op == "copy" && strings.HasPrefix(c.Key, moves.FilesPrefix(p1Host, p1ID)+first.Name) { |
| 818 | copied++ |
| 819 | } |
| 820 | } |
| 821 | if copied != 2 || len(snapshotSealed(h)) != 1 { |
| 822 | t.Fatalf("%d copies from the snapshot, %d sealed snapshots", copied, len(snapshotSealed(h))) |
| 823 | } |
| 824 | }) |
| 825 | t.Run("file gone, snapshot published", func(t *testing.T) { |
| 826 | h := newCutHarness(t) |
| 827 | h.ready(t) |
| 828 | h.writeOnStop(t) |
| 829 | h.crashAt = "archive-own-put" |
| 830 | h.cut(t) |
| 831 | h.crashPass(t) |
| 832 | first := h.journalArchive(t) |
| 833 | if err := os.RemoveAll(archiveDir(h.cfg.DataDir, p1Host, p1ID)); err != nil { |
| 834 | t.Fatal(err) |
| 835 | } |
| 836 | h.pass(t) |
| 837 | m := h.move(t) |
| 838 | if m.State != provision.MoveLanding || m.Source.Files.Sealed != first.Sealed || !sealNamesArchive(h.seal(t), first.Sealed) { |
| 839 | t.Fatalf("not reused: %+v files %+v", m, m.Source.Files) |
| 840 | } |
| 841 | // Staged by a server-side copy from the snapshot. |
| 842 | copied := 0 |
| 843 | for _, c := range h.f.stagingCalls() { |
| 844 | if c.Op == "copy" && strings.HasPrefix(c.Key, moves.FilesPrefix(p1Host, p1ID)+first.Name) { |
| 845 | copied++ |
| 846 | } |
| 847 | } |
| 848 | if copied != 2 { |
| 849 | t.Fatalf("%d copies from the snapshot, want 2", copied) |
| 850 | } |
| 851 | }) |
| 852 | t.Run("unchanged files reuse the verified archive", func(t *testing.T) { |
| 853 | h := newCutHarness(t) |
| 854 | h.ready(t) |
| 855 | staged := h.ledger(t) |
| 856 | h.cut(t) |
| 857 | h.pass(t) |
| 858 | m := h.move(t) |
| 859 | if m.State != provision.MoveLanding || m.Source.Files.Sealed != staged.ArchiveSealed { |
| 860 | t.Fatalf("the verified archive was not reused: %+v (ledger %s)", m.Source.Files, staged.ArchiveSealed) |
| 861 | } |
| 862 | seal := h.seal(t) |
| 863 | for _, o := range seal.Files { |
| 864 | if o.VersionID != staged.ArchiveVersions.SealedVersion && o.VersionID != staged.ArchiveVersions.ManifestVersion { |
| 865 | t.Fatalf("the seal names %s@%s, not the verified versions", o.Key, o.VersionID) |
| 866 | } |
| 867 | } |
| 868 | if got := snapshotSealed(h); len(got) != 1 || !strings.HasSuffix(got[0], archiveName(staged.ArchiveSealed)+".sealed") { |
| 869 | t.Fatalf("snapshots %v", got) |
| 870 | } |
| 871 | }) |
| 872 | } |
| 873 | |
| 874 | // G8: archived is written only after the seal names the archive. A crash |
| 875 | // after each of the archive's PUTs leaves the record staged; the replay |
| 876 | // completes and the seal names both files/ versions before archived. A |
| 877 | // record hand-edited to archived whose seal has no files does not flip. |
| 878 | func TestArchivedNeverPrecedesTheSealedArchive(t *testing.T) { |
| 879 | t.Parallel() |
| 880 | for _, point := range []string{"archive-own-put", "archive-first-copy", "archive-second-copy", "after-archive-seal"} { |
| 881 | t.Run(point, func(t *testing.T) { |
| 882 | h := newCutHarness(t) |
| 883 | h.ready(t) |
| 884 | h.writeOnStop(t) |
| 885 | h.crashAt = point |
| 886 | h.cut(t) |
| 887 | h.crashPass(t) |
| 888 | if m := h.move(t); m.CutPhase != provision.CutStaged { |
| 889 | t.Fatalf("archived before the seal named the archive: %+v", m) |
| 890 | } |
| 891 | if point != "after-archive-seal" && h.seal(t).HasFiles() { |
| 892 | t.Fatal("the seal names files before the copies finished") |
| 893 | } |
| 894 | h.pass(t) |
| 895 | m := h.move(t) |
| 896 | seal := h.seal(t) |
| 897 | if m.State != provision.MoveLanding || !sealNamesArchive(seal, m.Source.Files.Sealed) { |
| 898 | t.Fatalf("after the replay: %+v, seal files %+v", m, seal.Files) |
| 899 | } |
| 900 | for _, o := range seal.Files { |
| 901 | if !strings.HasPrefix(o.Key, moves.FilesPrefix(p1Host, p1ID)) || h.f.versionOf("data-uk", o.Key) != o.VersionID { |
| 902 | t.Fatalf("seal names %s@%s", o.Key, o.VersionID) |
| 903 | } |
| 904 | } |
| 905 | }) |
| 906 | } |
| 907 | t.Run("hand-edited to archived", func(t *testing.T) { |
| 908 | ctx := context.Background() |
| 909 | h := newCutHarness(t) |
| 910 | h.ready(t) |
| 911 | h.crashAt = "archive-journaled" |
| 912 | h.cut(t) |
| 913 | h.crashPass(t) |
| 914 | if h.seal(t).HasFiles() { |
| 915 | t.Fatal("fixture: the seal already names files") |
| 916 | } |
| 917 | if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, func(rec *provision.Record) error { |
| 918 | rec.Move.CutPhase = provision.CutArchived |
| 919 | return nil |
| 920 | }); err != nil { |
| 921 | t.Fatal(err) |
| 922 | } |
| 923 | h.pass(t) |
| 924 | h.pass(t) |
| 925 | m := h.move(t) |
| 926 | if m.State != provision.MoveCutting || m.CutPhase != provision.CutArchived || h.read(t, p1Host).Region != "ie" { |
| 927 | t.Fatalf("a hand-edited archived flipped: %+v", m) |
| 928 | } |
| 929 | if c := h.claim(t); c.Op.Kind != hostclaim.KindMoving { |
| 930 | t.Fatalf("the claim flipped: %+v", c.Op) |
| 931 | } |
| 932 | }) |
| 933 | } |
| 934 | |
| 935 | // §7.5: the record flips first (region, landing, the landing deadline, |
| 936 | // checking), then the claim follows: landing/checking with the target |
| 937 | // executing, the source's stamp and generation journalled, the holder's |
| 938 | // generation untouched. A crash between the two leaves the record ahead |
| 939 | // and the claim moving/sealed with nobody executing, which anyone who sees |
| 940 | // it may finish with the same Flip. |
| 941 | func TestFlipWritesRecordThenClaim(t *testing.T) { |
| 942 | t.Parallel() |
| 943 | ctx := context.Background() |
| 944 | h := newCutHarness(t) |
| 945 | h.ready(t) |
| 946 | h.cut(t) |
| 947 | h.pass(t) |
| 948 | rec := h.read(t, p1Host) |
| 949 | m := rec.Move |
| 950 | if rec.Region != "uk" || m.State != provision.MoveLanding || m.CutPhase != provision.CutFlipped || m.LandingPhase != provision.LandChecking || m.FlippedAt == nil || m.LandingDeadline == nil { |
| 951 | t.Fatalf("record: region %s %+v", rec.Region, m) |
| 952 | } |
| 953 | if d := m.LandingDeadline.Sub(*m.FlippedAt); d != cutLandingWindow { |
| 954 | t.Fatalf("landing window %v", d) |
| 955 | } |
| 956 | c := h.claim(t) |
| 957 | if c.Op.Kind != hostclaim.KindLanding || c.Op.Phase != "checking" || c.Op.Exec != "uk-box" || |
| 958 | c.Op.Journal[hostclaim.JournalSourceStamp] != "1" || c.Op.Journal[hostclaim.JournalSrcGen] != "cur" || c.Holder.Generation != "cur" { |
| 959 | t.Fatalf("claim: %+v holder %+v", c.Op, c.Holder) |
| 960 | } |
| 961 | |
| 962 | h2 := newCutHarness(t) |
| 963 | h2.ready(t) |
| 964 | h2.crashAt = "after-record-flip" |
| 965 | h2.cut(t) |
| 966 | h2.crashPass(t) |
| 967 | if r := h2.read(t, p1Host); r.Region != "uk" || r.Move.State != provision.MoveLanding { |
| 968 | t.Fatalf("record not flipped: %+v", r.Move) |
| 969 | } |
| 970 | c2 := h2.claim(t) |
| 971 | if c2.Op.Kind != hostclaim.KindMoving || c2.Op.Phase != hostclaim.PhaseSealed || c2.Op.Exec != "" { |
| 972 | t.Fatalf("claim after the crash: %+v", c2.Op) |
| 973 | } |
| 974 | obs, err := hostclaim.Read(ctx, h2.s, p1Host) |
| 975 | if err != nil { |
| 976 | t.Fatal(err) |
| 977 | } |
| 978 | if _, err := hostclaim.Update(ctx, h2.s, obs, hostclaim.Flip(p1ID, "uk-box", obs.Claim.Holder.Stamp, obs.Claim.Holder.Generation)); err != nil { |
| 979 | t.Fatalf("the recovery's flip: %v", err) |
| 980 | } |
| 981 | if c3 := h2.claim(t); c3.Op.Kind != hostclaim.KindLanding || c3.Op.Exec != "uk-box" { |
| 982 | t.Fatalf("claim after the recovery: %+v", c3.Op) |
| 983 | } |
| 984 | } |
| 985 | |
| 986 | // The deadline (§7.4): at admitted it goes back to ready; at stopping it |
| 987 | // reverts with "stop evidence budget" and the claim stays with the source, |
| 988 | // which still executes; after execution was given up it reverts with the |
| 989 | // deadline's reason and the claim follows to reverting. |
| 990 | func TestCutDeadlinePassedReverts(t *testing.T) { |
| 991 | t.Parallel() |
| 992 | ctx := context.Background() |
| 993 | g := provision.MoveGuard{ID: p1ID, Incarnation: h0inc(t)} |
| 994 | |
| 995 | t.Run("admitted", func(t *testing.T) { |
| 996 | h := newCutHarness(t) |
| 997 | h.ready(t) |
| 998 | h.cut(t) |
| 999 | now := h.clock.Now() |
| 1000 | if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120), 3)); err != nil { |
| 1001 | t.Fatal(err) |
| 1002 | } |
| 1003 | h.clock.Advance(2 * time.Minute) |
| 1004 | h.pass(t) |
| 1005 | if m := h.move(t); m.State != provision.MoveReady || h.ev.calls != 0 { |
| 1006 | t.Fatalf("admitted past the deadline: %+v", m) |
| 1007 | } |
| 1008 | }) |
| 1009 | t.Run("stopping", func(t *testing.T) { |
| 1010 | h := newCutHarness(t) |
| 1011 | h.ready(t) |
| 1012 | h.ev.stopped = false |
| 1013 | h.cut(t) |
| 1014 | h.pass(t) |
| 1015 | h.clock.Advance(2 * time.Minute) |
| 1016 | h.pass(t) |
| 1017 | m := h.move(t) |
| 1018 | if m.State != provision.MoveReverting || m.Reason != provision.RevertStopEvidenceBudget { |
| 1019 | t.Fatalf("stopping past the deadline: %+v", m) |
| 1020 | } |
| 1021 | if c := h.claim(t); c.Op.Kind != hostclaim.KindMoving || c.Op.Exec != "ie-box" { |
| 1022 | t.Fatalf("the claim moved without the source's evidence: %+v", c.Op) |
| 1023 | } |
| 1024 | }) |
| 1025 | t.Run("stopped, execution given up", func(t *testing.T) { |
| 1026 | h := newCutHarness(t) |
| 1027 | h.ready(t) |
| 1028 | h.crashAt = "after-exec-none" |
| 1029 | h.cut(t) |
| 1030 | h.crashPass(t) |
| 1031 | h.clock.Advance(2 * time.Minute) |
| 1032 | h.pass(t) |
| 1033 | m := h.move(t) |
| 1034 | if m.State != provision.MoveReverting || m.Reason != provision.RevertCutDeadline { |
| 1035 | t.Fatalf("stopped past the deadline: %+v", m) |
| 1036 | } |
| 1037 | c := h.claim(t) |
| 1038 | if c.Op.Kind != hostclaim.KindReverting || c.Op.Exec != "ie-box" { |
| 1039 | t.Fatalf("claim: %+v", c.Op) |
| 1040 | } |
| 1041 | j, err := moves.ReadJournal(h.cfg.DataDir, p1Host, p1ID) |
| 1042 | if err != nil || j.Steps[cutStepRevertBound] == "" { |
| 1043 | t.Fatalf("the revert's bound is not journalled: %v %+v", err, j) |
| 1044 | } |
| 1045 | }) |
| 1046 | t.Run("the claim half after a crash", func(t *testing.T) { |
| 1047 | h := newCutHarness(t) |
| 1048 | h.ready(t) |
| 1049 | h.crashAt = "after-exec-none" |
| 1050 | h.cut(t) |
| 1051 | h.crashPass(t) |
| 1052 | // The record decides the revert; the claim half is lost. |
| 1053 | if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveRevert(g, provision.RevertCutDeadline)); err != nil { |
| 1054 | t.Fatal(err) |
| 1055 | } |
| 1056 | if c := h.claim(t); c.Op.Kind != hostclaim.KindMoving { |
| 1057 | t.Fatalf("fixture: %+v", c.Op) |
| 1058 | } |
| 1059 | h.pass(t) |
| 1060 | if c := h.claim(t); c.Op.Kind != hostclaim.KindReverting { |
| 1061 | t.Fatalf("the reverting arm did not write the claim half: %+v", c.Op) |
| 1062 | } |
| 1063 | }) |
| 1064 | } |
| 1065 | |
| 1066 | // With the switch off, a cutting record is never touched. |
| 1067 | func TestCutIsInertWithTheSwitchOff(t *testing.T) { |
| 1068 | h := newCutHarness(t) |
| 1069 | h.ready(t) |
| 1070 | h.cut(t) |
| 1071 | before := h.read(t, p1Host) |
| 1072 | claimBefore := h.claim(t) |
| 1073 | h.cfg.Enabled = false |
| 1074 | h.pass(t) |
| 1075 | h.pass(t) |
| 1076 | if after := h.read(t, p1Host); after.Move.CutPhase != before.Move.CutPhase || after.Move.UpdatedAt != before.Move.UpdatedAt { |
| 1077 | t.Fatalf("the record moved: %+v", after.Move) |
| 1078 | } |
| 1079 | if c := h.claim(t); c.Gen != claimBefore.Gen { |
| 1080 | t.Fatal("the claim was written") |
| 1081 | } |
| 1082 | if h.ev.calls != 0 || len(h.holds) != 0 || h.fences != 0 { |
| 1083 | t.Fatal("the cut ran with the switch off") |
| 1084 | } |
| 1085 | } |
| 1086 | |
| 1087 | func h0inc(t *testing.T) string { |
| 1088 | t.Helper() |
| 1089 | inc, err := hostclaim.Incarnation("bab", "app", p1Host, p1Created) |
| 1090 | if err != nil { |
| 1091 | t.Fatal(err) |
| 1092 | } |
| 1093 | return inc |
| 1094 | } |
| 1095 | |
| 1096 | // nullVersions is a staging writer whose copies of matching keys answer |
| 1097 | // "null", as a versioning-suspended bucket does. |
| 1098 | type nullVersions struct { |
| 1099 | lease.StagingWriter |
| 1100 | match func(key string) bool |
| 1101 | } |
| 1102 | |
| 1103 | func (n nullVersions) CopyVersionedAt(ctx context.Context, signAt time.Time, srcBucket, srcKey, srcETag, dstBucket, dstKey string) (string, string, error) { |
| 1104 | etag, ver, err := n.StagingWriter.CopyVersionedAt(ctx, signAt, srcBucket, srcKey, srcETag, dstBucket, dstKey) |
| 1105 | if err == nil && n.match(dstKey) { |
| 1106 | ver = "null" |
| 1107 | } |
| 1108 | return etag, ver, err |
| 1109 | } |
| 1110 | |
| 1111 | // G27: the seal needs a usable version for every current replica key. A |
| 1112 | // final round that copied one without (a "null") seals nothing and runs |
| 1113 | // again; that is not a deferred round. |
| 1114 | func TestSealRefusesAnObjectWithoutAUsableVersion(t *testing.T) { |
| 1115 | h := newCutHarness(t) |
| 1116 | h.ready(t) |
| 1117 | suspended := true |
| 1118 | h.cfg.Staging = func(context.Context, string) (lease.StagingWriter, error) { |
| 1119 | return nullVersions{StagingWriter: h.f, match: func(key string) bool { return suspended && strings.HasSuffix(key, lastTXKey) }}, nil |
| 1120 | } |
| 1121 | h.freshWorker() // the worker memoizes its staging writer |
| 1122 | h.cut(t) |
| 1123 | h.pass(t) |
| 1124 | if m := h.move(t); m.CutPhase != provision.CutStopped { |
| 1125 | t.Fatalf("sealed over a null version: %+v", m) |
| 1126 | } |
| 1127 | if _, ok := h.claim(t).Op.Journal[hostclaim.JournalSeal]; ok { |
| 1128 | t.Fatal("a seal was journalled") |
| 1129 | } |
| 1130 | if n := h.ledger(t).DeferredRounds; n != 0 { |
| 1131 | t.Fatalf("counted as %d deferred rounds", n) |
| 1132 | } |
| 1133 | suspended = false |
| 1134 | h.pass(t) |
| 1135 | if m := h.move(t); m.State != provision.MoveLanding { |
| 1136 | t.Fatalf("the next round did not seal: %+v", m) |
| 1137 | } |
| 1138 | for _, o := range h.seal(t).Replica { |
| 1139 | if !lease.UsableVersionID(o.VersionID) { |
| 1140 | t.Fatalf("sealed %s@%q", o.Key, o.VersionID) |
| 1141 | } |
| 1142 | } |
| 1143 | } |
| 1144 | |
| 1145 | // holdMoving holds the row moving under the op, once, with the |
| 1146 | // stage-requested marker touched for a write that landed; a row already so |
| 1147 | // held is left alone, and a row another op holds is refused. |
| 1148 | func TestHoldMovingIsConditional(t *testing.T) { |
| 1149 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 1150 | if err != nil { |
| 1151 | t.Fatal(err) |
| 1152 | } |
| 1153 | defer reg.Close() |
| 1154 | if err := reg.Upsert(registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", Status: "ready", Provisioned: true}); err != nil { |
| 1155 | t.Fatal(err) |
| 1156 | } |
| 1157 | var written []string |
| 1158 | hold := holdMoving(reg, func(host string) { written = append(written, host) }) |
| 1159 | if err := hold(p1Host, p1ID); err != nil { |
| 1160 | t.Fatal(err) |
| 1161 | } |
| 1162 | if err := hold(p1Host, p1ID); err != nil { |
| 1163 | t.Fatalf("a second hold under the same op: %v", err) |
| 1164 | } |
| 1165 | rt, err := reg.Get(p1Host) |
| 1166 | if err != nil || rt.Status != "moving" || rt.HoldOp != p1ID || len(written) != 1 { |
| 1167 | t.Fatalf("row %+v, %v; marker touched %d times", rt, err, len(written)) |
| 1168 | } |
| 1169 | if err := hold(p1Host, p1ID2); err == nil { |
| 1170 | t.Fatal("another op's hold was taken over") |
| 1171 | } |
| 1172 | if rt, _ := reg.Get(p1Host); rt.HoldOp != p1ID { |
| 1173 | t.Fatalf("the hold moved to %s", rt.HoldOp) |
| 1174 | } |
| 1175 | if err := hold("absent.example.com", p1ID); err == nil { |
| 1176 | t.Fatal("a missing row was held") |
| 1177 | } |
| 1178 | } |
| 1179 | |
| 1180 | // localLTXPosition reads the newest level-0 file of the activator's meta |
| 1181 | // directory (lease.Held's SetMetaPath); no directory is position 0. |
| 1182 | func TestLocalLTXPosition(t *testing.T) { |
| 1183 | dir := t.TempDir() |
| 1184 | db := filepath.Join(dir, p1Host+".db") |
| 1185 | if n, err := localLTXPosition(db); err != nil || n != 0 { |
| 1186 | t.Fatalf("no meta directory: %d, %v", n, err) |
| 1187 | } |
| 1188 | l0 := filepath.Join(dir, ".activator-"+p1Host+".db-litestream", "ltx", "0") |
| 1189 | if err := os.MkdirAll(l0, 0o755); err != nil { |
| 1190 | t.Fatal(err) |
| 1191 | } |
| 1192 | for _, name := range []string{"0000000000000001-0000000000000005.ltx", "0000000000000006-000000000000000a.ltx", "junk", "0000000000000003-0000000000000004.ltx"} { |
| 1193 | if err := os.WriteFile(filepath.Join(l0, name), nil, 0o600); err != nil { |
| 1194 | t.Fatal(err) |
| 1195 | } |
| 1196 | } |
| 1197 | if n, err := localLTXPosition(db); err != nil || n != 10 { |
| 1198 | t.Fatalf("position %d, %v; want 10", n, err) |
| 1199 | } |
| 1200 | } |
| 1201 | |
| 1202 | // Ruling 3: coverage is never assumed. A database that is gone before the |
| 1203 | // final checkpoint leaves the seal FromDB false and SrcCovered false, since |
| 1204 | // nothing proved the replica holds its last write; one that was there |
| 1205 | // through the checkpoint, with its position journalled equal to N, keeps |
| 1206 | // SrcCovered true if the disk is lost afterwards; one still present but |
| 1207 | // behind its replica is not covered. |
| 1208 | func TestMissingDatabaseIsNotCovered(t *testing.T) { |
| 1209 | t.Parallel() |
| 1210 | t.Run("lost before the checkpoint", func(t *testing.T) { |
| 1211 | h := newCutHarness(t) |
| 1212 | h.ready(t) |
| 1213 | h.crashAt = "after-evidence" |
| 1214 | h.cut(t) |
| 1215 | h.crashPass(t) |
| 1216 | if m := h.move(t); m.CutPhase != provision.CutStopping { |
| 1217 | t.Fatalf("after the crash: %+v", m) |
| 1218 | } |
| 1219 | if err := os.Remove(h.route.DBPath); err != nil { |
| 1220 | t.Fatal(err) |
| 1221 | } |
| 1222 | h.freshWorker() |
| 1223 | h.pass(t) |
| 1224 | m := h.move(t) |
| 1225 | if m.State != provision.MoveLanding || h.checkpoints != 0 { |
| 1226 | t.Fatalf("the cut without a database: %+v (%d checkpoints)", m, h.checkpoints) |
| 1227 | } |
| 1228 | if m.Source.FinalTXID != 2 || m.Source.DBSHA256 != "" { |
| 1229 | t.Fatalf("source baselines: %+v", m.Source) |
| 1230 | } |
| 1231 | seal := h.seal(t) |
| 1232 | if seal.FromDB || seal.SrcCovered || seal.N != 2 { |
| 1233 | t.Fatalf("seal claims coverage with no database: %+v", seal) |
| 1234 | } |
| 1235 | }) |
| 1236 | t.Run("lost after the checkpoint journalled coverage", func(t *testing.T) { |
| 1237 | h := newCutHarness(t) |
| 1238 | h.ready(t) |
| 1239 | h.crashAt = "after-exec-none" |
| 1240 | h.cut(t) |
| 1241 | h.crashPass(t) |
| 1242 | if err := os.Remove(h.route.DBPath); err != nil { |
| 1243 | t.Fatal(err) |
| 1244 | } |
| 1245 | h.freshWorker() |
| 1246 | h.pass(t) |
| 1247 | if m := h.move(t); m.State != provision.MoveLanding { |
| 1248 | t.Fatalf("the replay: %+v", m) |
| 1249 | } |
| 1250 | seal := h.seal(t) |
| 1251 | if seal.FromDB || !seal.SrcCovered || seal.N != 3 { |
| 1252 | t.Fatalf("seal: %+v", seal) |
| 1253 | } |
| 1254 | }) |
| 1255 | t.Run("present but behind its replica", func(t *testing.T) { |
| 1256 | h := newCutHarness(t) |
| 1257 | h.ready(t) |
| 1258 | h.localPos = 2 |
| 1259 | h.cut(t) |
| 1260 | h.pass(t) |
| 1261 | if m := h.move(t); m.State != provision.MoveLanding { |
| 1262 | t.Fatalf("the cut: %+v", m) |
| 1263 | } |
| 1264 | if seal := h.seal(t); !seal.FromDB || seal.SrcCovered { |
| 1265 | t.Fatalf("seal: %+v", seal) |
| 1266 | } |
| 1267 | }) |
| 1268 | } |
| 1269 | |
| 1270 | // regularFile: a regular file is one; an absent path is not and no error; |
| 1271 | // anything else (a path under a file, a directory where a file should be) |
| 1272 | // is an error, never absence. |
| 1273 | func TestRegularFileDistinguishesAbsence(t *testing.T) { |
| 1274 | dir := t.TempDir() |
| 1275 | file := filepath.Join(dir, "a.db") |
| 1276 | if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { |
| 1277 | t.Fatal(err) |
| 1278 | } |
| 1279 | if ok, err := regularFile(file); !ok || err != nil { |
| 1280 | t.Fatalf("a file: %v %v", ok, err) |
| 1281 | } |
| 1282 | if ok, err := regularFile(filepath.Join(dir, "absent.db")); ok || err != nil { |
| 1283 | t.Fatalf("absent: %v %v", ok, err) |
| 1284 | } |
| 1285 | if ok, err := regularFile(filepath.Join(file, "under-a-file.db")); ok || err == nil { |
| 1286 | t.Fatalf("a path under a file: %v %v", ok, err) |
| 1287 | } |
| 1288 | if ok, err := regularFile(dir); ok || err == nil { |
| 1289 | t.Fatalf("a directory: %v %v", ok, err) |
| 1290 | } |
| 1291 | } |
| 1292 | |
| 1293 | // Ruling 4: a listing error is an error, never absence. With the sealed |
| 1294 | // file gone, a replay whose ListVersions fails changes nothing: the record |
| 1295 | // stays staged, the journalled identity stands, nothing is rebuilt and |
| 1296 | // nothing reverts. The next replay, listing again, finishes the identity |
| 1297 | // when something was published, and reverts when nothing was. |
| 1298 | func TestArchiveListingFailureIsNotAbsence(t *testing.T) { |
| 1299 | t.Parallel() |
| 1300 | for _, tc := range []struct { |
| 1301 | name, crash string |
| 1302 | then string |
| 1303 | }{ |
| 1304 | {"the sealed snapshot landed", "archive-sealed-snapshot", provision.MoveLanding}, |
| 1305 | {"nothing landed", "archive-journaled", provision.MoveReverting}, |
| 1306 | } { |
| 1307 | t.Run(tc.name, func(t *testing.T) { |
| 1308 | h := newCutHarness(t) |
| 1309 | h.ready(t) |
| 1310 | h.writeOnStop(t) |
| 1311 | h.crashAt = tc.crash |
| 1312 | h.cut(t) |
| 1313 | h.crashPass(t) |
| 1314 | first := h.journalArchive(t) |
| 1315 | if err := os.RemoveAll(archiveDir(h.cfg.DataDir, p1Host, p1ID)); err != nil { |
| 1316 | t.Fatal(err) |
| 1317 | } |
| 1318 | failed := 0 |
| 1319 | h.vs.deny = func(op, _, key string) error { |
| 1320 | if op == "list-versions" && strings.Contains(key, moves.SnapshotsPrefix(p1Host, p1ID)) { |
| 1321 | failed++ |
| 1322 | return errors.New("injected listing failure") |
| 1323 | } |
| 1324 | return nil |
| 1325 | } |
| 1326 | h.freshWorker() |
| 1327 | h.pass(t) |
| 1328 | if failed == 0 { |
| 1329 | t.Fatal("the replay never listed the snapshots") |
| 1330 | } |
| 1331 | m := h.move(t) |
| 1332 | if m.State != provision.MoveCutting || m.CutPhase != provision.CutStaged { |
| 1333 | t.Fatalf("after the listing failure: %+v", m) |
| 1334 | } |
| 1335 | if again := h.journalArchive(t); again.Sealed != first.Sealed { |
| 1336 | t.Fatalf("the listing failure changed the identity from %s to %s", first.Name, again.Name) |
| 1337 | } |
| 1338 | if h.seal(t).HasFiles() { |
| 1339 | t.Fatal("the seal names files") |
| 1340 | } |
| 1341 | h.vs.deny = nil |
| 1342 | h.freshWorker() |
| 1343 | h.pass(t) |
| 1344 | m = h.move(t) |
| 1345 | if m.State != tc.then { |
| 1346 | t.Fatalf("after the listing succeeded: %+v", m) |
| 1347 | } |
| 1348 | if tc.then == provision.MoveLanding && (m.Source.Files.Sealed != first.Sealed || !sealNamesArchive(h.seal(t), first.Sealed)) { |
| 1349 | t.Fatalf("not the journalled identity: %+v", m.Source.Files) |
| 1350 | } |
| 1351 | }) |
| 1352 | } |
| 1353 | } |
| 1354 | |
| 1355 | // Ruling 5: the archive seal's bound goes into the ledger as well as the |
| 1356 | // seal, and a ledger that cannot be read or written stops the phase. An |
| 1357 | // injected ledger-read failure after the archive is sealed leaves the |
| 1358 | // record staged and the ledger's bound as the stopped seal wrote it; the |
| 1359 | // next pass finishes and the ledger carries the archive seal's bound. |
| 1360 | func TestArchiveSealStopsOnALedgerFailure(t *testing.T) { |
| 1361 | h := newCutHarness(t) |
| 1362 | h.ready(t) |
| 1363 | ledgerKey := moves.LedgerKey(p1Host, p1ID) |
| 1364 | armed := false |
| 1365 | h.cfg.cutFault = func(point string) error { |
| 1366 | if point == "after-archive-seal" && !armed { |
| 1367 | armed = true |
| 1368 | h.f.failGet = func(_, key string) error { |
| 1369 | if key == ledgerKey { |
| 1370 | return errors.New("injected ledger read failure") |
| 1371 | } |
| 1372 | return nil |
| 1373 | } |
| 1374 | } |
| 1375 | return nil |
| 1376 | } |
| 1377 | h.cut(t) |
| 1378 | h.pass(t) |
| 1379 | if !armed { |
| 1380 | t.Fatal("the pass never sealed the archive") |
| 1381 | } |
| 1382 | m := h.move(t) |
| 1383 | if m.State != provision.MoveCutting || m.CutPhase != provision.CutStaged { |
| 1384 | t.Fatalf("the phase advanced over a ledger failure: %+v", m) |
| 1385 | } |
| 1386 | sealBound := h.seal(t).LeaseUntil |
| 1387 | h.f.failGet = nil |
| 1388 | // The ledger still carries the stopped seal's bound, written before the |
| 1389 | // archive: never later than the archive seal's. |
| 1390 | before := h.ledger(t) |
| 1391 | if before.LeaseUntil.IsZero() || before.LeaseUntil.After(sealBound) { |
| 1392 | t.Fatalf("ledger bound %v against the archive seal's %v", before.LeaseUntil, sealBound) |
| 1393 | } |
| 1394 | h.freshWorker() |
| 1395 | h.pass(t) |
| 1396 | if m := h.move(t); m.State != provision.MoveLanding { |
| 1397 | t.Fatalf("the replay: %+v", m) |
| 1398 | } |
| 1399 | after := h.ledger(t) |
| 1400 | if seal := h.seal(t); !after.LeaseUntil.Equal(seal.LeaseUntil) || after.LeaseUntil.Before(before.LeaseUntil) { |
| 1401 | t.Fatalf("ledger bound %v, seal bound %v (was %v)", after.LeaseUntil, seal.LeaseUntil, before.LeaseUntil) |
| 1402 | } |
| 1403 | } |
| 1404 | |
| 1405 | // Ruling 6: the crash matrix for the side effects the review found |
| 1406 | // unexercised. Each point crashes once; the durable state a fresh worker |
| 1407 | // would find is asserted; the fresh worker replays to the flip, with one |
| 1408 | // report, a seal naming the archive at the record's N, and the ledger |
| 1409 | // carrying the seal's bound. |
| 1410 | func TestCutCrashMatrix(t *testing.T) { |
| 1411 | t.Parallel() |
| 1412 | ctx := context.Background() |
| 1413 | lane := moves.ReplicaPrefix(p1Host, p1ID) |
| 1414 | for _, point := range []string{"after-evidence", "after-final-round", "after-seal", "archive-sealed-snapshot"} { |
| 1415 | t.Run(point, func(t *testing.T) { |
| 1416 | h := newCutHarness(t) |
| 1417 | h.ready(t) |
| 1418 | h.writeOnStop(t) // so the archive is the cut's own build |
| 1419 | h.crashAt = point |
| 1420 | h.cut(t) |
| 1421 | h.crashPass(t) |
| 1422 | m := h.move(t) |
| 1423 | c := h.claim(t) |
| 1424 | j, err := moves.ReadJournal(h.cfg.DataDir, p1Host, p1ID) |
| 1425 | if err != nil { |
| 1426 | t.Fatal(err) |
| 1427 | } |
| 1428 | _, _, reportErr := h.s.Get(ctx, h.stoppedKey()) |
| 1429 | _, sealed := c.Op.Journal[hostclaim.JournalSeal] |
| 1430 | switch point { |
| 1431 | case "after-evidence": |
| 1432 | // stopping: the evidence journalled, execution still the |
| 1433 | // source's, no report, no hold given up. |
| 1434 | if m.CutPhase != provision.CutStopping || !j.Done(cutStepEvidence) || c.Op.Exec != "ie-box" || !errors.Is(reportErr, os.ErrNotExist) { |
| 1435 | t.Fatalf("durable state: %+v, evidence %v, exec %q, report %v", m, j.Done(cutStepEvidence), c.Op.Exec, reportErr) |
| 1436 | } |
| 1437 | case "after-final-round": |
| 1438 | // stopped: execution given up, the report published, the |
| 1439 | // lane holding the final round's copy, and no seal yet. |
| 1440 | if m.CutPhase != provision.CutStopped || c.Op.Exec != "" || c.Op.Phase != hostclaim.PhaseMoving || reportErr != nil || sealed { |
| 1441 | t.Fatalf("durable state: %+v, claim %+v, report %v", m, c.Op, reportErr) |
| 1442 | } |
| 1443 | if _, ok := h.f.buckets["data-uk"][lane+lastTXKey]; !ok { |
| 1444 | t.Fatal("the final round's copy is not in the lane") |
| 1445 | } |
| 1446 | case "after-seal": |
| 1447 | // stopped: the claim sealed with a seal naming the replica and |
| 1448 | // the timers but no files; the ledger without a bound yet. |
| 1449 | if m.CutPhase != provision.CutStopped || c.Op.Phase != hostclaim.PhaseSealed || !sealed { |
| 1450 | t.Fatalf("durable state: %+v, claim %+v", m, c.Op) |
| 1451 | } |
| 1452 | if seal := h.seal(t); seal.HasFiles() || seal.N != 3 || len(seal.Timers) != 1 { |
| 1453 | t.Fatalf("seal: %+v", seal) |
| 1454 | } |
| 1455 | if l := h.ledger(t); !l.LeaseUntil.IsZero() { |
| 1456 | t.Fatalf("the ledger carries a bound (%v) before the seal's pass finished", l.LeaseUntil) |
| 1457 | } |
| 1458 | case "archive-sealed-snapshot": |
| 1459 | // staged: the identity journalled, the sealed object in |
| 1460 | // move-snapshots/ and the manifest not, the seal without files. |
| 1461 | id := h.journalArchive(t) |
| 1462 | snap := moves.SnapshotsPrefix(p1Host, p1ID) |
| 1463 | _, sealedThere := h.f.buckets["data-ie"][snap+id.Name+".sealed"] |
| 1464 | _, manifestThere := h.f.buckets["data-ie"][snap+id.Name+".json"] |
| 1465 | if m.CutPhase != provision.CutStaged || !sealedThere || manifestThere || h.seal(t).HasFiles() { |
| 1466 | t.Fatalf("durable state: %+v, sealed %v, manifest %v", m, sealedThere, manifestThere) |
| 1467 | } |
| 1468 | } |
| 1469 | h.freshWorker() |
| 1470 | h.pass(t) |
| 1471 | m = h.move(t) |
| 1472 | if m.State != provision.MoveLanding || m.CutPhase != provision.CutFlipped { |
| 1473 | t.Fatalf("the replay: %+v", m) |
| 1474 | } |
| 1475 | keys, _ := h.s.List(ctx, moves.ReportPrefix("bab", "app", p1Host, p1ID)) |
| 1476 | n := 0 |
| 1477 | for _, k := range keys { |
| 1478 | if strings.Contains(k, "stopped-") { |
| 1479 | n++ |
| 1480 | } |
| 1481 | } |
| 1482 | seal := h.seal(t) |
| 1483 | if n != 1 || !sealNamesArchive(seal, m.Source.Files.Sealed) || seal.N != m.Source.FinalTXID || !seal.ReachesN() { |
| 1484 | t.Fatalf("%d reports; seal %+v; source %+v", n, seal, m.Source) |
| 1485 | } |
| 1486 | for _, o := range append(append(append([]moves.SealedObject{}, seal.Replica...), seal.Timers...), seal.Files...) { |
| 1487 | if h.f.versionOf("data-uk", o.Key) != o.VersionID { |
| 1488 | t.Fatalf("the seal names %s@%s, current %s", o.Key, o.VersionID, h.f.versionOf("data-uk", o.Key)) |
| 1489 | } |
| 1490 | } |
| 1491 | if l := h.ledger(t); !l.LeaseUntil.Equal(seal.LeaseUntil) { |
| 1492 | t.Fatalf("ledger bound %v, seal bound %v", l.LeaseUntil, seal.LeaseUntil) |
| 1493 | } |
| 1494 | if c := h.claim(t); c.Op.Kind != hostclaim.KindLanding || c.Op.Exec != "uk-box" { |
| 1495 | t.Fatalf("claim: %+v", c.Op) |
| 1496 | } |
| 1497 | }) |
| 1498 | } |
| 1499 | } |
| 1500 | |
| 1501 | // Fix round 2, item 1: a loss is declared only when it is established. The |
| 1502 | // sealed file alone, with the journalled manifest, is enough to publish |
| 1503 | // the identity: losing only the local manifest before the first PUT does |
| 1504 | // not revert. A read of the sealed file that fails is an error, never a |
| 1505 | // loss: the record stays staged, the identity stands, and once the file |
| 1506 | // reads again the replay finishes it. |
| 1507 | func TestArchiveLossIsDeclaredOnlyWhenEstablished(t *testing.T) { |
| 1508 | t.Parallel() |
| 1509 | t.Run("only the manifest is gone", func(t *testing.T) { |
| 1510 | h := newCutHarness(t) |
| 1511 | h.ready(t) |
| 1512 | h.writeOnStop(t) |
| 1513 | h.crashAt = "archive-journaled" |
| 1514 | h.cut(t) |
| 1515 | h.crashPass(t) |
| 1516 | first := h.journalArchive(t) |
| 1517 | if err := os.Remove(first.manifestPath()); err != nil { |
| 1518 | t.Fatal(err) |
| 1519 | } |
| 1520 | h.freshWorker() |
| 1521 | h.pass(t) |
| 1522 | m := h.move(t) |
| 1523 | if m.State != provision.MoveLanding || m.Source.Files.Sealed != first.Sealed || !sealNamesArchive(h.seal(t), first.Sealed) { |
| 1524 | t.Fatalf("a kept sealed file with its manifest journalled: %+v files %+v", m, m.Source.Files) |
| 1525 | } |
| 1526 | snap := moves.SnapshotsPrefix(p1Host, p1ID) |
| 1527 | if _, ok := h.f.buckets["data-ie"][snap+first.Name+".json"]; !ok { |
| 1528 | t.Fatal("the manifest was not published from the journal") |
| 1529 | } |
| 1530 | }) |
| 1531 | t.Run("the sealed file cannot be read", func(t *testing.T) { |
| 1532 | h := newCutHarness(t) |
| 1533 | h.ready(t) |
| 1534 | h.writeOnStop(t) |
| 1535 | h.crashAt = "archive-journaled" |
| 1536 | h.cut(t) |
| 1537 | h.crashPass(t) |
| 1538 | first := h.journalArchive(t) |
| 1539 | // The sealed path answers a read error (EISDIR), not absence. |
| 1540 | kept, err := os.ReadFile(first.sealedPath()) |
| 1541 | if err != nil { |
| 1542 | t.Fatal(err) |
| 1543 | } |
| 1544 | if err := os.Remove(first.sealedPath()); err != nil { |
| 1545 | t.Fatal(err) |
| 1546 | } |
| 1547 | if err := os.Mkdir(first.sealedPath(), 0o700); err != nil { |
| 1548 | t.Fatal(err) |
| 1549 | } |
| 1550 | h.freshWorker() |
| 1551 | h.pass(t) |
| 1552 | m := h.move(t) |
| 1553 | if m.State != provision.MoveCutting || m.CutPhase != provision.CutStaged { |
| 1554 | t.Fatalf("a read error decided the archive: %+v", m) |
| 1555 | } |
| 1556 | if again := h.journalArchive(t); again.Sealed != first.Sealed { |
| 1557 | t.Fatalf("the identity changed from %s to %s", first.Name, again.Name) |
| 1558 | } |
| 1559 | if got := snapshotSealed(h); len(got) != 0 { |
| 1560 | t.Fatalf("snapshots %v, want none", got) |
| 1561 | } |
| 1562 | if err := os.Remove(first.sealedPath()); err != nil { |
| 1563 | t.Fatal(err) |
| 1564 | } |
| 1565 | if err := os.WriteFile(first.sealedPath(), kept, 0o600); err != nil { |
| 1566 | t.Fatal(err) |
| 1567 | } |
| 1568 | h.freshWorker() |
| 1569 | h.pass(t) |
| 1570 | m = h.move(t) |
| 1571 | if m.State != provision.MoveLanding || m.Source.Files.Sealed != first.Sealed || !sealNamesArchive(h.seal(t), first.Sealed) { |
| 1572 | t.Fatalf("once the file read again: %+v files %+v", m, m.Source.Files) |
| 1573 | } |
| 1574 | }) |
| 1575 | } |
| 1576 | |
| 1577 | // Fix round 2, item 2: an automatic move on a scoped holder does not loop. |
| 1578 | // decideReady never repins or requests a cut for a holder whose location is |
| 1579 | // not the activator's prefix, so repeated passes write nothing: the record's |
| 1580 | // stamp and the claim's generation stand. |
| 1581 | func TestScopedAutomaticMoveDoesNotLoop(t *testing.T) { |
| 1582 | h := newCutHarness(t) |
| 1583 | h.scoped = true |
| 1584 | h.start(t, "auto") |
| 1585 | h.pass(t) |
| 1586 | h.targetPass(t, h.target()) |
| 1587 | h.pass(t) |
| 1588 | rec := h.read(t, p1Host) |
| 1589 | if rec.Move.State == provision.MoveCutting { |
| 1590 | t.Fatalf("a cut was requested for a scoped holder: %+v", rec.Move) |
| 1591 | } |
| 1592 | claimGen := h.claim(t).Gen |
| 1593 | for i := 0; i < 3; i++ { |
| 1594 | h.pass(t) |
| 1595 | again := h.read(t, p1Host) |
| 1596 | if again.Move.State == provision.MoveCutting || again.Move.UpdatedAt != rec.Move.UpdatedAt || again.Move.State != rec.Move.State { |
| 1597 | t.Fatalf("pass %d wrote the record: %+v (was %+v)", i, again.Move, rec.Move) |
| 1598 | } |
| 1599 | if h.claim(t).Gen != claimGen { |
| 1600 | t.Fatalf("pass %d wrote the claim", i) |
| 1601 | } |
| 1602 | } |
| 1603 | if h.ev.calls != 0 || len(h.holds) != 0 { |
| 1604 | t.Fatal("something stopped") |
| 1605 | } |
| 1606 | } |
| 1607 | |