Skip to content

Commit 30762c3

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 30762c3

11 files changed

Lines changed: 728 additions & 209 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: 48 additions & 10 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
@@ -314,13 +322,20 @@ func instanceInfoToPaginatedSandboxes(runningSandboxes []sandbox.Sandbox) []util
314322
state = api.Paused
315323
}
316324

325+
// Paused snapshots come from Postgres at microsecond precision, but running
326+
// sandboxes carry nanosecond StartTime from time.Now(). Align the keyset to
327+
// microseconds so the in-memory sort/cursor (StartedAt) and the SQL predicate
328+
// agree at the running/paused boundary; otherwise asc pagination can re-emit
329+
// rows that share a truncated microsecond with the cursor.
330+
startedAt := info.StartTime.Truncate(time.Microsecond)
331+
317332
sandbox := utils.PaginatedSandbox{
318333
ListedSandbox: api.ListedSandbox{
319334
ClientID: info.ClientID,
320335
TemplateID: info.BaseTemplateID,
321336
Alias: info.Alias,
322337
SandboxID: info.SandboxID,
323-
StartedAt: info.StartTime,
338+
StartedAt: startedAt,
324339
CpuCount: api.CPUCount(info.VCpu),
325340
MemoryMB: api.MemoryMB(info.RamMB),
326341
DiskSizeMB: api.DiskSizeMB(info.TotalDiskSizeMB),
@@ -329,7 +344,7 @@ func instanceInfoToPaginatedSandboxes(runningSandboxes []sandbox.Sandbox) []util
329344
EnvdVersion: info.EnvdVersion,
330345
VolumeMounts: convertFromDBMountsToAPIMounts(info.VolumeMounts),
331346
},
332-
PaginationTimestamp: info.StartTime,
347+
PaginationTimestamp: startedAt,
333348
}
334349

335350
if info.Metadata != nil {
@@ -358,12 +373,35 @@ func convertFromDBMountsToAPIMounts(mounts []*dbtypes.SandboxVolumeMountConfig)
358373
return &results
359374
}
360375

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

368-
return a.sqlcDB.GetSnapshotsWithCursor(ctx, params)
386+
if order != utils.SortAsc {
387+
return a.sqlcDB.GetSnapshotsWithCursor(ctx, params)
388+
}
389+
390+
ascRows, err := a.sqlcDB.GetSnapshotsWithCursorAsc(ctx, queries.GetSnapshotsWithCursorAscParams{
391+
Limit: params.Limit,
392+
TeamID: params.TeamID,
393+
Metadata: params.Metadata,
394+
CursorTime: params.CursorTime,
395+
CursorID: params.CursorID,
396+
})
397+
if err != nil {
398+
return nil, err
399+
}
400+
401+
rows := make([]queries.GetSnapshotsWithCursorRow, len(ascRows))
402+
for i, r := range ascRows {
403+
rows[i] = queries.GetSnapshotsWithCursorRow(r)
404+
}
405+
406+
return rows, nil
369407
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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_TruncatesStartTime guards the keyset
14+
// pagination boundary: running sandboxes carry nanosecond StartTime while paused
15+
// snapshots are microsecond-precision in Postgres. The pagination key must be
16+
// truncated to microseconds so the in-memory sort/cursor and the SQL predicate
17+
// agree, otherwise ascending pagination can re-emit rows around the boundary.
18+
func TestInstanceInfoToPaginatedSandboxes_TruncatesStartTime(t *testing.T) {
19+
t.Parallel()
20+
21+
// Sub-microsecond bits set (…789 ns) so truncation is observable.
22+
start := time.Date(2026, 1, 2, 3, 4, 5, 123_456_789, time.UTC)
23+
24+
sandboxes := instanceInfoToPaginatedSandboxes([]sandbox.Sandbox{
25+
{SandboxID: "sbx", StartTime: start, State: sandbox.StateRunning},
26+
})
27+
28+
require.Len(t, sandboxes, 1)
29+
30+
want := start.Truncate(time.Microsecond)
31+
assert.Equal(t, want, sandboxes[0].StartedAt, "StartedAt must be microsecond-aligned")
32+
assert.Equal(t, want, sandboxes[0].PaginationTimestamp, "PaginationTimestamp must match StartedAt precision")
33+
assert.Zero(t, sandboxes[0].StartedAt.Nanosecond()%1000, "no sub-microsecond bits should remain")
34+
}

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: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,33 +77,58 @@ 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. Descending order pages through (started_at DESC, sandbox_id ASC);
82+
// ascending order is the exact reverse (started_at ASC, sandbox_id DESC), matching
83+
// SortPaginatedSandboxes and the keyset SQL queries.
84+
func FilterBasedOnCursor(sandboxes []PaginatedSandbox, cursorTime time.Time, cursorID string, order SortDirection) []PaginatedSandbox {
8285
var filteredSandboxes []PaginatedSandbox
8386
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) {
87+
var include bool
88+
if order == SortAsc {
89+
include = sandbox.StartedAt.After(cursorTime) ||
90+
(sandbox.StartedAt.Equal(cursorTime) && sandbox.SandboxID < cursorID)
91+
} else {
92+
include = sandbox.StartedAt.Before(cursorTime) ||
93+
(sandbox.StartedAt.Equal(cursorTime) && sandbox.SandboxID > cursorID)
94+
}
95+
96+
if include {
8897
filteredSandboxes = append(filteredSandboxes, sandbox)
8998
}
9099
}
91100

92101
return filteredSandboxes
93102
}
94103

95-
// SortPaginatedSandboxesDesc sorts the sandboxes by StartedAt (descending),
96-
// then by SandboxID (ascending) for stability
97-
func SortPaginatedSandboxesDesc(sandboxes []PaginatedSandbox) {
104+
// SortPaginatedSandboxes sorts the sandboxes by StartedAt then SandboxID for stable
105+
// pagination. Descending order is StartedAt DESC, SandboxID ASC; ascending order is
106+
// the exact reverse (StartedAt ASC, SandboxID DESC) so it maps onto a backward scan
107+
// of the (team_id, sandbox_started_at DESC, sandbox_id) index.
108+
func SortPaginatedSandboxes(sandboxes []PaginatedSandbox, order SortDirection) {
98109
slices.SortFunc(sandboxes, func(a, b PaginatedSandbox) int {
99110
if !a.StartedAt.Equal(b.StartedAt) {
111+
if order == SortAsc {
112+
return a.StartedAt.Compare(b.StartedAt)
113+
}
114+
100115
return b.StartedAt.Compare(a.StartedAt)
101116
}
102117

118+
if order == SortAsc {
119+
return strings.Compare(b.SandboxID, a.SandboxID)
120+
}
121+
103122
return strings.Compare(a.SandboxID, b.SandboxID)
104123
})
105124
}
106125

126+
// SortPaginatedSandboxesDesc preserves the descending-only entry point used by the
127+
// legacy (v1) list endpoint.
128+
func SortPaginatedSandboxesDesc(sandboxes []PaginatedSandbox) {
129+
SortPaginatedSandboxes(sandboxes, SortDesc)
130+
}
131+
107132
func FilterSandboxesOnMetadata(sandboxes []PaginatedSandbox, metadata *map[string]string) []PaginatedSandbox {
108133
if metadata == nil {
109134
return sandboxes

0 commit comments

Comments
 (0)