2025-11-14 10:26:23 -08:00
|
|
|
/**
|
|
|
|
|
* Transaction - Atomic Unit of Work
|
|
|
|
|
*
|
|
|
|
|
* Executes operations atomically: all succeed or all rollback.
|
|
|
|
|
* Prevents partial failures that leave system in inconsistent state.
|
|
|
|
|
*
|
|
|
|
|
* Usage:
|
|
|
|
|
* ```typescript
|
|
|
|
|
* const tx = new Transaction()
|
|
|
|
|
* tx.addOperation(operation1)
|
|
|
|
|
* tx.addOperation(operation2)
|
|
|
|
|
* await tx.execute() // Both succeed or both rollback
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
Operation,
|
|
|
|
|
RollbackAction,
|
|
|
|
|
TransactionState,
|
|
|
|
|
TransactionContext,
|
|
|
|
|
TransactionOptions
|
|
|
|
|
} from './types.js'
|
|
|
|
|
import {
|
|
|
|
|
InvalidTransactionStateError,
|
|
|
|
|
TransactionExecutionError,
|
|
|
|
|
TransactionRollbackError,
|
|
|
|
|
TransactionTimeoutError
|
|
|
|
|
} from './errors.js'
|
|
|
|
|
import { prodLog } from '../utils/logger.js'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Default transaction options
|
|
|
|
|
*/
|
|
|
|
|
const DEFAULT_OPTIONS: Required<TransactionOptions> = {
|
|
|
|
|
timeout: 30000, // 30 seconds
|
|
|
|
|
logging: false,
|
|
|
|
|
maxRollbackRetries: 3
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Transaction class
|
|
|
|
|
*/
|
|
|
|
|
export class Transaction implements TransactionContext {
|
|
|
|
|
private operations: Operation[] = []
|
|
|
|
|
private rollbackActions: RollbackAction[] = []
|
|
|
|
|
private state: TransactionState = 'pending'
|
|
|
|
|
private readonly options: Required<TransactionOptions>
|
|
|
|
|
private startTime?: number
|
|
|
|
|
private endTime?: number
|
|
|
|
|
|
|
|
|
|
constructor(options: TransactionOptions = {}) {
|
|
|
|
|
this.options = { ...DEFAULT_OPTIONS, ...options }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Add an operation to the transaction
|
|
|
|
|
*/
|
|
|
|
|
addOperation(operation: Operation): void {
|
|
|
|
|
if (this.state !== 'pending') {
|
|
|
|
|
throw new InvalidTransactionStateError(
|
|
|
|
|
this.state,
|
|
|
|
|
'add operation'
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
this.operations.push(operation)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current transaction state
|
|
|
|
|
*/
|
|
|
|
|
getState(): TransactionState {
|
|
|
|
|
return this.state
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get number of operations in transaction
|
|
|
|
|
*/
|
|
|
|
|
getOperationCount(): number {
|
|
|
|
|
return this.operations.length
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Execute all operations atomically
|
|
|
|
|
*/
|
|
|
|
|
async execute(): Promise<void> {
|
|
|
|
|
if (this.state !== 'pending') {
|
|
|
|
|
throw new InvalidTransactionStateError(
|
|
|
|
|
this.state,
|
|
|
|
|
'execute'
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.state = 'executing'
|
|
|
|
|
this.startTime = Date.now()
|
|
|
|
|
|
|
|
|
|
if (this.options.logging) {
|
|
|
|
|
prodLog.info(`[Transaction] Executing ${this.operations.length} operations`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-07-11 13:44:15 -07:00
|
|
|
// Execute each operation in order. This loop is the sole rollback-guarded
|
|
|
|
|
// region: ANY error that escapes it — an operation failure OR a
|
|
|
|
|
// mid-flight timeout — is caught below and rolls back every operation
|
|
|
|
|
// applied so far, in reverse order. That single guarantee is the
|
|
|
|
|
// transaction's atomicity contract, and the generation-store commit path
|
|
|
|
|
// depends on it: its abort cleanup assumes a throw from here already
|
|
|
|
|
// restored the applied operations byte-identically (it only discards the
|
|
|
|
|
// uncommitted staging directory). A rollback per exit path — the previous
|
|
|
|
|
// design — let the timeout throw slip past rollback and strand the
|
|
|
|
|
// already-applied writes as torn, generation-less state in canonical
|
|
|
|
|
// storage.
|
2025-11-14 10:26:23 -08:00
|
|
|
for (let i = 0; i < this.operations.length; i++) {
|
2026-07-11 13:44:15 -07:00
|
|
|
// Budget check BEFORE starting the next operation. A trip here throws
|
|
|
|
|
// into the catch below and rolls back like any other failure — it must
|
|
|
|
|
// never bypass rollback.
|
2025-11-14 10:26:23 -08:00
|
|
|
if (Date.now() - this.startTime > this.options.timeout) {
|
|
|
|
|
throw new TransactionTimeoutError(this.options.timeout, i)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const operation = this.operations[i]
|
|
|
|
|
|
2026-07-11 13:44:15 -07:00
|
|
|
if (this.options.logging) {
|
|
|
|
|
prodLog.info(`[Transaction] Executing operation ${i}: ${operation.name || 'unnamed'}`)
|
|
|
|
|
}
|
2025-11-14 10:26:23 -08:00
|
|
|
|
2026-07-11 13:44:15 -07:00
|
|
|
let rollbackAction: RollbackAction | undefined
|
|
|
|
|
try {
|
|
|
|
|
rollbackAction = await operation.execute()
|
2025-11-14 10:26:23 -08:00
|
|
|
} catch (error) {
|
2026-07-11 13:44:15 -07:00
|
|
|
// Normalize an operation failure to a TransactionExecutionError; the
|
|
|
|
|
// outer catch performs the (single) rollback and surfaces it.
|
|
|
|
|
throw new TransactionExecutionError(
|
2025-11-14 10:26:23 -08:00
|
|
|
`Operation ${i} failed: ${(error as Error).message}`,
|
|
|
|
|
i,
|
|
|
|
|
operation.name,
|
|
|
|
|
error as Error
|
|
|
|
|
)
|
2026-07-11 13:44:15 -07:00
|
|
|
}
|
2025-11-14 10:26:23 -08:00
|
|
|
|
2026-07-11 13:44:15 -07:00
|
|
|
// Record rollback action (if the operation provided one)
|
|
|
|
|
if (rollbackAction) {
|
|
|
|
|
this.rollbackActions.push(rollbackAction)
|
2025-11-14 10:26:23 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2026-07-11 13:44:15 -07:00
|
|
|
// Single rollback point: undo everything applied so far, in reverse
|
|
|
|
|
// order, then surface the original error. A rollback failure supersedes
|
|
|
|
|
// it (rollback() throws TransactionRollbackError wrapping this error).
|
|
|
|
|
await this.rollback(error as Error)
|
2025-11-14 10:26:23 -08:00
|
|
|
throw error
|
|
|
|
|
}
|
2026-07-11 13:44:15 -07:00
|
|
|
|
|
|
|
|
// All operations succeeded — commit.
|
|
|
|
|
this.commit()
|
2025-11-14 10:26:23 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Commit the transaction
|
|
|
|
|
*/
|
|
|
|
|
private commit(): void {
|
|
|
|
|
this.state = 'committed'
|
|
|
|
|
this.endTime = Date.now()
|
|
|
|
|
|
|
|
|
|
if (this.options.logging) {
|
|
|
|
|
const duration = this.endTime - (this.startTime || this.endTime)
|
|
|
|
|
prodLog.info(`[Transaction] Committed successfully in ${duration}ms`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear rollback actions - no longer needed
|
|
|
|
|
this.rollbackActions = []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Rollback all executed operations in reverse order
|
|
|
|
|
*/
|
|
|
|
|
private async rollback(originalError: Error): Promise<void> {
|
|
|
|
|
this.state = 'rolling_back'
|
|
|
|
|
|
|
|
|
|
if (this.options.logging) {
|
|
|
|
|
prodLog.info(`[Transaction] Rolling back ${this.rollbackActions.length} operations`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rollbackErrors: Error[] = []
|
|
|
|
|
|
|
|
|
|
// Execute rollback actions in REVERSE order
|
|
|
|
|
for (let i = this.rollbackActions.length - 1; i >= 0; i--) {
|
|
|
|
|
const action = this.rollbackActions[i]
|
|
|
|
|
|
|
|
|
|
// Retry rollback with exponential backoff
|
|
|
|
|
let attempts = 0
|
|
|
|
|
let success = false
|
|
|
|
|
|
|
|
|
|
while (attempts < this.options.maxRollbackRetries && !success) {
|
|
|
|
|
try {
|
|
|
|
|
await action()
|
|
|
|
|
success = true
|
|
|
|
|
|
|
|
|
|
if (this.options.logging) {
|
|
|
|
|
prodLog.info(`[Transaction] Rolled back operation ${i}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
attempts++
|
|
|
|
|
|
|
|
|
|
if (attempts >= this.options.maxRollbackRetries) {
|
|
|
|
|
// Max retries exceeded - log error and continue
|
|
|
|
|
const rollbackError = error as Error
|
|
|
|
|
rollbackErrors.push(rollbackError)
|
|
|
|
|
|
|
|
|
|
prodLog.error(
|
|
|
|
|
`[Transaction] Rollback failed for operation ${i} after ${attempts} attempts: ${rollbackError.message}`
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
// Retry with exponential backoff
|
|
|
|
|
const delayMs = Math.pow(2, attempts) * 100
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, delayMs))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.state = 'rolled_back'
|
|
|
|
|
this.endTime = Date.now()
|
|
|
|
|
|
|
|
|
|
if (this.options.logging) {
|
|
|
|
|
const duration = this.endTime - (this.startTime || this.endTime)
|
|
|
|
|
prodLog.info(`[Transaction] Rolled back in ${duration}ms`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If rollback encountered errors, wrap them with original error
|
|
|
|
|
if (rollbackErrors.length > 0) {
|
|
|
|
|
throw new TransactionRollbackError(
|
|
|
|
|
`Transaction rollback encountered ${rollbackErrors.length} errors during cleanup`,
|
|
|
|
|
originalError,
|
|
|
|
|
rollbackErrors
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get transaction execution time in milliseconds
|
|
|
|
|
*/
|
|
|
|
|
getExecutionTimeMs(): number | undefined {
|
|
|
|
|
if (this.startTime && this.endTime) {
|
|
|
|
|
return this.endTime - this.startTime
|
|
|
|
|
}
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
}
|