93 lines
2.7 KiB
TypeScript
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);
|
|
});
|
|
}
|