diff --git a/src/storage/backend/s3/adapter.ts b/src/storage/backend/s3/adapter.ts index 5c8fc4736..ca1fd8112 100644 --- a/src/storage/backend/s3/adapter.ts +++ b/src/storage/backend/s3/adapter.ts @@ -343,6 +343,26 @@ export class S3Backend implements StorageBackendAdapter { }) await this.client.send(command) } catch (e) { + // Some S3-compatible backends (e.g. GCS) do not support DeleteObjects; fall back to individual deletes + const code = (e as { Code?: string; name?: string })?.Code ?? (e as { name?: string })?.name + if (code === 'NotImplemented') { + const results = await Promise.allSettled( + prefixes.map((key) => + this.client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })) + ) + ) + for (const result of results) { + if (result.status === 'rejected') { + const errCode = + (result.reason as { Code?: string })?.Code ?? + (result.reason as { name?: string })?.name + if (errCode !== 'NoSuchKey') { + throw StorageBackendError.fromError(result.reason) + } + } + } + return + } throw StorageBackendError.fromError(e) } } diff --git a/src/test/s3-adapter.test.ts b/src/test/s3-adapter.test.ts index 0d5288e5f..036400004 100644 --- a/src/test/s3-adapter.test.ts +++ b/src/test/s3-adapter.test.ts @@ -20,9 +20,9 @@ describe('S3Backend', () => { beforeEach(() => { jest.clearAllMocks() mockSend = jest.fn() - ;(S3Client as jest.Mock).mockImplementation(() => ({ - send: mockSend, - })) + ; (S3Client as jest.Mock).mockImplementation(() => ({ + send: mockSend, + })) }) describe('getObject', () => { @@ -74,4 +74,71 @@ describe('S3Backend', () => { expect(result.metadata.mimetype).toBe('image/png') }) }) + + describe('deleteObjects', () => { + test('should use batch DeleteObjectsCommand when backend supports it', async () => { + mockSend.mockResolvedValue({ + Deleted: [{ Key: 'file1.txt' }, { Key: 'file2.txt' }], + $metadata: { httpStatusCode: 200 }, + }) + + const backend = new S3Backend({ region: 'us-east-1', endpoint: 'http://localhost:9000' }) + await backend.deleteObjects('test-bucket', ['file1.txt', 'file2.txt']) + + expect(mockSend).toHaveBeenCalledTimes(1) + expect(mockSend.mock.calls[0][0].constructor.name).toBe('DeleteObjectsCommand') + }) + + test('should fall back to individual DeleteObjectCommands when backend returns NotImplemented', async () => { + const err = Object.assign(new Error('NotImplemented'), { Code: 'NotImplemented' }) + mockSend + .mockRejectedValueOnce(err) + .mockResolvedValue({ $metadata: { httpStatusCode: 204 } }) + + const backend = new S3Backend({ region: 'us-east-1', endpoint: 'http://localhost:9000' }) + await backend.deleteObjects('test-bucket', ['file1.txt', 'file2.txt']) + + expect(mockSend).toHaveBeenCalledTimes(3) + expect(mockSend.mock.calls[0][0].constructor.name).toBe('DeleteObjectsCommand') + expect(mockSend.mock.calls[1][0].constructor.name).toBe('DeleteObjectCommand') + expect(mockSend.mock.calls[2][0].constructor.name).toBe('DeleteObjectCommand') + }) + + test('should ignore NoSuchKey errors in the individual fallback', async () => { + const notImplemented = Object.assign(new Error('NotImplemented'), { Code: 'NotImplemented' }) + const noSuchKey = Object.assign(new Error('NoSuchKey'), { Code: 'NoSuchKey' }) + mockSend + .mockRejectedValueOnce(notImplemented) + .mockResolvedValueOnce({ $metadata: { httpStatusCode: 204 } }) + .mockRejectedValueOnce(noSuchKey) + + const backend = new S3Backend({ region: 'us-east-1', endpoint: 'http://localhost:9000' }) + await expect( + backend.deleteObjects('test-bucket', ['file1.txt', 'file2.txt']) + ).resolves.toBeUndefined() + }) + + test('should throw when an individual fallback delete fails with a real error', async () => { + const notImplemented = Object.assign(new Error('NotImplemented'), { Code: 'NotImplemented' }) + const accessDenied = Object.assign(new Error('AccessDenied'), { Code: 'AccessDenied' }) + mockSend + .mockRejectedValueOnce(notImplemented) + .mockResolvedValueOnce({ $metadata: { httpStatusCode: 204 } }) + .mockRejectedValueOnce(accessDenied) + + const backend = new S3Backend({ region: 'us-east-1', endpoint: 'http://localhost:9000' }) + await expect( + backend.deleteObjects('test-bucket', ['file1.txt', 'file2.txt']) + ).rejects.toThrow() + }) + + test('should rethrow errors that are not NotImplemented', async () => { + const err = Object.assign(new Error('AccessDenied'), { Code: 'AccessDenied' }) + mockSend.mockRejectedValue(err) + + const backend = new S3Backend({ region: 'us-east-1', endpoint: 'http://localhost:9000' }) + await expect(backend.deleteObjects('test-bucket', ['file1.txt'])).rejects.toThrow() + expect(mockSend).toHaveBeenCalledTimes(1) + }) + }) })