Assist_Design/apps/bff/src/infra/database/services/transaction.service.ts
barsa 465a62a3e8 Refactor Domain Mappings and Update Import Paths
- Removed the domain mappings module, consolidating related types and schemas into the id-mappings feature.
- Updated import paths across the BFF to reflect the new structure, ensuring compliance with import hygiene rules.
- Cleaned up unused files and optimized the codebase for better maintainability and clarity.
2025-12-26 17:27:22 +09:00

353 lines
9.9 KiB
TypeScript

import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
export interface TransactionContext {
id: string;
startTime: Date;
operations: string[];
rollbackActions: (() => Promise<void>)[];
}
export interface TransactionContextHelpers {
addOperation: (description: string) => void;
addRollback: (rollbackFn: () => Promise<void>) => void;
}
export type TransactionOperation<T> = (
tx: Prisma.TransactionClient,
context: TransactionContext & TransactionContextHelpers
) => Promise<T>;
export type SimpleTransactionOperation<T> = (tx: Prisma.TransactionClient) => Promise<T>;
export interface TransactionOptions {
/**
* Maximum time to wait for transaction to complete (ms)
* Default: 30 seconds
*/
timeout?: number;
/**
* Maximum number of retry attempts on serialization failures
* Default: 3
*/
maxRetries?: number;
/**
* Custom isolation level for the transaction
* Default: ReadCommitted
*/
isolationLevel?: "ReadUncommitted" | "ReadCommitted" | "RepeatableRead" | "Serializable";
/**
* Description of the transaction for logging
*/
description?: string;
/**
* Whether to automatically rollback external operations on database rollback
* Default: true
*/
autoRollback?: boolean;
}
export interface TransactionResult<T> {
success: boolean;
data?: T;
error?: string;
duration: number;
operationsCount: number;
rollbacksExecuted: number;
}
/**
* Service for managing database transactions with external operation coordination
* Provides atomic operations across database and external systems
*/
@Injectable()
export class TransactionService {
private readonly defaultTimeout = 30000; // 30 seconds
private readonly defaultMaxRetries = 3;
constructor(
private readonly prisma: PrismaService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Execute operations within a database transaction with rollback support
*
* @example
* ```typescript
* const result = await this.transactionService.executeTransaction(
* async (tx, context) => {
* // Database operations
* const user = await tx.user.create({ data: userData });
*
* // External operations with rollback
* const whmcsClient = await this.whmcsService.createClient(user.email);
* context.addRollback(async () => {
* await this.whmcsService.deleteClient(whmcsClient.id);
* });
*
* // Salesforce operations with rollback
* const sfAccount = await this.salesforceService.createAccount(user);
* context.addRollback(async () => {
* await this.salesforceService.deleteAccount(sfAccount.Id);
* });
*
* return { user, whmcsClient, sfAccount };
* },
* {
* description: "User signup with external integrations",
* timeout: 60000
* }
* );
* ```
*/
async executeTransaction<T>(
operation: TransactionOperation<T>,
options: TransactionOptions = {}
): Promise<TransactionResult<T>> {
const {
timeout = this.defaultTimeout,
maxRetries = this.defaultMaxRetries,
isolationLevel = "ReadCommitted",
description = "Database transaction",
autoRollback = true,
} = options;
const transactionId = this.generateTransactionId();
const startTime = new Date();
let context: TransactionContext = {
id: transactionId,
startTime,
operations: [],
rollbackActions: [],
};
this.logger.log(`Starting transaction [${transactionId}]`, {
description,
timeout,
isolationLevel,
maxRetries,
});
let attempt = 0;
let lastError: Error | null = null;
while (attempt < maxRetries) {
attempt++;
try {
// Reset context for retry attempts
if (attempt > 1) {
context = {
id: transactionId,
startTime,
operations: [],
rollbackActions: [],
};
}
const result = await Promise.race([
this.executeTransactionAttempt(operation, context, isolationLevel),
this.createTimeoutPromise<T>(timeout, transactionId),
]);
const duration = Date.now() - startTime.getTime();
this.logger.log(`Transaction completed successfully [${transactionId}]`, {
description,
duration,
attempt,
operationsCount: context.operations.length,
});
return {
success: true,
data: result,
duration,
operationsCount: context.operations.length,
rollbacksExecuted: 0,
};
} catch (error) {
lastError = error as Error;
const duration = Date.now() - startTime.getTime();
this.logger.error(`Transaction attempt ${attempt} failed [${transactionId}]`, {
description,
error: getErrorMessage(error),
duration,
operationsCount: context.operations.length,
rollbackActionsCount: context.rollbackActions.length,
});
// Execute rollbacks if this is the final attempt or not a retryable error
if (attempt === maxRetries || !this.isRetryableError(error)) {
const rollbacksExecuted = await this.executeRollbacks(context, autoRollback);
return {
success: false,
error: getErrorMessage(error),
duration,
operationsCount: context.operations.length,
rollbacksExecuted,
};
}
// Wait before retry (exponential backoff)
await this.delay(Math.pow(2, attempt - 1) * 1000);
}
}
// This should never be reached, but just in case
const duration = Date.now() - startTime.getTime();
return {
success: false,
error: lastError ? getErrorMessage(lastError) : "Unknown transaction error",
duration,
operationsCount: context.operations.length,
rollbacksExecuted: 0,
};
}
/**
* Execute a simple database-only transaction (no external operations)
*/
async executeSimpleTransaction<T>(
operation: SimpleTransactionOperation<T>,
options: Omit<TransactionOptions, "autoRollback"> = {}
): Promise<T> {
const result = await this.executeTransaction(async tx => operation(tx), {
...options,
autoRollback: false,
});
if (!result.success) {
throw new Error(result.error || "Transaction failed");
}
return result.data!;
}
private async executeTransactionAttempt<T>(
operation: TransactionOperation<T>,
context: TransactionContext,
isolationLevel: Prisma.TransactionIsolationLevel
): Promise<T> {
return await this.prisma.$transaction(
async tx => {
// Enhance context with helper methods
const enhancedContext = this.enhanceContext(context);
// Execute the operation
return await operation(tx, enhancedContext);
},
{
isolationLevel,
timeout: 30000, // Prisma transaction timeout
}
);
}
private enhanceContext(
context: TransactionContext
): TransactionContext & TransactionContextHelpers {
const helpers: TransactionContextHelpers = {
addOperation: (description: string) => {
context.operations.push(`${new Date().toISOString()}: ${description}`);
},
addRollback: (rollbackFn: () => Promise<void>) => {
context.rollbackActions.push(rollbackFn);
},
};
return Object.assign(context, helpers);
}
private async executeRollbacks(
context: TransactionContext,
autoRollback: boolean
): Promise<number> {
if (!autoRollback || context.rollbackActions.length === 0) {
return 0;
}
this.logger.warn(
`Executing ${context.rollbackActions.length} rollback actions [${context.id}]`
);
let rollbacksExecuted = 0;
// Execute rollbacks in reverse order (LIFO)
for (let i = context.rollbackActions.length - 1; i >= 0; i--) {
try {
await context.rollbackActions[i]();
rollbacksExecuted++;
this.logger.debug(`Rollback ${i + 1} completed [${context.id}]`);
} catch (rollbackError) {
this.logger.error(`Rollback ${i + 1} failed [${context.id}]`, {
error: getErrorMessage(rollbackError),
});
// Continue with other rollbacks even if one fails
}
}
this.logger.log(
`Completed ${rollbacksExecuted}/${context.rollbackActions.length} rollbacks [${context.id}]`
);
return rollbacksExecuted;
}
private isRetryableError(error: unknown): boolean {
const errorMessage = getErrorMessage(error).toLowerCase();
// Retry on serialization failures, deadlocks, and temporary connection issues
return (
errorMessage.includes("serialization failure") ||
errorMessage.includes("deadlock") ||
errorMessage.includes("connection") ||
errorMessage.includes("timeout") ||
errorMessage.includes("lock wait timeout")
);
}
private async createTimeoutPromise<T>(timeout: number, transactionId: string): Promise<T> {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Transaction timeout after ${timeout}ms [${transactionId}]`));
}, timeout);
});
}
private async delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private generateTransactionId(): string {
return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Get transaction statistics for monitoring
*/
async getTransactionStats(): Promise<{
activeTransactions: number;
totalTransactions: number;
successRate: number;
averageDuration: number;
}> {
return await Promise.resolve({
activeTransactions: 0,
totalTransactions: 0,
successRate: 0,
averageDuration: 0,
});
}
}