diff --git a/packages/api/internal/api/api.gen.go b/packages/api/internal/api/api.gen.go index f30712a9f7..392823c421 100644 --- a/packages/api/internal/api/api.gen.go +++ b/packages/api/internal/api/api.gen.go @@ -165,6 +165,24 @@ func (e NodeStatus) Valid() bool { } } +// Defines values for OrderDirection. +const ( + Asc OrderDirection = "asc" + Desc OrderDirection = "desc" +) + +// Valid indicates whether the value is a known member of the OrderDirection enum. +func (e OrderDirection) Valid() bool { + switch e { + case Asc: + return true + case Desc: + return true + default: + return false + } +} + // Defines values for SandboxOnTimeout. const ( Kill SandboxOnTimeout = "kill" @@ -728,6 +746,9 @@ type NodeStatusChange struct { Status NodeStatus `json:"status"` } +// OrderDirection Sort direction +type OrderDirection string + // ResumedSandbox defines model for ResumedSandbox. type ResumedSandbox struct { // AutoPause Automatically pauses the sandbox after the timeout @@ -1659,6 +1680,9 @@ type GetV2SandboxesParams struct { // State Filter sandboxes by one or more states State *[]SandboxState `form:"state,omitempty" json:"state,omitempty"` + // Order Sort direction by sandbox start time. Defaults to desc (newest first). + Order *OrderDirection `form:"order,omitempty" json:"order,omitempty"` + // NextToken Cursor to start the list from NextToken *PaginationNextToken `form:"nextToken,omitempty" json:"nextToken,omitempty"` @@ -5527,6 +5551,18 @@ func NewGetV2SandboxesRequest(server string, params *GetV2SandboxesParams) (*htt } + if params.Order != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "order", *params.Order, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + if params.NextToken != nil { if queryFrag, err := runtime.StyleParamWithOptions("form", true, "nextToken", *params.NextToken, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { @@ -13514,6 +13550,14 @@ func (siw *ServerInterfaceWrapper) GetV2Sandboxes(c *gin.Context) { return } + // ------------- Optional query parameter "order" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "order", c.Request.URL.Query(), ¶ms.Order, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter order: %w", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "nextToken" ------------- err = runtime.BindQueryParameterWithOptions("form", true, false, "nextToken", c.Request.URL.Query(), ¶ms.NextToken, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) @@ -14008,205 +14052,206 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", - "AAD//w==", + "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", + "YcY2zmFZ+c26ahV/FwLP1+v/FwAA//8=", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/api/internal/handlers/sandboxes_list.go b/packages/api/internal/handlers/sandboxes_list.go index 55076b0226..f3d059f2b6 100644 --- a/packages/api/internal/handlers/sandboxes_list.go +++ b/packages/api/internal/handlers/sandboxes_list.go @@ -38,6 +38,7 @@ func (a *APIStore) getPausedSandboxes( queryLimit int32, cursorTime time.Time, cursorID string, + order utils.SortDirection, ) ([]utils.PaginatedSandbox, error) { queryMetadata := dbtypes.JSONBStringMap{} if metadataFilter != nil { @@ -49,7 +50,7 @@ func (a *APIStore) getPausedSandboxes( // O(rows × array_size) and caused 40s+ query times with large arrays. dbLimit := queryLimit + int32(len(runningSandboxesIDs)) - snapshots, err := a.throttledGetSnapshots(ctx, queries.GetSnapshotsWithCursorParams{ + snapshots, err := a.throttledGetSnapshots(ctx, order, queries.GetSnapshotsWithCursorParams{ Limit: dbLimit, TeamID: teamID, Metadata: queryMetadata, @@ -149,6 +150,20 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam states = append(states, *params.State...) } + // Sort direction by start time. Defaults to descending (newest first). + order := utils.SortDesc + if params.Order != nil { + if !params.Order.Valid() { + a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid order parameter: %s", *params.Order)) + + return + } + + if *params.Order == api.Asc { + order = utils.SortAsc + } + } + // Initialize pagination pagination, err := utils.NewPagination[utils.PaginatedSandbox]( utils.PaginationParams{ @@ -159,6 +174,7 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam DefaultLimit: sandboxesDefaultLimit, MaxLimit: sandboxesMaxLimit, DefaultID: utils.MaxSandboxID, + Order: order, }, ) if err != nil { @@ -204,7 +220,7 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam c.Header("X-Total-Running", strconv.Itoa(len(runningSandboxList))) // Filter based on cursor - runningSandboxList = utils.FilterBasedOnCursor(runningSandboxList, pagination.CursorTime(), pagination.CursorID()) + runningSandboxList = utils.FilterBasedOnCursor(runningSandboxList, pagination.CursorTime(), pagination.CursorID(), order) sandboxes = append(sandboxes, runningSandboxList...) } @@ -219,7 +235,7 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam runningSandboxesIDs = append(runningSandboxesIDs, info.SandboxID) } - pausedSandboxList, err := a.getPausedSandboxes(ctx, team.ID, runningSandboxesIDs, metadataFilter, pagination.QueryLimit(), pagination.CursorTime(), pagination.CursorID()) + pausedSandboxList, err := a.getPausedSandboxes(ctx, team.ID, runningSandboxesIDs, metadataFilter, pagination.QueryLimit(), pagination.CursorTime(), pagination.CursorID(), order) if err != nil { logger.L().Error(ctx, "Error getting paused sandboxes", zap.Error(err)) a.sendAPIStoreError(c, http.StatusInternalServerError, "Error getting paused sandboxes") @@ -229,14 +245,14 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam pausingSandboxList := instanceInfoToPaginatedSandboxes(pausingSandboxes) pausingSandboxList = utils.FilterSandboxesOnMetadata(pausingSandboxList, metadataFilter) - pausingSandboxList = utils.FilterBasedOnCursor(pausingSandboxList, pagination.CursorTime(), pagination.CursorID()) + pausingSandboxList = utils.FilterBasedOnCursor(pausingSandboxList, pagination.CursorTime(), pagination.CursorID(), order) sandboxes = append(sandboxes, pausedSandboxList...) sandboxes = append(sandboxes, pausingSandboxList...) } // We need to sort again after merging running and paused sandboxes - utils.SortPaginatedSandboxesDesc(sandboxes) + utils.SortPaginatedSandboxes(sandboxes, order) sandboxes = pagination.ProcessResultsWithHeader(c, sandboxes, func(s utils.PaginatedSandbox) (time.Time, string) { return s.PaginationTimestamp, s.SandboxID @@ -329,7 +345,12 @@ func instanceInfoToPaginatedSandboxes(runningSandboxes []sandbox.Sandbox) []util EnvdVersion: info.EnvdVersion, VolumeMounts: convertFromDBMountsToAPIMounts(info.VolumeMounts), }, - PaginationTimestamp: info.StartTime, + // Paused snapshots come from Postgres at microsecond precision, but running + // sandboxes carry nanosecond StartTime from time.Now(). Truncate only the + // pagination key (not the public StartedAt) so the in-memory sort/cursor and + // the SQL predicate agree at the running/paused boundary; otherwise asc + // pagination can re-emit rows that share a truncated microsecond with the cursor. + PaginationTimestamp: info.StartTime.Truncate(time.Microsecond), } if info.Metadata != nil { @@ -358,12 +379,35 @@ func convertFromDBMountsToAPIMounts(mounts []*dbtypes.SandboxVolumeMountConfig) return &results } -// throttledGetSnapshots runs GetSnapshotsWithCursor gated by the sandbox list semaphore. -func (a *APIStore) throttledGetSnapshots(ctx context.Context, params queries.GetSnapshotsWithCursorParams) ([]queries.GetSnapshotsWithCursorRow, error) { +// throttledGetSnapshots runs the cursor snapshot query gated by the sandbox list +// semaphore, picking the ascending or descending keyset query based on the requested +// order. The ascending query returns an identically-shaped row, converted back to the +// descending row type so callers share a single conversion path. +func (a *APIStore) throttledGetSnapshots(ctx context.Context, order utils.SortDirection, params queries.GetSnapshotsWithCursorParams) ([]queries.GetSnapshotsWithCursorRow, error) { if err := a.sandboxListSem.Acquire(ctx, 1); err != nil { return nil, err } defer a.sandboxListSem.Release(1) - return a.sqlcDB.GetSnapshotsWithCursor(ctx, params) + if order != utils.SortAsc { + return a.sqlcDB.GetSnapshotsWithCursor(ctx, params) + } + + ascRows, err := a.sqlcDB.GetSnapshotsWithCursorAsc(ctx, queries.GetSnapshotsWithCursorAscParams{ + Limit: params.Limit, + TeamID: params.TeamID, + Metadata: params.Metadata, + CursorTime: params.CursorTime, + CursorID: params.CursorID, + }) + if err != nil { + return nil, err + } + + rows := make([]queries.GetSnapshotsWithCursorRow, len(ascRows)) + for i, r := range ascRows { + rows[i] = queries.GetSnapshotsWithCursorRow(r) + } + + return rows, nil } diff --git a/packages/api/internal/handlers/sandboxes_list_test.go b/packages/api/internal/handlers/sandboxes_list_test.go new file mode 100644 index 0000000000..25aa738fb2 --- /dev/null +++ b/packages/api/internal/handlers/sandboxes_list_test.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/api/internal/sandbox" +) + +// TestInstanceInfoToPaginatedSandboxes_PaginationTimestampPrecision guards the keyset +// pagination boundary: running sandboxes carry nanosecond StartTime while paused +// snapshots are microsecond-precision in Postgres. Only the PaginationTimestamp keyset +// value is truncated to microseconds (so the in-memory sort/cursor and the SQL predicate +// agree); the public StartedAt must keep its full precision so list responses match the +// sandbox detail endpoint. +func TestInstanceInfoToPaginatedSandboxes_PaginationTimestampPrecision(t *testing.T) { + t.Parallel() + + // Sub-microsecond bits set (…789 ns) so truncation is observable. + start := time.Date(2026, 1, 2, 3, 4, 5, 123456789, time.UTC) + + sandboxes := instanceInfoToPaginatedSandboxes([]sandbox.Sandbox{ + {SandboxID: "sbx", StartTime: start, State: sandbox.StateRunning}, + }) + + require.Len(t, sandboxes, 1) + + assert.Equal(t, start, sandboxes[0].StartedAt, "public StartedAt must keep full precision") + assert.Equal(t, start.Truncate(time.Microsecond), sandboxes[0].PaginationTimestamp, + "pagination key must be microsecond-aligned") + assert.Zero(t, sandboxes[0].PaginationTimestamp.Nanosecond()%1000, + "pagination key should have no sub-microsecond bits") +} diff --git a/packages/api/internal/utils/pagination.go b/packages/api/internal/utils/pagination.go index 216c4d394f..2a111fe277 100644 --- a/packages/api/internal/utils/pagination.go +++ b/packages/api/internal/utils/pagination.go @@ -16,6 +16,15 @@ func generateCursor(timestamp time.Time, id string) string { return base64.URLEncoding.EncodeToString([]byte(cursor)) } +// SortDirection is the order in which keyset-paginated results are returned. +// The zero value is SortDesc, preserving the default newest-first behavior. +type SortDirection int + +const ( + SortDesc SortDirection = iota + SortAsc +) + // PaginationParams holds pagination parameters from the API request type PaginationParams struct { Limit *int32 @@ -27,6 +36,10 @@ type PaginationConfig struct { DefaultLimit int32 MaxLimit int32 DefaultID string // Default cursor ID when no token is provided (e.g., max UUID or max sandbox ID) + // Order controls the first-page cursor when no token is provided. For + // SortDesc the first page starts at "now" (newest first); for SortAsc it + // starts at the zero time (oldest first). + Order SortDirection } // Cursor represents a parsed pagination cursor @@ -60,7 +73,7 @@ func NewPagination[T any](params PaginationParams, config PaginationConfig) (*Pa // Parse cursor token var err error - p.cursor, err = parseCursorToken(params.NextToken, config.DefaultID) + p.cursor, err = parseCursorToken(params.NextToken, config) if err != nil { return nil, fmt.Errorf("invalid next token: %w", err) } @@ -132,7 +145,7 @@ func (p *Pagination[T]) setHeader(c *gin.Context) { } // parseCursorToken parses a cursor token, returning default values if token is nil/empty -func parseCursorToken(token *string, defaultID string) (Cursor, error) { +func parseCursorToken(token *string, config PaginationConfig) (Cursor, error) { if token != nil && *token != "" { cursorTime, cursorID, err := ParseCursor(*token) if err != nil { @@ -142,6 +155,13 @@ func parseCursorToken(token *string, defaultID string) (Cursor, error) { return Cursor{Time: cursorTime, ID: cursorID}, nil } - // Default to current time and provided default ID to get the first page - return Cursor{Time: time.Now(), ID: defaultID}, nil + // Default cursor for the first page. For descending order we start at "now" + // (so everything older is included); for ascending order we start at the + // zero time (so everything newer is included). + defaultTime := time.Now() + if config.Order == SortAsc { + defaultTime = time.Time{} + } + + return Cursor{Time: defaultTime, ID: config.DefaultID}, nil } diff --git a/packages/api/internal/utils/pagination_test.go b/packages/api/internal/utils/pagination_test.go index 7a95c15c18..72adae311c 100644 --- a/packages/api/internal/utils/pagination_test.go +++ b/packages/api/internal/utils/pagination_test.go @@ -191,6 +191,42 @@ func TestPagination_CursorTime(t *testing.T) { assert.Equal(t, timestamp, p.CursorTime()) } +func TestPagination_DefaultCursorByDirection(t *testing.T) { + t.Parallel() + + t.Run("descending defaults to now", func(t *testing.T) { + t.Parallel() + + before := time.Now() + p, err := NewPagination[testItem](PaginationParams{}, PaginationConfig{ + DefaultLimit: 10, + MaxLimit: 100, + DefaultID: "default-id", + }) + require.NoError(t, err) + after := time.Now() + + assert.False(t, p.CursorTime().Before(before), "default cursor time should be ~now") + assert.False(t, p.CursorTime().After(after), "default cursor time should be ~now") + assert.Equal(t, "default-id", p.CursorID()) + }) + + t.Run("ascending defaults to zero time", func(t *testing.T) { + t.Parallel() + + p, err := NewPagination[testItem](PaginationParams{}, PaginationConfig{ + DefaultLimit: 10, + MaxLimit: 100, + DefaultID: "default-id", + Order: SortAsc, + }) + require.NoError(t, err) + + assert.True(t, p.CursorTime().IsZero(), "ascending default cursor time should be the zero time") + assert.Equal(t, "default-id", p.CursorID()) + }) +} + func TestPagination_CursorID(t *testing.T) { t.Parallel() config := PaginationConfig{ diff --git a/packages/api/internal/utils/sandboxes_list.go b/packages/api/internal/utils/sandboxes_list.go index d16278e9c4..fe334033f8 100644 --- a/packages/api/internal/utils/sandboxes_list.go +++ b/packages/api/internal/utils/sandboxes_list.go @@ -77,14 +77,25 @@ func ParseCursor(cursor string) (time.Time, string, error) { return cursorTime, parts[1], nil } -func FilterBasedOnCursor(sandboxes []PaginatedSandbox, cursorTime time.Time, cursorID string) []PaginatedSandbox { - // Apply cursor-based filtering if cursor is provided +// FilterBasedOnCursor keeps only the sandboxes that fall after the cursor in the +// requested order. It compares on PaginationTimestamp (the microsecond-aligned keyset +// value), not the public StartedAt, so running and paused sandboxes share the same +// precision as the SQL predicate. Descending order pages through +// (timestamp DESC, sandbox_id ASC); ascending order is the exact reverse +// (timestamp ASC, sandbox_id DESC). +func FilterBasedOnCursor(sandboxes []PaginatedSandbox, cursorTime time.Time, cursorID string, order SortDirection) []PaginatedSandbox { var filteredSandboxes []PaginatedSandbox for _, sandbox := range sandboxes { - // Take sandboxes with start time before cursor time OR - // same start time but sandboxID greater than cursor ID (for stability) - if sandbox.StartedAt.Before(cursorTime) || - (sandbox.StartedAt.Equal(cursorTime) && sandbox.SandboxID > cursorID) { + var include bool + if order == SortAsc { + include = sandbox.PaginationTimestamp.After(cursorTime) || + (sandbox.PaginationTimestamp.Equal(cursorTime) && sandbox.SandboxID < cursorID) + } else { + include = sandbox.PaginationTimestamp.Before(cursorTime) || + (sandbox.PaginationTimestamp.Equal(cursorTime) && sandbox.SandboxID > cursorID) + } + + if include { filteredSandboxes = append(filteredSandboxes, sandbox) } } @@ -92,18 +103,36 @@ func FilterBasedOnCursor(sandboxes []PaginatedSandbox, cursorTime time.Time, cur return filteredSandboxes } -// SortPaginatedSandboxesDesc sorts the sandboxes by StartedAt (descending), -// then by SandboxID (ascending) for stability -func SortPaginatedSandboxesDesc(sandboxes []PaginatedSandbox) { +// SortPaginatedSandboxes sorts the sandboxes by PaginationTimestamp then SandboxID for +// stable pagination. It uses PaginationTimestamp (the microsecond-aligned keyset value), +// not the public StartedAt, so the order matches the cursor filter and SQL predicate. +// Descending order is timestamp DESC, SandboxID ASC; ascending order is the exact +// reverse (timestamp ASC, SandboxID DESC) so it maps onto a backward scan of the +// (team_id, sandbox_started_at DESC, sandbox_id) index. +func SortPaginatedSandboxes(sandboxes []PaginatedSandbox, order SortDirection) { slices.SortFunc(sandboxes, func(a, b PaginatedSandbox) int { - if !a.StartedAt.Equal(b.StartedAt) { - return b.StartedAt.Compare(a.StartedAt) + if !a.PaginationTimestamp.Equal(b.PaginationTimestamp) { + if order == SortAsc { + return a.PaginationTimestamp.Compare(b.PaginationTimestamp) + } + + return b.PaginationTimestamp.Compare(a.PaginationTimestamp) + } + + if order == SortAsc { + return strings.Compare(b.SandboxID, a.SandboxID) } return strings.Compare(a.SandboxID, b.SandboxID) }) } +// SortPaginatedSandboxesDesc preserves the descending-only entry point used by the +// legacy (v1) list endpoint. +func SortPaginatedSandboxesDesc(sandboxes []PaginatedSandbox) { + SortPaginatedSandboxes(sandboxes, SortDesc) +} + func FilterSandboxesOnMetadata(sandboxes []PaginatedSandbox, metadata *map[string]string) []PaginatedSandbox { if metadata == nil { return sandboxes diff --git a/packages/api/internal/utils/sandboxes_list_test.go b/packages/api/internal/utils/sandboxes_list_test.go index 1aab03dd8b..cb7c7dc59d 100644 --- a/packages/api/internal/utils/sandboxes_list_test.go +++ b/packages/api/internal/utils/sandboxes_list_test.go @@ -2,11 +2,107 @@ package utils import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/api/internal/api" ) +func newPaginatedSandbox(id string, startedAt time.Time) PaginatedSandbox { + return PaginatedSandbox{ + ListedSandbox: api.ListedSandbox{ + SandboxID: id, + StartedAt: startedAt, + }, + PaginationTimestamp: startedAt, + } +} + +func sandboxIDs(sandboxes []PaginatedSandbox) []string { + ids := make([]string, len(sandboxes)) + for i, s := range sandboxes { + ids[i] = s.SandboxID + } + + return ids +} + +func TestSortPaginatedSandboxes(t *testing.T) { + t.Parallel() + + t0 := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + t1 := time.Date(2024, 1, 1, 11, 0, 0, 0, time.UTC) + + // Two sandboxes share t1 to exercise the SandboxID tie-break. + build := func() []PaginatedSandbox { + return []PaginatedSandbox{ + newPaginatedSandbox("b", t1), + newPaginatedSandbox("a", t1), + newPaginatedSandbox("c", t0), + } + } + + t.Run("descending: started_at desc, sandbox_id asc", func(t *testing.T) { + t.Parallel() + + sandboxes := build() + SortPaginatedSandboxes(sandboxes, SortDesc) + assert.Equal(t, []string{"a", "b", "c"}, sandboxIDs(sandboxes)) + }) + + t.Run("ascending: started_at asc, sandbox_id desc", func(t *testing.T) { + t.Parallel() + + sandboxes := build() + SortPaginatedSandboxes(sandboxes, SortAsc) + assert.Equal(t, []string{"c", "b", "a"}, sandboxIDs(sandboxes)) + }) + + t.Run("Desc wrapper matches descending", func(t *testing.T) { + t.Parallel() + + sandboxes := build() + SortPaginatedSandboxesDesc(sandboxes) + assert.Equal(t, []string{"a", "b", "c"}, sandboxIDs(sandboxes)) + }) +} + +func TestFilterBasedOnCursor(t *testing.T) { + t.Parallel() + + t0 := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + t1 := time.Date(2024, 1, 1, 11, 0, 0, 0, time.UTC) + t2 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + sandboxes := []PaginatedSandbox{ + newPaginatedSandbox("older", t0), + newPaginatedSandbox("cursor-a", t1), + newPaginatedSandbox("cursor-m", t1), + newPaginatedSandbox("cursor-z", t1), + newPaginatedSandbox("newer", t2), + } + + t.Run("descending keeps older and equal-time greater id", func(t *testing.T) { + t.Parallel() + + // Cursor at (t1, "cursor-m"): next page is everything strictly "after" it + // in started_at DESC, sandbox_id ASC order. + got := FilterBasedOnCursor(sandboxes, t1, "cursor-m", SortDesc) + assert.ElementsMatch(t, []string{"cursor-z", "older"}, sandboxIDs(got)) + }) + + t.Run("ascending keeps newer and equal-time smaller id", func(t *testing.T) { + t.Parallel() + + // Cursor at (t1, "cursor-m"): next page is everything strictly "after" it + // in started_at ASC, sandbox_id DESC order. + got := FilterBasedOnCursor(sandboxes, t1, "cursor-m", SortAsc) + assert.ElementsMatch(t, []string{"cursor-a", "newer"}, sandboxIDs(got)) + }) +} + func TestParseFilters(t *testing.T) { t.Parallel() t.Run("happy path", func(t *testing.T) { diff --git a/packages/db/pkg/tests/snapshots/snapshot_latest_assignment_test.go b/packages/db/pkg/tests/snapshots/snapshot_latest_assignment_test.go index c33b12bfda..747833ceb7 100644 --- a/packages/db/pkg/tests/snapshots/snapshot_latest_assignment_test.go +++ b/packages/db/pkg/tests/snapshots/snapshot_latest_assignment_test.go @@ -119,6 +119,68 @@ func TestGetSnapshotsWithCursor_ReturnsLatestAssignment(t *testing.T) { "GetSnapshotsWithCursor should return the build from the latest assignment") } +// TestGetSnapshotsWithCursorAsc_OrdersOldestFirstAndPaginates verifies the ascending +// keyset query returns snapshots oldest-first and that its cursor predicate walks the +// pages without gaps or overlaps. +func TestGetSnapshotsWithCursorAsc_OrdersOldestFirstAndPaginates(t *testing.T) { + t.Parallel() + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + baseTemplateID := testutils.CreateTestTemplate(t, db, teamID) + + // Create three snapshots with strictly increasing sandbox_started_at. + oldestToNewest := make([]string, 0, 3) + for range 3 { + sandboxID := "sandbox-" + uuid.New().String() + snapshotTemplateID := "snapshot-template-" + uuid.New().String() + testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, teamID, baseTemplateID) + oldestToNewest = append(oldestToNewest, sandboxID) + time.Sleep(10 * time.Millisecond) + } + + // First page (zero-time / MaxSandboxID cursor) mirrors the handler's first-page + // defaults for ascending order. + firstPageCursor := queries.GetSnapshotsWithCursorAscParams{ + TeamID: teamID, + Metadata: types.JSONBStringMap{}, + CursorID: "zzzzzzzzzzzzzzzzzzzz", + CursorTime: pgtype.Timestamptz{Time: time.Time{}, Valid: true}, + Limit: 10, + } + + all, err := db.SqlcClient.GetSnapshotsWithCursorAsc(ctx, firstPageCursor) + require.NoError(t, err) + require.Len(t, all, 3) + assert.Equal(t, []string{ + all[0].Snapshot.SandboxID, + all[1].Snapshot.SandboxID, + all[2].Snapshot.SandboxID, + }, oldestToNewest, "ascending query should return oldest sandbox first") + + // Walk the pages with limit 2: page one is the two oldest... + page1Cursor := firstPageCursor + page1Cursor.Limit = 2 + page1, err := db.SqlcClient.GetSnapshotsWithCursorAsc(ctx, page1Cursor) + require.NoError(t, err) + require.Len(t, page1, 2) + assert.Equal(t, []string{page1[0].Snapshot.SandboxID, page1[1].Snapshot.SandboxID}, oldestToNewest[:2]) + + // ...and the next page (cursor = last returned row) is the newest, with no overlap. + last := page1[1].Snapshot + page2, err := db.SqlcClient.GetSnapshotsWithCursorAsc(ctx, queries.GetSnapshotsWithCursorAscParams{ + TeamID: teamID, + Metadata: types.JSONBStringMap{}, + CursorID: last.SandboxID, + CursorTime: last.SandboxStartedAt, + Limit: 2, + }) + require.NoError(t, err) + require.Len(t, page2, 1) + assert.Equal(t, oldestToNewest[2], page2[0].Snapshot.SandboxID) +} + // TestGetLastSnapshot_BuildSharedWithOtherTemplate verifies that when a build is assigned // to multiple templates, GetLastSnapshot still returns the correct build for this template. func TestGetLastSnapshot_BuildSharedWithOtherTemplate(t *testing.T) { diff --git a/packages/db/queries/get_snapshots_with_cursor.sql b/packages/db/queries/get_snapshots_with_cursor.sql index f6fb005ebf..02564bfc03 100644 --- a/packages/db/queries/get_snapshots_with_cursor.sql +++ b/packages/db/queries/get_snapshots_with_cursor.sql @@ -34,3 +34,47 @@ WHERE AND (s.sandbox_started_at, @cursor_id::text) < (@cursor_time, s.sandbox_id) ORDER BY s.sandbox_started_at DESC, s.sandbox_id ASC LIMIT $1; + +-- name: GetSnapshotsWithCursorAsc :many +-- Ascending counterpart of GetSnapshotsWithCursor. It is the exact reverse order +-- (started_at ASC, sandbox_id DESC) which maps onto a backward scan of the +-- idx_snapshots_team_time_id (team_id, sandbox_started_at DESC, sandbox_id) index. +SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names, + sqlc.embed(s), + eb.id AS build_id, + eb.vcpu AS build_vcpu, + eb.ram_mb AS build_ram_mb, + eb.total_disk_size_mb AS build_total_disk_size_mb, + eb.envd_version AS build_envd_version, + eb.created_at AS build_created_at +FROM "public"."snapshots" s +JOIN "public"."active_envs" e ON e.id = s.env_id +LEFT JOIN LATERAL ( + SELECT + ARRAY_AGG(alias ORDER BY alias) AS aliases, + ARRAY_AGG(CASE WHEN namespace IS NOT NULL THEN namespace || '/' || alias ELSE alias END ORDER BY alias) AS names + FROM "public"."env_aliases" + WHERE env_id = s.base_env_id +) ea ON TRUE +JOIN LATERAL ( + SELECT eb.id, eb.vcpu, eb.ram_mb, eb.total_disk_size_mb, eb.envd_version, eb.created_at + FROM "public"."env_build_assignments" eba + JOIN "public"."env_builds" eb ON eb.id = eba.build_id + WHERE + eba.env_id = s.env_id + AND eba.tag = 'default' + AND eb.status_group = 'ready' + ORDER BY eba.created_at DESC + LIMIT 1 +) eb ON TRUE +WHERE + s.team_id = @team_id + AND s.metadata @> @metadata + -- started_at ascending, sandbox_id descending (reverse of the descending query). + -- The redundant >= bound is what the planner turns into an index condition on + -- idx_snapshots_team_time_id; with only the OR predicate every deep page would + -- rescan the team's range from the oldest row. + AND s.sandbox_started_at >= @cursor_time + AND (s.sandbox_started_at > @cursor_time OR s.sandbox_id < @cursor_id::text) +ORDER BY s.sandbox_started_at ASC, s.sandbox_id DESC +LIMIT $1; diff --git a/packages/db/queries/get_snapshots_with_cursor.sql.go b/packages/db/queries/get_snapshots_with_cursor.sql.go index 3604d7aad5..a36004967e 100644 --- a/packages/db/queries/get_snapshots_with_cursor.sql.go +++ b/packages/db/queries/get_snapshots_with_cursor.sql.go @@ -119,3 +119,116 @@ func (q *Queries) GetSnapshotsWithCursor(ctx context.Context, arg GetSnapshotsWi } return items, nil } + +const getSnapshotsWithCursorAsc = `-- name: GetSnapshotsWithCursorAsc :many +SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names, + s.created_at, s.env_id, s.sandbox_id, s.id, s.metadata, s.base_env_id, s.sandbox_started_at, s.env_secure, s.origin_node_id, s.allow_internet_access, s.auto_pause, s.team_id, s.config, + eb.id AS build_id, + eb.vcpu AS build_vcpu, + eb.ram_mb AS build_ram_mb, + eb.total_disk_size_mb AS build_total_disk_size_mb, + eb.envd_version AS build_envd_version, + eb.created_at AS build_created_at +FROM "public"."snapshots" s +JOIN "public"."active_envs" e ON e.id = s.env_id +LEFT JOIN LATERAL ( + SELECT + ARRAY_AGG(alias ORDER BY alias) AS aliases, + ARRAY_AGG(CASE WHEN namespace IS NOT NULL THEN namespace || '/' || alias ELSE alias END ORDER BY alias) AS names + FROM "public"."env_aliases" + WHERE env_id = s.base_env_id +) ea ON TRUE +JOIN LATERAL ( + SELECT eb.id, eb.vcpu, eb.ram_mb, eb.total_disk_size_mb, eb.envd_version, eb.created_at + FROM "public"."env_build_assignments" eba + JOIN "public"."env_builds" eb ON eb.id = eba.build_id + WHERE + eba.env_id = s.env_id + AND eba.tag = 'default' + AND eb.status_group = 'ready' + ORDER BY eba.created_at DESC + LIMIT 1 +) eb ON TRUE +WHERE + s.team_id = $2 + AND s.metadata @> $3 + -- started_at ascending, sandbox_id descending (reverse of the descending query). + -- The redundant >= bound is what the planner turns into an index condition on + -- idx_snapshots_team_time_id; with only the OR predicate every deep page would + -- rescan the team's range from the oldest row. + AND s.sandbox_started_at >= $4 + AND (s.sandbox_started_at > $4 OR s.sandbox_id < $5::text) +ORDER BY s.sandbox_started_at ASC, s.sandbox_id DESC +LIMIT $1 +` + +type GetSnapshotsWithCursorAscParams struct { + Limit int32 + TeamID uuid.UUID + Metadata types.JSONBStringMap + CursorTime pgtype.Timestamptz + CursorID string +} + +type GetSnapshotsWithCursorAscRow struct { + Aliases []string + Names []string + Snapshot Snapshot + BuildID uuid.UUID + BuildVcpu int64 + BuildRamMb int64 + BuildTotalDiskSizeMb *int64 + BuildEnvdVersion *string + BuildCreatedAt time.Time +} + +// Ascending counterpart of GetSnapshotsWithCursor. It is the exact reverse order +// (started_at ASC, sandbox_id DESC) which maps onto a backward scan of the +// idx_snapshots_team_time_id (team_id, sandbox_started_at DESC, sandbox_id) index. +func (q *Queries) GetSnapshotsWithCursorAsc(ctx context.Context, arg GetSnapshotsWithCursorAscParams) ([]GetSnapshotsWithCursorAscRow, error) { + rows, err := q.db.Query(ctx, getSnapshotsWithCursorAsc, + arg.Limit, + arg.TeamID, + arg.Metadata, + arg.CursorTime, + arg.CursorID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSnapshotsWithCursorAscRow + for rows.Next() { + var i GetSnapshotsWithCursorAscRow + if err := rows.Scan( + &i.Aliases, + &i.Names, + &i.Snapshot.CreatedAt, + &i.Snapshot.EnvID, + &i.Snapshot.SandboxID, + &i.Snapshot.ID, + &i.Snapshot.Metadata, + &i.Snapshot.BaseEnvID, + &i.Snapshot.SandboxStartedAt, + &i.Snapshot.EnvSecure, + &i.Snapshot.OriginNodeID, + &i.Snapshot.AllowInternetAccess, + &i.Snapshot.AutoPause, + &i.Snapshot.TeamID, + &i.Snapshot.Config, + &i.BuildID, + &i.BuildVcpu, + &i.BuildRamMb, + &i.BuildTotalDiskSizeMb, + &i.BuildEnvdVersion, + &i.BuildCreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/spec/openapi.yml b/spec/openapi.yml index 9c7121bb63..e83e53561c 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -254,6 +254,14 @@ components: - running - paused + OrderDirection: + type: string + description: Sort direction + default: desc + enum: + - asc + - desc + SnapshotInfo: type: object required: @@ -2245,6 +2253,12 @@ paths: $ref: "#/components/schemas/SandboxState" style: form explode: false + - name: order + in: query + description: Sort direction by sandbox start time. Defaults to desc (newest first). + required: false + schema: + $ref: "#/components/schemas/OrderDirection" - $ref: "#/components/parameters/paginationNextToken" - $ref: "#/components/parameters/paginationLimit" responses: diff --git a/tests/integration/internal/api/generated.go b/tests/integration/internal/api/generated.go index 778a55007e..e424f2b5a4 100644 --- a/tests/integration/internal/api/generated.go +++ b/tests/integration/internal/api/generated.go @@ -160,6 +160,24 @@ func (e NodeStatus) Valid() bool { } } +// Defines values for OrderDirection. +const ( + Asc OrderDirection = "asc" + Desc OrderDirection = "desc" +) + +// Valid indicates whether the value is a known member of the OrderDirection enum. +func (e OrderDirection) Valid() bool { + switch e { + case Asc: + return true + case Desc: + return true + default: + return false + } +} + // Defines values for SandboxOnTimeout. const ( Kill SandboxOnTimeout = "kill" @@ -723,6 +741,9 @@ type NodeStatusChange struct { Status NodeStatus `json:"status"` } +// OrderDirection Sort direction +type OrderDirection string + // ResumedSandbox defines model for ResumedSandbox. type ResumedSandbox struct { // AutoPause Automatically pauses the sandbox after the timeout @@ -1654,6 +1675,9 @@ type GetV2SandboxesParams struct { // State Filter sandboxes by one or more states State *[]SandboxState `form:"state,omitempty" json:"state,omitempty"` + // Order Sort direction by sandbox start time. Defaults to desc (newest first). + Order *OrderDirection `form:"order,omitempty" json:"order,omitempty"` + // NextToken Cursor to start the list from NextToken *PaginationNextToken `form:"nextToken,omitempty" json:"nextToken,omitempty"` @@ -5522,6 +5546,18 @@ func NewGetV2SandboxesRequest(server string, params *GetV2SandboxesParams) (*htt } + if params.Order != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "order", *params.Order, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + if params.NextToken != nil { if queryFrag, err := runtime.StyleParamWithOptions("form", true, "nextToken", *params.NextToken, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {