console: loss paging resumes its scan where the last one stopped (durable cursor, wrap-around), so a pending record behind slow reads is always reached
Answers Astra's P2 on 644891f0.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+170
−26
internal/console/claim_authority_census_test.go+1 −1internal/console/claim_loss_pages.go+46 −22internal/console/claim_loss_pages_test.go+114 −0internal/console/store_gate.go+4 −1internal/console/testdata/claim-authority-sites.txt+4 −1internal/provision/plan9_b8_census_test.go+1 −1
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 316.921s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.052s
ok github.com/carlosframework/platform/cmd/moveprobe 93.386s
ok github.com/carlosframework/platform/internal/activator 212.737s
ok github.com/carlosframework/platform/internal/agentsock 0.021s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.021s
ok github.com/carlosframework/platform/internal/appconfig 2.271s
ok github.com/carlosframework/platform/internal/apperr 0.130s
ok github.com/carlosframework/platform/internal/applog 0.169s
ok github.com/carlosframework/platform/internal/awsinv 0.010s
ok github.com/carlosframework/platform/internal/boxchoice 0.005s
ok github.com/carlosframework/platform/internal/boxid 0.880s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.012s
ok github.com/carlosframework/platform/internal/caps 0.019s
ok github.com/carlosframework/platform/internal/certwarm 0.004s
ok github.com/carlosframework/platform/internal/cgroup 0.212s
ok github.com/carlosframework/platform/internal/changelog 0.301s
ok github.com/carlosframework/platform/internal/clamd 0.061s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 356.410s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.907s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.010s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 1.978s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.005s
ok github.com/carlosframework/platform/internal/edge 25.891s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.009s
ok github.com/carlosframework/platform/internal/fleetapi 0.128s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.022s
ok github.com/carlosframework/platform/internal/geo 0.016s
ok github.com/carlosframework/platform/internal/geoip 0.036s
ok github.com/carlosframework/platform/internal/host 55.081s
ok github.com/carlosframework/platform/internal/hostactors 0.363s
ok github.com/carlosframework/platform/internal/hostbox 1.193s
ok github.com/carlosframework/platform/internal/hostclaim 0.242s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 0.317s
ok github.com/carlosframework/platform/internal/hostfence 0.037s
ok github.com/carlosframework/platform/internal/hostfiles 4.166s
ok github.com/carlosframework/platform/internal/hostfloor 0.016s
ok github.com/carlosframework/platform/internal/hostguard 0.126s
ok github.com/carlosframework/platform/internal/hostops 1.338s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.002s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostresolve 0.129s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 1.705s
ok github.com/carlosframework/platform/internal/instanceexec 2.676s
ok github.com/carlosframework/platform/internal/instancefs 0.004s
ok github.com/carlosframework/platform/internal/instancekey 0.004s
ok github.com/carlosframework/platform/internal/instanceupdates 0.009s
ok github.com/carlosframework/platform/internal/layout 1.321s
ok github.com/carlosframework/platform/internal/lease 9.431s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s
ok github.com/carlosframework/platform/internal/ledger 0.020s
ok github.com/carlosframework/platform/internal/mail 0.145s
ok github.com/carlosframework/platform/internal/maintenance 0.081s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.508s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.086s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.051s
ok github.com/carlosframework/platform/internal/moves/archive 0.739s
ok github.com/carlosframework/platform/internal/objstore 0.156s
ok github.com/carlosframework/platform/internal/ops 0.153s
ok github.com/carlosframework/platform/internal/park 0.185s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 3.111s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 3.681s
ok github.com/carlosframework/platform/internal/relay 7.224s
ok github.com/carlosframework/platform/internal/release 17.821s
ok github.com/carlosframework/platform/internal/rollout 3.228s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.006s
ok github.com/carlosframework/platform/internal/s3compat 0.004s
ok github.com/carlosframework/platform/internal/safefile 0.006s
ok github.com/carlosframework/platform/internal/schedule 2.197s
ok github.com/carlosframework/platform/internal/sim/hostlife 2.338s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.118s
ok github.com/carlosframework/platform/internal/status 0.036s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.127s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.003s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.002s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.001s
ok github.com/carlosframework/platform/internal/vet 1.531s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.029s
=== RUN TestT4StorageProbeMinio
2026/10/12 16:20:41 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DDD4A7467179EC, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.025s
minio contract: PASS
diff --git a/internal/console/claim_authority_census_test.go b/internal/console/claim_authority_census_test.go| index 156eb8ca8..a0e03fae4 100644 |
| --- a/internal/console/claim_authority_census_test.go |
| +++ b/internal/console/claim_authority_census_test.go |
| @@ -75,7 +75,7 @@ func claimAuthoritySites(t *testing.T) []string { |
| } |
| // Loss paging reads signed control evidence and writes only its |
| // NonApp delivery journal. Inventory those sites explicitly. |
| - lossPageSite := e.Name() == "claim_loss_pages.go" && (method == "List" || method == "ReadLossRecord" || method == "Exec" || method == "ExecContext" || method == "ClaimLossPageAttempt") |
| + lossPageSite := e.Name() == "claim_loss_pages.go" && (method == "List" || method == "ReadLossRecord" || method == "Exec" || method == "ExecContext" || method == "QueryRowContext" || method == "ClaimLossPageAttempt") |
| lossPageSchema := e.Name() == "store_gate.go" && fn.Name.Name == "OpenGated" && method == "Exec" |
| if !selected[method] && !lossPageSite && !lossPageSchema { |
| return true |
diff --git a/internal/console/claim_loss_pages.go b/internal/console/claim_loss_pages.go| index 976e0198e..a57f8fb83 100644 |
| --- a/internal/console/claim_loss_pages.go |
| +++ b/internal/console/claim_loss_pages.go |
| @@ -2,9 +2,11 @@ package console |
| import ( |
| "context" |
| + "database/sql" |
| "errors" |
| "fmt" |
| "log" |
| + "sort" |
| "strings" |
| "time" |
| @@ -59,42 +61,64 @@ func (s *Server) pageClaimLossesAt(ctx context.Context, now func() time.Time) er |
| if s.cfg.Releases == nil || s.store == nil || len(s.cfg.ReleasePub) == 0 { |
| return hostops.ErrContract |
| } |
| + var after string |
| + if err := s.store.db.QueryRowContext(ctx, `SELECT last_key FROM claim_loss_pages WHERE rule = 'scan'`).Scan(&after); err != nil && !errors.Is(err, sql.ErrNoRows) { |
| + return err |
| + } |
| keys, err := s.cfg.Releases.List(ctx, "control/host-loss/") |
| if err != nil { |
| return err |
| } |
| + // Store listings need not be ordered. Visit each key once, wrapping even |
| + // when the saved key has since been removed or earlier keys were added. |
| + sort.Strings(keys) |
| + start := sort.Search(len(keys), func(i int) bool { return keys[i] > after }) |
| var errs []error |
| - for _, key := range keys { |
| + for i := range keys { |
| if err := ctx.Err(); err != nil { |
| return errors.Join(append(errs, err)...) |
| } |
| - id := strings.TrimSuffix(strings.TrimPrefix(key, "control/host-loss/"), ".json") |
| - exact, err := hostclaim.LossRecordKey(id) |
| - if err != nil || exact != key { |
| - errs = append(errs, hostclaim.ErrLossRecord) |
| - continue |
| - } |
| - observed, err := hostclaim.ReadLossRecord(ctx, s.cfg.Releases, s.cfg.ReleasePub, id) |
| - if errors.Is(err, hostclaim.ErrLossRecordAbsent) { |
| - continue |
| - } |
| - if err != nil { |
| - errs = append(errs, err) |
| - continue |
| - } |
| - r := observed.Record |
| - if r.AlertsPending { |
| - subject, body := claimLossPendingPage(r.Host, r.ID) |
| - errs = append(errs, s.pageClaimLoss(ctx, "pending", r, now, subject, body)) |
| + key := keys[(start+i)%len(keys)] |
| + errs = append(errs, s.pageClaimLossRecord(ctx, key, now)) |
| + if err := ctx.Err(); err != nil { |
| + return errors.Join(append(errs, err)...) |
| } |
| - if r.Timers.Knowledge == hostclaim.TimersPartial || r.Timers.Knowledge == hostclaim.TimersUnknown { |
| - subject, body := claimTimerLossPage(r.Host, r.ID) |
| - errs = append(errs, s.pageClaimLoss(ctx, "timers", r, now, subject, body)) |
| + // Never advance past canceled work. Other record errors are retried on |
| + // the next lap so one bad record cannot block everything after it. |
| + if _, err := s.store.db.ExecContext(ctx, `INSERT INTO claim_loss_pages (rule, first_attempted_at, attempted_at, last_key) VALUES ('scan', 0, 0, ?) |
| +ON CONFLICT(rule) DO UPDATE SET last_key = excluded.last_key`, key); err != nil { |
| + return errors.Join(append(errs, err)...) |
| } |
| } |
| return errors.Join(errs...) |
| } |
| +func (s *Server) pageClaimLossRecord(ctx context.Context, key string, now func() time.Time) error { |
| + id := strings.TrimSuffix(strings.TrimPrefix(key, "control/host-loss/"), ".json") |
| + exact, err := hostclaim.LossRecordKey(id) |
| + if err != nil || exact != key { |
| + return hostclaim.ErrLossRecord |
| + } |
| + observed, err := hostclaim.ReadLossRecord(ctx, s.cfg.Releases, s.cfg.ReleasePub, id) |
| + if errors.Is(err, hostclaim.ErrLossRecordAbsent) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + r := observed.Record |
| + var errs []error |
| + if r.AlertsPending { |
| + subject, body := claimLossPendingPage(r.Host, r.ID) |
| + errs = append(errs, s.pageClaimLoss(ctx, "pending", r, now, subject, body)) |
| + } |
| + if r.Timers.Knowledge == hostclaim.TimersPartial || r.Timers.Knowledge == hostclaim.TimersUnknown { |
| + subject, body := claimTimerLossPage(r.Host, r.ID) |
| + errs = append(errs, s.pageClaimLoss(ctx, "timers", r, now, subject, body)) |
| + } |
| + return errors.Join(errs...) |
| +} |
| + |
| func (s *Server) pageClaimLoss(ctx context.Context, condition string, r hostclaim.LossRecord, now func() time.Time, subject, body string) error { |
| if err := ctx.Err(); err != nil { |
| return err |
diff --git a/internal/console/claim_loss_pages_test.go b/internal/console/claim_loss_pages_test.go| index 6978fbd26..40f98840b 100644 |
| --- a/internal/console/claim_loss_pages_test.go |
| +++ b/internal/console/claim_loss_pages_test.go |
| @@ -8,6 +8,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "path/filepath" |
| + "slices" |
| "strings" |
| "sync" |
| "sync/atomic" |
| @@ -483,10 +484,15 @@ INSERT INTO claim_loss_pages VALUES ('pending old', ?)`, at.UnixNano()) |
| } |
| var first, last int64 |
| err = st.db.QueryRow(`SELECT first_attempted_at, attempted_at FROM claim_loss_pages WHERE rule = 'pending old'`).Scan(&first, &last) |
| + var cursor string |
| + cursorErr := st.db.QueryRow(`SELECT last_key FROM claim_loss_pages WHERE rule = 'pending old'`).Scan(&cursor) |
| st.Close() |
| if err != nil || first != at.UnixNano() || last != at.UnixNano() { |
| t.Fatalf("upgrade lost durable attempt: %d %d %v", first, last, err) |
| } |
| + if cursorErr != nil || cursor != "" { |
| + t.Fatalf("upgrade scan progress: %q %v", cursor, cursorErr) |
| + } |
| } |
| } |
| @@ -566,3 +572,111 @@ func TestClaimLossPagesSinkTimeoutAndCancellation(t *testing.T) { |
| t.Fatalf("canceled pass reserved another condition: %d %v", attempts, err) |
| } |
| } |
| + |
| +type lossPageSlowStore struct { |
| + release.Store |
| + reads []string |
| + reverse bool |
| +} |
| + |
| +func (s *lossPageSlowStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + keys, err := s.Store.List(ctx, prefix) |
| + if s.reverse { |
| + slices.Reverse(keys) |
| + } |
| + return keys, err |
| +} |
| + |
| +func (s *lossPageSlowStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + s.reads = append(s.reads, key) |
| + if len(s.reads) > 2 { |
| + <-ctx.Done() |
| + return nil, "", ctx.Err() |
| + } |
| + timer := time.NewTimer(10 * time.Millisecond) |
| + defer timer.Stop() |
| + select { |
| + case <-timer.C: |
| + return s.Store.Get(ctx, key) |
| + case <-ctx.Done(): |
| + return nil, "", ctx.Err() |
| + } |
| +} |
| + |
| +func TestClaimLossPagesScanProgressSurvivesRestartAndWraps(t *testing.T) { |
| + s, raw, signer, pages := lossPageWorld(t) |
| + for _, id := range []string{"b-retained", "c-retained", "d-retained", "e-retained", "f-retained", "z-pending"} { |
| + putLossPageRecord(t, raw, signer, lossPageRecord(id, id == "z-pending", hostclaim.TimersComplete)) |
| + } |
| + slow := &lossPageSlowStore{Store: raw} |
| + s.cfg.Releases = slow |
| + pass := func(first string) { |
| + t.Helper() |
| + slow.reads = nil |
| + ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond) |
| + defer cancel() |
| + if err := s.pageClaimLossesAt(ctx, time.Now); !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("slow scan: %v", err) |
| + } |
| + key, _ := hostclaim.LossRecordKey(first) |
| + if len(slow.reads) != 3 || slow.reads[0] != key { |
| + t.Fatalf("scan reads: %v; want first %s and one unfinished read", slow.reads, key) |
| + } |
| + } |
| + pass("b-retained") |
| + if len(*pages) != 0 { |
| + t.Fatalf("first scan reached beyond its budget: %v", *pages) |
| + } |
| + path := s.store.gate.path |
| + if err := s.store.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, err := OpenGated(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + s = &Server{cfg: s.cfg, store: st} |
| + pass("d-retained") |
| + pass("f-retained") |
| + if len(*pages) != 1 || !strings.Contains((*pages)[0]["body"], "z-pending") { |
| + t.Fatalf("pending record not delivered within three scans: %v", *pages) |
| + } |
| + putLossPageRecord(t, raw, signer, lossPageRecord("a-new", true, hostclaim.TimersComplete)) |
| + key, _ := hostclaim.LossRecordKey("z-pending") |
| + if err := raw.Delete(t.Context(), key); err != nil { |
| + t.Fatal(err) |
| + } |
| + slow.reverse = true |
| + pass("a-new") |
| + if len(*pages) != 2 || !strings.Contains((*pages)[1]["body"], "a-new") { |
| + t.Fatalf("scan did not wrap past the removed cursor to the new early record: %v", *pages) |
| + } |
| +} |
| + |
| +func TestClaimLossPagesScanRetriesUnfinishedPendingRead(t *testing.T) { |
| + s, raw, signer, pages := lossPageWorld(t) |
| + for _, id := range []string{"a-retained", "b-retained", "c-pending"} { |
| + putLossPageRecord(t, raw, signer, lossPageRecord(id, id == "c-pending", hostclaim.TimersComplete)) |
| + } |
| + s.cfg.Releases = &lossPageSlowStore{Store: raw} |
| + ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond) |
| + defer cancel() |
| + if err := s.pageClaimLossesAt(ctx, time.Now); !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("slow scan: %v", err) |
| + } |
| + if len(*pages) != 0 { |
| + t.Fatalf("unfinished read paged: %v", *pages) |
| + } |
| + slow := &lossPageSlowStore{Store: raw} |
| + s.cfg.Releases = slow |
| + ctx, cancel = context.WithTimeout(t.Context(), 250*time.Millisecond) |
| + defer cancel() |
| + if err := s.pageClaimLossesAt(ctx, time.Now); !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("resumed scan: %v", err) |
| + } |
| + key, _ := hostclaim.LossRecordKey("c-pending") |
| + if !slices.Equal(slow.reads, []string{key, "control/host-loss/a-retained.json", "control/host-loss/b-retained.json"}) || len(*pages) != 1 { |
| + t.Fatalf("unfinished pending read skipped: reads=%v pages=%v", slow.reads, *pages) |
| + } |
| +} |
diff --git a/internal/console/store_gate.go b/internal/console/store_gate.go| index 370b5475b..bda729a56 100644 |
| --- a/internal/console/store_gate.go |
| +++ b/internal/console/store_gate.go |
| @@ -209,7 +209,7 @@ var claimSQLTables = map[string]claimSQLTable{ |
| "memberships": {NonApp: "account membership, keyed by account and address; names no app"}, |
| "invites": {NonApp: "account invites, keyed by account and address; names no app"}, |
| "alert_seen": {NonApp: "operator alert memory, keyed by box, rule and address"}, |
| - "claim_loss_pages": {NonApp: "claim recovery page attempts per loss record and condition; no app state or lifecycle authority"}, |
| + "claim_loss_pages": {NonApp: "claim recovery page attempts per loss record and condition, and scan progress; no app state or lifecycle authority"}, |
| "move_pages": {NonApp: "the move pass's last page attempt per finding (rule names the host and the move); no app column"}, |
| "move_log_outbox": {NonApp: "a moves audit outbox that stores no app name: rows are keyed by host, move and entry and bound to an instance incarnation, verified against the host's claim and record at every write, written only by the moves recovery and log passes, and purged with the incarnation"}, |
| "edge_proof_seen": {NonApp: "single-row edge proof marker"}, |
| @@ -1805,6 +1805,9 @@ func OpenGated(path string) (*Store, error) { |
| if _, err := s.db.Exec(`UPDATE claim_loss_pages SET first_attempted_at = attempted_at WHERE first_attempted_at IS NULL`); err != nil { |
| return err |
| } |
| + if _, err := s.db.Exec(`ALTER TABLE claim_loss_pages ADD COLUMN last_key TEXT NOT NULL DEFAULT ''`); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| + return err |
| + } |
| return s.migrateClaimAppIdentity() |
| }) |
| } |
diff --git a/internal/console/testdata/claim-authority-sites.txt b/internal/console/testdata/claim-authority-sites.txt| index ed4ae56ac..ee9b892fd 100644 |
| --- a/internal/console/testdata/claim-authority-sites.txt |
| +++ b/internal/console/testdata/claim-authority-sites.txt |
| @@ -126,9 +126,12 @@ store_gate.go|openGated|sql.OpenDB(claimConnector{g: g}) |
| store_gate.go|withClaimSQL|s.withPreparedClaimSQL(ctx, scopes, check, func(ctx context.Context, _ claimSQLScope) error { return ctx.Err() }, nil, false, fn) |
| view.go|openRegistry|registry.Open(path) |
| claim_loss_pages.go|ClaimLossPageAttempt|s.db.ExecContext(ctx, `INSERT INTO claim_loss_pages (rule, first_attempted_at, attempted_at) VALUES (?, ?, ?) ON CONFLICT(rule) DO UPDATE SET attempted_at = excluded.attempted_at WHERE claim_loss_pages.attempted_at <= CASE WHEN claim_loss_pages.first_attempted_at > ? THEN ? ELSE ? END`, rule, now.UnixNano(), now.UnixNano(), now.Add(-claimLossPageDailyInterval).UnixNano(), now.Add(-claimLossPageInterval).UnixNano(), now.Add(-claimLossPageDailyInterval).UnixNano()) |
| -claim_loss_pages.go|pageClaimLossesAt|hostclaim.ReadLossRecord(ctx, s.cfg.Releases, s.cfg.ReleasePub, id) |
| +claim_loss_pages.go|pageClaimLossRecord|hostclaim.ReadLossRecord(ctx, s.cfg.Releases, s.cfg.ReleasePub, id) |
| claim_loss_pages.go|pageClaimLossesAt|s.cfg.Releases.List(ctx, "control/host-loss/") |
| claim_loss_pages.go|pageClaimLoss|s.store.ClaimLossPageAttempt(ctx, condition+" "+r.ID, now()) |
| store_gate.go|OpenGated|s.db.Exec(`CREATE TABLE IF NOT EXISTS claim_loss_pages ( rule TEXT PRIMARY KEY, first_attempted_at INTEGER NOT NULL, attempted_at INTEGER NOT NULL );`) |
| store_gate.go|OpenGated|s.db.Exec(`ALTER TABLE claim_loss_pages ADD COLUMN first_attempted_at INTEGER`) |
| store_gate.go|OpenGated|s.db.Exec(`UPDATE claim_loss_pages SET first_attempted_at = attempted_at WHERE first_attempted_at IS NULL`) |
| +claim_loss_pages.go|pageClaimLossesAt|s.store.db.ExecContext(ctx, `INSERT INTO claim_loss_pages (rule, first_attempted_at, attempted_at, last_key) VALUES ('scan', 0, 0, ?) ON CONFLICT(rule) DO UPDATE SET last_key = excluded.last_key`, key) |
| +claim_loss_pages.go|pageClaimLossesAt|s.store.db.QueryRowContext(ctx, `SELECT last_key FROM claim_loss_pages WHERE rule = 'scan'`) |
| +store_gate.go|OpenGated|s.db.Exec(`ALTER TABLE claim_loss_pages ADD COLUMN last_key TEXT NOT NULL DEFAULT ''`) |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 001f7bb4c..482c1a394 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -473,7 +473,7 @@ func Reviewed(ctx context.Context, s Store, k string) error { |
| // BEGIN REVIEWED INVENTORY |
| var plan9B8CensusInventory = []censusEntry{ |
| // Read-only discovery of signed loss records; no record write or lifecycle authority. |
| - {"internal/console/claim_loss_pages.go", "Server.pageClaimLossesAt", "key-site", "key:LossRecordKey", "c60f826092bf31e7f657ec60592187f433026e4a53fb5d9ca011a696d7cd2134"}, |
| + {"internal/console/claim_loss_pages.go", "Server.pageClaimLossRecord", "key-site", "key:LossRecordKey", "bf64c04d9c4448e2eeab3deb145e5844378ea746ee242408657d706e5ee3b8a3"}, |
| {"internal/hostbox/parkleases.go", "ReplicaParkLeaseSet", "key-site", "lit:instances", "0ca8d9b743402fda9d15cc215464423b35e21c1c2b47f91294d76188b7d43fb6"}, |
| {"internal/host/claimretirementcensus.go", "ClaimRetirementCensus", "key-site", "lit:instances", "abc0bd8c5f5d328abfac8821f42f8f23954723ed6506747f33b0197824e2611f"}, |
| {"internal/host/claimproduction.go", "productionClaimRegional.View", "key-site", "lit:instances", "533203b2aa729801438da4f3a85799a58ea0e67d20eb5625f0de32ea623f1607"}, |