@@ -432,6 +432,42 @@ interface CleanupBatchContext {
432432 label : string
433433}
434434
435+ /**
436+ * Applies one re-root, treating the deduplicated name as a HINT rather than a guarantee.
437+ *
438+ * Both name allocators can hand back a name that is already taken: `fileExistsInWorkspace`
439+ * swallows query errors and returns `false`, so `allocateUniqueWorkspaceFileName` fails OPEN,
440+ * and `deduplicateWorkflowName`'s lookups can throw outright. Either way the UPDATE raises
441+ * 23505, and an uncaught 23505 here aborts the batch — precisely the permanent retention stall
442+ * this whole hook exists to prevent. So any failure retries with the row id, which is unique by
443+ * construction and cannot collide.
444+ *
445+ * A failure of that retry is swallowed too: one unfixable row must not stop the other children
446+ * from being made safe. It is logged at error level because the folder's DELETE can then still
447+ * stall on that row via the FK's SET NULL.
448+ */
449+ async function reRootOne (
450+ preferred : ( ) => Promise < unknown > ,
451+ withUniqueName : ( ) => Promise < unknown > ,
452+ subject : string ,
453+ label : string
454+ ) : Promise < void > {
455+ try {
456+ await preferred ( )
457+ return
458+ } catch ( error ) {
459+ logger . warn ( `[${ label } ] Re-rooting ${ subject } under its deduplicated name failed; retrying` , {
460+ error,
461+ } )
462+ }
463+
464+ try {
465+ await withUniqueName ( )
466+ } catch ( error ) {
467+ logger . error ( `[${ label } ] Could not re-root ${ subject } ; its folder delete may stall` , { error } )
468+ }
469+ }
470+
435471/**
436472 * Re-roots any still-active workflow or workspace file filed under a folder that is about to be
437473 * hard-deleted, giving it a collision-free name first.
@@ -485,9 +521,24 @@ async function reRootActiveFolderChildren(
485521 . where ( and ( inArray ( workflow . folderId , expiredIds ) , isNull ( workflow . archivedAt ) ) )
486522
487523 for ( const row of workflows ) {
488- if ( ! row . workspaceId ) continue
489- const name = await deduplicateWorkflowName ( row . name , row . workspaceId , null , cleanupDb )
490- await cleanupDb . update ( workflow ) . set ( { folderId : null , name } ) . where ( eq ( workflow . id , row . id ) )
524+ const workspaceId = row . workspaceId
525+ if ( ! workspaceId ) continue
526+ await reRootOne (
527+ async ( ) => {
528+ const name = await deduplicateWorkflowName ( row . name , workspaceId , null , cleanupDb )
529+ await cleanupDb
530+ . update ( workflow )
531+ . set ( { folderId : null , name } )
532+ . where ( eq ( workflow . id , row . id ) )
533+ } ,
534+ ( ) =>
535+ cleanupDb
536+ . update ( workflow )
537+ . set ( { folderId : null , name : `${ row . name } (${ row . id } )` } )
538+ . where ( eq ( workflow . id , row . id ) ) ,
539+ `workflow ${ row . id } ` ,
540+ label
541+ )
491542 }
492543
493544 const files = await cleanupDb
@@ -506,22 +557,28 @@ async function reRootActiveFolderChildren(
506557 )
507558
508559 for ( const row of files ) {
509- if ( ! row . workspaceId ) continue
510- /**
511- * `allocateUniqueWorkspaceFileName` throws once the copy-suffix range is exhausted. Falling
512- * back to the row id keeps the sweep running on a name that cannot collide — the opposite
513- * failure (letting it throw) is the stall this function exists to prevent.
514- */
515- let originalName : string
516- try {
517- originalName = await allocateUniqueWorkspaceFileName ( row . workspaceId , row . originalName , null )
518- } catch {
519- originalName = `${ row . originalName } (${ row . id } )`
520- }
521- await cleanupDb
522- . update ( workspaceFiles )
523- . set ( { folderId : null , originalName } )
524- . where ( eq ( workspaceFiles . id , row . id ) )
560+ const workspaceId = row . workspaceId
561+ if ( ! workspaceId ) continue
562+ await reRootOne (
563+ async ( ) => {
564+ const originalName = await allocateUniqueWorkspaceFileName (
565+ workspaceId ,
566+ row . originalName ,
567+ null
568+ )
569+ await cleanupDb
570+ . update ( workspaceFiles )
571+ . set ( { folderId : null , originalName } )
572+ . where ( eq ( workspaceFiles . id , row . id ) )
573+ } ,
574+ ( ) =>
575+ cleanupDb
576+ . update ( workspaceFiles )
577+ . set ( { folderId : null , originalName : `${ row . originalName } (${ row . id } )` } )
578+ . where ( eq ( workspaceFiles . id , row . id ) ) ,
579+ `workspace file ${ row . id } ` ,
580+ label
581+ )
525582 }
526583
527584 if ( workflows . length > 0 || files . length > 0 ) {
0 commit comments