Skip to content

Commit 0d68a8c

Browse files
icecrasher321claude
andcommitted
fix(sandboxes): claim the image row and its reference check in one statement
Greptile P1. Reading references in one statement and deleting in another left a window — a wide one, since a provider delete is a network call — where a second workspace could declare the same package list, inherit the `ready` row, and have its next run fail against a template already on its way out. Content addressing is what makes that reachable: the image is shared, so one workspace's delete can strand another's sandbox. The reference check now lives in the conditional DELETE itself, so winning the delete is the proof that nothing referenced the hash. A workspace that adopts the hash first makes the delete match nothing and the release becomes a no-op. Claiming the row before the provider call would otherwise strand a template nothing points at if the provider then refused, so that path puts the row back and the retention sweep inherits the retry — the same property the previous ordering had. The sweep is deliberately left as it is: its equivalent window needs a hash unreferenced AND unused for 30 days, and its provider-first ordering encodes the documented retry-on-refusal behaviour this path now reproduces explicitly. No transaction is opened. The provider call sits between discrete statements rather than inside one, so no pooled connection is held across it — which is why this uses a conditional delete instead of the repo's `pg_advisory_xact_lock` pattern, whose lock only releases at commit. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9b1b022 commit 0d68a8c

2 files changed

Lines changed: 124 additions & 82 deletions

File tree

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

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

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

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

2522
vi.mock('@sim/db/schema', () => ({
@@ -43,6 +40,7 @@ vi.mock('drizzle-orm', () => ({
4340
eq: (...args: unknown[]) => args,
4441
inArray: (...args: unknown[]) => args,
4542
lt: (...args: unknown[]) => args,
43+
notInArray: (...args: unknown[]) => args,
4644
or: (...args: unknown[]) => args,
4745
sql: (...args: unknown[]) => args,
4846
}))
@@ -67,16 +65,6 @@ import {
6765
releaseSandboxImage,
6866
} from '@/lib/execution/remote-sandbox/image-registry'
6967

70-
/** Queues the rows each successive `db.select()` chain resolves to. */
71-
function queueSelects(...results: unknown[][]) {
72-
mockSelect.mockReset()
73-
for (const rows of results) {
74-
mockSelect.mockReturnValueOnce({
75-
from: () => ({ where: () => ({ limit: () => Promise.resolve(rows) }) }),
76-
})
77-
}
78-
}
79-
8068
const READY_IMAGE = {
8169
id: 'img-1',
8270
status: 'ready',
@@ -88,13 +76,34 @@ const READY_IMAGE = {
8876
beforeEach(() => {
8977
vi.clearAllMocks()
9078
mockProviderStrategy.current = 'prebuilt'
91-
mockDelete.mockReturnValue({ where: () => Promise.resolve() })
79+
mockDelete.mockReturnValue({ where: () => ({ returning: () => Promise.resolve([]) }) })
80+
mockInsert.mockReturnValue({ values: () => ({ onConflictDoNothing: () => Promise.resolve() }) })
9281
mockDeleteImage.mockResolvedValue(undefined)
9382
})
9483

84+
/** Serializes the mocked predicate tree so a clause can be asserted by shape. */
85+
function predicateText(predicate: unknown): string {
86+
if (predicate == null) return ''
87+
if (Array.isArray(predicate)) return predicate.map(predicateText).join(' ')
88+
if (typeof predicate === 'object') return JSON.stringify(predicate)
89+
return String(predicate)
90+
}
91+
92+
/** Captures the conditional-delete predicate and what the claim resolves to. */
93+
function stubClaim(rows: unknown[]): () => unknown {
94+
let captured: unknown
95+
mockDelete.mockReturnValue({
96+
where: (predicate: unknown) => {
97+
captured = predicate
98+
return { returning: () => Promise.resolve(rows) }
99+
},
100+
})
101+
return () => captured
102+
}
103+
95104
describe('releaseSandboxImage', () => {
96-
it('deletes the provider image and its row when nothing references the hash', async () => {
97-
queueSelects([], [READY_IMAGE])
105+
it('deletes the provider image once the row is claimed', async () => {
106+
stubClaim([READY_IMAGE])
98107

99108
await releaseSandboxImage('hash-1')
100109

@@ -103,64 +112,73 @@ describe('releaseSandboxImage', () => {
103112
buildId: 'build-1',
104113
providerImageId: 'tmpl-1',
105114
})
106-
expect(mockDelete).toHaveBeenCalledTimes(1)
107115
})
108116

109117
/**
110-
* The case that would break a bystander: two workspaces declaring the same
111-
* package list share one build, so one workspace deleting its sandbox must not
112-
* delete the image out from under the other.
118+
* The bystander case: two workspaces declaring the same package list share one
119+
* build, so one workspace's delete must not take the image out from under the
120+
* other. The guard is the conditional delete itself — reading references in a
121+
* separate statement left a window, spanning a provider network call, in which
122+
* another workspace could adopt the hash between the check and the delete.
113123
*/
114-
it('leaves the image alone while another sandbox still declares that package list', async () => {
115-
// A releasable image IS queued behind the reference lookup on purpose: without
116-
// it, dropping the guard would fault on the missing second select and get
117-
// swallowed, and this test would pass for the wrong reason.
118-
queueSelects([{ id: 'sbx-other' }], [READY_IMAGE])
124+
it('claims only when no sandbox references the hash, in one statement', async () => {
125+
const read = stubClaim([READY_IMAGE])
119126

120127
await releaseSandboxImage('hash-1')
121128

122-
expect(mockDeleteImage).not.toHaveBeenCalled()
123-
expect(mockDelete).not.toHaveBeenCalled()
129+
const clause = predicateText(read())
130+
expect(clause).toContain('not exists')
131+
expect(clause).toContain('workspace_sandbox')
124132
})
125133

126-
it('no-ops under a runtime provider, which has no images to release', async () => {
127-
mockProviderStrategy.current = 'runtime'
128-
queueSelects([], [READY_IMAGE])
134+
it('excludes an in-flight build from the claim rather than racing it', async () => {
135+
const read = stubClaim([READY_IMAGE])
136+
137+
await releaseSandboxImage('hash-1')
138+
139+
const clause = predicateText(read())
140+
expect(clause).toContain('pending')
141+
expect(clause).toContain('building')
142+
})
143+
144+
it('touches the provider only when the claim actually took a row', async () => {
145+
stubClaim([])
129146

130147
await releaseSandboxImage('hash-1')
131148

132-
expect(mockSelect).not.toHaveBeenCalled()
133149
expect(mockDeleteImage).not.toHaveBeenCalled()
134150
})
135151

136-
it.each(['pending', 'building'])(
137-
'leaves a %s build for the sweep rather than racing it',
138-
async (status) => {
139-
queueSelects([], [{ ...READY_IMAGE, status }])
152+
it('no-ops under a runtime provider, which has no images to release', async () => {
153+
mockProviderStrategy.current = 'runtime'
154+
stubClaim([READY_IMAGE])
140155

141-
await releaseSandboxImage('hash-1')
156+
await releaseSandboxImage('hash-1')
142157

143-
expect(mockDeleteImage).not.toHaveBeenCalled()
144-
expect(mockDelete).not.toHaveBeenCalled()
145-
}
146-
)
158+
expect(mockDelete).not.toHaveBeenCalled()
159+
expect(mockDeleteImage).not.toHaveBeenCalled()
160+
})
147161

148-
it('keeps the row when the provider refuses, so the sweep retries', async () => {
149-
queueSelects([], [READY_IMAGE])
162+
/**
163+
* Claiming before the provider call means a refusal would otherwise strand a
164+
* template nothing points at, so the row goes back and the sweep inherits it.
165+
*/
166+
it('restores the claimed row when the provider refuses', async () => {
167+
stubClaim([READY_IMAGE])
150168
mockDeleteImage.mockRejectedValue(new Error('E2B unreachable'))
151169

152170
await expect(releaseSandboxImage('hash-1')).resolves.toBeUndefined()
153171

154-
expect(mockDelete).not.toHaveBeenCalled()
172+
expect(mockInsert).toHaveBeenCalledTimes(1)
155173
})
156174

157-
it('does nothing when the hash has no build row at all', async () => {
158-
queueSelects([], [])
175+
it('skips the provider when the claimed row never had an image', async () => {
176+
stubClaim([{ ...READY_IMAGE, imageRef: null }])
159177

160178
await releaseSandboxImage('hash-1')
161179

162180
expect(mockDeleteImage).not.toHaveBeenCalled()
163-
expect(mockDelete).not.toHaveBeenCalled()
181+
expect(mockInsert).not.toHaveBeenCalled()
164182
})
165183
})
166184

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

Lines changed: 53 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { db } from '@sim/db'
2-
import { sandboxImage, workspaceSandbox } from '@sim/db/schema'
2+
import { sandboxImage } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
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, or, sql } from 'drizzle-orm'
8+
import { and, eq, inArray, lt, notInArray, or, 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,7 @@ 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 } from '@/lib/execution/remote-sandbox/types'
19+
import type { SandboxImageBuild, SandboxImageStatus } from '@/lib/execution/remote-sandbox/types'
2020

2121
const logger = createLogger('SandboxImageRegistry')
2222

@@ -287,52 +287,76 @@ export async function runSandboxImageBuild(payload: SandboxImageBuildPayload): P
287287
* image sits in provider storage until the retention sweep, which is up to
288288
* `SANDBOX_IMAGE_RETENTION_DAYS` of paying to store something nothing can select.
289289
*
290-
* The reference check is what makes deleting this eagerly safe. Builds are keyed
291-
* by content, not by workspace, so two workspaces declaring the same package list
292-
* share one image, and deleting on the strength of one workspace's action would
293-
* break the other. An in-flight build is left alone rather than raced; the sweep
294-
* collects it once it settles.
290+
* Builds are keyed by content, not by workspace, so two workspaces declaring the
291+
* same package list share one image and deleting on the strength of one
292+
* workspace's action would break the other. The reference check is therefore part
293+
* of the same statement that removes the row: reading references first and
294+
* deleting second left a window — wide, because a provider delete is a network
295+
* call — in which another workspace could adopt the hash, inherit a `ready` row,
296+
* and have its next run fail against a template already on its way out. Winning
297+
* the conditional delete is what proves nothing referenced the hash.
298+
*
299+
* Claiming the row before the provider call means a provider that then refuses
300+
* would strand a template nothing points at, so the row is put back and the
301+
* retention sweep inherits the retry. An in-flight build is left alone rather
302+
* than raced; the sweep collects it once it settles.
295303
*
296304
* Best-effort by contract: failures are logged and swallowed, because this runs
297305
* after the mutation it follows has already committed and must never turn a
298-
* successful delete into an error. The sweep stays the backstop.
306+
* successful delete into an error.
299307
*/
300308
export async function releaseSandboxImage(specHash: string): Promise<void> {
301309
const provider = resolveProvider()
302310
if (provider.dependencyStrategy !== 'prebuilt' || !provider.images) return
303311
const images = provider.images
304312

305313
try {
306-
const [referenced] = await db
307-
.select({ id: workspaceSandbox.id })
308-
.from(workspaceSandbox)
309-
.where(eq(workspaceSandbox.specHash, specHash))
310-
.limit(1)
311-
if (referenced) return
312-
313-
const [image] = await db
314-
.select({
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({
315325
id: sandboxImage.id,
326+
spec: sandboxImage.spec,
316327
status: sandboxImage.status,
317328
imageRef: sandboxImage.imageRef,
318329
buildId: sandboxImage.buildId,
319330
providerImageId: sandboxImage.providerImageId,
320331
})
321-
.from(sandboxImage)
322-
.where(and(eq(sandboxImage.provider, provider.id), eq(sandboxImage.specHash, specHash)))
323-
.limit(1)
324-
if (!image) return
325-
if (image.status === 'pending' || image.status === 'building') return
332+
if (!claimed) return
333+
334+
invalidateSandboxResolution()
326335

327-
if (image.imageRef) {
336+
if (!claimed.imageRef) return
337+
try {
328338
await images.deleteImage({
329-
imageRef: image.imageRef,
330-
buildId: image.buildId ?? '',
331-
providerImageId: image.providerImageId ?? undefined,
339+
imageRef: claimed.imageRef,
340+
buildId: claimed.buildId ?? '',
341+
providerImageId: claimed.providerImageId ?? undefined,
332342
})
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
333358
}
334-
await db.delete(sandboxImage).where(eq(sandboxImage.id, image.id))
335-
invalidateSandboxResolution()
359+
336360
logger.info('Released unreferenced sandbox image', { specHash })
337361
} catch (error) {
338362
logger.warn('Failed to release sandbox image; the retention sweep will retry', {

0 commit comments

Comments
 (0)