diff --git a/apps/api/src/box/controllers/box.controller.ts b/apps/api/src/box/controllers/box.controller.ts index c70d76627..8ad50b187 100644 --- a/apps/api/src/box/controllers/box.controller.ts +++ b/apps/api/src/box/controllers/box.controller.ts @@ -139,9 +139,7 @@ export class BoxController { @Query('includeErroredDeleted') includeErroredDeleted?: boolean, ): Promise { 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') diff --git a/apps/api/src/box/entities/box.entity.ts b/apps/api/src/box/entities/box.entity.ts index 460a37085..d0787b6e6 100644 --- a/apps/api/src/box/entities/box.entity.ts +++ b/apps/api/src/box/entities/box.entity.ts @@ -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'], { diff --git a/apps/api/src/box/services/box.service.list-cache.spec.ts b/apps/api/src/box/services/box.service.list-cache.spec.ts new file mode 100644 index 000000000..a51be0d58 --- /dev/null +++ b/apps/api/src/box/services/box.service.list-cache.spec.ts @@ -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) + }) +}) diff --git a/apps/api/src/box/services/box.service.list-paged.spec.ts b/apps/api/src/box/services/box.service.list-paged.spec.ts new file mode 100644 index 000000000..14a77b4e7 --- /dev/null +++ b/apps/api/src/box/services/box.service.list-paged.spec.ts @@ -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) + } + }) +}) diff --git a/apps/api/src/box/services/box.service.ts b/apps/api/src/box/services/box.service.ts index 63b8093a9..677b17862 100644 --- a/apps/api/src/box/services/box.service.ts +++ b/apps/api/src/box/services/box.service.ts @@ -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, @@ -331,17 +333,17 @@ export class BoxService { return this.toBoxDto(updatedBox) } - async findAllDeprecated( + private buildDeprecatedListWhere( organizationId: string, labels?: { [key: string]: string }, includeErroredDestroyed?: boolean, - ): Promise { + ): FindOptionsWhere[] { const baseFindOptions: FindOptionsWhere = { organizationId, ...(labels ? { labels: JsonContains(labels) } : {}), } - const where: FindOptionsWhere[] = [ + return [ { ...baseFindOptions, state: Not(In([BoxState.DESTROYED, BoxState.ERROR])), @@ -352,8 +354,65 @@ export class BoxService { ...(includeErroredDestroyed ? {} : { desiredState: Not(BoxDesiredState.DESTROYED) }), }, ] + } + + async findAllDeprecated( + organizationId: string, + labels?: { [key: string]: string }, + includeErroredDestroyed?: boolean, + ): Promise { + 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 { + 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( diff --git a/apps/api/src/box/utils/box-lookup-cache.util.ts b/apps/api/src/box/utils/box-lookup-cache.util.ts index ac2d25318..6f4ae3930 100644 --- a/apps/api/src/box/utils/box-lookup-cache.util.ts +++ b/apps/api/src/box/utils/box-lookup-cache.util.ts @@ -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 diff --git a/apps/api/src/boxlite-rest/boxlite-box.controller.ts b/apps/api/src/boxlite-rest/boxlite-box.controller.ts index c95de4340..813e0a89b 100644 --- a/apps/api/src/boxlite-rest/boxlite-box.controller.ts +++ b/apps/api/src/boxlite-rest/boxlite-box.controller.ts @@ -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' @@ -103,11 +104,21 @@ export class BoxliteBoxController { async listBoxes( @AuthContext() authContext: OrganizationAuthContext, @Query('pageSize') pageSize?: string, + @Query('pageToken') pageToken?: string, ): Promise { - 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) } : {}), } } diff --git a/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts b/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts index 1236c10d8..c4f94ff93 100644 --- a/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts +++ b/apps/api/src/boxlite-rest/boxlite-rest-routing.spec.ts @@ -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([]), }, }, diff --git a/apps/api/src/boxlite-rest/utils/list-pagination.util.spec.ts b/apps/api/src/boxlite-rest/utils/list-pagination.util.spec.ts new file mode 100644 index 000000000..8e97dfd1c --- /dev/null +++ b/apps/api/src/boxlite-rest/utils/list-pagination.util.spec.ts @@ -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) + }) +}) diff --git a/apps/api/src/boxlite-rest/utils/list-pagination.util.ts b/apps/api/src/boxlite-rest/utils/list-pagination.util.ts new file mode 100644 index 000000000..147d71d1f --- /dev/null +++ b/apps/api/src/boxlite-rest/utils/list-pagination.util.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2025 Daytona Platforms Inc. + * Modified by BoxLite AI, 2025-2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import { BadRequestException } from '@nestjs/common' + +// Contract: openapi/box.openapi.yaml `pageSize` parameter. +export const DEFAULT_LIST_PAGE_SIZE = 100 +export const MAX_LIST_PAGE_SIZE = 1000 + +// Parse and clamp the `pageSize` query param to the documented bounds. A +// missing or unparseable value falls back to the default; out-of-range values +// clamp rather than error, matching how callers treat the documented default. +export function resolveListPageSize(pageSize?: string): number { + if (pageSize === undefined || pageSize === '') { + return DEFAULT_LIST_PAGE_SIZE + } + const parsed = Number(pageSize) + if (!Number.isInteger(parsed)) { + return DEFAULT_LIST_PAGE_SIZE + } + return Math.min(Math.max(parsed, 1), MAX_LIST_PAGE_SIZE) +} + +// Page tokens are opaque to clients (openapi `pageToken`). We encode the 1-based +// page number as base64url JSON so the value is stable and self-describing on +// the server without leaking an interpretable format to clients. +export function encodePageToken(page: number): string { + return Buffer.from(JSON.stringify({ page }), 'utf8').toString('base64url') +} + +// Decode a `pageToken` into a 1-based page number. Absent token => page 1. A +// malformed token is a client error (400), not a silent reset to page 1, so a +// paging bug surfaces instead of quietly re-serving the first page forever. +export function decodePageToken(pageToken?: string): number { + if (pageToken === undefined || pageToken === '') { + return 1 + } + try { + const decoded = JSON.parse(Buffer.from(pageToken, 'base64url').toString('utf8')) as { page?: unknown } + if (typeof decoded.page === 'number' && Number.isInteger(decoded.page) && decoded.page >= 1) { + return decoded.page + } + } catch { + // fall through to the shared error below + } + throw new BadRequestException('Invalid pageToken') +} diff --git a/apps/api/src/migrations/pre-deploy/1782307383251-migration.ts b/apps/api/src/migrations/pre-deploy/1782307383251-migration.ts new file mode 100644 index 000000000..3dd9176ec --- /dev/null +++ b/apps/api/src/migrations/pre-deploy/1782307383251-migration.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class Migration1782307383251 implements MigrationInterface { + name = 'Migration1782307383251' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "box_org_createdat_idx" ON "box" ("organizationId", "createdAt")`, + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "public"."box_org_createdat_idx"`) + } +} diff --git a/src/boxlite/src/rest/client.rs b/src/boxlite/src/rest/client.rs index 5dcb72a2e..d9423d85c 100644 --- a/src/boxlite/src/rest/client.rs +++ b/src/boxlite/src/rest/client.rs @@ -202,6 +202,15 @@ impl ApiClient { self.send_json(builder).await } + pub async fn get_with_query( + &self, + path: &str, + query: &[(&str, &str)], + ) -> BoxliteResult { + let builder = self.http.get(self.url(path)).query(query); + self.send_json(builder).await + } + pub async fn get_root(&self, path: &str) -> BoxliteResult { let builder = self.http.get(self.url_root(path)); self.send_json(builder).await diff --git a/src/boxlite/src/rest/runtime.rs b/src/boxlite/src/rest/runtime.rs index 157b0a7d5..b0b416819 100644 --- a/src/boxlite/src/rest/runtime.rs +++ b/src/boxlite/src/rest/runtime.rs @@ -88,8 +88,27 @@ impl RuntimeBackend for RestRuntime { } async fn list_info(&self) -> BoxliteResult> { - let resp: ListBoxesResponse = self.client.get("/boxes").await?; - resp.boxes.iter().map(|b| b.to_box_info()).collect() + // The server pages this endpoint (openapi `pageSize`/`pageToken`), so a + // single request only returns one page. Follow `next_page_token` to + // preserve the "list every box" contract callers depend on, requesting + // the max page size to keep round-trips down. + let mut all = Vec::new(); + let mut page_token: Option = None; + loop { + let mut query: Vec<(&str, &str)> = vec![("pageSize", "1000")]; + if let Some(token) = page_token.as_deref() { + query.push(("pageToken", token)); + } + let resp: ListBoxesResponse = self.client.get_with_query("/boxes", &query).await?; + for box_response in &resp.boxes { + all.push(box_response.to_box_info()?); + } + match resp.next_page_token { + Some(token) if !token.is_empty() => page_token = Some(token), + _ => break, + } + } + Ok(all) } async fn exists(&self, id_or_name: &str) -> BoxliteResult { diff --git a/src/boxlite/src/rest/types.rs b/src/boxlite/src/rest/types.rs index 657630d1f..a09693edf 100644 --- a/src/boxlite/src/rest/types.rs +++ b/src/boxlite/src/rest/types.rs @@ -269,7 +269,6 @@ impl BoxResponse { #[derive(Debug, Deserialize)] pub(crate) struct ListBoxesResponse { pub boxes: Vec, - #[allow(dead_code)] pub next_page_token: Option, }