carlos / platform Public

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>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev eaa1a0f1445ca4d0ad13be91a2a05a2e684ee1a9 parent 644891f
6 files changed, +170 −26
  • internal/console/claim_authority_census_test.go +1 −1
  • internal/console/claim_loss_pages.go +46 −22
  • internal/console/claim_loss_pages_test.go +114 −0
  • internal/console/store_gate.go +4 −1
  • internal/console/testdata/claim-authority-sites.txt +4 −1
  • internal/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"},