@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
55
66const {
77 mockGetTableById,
8+ mockGetJobProgress,
89 mockSelectRowIdPage,
910 mockDeletePageByIds,
1011 mockUpdateJobProgress,
@@ -14,6 +15,7 @@ const {
1415 mockBuildFilterClause,
1516} = vi . hoisted ( ( ) => ( {
1617 mockGetTableById : vi . fn ( ) ,
18+ mockGetJobProgress : vi . fn ( ) ,
1719 mockSelectRowIdPage : vi . fn ( ) ,
1820 mockDeletePageByIds : vi . fn ( ) ,
1921 mockUpdateJobProgress : vi . fn ( ) ,
@@ -25,6 +27,7 @@ const {
2527
2628vi . mock ( '@/lib/table/service' , ( ) => ( {
2729 getTableById : mockGetTableById ,
30+ getJobProgress : mockGetJobProgress ,
2831 selectRowIdPage : mockSelectRowIdPage ,
2932 deletePageByIds : mockDeletePageByIds ,
3033 updateJobProgress : mockUpdateJobProgress ,
@@ -38,7 +41,7 @@ vi.mock('@/lib/table/constants', () => ({
3841 USER_TABLE_ROWS_SQL_NAME : 'user_table_rows' ,
3942} ) )
4043
41- import { runTableDelete } from '@/lib/table/delete-runner'
44+ import { markTableDeleteFailed , runTableDelete } from '@/lib/table/delete-runner'
4245
4346const table = { id : 'tbl_1' , workspaceId : 'ws_1' , schema : { columns : [ ] } }
4447const cutoff = new Date ( '2026-06-05T00:00:00Z' )
@@ -51,6 +54,7 @@ describe('runTableDelete', () => {
5154 beforeEach ( ( ) => {
5255 vi . clearAllMocks ( )
5356 mockGetTableById . mockResolvedValue ( table )
57+ mockGetJobProgress . mockResolvedValue ( 0 )
5458 mockUpdateJobProgress . mockResolvedValue ( true )
5559 mockMarkJobReady . mockResolvedValue ( true )
5660 mockMarkJobFailed . mockResolvedValue ( undefined )
@@ -103,17 +107,57 @@ describe('runTableDelete', () => {
103107 )
104108 } )
105109
106- it ( 'marks the job failed and emits a failed event on error ' , async ( ) => {
110+ it ( 'rethrows unexpected errors without failing the job (caller retries decide) ' , async ( ) => {
107111 mockSelectRowIdPage . mockRejectedValue ( new Error ( 'boom' ) )
108112
113+ await expect ( runTableDelete ( basePayload ( ) ) ) . rejects . toThrow ( 'boom' )
114+
115+ expect ( mockMarkJobFailed ) . not . toHaveBeenCalled ( )
116+ expect ( mockAppendTableEvent ) . not . toHaveBeenCalledWith (
117+ expect . objectContaining ( { status : 'failed' } )
118+ )
119+ } )
120+
121+ it ( 'returns quietly when superseded mid-run without failing the job' , async ( ) => {
122+ mockSelectRowIdPage . mockResolvedValue ( [ 'a' , 'b' ] )
123+ mockUpdateJobProgress . mockResolvedValueOnce ( true ) . mockResolvedValueOnce ( false )
124+
125+ await expect ( runTableDelete ( basePayload ( ) ) ) . resolves . toBeUndefined ( )
126+
127+ expect ( mockMarkJobFailed ) . not . toHaveBeenCalled ( )
128+ } )
129+
130+ it ( 'rethrows the root cause so the clean message survives serialization' , async ( ) => {
131+ const cause = new Error ( 'canceling statement due to statement timeout' )
132+ mockSelectRowIdPage . mockRejectedValue ( new Error ( 'Failed query: delete ...' , { cause } ) )
133+
134+ await expect ( runTableDelete ( basePayload ( ) ) ) . rejects . toThrow (
135+ 'canceling statement due to statement timeout'
136+ )
137+ } )
138+
139+ it ( 'resumes cumulative progress on retry instead of resetting to zero' , async ( ) => {
140+ mockGetJobProgress . mockResolvedValue ( 7 )
141+ mockSelectRowIdPage . mockResolvedValueOnce ( [ 'a' , 'b' ] ) . mockResolvedValueOnce ( [ ] )
142+
109143 await runTableDelete ( basePayload ( ) )
110144
111- expect ( mockMarkJobFailed ) . toHaveBeenCalledWith ( 'tbl_1' , 'job_1' , 'boom ' )
145+ expect ( mockUpdateJobProgress ) . toHaveBeenNthCalledWith ( 1 , 'tbl_1' , 7 , 'job_1 ' )
112146 expect ( mockAppendTableEvent ) . toHaveBeenCalledWith (
113- expect . objectContaining ( { kind : 'job' , type : 'delete' , status : 'failed ' , error : 'boom' } )
147+ expect . objectContaining ( { status : 'ready ' , progress : 9 } )
114148 )
115149 } )
116150
151+ it ( 'stops at the seed read when the job is no longer owned' , async ( ) => {
152+ mockGetJobProgress . mockResolvedValue ( null )
153+
154+ await expect ( runTableDelete ( basePayload ( ) ) ) . resolves . toBeUndefined ( )
155+
156+ expect ( mockSelectRowIdPage ) . not . toHaveBeenCalled ( )
157+ expect ( mockDeletePageByIds ) . not . toHaveBeenCalled ( )
158+ expect ( mockMarkJobFailed ) . not . toHaveBeenCalled ( )
159+ } )
160+
117161 it ( 'passes the cutoff and filter clause through to the page query' , async ( ) => {
118162 mockSelectRowIdPage . mockResolvedValueOnce ( [ ] )
119163
@@ -129,3 +173,42 @@ describe('runTableDelete', () => {
129173 )
130174 } )
131175} )
176+
177+ describe ( 'markTableDeleteFailed' , ( ) => {
178+ beforeEach ( ( ) => {
179+ vi . clearAllMocks ( )
180+ mockMarkJobFailed . mockResolvedValue ( undefined )
181+ } )
182+
183+ it ( 'marks the job failed and emits the failed event' , async ( ) => {
184+ await markTableDeleteFailed ( 'tbl_1' , 'job_1' , new Error ( 'boom' ) )
185+
186+ expect ( mockMarkJobFailed ) . toHaveBeenCalledWith ( 'tbl_1' , 'job_1' , 'boom' )
187+ expect ( mockAppendTableEvent ) . toHaveBeenCalledWith (
188+ expect . objectContaining ( { kind : 'job' , type : 'delete' , status : 'failed' , error : 'boom' } )
189+ )
190+ } )
191+
192+ it ( 'prefers the error cause over a verbose wrapper message' , async ( ) => {
193+ const cause = new Error ( 'canceling statement due to statement timeout' )
194+ const wrapper = new Error ( `Failed query: delete from x where id in (${ '$1,' . repeat ( 5000 ) } )` , {
195+ cause,
196+ } )
197+
198+ await markTableDeleteFailed ( 'tbl_1' , 'job_1' , wrapper )
199+
200+ expect ( mockMarkJobFailed ) . toHaveBeenCalledWith (
201+ 'tbl_1' ,
202+ 'job_1' ,
203+ 'canceling statement due to statement timeout'
204+ )
205+ } )
206+
207+ it ( 'truncates oversized messages' , async ( ) => {
208+ await markTableDeleteFailed ( 'tbl_1' , 'job_1' , new Error ( 'x' . repeat ( 2000 ) ) )
209+
210+ const [ , , message ] = mockMarkJobFailed . mock . calls [ 0 ]
211+ expect ( message ) . toHaveLength ( 503 )
212+ expect ( message . endsWith ( '...' ) ) . toBe ( true )
213+ } )
214+ } )
0 commit comments