Skip to content

Commit 37e707b

Browse files
committed
feat(api): add sort order to GET /v2/sandboxes
Add an order=asc|desc query param to GET /v2/sandboxes so clients can request ascending or descending order by sandbox start time (defaults to desc, the previous hardcoded behavior). - Ascending is implemented as the exact reverse of the existing keyset order (started_at ASC, sandbox_id DESC), so it maps onto a backward scan of the existing idx_snapshots_team_time_id index - no new index/migration. - Add a direction-aware first-page cursor, ascending variants of the in-memory sort and cursor filter, and a GetSnapshotsWithCursorAsc keyset query. - Thread the direction through the v2 handler; the legacy v1 list is untouched. - Tests: ascending default cursor, sort + cursor-filter tie-breaks, and a DB-backed test asserting oldest-first ordering and gap-free pagination.
1 parent 9b162bf commit 37e707b

11 files changed

Lines changed: 733 additions & 210 deletions

packages/api/internal/api/api.gen.go

Lines changed: 231 additions & 186 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/api/internal/handlers/sandboxes_list.go

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func (a *APIStore) getPausedSandboxes(
3838
queryLimit int32,
3939
cursorTime time.Time,
4040
cursorID string,
41+
order utils.SortDirection,
4142
) ([]utils.PaginatedSandbox, error) {
4243
queryMetadata := dbtypes.JSONBStringMap{}
4344
if metadataFilter != nil {
@@ -49,7 +50,7 @@ func (a *APIStore) getPausedSandboxes(
4950
// O(rows × array_size) and caused 40s+ query times with large arrays.
5051
dbLimit := queryLimit + int32(len(runningSandboxesIDs))
5152

52-
snapshots, err := a.throttledGetSnapshots(ctx, queries.GetSnapshotsWithCursorParams{
53+
snapshots, err := a.throttledGetSnapshots(ctx, order, queries.GetSnapshotsWithCursorParams{
5354
Limit: dbLimit,
5455
TeamID: teamID,
5556
Metadata: queryMetadata,
@@ -149,6 +150,12 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam
149150
states = append(states, *params.State...)
150151
}
151152

153+
// Sort direction by start time. Defaults to descending (newest first).
154+
order := utils.SortDesc
155+
if params.Order != nil && *params.Order == api.Asc {
156+
order = utils.SortAsc
157+
}
158+
152159
// Initialize pagination
153160
pagination, err := utils.NewPagination[utils.PaginatedSandbox](
154161
utils.PaginationParams{
@@ -159,6 +166,7 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam
159166
DefaultLimit: sandboxesDefaultLimit,
160167
MaxLimit: sandboxesMaxLimit,
161168
DefaultID: utils.MaxSandboxID,
169+
Order: order,
162170
},
163171
)
164172
if err != nil {
@@ -204,7 +212,7 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam
204212
c.Header("X-Total-Running", strconv.Itoa(len(runningSandboxList)))
205213

206214
// Filter based on cursor
207-
runningSandboxList = utils.FilterBasedOnCursor(runningSandboxList, pagination.CursorTime(), pagination.CursorID())
215+
runningSandboxList = utils.FilterBasedOnCursor(runningSandboxList, pagination.CursorTime(), pagination.CursorID(), order)
208216

209217
sandboxes = append(sandboxes, runningSandboxList...)
210218
}
@@ -219,7 +227,7 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam
219227
runningSandboxesIDs = append(runningSandboxesIDs, info.SandboxID)
220228
}
221229

222-
pausedSandboxList, err := a.getPausedSandboxes(ctx, team.ID, runningSandboxesIDs, metadataFilter, pagination.QueryLimit(), pagination.CursorTime(), pagination.CursorID())
230+
pausedSandboxList, err := a.getPausedSandboxes(ctx, team.ID, runningSandboxesIDs, metadataFilter, pagination.QueryLimit(), pagination.CursorTime(), pagination.CursorID(), order)
223231
if err != nil {
224232
logger.L().Error(ctx, "Error getting paused sandboxes", zap.Error(err))
225233
a.sendAPIStoreError(c, http.StatusInternalServerError, "Error getting paused sandboxes")
@@ -229,14 +237,14 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam
229237

230238
pausingSandboxList := instanceInfoToPaginatedSandboxes(pausingSandboxes)
231239
pausingSandboxList = utils.FilterSandboxesOnMetadata(pausingSandboxList, metadataFilter)
232-
pausingSandboxList = utils.FilterBasedOnCursor(pausingSandboxList, pagination.CursorTime(), pagination.CursorID())
240+
pausingSandboxList = utils.FilterBasedOnCursor(pausingSandboxList, pagination.CursorTime(), pagination.CursorID(), order)
233241

234242
sandboxes = append(sandboxes, pausedSandboxList...)
235243
sandboxes = append(sandboxes, pausingSandboxList...)
236244
}
237245

238246
// We need to sort again after merging running and paused sandboxes
239-
utils.SortPaginatedSandboxesDesc(sandboxes)
247+
utils.SortPaginatedSandboxes(sandboxes, order)
240248

241249
sandboxes = pagination.ProcessResultsWithHeader(c, sandboxes, func(s utils.PaginatedSandbox) (time.Time, string) {
242250
return s.PaginationTimestamp, s.SandboxID
@@ -329,7 +337,12 @@ func instanceInfoToPaginatedSandboxes(runningSandboxes []sandbox.Sandbox) []util
329337
EnvdVersion: info.EnvdVersion,
330338
VolumeMounts: convertFromDBMountsToAPIMounts(info.VolumeMounts),
331339
},
332-
PaginationTimestamp: info.StartTime,
340+
// Paused snapshots come from Postgres at microsecond precision, but running
341+
// sandboxes carry nanosecond StartTime from time.Now(). Truncate only the
342+
// pagination key (not the public StartedAt) so the in-memory sort/cursor and
343+
// the SQL predicate agree at the running/paused boundary; otherwise asc
344+
// pagination can re-emit rows that share a truncated microsecond with the cursor.
345+
PaginationTimestamp: info.StartTime.Truncate(time.Microsecond),
333346
}
334347

335348
if info.Metadata != nil {
@@ -358,12 +371,35 @@ func convertFromDBMountsToAPIMounts(mounts []*dbtypes.SandboxVolumeMountConfig)
358371
return &results
359372
}
360373

361-
// throttledGetSnapshots runs GetSnapshotsWithCursor gated by the sandbox list semaphore.
362-
func (a *APIStore) throttledGetSnapshots(ctx context.Context, params queries.GetSnapshotsWithCursorParams) ([]queries.GetSnapshotsWithCursorRow, error) {
374+
// throttledGetSnapshots runs the cursor snapshot query gated by the sandbox list
375+
// semaphore, picking the ascending or descending keyset query based on the requested
376+
// order. The ascending query returns an identically-shaped row, converted back to the
377+
// descending row type so callers share a single conversion path.
378+
func (a *APIStore) throttledGetSnapshots(ctx context.Context, order utils.SortDirection, params queries.GetSnapshotsWithCursorParams) ([]queries.GetSnapshotsWithCursorRow, error) {
363379
if err := a.sandboxListSem.Acquire(ctx, 1); err != nil {
364380
return nil, err
365381
}
366382
defer a.sandboxListSem.Release(1)
367383

368-
return a.sqlcDB.GetSnapshotsWithCursor(ctx, params)
384+
if order != utils.SortAsc {
385+
return a.sqlcDB.GetSnapshotsWithCursor(ctx, params)
386+
}
387+
388+
ascRows, err := a.sqlcDB.GetSnapshotsWithCursorAsc(ctx, queries.GetSnapshotsWithCursorAscParams{
389+
Limit: params.Limit,
390+
TeamID: params.TeamID,
391+
Metadata: params.Metadata,
392+
CursorTime: params.CursorTime,
393+
CursorID: params.CursorID,
394+
})
395+
if err != nil {
396+
return nil, err
397+
}
398+
399+
rows := make([]queries.GetSnapshotsWithCursorRow, len(ascRows))
400+
for i, r := range ascRows {
401+
rows[i] = queries.GetSnapshotsWithCursorRow(r)
402+
}
403+
404+
return rows, nil
369405
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package handlers
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/e2b-dev/infra/packages/api/internal/sandbox"
11+
)
12+
13+
// TestInstanceInfoToPaginatedSandboxes_PaginationTimestampPrecision guards the keyset
14+
// pagination boundary: running sandboxes carry nanosecond StartTime while paused
15+
// snapshots are microsecond-precision in Postgres. Only the PaginationTimestamp keyset
16+
// value is truncated to microseconds (so the in-memory sort/cursor and the SQL predicate
17+
// agree); the public StartedAt must keep its full precision so list responses match the
18+
// sandbox detail endpoint.
19+
func TestInstanceInfoToPaginatedSandboxes_PaginationTimestampPrecision(t *testing.T) {
20+
t.Parallel()
21+
22+
// Sub-microsecond bits set (…789 ns) so truncation is observable.
23+
start := time.Date(2026, 1, 2, 3, 4, 5, 123456789, time.UTC)
24+
25+
sandboxes := instanceInfoToPaginatedSandboxes([]sandbox.Sandbox{
26+
{SandboxID: "sbx", StartTime: start, State: sandbox.StateRunning},
27+
})
28+
29+
require.Len(t, sandboxes, 1)
30+
31+
assert.Equal(t, start, sandboxes[0].StartedAt, "public StartedAt must keep full precision")
32+
assert.Equal(t, start.Truncate(time.Microsecond), sandboxes[0].PaginationTimestamp,
33+
"pagination key must be microsecond-aligned")
34+
assert.Zero(t, sandboxes[0].PaginationTimestamp.Nanosecond()%1000,
35+
"pagination key should have no sub-microsecond bits")
36+
}

packages/api/internal/utils/pagination.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ func generateCursor(timestamp time.Time, id string) string {
1616
return base64.URLEncoding.EncodeToString([]byte(cursor))
1717
}
1818

19+
// SortDirection is the order in which keyset-paginated results are returned.
20+
// The zero value is SortDesc, preserving the default newest-first behavior.
21+
type SortDirection int
22+
23+
const (
24+
SortDesc SortDirection = iota
25+
SortAsc
26+
)
27+
1928
// PaginationParams holds pagination parameters from the API request
2029
type PaginationParams struct {
2130
Limit *int32
@@ -27,6 +36,10 @@ type PaginationConfig struct {
2736
DefaultLimit int32
2837
MaxLimit int32
2938
DefaultID string // Default cursor ID when no token is provided (e.g., max UUID or max sandbox ID)
39+
// Order controls the first-page cursor when no token is provided. For
40+
// SortDesc the first page starts at "now" (newest first); for SortAsc it
41+
// starts at the zero time (oldest first).
42+
Order SortDirection
3043
}
3144

3245
// Cursor represents a parsed pagination cursor
@@ -60,7 +73,7 @@ func NewPagination[T any](params PaginationParams, config PaginationConfig) (*Pa
6073

6174
// Parse cursor token
6275
var err error
63-
p.cursor, err = parseCursorToken(params.NextToken, config.DefaultID)
76+
p.cursor, err = parseCursorToken(params.NextToken, config)
6477
if err != nil {
6578
return nil, fmt.Errorf("invalid next token: %w", err)
6679
}
@@ -132,7 +145,7 @@ func (p *Pagination[T]) setHeader(c *gin.Context) {
132145
}
133146

134147
// parseCursorToken parses a cursor token, returning default values if token is nil/empty
135-
func parseCursorToken(token *string, defaultID string) (Cursor, error) {
148+
func parseCursorToken(token *string, config PaginationConfig) (Cursor, error) {
136149
if token != nil && *token != "" {
137150
cursorTime, cursorID, err := ParseCursor(*token)
138151
if err != nil {
@@ -142,6 +155,13 @@ func parseCursorToken(token *string, defaultID string) (Cursor, error) {
142155
return Cursor{Time: cursorTime, ID: cursorID}, nil
143156
}
144157

145-
// Default to current time and provided default ID to get the first page
146-
return Cursor{Time: time.Now(), ID: defaultID}, nil
158+
// Default cursor for the first page. For descending order we start at "now"
159+
// (so everything older is included); for ascending order we start at the
160+
// zero time (so everything newer is included).
161+
defaultTime := time.Now()
162+
if config.Order == SortAsc {
163+
defaultTime = time.Time{}
164+
}
165+
166+
return Cursor{Time: defaultTime, ID: config.DefaultID}, nil
147167
}

packages/api/internal/utils/pagination_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,42 @@ func TestPagination_CursorTime(t *testing.T) {
191191
assert.Equal(t, timestamp, p.CursorTime())
192192
}
193193

194+
func TestPagination_DefaultCursorByDirection(t *testing.T) {
195+
t.Parallel()
196+
197+
t.Run("descending defaults to now", func(t *testing.T) {
198+
t.Parallel()
199+
200+
before := time.Now()
201+
p, err := NewPagination[testItem](PaginationParams{}, PaginationConfig{
202+
DefaultLimit: 10,
203+
MaxLimit: 100,
204+
DefaultID: "default-id",
205+
})
206+
require.NoError(t, err)
207+
after := time.Now()
208+
209+
assert.False(t, p.CursorTime().Before(before), "default cursor time should be ~now")
210+
assert.False(t, p.CursorTime().After(after), "default cursor time should be ~now")
211+
assert.Equal(t, "default-id", p.CursorID())
212+
})
213+
214+
t.Run("ascending defaults to zero time", func(t *testing.T) {
215+
t.Parallel()
216+
217+
p, err := NewPagination[testItem](PaginationParams{}, PaginationConfig{
218+
DefaultLimit: 10,
219+
MaxLimit: 100,
220+
DefaultID: "default-id",
221+
Order: SortAsc,
222+
})
223+
require.NoError(t, err)
224+
225+
assert.True(t, p.CursorTime().IsZero(), "ascending default cursor time should be the zero time")
226+
assert.Equal(t, "default-id", p.CursorID())
227+
})
228+
}
229+
194230
func TestPagination_CursorID(t *testing.T) {
195231
t.Parallel()
196232
config := PaginationConfig{

packages/api/internal/utils/sandboxes_list.go

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,33 +77,62 @@ func ParseCursor(cursor string) (time.Time, string, error) {
7777
return cursorTime, parts[1], nil
7878
}
7979

80-
func FilterBasedOnCursor(sandboxes []PaginatedSandbox, cursorTime time.Time, cursorID string) []PaginatedSandbox {
81-
// Apply cursor-based filtering if cursor is provided
80+
// FilterBasedOnCursor keeps only the sandboxes that fall after the cursor in the
81+
// requested order. It compares on PaginationTimestamp (the microsecond-aligned keyset
82+
// value), not the public StartedAt, so running and paused sandboxes share the same
83+
// precision as the SQL predicate. Descending order pages through
84+
// (timestamp DESC, sandbox_id ASC); ascending order is the exact reverse
85+
// (timestamp ASC, sandbox_id DESC).
86+
func FilterBasedOnCursor(sandboxes []PaginatedSandbox, cursorTime time.Time, cursorID string, order SortDirection) []PaginatedSandbox {
8287
var filteredSandboxes []PaginatedSandbox
8388
for _, sandbox := range sandboxes {
84-
// Take sandboxes with start time before cursor time OR
85-
// same start time but sandboxID greater than cursor ID (for stability)
86-
if sandbox.StartedAt.Before(cursorTime) ||
87-
(sandbox.StartedAt.Equal(cursorTime) && sandbox.SandboxID > cursorID) {
89+
var include bool
90+
if order == SortAsc {
91+
include = sandbox.PaginationTimestamp.After(cursorTime) ||
92+
(sandbox.PaginationTimestamp.Equal(cursorTime) && sandbox.SandboxID < cursorID)
93+
} else {
94+
include = sandbox.PaginationTimestamp.Before(cursorTime) ||
95+
(sandbox.PaginationTimestamp.Equal(cursorTime) && sandbox.SandboxID > cursorID)
96+
}
97+
98+
if include {
8899
filteredSandboxes = append(filteredSandboxes, sandbox)
89100
}
90101
}
91102

92103
return filteredSandboxes
93104
}
94105

95-
// SortPaginatedSandboxesDesc sorts the sandboxes by StartedAt (descending),
96-
// then by SandboxID (ascending) for stability
97-
func SortPaginatedSandboxesDesc(sandboxes []PaginatedSandbox) {
106+
// SortPaginatedSandboxes sorts the sandboxes by PaginationTimestamp then SandboxID for
107+
// stable pagination. It uses PaginationTimestamp (the microsecond-aligned keyset value),
108+
// not the public StartedAt, so the order matches the cursor filter and SQL predicate.
109+
// Descending order is timestamp DESC, SandboxID ASC; ascending order is the exact
110+
// reverse (timestamp ASC, SandboxID DESC) so it maps onto a backward scan of the
111+
// (team_id, sandbox_started_at DESC, sandbox_id) index.
112+
func SortPaginatedSandboxes(sandboxes []PaginatedSandbox, order SortDirection) {
98113
slices.SortFunc(sandboxes, func(a, b PaginatedSandbox) int {
99-
if !a.StartedAt.Equal(b.StartedAt) {
100-
return b.StartedAt.Compare(a.StartedAt)
114+
if !a.PaginationTimestamp.Equal(b.PaginationTimestamp) {
115+
if order == SortAsc {
116+
return a.PaginationTimestamp.Compare(b.PaginationTimestamp)
117+
}
118+
119+
return b.PaginationTimestamp.Compare(a.PaginationTimestamp)
120+
}
121+
122+
if order == SortAsc {
123+
return strings.Compare(b.SandboxID, a.SandboxID)
101124
}
102125

103126
return strings.Compare(a.SandboxID, b.SandboxID)
104127
})
105128
}
106129

130+
// SortPaginatedSandboxesDesc preserves the descending-only entry point used by the
131+
// legacy (v1) list endpoint.
132+
func SortPaginatedSandboxesDesc(sandboxes []PaginatedSandbox) {
133+
SortPaginatedSandboxes(sandboxes, SortDesc)
134+
}
135+
107136
func FilterSandboxesOnMetadata(sandboxes []PaginatedSandbox, metadata *map[string]string) []PaginatedSandbox {
108137
if metadata == nil {
109138
return sandboxes

0 commit comments

Comments
 (0)