Skip to content
Draft
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
4 changes: 1 addition & 3 deletions apps/api/src/box/controllers/box.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,7 @@ export class BoxController {
@Query('includeErroredDeleted') includeErroredDeleted?: boolean,
): Promise<BoxDto[]> {
const labels = labelsQuery ? JSON.parse(labelsQuery) : undefined
const boxes = await this.boxService.findAllDeprecated(authContext.organizationId, labels, includeErroredDeleted)

return this.boxService.toBoxDtos(boxes)
return this.boxService.listBoxesCached(authContext.organizationId, labels, includeErroredDeleted)
}

@Get('paginated')
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/box/entities/box.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { BOX_ID_LENGTH, BOX_ID_REGEX, generateBoxId } from '../utils/box-id.util
@Index('box_runnerid_idx', ['runnerId'])
@Index('box_runner_state_idx', ['runnerId', 'state'])
@Index('box_organizationid_idx', ['organizationId'])
@Index('box_org_createdat_idx', ['organizationId', 'createdAt'])
@Index('box_region_idx', ['region'])
@Index('box_resources_idx', ['cpu', 'mem', 'disk', 'gpu'])
@Index('box_runner_state_desired_idx', ['runnerId', 'state', 'desiredState'], {
Expand Down
72 changes: 72 additions & 0 deletions apps/api/src/box/services/box.service.list-cache.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright 2025 Daytona Platforms Inc.
* Modified by BoxLite AI, 2025-2026
* SPDX-License-Identifier: AGPL-3.0
*/

import { BoxService } from './box.service'

const ORG = '057963b2-60ca-4356-81fc-11503e15f249'

function createService(redis: any): BoxService {
const service = Object.create(BoxService.prototype) as BoxService
;(service as any).redis = redis
;(service as any).logger = { warn: jest.fn() }
return service
}

describe('BoxService.listBoxesCached', () => {
it('serves a cache hit without touching the database', async () => {
const dtos = [{ id: 'b1' }, { id: 'b2' }]
const redis = {
get: jest.fn().mockResolvedValue(JSON.stringify(dtos)),
setex: jest.fn(),
}
const service = createService(redis)
const findAll = jest.fn()
;(service as any).findAllDeprecated = findAll
;(service as any).toBoxDtos = jest.fn()

const result = await service.listBoxesCached(ORG)

expect(result).toEqual(dtos)
expect(findAll).not.toHaveBeenCalled()
expect(redis.setex).not.toHaveBeenCalled()
})

it('queries and populates the cache on a miss', async () => {
const boxes = [{ id: 'b1' }]
const dtos = [{ id: 'b1', dto: true }]
const redis = {
get: jest.fn().mockResolvedValue(null),
setex: jest.fn().mockResolvedValue('OK'),
}
const service = createService(redis)
const findAll = jest.fn().mockResolvedValue(boxes)
const toDtos = jest.fn().mockResolvedValue(dtos)
;(service as any).findAllDeprecated = findAll
;(service as any).toBoxDtos = toDtos

const result = await service.listBoxesCached(ORG)

expect(result).toBe(dtos)
expect(findAll).toHaveBeenCalledTimes(1)
expect(toDtos).toHaveBeenCalledWith(boxes)
expect(redis.setex).toHaveBeenCalledTimes(1)
expect(redis.setex.mock.calls[0][2]).toBe(JSON.stringify(dtos))
})

it('keys the cache distinctly by includeErroredDeleted and labels', async () => {
const redis = { get: jest.fn().mockResolvedValue(null), setex: jest.fn().mockResolvedValue('OK') }
const service = createService(redis)
;(service as any).findAllDeprecated = jest.fn().mockResolvedValue([])
;(service as any).toBoxDtos = jest.fn().mockResolvedValue([])

await service.listBoxesCached(ORG)
await service.listBoxesCached(ORG, { tier: 'a' }, true)

const k1 = redis.get.mock.calls[0][0]
const k2 = redis.get.mock.calls[1][0]
expect(k1).not.toEqual(k2)
})
})
57 changes: 57 additions & 0 deletions apps/api/src/box/services/box.service.list-paged.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright 2025 Daytona Platforms Inc.
* Modified by BoxLite AI, 2025-2026
* SPDX-License-Identifier: AGPL-3.0
*/

import { BoxService } from './box.service'

const ORG = '057963b2-60ca-4356-81fc-11503e15f249'

function createService(boxRepository: any): BoxService {
const service = Object.create(BoxService.prototype) as BoxService
;(service as any).boxRepository = boxRepository
return service
}

describe('BoxService.listBoxesPageDeprecated', () => {
it('fetches limit+1 rows (no COUNT) and reports hasMore when the extra row exists', async () => {
const page = Array.from({ length: 100 }, (_, i) => ({ id: `b${i}` }))
const find = jest.fn().mockResolvedValue([...page, { id: 'overflow' }])
const findAndCount = jest.fn()
const service = createService({ find, findAndCount })

const result = await service.listBoxesPageDeprecated(ORG, { limit: 100, offset: 200 })

expect(findAndCount).not.toHaveBeenCalled()
expect(result.hasMore).toBe(true)
expect(result.items).toEqual(page)
const arg = find.mock.calls[0][0]
expect(arg.skip).toBe(200)
expect(arg.take).toBe(101)
expect(arg.order).toEqual({ createdAt: 'DESC' })
})

it('reports hasMore=false and returns all rows when the page is not full', async () => {
const items = [{ id: 'b1' }, { id: 'b2' }]
const find = jest.fn().mockResolvedValue(items)
const service = createService({ find })

const result = await service.listBoxesPageDeprecated(ORG, { limit: 100, offset: 0 })

expect(result).toEqual({ items, hasMore: false })
})

it('scopes the query to the organization', async () => {
const find = jest.fn().mockResolvedValue([])
const service = createService({ find })

await service.listBoxesPageDeprecated(ORG, { limit: 10, offset: 0 })

const where = find.mock.calls[0][0].where
expect(Array.isArray(where)).toBe(true)
for (const clause of where) {
expect(clause.organizationId).toBe(ORG)
}
})
})
67 changes: 63 additions & 4 deletions apps/api/src/box/services/box.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ import { BoxCreatedEvent } from '../events/box-create.event'
import { InjectRedis } from '@nestjs-modules/ioredis'
import { Redis } from 'ioredis'
import {
BOX_LIST_CACHE_TTL_S,
BOX_LOOKUP_CACHE_TTL_MS,
BOX_ORG_ID_CACHE_TTL_MS,
TOOLBOX_PROXY_URL_CACHE_TTL_S,
boxListCacheKey,
boxLookupCacheKeyById,
boxLookupCacheKeyByName,
boxOrgIdCacheKeyById,
Expand Down Expand Up @@ -331,17 +333,17 @@ export class BoxService {
return this.toBoxDto(updatedBox)
}

async findAllDeprecated(
private buildDeprecatedListWhere(
organizationId: string,
labels?: { [key: string]: string },
includeErroredDestroyed?: boolean,
): Promise<Box[]> {
): FindOptionsWhere<Box>[] {
const baseFindOptions: FindOptionsWhere<Box> = {
organizationId,
...(labels ? { labels: JsonContains(labels) } : {}),
}

const where: FindOptionsWhere<Box>[] = [
return [
{
...baseFindOptions,
state: Not(In([BoxState.DESTROYED, BoxState.ERROR])),
Expand All @@ -352,8 +354,65 @@ export class BoxService {
...(includeErroredDestroyed ? {} : { desiredState: Not(BoxDesiredState.DESTROYED) }),
},
]
}

async findAllDeprecated(
organizationId: string,
labels?: { [key: string]: string },
includeErroredDestroyed?: boolean,
): Promise<Box[]> {
return this.boxRepository.find({
where: this.buildDeprecatedListWhere(organizationId, labels, includeErroredDestroyed),
})
}

// Bounded variant of findAllDeprecated for the public REST list endpoint:
// a single page is never the whole table, so one request can't saturate the
// event loop serializing every box in the org.
//
// Fetches one extra row instead of running a COUNT(*): the public response
// only needs to know whether another page exists, and COUNT(*) over the org's
// boxes scans every matching row on every request (O(org size), not O(page)).
async listBoxesPageDeprecated(
organizationId: string,
options: {
limit: number
offset: number
labels?: { [key: string]: string }
includeErroredDestroyed?: boolean
},
): Promise<{ items: Box[]; hasMore: boolean }> {
const rows = await this.boxRepository.find({
where: this.buildDeprecatedListWhere(organizationId, options.labels, options.includeErroredDestroyed),
order: { createdAt: 'DESC' },
skip: options.offset,
take: options.limit + 1,
})
const hasMore = rows.length > options.limit
return { items: hasMore ? rows.slice(0, options.limit) : rows, hasMore }
}

async listBoxesCached(
organizationId: string,
labels?: { [key: string]: string },
includeErroredDeleted?: boolean,
): Promise<BoxDto[]> {
const cacheKey = boxListCacheKey({ organizationId, labels, includeErroredDeleted })
const cached = await this.redis.get(cacheKey).catch((err) => {
this.logger.warn(`Failed to read box list cache for org ${organizationId}: ${err.message}`)
return null
})
if (cached) {
return JSON.parse(cached) as BoxDto[]
}

const boxes = await this.findAllDeprecated(organizationId, labels, includeErroredDeleted)
const dtos = await this.toBoxDtos(boxes)

return this.boxRepository.find({ where })
this.redis.setex(cacheKey, BOX_LIST_CACHE_TTL_S, JSON.stringify(dtos)).catch((err) => {
this.logger.warn(`Failed to cache box list for org ${organizationId}: ${err.message}`)
})
return dtos
}

async findAll(
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/box/utils/box-lookup-cache.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@
export const BOX_LOOKUP_CACHE_TTL_MS = 10_000
export const BOX_ORG_ID_CACHE_TTL_MS = 60_000
export const TOOLBOX_PROXY_URL_CACHE_TTL_S = 30 * 60 // 30 minutes
// The unbounded listBoxes endpoint materializes and serializes the org's whole
// box table per request; under poll-heavy traffic that pegs the single event
// loop. A short TTL collapses bursts of identical polls onto one query while
// keeping the list fresh within a few seconds.
export const BOX_LIST_CACHE_TTL_S = 3

export function boxListCacheKey(args: {
organizationId: string
labels?: { [key: string]: string }
includeErroredDeleted?: boolean
}): string {
const labels = args.labels
? JSON.stringify(Object.fromEntries(Object.entries(args.labels).sort(([a], [b]) => a.localeCompare(b))))
: 'none'
const includeErroredDeleted = args.includeErroredDeleted ? 1 : 0
return `box:list:org:${args.organizationId}:errdel:${includeErroredDeleted}:labels:${labels}`
}

type BoxLookupCacheKeyArgs = {
organizationId?: string | null
Expand Down
15 changes: 13 additions & 2 deletions apps/api/src/boxlite-rest/boxlite-box.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { BoxDesiredState } from '../box/enums/box-desired-state.enum'
import { BoxResponseDto, ListBoxesResponseDto } from './dto/box-response.dto'
import { CreateBoxDto } from './dto/create-box.dto'
import { boxToBoxResponse, createBoxToCreateBox } from './mappers/box-to-box.mapper'
import { resolveListPageSize, decodePageToken, encodePageToken } from './utils/list-pagination.util'
import { Audit, MASKED_AUDIT_VALUE, TypedRequest } from '../audit/decorators/audit.decorator'
import { AuditAction } from '../audit/enums/audit-action.enum'
import { AuditTarget } from '../audit/enums/audit-target.enum'
Expand Down Expand Up @@ -103,11 +104,21 @@ export class BoxliteBoxController {
async listBoxes(
@AuthContext() authContext: OrganizationAuthContext,
@Query('pageSize') pageSize?: string,
@Query('pageToken') pageToken?: string,
): Promise<ListBoxesResponseDto> {
const boxes = await this.boxService.findAllDeprecated(authContext.organizationId)
const dtos = await this.boxService.toBoxDtos(boxes)
const limit = resolveListPageSize(pageSize)
const page = decodePageToken(pageToken)
const offset = (page - 1) * limit

const { items, hasMore } = await this.boxService.listBoxesPageDeprecated(authContext.organizationId, {
limit,
offset,
})
const dtos = await this.boxService.toBoxDtos(items)

return {
boxes: dtos.map(boxToBoxResponse),
...(hasMore ? { next_page_token: encodePageToken(page + 1) } : {}),
}
}

Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('BoxLite REST routing', () => {
{
provide: BoxService,
useValue: {
findAllDeprecated: jest.fn().mockResolvedValue([]),
listBoxesPageDeprecated: jest.fn().mockResolvedValue({ items: [], hasMore: false }),
toBoxDtos: jest.fn().mockResolvedValue([]),
},
},
Expand Down
55 changes: 55 additions & 0 deletions apps/api/src/boxlite-rest/utils/list-pagination.util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright 2025 Daytona Platforms Inc.
* Modified by BoxLite AI, 2025-2026
* SPDX-License-Identifier: AGPL-3.0
*/

import { BadRequestException } from '@nestjs/common'
import {
DEFAULT_LIST_PAGE_SIZE,
MAX_LIST_PAGE_SIZE,
resolveListPageSize,
encodePageToken,
decodePageToken,
} from './list-pagination.util'

describe('resolveListPageSize', () => {
it('defaults when the param is absent or empty', () => {
expect(resolveListPageSize(undefined)).toBe(DEFAULT_LIST_PAGE_SIZE)
expect(resolveListPageSize('')).toBe(DEFAULT_LIST_PAGE_SIZE)
})

it('defaults on a non-integer value rather than serving an unbounded list', () => {
expect(resolveListPageSize('abc')).toBe(DEFAULT_LIST_PAGE_SIZE)
expect(resolveListPageSize('10.5')).toBe(DEFAULT_LIST_PAGE_SIZE)
})

it('clamps to the documented bounds', () => {
expect(resolveListPageSize('0')).toBe(1)
expect(resolveListPageSize('-5')).toBe(1)
expect(resolveListPageSize('250')).toBe(250)
expect(resolveListPageSize('99999')).toBe(MAX_LIST_PAGE_SIZE)
})
})

describe('page token round-trip', () => {
it('absent token resolves to page 1', () => {
expect(decodePageToken(undefined)).toBe(1)
expect(decodePageToken('')).toBe(1)
})

it('decodes what it encodes', () => {
expect(decodePageToken(encodePageToken(2))).toBe(2)
expect(decodePageToken(encodePageToken(57))).toBe(57)
})

it('matches the documented example token', () => {
// openapi/box.openapi.yaml advertises eyJwYWdlIjoyfQ === {"page":2}
expect(decodePageToken('eyJwYWdlIjoyfQ')).toBe(2)
})

it('rejects a malformed token instead of silently resetting to page 1', () => {
expect(() => decodePageToken('not-base64-json!!')).toThrow(BadRequestException)
expect(() => decodePageToken(encodePageToken(0))).toThrow(BadRequestException)
})
})
Loading
Loading