Skip to content

Commit 7c800c6

Browse files
committed
fix(storage): abort backend multipart when reaping abandoned sys_upload_session (objectstack-ai#2970)
The sys_upload_session lifecycle (objectstack-ai#2984) reaps abandoned/terminal chunked-upload session rows but not the underlying backend multipart — on S3 an initiated-but-not-completed multipart keeps its parts billable and invisible until AbortMultipartUpload, so reaping only the row stranded them (backend_upload_id, the sole pointer, gone). createUploadSessionReapGuard registers a LifecycleReapGuard on sys_upload_session that aborts the backend multipart before the row is deleted: skips 'completed' sessions (multipart already an object — an abort would NoSuchUpload-error), re-seeds the S3 uploadId->key map from the row (cold sweeps lack the live in-process map), and vetoes (keeps the row for retry) on abort failure so the pointer survives. Local adapter parts dir removed the same way. Verified: 7 new unit tests (S3 key re-seed, completed-skip, no-backend, veto-on-failure, local adapter, no-abort adapter); service-storage 99; dogfood matrix drives a real chunked upload → part on disk → sweep → row gone AND parts dir aborted. Closes the last objectstack-ai#2970 sub-follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
1 parent 96a14d0 commit 7c800c6

7 files changed

Lines changed: 208 additions & 10 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(storage): abort the backend multipart upload when reaping an abandoned sys_upload_session (#2970)
6+
7+
The `sys_upload_session` lifecycle (added in #2984) reaps abandoned/terminal
8+
chunked-upload session ROWS, but not the underlying backend multipart upload —
9+
on S3 an initiated-but-not-completed multipart keeps its already-uploaded parts
10+
billable and invisible to normal listing until an explicit
11+
`AbortMultipartUpload`, so reaping only the row stranded them (with
12+
`backend_upload_id`, the sole pointer, gone).
13+
14+
`createUploadSessionReapGuard` registers a `LifecycleReapGuard` on
15+
`sys_upload_session` that aborts the backend multipart before the row is
16+
deleted: it skips `completed` sessions (their multipart already became a real
17+
object — an abort would `NoSuchUpload`-error), re-seeds the S3 adapter's
18+
`uploadId → key` map from the row (a cold sweep lacks the live in-process map),
19+
and vetoes (keeps the row for retry) on abort failure so the pointer survives.
20+
The local adapter's parts directory is removed the same way.

packages/dogfood/test/attachments-permission-matrix.dogfood.test.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -476,19 +476,34 @@ describe('attachments permission matrix (#2755)', () => {
476476
expect(await ql.findOne('sys_file', { where: { id: data.fileId }, context: SYS })).toBeNull();
477477
});
478478

479-
it('(item 4) abandoned sys_upload_session rows are reaped past their expiry window', async () => {
480-
// Initiate a chunked upload (creates a sys_upload_session) but never
481-
// complete it.
479+
it('(item 4 + multipart-abort guard) an abandoned chunked upload is reaped AND its uploaded parts are aborted', async () => {
480+
// Initiate a chunked upload (creates a sys_upload_session) and upload one
481+
// chunk — but never complete it. The chunk lands as a part on disk.
482482
const init = await stack.api('/storage/upload/chunked', {
483483
method: 'POST',
484484
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${memberATok}` },
485485
body: JSON.stringify({ filename: 'big.bin', mimeType: 'application/octet-stream', totalSize: 10_485_760 }),
486486
});
487487
expect(init.status).toBe(200);
488-
const { uploadId } = ((await init.json()) as any).data;
488+
const { uploadId, resumeToken } = ((await init.json()) as any).data;
489489
const session = await ql.findOne('sys_upload_session', { where: { id: uploadId }, context: SYS });
490490
expect(session?.id, 'session row created').toBeTruthy();
491491

492+
const chunkRes = await stack.api(`/storage/upload/chunked/${uploadId}/chunk/0`, {
493+
method: 'PUT',
494+
headers: {
495+
Authorization: `Bearer ${memberATok}`,
496+
'Content-Type': 'application/octet-stream',
497+
'x-resume-token': resumeToken,
498+
},
499+
body: Buffer.from('a partial chunk of bytes'),
500+
});
501+
expect(chunkRes.status, await chunkRes.clone().text()).toBeLessThan(300);
502+
503+
// The local adapter stores parts under `<rootDir>/.parts/<uploadId>/`.
504+
const partsDir = join(rootDir, '.parts', String(uploadId));
505+
await expect(fs.access(partsDir), 'part exists before the sweep').resolves.toBeUndefined();
506+
492507
// Backdate expires_at past the 1d TTL grace.
493508
await ql.update(
494509
'sys_upload_session',
@@ -498,7 +513,9 @@ describe('attachments permission matrix (#2755)', () => {
498513

499514
const report = await lifecycle.sweep();
500515
expect(report.errors, JSON.stringify(report.errors)).toEqual([]);
516+
// Row gone AND the backend multipart parts aborted (dir removed).
501517
expect(await ql.findOne('sys_upload_session', { where: { id: uploadId }, context: SYS })).toBeNull();
518+
await expect(fs.access(partsDir), 'parts aborted by the reap guard').rejects.toThrow();
502519
});
503520
});
504521
});

packages/services/service-storage/src/attachment-lifecycle.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, it, expect, vi } from 'vitest';
44
import {
55
installAttachmentLifecycleHooks,
66
createSysFileReapGuard,
7+
createUploadSessionReapGuard,
78
type AttachmentLifecycleEngine,
89
} from './attachment-lifecycle.js';
910

@@ -247,3 +248,93 @@ describe('createSysFileReapGuard', () => {
247248
expect(confirmed).toEqual([]);
248249
});
249250
});
251+
252+
describe('createUploadSessionReapGuard', () => {
253+
/** Swappable-style storage fake: `getInner()` exposes an S3-like inner with
254+
* `setUploadKey`; `abortChunkedUpload` is forwarded. */
255+
const s3Storage = (abortImpl?: () => Promise<void>) => {
256+
const inner = {
257+
setUploadKey: vi.fn((_id: string, _key: string) => {}),
258+
};
259+
return {
260+
getInner: () => inner,
261+
abortChunkedUpload: vi.fn(abortImpl ?? (async () => {})),
262+
_inner: inner,
263+
} as any;
264+
};
265+
266+
it('aborts the backend multipart (re-seeding the key) then reaps an abandoned session', async () => {
267+
const s = s3Storage();
268+
const guard = createUploadSessionReapGuard(() => s, silentLogger());
269+
270+
const confirmed = await guard('sys_upload_session', [
271+
{ id: 'u1', backend_upload_id: 'mp-1', key: 'attachments/u1.bin', status: 'in_progress' },
272+
]);
273+
274+
expect(s._inner.setUploadKey).toHaveBeenCalledWith('mp-1', 'attachments/u1.bin');
275+
expect(s.abortChunkedUpload).toHaveBeenCalledWith('mp-1');
276+
expect(confirmed).toEqual(['u1']);
277+
});
278+
279+
it('does NOT abort a completed session (its multipart is already an object) — just reaps the row', async () => {
280+
const s = s3Storage();
281+
const guard = createUploadSessionReapGuard(() => s, silentLogger());
282+
283+
const confirmed = await guard('sys_upload_session', [
284+
{ id: 'u2', backend_upload_id: 'mp-2', key: 'attachments/u2.bin', status: 'completed' },
285+
]);
286+
287+
expect(s.abortChunkedUpload).not.toHaveBeenCalled();
288+
expect(confirmed).toEqual(['u2']);
289+
});
290+
291+
it('reaps a session with no backend_upload_id without calling abort', async () => {
292+
const s = s3Storage();
293+
const guard = createUploadSessionReapGuard(() => s, silentLogger());
294+
295+
const confirmed = await guard('sys_upload_session', [
296+
{ id: 'u3', key: 'attachments/u3.bin', status: 'expired' },
297+
]);
298+
299+
expect(s.abortChunkedUpload).not.toHaveBeenCalled();
300+
expect(confirmed).toEqual(['u3']);
301+
});
302+
303+
it('VETOES on abort failure so backend_upload_id survives for the retry', async () => {
304+
const s = s3Storage(async () => {
305+
throw new Error('S3 abort transient failure');
306+
});
307+
const logger = silentLogger();
308+
const guard = createUploadSessionReapGuard(() => s, logger);
309+
310+
const confirmed = await guard('sys_upload_session', [
311+
{ id: 'u4', backend_upload_id: 'mp-4', key: 'attachments/u4.bin', status: 'failed' },
312+
]);
313+
314+
expect(confirmed).toEqual([]); // kept
315+
expect(logger.warn).toHaveBeenCalled();
316+
});
317+
318+
it('works with a local-style adapter (no setUploadKey / getInner) — aborts by id', async () => {
319+
const local = { abortChunkedUpload: vi.fn(async () => {}) } as any;
320+
const guard = createUploadSessionReapGuard(() => local, silentLogger());
321+
322+
const confirmed = await guard('sys_upload_session', [
323+
{ id: 'u5', backend_upload_id: 'local-5', key: 'attachments/u5.bin', status: 'expired' },
324+
]);
325+
326+
expect(local.abortChunkedUpload).toHaveBeenCalledWith('local-5');
327+
expect(confirmed).toEqual(['u5']);
328+
});
329+
330+
it('reaps the row when the adapter cannot abort at all', async () => {
331+
const noAbort = {} as any;
332+
const guard = createUploadSessionReapGuard(() => noAbort, silentLogger());
333+
334+
const confirmed = await guard('sys_upload_session', [
335+
{ id: 'u6', backend_upload_id: 'mp-6', key: 'k', status: 'in_progress' },
336+
]);
337+
338+
expect(confirmed).toEqual(['u6']);
339+
});
340+
});

packages/services/service-storage/src/attachment-lifecycle.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,67 @@ export function createSysFileReapGuard(
259259
return confirmed;
260260
};
261261
}
262+
263+
/**
264+
* The `sys_upload_session` reap guard (#2970 sub-follow-up). The
265+
* LifecycleService reaps abandoned/terminal chunked-upload session ROWS by the
266+
* declared TTL (`expires_at`) / retention (terminal statuses); this guard
267+
* aborts the underlying BACKEND multipart upload before the row is deleted, so
268+
* a session's already-uploaded parts don't leak. On S3 an initiated-but-not-
269+
* completed multipart keeps its parts billable and invisible to normal listing
270+
* until an explicit AbortMultipartUpload — reaping only the row would strand
271+
* them, with `backend_upload_id` (the sole pointer) gone.
272+
*
273+
* - `completed`: the multipart was already finalized into a real object —
274+
* nothing to abort (an abort would `NoSuchUpload`-error and wedge the reap).
275+
* Confirm the row.
276+
* - no `backend_upload_id`, or an adapter without `abortChunkedUpload`:
277+
* nothing to abort. Confirm.
278+
* - otherwise (in_progress / failed / expired with a backend upload): abort
279+
* the backend multipart, re-seeding the S3 `uploadId → key` map from the row
280+
* first (a cold sweep lacks the live in-process map; a no-op for adapters
281+
* that don't track keys, e.g. local). Confirm on success; VETO on failure
282+
* so the row — the only pointer to the leaked multipart — is retried.
283+
*/
284+
export function createUploadSessionReapGuard(
285+
getStorage: () => IStorageService | null | undefined,
286+
logger: AttachmentLifecycleLogger,
287+
): (object: string, rows: Array<Record<string, unknown>>) => Promise<Array<string | number>> {
288+
return async (_object, rows) => {
289+
const confirmed: Array<string | number> = [];
290+
const storage = getStorage();
291+
for (const row of rows) {
292+
const id = row?.id as string | number | undefined;
293+
if (id === undefined || id === null) continue;
294+
295+
const backendId = typeof row.backend_upload_id === 'string' ? row.backend_upload_id : '';
296+
// Nothing to abort → just reap the row: no backend multipart, an already
297+
// -completed upload (its parts became an object), or an adapter that
298+
// can't abort.
299+
if (!backendId || row.status === 'completed' || !storage || typeof storage.abortChunkedUpload !== 'function') {
300+
confirmed.push(id);
301+
continue;
302+
}
303+
304+
try {
305+
// A cold sweep runs long after the live session, so the S3 adapter's
306+
// in-process `uploadId → key` map (populated by `setUploadKey` during
307+
// upload) is empty — re-seed it from the row so the abort can resolve
308+
// the S3 key. `setUploadKey` is S3-specific and not forwarded by the
309+
// swappable proxy, so reach the inner adapter; a no-op for `local`.
310+
const inner: any = typeof (storage as any).getInner === 'function' ? (storage as any).getInner() : storage;
311+
if (typeof row.key === 'string' && row.key && typeof inner?.setUploadKey === 'function') {
312+
inner.setUploadKey(backendId, row.key);
313+
}
314+
await storage.abortChunkedUpload(backendId);
315+
confirmed.push(id);
316+
} catch (err) {
317+
logger.warn(
318+
`[storage] reap guard: multipart abort failed for sys_upload_session ${id} (${(err as Error)?.message ?? err}); retrying next sweep`,
319+
);
320+
// veto — keep the row so `backend_upload_id` survives for the retry.
321+
}
322+
}
323+
return confirmed;
324+
};
325+
}

packages/services/service-storage/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export type { FileRecord, UploadSessionRecord } from './metadata-store.js';
1212
export { registerStorageRoutes } from './storage-routes.js';
1313
export type { StorageRoutesOptions, FileReadVerdict } from './storage-routes.js';
1414
export { SystemFile, SystemUploadSession } from './objects/index.js';
15-
export { installAttachmentLifecycleHooks, createSysFileReapGuard } from './attachment-lifecycle.js';
15+
export { installAttachmentLifecycleHooks, createSysFileReapGuard, createUploadSessionReapGuard } from './attachment-lifecycle.js';
1616
export type { AttachmentLifecycleEngine, AttachmentLifecycleLogger } from './attachment-lifecycle.js';
1717
export { installAttachmentAccessHooks, installAttachmentReadVisibility } from './attachment-access-hooks.js';
1818
export type { AttachmentSharingLike } from './attachment-access-hooks.js';

packages/services/service-storage/src/storage-service-plugin.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,9 @@ describe('StorageServicePlugin: sys_file orphan lifecycle wiring (#2755)', () =>
229229
'beforeDelete',
230230
'beforeInsert',
231231
]);
232-
expect(guards).toHaveLength(1);
233-
expect(guards[0].object).toBe('sys_file');
234-
expect(typeof guards[0].guard).toBe('function');
232+
// Two reap guards: sys_file (#2755) + sys_upload_session multipart-abort (#2970).
233+
expect(guards.map((g) => g.object).sort()).toEqual(['sys_file', 'sys_upload_session']);
234+
expect(guards.every((g) => typeof g.guard === 'function')).toBe(true);
235235
// Read-visibility middleware registered on sys_attachment (#2970 item 1).
236236
expect(middlewares).toEqual([{ object: 'sys_attachment' }]);
237237
});

packages/services/service-storage/src/storage-service-plugin.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { StorageMetadataStore } from './metadata-store.js';
1616
import type { FileRecord } from './metadata-store.js';
1717
import { registerStorageRoutes } from './storage-routes.js';
1818
import type { FileReadVerdict } from './storage-routes.js';
19-
import { installAttachmentLifecycleHooks, createSysFileReapGuard } from './attachment-lifecycle.js';
19+
import { installAttachmentLifecycleHooks, createSysFileReapGuard, createUploadSessionReapGuard } from './attachment-lifecycle.js';
2020
import { installAttachmentAccessHooks, installAttachmentReadVisibility } from './attachment-access-hooks.js';
2121
import { SystemFile, SystemUploadSession } from './objects/index.js';
2222
// ADR-0052 §3 ownership: `sys_attachment` (a file↔record link) belongs with the
@@ -228,7 +228,13 @@ export class StorageServicePlugin implements Plugin {
228228
'sys_file',
229229
createSysFileReapGuard(engine as any, () => this.storage, ctx.logger),
230230
);
231-
ctx.logger.info('StorageServicePlugin: sys_file reap guard registered with the lifecycle service');
231+
// Abort the backend multipart upload before an abandoned/terminal
232+
// sys_upload_session row is reaped, so its parts don't leak (#2970).
233+
lifecycle.registerReapGuard(
234+
'sys_upload_session',
235+
createUploadSessionReapGuard(() => this.storage, ctx.logger),
236+
);
237+
ctx.logger.info('StorageServicePlugin: sys_file + sys_upload_session reap guards registered with the lifecycle service');
232238
}
233239
} catch {
234240
// lifecycle service absent (bare kernel) — the sys_file lifecycle

0 commit comments

Comments
 (0)