@@ -5,7 +5,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors'
55import { sleep } from '@sim/utils/helpers'
66import { generateId } from '@sim/utils/id'
77import { 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'
99import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
1010import { runDetached } from '@/lib/core/utils/background'
1111import {
@@ -16,7 +16,11 @@ import {
1616import { resolveProvider } from '@/lib/execution/remote-sandbox/provider'
1717import { invalidateSandboxResolution } from '@/lib/execution/remote-sandbox/resolve'
1818import 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
2125const logger = createLogger ( 'SandboxImageRegistry' )
2226
@@ -308,61 +312,97 @@ export async function runSandboxImageBuild(payload: SandboxImageBuildPayload): P
308312export 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