console, dbdoor: the door lists genselect's plan for the holder's generation (Plan 7 follow-up)
Replaces the refusal of a non-empty holder.generation with generation-aware selection through internal/genselect; refusals map to a retryable 409; handles still pin exact versions.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+326
−63
internal/console/apidbclaim.go+25 −39internal/console/apidbclaim_generation_test.go+164 −0internal/console/apidbclaim_http_test.go+33 −7internal/console/apidbclaim_range_test.go+3 −0internal/console/apidbclaim_test.go+5 −8internal/dbdoor/client.go+19 −5internal/dbdoor/client_handles.go+29 −2internal/dbdoor/client_handles_test.go+45 −0internal/dbdoor/coords.go+3 −2
CI passed — run details
diff --git a/internal/console/apidbclaim.go b/internal/console/apidbclaim.go| index 964d66466..f7e71edec 100644 |
| --- a/internal/console/apidbclaim.go |
| +++ b/internal/console/apidbclaim.go |
| @@ -14,12 +14,12 @@ import ( |
| "net/http" |
| "os" |
| "reflect" |
| - "sort" |
| "strconv" |
| "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/dbdoor" |
| + "github.com/carlosframework/platform/internal/genselect" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -32,7 +32,6 @@ var ( |
| errDBNotAdopted = errors.New("host not adopted") |
| errDBClaimChanged = errors.New("host claim changed; retry the database pull") |
| errDBClaimClosed = errors.New("host is not accepting database reads") |
| - errDBGeneration = errors.New("database generation requires a generation-aware reader") |
| errDBHandle = errors.New("invalid database file handle") |
| errDBHandleExpired = errors.New("database file handle expired; retry the database pull") |
| ) |
| @@ -143,11 +142,6 @@ func (s *Server) dbClaim(ctx context.Context, g dbRequest, rec provision.Record) |
| if h.Location == "" { |
| return obs, errDBClaimClosed |
| } |
| - // The moves generation restore contract is not provided by Plans 1/2. |
| - // Never present root files as the installed generation's database. |
| - if h.Generation != "" { |
| - return obs, errDBGeneration |
| - } |
| return obs, nil |
| } |
| @@ -164,7 +158,7 @@ func (s *Server) dbClaimFailure(w http.ResponseWriter, r *http.Request, g dbRequ |
| switch { |
| case errors.Is(err, os.ErrNotExist): |
| http.NotFound(w, r) |
| - case errors.Is(err, errDBNotAdopted), errors.Is(err, errDBClaimChanged), errors.Is(err, errDBClaimClosed), errors.Is(err, errDBGeneration): |
| + case errors.Is(err, errDBNotAdopted), errors.Is(err, errDBClaimChanged), errors.Is(err, errDBClaimClosed): |
| w.Header().Set("Retry-After", "1") |
| jsonError(w, http.StatusConflict, err.Error()) |
| case errors.Is(err, errDBHandleExpired): |
| @@ -177,35 +171,21 @@ func (s *Server) dbClaimFailure(w http.ResponseWriter, r *http.Request, g dbRequ |
| } |
| func (s *Server) listDBVersions(ctx context.Context, g dbRequest, level int) (dbdoor.ListResponse, error) { |
| - out := dbdoor.ListResponse{Protocol: 2, Files: []dbdoor.LTXFile{}} |
| + // Select the complete baseline once; the client retains it across levels. |
| + out := dbdoor.ListResponse{Protocol: 2, Plan: true, Files: []dbdoor.LTXFile{}} |
| h := g.claim.Claim.Holder |
| - prefix := dbLevelPrefix(h.Location, level) |
| - versions, err := s.cfg.DBVersions.ListVersions(ctx, s.cfg.HibernateBucket, prefix) |
| + plan, err := genselect.Select(ctx, s.cfg.DBVersions, genselect.Request{ |
| + Bucket: s.cfg.HibernateBucket, Region: s.homeRegionOut(s.cfg.Region), Host: g.host, Holder: *h, |
| + }) |
| if err != nil { |
| - return dbdoor.ListResponse{}, err |
| + return dbdoor.ListResponse{}, errDBClaimChanged |
| } |
| - seen := map[string]bool{} |
| - for _, v := range versions { |
| - if !strings.HasPrefix(v.Key, prefix) { |
| - return dbdoor.ListResponse{}, errors.New("db: malformed version listing") |
| - } |
| - if !v.IsLatest || v.DeleteMarker { |
| - continue |
| - } |
| - name := strings.TrimPrefix(v.Key, prefix) |
| - lo, hi, err := ltx.ParseFilename(name) |
| - if err != nil || lo == 0 || hi < lo || v.Key != dbVersionKey(h.Location, level, uint64(lo), uint64(hi)) { |
| - continue |
| - } |
| - if v.VersionID == "" || v.VersionID == "null" || v.Size < 0 || seen[v.Key] { |
| - return dbdoor.ListResponse{}, errors.New("db: malformed version listing") |
| - } |
| - seen[v.Key] = true |
| - token, err := signDBHandle(s.dbHandleKey, dbFileHandle{V: 1, Host: g.host, Location: h.Location, Key: v.Key, VersionID: v.VersionID, Incarnation: h.Incarnation, Expiry: time.Now().Add(dbHandleTTL).Unix(), Account: g.acct.Sqid, App: g.app, Size: v.Size}) |
| + for _, f := range plan.Files { |
| + token, err := signDBHandle(s.dbHandleKey, dbFileHandle{V: 1, Host: g.host, Location: h.Location, Key: f.Key, VersionID: f.VersionID, Incarnation: h.Incarnation, Expiry: time.Now().Add(dbHandleTTL).Unix(), Account: g.acct.Sqid, App: g.app, Size: f.Size}) |
| if err != nil { |
| return dbdoor.ListResponse{}, err |
| } |
| - out.Files = append(out.Files, dbdoor.LTXFile{Level: level, MinTXID: uint64(lo), MaxTXID: uint64(hi), Size: v.Size, CreatedAt: v.LastModified, Handle: token}) |
| + out.Files = append(out.Files, dbdoor.LTXFile{Level: f.Level, MinTXID: f.MinTXID, MaxTXID: f.MaxTXID, Size: f.Size, Handle: token}) |
| } |
| o2, err := hostclaim.Read(ctx, s.cfg.Releases, g.host) |
| if err != nil { |
| @@ -214,22 +194,28 @@ func (s *Server) listDBVersions(ctx context.Context, g dbRequest, level int) (db |
| if !sameDBAdmission(g.claim, o2) { |
| return dbdoor.ListResponse{}, errDBClaimChanged |
| } |
| - sort.Slice(out.Files, func(i, j int) bool { |
| - if out.Files[i].MinTXID == out.Files[j].MinTXID { |
| - return out.Files[i].MaxTXID < out.Files[j].MaxTXID |
| - } |
| - return out.Files[i].MinTXID < out.Files[j].MinTXID |
| - }) |
| return out, nil |
| } |
| +func dbHandleCoordinate(h dbFileHandle, level int, lo, hi uint64) bool { |
| + if h.Key == dbVersionKey(h.Location, level, lo, hi) { |
| + return true |
| + } |
| + rel, ok := strings.CutPrefix(h.Key, h.Location+"/g-") |
| + if !ok { |
| + return false |
| + } |
| + gen, tail, ok := strings.Cut(rel, "/") |
| + return ok && gen != "" && gen != "." && gen != ".." && gen != hostclaim.RootGeneration && !strings.ContainsAny(gen, "\\\x00") && tail == dbVersionKey("", level, lo, hi)[1:] |
| +} |
| + |
| func (s *Server) pinnedDBReader(r *http.Request, g dbRequest, level int, lo, hi uint64) (*dbVersionReader, error) { |
| h, err := verifyDBHandle(s.dbHandleKey, r.Header.Get(dbdoor.HandleHeader), time.Now()) |
| if err != nil { |
| return nil, err |
| } |
| holder := g.claim.Claim.Holder |
| - if h.Host != g.host || h.Account != g.acct.Sqid || h.App != g.app || h.Location != holder.Location || h.Incarnation != holder.Incarnation || h.Key != dbVersionKey(h.Location, level, lo, hi) { |
| + if h.Host != g.host || h.Account != g.acct.Sqid || h.App != g.app || h.Location != holder.Location || h.Incarnation != holder.Incarnation || !dbHandleCoordinate(h, level, lo, hi) { |
| return nil, errDBHandle |
| } |
| return &dbVersionReader{store: s.cfg.DBVersions, bucket: s.cfg.HibernateBucket, handle: h}, nil |
| @@ -247,7 +233,7 @@ func (v *dbVersionReader) LTXFiles(context.Context, int, ltx.TXID, bool) (ltx.Fi |
| return nil, errDBHandle |
| } |
| func (v *dbVersionReader) OpenLTXFile(ctx context.Context, level int, lo, hi ltx.TXID, offset, size int64) (io.ReadCloser, error) { |
| - if v.handle.Key != dbVersionKey(v.handle.Location, level, uint64(lo), uint64(hi)) { |
| + if !dbHandleCoordinate(v.handle, level, uint64(lo), uint64(hi)) { |
| return nil, errDBHandle |
| } |
| n, err := dbVersionRange(v.handle.Size, offset, size) |
diff --git a/internal/console/apidbclaim_generation_test.go b/internal/console/apidbclaim_generation_test.go| new file mode 100644 |
| index 000000000..2f935db56 |
| --- /dev/null |
| +++ b/internal/console/apidbclaim_generation_test.go |
| @@ -0,0 +1,164 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/dbdoor" |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/superfly/ltx" |
| +) |
| + |
| +func doorLTXHeader(t *testing.T, lo, hi uint64) []byte { |
| + t.Helper() |
| + h := ltx.Header{Version: ltx.Version, Flags: ltx.HeaderFlagNoChecksum, PageSize: 4096, MinTXID: ltx.TXID(lo), MaxTXID: ltx.TXID(hi)} |
| + b, err := h.MarshalBinary() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| + |
| +func TestDoorPlanHTTPGenerationSelection(t *testing.T) { |
| + for _, mode := range []string{"origin", "published", "unfinished", "unfinished-origin", "gap", "missing-pin", "partial-list", "disagreement", "bad-header", "holder-change", "op-change"} { |
| + t.Run(mode, func(t *testing.T) { |
| + fx := dbFixture(t) |
| + model := enableDoorPlan(t, fx) |
| + fx.srv.cfg.HomeRegion = "ie" |
| + obs, err := hostclaim.Read(t.Context(), fx.rel, dbTestHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := cloneDoorClaim(obs.Claim) |
| + loc := c.Holder.Location |
| + gen := "live" |
| + if mode == "origin" || mode == "unfinished-origin" { |
| + gen = "" |
| + } |
| + c.Holder.Generation = gen |
| + base := loc |
| + if gen != "" { |
| + base += "/g-" + gen |
| + } |
| + put := func(level int, lo, hi uint64) genselect.File { |
| + key := dbVersionKey(base, level, lo, hi) |
| + b := doorLTXHeader(t, lo, hi) |
| + id := model.Put("data-test", key, b) |
| + return genselect.File{Key: key, VersionID: id, Level: level, MinTXID: lo, MaxTXID: hi, Size: int64(len(b))} |
| + } |
| + root := put(9, 1, 11) |
| + cont := put(1, 10, 13) |
| + put(0, 11, 11) |
| + put(0, 12, 12) |
| + if gen != "" { |
| + marker := genselect.Marker{Op: gen, Host: dbTestHost, Incarnation: c.Holder.Incarnation, Box: "box", InstalledAt: time.Unix(1, 0).UTC(), Pin: genselect.Pin{SnapshotKey: root.Key, SnapshotVersion: root.VersionID, SnapshotTXID: 11}} |
| + raw, err := json.Marshal(marker) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + model.Put("data-test", loc+"/generation.json", raw) |
| + model.Put("data-test", root.Key, []byte("late duplicate must never be selected")) |
| + model.Put("data-test", dbVersionKey(loc, 9, 1, 99), doorLTXHeader(t, 1, 99)) |
| + } |
| + switch mode { |
| + case "unfinished", "unfinished-origin": |
| + predecessor := gen |
| + if predecessor == "" { |
| + predecessor = hostclaim.RootGeneration |
| + } |
| + c.Holder.Generation = "next" |
| + c.Holder.Admissions = []hostclaim.AdmissionRecord{{Generation: "next", Predecessor: predecessor, Region: "ie", PredecessorRegion: "ie", Mode: "cold"}} |
| + case "gap": |
| + model.BareDelete("data-test", cont.Key) |
| + model.BareDelete("data-test", dbVersionKey(base, 0, 12, 12)) |
| + put(0, 13, 13) |
| + case "missing-pin": |
| + if err := model.DeleteVersion(t.Context(), "data-test", root.Key, root.VersionID); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "partial-list": |
| + model.PageSize, model.FailListAfterPages = 1, 1 |
| + case "disagreement": |
| + c.Holder.Generation = "unknown" |
| + case "bad-header": |
| + model.Put("data-test", dbVersionKey(base, 9, 1, 20), []byte("broken successor")) |
| + } |
| + writeDoorClaim(t, fx.srv, dbRequest{host: dbTestHost}, c) |
| + if mode == "holder-change" || mode == "op-change" { |
| + fx.srv.cfg.DBVersions = doorOpenHook{VersionedStore: model, open: func(ctx context.Context, bucket, key, version string) (io.ReadCloser, error) { |
| + rc, err := model.OpenVersion(ctx, bucket, key, version) |
| + next := cloneDoorClaim(c) |
| + if mode == "holder-change" { |
| + next.Holder.Session++ |
| + } else { |
| + next.Op.Exec = "changed" |
| + } |
| + writeDoorClaim(t, fx.srv, dbRequest{host: dbTestHost}, next) |
| + return rc, err |
| + }} |
| + } |
| + status, h, body := rawReq(t, "GET", fx.url(dbdoor.ListPath("bab", "titogo", dbTestHost, 9)), fx.member, "") |
| + if mode != "origin" && mode != "published" && mode != "unfinished" && mode != "unfinished-origin" { |
| + if status != 409 || h.Get("Retry-After") != "1" || strings.Contains(body, "files") || strings.Contains(body, "instances/") || strings.Contains(body, "genselect:") { |
| + t.Fatalf("refusal: %d %v %s", status, h, body) |
| + } |
| + return |
| + } |
| + if status != 200 { |
| + t.Fatalf("list: %d %s", status, body) |
| + } |
| + var list dbdoor.ListResponse |
| + if err := json.Unmarshal([]byte(body), &list); err != nil { |
| + t.Fatal(err) |
| + } |
| + if list.Protocol != 2 || len(list.Files) != 2 { |
| + t.Fatalf("list = %+v", list) |
| + } |
| + for i, want := range []genselect.File{root, cont} { |
| + f := list.Files[i] |
| + handle, err := verifyDBHandle(fx.srv.dbHandleKey, f.Handle, time.Now()) |
| + if err != nil || handle.Key != want.Key || handle.VersionID != want.VersionID || handle.Size != want.Size || f.Size != want.Size || f.Level != want.Level || f.MinTXID != want.MinTXID || f.MaxTXID != want.MaxTXID { |
| + t.Fatalf("file %d: %+v %+v %v; want %+v", i, f, handle, err, want) |
| + } |
| + model.Put("data-test", want.Key, []byte("overwritten after listing")) |
| + req, err := http.NewRequest("GET", fx.url(dbdoor.GetPath("bab", "titogo", dbTestHost, f.Level, f.MinTXID, f.MaxTXID)), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Authorization", "Bearer "+fx.member) |
| + req.Header.Set(dbdoor.HandleHeader, f.Handle) |
| + req.Header.Set("Range", "bytes=2-7") |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := io.ReadAll(res.Body) |
| + res.Body.Close() |
| + if err != nil || res.StatusCode != 206 || !bytes.Equal(got, doorLTXHeader(t, want.MinTXID, want.MaxTXID)[2:8]) || res.Header.Get(dbdoor.HandleHeader) != f.Handle { |
| + t.Fatalf("get: %d %x %v", res.StatusCode, got, err) |
| + } |
| + if err := model.DeleteVersion(t.Context(), "data-test", want.Key, want.VersionID); err != nil { |
| + t.Fatal(err) |
| + } |
| + res, err = http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + res.Body.Close() |
| + if res.StatusCode != 404 { |
| + t.Fatalf("missing version fell back: %d", res.StatusCode) |
| + } |
| + } |
| + if fx.opens.Load() != 0 { |
| + t.Fatal("opened unversioned replica") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/console/apidbclaim_http_test.go b/internal/console/apidbclaim_http_test.go| index bde54e1b0..7c461700a 100644 |
| --- a/internal/console/apidbclaim_http_test.go |
| +++ b/internal/console/apidbclaim_http_test.go |
| @@ -15,11 +15,13 @@ import ( |
| "strings" |
| "sync/atomic" |
| "testing" |
| + "time" |
| lsfile "github.com/benbjohnson/litestream/file" |
| "github.com/carlosframework/platform/internal/dbdoor" |
| "github.com/carlosframework/platform/internal/dbdoor/dbdoortest" |
| + "github.com/carlosframework/platform/internal/genselect" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/lease/leasetest" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -44,6 +46,7 @@ func enableDoorPlan(t *testing.T, fx *dbFx) *leasetest.VersionedBucket { |
| } |
| m := leasetest.NewVersionedBucket() |
| m.AddBucket("data-test") |
| + fx.srv.cfg.HomeRegion = "ie" |
| fx.srv.cfg.HostClaim = true |
| fx.srv.cfg.DBVersions = m |
| secret, err := fx.st.SigningKey() |
| @@ -55,12 +58,34 @@ func enableDoorPlan(t *testing.T, fx *dbFx) *leasetest.VersionedBucket { |
| } |
| func TestDoorPlanHTTPCompatibilityAndRestore(t *testing.T) { |
| - for _, enabled := range []bool{false, true} { |
| - t.Run(fmt.Sprint(enabled), func(t *testing.T) { |
| + for _, mode := range []string{"legacy", "origin", "published", "unfinished"} { |
| + t.Run(mode, func(t *testing.T) { |
| + enabled := mode != "legacy" |
| fx := dbFixture(t) |
| fx.replicate(t, dbTestHost, 3) |
| if enabled { |
| model := enableDoorPlan(t, fx) |
| + location := "instances/" + dbTestHost |
| + if mode == "published" || mode == "unfinished" { |
| + obs, err := hostclaim.Read(t.Context(), fx.rel, dbTestHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := cloneDoorClaim(obs.Claim) |
| + c.Holder.Generation = "live" |
| + marker := genselect.Marker{Op: "live", Host: dbTestHost, Incarnation: c.Holder.Incarnation, Box: "box", InstalledAt: time.Unix(1, 0).UTC()} |
| + raw, err := json.Marshal(marker) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + model.Put("data-test", location+"/generation.json", raw) |
| + if mode == "unfinished" { |
| + c.Holder.Generation = "next" |
| + c.Holder.Admissions = []hostclaim.AdmissionRecord{{Generation: "next", Predecessor: "live", Region: "ie", PredecessorRegion: "ie", Mode: "cold"}} |
| + } |
| + writeDoorClaim(t, fx.srv, dbRequest{host: dbTestHost}, c) |
| + location += "/g-live" |
| + } |
| root := filepath.Join(fx.root, "instances", dbTestHost, "ltx") |
| err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { |
| if err != nil { |
| @@ -85,7 +110,7 @@ func TestDoorPlanHTTPCompatibilityAndRestore(t *testing.T) { |
| if err != nil { |
| return err |
| } |
| - model.Put("data-test", dbLevelPrefix("instances/"+dbTestHost, level)+parts[1], b) |
| + model.Put("data-test", dbLevelPrefix(location, level)+parts[1], b) |
| return nil |
| }) |
| if err != nil { |
| @@ -180,14 +205,15 @@ func TestDoorPlanHTTPTruncationResumeAndFlagOff(t *testing.T) { |
| fx := dbFixture(t) |
| model := enableDoorPlan(t, fx) |
| key := dbVersionKey("instances/"+dbTestHost, 0, 1, 1) |
| - model.Put("data-test", key, []byte("original")) |
| + payload := append(doorLTXHeader(t, 1, 1), []byte("original")...) |
| + model.Put("data-test", key, payload) |
| var opens atomic.Int32 |
| fx.srv.cfg.DBVersions = doorOpenHook{VersionedStore: model, open: func(ctx context.Context, b, k, id string) (io.ReadCloser, error) { |
| rc, err := model.OpenVersion(ctx, b, k, id) |
| if err != nil { |
| return nil, err |
| } |
| - if opens.Add(1) == 1 { |
| + if opens.Add(1) == 2 { |
| return &dbRangeReader{Reader: io.LimitReader(rc, 3), Closer: rc}, nil |
| } |
| return rc, nil |
| @@ -221,7 +247,7 @@ func TestDoorPlanHTTPTruncationResumeAndFlagOff(t *testing.T) { |
| res = do(dbdoor.GetTail(0, 1, 1), h) |
| first, err := io.ReadAll(res.Body) |
| res.Body.Close() |
| - if !errors.Is(err, io.ErrUnexpectedEOF) || string(first) != "ori" || res.Header.Get(dbdoor.HandleHeader) != f.Handle { |
| + if !errors.Is(err, io.ErrUnexpectedEOF) || string(first) != string(payload[:3]) || res.Header.Get(dbdoor.HandleHeader) != f.Handle { |
| t.Fatal(string(first), err, res.Header) |
| } |
| model.Put("data-test", key, []byte("replacement")) |
| @@ -229,7 +255,7 @@ func TestDoorPlanHTTPTruncationResumeAndFlagOff(t *testing.T) { |
| res = do(dbdoor.GetTail(0, 1, 1), h) |
| rest, err := io.ReadAll(res.Body) |
| res.Body.Close() |
| - if err != nil || res.StatusCode != 206 || string(append(first, rest...)) != "original" { |
| + if err != nil || res.StatusCode != 206 || string(append(first, rest...)) != string(payload) { |
| t.Fatal(string(rest), err, res.StatusCode) |
| } |
| events, err := fx.st.Events(OpsAccountID, "titogo", 10) |
diff --git a/internal/console/apidbclaim_range_test.go b/internal/console/apidbclaim_range_test.go| index 937c44ab6..883e2bf8c 100644 |
| --- a/internal/console/apidbclaim_range_test.go |
| +++ b/internal/console/apidbclaim_range_test.go |
| @@ -138,6 +138,7 @@ func TestDoorPlanHTTPDeepRangeKeepsWatchdogAlive(t *testing.T) { |
| const offset = 4 << 20 |
| want := bytes.Repeat([]byte("tail"), 64<<10) |
| payload := append(bytes.Repeat([]byte("p"), offset), want...) |
| + copy(payload, doorLTXHeader(t, 1, 1)) |
| key := dbVersionKey("instances/"+dbTestHost, 0, 1, 1) |
| id := model.Put("data-test", key, payload) |
| var read, fullOpens, rangeOpens atomic.Int64 |
| @@ -162,6 +163,8 @@ func TestDoorPlanHTTPDeepRangeKeepsWatchdogAlive(t *testing.T) { |
| if err := json.Unmarshal([]byte(body), &list); err != nil || status != 200 || len(list.Files) != 1 { |
| t.Fatalf("list = %d %s, %v", status, body, err) |
| } |
| + read.Store(0) |
| + fullOpens.Store(0) |
| model.Put("data-test", key, []byte("replacement")) |
| ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) |
| defer cancel() |
diff --git a/internal/console/apidbclaim_test.go b/internal/console/apidbclaim_test.go| index 6c5639012..560258018 100644 |
| --- a/internal/console/apidbclaim_test.go |
| +++ b/internal/console/apidbclaim_test.go |
| @@ -68,7 +68,7 @@ func doorPlanFixture(t *testing.T, legacy bool) (*Server, dbRequest, provision.R |
| } |
| model := leasetest.NewVersionedBucket() |
| model.AddBucket("data") |
| - s := &Server{cfg: Config{HostClaim: true, Releases: store, HibernateBucket: "data", DBVersions: model}, dbHandleKey: []byte(strings.Repeat("s", 32))} |
| + s := &Server{cfg: Config{HomeRegion: "ie", HostClaim: true, Releases: store, HibernateBucket: "data", DBVersions: model}, dbHandleKey: []byte(strings.Repeat("s", 32))} |
| g := dbRequest{acct: &Account{Sqid: "bab"}, app: "jam", host: rec.Host} |
| g.claim, err = s.dbClaim(t.Context(), g, rec) |
| if err != nil { |
| @@ -134,7 +134,7 @@ func TestDoorPlanListingAndPinnedOpen(t *testing.T) { |
| loc := g.claim.Claim.Holder.Location |
| key := dbVersionKey(loc, 0, 1, 1) |
| m.Put("data", key, []byte("older")) |
| - id := m.Put("data", key, []byte("original")) |
| + id := m.Put("data", key, append(doorLTXHeader(t, 1, 1), []byte("original")...)) |
| deleted := dbVersionKey(loc, 0, 2, 2) |
| m.Put("data", deleted, []byte("hidden")) |
| m.BareDelete("data", deleted) |
| @@ -167,7 +167,7 @@ func TestDoorPlanListingAndPinnedOpen(t *testing.T) { |
| next.Holder.Incarnation, _ = hostclaim.Incarnation("bab", "jam", g.host, next.Holder.CreatedAt) |
| next.Holder.Location = "instances/" + g.host + "/" + next.Holder.Incarnation |
| writeDoorClaim(t, s, g, next) |
| - rc, err := rd.OpenLTXFile(t.Context(), 0, 1, 1, 2, 3) |
| + rc, err := rd.OpenLTXFile(t.Context(), 0, 1, 1, 102, 3) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -203,7 +203,7 @@ func TestDoorPlanBracket(t *testing.T) { |
| for _, change := range []string{"holder", "op", "same-gen-op", "chore", "absent", "malformed", "read-error"} { |
| t.Run(change, func(t *testing.T) { |
| s, g, _, m := doorPlanFixture(t, false) |
| - m.Put("data", dbVersionKey(g.claim.Claim.Holder.Location, 0, 1, 1), []byte("old")) |
| + m.Put("data", dbVersionKey(g.claim.Claim.Holder.Location, 0, 1, 1), doorLTXHeader(t, 1, 1)) |
| fault := &doorReadFault{Store: s.cfg.Releases} |
| s.cfg.Releases = fault |
| s.cfg.DBVersions = doorVersionHook{VersionedStore: m, after: func() { |
| @@ -243,7 +243,7 @@ func TestDoorPlanBracket(t *testing.T) { |
| } |
| func TestDoorPlanClaimRefusals(t *testing.T) { |
| - for _, change := range []string{"absent", "free", "deleting", "parking", "purging", "owner", "incarnation", "location", "generation", "malformed", "read-error"} { |
| + for _, change := range []string{"absent", "free", "deleting", "parking", "purging", "owner", "incarnation", "location", "malformed", "read-error"} { |
| t.Run(change, func(t *testing.T) { |
| s, g, rec, m := doorPlanFixture(t, true) |
| m.FailOn = func(op, key string) error { t.Fatalf("refusal reached data bucket: %s %s", op, key); return nil } |
| @@ -267,9 +267,6 @@ func TestDoorPlanClaimRefusals(t *testing.T) { |
| case "location": |
| c.Holder.Location = "" |
| want = hostclaim.ErrMalformed |
| - case "generation": |
| - c.Holder.Generation = "g-current" |
| - want = errDBGeneration |
| case "malformed": |
| c.V = 99 |
| want = hostclaim.ErrMalformed |
diff --git a/internal/dbdoor/client.go b/internal/dbdoor/client.go| index 65d637f2d..fb54df849 100644 |
| --- a/internal/dbdoor/client.go |
| +++ b/internal/dbdoor/client.go |
| @@ -80,6 +80,20 @@ func (c *Client) SetLogger(l *slog.Logger) { c.logger = l } |
| // useMetadata is ignored; nothing in a non-timestamp restore reads |
| // CreatedAt except debug logs. |
| func (c *Client) LTXFiles(ctx context.Context, level int, seek ltx.TXID, _ bool) (ltx.FileIterator, error) { |
| + c.handles.listMu.Lock() |
| + defer c.handles.listMu.Unlock() |
| + files, ok := c.planFiles(level) |
| + if !ok { |
| + var err error |
| + files, err = c.listFiles(ctx, level) |
| + if err != nil { |
| + return nil, err |
| + } |
| + } |
| + return fileIterator(files, level, seek), nil |
| +} |
| + |
| +func (c *Client) listFiles(ctx context.Context, level int) ([]LTXFile, error) { |
| res, err := c.do(ctx, ListTail(level), http.Header{}) |
| if err != nil { |
| return nil, err |
| @@ -92,10 +106,10 @@ func (c *Client) LTXFiles(ctx context.Context, level int, seek ltx.TXID, _ bool) |
| if err := json.NewDecoder(io.LimitReader(res.Body, 16<<20)).Decode(&list); err != nil { |
| return nil, fmt.Errorf("dbdoor: unreadable listing for level %d: %w", level, err) |
| } |
| - files, err := c.rememberFiles(level, list) |
| - if err != nil { |
| - return nil, err |
| - } |
| + return c.rememberFiles(level, list) |
| +} |
| + |
| +func fileIterator(files []LTXFile, level int, seek ltx.TXID) ltx.FileIterator { |
| infos := make([]*ltx.FileInfo, 0, len(files)) |
| for _, f := range files { |
| if ltx.TXID(f.MinTXID) < seek { |
| @@ -106,7 +120,7 @@ func (c *Client) LTXFiles(ctx context.Context, level int, seek ltx.TXID, _ bool) |
| Size: f.Size, CreatedAt: f.CreatedAt, |
| }) |
| } |
| - return ltx.NewFileInfoSliceIterator(infos), nil |
| + return ltx.NewFileInfoSliceIterator(infos) |
| } |
| // OpenLTXFile opens one file by coordinate, sending the S3 client's exact |
diff --git a/internal/dbdoor/client_handles.go b/internal/dbdoor/client_handles.go| index 4ee7e8f20..002095564 100644 |
| --- a/internal/dbdoor/client_handles.go |
| +++ b/internal/dbdoor/client_handles.go |
| @@ -9,7 +9,9 @@ import ( |
| const HandleHeader = "X-Carlos-DB-Handle" |
| type dbHandleState struct { |
| + listMu sync.Mutex |
| mu sync.Mutex |
| + plan []LTXFile |
| pinned bool |
| files map[string]LTXFile |
| } |
| @@ -23,6 +25,9 @@ func (c *Client) rememberFiles(level int, list ListResponse) ([]LTXFile, error) |
| if c.handles.pinned && list.Protocol != 2 { |
| return nil, errors.New("dbdoor: pinned listing protocol disappeared") |
| } |
| + if list.Plan && list.Protocol != 2 { |
| + return nil, errors.New("dbdoor: invalid pinned listing") |
| + } |
| if list.Protocol == 0 { |
| return list.Files, nil |
| } |
| @@ -33,8 +38,8 @@ func (c *Client) rememberFiles(level int, list ListResponse) ([]LTXFile, error) |
| seen := map[string]bool{} |
| out := make([]LTXFile, 0, len(list.Files)) |
| for _, f := range list.Files { |
| - k := GetTail(level, f.MinTXID, f.MaxTXID) |
| - if f.Level != level || f.MinTXID == 0 || f.MaxTXID < f.MinTXID || f.Handle == "" || f.Size < 0 || seen[k] { |
| + k := GetTail(f.Level, f.MinTXID, f.MaxTXID) |
| + if (!list.Plan && f.Level != level) || f.Level < 0 || f.Level > MaxLevel || f.MinTXID == 0 || f.MaxTXID < f.MinTXID || f.Handle == "" || f.Size < 0 || seen[k] { |
| return nil, errors.New("dbdoor: invalid pinned listing") |
| } |
| seen[k] = true |
| @@ -47,6 +52,12 @@ func (c *Client) rememberFiles(level int, list ListResponse) ([]LTXFile, error) |
| files[k] = f |
| out = append(out, f) |
| } |
| + if list.Plan { |
| + if c.handles.plan == nil { |
| + c.handles.plan = out |
| + } |
| + out = filesAtLevel(c.handles.plan, level) |
| + } |
| c.handles.files = files |
| c.handles.pinned = true |
| return out, nil |
| @@ -63,3 +74,19 @@ func (c *Client) fileHandle(level int, lo, hi uint64) (string, error) { |
| } |
| return "", fmt.Errorf("dbdoor: coordinate has no pinned handle: %d %s", level, Span(lo, hi)) |
| } |
| + |
| +func filesAtLevel(files []LTXFile, level int) []LTXFile { |
| + out := make([]LTXFile, 0) |
| + for _, f := range files { |
| + if f.Level == level { |
| + out = append(out, f) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func (c *Client) planFiles(level int) ([]LTXFile, bool) { |
| + c.handles.mu.Lock() |
| + defer c.handles.mu.Unlock() |
| + return filesAtLevel(c.handles.plan, level), c.handles.plan != nil |
| +} |
diff --git a/internal/dbdoor/client_handles_test.go b/internal/dbdoor/client_handles_test.go| index cc0c5af00..52fccd044 100644 |
| --- a/internal/dbdoor/client_handles_test.go |
| +++ b/internal/dbdoor/client_handles_test.go |
| @@ -91,3 +91,48 @@ func TestHandleProtocolNeverFallsBack(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestHandleProtocolCompletePlan(t *testing.T) { |
| + lists := 0 |
| + c := NewClient(func(ctx context.Context, tail string, h http.Header) (*http.Response, error) { |
| + if strings.Count(tail, "/") == 2 { |
| + lists++ |
| + if lists != 1 { |
| + t.Fatal("relisted during one restore") |
| + } |
| + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"protocol":2,"plan":true,"files":[{"level":9,"min_txid":1,"max_txid":11,"size":100,"handle":"root"},{"level":1,"min_txid":10,"max_txid":13,"size":100,"handle":"continuation"}]}`))}, nil |
| + } |
| + if h.Get(HandleHeader) != "continuation" { |
| + t.Fatal("lost selected handle", h) |
| + } |
| + return &http.Response{StatusCode: 206, Header: h.Clone(), Body: io.NopCloser(strings.NewReader("bytes"))}, nil |
| + }) |
| + for _, level := range []int{9, 8, 1, 0, 9} { |
| + it, err := c.LTXFiles(t.Context(), level, 0, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var count int |
| + for it.Next() { |
| + count++ |
| + if it.Item().Level != level { |
| + t.Fatal(it.Item()) |
| + } |
| + } |
| + if err := it.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := 0 |
| + if level == 9 || level == 1 { |
| + want = 1 |
| + } |
| + if count != want { |
| + t.Fatalf("level %d: %d files, want %d", level, count, want) |
| + } |
| + } |
| + rc, err := c.OpenLTXFile(t.Context(), 1, 10, 13, 0, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rc.Close() |
| +} |
diff --git a/internal/dbdoor/coords.go b/internal/dbdoor/coords.go| index 689b02d2f..444bf4c3b 100644 |
| --- a/internal/dbdoor/coords.go |
| +++ b/internal/dbdoor/coords.go |
| @@ -40,10 +40,11 @@ type LTXFile struct { |
| CreatedAt time.Time `json:"created_at"` |
| } |
| -// ListResponse is ordered by MinTXID ASCENDING: restoreLevelCursor assumes |
| -// filename order, which S3 gives because the names are zero-padded hex. |
| +// ListResponse contains one ordered level, or a complete restore plan with |
| +// its root first. Clients retain a complete plan across level requests. |
| type ListResponse struct { |
| Protocol int `json:"protocol,omitempty"` |
| + Plan bool `json:"plan,omitempty"` |
| Files []LTXFile `json:"files"` |
| } |