Skip to content

Commit b6b815f

Browse files
fix: gracefully ignore missing tenant when cleaning up iceberg resources (#1223)
* fix: gracefully ignore missing tenant when cleaning up iceberg resources * add delete iceberg resources tests * refactor: simplify tests with reset modules Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com> --------- Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com> Co-authored-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent d432f7d commit b6b815f

2 files changed

Lines changed: 288 additions & 7 deletions

File tree

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import { vi } from 'vitest'
2+
3+
const {
4+
MockPgMetastore,
5+
MockRestCatalogClient,
6+
MockShardCatalog,
7+
MockPgShardStoreFactory,
8+
mockConfig,
9+
mockCreateStorage,
10+
mockMultitenantPgExecutor,
11+
} = vi.hoisted(() => ({
12+
MockPgMetastore: vi.fn(),
13+
MockRestCatalogClient: vi.fn(),
14+
MockShardCatalog: vi.fn(),
15+
MockPgShardStoreFactory: vi.fn(),
16+
mockConfig: {
17+
icebergCatalogUrl: 'http://catalog',
18+
icebergCatalogAuthType: 'none',
19+
isMultitenant: true,
20+
},
21+
mockCreateStorage: vi.fn(),
22+
mockMultitenantPgExecutor: 'mock-multitenant-executor',
23+
}))
24+
25+
vi.mock('../../../config', () => ({
26+
getConfig: () => mockConfig,
27+
}))
28+
29+
vi.mock('@internal/database', () => ({
30+
multitenantPgExecutor: mockMultitenantPgExecutor,
31+
}))
32+
33+
vi.mock('@storage/protocols/iceberg/pg', () => ({
34+
PgMetastore: MockPgMetastore,
35+
}))
36+
37+
vi.mock('@storage/protocols/iceberg/catalog', () => ({
38+
RestCatalogClient: MockRestCatalogClient,
39+
getCatalogAuthStrategy: vi.fn().mockReturnValue('mock-auth'),
40+
}))
41+
42+
vi.mock('@internal/sharding', () => ({
43+
ShardCatalog: MockShardCatalog,
44+
PgShardStoreFactory: MockPgShardStoreFactory,
45+
}))
46+
47+
vi.mock('@internal/monitoring', () => ({
48+
logger: { error: vi.fn() },
49+
}))
50+
51+
vi.mock('../base-event', () => ({
52+
BaseEvent: class {
53+
static createStorage = mockCreateStorage
54+
static getQueueName(this: { queueName: string }) {
55+
return this.queueName
56+
}
57+
},
58+
}))
59+
60+
async function importHandler(isMultitenant: boolean) {
61+
mockConfig.isMultitenant = isMultitenant
62+
vi.resetModules()
63+
return (await import('./delete-iceberg-resources')).DeleteIcebergResources
64+
}
65+
66+
const jobData = {
67+
catalogId: 'catalog-123',
68+
tenant: {
69+
ref: 'tenant-a',
70+
host: '',
71+
},
72+
sbReqId: 'sb-req-123',
73+
}
74+
75+
const metastore = { transaction: vi.fn() }
76+
77+
const store = {
78+
lockResource: vi.fn(),
79+
findCatalogById: vi.fn(),
80+
listNamespaces: vi.fn(),
81+
listTables: vi.fn(),
82+
dropTable: vi.fn(),
83+
dropNamespace: vi.fn(),
84+
dropCatalog: vi.fn(),
85+
getTnx: vi.fn(),
86+
}
87+
88+
const restCatalog = {
89+
dropTable: vi.fn(),
90+
listTables: vi.fn(),
91+
dropNamespace: vi.fn(),
92+
}
93+
94+
const shardCatalog = { withTnx: vi.fn() }
95+
const sharder = { freeByResource: vi.fn() }
96+
97+
const db = {
98+
connection: {
99+
pool: {
100+
acquire: vi.fn(),
101+
},
102+
},
103+
deleteAnalyticsBucket: vi.fn(),
104+
destroyConnection: vi.fn(),
105+
}
106+
107+
function expectIcebergCleanup({ multitenant }: { multitenant: boolean }) {
108+
expect(mockCreateStorage).toHaveBeenCalledWith(jobData)
109+
expect(MockPgMetastore).toHaveBeenCalledWith(
110+
multitenant ? mockMultitenantPgExecutor : 'mock-db-connection',
111+
{
112+
multiTenant: multitenant,
113+
schema: multitenant ? 'public' : 'storage',
114+
}
115+
)
116+
expect(metastore.transaction).toHaveBeenCalled()
117+
expect(store.lockResource).toHaveBeenCalledWith('catalog', 'catalog-123')
118+
expect(store.findCatalogById).toHaveBeenCalledWith({
119+
id: 'catalog-123',
120+
deleted: true,
121+
tenantId: 'tenant-a',
122+
})
123+
expect(store.listNamespaces).toHaveBeenCalledWith({
124+
catalogId: 'catalog-123',
125+
tenantId: 'tenant-a',
126+
})
127+
expect(store.listTables).toHaveBeenCalledWith({
128+
namespaceId: 'ns-1',
129+
pageSize: 1000,
130+
tenantId: 'tenant-a',
131+
})
132+
expect(restCatalog.dropTable).toHaveBeenCalledWith({
133+
namespace: 'namespace-1',
134+
table: 'table-1',
135+
purgeRequested: true,
136+
warehouse: 'shard-key-1',
137+
})
138+
expect(store.dropTable).toHaveBeenCalledWith({
139+
name: 'table-1',
140+
namespaceId: 'ns-1',
141+
catalogId: 'catalog-123',
142+
tenantId: 'tenant-a',
143+
})
144+
expect(restCatalog.listTables).toHaveBeenCalledWith({
145+
namespace: 'tenant-a_ns_1',
146+
warehouse: 'shard-key-1',
147+
pageSize: 1,
148+
})
149+
expect(restCatalog.dropNamespace).toHaveBeenCalledWith({
150+
namespace: 'namespace-1',
151+
warehouse: 'shard-key-1',
152+
})
153+
expect(store.dropNamespace).toHaveBeenCalledWith({
154+
namespace: 'namespace-1',
155+
catalogId: 'catalog-123',
156+
tenantId: 'tenant-a',
157+
})
158+
expect(store.dropCatalog).toHaveBeenCalledWith({
159+
bucketId: 'catalog-123',
160+
tenantId: 'tenant-a',
161+
soft: false,
162+
})
163+
164+
if (multitenant) {
165+
expect(MockPgShardStoreFactory).toHaveBeenCalledWith(mockMultitenantPgExecutor)
166+
expect(MockShardCatalog).toHaveBeenCalled()
167+
expect(shardCatalog.withTnx).toHaveBeenCalledWith('mock-transaction')
168+
expect(sharder.freeByResource).toHaveBeenCalledWith('shard-id-1', {
169+
kind: 'iceberg-table',
170+
tenantId: 'tenant-a',
171+
bucketName: 'catalog-123',
172+
logicalName: 'ns-1/table-1',
173+
})
174+
} else {
175+
expect(MockShardCatalog).not.toHaveBeenCalled()
176+
}
177+
}
178+
179+
describe('DeleteIcebergResources.handle', () => {
180+
let DeleteIcebergResources: Awaited<ReturnType<typeof importHandler>>
181+
182+
const makeJob = () => ({
183+
id: 'job-1',
184+
name: DeleteIcebergResources.getQueueName(),
185+
data: jobData,
186+
})
187+
188+
beforeEach(() => {
189+
vi.clearAllMocks()
190+
191+
MockPgMetastore.mockImplementation(function () {
192+
return metastore
193+
})
194+
MockRestCatalogClient.mockImplementation(function () {
195+
return restCatalog
196+
})
197+
MockShardCatalog.mockImplementation(function () {
198+
return shardCatalog
199+
})
200+
201+
metastore.transaction.mockImplementation(async (fn) => fn(store))
202+
store.getTnx.mockReturnValue('mock-transaction')
203+
shardCatalog.withTnx.mockReturnValue(sharder)
204+
db.connection.pool.acquire.mockReturnValue('mock-db-connection')
205+
206+
store.findCatalogById.mockResolvedValue({ id: 'catalog-123', deleted_at: new Date() })
207+
store.listNamespaces.mockResolvedValue([{ id: 'ns-1', name: 'namespace-1' }])
208+
store.listTables.mockResolvedValue([
209+
{ name: 'table-1', shard_key: 'shard-key-1', shard_id: 'shard-id-1' },
210+
])
211+
restCatalog.listTables.mockResolvedValue({ identifiers: [] })
212+
db.destroyConnection.mockResolvedValue(undefined)
213+
})
214+
215+
describe('multitenant', () => {
216+
beforeAll(async () => {
217+
DeleteIcebergResources = await importHandler(true)
218+
})
219+
220+
it('should remove all resources and multitenant db rows when createStorage fails', async () => {
221+
mockCreateStorage.mockRejectedValue(new Error('Tenant not found'))
222+
223+
await expect(DeleteIcebergResources.handle(makeJob() as never)).resolves.toBeUndefined()
224+
225+
expectIcebergCleanup({ multitenant: true })
226+
expect(db.deleteAnalyticsBucket).not.toHaveBeenCalled()
227+
expect(db.destroyConnection).not.toHaveBeenCalled()
228+
})
229+
230+
it('should remove all resources, multitenant db rows, and clean up tenant db when createStorage succeeds', async () => {
231+
mockCreateStorage.mockResolvedValue({ db })
232+
233+
await expect(DeleteIcebergResources.handle(makeJob() as never)).resolves.toBeUndefined()
234+
235+
expectIcebergCleanup({ multitenant: true })
236+
expect(db.deleteAnalyticsBucket).toHaveBeenCalledWith('catalog-123')
237+
expect(db.destroyConnection).toHaveBeenCalled()
238+
})
239+
})
240+
241+
describe('non-multitenant', () => {
242+
beforeAll(async () => {
243+
DeleteIcebergResources = await importHandler(false)
244+
})
245+
246+
it('should error when createStorage fails', async () => {
247+
mockCreateStorage.mockRejectedValue(new Error('Failed to create storage'))
248+
249+
await expect(DeleteIcebergResources.handle(makeJob() as never)).rejects.toThrow(
250+
'Failed to create storage'
251+
)
252+
253+
expect(mockCreateStorage).toHaveBeenCalledWith(jobData)
254+
expect(MockPgMetastore).not.toHaveBeenCalled()
255+
expect(metastore.transaction).not.toHaveBeenCalled()
256+
expect(store.lockResource).not.toHaveBeenCalled()
257+
expect(store.dropCatalog).not.toHaveBeenCalled()
258+
expect(db.connection.pool.acquire).not.toHaveBeenCalled()
259+
expect(db.deleteAnalyticsBucket).not.toHaveBeenCalled()
260+
expect(db.destroyConnection).not.toHaveBeenCalled()
261+
})
262+
263+
it('should remove all resources when createStorage succeeds', async () => {
264+
mockCreateStorage.mockResolvedValue({ db })
265+
266+
await expect(DeleteIcebergResources.handle(makeJob() as never)).resolves.toBeUndefined()
267+
268+
expect(db.connection.pool.acquire).toHaveBeenCalled()
269+
expectIcebergCleanup({ multitenant: false })
270+
expect(db.deleteAnalyticsBucket).not.toHaveBeenCalled()
271+
expect(db.destroyConnection).toHaveBeenCalled()
272+
})
273+
})
274+
})

src/storage/events/iceberg/delete-iceberg-resources.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,21 @@ export class DeleteIcebergResources extends BaseEvent<DeleteIcebergResourcesPayl
4949
}
5050

5151
static async handle(job: Job<DeleteIcebergResourcesPayload>) {
52-
let storage: Storage | undefined
52+
let eventStorage: Storage | undefined
5353

5454
try {
55-
storage = await this.createStorage(job.data)
56-
const eventStorage = storage
55+
try {
56+
eventStorage = await this.createStorage(job.data)
57+
} catch (e) {
58+
// don't require tenant db for multitenant
59+
// if the tenant was removed we can still cleanup the resources and multitenant db
60+
if (!isMultitenant) {
61+
throw e
62+
}
63+
}
5764

5865
const metastore = new PgMetastore(
59-
isMultitenant ? multitenantPgExecutor : eventStorage.db.connection.pool.acquire(),
66+
isMultitenant ? multitenantPgExecutor : eventStorage!.db.connection.pool.acquire(),
6067
{
6168
multiTenant: isMultitenant,
6269
schema: isMultitenant ? 'public' : 'storage',
@@ -166,14 +173,14 @@ export class DeleteIcebergResources extends BaseEvent<DeleteIcebergResourcesPayl
166173
soft: false,
167174
})
168175

169-
if (isMultitenant) {
176+
if (isMultitenant && eventStorage) {
170177
// Delete the underlying bucket
171178
await eventStorage.db.deleteAnalyticsBucket(job.data.catalogId)
172179
}
173180
})
174181
} finally {
175-
if (storage) {
176-
await storage.db.destroyConnection().catch((e) => {
182+
if (eventStorage) {
183+
await eventStorage.db.destroyConnection().catch((e) => {
177184
logger.error(
178185
{ error: e, sbReqId: job.data.sbReqId },
179186
`[DeleteIcebergResources] ${job.data.tenant.ref} - FAILED DISPOSING CONNECTION`

0 commit comments

Comments
 (0)