Skip to content

Commit a809286

Browse files
authored
fix: replace mutex with single flight pattern (#1193)
Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent 6d5e45b commit a809286

19 files changed

Lines changed: 906 additions & 189 deletions

File tree

package-lock.json

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@
8080
"@platformatic/globals": "^3.59.0",
8181
"@platformatic/node": "^3.59.0",
8282
"@platformatic/wattpm-pprof-capture": "^3.59.0",
83-
"@shopify/semaphore": "^3.0.2",
8483
"@smithy/node-http-handler": "^2.3.1",
8584
"@tus/file-store": "2.1.0",
8685
"@tus/s3-store": "2.0.3",

src/http/plugins/db.test.ts

Lines changed: 303 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import Fastify, { type FastifyRequest } from 'fastify'
22
import { vi } from 'vitest'
3+
import { MultitenantMigrationStrategy } from '../../config'
34

45
async function loadDbPlugins({
56
databaseEnableQueryCancellation = false,
7+
dbMigrationStrategy = MultitenantMigrationStrategy.PROGRESSIVE,
8+
isMultitenant = false,
69
}: {
710
databaseEnableQueryCancellation?: boolean
11+
dbMigrationStrategy?: MultitenantMigrationStrategy
12+
isMultitenant?: boolean
813
} = {}) {
914
vi.resetModules()
1015

@@ -19,43 +24,54 @@ async function loadDbPlugins({
1924
role: 'service_role',
2025
},
2126
})
27+
const getTenantConfig = vi.fn()
28+
const areMigrationsUpToDate = vi.fn()
29+
const runMigrationsOnTenant = vi.fn()
30+
const updateTenantMigrationsState = vi.fn()
31+
const progressiveMigrations = {
32+
addTenant: vi.fn(),
33+
}
2234

2335
vi.doMock('@internal/database', () => {
2436
return {
2537
getPostgresConnection,
2638
getServiceKeyUser,
27-
getTenantConfig: vi.fn(),
39+
getTenantConfig,
2840
PgTenantConnection: class {},
2941
}
3042
})
3143

3244
vi.doMock('@internal/database/migrations', () => {
3345
return {
34-
areMigrationsUpToDate: vi.fn(),
46+
areMigrationsUpToDate,
3547
DBMigration: {},
3648
lastLocalMigrationName: vi.fn().mockResolvedValue('initialmigration'),
37-
progressiveMigrations: {
38-
addTenant: vi.fn(),
39-
},
40-
runMigrationsOnTenant: vi.fn(),
41-
updateTenantMigrationsState: vi.fn(),
49+
progressiveMigrations,
50+
runMigrationsOnTenant,
51+
updateTenantMigrationsState,
4252
}
4353
})
4454

4555
const configModule = await import('../../config')
4656
configModule.getConfig({ reload: true })
4757
configModule.mergeConfig({
4858
databaseEnableQueryCancellation,
49-
isMultitenant: false,
59+
dbMigrationStrategy,
60+
isMultitenant,
5061
})
5162

5263
const { db, dbSuperUser } = await import('./db')
5364

5465
return {
5566
db,
5667
dbSuperUser,
68+
areMigrationsUpToDate,
5769
getPostgresConnection,
70+
getTenantConfig,
71+
progressiveMigrations,
5872
requestDb,
73+
runMigrationsOnTenant,
74+
updateTenantMigrationsState,
5975
}
6076
}
6177

@@ -99,6 +115,285 @@ describe('dbSuperUser plugin', () => {
99115
})
100116
})
101117

118+
describe('migrations plugin', () => {
119+
async function buildMigrationApp(
120+
plugins: Awaited<ReturnType<typeof loadDbPlugins>>,
121+
getTenantId: (request: FastifyRequest) => string = () => 'tenant-id'
122+
) {
123+
const app = Fastify()
124+
125+
app.decorateRequest('tenantId')
126+
app.addHook('onRequest', async (request) => {
127+
request.tenantId = getTenantId(request)
128+
})
129+
await app.register(plugins.dbSuperUser)
130+
app.get('/test', async () => ({ ok: true }))
131+
132+
const injectTenant = (headers: Record<string, string> = {}) =>
133+
app.inject({ method: 'GET', url: '/test', headers })
134+
135+
return { app, injectTenant }
136+
}
137+
138+
function waitForImmediate() {
139+
return new Promise((resolve) => setImmediate(resolve))
140+
}
141+
142+
afterEach(() => {
143+
vi.doUnmock('@internal/database')
144+
vi.doUnmock('@internal/database/migrations')
145+
vi.restoreAllMocks()
146+
vi.resetModules()
147+
})
148+
149+
it('shares the same on-request migration check across concurrent same-tenant requests', async () => {
150+
const plugins = await loadDbPlugins({
151+
dbMigrationStrategy: MultitenantMigrationStrategy.ON_REQUEST,
152+
isMultitenant: true,
153+
})
154+
const tenant = {
155+
databaseUrl: 'postgres://tenant-db',
156+
migrationVersion: 'initialmigration',
157+
syncMigrationsDone: false,
158+
}
159+
const migration = Promise.withResolvers<void>()
160+
161+
plugins.getTenantConfig.mockResolvedValue(tenant)
162+
plugins.areMigrationsUpToDate.mockResolvedValue(false)
163+
plugins.runMigrationsOnTenant.mockReturnValue(migration.promise)
164+
plugins.updateTenantMigrationsState.mockResolvedValue(undefined)
165+
166+
const { app, injectTenant } = await buildMigrationApp(plugins)
167+
168+
try {
169+
const first = injectTenant()
170+
const second = injectTenant()
171+
172+
await waitForImmediate()
173+
174+
expect(plugins.areMigrationsUpToDate).toHaveBeenCalledTimes(1)
175+
expect(plugins.runMigrationsOnTenant).toHaveBeenCalledTimes(1)
176+
177+
migration.resolve()
178+
179+
const responses = await Promise.all([first, second])
180+
expect(responses).toEqual([
181+
expect.objectContaining({ statusCode: 200 }),
182+
expect.objectContaining({ statusCode: 200 }),
183+
])
184+
expect(tenant.syncMigrationsDone).toBe(true)
185+
186+
const third = await injectTenant()
187+
188+
expect(third.statusCode).toBe(200)
189+
expect(plugins.areMigrationsUpToDate).toHaveBeenCalledTimes(1)
190+
expect(plugins.runMigrationsOnTenant).toHaveBeenCalledTimes(1)
191+
} finally {
192+
await app.close()
193+
}
194+
})
195+
196+
it('shares the on-request migration check across route scopes', async () => {
197+
const plugins = await loadDbPlugins({
198+
dbMigrationStrategy: MultitenantMigrationStrategy.ON_REQUEST,
199+
isMultitenant: true,
200+
})
201+
const tenant = {
202+
databaseUrl: 'postgres://tenant-db',
203+
migrationVersion: 'initialmigration',
204+
syncMigrationsDone: false,
205+
}
206+
const migration = Promise.withResolvers<void>()
207+
208+
plugins.getTenantConfig.mockResolvedValue(tenant)
209+
plugins.areMigrationsUpToDate.mockResolvedValue(false)
210+
plugins.runMigrationsOnTenant.mockReturnValue(migration.promise)
211+
plugins.updateTenantMigrationsState.mockResolvedValue(undefined)
212+
213+
// Two separate Fastify apps stand in for two route scopes, each registering
214+
// its own copy of the migrations plugin against the same module state.
215+
const firstScope = await buildMigrationApp(plugins)
216+
const secondScope = await buildMigrationApp(plugins)
217+
218+
try {
219+
const first = firstScope.injectTenant()
220+
const second = secondScope.injectTenant()
221+
222+
await waitForImmediate()
223+
224+
expect(plugins.runMigrationsOnTenant).toHaveBeenCalledTimes(1)
225+
226+
migration.resolve()
227+
228+
await expect(Promise.all([first, second])).resolves.toEqual([
229+
expect.objectContaining({ statusCode: 200 }),
230+
expect.objectContaining({ statusCode: 200 }),
231+
])
232+
} finally {
233+
await firstScope.app.close()
234+
await secondScope.app.close()
235+
}
236+
})
237+
238+
it('shares migration failures across concurrent same-tenant requests and retries later', async () => {
239+
const plugins = await loadDbPlugins({
240+
dbMigrationStrategy: MultitenantMigrationStrategy.ON_REQUEST,
241+
isMultitenant: true,
242+
})
243+
const tenant = {
244+
databaseUrl: 'postgres://tenant-db',
245+
migrationVersion: 'initialmigration',
246+
syncMigrationsDone: false,
247+
}
248+
const migration = Promise.withResolvers<void>()
249+
250+
plugins.getTenantConfig.mockResolvedValue(tenant)
251+
plugins.areMigrationsUpToDate.mockResolvedValue(false)
252+
plugins.runMigrationsOnTenant
253+
.mockReturnValueOnce(migration.promise)
254+
.mockResolvedValueOnce(undefined)
255+
plugins.updateTenantMigrationsState.mockResolvedValue(undefined)
256+
257+
const { app, injectTenant } = await buildMigrationApp(plugins)
258+
259+
try {
260+
const first = injectTenant()
261+
const second = injectTenant()
262+
263+
await waitForImmediate()
264+
265+
expect(plugins.runMigrationsOnTenant).toHaveBeenCalledTimes(1)
266+
267+
migration.reject(new Error('migration failed'))
268+
269+
const failedResponses = await Promise.all([first, second])
270+
expect(failedResponses).toEqual([
271+
expect.objectContaining({ statusCode: 500 }),
272+
expect.objectContaining({ statusCode: 500 }),
273+
])
274+
expect(tenant.syncMigrationsDone).toBe(false)
275+
276+
const retry = await injectTenant()
277+
278+
expect(retry.statusCode).toBe(200)
279+
expect(plugins.areMigrationsUpToDate).toHaveBeenCalledTimes(2)
280+
expect(plugins.runMigrationsOnTenant).toHaveBeenCalledTimes(2)
281+
expect(tenant.syncMigrationsDone).toBe(true)
282+
} finally {
283+
await app.close()
284+
}
285+
})
286+
287+
it('skips on-request migration checks when the tenant is already marked migrated', async () => {
288+
const plugins = await loadDbPlugins({
289+
dbMigrationStrategy: MultitenantMigrationStrategy.ON_REQUEST,
290+
isMultitenant: true,
291+
})
292+
293+
plugins.getTenantConfig.mockResolvedValue({
294+
databaseUrl: 'postgres://tenant-db',
295+
migrationVersion: 'initialmigration',
296+
syncMigrationsDone: true,
297+
})
298+
299+
const { app, injectTenant } = await buildMigrationApp(plugins)
300+
301+
try {
302+
const response = await injectTenant()
303+
304+
expect(response.statusCode).toBe(200)
305+
expect(plugins.areMigrationsUpToDate).not.toHaveBeenCalled()
306+
expect(plugins.runMigrationsOnTenant).not.toHaveBeenCalled()
307+
} finally {
308+
await app.close()
309+
}
310+
})
311+
312+
it('skips migration execution when tenant migrations are already up to date', async () => {
313+
const plugins = await loadDbPlugins({
314+
dbMigrationStrategy: MultitenantMigrationStrategy.ON_REQUEST,
315+
isMultitenant: true,
316+
})
317+
318+
plugins.getTenantConfig.mockResolvedValue({
319+
databaseUrl: 'postgres://tenant-db',
320+
migrationVersion: 'initialmigration',
321+
syncMigrationsDone: false,
322+
})
323+
plugins.areMigrationsUpToDate.mockResolvedValue(true)
324+
325+
const { app, injectTenant } = await buildMigrationApp(plugins)
326+
327+
try {
328+
const response = await injectTenant()
329+
330+
expect(response.statusCode).toBe(200)
331+
expect(plugins.areMigrationsUpToDate).toHaveBeenCalledTimes(1)
332+
expect(plugins.runMigrationsOnTenant).not.toHaveBeenCalled()
333+
334+
const secondResponse = await injectTenant()
335+
336+
expect(secondResponse.statusCode).toBe(200)
337+
expect(plugins.areMigrationsUpToDate).toHaveBeenCalledTimes(1)
338+
expect(plugins.runMigrationsOnTenant).not.toHaveBeenCalled()
339+
} finally {
340+
await app.close()
341+
}
342+
})
343+
344+
it('does not coalesce on-request migrations for different tenants', async () => {
345+
const plugins = await loadDbPlugins({
346+
dbMigrationStrategy: MultitenantMigrationStrategy.ON_REQUEST,
347+
isMultitenant: true,
348+
})
349+
const migrations = {
350+
'tenant-a': Promise.withResolvers<void>(),
351+
'tenant-b': Promise.withResolvers<void>(),
352+
}
353+
354+
plugins.getTenantConfig.mockImplementation(async (tenantId: string) => ({
355+
databaseUrl: `postgres://${tenantId}`,
356+
migrationVersion: 'initialmigration',
357+
syncMigrationsDone: false,
358+
}))
359+
plugins.areMigrationsUpToDate.mockResolvedValue(false)
360+
plugins.runMigrationsOnTenant.mockImplementation(
361+
({ tenantId }: { tenantId: keyof typeof migrations }) => migrations[tenantId].promise
362+
)
363+
plugins.updateTenantMigrationsState.mockResolvedValue(undefined)
364+
365+
const { app, injectTenant } = await buildMigrationApp(
366+
plugins,
367+
(request) => request.headers['x-tenant-id'] as string
368+
)
369+
370+
try {
371+
const first = injectTenant({ 'x-tenant-id': 'tenant-a' })
372+
const second = injectTenant({ 'x-tenant-id': 'tenant-b' })
373+
374+
await waitForImmediate()
375+
376+
expect(plugins.runMigrationsOnTenant).toHaveBeenCalledTimes(2)
377+
expect(plugins.runMigrationsOnTenant).toHaveBeenCalledWith(
378+
expect.objectContaining({ tenantId: 'tenant-a' })
379+
)
380+
expect(plugins.runMigrationsOnTenant).toHaveBeenCalledWith(
381+
expect.objectContaining({ tenantId: 'tenant-b' })
382+
)
383+
384+
migrations['tenant-a'].resolve()
385+
migrations['tenant-b'].resolve()
386+
387+
await expect(Promise.all([first, second])).resolves.toEqual([
388+
expect.objectContaining({ statusCode: 200 }),
389+
expect.objectContaining({ statusCode: 200 }),
390+
])
391+
} finally {
392+
await app.close()
393+
}
394+
})
395+
})
396+
102397
describe.each([
103398
{
104399
name: 'db plugin',

0 commit comments

Comments
 (0)