Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
417 changes: 231 additions & 186 deletions packages/api/internal/api/api.gen.go

Large diffs are not rendered by default.

54 changes: 45 additions & 9 deletions packages/api/internal/handlers/sandboxes_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -149,6 +150,12 @@ 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 && *params.Order == api.Asc {
order = utils.SortAsc
}

// Initialize pagination
pagination, err := utils.NewPagination[utils.PaginatedSandbox](
utils.PaginationParams{
Expand All @@ -159,6 +166,7 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam
DefaultLimit: sandboxesDefaultLimit,
MaxLimit: sandboxesMaxLimit,
DefaultID: utils.MaxSandboxID,
Order: order,
},
)
if err != nil {
Expand Down Expand Up @@ -204,7 +212,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...)
}
Expand All @@ -219,7 +227,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")
Expand All @@ -229,14 +237,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
Expand Down Expand Up @@ -329,7 +337,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 {
Expand Down Expand Up @@ -358,12 +371,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
}

Comment thread
huv1k marked this conversation as resolved.
rows := make([]queries.GetSnapshotsWithCursorRow, len(ascRows))
for i, r := range ascRows {
rows[i] = queries.GetSnapshotsWithCursorRow(r)
}

return rows, nil
}
36 changes: 36 additions & 0 deletions packages/api/internal/handlers/sandboxes_list_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
28 changes: 24 additions & 4 deletions packages/api/internal/utils/pagination.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
36 changes: 36 additions & 0 deletions packages/api/internal/utils/pagination_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
51 changes: 40 additions & 11 deletions packages/api/internal/utils/sandboxes_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,33 +77,62 @@ 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)
}
}

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
Expand Down
Loading
Loading