Skip to content

Commit 99b6de7

Browse files
authored
fix(account-deletion): resume the workspace storage sweep from its saved listing position (#9159)
* fix(account-deletion): resume a workspace storage sweep from its saved listing position A sweep that hit its deadline checkpointed only the family, so the next run re-listed the family from page one. When the early pages held only kept objects and walking them filled the handler window, every run re-walked them, deleted nothing, spent an attempt, and eventually dead-lettered, leaving the unreferenced objects behind them. A stopped run now checkpoints { family, pageToken } and the next run resumes there; moving the saved position counts as progress, so the run continues without spending an attempt. Completing a family clears the token. A saved token that no longer lists (expired or rejected by the provider) restarts the family from the start once, and that restart does not count as progress, so a cursor that never lists stays bounded by the attempt budget. Payloads without a token still parse; a present token must be a non-empty string of at most 4096 characters. * fix(account-deletion): bound position-only sweep resumes and reject foreign local page tokens Provider cursors are opaque, so a moved listing position alone no longer counts as progress past 1000 resumes per event; past that a run that only moved its position spends an attempt, keeping the sweep bounded even by a cursor that revisits positions. A local page token that is not a key under the prefix (one saved under another storage provider) now fails to list, so the sweep restarts the family instead of skipping every key before it. * fix(account-deletion): retry a saved sweep position once before restarting its family A single failed listing from a saved page token restarted the family from the prefix start, so a transient provider error at a deep position threw the position away. The first failure now records tokenFailures: 1 and rethrows, so the outbox retries from the same position under its normal attempt budget; only a second failure from that token restarts the family. Any successful listing clears the count.
1 parent e16fd83 commit 99b6de7

3 files changed

Lines changed: 284 additions & 19 deletions

File tree

‎apps/sim/lib/uploads/core/prefix-sweep.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,13 @@ async function localKeys(
9494
}
9595

9696
/** One provider page of object keys; `done` once the listing has nothing after this page. */
97-
interface StorageKeyPage {
97+
export interface StorageKeyPage {
9898
keys: string[]
9999
done: boolean
100-
/** Provider cursor for the following page, valid only within the current run. */
100+
/**
101+
* Opaque provider cursor for the following page. A caller may persist it across runs, but a
102+
* stored one can stop listing (a provider expiring or rejecting it), which that caller handles.
103+
*/
101104
nextPageToken?: string
102105
}
103106

@@ -170,6 +173,8 @@ export async function listStorageKeyPage(
170173
: undefined
171174
)
172175
}
176+
if (pageToken && !pageToken.startsWith(prefix))
177+
throw new Error('Local storage page token is not a key under the prefix')
173178
const keys = await localKeys(prefix, signal, pageToken)
174179
return keyPage(keys, keys.length < STORAGE_KEY_PAGE_SIZE ? undefined : keys[keys.length - 1])
175180
}

‎apps/sim/lib/users/storage-sweep.test.ts‎

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1616
vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock)
1717
vi.mock('@/lib/uploads/config', () => uploadsConfigMock)
1818
vi.mock('@/lib/uploads/server/metadata', () => uploadsMetadataMock)
19+
vi.mock('@/lib/uploads/core/prefix-sweep', async (importOriginal) => {
20+
const actual = await importOriginal<typeof import('@/lib/uploads/core/prefix-sweep')>()
21+
return { ...actual, listStorageKeyPage: vi.fn(actual.listStorageKeyPage) }
22+
})
1923

2024
import type { OutboxEventContext } from '@/lib/core/outbox/service'
2125
import type { DbTransaction } from '@/lib/db/types'
26+
import { listStorageKeyPage } from '@/lib/uploads/core/prefix-sweep'
2227
import { createStorageReferenceGuard } from '@/lib/users/storage-reference-guard'
2328
import {
2429
accountStorageSweepOutboxHandlers,
@@ -47,11 +52,59 @@ function outboxContext(overrides: Partial<OutboxEventContext> = {}): OutboxEvent
4752
}
4853
}
4954

55+
/** A context whose deadline is near once it has listed `listings` storage pages. */
56+
function deadlineAfter(listings: number, overrides: Partial<OutboxEventContext> = {}) {
57+
const context = outboxContext(overrides)
58+
const listed = vi.mocked(listStorageKeyPage).mock.calls.length
59+
Object.defineProperty(context, 'deadlineAt', {
60+
get: () =>
61+
vi.mocked(listStorageKeyPage).mock.calls.length - listed >= listings
62+
? 1
63+
: Number.POSITIVE_INFINITY,
64+
})
65+
return context
66+
}
67+
68+
/** Re-runs the sweep the way the outbox does, merging each checkpoint into the next payload. */
69+
async function sweepUntilSettled(payload: Record<string, unknown>, listingsPerRun: number) {
70+
let current = payload
71+
const outcomes: Awaited<ReturnType<typeof sweep>>[] = []
72+
for (let run = 0; run < 12; run++) {
73+
const outcome = await sweep(
74+
current,
75+
deadlineAfter(listingsPerRun, {
76+
checkpointPayload: vi.fn(async (patch: Record<string, unknown>) => {
77+
current = { ...current, ...patch }
78+
}),
79+
})
80+
)
81+
outcomes.push(outcome)
82+
if (outcome === undefined) break
83+
}
84+
return outcomes
85+
}
86+
5087
async function put(key: string, body = 'x'): Promise<void> {
5188
await mkdir(dirname(join(root, key)), { recursive: true })
5289
await writeFile(join(root, key), body)
5390
}
5491

92+
/** Writes `count` objects a surviving document names, answering `lookups` reference checks. */
93+
async function putKept(count: number, lookups = 1): Promise<string[]> {
94+
const width = String(count - 1).length
95+
const keys = Array.from(
96+
{ length: count },
97+
(_, index) => `workspace/wsdoomed1/a-kept/${String(index).padStart(width, '0')}.bin`
98+
)
99+
for (const key of keys) await put(key)
100+
for (let lookup = 0; lookup < lookups; lookup++)
101+
queueTableRows(
102+
schemaMock.document,
103+
keys.map((key) => ({ key }))
104+
)
105+
return keys
106+
}
107+
55108
async function exists(key: string): Promise<boolean> {
56109
try {
57110
await stat(join(root, key))
@@ -249,12 +302,132 @@ describe('deleted workspace storage sweep', () => {
249302
for (const key of keptKeys) expect(await exists(key)).toBe(true)
250303
})
251304

305+
it('reaches objects behind more kept pages than one run can walk, spending no attempt', async () => {
306+
const keptKeys = await putKept(1000, 30)
307+
await put('workspace/wsdoomed1/z-unkept/0.bin')
308+
309+
const outcomes = await sweepUntilSettled({ version: 1, workspaceId: 'wsdoomed1' }, 2)
310+
311+
expect(outcomes.at(-1)).toBeUndefined()
312+
for (const outcome of outcomes.slice(0, -1))
313+
expect(outcome).toMatchObject({ consumeAttempt: false })
314+
expect(await exists('workspace/wsdoomed1/z-unkept/0.bin')).toBe(false)
315+
for (const key of keptKeys) expect(await exists(key)).toBe(true)
316+
})
317+
318+
it('resumes a family from its saved listing position', async () => {
319+
const checkpointPayload = vi.fn(async () => {})
320+
321+
await expect(
322+
sweep(
323+
{ version: 1, workspaceId: 'wsdoomed1', family: 0, pageToken: 'workspace/wsdoomed1/a.txt' },
324+
outboxContext({ checkpointPayload })
325+
)
326+
).resolves.toBe(undefined)
327+
328+
expect(await exists('workspace/wsdoomed1/a.txt')).toBe(true)
329+
expect(await exists('workspace/wsdoomed1/nested/b.txt')).toBe(false)
330+
expect(await exists('table-import/wsdoomed1/import-1/rows.csv')).toBe(false)
331+
expect(checkpointPayload).toHaveBeenCalledWith({ family: 1, pageToken: null })
332+
})
333+
334+
it('retries a saved position that failed to list once instead of walking from the start', async () => {
335+
let payload: Record<string, unknown> = {
336+
version: 1,
337+
workspaceId: 'wsdoomed1',
338+
pageToken: 'workspace/wsdoomed1/a.txt',
339+
}
340+
const checkpointPayload = vi.fn(async (patch: Record<string, unknown>) => {
341+
payload = { ...payload, ...patch }
342+
})
343+
vi.mocked(listStorageKeyPage).mockRejectedValueOnce(new Error('Service unavailable'))
344+
345+
await expect(sweep(payload, outboxContext({ checkpointPayload }))).rejects.toThrow(
346+
'Service unavailable'
347+
)
348+
for (const key of DOOMED_KEYS) expect(await exists(key)).toBe(true)
349+
expect(payload).toMatchObject({ pageToken: 'workspace/wsdoomed1/a.txt', tokenFailures: 1 })
350+
351+
await expect(sweep(payload, outboxContext({ checkpointPayload }))).resolves.toBe(undefined)
352+
expect(await exists('workspace/wsdoomed1/a.txt')).toBe(true)
353+
expect(await exists('workspace/wsdoomed1/nested/b.txt')).toBe(false)
354+
expect(payload).toMatchObject({ tokenFailures: 0 })
355+
})
356+
357+
it.each([
358+
{ name: 'no longer lists', pageToken: 'expired-token', rejects: true },
359+
{ name: 'is not a key under the prefix', pageToken: 'zz-other-provider', rejects: false },
360+
])(
361+
'walks the family from the start once its saved position $name on a second attempt',
362+
async ({ pageToken, rejects }) => {
363+
let payload: Record<string, unknown> = { version: 1, workspaceId: 'wsdoomed1', pageToken }
364+
const checkpointPayload = vi.fn(async (patch: Record<string, unknown>) => {
365+
payload = { ...payload, ...patch }
366+
})
367+
const invalid = new Error('The continuation token is invalid')
368+
if (rejects)
369+
vi.mocked(listStorageKeyPage).mockRejectedValueOnce(invalid).mockRejectedValueOnce(invalid)
370+
371+
await expect(sweep(payload, outboxContext({ checkpointPayload }))).rejects.toThrow()
372+
for (const key of DOOMED_KEYS) expect(await exists(key)).toBe(true)
373+
374+
await expect(sweep(payload, outboxContext({ checkpointPayload }))).resolves.toBe(undefined)
375+
for (const key of DOOMED_KEYS) expect(await exists(key)).toBe(false)
376+
}
377+
)
378+
379+
it('spends an attempt when a restart from a rejected position only walks kept objects', async () => {
380+
const keptKeys = await putKept(500)
381+
vi.mocked(listStorageKeyPage).mockRejectedValueOnce(
382+
new Error('The continuation token is invalid')
383+
)
384+
const checkpointPayload = vi.fn(async () => {})
385+
386+
const outcome = await sweep(
387+
{ version: 1, workspaceId: 'wsdoomed1', pageToken: 'expired-token', tokenFailures: 1 },
388+
deadlineAfter(2, { checkpointPayload })
389+
)
390+
391+
expect(outcome).toMatchObject({ outcome: 'deferred' })
392+
expect(outcome).not.toHaveProperty('consumeAttempt')
393+
expect(checkpointPayload).toHaveBeenCalledWith({
394+
family: 0,
395+
pageToken: keptKeys.at(-1),
396+
resumes: 0,
397+
})
398+
})
399+
400+
it('spends an attempt on a moved position once its resume budget is gone', async () => {
401+
const keptKeys = await putKept(1000)
402+
const checkpointPayload = vi.fn(async () => {})
403+
404+
const outcome = await sweep(
405+
{ version: 1, workspaceId: 'wsdoomed1', resumes: 1000 },
406+
deadlineAfter(1, { checkpointPayload })
407+
)
408+
409+
expect(outcome).toMatchObject({ outcome: 'deferred' })
410+
expect(outcome).not.toHaveProperty('consumeAttempt')
411+
expect(checkpointPayload).toHaveBeenCalledWith({
412+
family: 0,
413+
pageToken: keptKeys[499],
414+
resumes: 1001,
415+
})
416+
})
417+
252418
it.each([
253419
{ version: 1, workspaceId: '' },
254420
{ version: 1, workspaceId: '../wsdoomed1' },
255421
{ version: 1, workspaceId: 'wsdoomed1/' },
256422
{ version: 1, workspaceId: '*' },
257423
{ workspaceId: 'wsdoomed1' },
424+
{ version: 1, workspaceId: 'wsdoomed1', pageToken: 42 },
425+
{ version: 1, workspaceId: 'wsdoomed1', pageToken: '' },
426+
{ version: 1, workspaceId: 'wsdoomed1', pageToken: 'x'.repeat(4097) },
427+
{ version: 1, workspaceId: 'wsdoomed1', resumes: -1 },
428+
{ version: 1, workspaceId: 'wsdoomed1', resumes: '1' },
429+
{ version: 1, workspaceId: 'wsdoomed1', tokenFailures: 2 },
430+
{ version: 1, workspaceId: 'wsdoomed1', tokenFailures: '1' },
258431
])('rejects an unbounded or malformed payload %o', async (payload) => {
259432
await expect(sweep(payload, outboxContext())).rejects.toThrow()
260433
for (const key of DOOMED_KEYS) expect(await exists(key)).toBe(true)

0 commit comments

Comments
 (0)