Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/storage/backend/s3/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
73 changes: 70 additions & 3 deletions src/test/s3-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)
})
})
})