gcx/backend/packages/common/src/database/optimistic-lock.ts

93 lines
2.7 KiB
TypeScript

import { Logger } from '@nestjs/common';
import { EntityManager, OptimisticLockVersionMismatchError } from 'typeorm';
const logger = new Logger('OptimisticLock');
/**
* Optimistic Lock retry wrapper.
* Retries the operation when a version conflict is detected.
*
* Critical for financial operations:
* - Wallet balance updates (prevent double-spending)
* - Order status transitions
* - Coupon inventory (prevent overselling)
* - Settlement records
*
* Usage:
* await withOptimisticLock(manager, 3, async (mgr) => {
* const wallet = await mgr.findOne(Wallet, { where: { id }, lock: { mode: 'optimistic', version } });
* wallet.balance = wallet.balance.minus(amount);
* await mgr.save(wallet);
* });
*/
export async function withOptimisticLock<T>(
manager: EntityManager,
maxRetries: number,
operation: (manager: EntityManager) => Promise<T>,
): Promise<T> {
let attempt = 0;
while (attempt <= maxRetries) {
try {
return await manager.transaction(async (txManager) => {
return await operation(txManager);
});
} catch (error) {
if (
error instanceof OptimisticLockVersionMismatchError ||
error.message?.includes('version')
) {
attempt++;
if (attempt > maxRetries) {
logger.error(
`Optimistic lock failed after ${maxRetries} retries: ${error.message}`,
);
throw error;
}
logger.warn(
`Optimistic lock conflict, retry ${attempt}/${maxRetries}`,
);
// Exponential backoff: 50ms, 100ms, 200ms...
await new Promise((r) => setTimeout(r, 50 * Math.pow(2, attempt - 1)));
} else {
throw error;
}
}
}
throw new Error('Optimistic lock: unreachable');
}
/**
* Pessimistic lock helper for critical inventory operations.
* Uses SELECT ... FOR UPDATE to serialize access.
*
* Usage (coupon inventory):
* await withPessimisticLock(manager, Coupon, couponId, async (coupon, mgr) => {
* if (coupon.remainingQuantity <= 0) throw new Error('Sold out');
* coupon.remainingQuantity -= 1;
* await mgr.save(coupon);
* });
*/
export async function withPessimisticLock<Entity extends { id: string }>(
manager: EntityManager,
entityClass: new () => Entity,
entityId: string,
operation: (entity: Entity, manager: EntityManager) => Promise<void>,
): Promise<void> {
await manager.transaction(async (txManager) => {
const entity = await txManager.findOne(entityClass as any, {
where: { id: entityId } as any,
lock: { mode: 'pessimistic_write' },
});
if (!entity) {
throw new Error(
`${entityClass.name} with id ${entityId} not found`,
);
}
await operation(entity as Entity, txManager);
});
}