| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "path/filepath" |
| 11 | "slices" |
| 12 | "strings" |
| 13 | "sync" |
| 14 | "sync/atomic" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/aws/aws-sdk-go-v2/service/sns" |
| 19 | "github.com/carlosframework/platform/internal/alerting" |
| 20 | "github.com/carlosframework/platform/internal/hostclaim" |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | ) |
| 23 | |
| 24 | func lossPageRecord(id string, pending bool, knowledge hostclaim.TimerKnowledge) hostclaim.LossRecord { |
| 25 | at := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) |
| 26 | r := hostclaim.LossRecord{V: 1, ID: id, State: "prepared", Actor: "operator", AcceptedAt: at, |
| 27 | Host: "host.example", HolderIncarnation: strings.Repeat("a", 64), HolderStamp: 1, |
| 28 | ExpectedClaimGen: 1, ExpectedClaimETag: "claim", OpID: "operation", OpKind: hostclaim.KindMoving, |
| 29 | OpPhase: "precommit", Box: "box-a", Enrollment: "enrollment", QuarantineRevision: 1, FencedAt: at, |
| 30 | CloudObservation: hostclaim.LossCloudObservation{ObservedAt: at, Account: "123", Region: "eu-west-1", InstanceID: "i-1", State: "stopped", RequestID: "request"}, |
| 31 | OperatorAttestation: hostclaim.LossOperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true}, |
| 32 | ConfirmedConsequences: hostclaim.LossConsequences{RecoveryIntent: json.RawMessage(`{"kind":"lost-target"}`)}, |
| 33 | AlertsPending: pending, Timers: hostclaim.TimerLoss{Knowledge: knowledge}} |
| 34 | if knowledge != hostclaim.TimersUnknown { |
| 35 | r.Timers.Source = &hostclaim.TimerSource{Bucket: "bucket", Key: "timers", VersionID: "v1", SHA256: strings.Repeat("b", 64), CapturedAt: at} |
| 36 | } |
| 37 | return r |
| 38 | } |
| 39 | |
| 40 | func lossPageWorld(t *testing.T) (*Server, *release.DirStore, release.Signer, *[]map[string]string) { |
| 41 | t.Helper() |
| 42 | raw := release.NewDirStore(t.TempDir()) |
| 43 | _, key, err := ed25519.GenerateKey(nil) |
| 44 | if err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | publishRestoreRoster(t, raw, key) |
| 48 | s := newEnabledRestoreConsole(t, raw, key, crConsole) |
| 49 | var pages []map[string]string |
| 50 | alerting.Arm(&alerting.Sinks{WebhookURL: "http://unused", Post: func(_ string, body []byte) error { |
| 51 | var page map[string]string |
| 52 | if err := json.Unmarshal(body, &page); err != nil { |
| 53 | return err |
| 54 | } |
| 55 | pages = append(pages, page) |
| 56 | return nil |
| 57 | }}) |
| 58 | t.Cleanup(func() { alerting.Arm(nil) }) |
| 59 | return s, raw, release.RootSigner(key), &pages |
| 60 | } |
| 61 | |
| 62 | func putLossPageRecord(t *testing.T, raw release.Store, signer release.Signer, r hostclaim.LossRecord) { |
| 63 | t.Helper() |
| 64 | if _, err := hostclaim.CreateLossRecord(t.Context(), raw, signer, r); err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | func TestClaimLossPagesIntervalAndRestart(t *testing.T) { |
| 70 | s, raw, signer, pages := lossPageWorld(t) |
| 71 | for _, id := range []string{"loss-one", "loss-two"} { |
| 72 | putLossPageRecord(t, raw, signer, lossPageRecord(id, true, hostclaim.TimersComplete)) |
| 73 | } |
| 74 | now := time.Date(2026, 10, 12, 13, 0, 0, 123, time.UTC) |
| 75 | pass := func() { |
| 76 | t.Helper() |
| 77 | if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return now }); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | } |
| 81 | pass() |
| 82 | pass() |
| 83 | if len(*pages) != 2 { |
| 84 | t.Fatalf("first passes: %v", *pages) |
| 85 | } |
| 86 | // Reopen the actual SQLite file; no in-memory limiter survives this. |
| 87 | path := s.store.gate.path |
| 88 | if err := s.store.Close(); err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | st, err := OpenGated(path) |
| 92 | if err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | t.Cleanup(func() { st.Close() }) |
| 96 | s = &Server{cfg: s.cfg, store: st} |
| 97 | now = now.Add(time.Hour - time.Nanosecond) |
| 98 | pass() |
| 99 | if len(*pages) != 2 { |
| 100 | t.Fatalf("restart lost rate limit: %v", *pages) |
| 101 | } |
| 102 | now = now.Add(time.Nanosecond) |
| 103 | pass() |
| 104 | if len(*pages) != 4 { |
| 105 | t.Fatalf("interval did not repeat each record: %v", *pages) |
| 106 | } |
| 107 | key, _ := hostclaim.LossRecordKey("loss-one") |
| 108 | if err := raw.Delete(t.Context(), key); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | now = now.Add(time.Hour) |
| 112 | pass() |
| 113 | if len(*pages) != 5 || !strings.Contains((*pages)[4]["body"], "loss-two") { |
| 114 | t.Fatalf("absent pending record paged: %v", *pages) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | func TestClaimLossPagesClearedAndTimers(t *testing.T) { |
| 119 | for _, knowledge := range []hostclaim.TimerKnowledge{hostclaim.TimersComplete, hostclaim.TimersPartial, hostclaim.TimersUnknown} { |
| 120 | t.Run(string(knowledge), func(t *testing.T) { |
| 121 | s, raw, signer, pages := lossPageWorld(t) |
| 122 | r := lossPageRecord("loss-timer", false, knowledge) |
| 123 | putLossPageRecord(t, raw, signer, r) |
| 124 | now := r.AcceptedAt |
| 125 | if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return now }); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | want := 1 |
| 129 | if knowledge == hostclaim.TimersComplete { |
| 130 | want = 0 |
| 131 | } |
| 132 | if len(*pages) != want { |
| 133 | t.Fatalf("pages: %v", *pages) |
| 134 | } |
| 135 | if want != 0 && (!strings.Contains((*pages)[0]["body"], r.Host) || !strings.Contains((*pages)[0]["body"], "re-arm")) { |
| 136 | t.Fatalf("timer instructions: %v", *pages) |
| 137 | } |
| 138 | key, _ := hostclaim.LossRecordKey(r.ID) |
| 139 | if err := raw.Delete(t.Context(), key); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return now.Add(2 * time.Hour) }); err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | if len(*pages) != want { |
| 146 | t.Fatal("cleared record paged") |
| 147 | } |
| 148 | }) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func TestClaimLossPagesRecoveryWiringWithoutMovesOrClaims(t *testing.T) { |
| 153 | s, raw, signer, pages := lossPageWorld(t) |
| 154 | putLossPageRecord(t, raw, signer, lossPageRecord("orphan-loss", true, hostclaim.TimersComplete)) |
| 155 | if s.cfg.MovesEnabled { |
| 156 | t.Fatal("fixture enabled moves") |
| 157 | } |
| 158 | if err := s.RunClaimRecoveryPass(t.Context()); err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | waitClaimLossPages(t, s) |
| 162 | if len(*pages) != 1 { |
| 163 | t.Fatalf("record without a claim was not paged: %v", *pages) |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | func TestClaimLossPagesFlagOff(t *testing.T) { |
| 168 | calls := 0 |
| 169 | alerting.Arm(&alerting.Sinks{WebhookURL: "http://unused", Post: func(string, []byte) error { calls++; return nil }}) |
| 170 | t.Cleanup(func() { alerting.Arm(nil) }) |
| 171 | s := &Server{} |
| 172 | if err := s.pageClaimLossesAt(t.Context(), time.Now); err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | if err := s.RunClaimRecoveryPass(t.Context()); err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | s.scheduleClaimLossPages(t.Context()) |
| 179 | if s.claimLossPageDone != nil { |
| 180 | t.Fatal("disabled runtime started a worker") |
| 181 | } |
| 182 | if calls != 0 { |
| 183 | t.Fatal("disabled runtime paged") |
| 184 | } |
| 185 | st, err := Open(filepath.Join(t.TempDir(), "off.db")) |
| 186 | if err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | defer st.Close() |
| 190 | var count int |
| 191 | if err := st.db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE name = 'claim_loss_pages'`).Scan(&count); err != nil || count != 0 { |
| 192 | t.Fatalf("flag-off schema changed: %d %v", count, err) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | func TestClaimLossPageFailedDeliveryIsRateLimited(t *testing.T) { |
| 197 | s, raw, signer, _ := lossPageWorld(t) |
| 198 | putLossPageRecord(t, raw, signer, lossPageRecord("failed-page", true, hostclaim.TimersComplete)) |
| 199 | calls := 0 |
| 200 | failure := errors.New("delivery failed") |
| 201 | alerting.Arm(&alerting.Sinks{WebhookURL: "http://unused", Post: func(string, []byte) error { calls++; return failure }}) |
| 202 | now := time.Now() |
| 203 | if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return now }); !errors.Is(err, failure) { |
| 204 | t.Fatalf("delivery: %v", err) |
| 205 | } |
| 206 | if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return now.Add(time.Minute) }); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | if calls != 1 { |
| 210 | t.Fatalf("attempts: %d", calls) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | func TestClaimLossPagesAutomaticReportingDoesNotAcknowledge(t *testing.T) { |
| 215 | s, raw, _, pages := lossPageWorld(t) |
| 216 | ref := hostclaim.FenceRef{Enrollment: strings.Repeat("a", 32), SHA256: strings.Repeat("b", 64), Key: "control/box-fences/" + strings.Repeat("a", 32) + ".json"} |
| 217 | sum := sha256.Sum256([]byte("operation\x00box-a\x00" + ref.Enrollment + "\x00" + ref.SHA256)) |
| 218 | id := "auto-" + hex.EncodeToString(sum[:16]) |
| 219 | loss := hostclaim.AutomaticLoss{ID: id, Box: "box-a", Enrollment: ref.Enrollment, Fence: ref, |
| 220 | ObservedAt: time.Now().UTC().Format(time.RFC3339Nano), Op: "operation", Kind: hostclaim.KindLanding, |
| 221 | Phase: hostclaim.PhaseProbing, Role: "target", Incarnation: strings.Repeat("c", 64), Stamp: 1, Timers: hostclaim.TimersUnknown} |
| 222 | _, err := hostclaim.Update(t.Context(), raw, hostclaim.Observed{Host: "host.example", Absent: true}, func(c *hostclaim.Claim) error { |
| 223 | c.LossRefs = []string{id} |
| 224 | c.EvidenceRefs = []hostclaim.AutomaticLoss{loss} |
| 225 | return nil |
| 226 | }) |
| 227 | if err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | if err := s.RunClaimRecoveryPass(t.Context()); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | waitClaimLossPages(t, s) |
| 234 | if len(*pages) != 2 { |
| 235 | t.Fatalf("materialized record must page both conditions in the same pass: %v", *pages) |
| 236 | } |
| 237 | c, err := hostclaim.Read(t.Context(), raw, "host.example") |
| 238 | if err != nil || !c.Claim.EvidenceRefs[0].Reported { |
| 239 | t.Fatalf("reporting did not finish: %+v %v", c, err) |
| 240 | } |
| 241 | if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return time.Now().Add(2 * time.Hour) }); err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | if len(*pages) != 4 { |
| 245 | t.Fatal("claim reporting silenced pending operator alerts") |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | func TestClaimLossPageReservationSerializesPasses(t *testing.T) { |
| 250 | st := openGatedStore(t) |
| 251 | now := time.Now() |
| 252 | var wg sync.WaitGroup |
| 253 | var attempts atomic.Int64 |
| 254 | for i := 0; i < 12; i++ { |
| 255 | wg.Add(1) |
| 256 | go func() { |
| 257 | defer wg.Done() |
| 258 | ok, err := st.ClaimLossPageAttempt(t.Context(), "pending loss", now) |
| 259 | if err != nil { |
| 260 | t.Error(err) |
| 261 | } |
| 262 | if ok { |
| 263 | attempts.Add(1) |
| 264 | } |
| 265 | }() |
| 266 | } |
| 267 | wg.Wait() |
| 268 | if attempts.Load() != 1 { |
| 269 | t.Fatalf("reserved %d attempts", attempts.Load()) |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | func TestClaimLossPagesRejectBadEvidenceAndContinue(t *testing.T) { |
| 274 | s, raw, signer, pages := lossPageWorld(t) |
| 275 | if _, err := raw.Put(t.Context(), "control/host-loss/bad.json", []byte(`{}`), ""); err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | putLossPageRecord(t, raw, signer, lossPageRecord("valid", true, hostclaim.TimersComplete)) |
| 279 | if err := s.pageClaimLossesAt(t.Context(), time.Now); !errors.Is(err, hostclaim.ErrLossRecord) { |
| 280 | t.Fatalf("invalid signature accepted: %v", err) |
| 281 | } |
| 282 | if len(*pages) != 1 { |
| 283 | t.Fatalf("bad record blocked good record: %v", *pages) |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | func TestClaimLossPagesJournalFailureDoesNotSend(t *testing.T) { |
| 288 | s, raw, signer, pages := lossPageWorld(t) |
| 289 | putLossPageRecord(t, raw, signer, lossPageRecord("loss", true, hostclaim.TimersComplete)) |
| 290 | if err := s.store.Close(); err != nil { |
| 291 | t.Fatal(err) |
| 292 | } |
| 293 | if err := s.pageClaimLossesAt(t.Context(), time.Now); err == nil { |
| 294 | t.Fatal("journal error hidden") |
| 295 | } |
| 296 | if len(*pages) != 0 { |
| 297 | t.Fatal("sent without durable reservation") |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | func waitClaimLossPages(t *testing.T, s *Server) { |
| 302 | t.Helper() |
| 303 | s.claimLossPageMu.Lock() |
| 304 | done := s.claimLossPageDone |
| 305 | s.claimLossPageMu.Unlock() |
| 306 | if done == nil { |
| 307 | t.Fatal("no loss paging worker scheduled") |
| 308 | } |
| 309 | select { |
| 310 | case <-done: |
| 311 | case <-time.After(5 * time.Second): |
| 312 | t.Fatal("loss paging worker did not finish") |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | func TestClaimLossPagesHourlyToDailyAcrossReopen(t *testing.T) { |
| 317 | s, raw, signer, pages := lossPageWorld(t) |
| 318 | r := lossPageRecord("old-loss", true, hostclaim.TimersUnknown) |
| 319 | putLossPageRecord(t, raw, signer, r) |
| 320 | first := r.AcceptedAt.Add(30 * 24 * time.Hour) |
| 321 | pass := func(at time.Time, want int) { |
| 322 | t.Helper() |
| 323 | if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return at }); err != nil { |
| 324 | t.Fatal(err) |
| 325 | } |
| 326 | if len(*pages) != want { |
| 327 | t.Fatalf("at %s: got %d pages, want %d", at.Sub(first), len(*pages), want) |
| 328 | } |
| 329 | } |
| 330 | for hour := 0; hour < 24; hour++ { |
| 331 | pass(first.Add(time.Duration(hour)*time.Hour), 2*(hour+1)) |
| 332 | } |
| 333 | path := s.store.gate.path |
| 334 | if err := s.store.Close(); err != nil { |
| 335 | t.Fatal(err) |
| 336 | } |
| 337 | st, err := OpenGated(path) |
| 338 | if err != nil { |
| 339 | t.Fatal(err) |
| 340 | } |
| 341 | t.Cleanup(func() { st.Close() }) |
| 342 | s = &Server{cfg: s.cfg, store: st} |
| 343 | pass(first.Add(24*time.Hour-time.Nanosecond), 48) |
| 344 | // Once the first 24 hours end, the last attempt must be a day old. |
| 345 | pass(first.Add(24*time.Hour), 48) |
| 346 | pass(first.Add(47*time.Hour-time.Nanosecond), 48) |
| 347 | pass(first.Add(47*time.Hour), 50) |
| 348 | pass(first.Add(71*time.Hour-time.Nanosecond), 50) |
| 349 | pass(first.Add(71*time.Hour), 52) |
| 350 | for _, condition := range []string{"pending", "timers"} { |
| 351 | var saved int64 |
| 352 | if err := st.db.QueryRow(`SELECT first_attempted_at FROM claim_loss_pages WHERE rule = ?`, condition+" "+r.ID).Scan(&saved); err != nil || saved != first.UnixNano() { |
| 353 | t.Fatalf("first %s attempt changed: %d %v", condition, saved, err) |
| 354 | } |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | func TestClaimLossPagesClockAtEachReservation(t *testing.T) { |
| 359 | s, raw, signer, _ := lossPageWorld(t) |
| 360 | for _, id := range []string{"a-early", "b-late"} { |
| 361 | putLossPageRecord(t, raw, signer, lossPageRecord(id, true, hostclaim.TimersComplete)) |
| 362 | } |
| 363 | first := time.Now() |
| 364 | now := first |
| 365 | late := 0 |
| 366 | calls := 0 |
| 367 | alerting.Arm(&alerting.Sinks{WebhookURL: "http://unused", Post: func(_ string, b []byte) error { |
| 368 | calls++ |
| 369 | if strings.Contains(string(b), "b-late") { |
| 370 | late++ |
| 371 | } |
| 372 | if calls == 1 { |
| 373 | now = now.Add(time.Hour) |
| 374 | } |
| 375 | return nil |
| 376 | }}) |
| 377 | clock := func() time.Time { return now } |
| 378 | if err := s.pageClaimLossesAt(t.Context(), clock); err != nil { |
| 379 | t.Fatal(err) |
| 380 | } |
| 381 | now = now.Add(10 * time.Second) |
| 382 | if err := s.pageClaimLossesAt(t.Context(), clock); err != nil { |
| 383 | t.Fatal(err) |
| 384 | } |
| 385 | if late != 1 || calls != 3 { |
| 386 | t.Fatalf("late record retried ten seconds later: late=%d, total=%d", late, calls) |
| 387 | } |
| 388 | var reserved int64 |
| 389 | if err := s.store.db.QueryRow(`SELECT attempted_at FROM claim_loss_pages WHERE rule = 'pending b-late'`).Scan(&reserved); err != nil || reserved != first.Add(time.Hour).UnixNano() { |
| 390 | t.Fatalf("late reservation: %d %v", reserved, err) |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | func TestClaimLossPagesBlockedSinkDoesNotBlockRecovery(t *testing.T) { |
| 395 | s, raw, signer, _ := lossPageWorld(t) |
| 396 | putLossPageRecord(t, raw, signer, lossPageRecord("blocking", true, hostclaim.TimersComplete)) |
| 397 | entered, releaseSink := make(chan struct{}), make(chan struct{}) |
| 398 | var unblock sync.Once |
| 399 | var calls atomic.Int64 |
| 400 | alerting.Arm(&alerting.Sinks{WebhookURL: "http://unused", Post: func(string, []byte) error { |
| 401 | if calls.Add(1) == 1 { |
| 402 | close(entered) |
| 403 | <-releaseSink |
| 404 | } |
| 405 | return nil |
| 406 | }}) |
| 407 | t.Cleanup(func() { |
| 408 | unblock.Do(func() { close(releaseSink) }) |
| 409 | waitClaimLossPages(t, s) |
| 410 | }) |
| 411 | var records, purges, admissions atomic.Int64 |
| 412 | wrap := func(fn func(context.Context) error, count *atomic.Int64) func(context.Context) error { |
| 413 | return func(ctx context.Context) error { count.Add(1); return fn(ctx) } |
| 414 | } |
| 415 | r := s.cfg.HostClaimRecovery |
| 416 | r.SweepRecords = wrap(r.SweepRecords, &records) |
| 417 | r.SweepPurges = wrap(r.SweepPurges, &purges) |
| 418 | r.SweepAdmissions = wrap(r.SweepAdmissions, &admissions) |
| 419 | pass := func() { |
| 420 | t.Helper() |
| 421 | done := make(chan error, 1) |
| 422 | go func() { done <- s.RunClaimRecoveryPass(t.Context()) }() |
| 423 | select { |
| 424 | case err := <-done: |
| 425 | if err != nil { |
| 426 | t.Fatal(err) |
| 427 | } |
| 428 | case <-time.After(2 * time.Second): |
| 429 | unblock.Do(func() { close(releaseSink) }) |
| 430 | <-done |
| 431 | t.Fatal("paging blocked recovery") |
| 432 | } |
| 433 | } |
| 434 | pass() |
| 435 | select { |
| 436 | case <-entered: |
| 437 | case <-time.After(2 * time.Second): |
| 438 | t.Fatal("sink was not called") |
| 439 | } |
| 440 | s.claimLossPageMu.Lock() |
| 441 | worker := s.claimLossPageDone |
| 442 | s.claimLossPageMu.Unlock() |
| 443 | for range 3 { |
| 444 | pass() |
| 445 | } |
| 446 | if records.Load() != 4 || purges.Load() != 4 || admissions.Load() != 4 { |
| 447 | t.Fatalf("sweeps delayed: records=%d purges=%d admissions=%d", records.Load(), purges.Load(), admissions.Load()) |
| 448 | } |
| 449 | s.claimLossPageMu.Lock() |
| 450 | overlapped := s.claimLossPageDone != worker |
| 451 | s.claimLossPageMu.Unlock() |
| 452 | if overlapped || calls.Load() != 1 { |
| 453 | t.Fatal("paging workers overlapped") |
| 454 | } |
| 455 | unblock.Do(func() { close(releaseSink) }) |
| 456 | waitClaimLossPages(t, s) |
| 457 | putLossPageRecord(t, raw, signer, lossPageRecord("next-worker", true, hostclaim.TimersComplete)) |
| 458 | pass() |
| 459 | waitClaimLossPages(t, s) |
| 460 | if calls.Load() != 2 { |
| 461 | t.Fatal("next recovery did not schedule a new paging worker") |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | func TestClaimLossPageJournalUpgrade(t *testing.T) { |
| 466 | path := filepath.Join(t.TempDir(), "old.db") |
| 467 | st, err := Open(path) |
| 468 | if err != nil { |
| 469 | t.Fatal(err) |
| 470 | } |
| 471 | at := time.Now().Add(-23 * time.Hour) |
| 472 | _, err = st.db.Exec(`CREATE TABLE claim_loss_pages (rule TEXT PRIMARY KEY, attempted_at INTEGER NOT NULL); |
| 473 | INSERT INTO claim_loss_pages VALUES ('pending old', ?)`, at.UnixNano()) |
| 474 | if err != nil { |
| 475 | t.Fatal(err) |
| 476 | } |
| 477 | if err := st.Close(); err != nil { |
| 478 | t.Fatal(err) |
| 479 | } |
| 480 | for range 2 { |
| 481 | st, err := OpenGated(path) |
| 482 | if err != nil { |
| 483 | t.Fatal(err) |
| 484 | } |
| 485 | var first, last int64 |
| 486 | err = st.db.QueryRow(`SELECT first_attempted_at, attempted_at FROM claim_loss_pages WHERE rule = 'pending old'`).Scan(&first, &last) |
| 487 | var cursor string |
| 488 | cursorErr := st.db.QueryRow(`SELECT last_key FROM claim_loss_pages WHERE rule = 'pending old'`).Scan(&cursor) |
| 489 | st.Close() |
| 490 | if err != nil || first != at.UnixNano() || last != at.UnixNano() { |
| 491 | t.Fatalf("upgrade lost durable attempt: %d %d %v", first, last, err) |
| 492 | } |
| 493 | if cursorErr != nil || cursor != "" { |
| 494 | t.Fatalf("upgrade scan progress: %q %v", cursor, cursorErr) |
| 495 | } |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | type lossPageDeadlineStore struct { |
| 500 | release.Store |
| 501 | deadline chan time.Time |
| 502 | } |
| 503 | |
| 504 | func (s lossPageDeadlineStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 505 | deadline, _ := ctx.Deadline() |
| 506 | s.deadline <- deadline |
| 507 | <-ctx.Done() |
| 508 | return nil, ctx.Err() |
| 509 | } |
| 510 | |
| 511 | func TestClaimLossPagesWorkerBudget(t *testing.T) { |
| 512 | s, _, _, pages := lossPageWorld(t) |
| 513 | deadline := make(chan time.Time, 1) |
| 514 | s.cfg.Releases = lossPageDeadlineStore{Store: s.cfg.Releases, deadline: deadline} |
| 515 | ctx, cancel := context.WithCancel(t.Context()) |
| 516 | defer cancel() |
| 517 | before := time.Now() |
| 518 | s.scheduleClaimLossPages(ctx) |
| 519 | select { |
| 520 | case at := <-deadline: |
| 521 | if at.Before(before.Add(claimLossPageBudget)) || at.After(time.Now().Add(claimLossPageBudget)) { |
| 522 | t.Errorf("scan has no per-pass budget: deadline=%v", at) |
| 523 | } |
| 524 | case <-time.After(time.Second): |
| 525 | t.Error("worker never started discovery") |
| 526 | } |
| 527 | cancel() |
| 528 | waitClaimLossPages(t, s) |
| 529 | if len(*pages) != 0 { |
| 530 | t.Fatal("canceled scan delivered a page") |
| 531 | } |
| 532 | s.claimLossPageMu.Lock() |
| 533 | done := s.claimLossPageDone |
| 534 | s.claimLossPageMu.Unlock() |
| 535 | s.scheduleClaimLossPages(ctx) |
| 536 | if s.claimLossPageDone != done { |
| 537 | t.Fatal("canceled recovery scheduled another worker") |
| 538 | } |
| 539 | } |
| 540 | |
| 541 | type lossPageBlockingSNS struct { |
| 542 | entered chan time.Time |
| 543 | } |
| 544 | |
| 545 | func (p lossPageBlockingSNS) Publish(ctx context.Context, _ *sns.PublishInput, _ ...func(*sns.Options)) (*sns.PublishOutput, error) { |
| 546 | deadline, _ := ctx.Deadline() |
| 547 | p.entered <- deadline |
| 548 | <-ctx.Done() |
| 549 | return nil, ctx.Err() |
| 550 | } |
| 551 | |
| 552 | func TestClaimLossPagesSinkTimeoutAndCancellation(t *testing.T) { |
| 553 | s, raw, signer, _ := lossPageWorld(t) |
| 554 | putLossPageRecord(t, raw, signer, lossPageRecord("blocked-sns", true, hostclaim.TimersUnknown)) |
| 555 | entered := make(chan time.Time, 1) |
| 556 | alerting.Arm(&alerting.Sinks{TopicARN: "test", Pub: lossPageBlockingSNS{entered: entered}}) |
| 557 | ctx, cancel := context.WithCancel(t.Context()) |
| 558 | defer cancel() |
| 559 | s.scheduleClaimLossPages(ctx) |
| 560 | select { |
| 561 | case deadline := <-entered: |
| 562 | if deadline.IsZero() || time.Until(deadline) > alerting.PublishTimeout { |
| 563 | t.Errorf("SNS sink has no timeout: %v", deadline) |
| 564 | } |
| 565 | case <-time.After(time.Second): |
| 566 | t.Error("worker never called SNS") |
| 567 | } |
| 568 | cancel() |
| 569 | waitClaimLossPages(t, s) |
| 570 | var attempts int |
| 571 | if err := s.store.db.QueryRow(`SELECT count(*) FROM claim_loss_pages`).Scan(&attempts); err != nil || attempts != 1 { |
| 572 | t.Fatalf("canceled pass reserved another condition: %d %v", attempts, err) |
| 573 | } |
| 574 | } |
| 575 | |
| 576 | type lossPageSlowStore struct { |
| 577 | release.Store |
| 578 | reads []string |
| 579 | reverse bool |
| 580 | } |
| 581 | |
| 582 | func (s *lossPageSlowStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 583 | keys, err := s.Store.List(ctx, prefix) |
| 584 | if s.reverse { |
| 585 | slices.Reverse(keys) |
| 586 | } |
| 587 | return keys, err |
| 588 | } |
| 589 | |
| 590 | func (s *lossPageSlowStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 591 | s.reads = append(s.reads, key) |
| 592 | if len(s.reads) > 2 { |
| 593 | <-ctx.Done() |
| 594 | return nil, "", ctx.Err() |
| 595 | } |
| 596 | timer := time.NewTimer(10 * time.Millisecond) |
| 597 | defer timer.Stop() |
| 598 | select { |
| 599 | case <-timer.C: |
| 600 | return s.Store.Get(ctx, key) |
| 601 | case <-ctx.Done(): |
| 602 | return nil, "", ctx.Err() |
| 603 | } |
| 604 | } |
| 605 | |
| 606 | func TestClaimLossPagesScanProgressSurvivesRestartAndWraps(t *testing.T) { |
| 607 | s, raw, signer, pages := lossPageWorld(t) |
| 608 | for _, id := range []string{"b-retained", "c-retained", "d-retained", "e-retained", "f-retained", "z-pending"} { |
| 609 | putLossPageRecord(t, raw, signer, lossPageRecord(id, id == "z-pending", hostclaim.TimersComplete)) |
| 610 | } |
| 611 | slow := &lossPageSlowStore{Store: raw} |
| 612 | s.cfg.Releases = slow |
| 613 | pass := func(first string) { |
| 614 | t.Helper() |
| 615 | slow.reads = nil |
| 616 | ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond) |
| 617 | defer cancel() |
| 618 | if err := s.pageClaimLossesAt(ctx, time.Now); !errors.Is(err, context.DeadlineExceeded) { |
| 619 | t.Fatalf("slow scan: %v", err) |
| 620 | } |
| 621 | key, _ := hostclaim.LossRecordKey(first) |
| 622 | if len(slow.reads) != 3 || slow.reads[0] != key { |
| 623 | t.Fatalf("scan reads: %v; want first %s and one unfinished read", slow.reads, key) |
| 624 | } |
| 625 | } |
| 626 | pass("b-retained") |
| 627 | if len(*pages) != 0 { |
| 628 | t.Fatalf("first scan reached beyond its budget: %v", *pages) |
| 629 | } |
| 630 | path := s.store.gate.path |
| 631 | if err := s.store.Close(); err != nil { |
| 632 | t.Fatal(err) |
| 633 | } |
| 634 | st, err := OpenGated(path) |
| 635 | if err != nil { |
| 636 | t.Fatal(err) |
| 637 | } |
| 638 | t.Cleanup(func() { st.Close() }) |
| 639 | s = &Server{cfg: s.cfg, store: st} |
| 640 | pass("d-retained") |
| 641 | pass("f-retained") |
| 642 | if len(*pages) != 1 || !strings.Contains((*pages)[0]["body"], "z-pending") { |
| 643 | t.Fatalf("pending record not delivered within three scans: %v", *pages) |
| 644 | } |
| 645 | putLossPageRecord(t, raw, signer, lossPageRecord("a-new", true, hostclaim.TimersComplete)) |
| 646 | key, _ := hostclaim.LossRecordKey("z-pending") |
| 647 | if err := raw.Delete(t.Context(), key); err != nil { |
| 648 | t.Fatal(err) |
| 649 | } |
| 650 | slow.reverse = true |
| 651 | pass("a-new") |
| 652 | if len(*pages) != 2 || !strings.Contains((*pages)[1]["body"], "a-new") { |
| 653 | t.Fatalf("scan did not wrap past the removed cursor to the new early record: %v", *pages) |
| 654 | } |
| 655 | } |
| 656 | |
| 657 | func TestClaimLossPagesScanRetriesUnfinishedPendingRead(t *testing.T) { |
| 658 | s, raw, signer, pages := lossPageWorld(t) |
| 659 | for _, id := range []string{"a-retained", "b-retained", "c-pending"} { |
| 660 | putLossPageRecord(t, raw, signer, lossPageRecord(id, id == "c-pending", hostclaim.TimersComplete)) |
| 661 | } |
| 662 | s.cfg.Releases = &lossPageSlowStore{Store: raw} |
| 663 | ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond) |
| 664 | defer cancel() |
| 665 | if err := s.pageClaimLossesAt(ctx, time.Now); !errors.Is(err, context.DeadlineExceeded) { |
| 666 | t.Fatalf("slow scan: %v", err) |
| 667 | } |
| 668 | if len(*pages) != 0 { |
| 669 | t.Fatalf("unfinished read paged: %v", *pages) |
| 670 | } |
| 671 | slow := &lossPageSlowStore{Store: raw} |
| 672 | s.cfg.Releases = slow |
| 673 | ctx, cancel = context.WithTimeout(t.Context(), 250*time.Millisecond) |
| 674 | defer cancel() |
| 675 | if err := s.pageClaimLossesAt(ctx, time.Now); !errors.Is(err, context.DeadlineExceeded) { |
| 676 | t.Fatalf("resumed scan: %v", err) |
| 677 | } |
| 678 | key, _ := hostclaim.LossRecordKey("c-pending") |
| 679 | if !slices.Equal(slow.reads, []string{key, "control/host-loss/a-retained.json", "control/host-loss/b-retained.json"}) || len(*pages) != 1 { |
| 680 | t.Fatalf("unfinished pending read skipped: reads=%v pages=%v", slow.reads, *pages) |
| 681 | } |
| 682 | } |
| 683 | |
| 684 | // lossPageStalledStore never answers a read of one key. |
| 685 | type lossPageStalledStore struct { |
| 686 | release.Store |
| 687 | stalled string |
| 688 | reads []string |
| 689 | } |
| 690 | |
| 691 | func (s *lossPageStalledStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 692 | s.reads = append(s.reads, key) |
| 693 | if key == s.stalled { |
| 694 | <-ctx.Done() |
| 695 | return nil, "", ctx.Err() |
| 696 | } |
| 697 | return s.Store.Get(ctx, key) |
| 698 | } |
| 699 | |
| 700 | func TestClaimLossPagesPersistentlyStalledReadDoesNotStarve(t *testing.T) { |
| 701 | prior := claimLossReadBudget |
| 702 | claimLossReadBudget = 50 * time.Millisecond |
| 703 | t.Cleanup(func() { claimLossReadBudget = prior }) |
| 704 | s, raw, signer, pages := lossPageWorld(t) |
| 705 | for _, id := range []string{"a-stalled", "b-pending"} { |
| 706 | putLossPageRecord(t, raw, signer, lossPageRecord(id, true, hostclaim.TimersComplete)) |
| 707 | } |
| 708 | stalledKey, _ := hostclaim.LossRecordKey("a-stalled") |
| 709 | pendingKey, _ := hostclaim.LossRecordKey("b-pending") |
| 710 | store := &lossPageStalledStore{Store: raw, stalled: stalledKey} |
| 711 | s.cfg.Releases = store |
| 712 | for lap := 0; lap < 2; lap++ { |
| 713 | store.reads = nil |
| 714 | ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) |
| 715 | err := s.pageClaimLossesAt(ctx, time.Now) |
| 716 | cancel() |
| 717 | if err == nil || !errors.Is(err, context.DeadlineExceeded) { |
| 718 | t.Fatalf("lap %d: the stalled read should surface as a record error, got %v", lap, err) |
| 719 | } |
| 720 | if !slices.Contains(store.reads, pendingKey) { |
| 721 | t.Fatalf("lap %d: the record behind the stalled one was never read: %v", lap, store.reads) |
| 722 | } |
| 723 | if !slices.Contains(store.reads, stalledKey) { |
| 724 | t.Fatalf("lap %d: the stalled record was not retried: %v", lap, store.reads) |
| 725 | } |
| 726 | } |
| 727 | if len(*pages) != 1 || !strings.Contains((*pages)[0]["body"], "b-pending") { |
| 728 | t.Fatalf("pending record behind a stalled read was not paged exactly once: %v", *pages) |
| 729 | } |
| 730 | } |
| 731 | |