Skip to content

Commit 0902b6a

Browse files
committed
fix running sandboxes pagination
1 parent 95ca318 commit 0902b6a

File tree

7 files changed

+126
-25
lines changed

7 files changed

+126
-25
lines changed

packages/api/internal/handlers/sandboxes_list.go

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import (
55
"fmt"
66
"net/http"
77
"slices"
8-
"sort"
98
"strconv"
9+
"strings"
1010
"time"
1111

1212
"github.com/gin-gonic/gin"
@@ -90,10 +90,7 @@ func (a *APIStore) GetSandboxes(c *gin.Context, params api.GetSandboxesParams) {
9090
runningSandboxes := getRunningSandboxes(sandboxes[instance.StateRunning], metadataFilter)
9191

9292
// Sort sandboxes by start time descending
93-
slices.SortFunc(runningSandboxes, func(a, b utils.PaginatedSandbox) int {
94-
// SortFunc sorts the list ascending by default, because we want the opposite behavior we switch `a` and `b`
95-
return b.StartedAt.Compare(a.StartedAt)
96-
})
93+
sortPaginatedSandboxesDesc(runningSandboxes)
9794

9895
c.JSON(http.StatusOK, runningSandboxes)
9996
}
@@ -149,6 +146,9 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam
149146

150147
sandboxesInCache := a.orchestrator.GetSandboxes(ctx, &team.ID, []instance.State{instance.StateRunning, instance.StatePaused, instance.StatePausing})
151148

149+
// Sort sandboxes from the cache to properly apply the cursor filtering
150+
sortCacheSandboxesDesc(sandboxesInCache)
151+
152152
// Running Sandbox IDs
153153
runningSandboxesIDs := make([]string, 0)
154154
for _, info := range sandboxesInCache[instance.StateRunning] {
@@ -187,13 +187,8 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam
187187
sandboxes = append(sandboxes, pausingSandboxList...)
188188
}
189189

190-
// Sort by StartedAt (descending), then by SandboxID (ascending) for stability
191-
sort.Slice(sandboxes, func(a, b int) bool {
192-
if !sandboxes[a].StartedAt.Equal(sandboxes[b].StartedAt) {
193-
return sandboxes[a].StartedAt.After(sandboxes[b].StartedAt)
194-
}
195-
return sandboxes[a].SandboxID < sandboxes[b].SandboxID
196-
})
190+
// We need to sort again after merging running and paused sandboxes
191+
sortPaginatedSandboxesDesc(sandboxes)
197192

198193
var nextToken *string
199194
if len(sandboxes) > int(limit) {
@@ -301,3 +296,31 @@ func instanceInfoToPaginatedSandboxes(runningSandboxes []*instance.InstanceInfo)
301296

302297
return sandboxes
303298
}
299+
300+
// sortCacheSandboxesDesc sorts the sandboxes in the cache by StartedAt (descending),
301+
// then by SandboxID (ascending) for stability
302+
func sortCacheSandboxesDesc(cache map[instance.State][]*instance.InstanceInfo) {
303+
for state := range cache {
304+
if _, ok := cache[state]; !ok {
305+
continue
306+
}
307+
308+
slices.SortFunc(cache[state], func(a, b *instance.InstanceInfo) int {
309+
if !a.StartTime.Equal(b.StartTime) {
310+
return -a.StartTime.Compare(b.StartTime)
311+
}
312+
return strings.Compare(a.SandboxID, b.SandboxID)
313+
})
314+
}
315+
}
316+
317+
// sortPaginatedSandboxesDesc sorts the sandboxes by StartedAt (descending),
318+
// then by SandboxID (ascending) for stability
319+
func sortPaginatedSandboxesDesc(sandboxes []utils.PaginatedSandbox) {
320+
slices.SortFunc(sandboxes, func(a, b utils.PaginatedSandbox) int {
321+
if !a.StartedAt.Equal(b.StartedAt) {
322+
return -a.StartedAt.Compare(b.StartedAt)
323+
}
324+
return strings.Compare(a.SandboxID, b.SandboxID)
325+
})
326+
}

packages/api/internal/utils/sandboxes_list.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func FilterBasedOnCursor(sandboxes []PaginatedSandbox, cursorTime time.Time, cur
8888
}
8989
sandboxes = filteredSandboxes
9090

91-
// Apply limit if provided (get limit + 1 for pagination if possible)
91+
// Apply limit (get limit + 1 for pagination if possible)
9292
if len(sandboxes) > int(limit) {
9393
sandboxes = sandboxes[:limit+1]
9494
}

tests/integration/internal/tests/api/sandboxes/sandbox_list_test.go

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,9 @@ func TestSandboxListPaginationRunning(t *testing.T) {
242242
sbx2 := utils.SetupSandboxWithCleanup(t, c, utils.WithMetadata(api.SandboxMetadata{metadataKey: metadataValue}))
243243
sandbox2ID := sbx2.SandboxID
244244

245+
sbx3 := utils.SetupSandboxWithCleanup(t, c, utils.WithMetadata(api.SandboxMetadata{metadataKey: metadataValue}))
246+
sandbox3ID := sbx3.SandboxID
247+
245248
// Test pagination with limit
246249
var limit int32 = 1
247250

@@ -254,12 +257,12 @@ func TestSandboxListPaginationRunning(t *testing.T) {
254257
require.NoError(t, err)
255258
require.Equal(t, http.StatusOK, listResponse.StatusCode())
256259
require.Len(t, *listResponse.JSON200, 1)
257-
assert.Equal(t, sandbox2ID, (*listResponse.JSON200)[0].SandboxID)
260+
assert.Equal(t, sandbox3ID, (*listResponse.JSON200)[0].SandboxID)
258261

259262
totalHeader := listResponse.HTTPResponse.Header.Get("X-Total-Running")
260263
total, err := strconv.Atoi(totalHeader)
261264
require.NoError(t, err)
262-
assert.Equal(t, 2, total)
265+
assert.Equal(t, 3, total)
263266

264267
// Get second page using the next token from first response
265268
nextToken := listResponse.HTTPResponse.Header.Get("X-Next-Token")
@@ -274,13 +277,93 @@ func TestSandboxListPaginationRunning(t *testing.T) {
274277
require.NoError(t, err)
275278
require.Equal(t, http.StatusOK, secondPageResponse.StatusCode())
276279
require.Len(t, *secondPageResponse.JSON200, 1)
277-
assert.Equal(t, sandbox1ID, (*secondPageResponse.JSON200)[0].SandboxID)
280+
assert.Equal(t, sandbox2ID, (*secondPageResponse.JSON200)[0].SandboxID)
278281

279-
// No more pages
282+
// Get third page using the next token from second response
280283
nextToken = secondPageResponse.HTTPResponse.Header.Get("X-Next-Token")
284+
assert.NotEmpty(t, nextToken)
285+
286+
thirdPageResponse, err := c.GetV2SandboxesWithResponse(t.Context(), &api.GetV2SandboxesParams{
287+
Limit: &limit,
288+
NextToken: &nextToken,
289+
State: &[]api.SandboxState{api.Running},
290+
Metadata: &metadataString,
291+
}, setup.WithAPIKey())
292+
require.NoError(t, err)
293+
require.Equal(t, http.StatusOK, thirdPageResponse.StatusCode())
294+
require.Len(t, *thirdPageResponse.JSON200, 1)
295+
assert.Equal(t, sandbox1ID, (*thirdPageResponse.JSON200)[0].SandboxID)
296+
297+
// No more pages
298+
nextToken = thirdPageResponse.HTTPResponse.Header.Get("X-Next-Token")
281299
assert.Empty(t, nextToken)
282300
}
283301

302+
func TestSandboxListPaginationRunningLargerLimit(t *testing.T) {
303+
c := setup.GetAPIClient()
304+
305+
metadataKey := "uniqueIdentifier"
306+
metadataValue := id.Generate()
307+
metadataString := fmt.Sprintf("%s=%s", metadataKey, metadataValue)
308+
309+
sbxsCount := 12
310+
sandboxes := make([]string, sbxsCount)
311+
for i := range sbxsCount {
312+
sbx := utils.SetupSandboxWithCleanup(t, c, utils.WithMetadata(api.SandboxMetadata{metadataKey: metadataValue}))
313+
sandboxes[sbxsCount-i-1] = sbx.SandboxID
314+
315+
t.Logf("Created sandbox %d/%d: %s", i+1, sbxsCount, sbx.SandboxID)
316+
}
317+
318+
t.Run("check all sandboxes list", func(t *testing.T) {
319+
listResponse, err := c.GetV2SandboxesWithResponse(t.Context(), &api.GetV2SandboxesParams{
320+
Limit: sharedUtils.ToPtr(int32(sbxsCount)),
321+
State: &[]api.SandboxState{api.Running},
322+
Metadata: &metadataString,
323+
}, setup.WithAPIKey())
324+
require.NoError(t, err)
325+
require.Equal(t, http.StatusOK, listResponse.StatusCode())
326+
require.Len(t, *listResponse.JSON200, sbxsCount)
327+
for i := range sbxsCount {
328+
assert.Equal(t, sandboxes[i], (*listResponse.JSON200)[i].SandboxID)
329+
}
330+
})
331+
332+
t.Run("check paginated list", func(t *testing.T) {
333+
// Test pagination with limit
334+
limit := 2
335+
336+
var nextToken *string
337+
for i := 0; i < sbxsCount; i += limit {
338+
sbxID := sandboxes[i]
339+
340+
listResponse, err := c.GetV2SandboxesWithResponse(t.Context(), &api.GetV2SandboxesParams{
341+
Limit: sharedUtils.ToPtr(int32(limit)),
342+
NextToken: nextToken,
343+
State: &[]api.SandboxState{api.Running},
344+
Metadata: &metadataString,
345+
}, setup.WithAPIKey())
346+
require.NoError(t, err)
347+
require.Equal(t, http.StatusOK, listResponse.StatusCode())
348+
require.Len(t, *listResponse.JSON200, int(limit))
349+
assert.Equal(t, sbxID, (*listResponse.JSON200)[0].SandboxID, "page starting at %d should start with sandbox %s, token %s", i, sbxID, sharedUtils.Sprintp(nextToken))
350+
351+
totalHeader := listResponse.HTTPResponse.Header.Get("X-Total-Running")
352+
total, err := strconv.Atoi(totalHeader)
353+
require.NoError(t, err)
354+
assert.Equal(t, sbxsCount, total)
355+
356+
nextToken = sharedUtils.ToPtr(listResponse.HTTPResponse.Header.Get("X-Next-Token"))
357+
358+
if i+limit == sbxsCount {
359+
assert.Empty(t, *nextToken)
360+
} else {
361+
assert.NotEmpty(t, *nextToken)
362+
}
363+
}
364+
})
365+
}
366+
284367
func TestSandboxListPaginationPaused(t *testing.T) {
285368
c := setup.GetAPIClient()
286369

tests/integration/internal/tests/api/templates/build_template_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import (
1010
"github.com/stretchr/testify/assert"
1111
"github.com/stretchr/testify/require"
1212

13+
"github.com/e2b-dev/infra/packages/shared/pkg/utils"
1314
"github.com/e2b-dev/infra/tests/integration/internal/api"
1415
"github.com/e2b-dev/infra/tests/integration/internal/setup"
15-
"github.com/e2b-dev/infra/tests/integration/internal/utils"
1616
)
1717

1818
const (

tests/integration/internal/tests/api/templates/delete_template_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import (
77
"github.com/stretchr/testify/assert"
88
"github.com/stretchr/testify/require"
99

10+
"github.com/e2b-dev/infra/packages/shared/pkg/utils"
1011
"github.com/e2b-dev/infra/tests/integration/internal/api"
1112
"github.com/e2b-dev/infra/tests/integration/internal/setup"
12-
"github.com/e2b-dev/infra/tests/integration/internal/utils"
1313
)
1414

1515
func TestDeleteTemplate(t *testing.T) {

tests/integration/internal/tests/api/templates/request_build_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import (
88
"github.com/stretchr/testify/assert"
99
"github.com/stretchr/testify/require"
1010

11+
"github.com/e2b-dev/infra/packages/shared/pkg/utils"
1112
"github.com/e2b-dev/infra/tests/integration/internal/api"
1213
"github.com/e2b-dev/infra/tests/integration/internal/setup"
13-
"github.com/e2b-dev/infra/tests/integration/internal/utils"
1414
)
1515

1616
func TestRequestTemplateBuild(t *testing.T) {

tests/integration/internal/utils/ptr.go

Lines changed: 0 additions & 5 deletions
This file was deleted.

0 commit comments

Comments
 (0)