Skip to content

Commit d9bcc57

Browse files
icecrasher321claude
andcommitted
fix(sandboxes): route the retention sweep through the same image claim
Cursor and Greptile both flagged the sweep as still carrying the interleaving just fixed in releaseSandboxImage, and they are right — the reason given for leaving it alone last round does not survive scrutiny. That reason was that provider-first ordering encodes retry-on-refusal, so making the claim atomic would trade a race for an orphaned template. The release path already answers that: claim the row, and put it back if the provider refuses. The sweep can have both properties too. The rarity argument was also weaker than stated. The sweep nominates up to 200 candidates and then works through them eight network deletes at a time, so its check-to-delete gap is seconds to minutes — wider than the window that was just closed, not narrower. Both callers now share `claimAndDeleteImage`, which owns the whole contract: the unreferenced check lives inside the DELETE, the provider call runs only after the claim succeeds, and a refusal restores the row. Having written that ordering twice is what let the two paths drift, so it exists once now. The sweep's query becomes a nomination step only. Its retention cutoff is passed into the claim rather than trusted from the earlier read, so a candidate that stops qualifying mid-sweep fails its claim and is skipped instead of losing its image. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2d84fe2 commit d9bcc57

2 files changed

Lines changed: 174 additions & 81 deletions

File tree

apps/sim/lib/execution/remote-sandbox/image-registry.test.ts

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,18 @@
88
*/
99
import { beforeEach, describe, expect, it, vi } from 'vitest'
1010

11-
const { mockDelete, mockInsert, mockDeleteImage, mockProviderStrategy } = vi.hoisted(() => ({
12-
mockDelete: vi.fn(),
13-
mockInsert: vi.fn(),
14-
mockDeleteImage: vi.fn(),
15-
mockProviderStrategy: { current: 'prebuilt' as 'prebuilt' | 'runtime' },
16-
}))
11+
const { mockDelete, mockInsert, mockSelect, mockDeleteImage, mockProviderStrategy } = vi.hoisted(
12+
() => ({
13+
mockDelete: vi.fn(),
14+
mockInsert: vi.fn(),
15+
mockSelect: vi.fn(),
16+
mockDeleteImage: vi.fn(),
17+
mockProviderStrategy: { current: 'prebuilt' as 'prebuilt' | 'runtime' },
18+
})
19+
)
1720

1821
vi.mock('@sim/db', () => ({
19-
db: { delete: mockDelete, insert: mockInsert },
22+
db: { delete: mockDelete, insert: mockInsert, select: mockSelect },
2023
}))
2124

2225
vi.mock('@sim/db/schema', () => ({
@@ -60,6 +63,7 @@ vi.mock('@/lib/execution/remote-sandbox/provider', () => ({
6063
}))
6164

6265
import {
66+
cleanupSandboxImages,
6367
ensureSandboxImage,
6468
FAILED_BUILD_RETRY_COOLDOWN_MS,
6569
releaseSandboxImage,
@@ -182,6 +186,63 @@ describe('releaseSandboxImage', () => {
182186
})
183187
})
184188

189+
/**
190+
* The sweep reads a batch of candidates and then works through them a chunk of
191+
* network calls at a time, so minutes can pass between the query and any one
192+
* delete. Whether a row still qualifies has to be decided by the claim, not by
193+
* that earlier read.
194+
*/
195+
describe('cleanupSandboxImages', () => {
196+
const CANDIDATE = { id: 'img-1', specHash: 'hash-1', imageRef: 'sim-sbx-abc' }
197+
198+
/** One candidate row from the sweep's nomination query. */
199+
function stubCandidates(rows: unknown[]) {
200+
mockSelect.mockReturnValue({
201+
from: () => ({ where: () => ({ limit: () => Promise.resolve(rows) }) }),
202+
})
203+
}
204+
205+
it('skips a candidate a workspace adopted after the query ran', async () => {
206+
stubCandidates([CANDIDATE])
207+
stubClaim([])
208+
209+
const result = await cleanupSandboxImages(30)
210+
211+
expect(mockDeleteImage).not.toHaveBeenCalled()
212+
expect(result).toEqual({ deleted: 0, failed: 0 })
213+
})
214+
215+
it('deletes the image for a candidate that still qualifies at claim time', async () => {
216+
stubCandidates([CANDIDATE])
217+
stubClaim([READY_IMAGE])
218+
219+
const result = await cleanupSandboxImages(30)
220+
221+
expect(mockDeleteImage).toHaveBeenCalledTimes(1)
222+
expect(result).toEqual({ deleted: 1, failed: 0 })
223+
})
224+
225+
it('restores the row and counts a failure when the provider refuses', async () => {
226+
stubCandidates([CANDIDATE])
227+
stubClaim([READY_IMAGE])
228+
mockDeleteImage.mockRejectedValue(new Error('E2B unreachable'))
229+
230+
const result = await cleanupSandboxImages(30)
231+
232+
expect(mockInsert).toHaveBeenCalledTimes(1)
233+
expect(result).toEqual({ deleted: 0, failed: 1 })
234+
})
235+
236+
it('keeps the retention cutoff in the claim, not just the candidate query', async () => {
237+
stubCandidates([CANDIDATE])
238+
const read = stubClaim([READY_IMAGE])
239+
240+
await cleanupSandboxImages(30)
241+
242+
expect(hasTimeBound(read())).toBe(true)
243+
})
244+
})
245+
185246
/** True when any leaf of the mocked predicate tree is a `Date`, i.e. a time bound. */
186247
function hasTimeBound(predicate: unknown): boolean {
187248
if (predicate instanceof Date) return true

apps/sim/lib/execution/remote-sandbox/image-registry.ts

Lines changed: 106 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors'
55
import { sleep } from '@sim/utils/helpers'
66
import { generateId } from '@sim/utils/id'
77
import { backoffWithJitter } from '@sim/utils/retry'
8-
import { and, eq, inArray, lt, notInArray, or, sql } from 'drizzle-orm'
8+
import { and, eq, inArray, lt, notInArray, or, type SQL, sql } from 'drizzle-orm'
99
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
1010
import { runDetached } from '@/lib/core/utils/background'
1111
import {
@@ -16,7 +16,11 @@ import {
1616
import { resolveProvider } from '@/lib/execution/remote-sandbox/provider'
1717
import { invalidateSandboxResolution } from '@/lib/execution/remote-sandbox/resolve'
1818
import type { SandboxSpec } from '@/lib/execution/remote-sandbox/sandbox-spec'
19-
import type { SandboxImageBuild, SandboxImageStatus } from '@/lib/execution/remote-sandbox/types'
19+
import type {
20+
SandboxImageBuild,
21+
SandboxImageBuilder,
22+
SandboxImageStatus,
23+
} from '@/lib/execution/remote-sandbox/types'
2024

2125
const logger = createLogger('SandboxImageRegistry')
2226

@@ -308,61 +312,97 @@ export async function runSandboxImageBuild(payload: SandboxImageBuildPayload): P
308312
export async function releaseSandboxImage(specHash: string): Promise<void> {
309313
const provider = resolveProvider()
310314
if (provider.dependencyStrategy !== 'prebuilt' || !provider.images) return
311-
const images = provider.images
312315

313316
try {
314-
const [claimed] = await db
315-
.delete(sandboxImage)
316-
.where(
317-
and(
318-
eq(sandboxImage.provider, provider.id),
319-
eq(sandboxImage.specHash, specHash),
320-
notInArray(sandboxImage.status, ['pending', 'building']),
321-
sql`not exists (select 1 from workspace_sandbox ws where ws.spec_hash = ${specHash})`
322-
)
323-
)
324-
.returning({
325-
id: sandboxImage.id,
326-
spec: sandboxImage.spec,
327-
status: sandboxImage.status,
328-
imageRef: sandboxImage.imageRef,
329-
buildId: sandboxImage.buildId,
330-
providerImageId: sandboxImage.providerImageId,
331-
})
332-
if (!claimed) return
317+
const outcome = await claimAndDeleteImage(provider.id, provider.images, specHash)
318+
if (outcome === 'released') {
319+
invalidateSandboxResolution()
320+
logger.info('Released unreferenced sandbox image', { specHash })
321+
}
322+
} catch (error) {
323+
logger.warn('Failed to release sandbox image; the retention sweep will retry', {
324+
specHash,
325+
error: getErrorMessage(error),
326+
})
327+
}
328+
}
333329

334-
invalidateSandboxResolution()
330+
type ImageClaimOutcome = 'released' | 'skipped' | 'failed'
335331

336-
if (!claimed.imageRef) return
337-
try {
338-
await images.deleteImage({
339-
imageRef: claimed.imageRef,
340-
buildId: claimed.buildId ?? '',
341-
providerImageId: claimed.providerImageId ?? undefined,
342-
})
343-
} catch (error) {
344-
await db
345-
.insert(sandboxImage)
346-
.values({
347-
id: claimed.id,
348-
provider: provider.id,
349-
specHash,
350-
spec: claimed.spec,
351-
status: claimed.status as SandboxImageStatus,
352-
imageRef: claimed.imageRef,
353-
buildId: claimed.buildId,
354-
providerImageId: claimed.providerImageId,
355-
})
356-
.onConflictDoNothing()
357-
throw error
358-
}
332+
/**
333+
* Takes ownership of a spec hash's registry row and deletes the image behind it.
334+
*
335+
* Both callers that remove an image go through here, because the ordering is the
336+
* whole contract and having written it twice is what let the two paths drift:
337+
*
338+
* 1. The unreferenced check is part of the `DELETE`, not a query before it.
339+
* Winning the delete is the proof nothing referenced the hash. Reading first
340+
* and deleting second leaves a window — spanning a provider network call —
341+
* where another workspace declares the same package list, inherits the `ready`
342+
* row without rebuilding, and loses the template underneath it.
343+
* 2. The provider delete runs only after the claim, so two sweeps or a sweep and
344+
* a release cannot both issue it.
345+
* 3. A provider that refuses gets the row put back, since claiming first would
346+
* otherwise strand a template nothing points at and no later pass would find
347+
* it. Restoring is what keeps the retry on the sweep.
348+
*
349+
* `extraConditions` lets the sweep add its retention cutoff to the same claim
350+
* rather than trusting the candidate query it ran earlier.
351+
*/
352+
async function claimAndDeleteImage(
353+
providerId: string,
354+
images: SandboxImageBuilder,
355+
specHash: string,
356+
extraConditions: SQL[] = []
357+
): Promise<ImageClaimOutcome> {
358+
const [claimed] = await db
359+
.delete(sandboxImage)
360+
.where(
361+
and(
362+
eq(sandboxImage.provider, providerId),
363+
eq(sandboxImage.specHash, specHash),
364+
notInArray(sandboxImage.status, ['pending', 'building']),
365+
sql`not exists (select 1 from workspace_sandbox ws where ws.spec_hash = ${specHash})`,
366+
...extraConditions
367+
)
368+
)
369+
.returning({
370+
id: sandboxImage.id,
371+
spec: sandboxImage.spec,
372+
status: sandboxImage.status,
373+
imageRef: sandboxImage.imageRef,
374+
buildId: sandboxImage.buildId,
375+
providerImageId: sandboxImage.providerImageId,
376+
})
377+
if (!claimed) return 'skipped'
378+
if (!claimed.imageRef) return 'released'
359379

360-
logger.info('Released unreferenced sandbox image', { specHash })
380+
try {
381+
await images.deleteImage({
382+
imageRef: claimed.imageRef,
383+
buildId: claimed.buildId ?? '',
384+
providerImageId: claimed.providerImageId ?? undefined,
385+
})
386+
return 'released'
361387
} catch (error) {
362-
logger.warn('Failed to release sandbox image; the retention sweep will retry', {
388+
await db
389+
.insert(sandboxImage)
390+
.values({
391+
id: claimed.id,
392+
provider: providerId,
393+
specHash,
394+
spec: claimed.spec,
395+
status: claimed.status as SandboxImageStatus,
396+
imageRef: claimed.imageRef,
397+
buildId: claimed.buildId,
398+
providerImageId: claimed.providerImageId,
399+
})
400+
.onConflictDoNothing()
401+
logger.warn('Provider refused a sandbox image delete; restored the row for retry', {
363402
specHash,
364403
error: getErrorMessage(error),
365404
})
405+
return 'failed'
366406
}
367407
}
368408

@@ -374,10 +414,18 @@ const CLEANUP_CONCURRENCY = 8
374414

375415
/**
376416
* Removes build rows that no `workspace_sandbox` still references and that have
377-
* gone unused past the retention window, deleting the provider image first.
417+
* gone unused past the retention window.
418+
*
419+
* The query below only nominates candidates. Every row is re-checked and claimed
420+
* atomically by {@link claimAndDeleteImage} before its image is touched, because
421+
* this sweep reads up to {@link CLEANUP_BATCH_LIMIT} rows and then works through
422+
* them a chunk of network calls at a time — leaving minutes in which a workspace
423+
* could declare one of those package lists, inherit the `ready` row, and lose the
424+
* template underneath it. A candidate that stops qualifying in that window simply
425+
* fails its claim and is skipped.
378426
*
379-
* A provider delete that fails leaves the row in place so the next sweep retries,
380-
* rather than orphaning a remote template that nothing points at any more.
427+
* A provider delete that fails restores the row, so the next sweep retries rather
428+
* than orphaning a remote template nothing points at any more.
381429
*
382430
* Progress is committed per chunk. A sweep that is killed part-way — by a route
383431
* timeout or a redeploy — therefore keeps what it already deleted, instead of
@@ -424,31 +472,15 @@ export async function cleanupSandboxImages(retentionDays: number): Promise<{
424472
for (let offset = 0; offset < stale.length; offset += CLEANUP_CONCURRENCY) {
425473
const chunk = stale.slice(offset, offset + CLEANUP_CONCURRENCY)
426474
const outcomes = await Promise.all(
427-
chunk.map(async (row) => {
428-
if (!row.imageRef) return row.id
429-
try {
430-
await images.deleteImage({
431-
imageRef: row.imageRef,
432-
buildId: row.buildId ?? '',
433-
providerImageId: row.providerImageId ?? undefined,
434-
})
435-
return row.id
436-
} catch (error) {
437-
logger.warn('Failed to delete sandbox image from provider; leaving the row for retry', {
438-
specHash: row.specHash,
439-
error: getErrorMessage(error),
440-
})
441-
return null
442-
}
443-
})
475+
chunk.map((row) =>
476+
claimAndDeleteImage(provider.id, images, row.specHash, [
477+
sql`coalesce(${sandboxImage.lastUsedAt}, ${sandboxImage.createdAt}) < ${cutoff}`,
478+
])
479+
)
444480
)
445481

446-
const deletable = outcomes.filter((id): id is string => id !== null)
447-
failed += outcomes.length - deletable.length
448-
if (deletable.length > 0) {
449-
await db.delete(sandboxImage).where(inArray(sandboxImage.id, deletable))
450-
deleted += deletable.length
451-
}
482+
deleted += outcomes.filter((outcome) => outcome === 'released').length
483+
failed += outcomes.filter((outcome) => outcome === 'failed').length
452484
}
453485

454486
if (deleted > 0) invalidateSandboxResolution()

0 commit comments

Comments
 (0)