Skip to content

Commit 7b28403

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 711aa33 commit 7b28403

10 files changed

Lines changed: 668 additions & 208 deletions

File tree

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

Lines changed: 232 additions & 187 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: 36 additions & 8 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+
ascending bool,
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, ascending, queries.GetSnapshotsWithCursorParams{
5354
Limit: dbLimit,
5455
TeamID: teamID,
5556
Metadata: queryMetadata,
@@ -149,6 +150,9 @@ 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+
ascending := params.Order != nil && *params.Order == api.Asc
155+
152156
// Initialize pagination
153157
pagination, err := utils.NewPagination[utils.PaginatedSandbox](
154158
utils.PaginationParams{
@@ -159,6 +163,7 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam
159163
DefaultLimit: sandboxesDefaultLimit,
160164
MaxLimit: sandboxesMaxLimit,
161165
DefaultID: utils.MaxSandboxID,
166+
Ascending: ascending,
162167
},
163168
)
164169
if err != nil {
@@ -204,7 +209,7 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam
204209
c.Header("X-Total-Running", strconv.Itoa(len(runningSandboxList)))
205210

206211
// Filter based on cursor
207-
runningSandboxList = utils.FilterBasedOnCursor(runningSandboxList, pagination.CursorTime(), pagination.CursorID())
212+
runningSandboxList = utils.FilterBasedOnCursor(runningSandboxList, pagination.CursorTime(), pagination.CursorID(), ascending)
208213

209214
sandboxes = append(sandboxes, runningSandboxList...)
210215
}
@@ -219,7 +224,7 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam
219224
runningSandboxesIDs = append(runningSandboxesIDs, info.SandboxID)
220225
}
221226

222-
pausedSandboxList, err := a.getPausedSandboxes(ctx, team.ID, runningSandboxesIDs, metadataFilter, pagination.QueryLimit(), pagination.CursorTime(), pagination.CursorID())
227+
pausedSandboxList, err := a.getPausedSandboxes(ctx, team.ID, runningSandboxesIDs, metadataFilter, pagination.QueryLimit(), pagination.CursorTime(), pagination.CursorID(), ascending)
223228
if err != nil {
224229
logger.L().Error(ctx, "Error getting paused sandboxes", zap.Error(err))
225230
a.sendAPIStoreError(c, http.StatusInternalServerError, "Error getting paused sandboxes")
@@ -229,14 +234,14 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam
229234

230235
pausingSandboxList := instanceInfoToPaginatedSandboxes(pausingSandboxes)
231236
pausingSandboxList = utils.FilterSandboxesOnMetadata(pausingSandboxList, metadataFilter)
232-
pausingSandboxList = utils.FilterBasedOnCursor(pausingSandboxList, pagination.CursorTime(), pagination.CursorID())
237+
pausingSandboxList = utils.FilterBasedOnCursor(pausingSandboxList, pagination.CursorTime(), pagination.CursorID(), ascending)
233238

234239
sandboxes = append(sandboxes, pausedSandboxList...)
235240
sandboxes = append(sandboxes, pausingSandboxList...)
236241
}
237242

238243
// We need to sort again after merging running and paused sandboxes
239-
utils.SortPaginatedSandboxesDesc(sandboxes)
244+
utils.SortPaginatedSandboxes(sandboxes, ascending)
240245

241246
sandboxes = pagination.ProcessResultsWithHeader(c, sandboxes, func(s utils.PaginatedSandbox) (time.Time, string) {
242247
return s.PaginationTimestamp, s.SandboxID
@@ -358,12 +363,35 @@ func convertFromDBMountsToAPIMounts(mounts []*dbtypes.SandboxVolumeMountConfig)
358363
return &results
359364
}
360365

361-
// throttledGetSnapshots runs GetSnapshotsWithCursor gated by the sandbox list semaphore.
362-
func (a *APIStore) throttledGetSnapshots(ctx context.Context, params queries.GetSnapshotsWithCursorParams) ([]queries.GetSnapshotsWithCursorRow, error) {
366+
// throttledGetSnapshots runs the cursor snapshot query gated by the sandbox list
367+
// semaphore, picking the ascending or descending keyset query based on the requested
368+
// order. The ascending query returns an identically-shaped row, converted back to the
369+
// descending row type so callers share a single conversion path.
370+
func (a *APIStore) throttledGetSnapshots(ctx context.Context, ascending bool, params queries.GetSnapshotsWithCursorParams) ([]queries.GetSnapshotsWithCursorRow, error) {
363371
if err := a.sandboxListSem.Acquire(ctx, 1); err != nil {
364372
return nil, err
365373
}
366374
defer a.sandboxListSem.Release(1)
367375

368-
return a.sqlcDB.GetSnapshotsWithCursor(ctx, params)
376+
if !ascending {
377+
return a.sqlcDB.GetSnapshotsWithCursor(ctx, params)
378+
}
379+
380+
ascRows, err := a.sqlcDB.GetSnapshotsWithCursorAsc(ctx, queries.GetSnapshotsWithCursorAscParams{
381+
Limit: params.Limit,
382+
TeamID: params.TeamID,
383+
Metadata: params.Metadata,
384+
CursorTime: params.CursorTime,
385+
CursorID: params.CursorID,
386+
})
387+
if err != nil {
388+
return nil, err
389+
}
390+
391+
rows := make([]queries.GetSnapshotsWithCursorRow, len(ascRows))
392+
for i, r := range ascRows {
393+
rows[i] = queries.GetSnapshotsWithCursorRow(r)
394+
}
395+
396+
return rows, nil
369397
}

packages/api/internal/utils/pagination.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ type PaginationConfig struct {
2727
DefaultLimit int32
2828
MaxLimit int32
2929
DefaultID string // Default cursor ID when no token is provided (e.g., max UUID or max sandbox ID)
30+
// Ascending controls the first-page cursor when no token is provided.
31+
// For descending order the first page starts at "now" (newest first); for
32+
// ascending order it starts at the zero time (oldest first).
33+
Ascending bool
3034
}
3135

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

6165
// Parse cursor token
6266
var err error
63-
p.cursor, err = parseCursorToken(params.NextToken, config.DefaultID)
67+
p.cursor, err = parseCursorToken(params.NextToken, config)
6468
if err != nil {
6569
return nil, fmt.Errorf("invalid next token: %w", err)
6670
}
@@ -132,7 +136,7 @@ func (p *Pagination[T]) setHeader(c *gin.Context) {
132136
}
133137

134138
// parseCursorToken parses a cursor token, returning default values if token is nil/empty
135-
func parseCursorToken(token *string, defaultID string) (Cursor, error) {
139+
func parseCursorToken(token *string, config PaginationConfig) (Cursor, error) {
136140
if token != nil && *token != "" {
137141
cursorTime, cursorID, err := ParseCursor(*token)
138142
if err != nil {
@@ -142,6 +146,13 @@ func parseCursorToken(token *string, defaultID string) (Cursor, error) {
142146
return Cursor{Time: cursorTime, ID: cursorID}, nil
143147
}
144148

145-
// Default to current time and provided default ID to get the first page
146-
return Cursor{Time: time.Now(), ID: defaultID}, nil
149+
// Default cursor for the first page. For descending order we start at "now"
150+
// (so everything older is included); for ascending order we start at the
151+
// zero time (so everything newer is included).
152+
defaultTime := time.Now()
153+
if config.Ascending {
154+
defaultTime = time.Time{}
155+
}
156+
157+
return Cursor{Time: defaultTime, ID: config.DefaultID}, nil
147158
}

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+
Ascending: true,
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, ascending bool) []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 ascending {
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, ascending bool) {
98109
slices.SortFunc(sandboxes, func(a, b PaginatedSandbox) int {
99110
if !a.StartedAt.Equal(b.StartedAt) {
111+
if ascending {
112+
return a.StartedAt.Compare(b.StartedAt)
113+
}
114+
100115
return b.StartedAt.Compare(a.StartedAt)
101116
}
102117

118+
if ascending {
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, false)
130+
}
131+
107132
func FilterSandboxesOnMetadata(sandboxes []PaginatedSandbox, metadata *map[string]string) []PaginatedSandbox {
108133
if metadata == nil {
109134
return sandboxes

packages/api/internal/utils/sandboxes_list_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,107 @@ package utils
22

33
import (
44
"testing"
5+
"time"
56

67
"github.com/stretchr/testify/assert"
78
"github.com/stretchr/testify/require"
9+
10+
"github.com/e2b-dev/infra/packages/api/internal/api"
811
)
912

13+
func newPaginatedSandbox(id string, startedAt time.Time) PaginatedSandbox {
14+
return PaginatedSandbox{
15+
ListedSandbox: api.ListedSandbox{
16+
SandboxID: id,
17+
StartedAt: startedAt,
18+
},
19+
PaginationTimestamp: startedAt,
20+
}
21+
}
22+
23+
func sandboxIDs(sandboxes []PaginatedSandbox) []string {
24+
ids := make([]string, len(sandboxes))
25+
for i, s := range sandboxes {
26+
ids[i] = s.SandboxID
27+
}
28+
29+
return ids
30+
}
31+
32+
func TestSortPaginatedSandboxes(t *testing.T) {
33+
t.Parallel()
34+
35+
t0 := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
36+
t1 := time.Date(2024, 1, 1, 11, 0, 0, 0, time.UTC)
37+
38+
// Two sandboxes share t1 to exercise the SandboxID tie-break.
39+
build := func() []PaginatedSandbox {
40+
return []PaginatedSandbox{
41+
newPaginatedSandbox("b", t1),
42+
newPaginatedSandbox("a", t1),
43+
newPaginatedSandbox("c", t0),
44+
}
45+
}
46+
47+
t.Run("descending: started_at desc, sandbox_id asc", func(t *testing.T) {
48+
t.Parallel()
49+
50+
sandboxes := build()
51+
SortPaginatedSandboxes(sandboxes, false)
52+
assert.Equal(t, []string{"a", "b", "c"}, sandboxIDs(sandboxes))
53+
})
54+
55+
t.Run("ascending: started_at asc, sandbox_id desc", func(t *testing.T) {
56+
t.Parallel()
57+
58+
sandboxes := build()
59+
SortPaginatedSandboxes(sandboxes, true)
60+
assert.Equal(t, []string{"c", "b", "a"}, sandboxIDs(sandboxes))
61+
})
62+
63+
t.Run("Desc wrapper matches descending", func(t *testing.T) {
64+
t.Parallel()
65+
66+
sandboxes := build()
67+
SortPaginatedSandboxesDesc(sandboxes)
68+
assert.Equal(t, []string{"a", "b", "c"}, sandboxIDs(sandboxes))
69+
})
70+
}
71+
72+
func TestFilterBasedOnCursor(t *testing.T) {
73+
t.Parallel()
74+
75+
t0 := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
76+
t1 := time.Date(2024, 1, 1, 11, 0, 0, 0, time.UTC)
77+
t2 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
78+
79+
sandboxes := []PaginatedSandbox{
80+
newPaginatedSandbox("older", t0),
81+
newPaginatedSandbox("cursor-a", t1),
82+
newPaginatedSandbox("cursor-m", t1),
83+
newPaginatedSandbox("cursor-z", t1),
84+
newPaginatedSandbox("newer", t2),
85+
}
86+
87+
t.Run("descending keeps older and equal-time greater id", func(t *testing.T) {
88+
t.Parallel()
89+
90+
// Cursor at (t1, "cursor-m"): next page is everything strictly "after" it
91+
// in started_at DESC, sandbox_id ASC order.
92+
got := FilterBasedOnCursor(sandboxes, t1, "cursor-m", false)
93+
assert.ElementsMatch(t, []string{"cursor-z", "older"}, sandboxIDs(got))
94+
})
95+
96+
t.Run("ascending keeps newer and equal-time smaller id", func(t *testing.T) {
97+
t.Parallel()
98+
99+
// Cursor at (t1, "cursor-m"): next page is everything strictly "after" it
100+
// in started_at ASC, sandbox_id DESC order.
101+
got := FilterBasedOnCursor(sandboxes, t1, "cursor-m", true)
102+
assert.ElementsMatch(t, []string{"cursor-a", "newer"}, sandboxIDs(got))
103+
})
104+
}
105+
10106
func TestParseFilters(t *testing.T) {
11107
t.Parallel()
12108
t.Run("happy path", func(t *testing.T) {

0 commit comments

Comments
 (0)