mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:25:20 +00:00
202 lines
7.3 KiB
TypeScript
202 lines
7.3 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { getPayload, type Payload } from 'payload'
|
|
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
|
|
|
import config from '@/payload.config'
|
|
import { contactFichaLockKey, contactPhoneLockKeys } from '@/utilities/contactPhoneLocks'
|
|
import {
|
|
acquireTextAdvisoryLocks,
|
|
getPostgresTransactionDatabase,
|
|
type PostgresTransactionDatabase,
|
|
} from '@/utilities/postgresTransactionLocks'
|
|
import { stub } from '../helpers/stub'
|
|
import {
|
|
getTestTransactionBackendPID,
|
|
waitForAdvisoryLockWaiter,
|
|
} from '../helpers/testDatabaseLease'
|
|
|
|
type DatabaseSession = NonNullable<Payload['db']['sessions']>[string]
|
|
|
|
let payload: Payload
|
|
|
|
/**
|
|
* Recursively flattens a drizzle-orm `SQL` object's `queryChunks` down to the
|
|
* raw string bind params (skipping the `StringChunk` text fragments), so the
|
|
* mocked test below can assert which keys were sent in a single round trip.
|
|
*/
|
|
const collectLockParams = (query: unknown): string[] => {
|
|
const chunks = (query as { queryChunks?: unknown[] }).queryChunks ?? []
|
|
return chunks.flatMap((chunk): string[] => {
|
|
if (typeof chunk === 'string') return [chunk]
|
|
if (chunk && typeof chunk === 'object' && 'queryChunks' in chunk) {
|
|
return collectLockParams(chunk)
|
|
}
|
|
return []
|
|
})
|
|
}
|
|
|
|
const beginTransaction = async (): Promise<{
|
|
req: { transactionID: number | string }
|
|
transaction: PostgresTransactionDatabase
|
|
transactionID: number | string
|
|
}> => {
|
|
const transactionID = await payload.db.beginTransaction()
|
|
if (transactionID === null) throw new Error('Expected a PostgreSQL transaction.')
|
|
const req = { transactionID }
|
|
const transaction = await getPostgresTransactionDatabase(payload, req)
|
|
return { req, transaction, transactionID }
|
|
}
|
|
|
|
const rollback = async (...transactionIDs: Array<number | string>): Promise<void> => {
|
|
await Promise.all(
|
|
transactionIDs.map((transactionID) => payload.db.rollbackTransaction(transactionID)),
|
|
)
|
|
}
|
|
|
|
describe('PostgreSQL transaction advisory locks', () => {
|
|
beforeAll(async () => {
|
|
payload = await getPayload({ config: await config })
|
|
})
|
|
|
|
it('builds one canonical sorted contact-phone namespace for creates and changes', () => {
|
|
expect(contactPhoneLockKeys(['71999990002', '71999990001', '71999990002'])).toEqual([
|
|
'contact-phone:71999990001',
|
|
'contact-phone:71999990002',
|
|
])
|
|
})
|
|
|
|
it('derives the canonical contact-ficha lock key (C120 append serialization)', () => {
|
|
expect(contactFichaLockKey(42)).toBe('contact-ficha:42')
|
|
expect(contactFichaLockKey('42')).toBe('contact-ficha:42')
|
|
})
|
|
|
|
it('uses only the exact Payload transaction session and fails closed without it', async () => {
|
|
const sessionDatabase = { execute: vi.fn().mockResolvedValue(undefined) }
|
|
const unrelatedDatabase = { execute: vi.fn().mockResolvedValue(undefined) }
|
|
const fixture = {
|
|
db: stub<Payload['db']>({
|
|
name: 'postgres',
|
|
sessions: {
|
|
'41': stub<DatabaseSession>({ db: sessionDatabase }),
|
|
'42': stub<DatabaseSession>({ db: unrelatedDatabase }),
|
|
},
|
|
}),
|
|
}
|
|
|
|
await expect(
|
|
getPostgresTransactionDatabase(fixture, {
|
|
transactionID: Promise.resolve(41),
|
|
}),
|
|
).resolves.toBe(sessionDatabase)
|
|
await acquireTextAdvisoryLocks(fixture, { transactionID: 41 }, [
|
|
'domain:2',
|
|
'domain:1',
|
|
'domain:1',
|
|
])
|
|
// Batched: all (deduplicated, sorted) keys go out in exactly one round trip.
|
|
expect(sessionDatabase.execute).toHaveBeenCalledTimes(1)
|
|
const [[query]] = sessionDatabase.execute.mock.calls
|
|
expect(collectLockParams(query)).toEqual(['domain:1', 'domain:2'])
|
|
expect(unrelatedDatabase.execute).not.toHaveBeenCalled()
|
|
|
|
await expect(getPostgresTransactionDatabase(fixture, {})).rejects.toThrow(
|
|
'A transação PostgreSQL não está disponível.',
|
|
)
|
|
await expect(acquireTextAdvisoryLocks(fixture, {}, ['domain:1'])).rejects.toThrow(
|
|
'A transação PostgreSQL não está disponível.',
|
|
)
|
|
await expect(getPostgresTransactionDatabase(fixture, { transactionID: 99 })).rejects.toThrow(
|
|
'A sessão PostgreSQL da transação não está disponível.',
|
|
)
|
|
})
|
|
|
|
it('rejects invalid text keys before acquiring any lock', async () => {
|
|
const database = { execute: vi.fn().mockResolvedValue(undefined) }
|
|
const fixture = {
|
|
db: stub<Payload['db']>({
|
|
name: 'postgres',
|
|
sessions: { '51': stub<DatabaseSession>({ db: database }) },
|
|
}),
|
|
}
|
|
|
|
await expect(
|
|
acquireTextAdvisoryLocks(fixture, { transactionID: 51 }, ['valid:key', ' ']),
|
|
).rejects.toThrow('A chave do bloqueio PostgreSQL deve ser um texto não vazio.')
|
|
expect(database.execute).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('serializes the same namespace and exposes its stable text hash in pg_locks', async () => {
|
|
const first = await beginTransaction()
|
|
const second = await beginTransaction()
|
|
const key = `phase2c:same:${Date.now()}`
|
|
|
|
try {
|
|
await acquireTextAdvisoryLocks(payload, first.req, [key])
|
|
const waiterPID = await getTestTransactionBackendPID(payload, second.transactionID)
|
|
const pending = acquireTextAdvisoryLocks(payload, second.req, [key])
|
|
const waiting = await waitForAdvisoryLockWaiter(payload, {
|
|
key,
|
|
mode: 'ExclusiveLock',
|
|
waiterPID,
|
|
})
|
|
|
|
expect(waiting).toMatchObject({
|
|
classID: waiting.expectedClassID,
|
|
granted: false,
|
|
objectID: waiting.expectedObjectID,
|
|
objectSubID: 1,
|
|
pid: waiterPID,
|
|
})
|
|
await payload.db.rollbackTransaction(first.transactionID)
|
|
await pending
|
|
await payload.db.rollbackTransaction(second.transactionID)
|
|
} catch (error) {
|
|
await rollback(first.transactionID, second.transactionID).catch(() => undefined)
|
|
throw error
|
|
}
|
|
})
|
|
|
|
it('allows different domains with the same entity ID to overlap', async () => {
|
|
const first = await beginTransaction()
|
|
const second = await beginTransaction()
|
|
const entityID = Date.now()
|
|
|
|
try {
|
|
await acquireTextAdvisoryLocks(payload, first.req, [`vote-estimate:${entityID}`])
|
|
await expect(
|
|
acquireTextAdvisoryLocks(payload, second.req, [`nucleus-updates:${entityID}`]),
|
|
).resolves.toBeUndefined()
|
|
} finally {
|
|
await rollback(first.transactionID, second.transactionID)
|
|
}
|
|
})
|
|
|
|
it('sorts and deduplicates multiple keys so opposite input orders cannot deadlock', async () => {
|
|
const first = await beginTransaction()
|
|
const second = await beginTransaction()
|
|
const prefix = `phase2c:order:${Date.now()}`
|
|
const low = `${prefix}:a`
|
|
const high = `${prefix}:b`
|
|
|
|
try {
|
|
await acquireTextAdvisoryLocks(payload, first.req, [high, low, high])
|
|
const waiterPID = await getTestTransactionBackendPID(payload, second.transactionID)
|
|
const pending = acquireTextAdvisoryLocks(payload, second.req, [low, high, low])
|
|
await expect(
|
|
waitForAdvisoryLockWaiter(payload, {
|
|
key: low,
|
|
mode: 'ExclusiveLock',
|
|
waiterPID,
|
|
}),
|
|
).resolves.toMatchObject({ granted: false, pid: waiterPID })
|
|
|
|
await payload.db.rollbackTransaction(first.transactionID)
|
|
await pending
|
|
await payload.db.rollbackTransaction(second.transactionID)
|
|
} catch (error) {
|
|
await rollback(first.transactionID, second.transactionID).catch(() => undefined)
|
|
throw error
|
|
}
|
|
})
|
|
})
|