2025-11-14 10:26:23 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* TransactionManager Tests
|
|
|
|
|
|
*
|
|
|
|
|
|
* Tests the TransactionManager for:
|
|
|
|
|
|
* - High-level transaction API
|
|
|
|
|
|
* - Statistics tracking
|
|
|
|
|
|
* - Error handling
|
|
|
|
|
|
* - Result wrapping
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
|
|
|
|
import { TransactionManager } from '../../src/transaction/TransactionManager.js'
|
2026-07-17 16:49:38 -07:00
|
|
|
|
import { transactTimeoutBudget } from '../../src/transaction/Transaction.js'
|
2025-11-14 10:26:23 -08:00
|
|
|
|
import { TransactionError } from '../../src/transaction/errors.js'
|
|
|
|
|
|
|
|
|
|
|
|
describe('TransactionManager', () => {
|
|
|
|
|
|
let manager: TransactionManager
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
manager = new TransactionManager()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
describe('executeTransaction', () => {
|
|
|
|
|
|
it('should execute transaction and return user value', async () => {
|
|
|
|
|
|
const result = await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
return 'success'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
expect(result).toBe('success')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('should execute multiple operations', async () => {
|
|
|
|
|
|
const executionOrder: number[] = []
|
|
|
|
|
|
|
|
|
|
|
|
await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
executionOrder.push(1)
|
|
|
|
|
|
return async () => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
executionOrder.push(2)
|
|
|
|
|
|
return async () => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
expect(executionOrder).toEqual([1, 2])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('should throw TransactionError on failure', async () => {
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
throw new Error('Operation failed')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
).rejects.toThrow(TransactionError)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('should pass through custom transaction options', async () => {
|
|
|
|
|
|
// Should timeout with many operations
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
manager.executeTransaction(
|
|
|
|
|
|
async (tx) => {
|
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
|
|
|
|
return async () => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{ timeout: 50 }
|
|
|
|
|
|
)
|
|
|
|
|
|
).rejects.toThrow()
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
describe('executeTransactionWithResult', () => {
|
|
|
|
|
|
it('should return detailed result', async () => {
|
|
|
|
|
|
const result = await manager.executeTransactionWithResult(async (tx) => {
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1))
|
|
|
|
|
|
return async () => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
return 'success'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.value).toBe('success')
|
|
|
|
|
|
expect(result.operationCount).toBe(2)
|
|
|
|
|
|
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('should measure execution time', async () => {
|
|
|
|
|
|
const result = await manager.executeTransactionWithResult(async (tx) => {
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
execute: async () => {
|
2026-07-15 12:46:33 -07:00
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 25))
|
2025-11-14 10:26:23 -08:00
|
|
|
|
return async () => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return 'done'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-15 12:46:33 -07:00
|
|
|
|
// Timer coalescing can fire a setTimeout up to a few ms EARLY under
|
|
|
|
|
|
// load, so assert well below the sleep — this tests that time is
|
|
|
|
|
|
// MEASURED, not the OS timer's precision.
|
|
|
|
|
|
expect(result.executionTimeMs).toBeGreaterThanOrEqual(20)
|
2025-11-14 10:26:23 -08:00
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
describe('Statistics Tracking', () => {
|
|
|
|
|
|
it('should track total transactions', async () => {
|
|
|
|
|
|
await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const stats = manager.getStats()
|
|
|
|
|
|
expect(stats.totalTransactions).toBe(2)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('should track successful transactions', async () => {
|
|
|
|
|
|
await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const stats = manager.getStats()
|
|
|
|
|
|
expect(stats.successfulTransactions).toBe(1)
|
|
|
|
|
|
expect(stats.failedTransactions).toBe(0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('should track failed transactions', async () => {
|
|
|
|
|
|
await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
}).catch(() => {})
|
|
|
|
|
|
|
|
|
|
|
|
await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
throw new Error('Failed')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}).catch(() => {})
|
|
|
|
|
|
|
|
|
|
|
|
const stats = manager.getStats()
|
|
|
|
|
|
expect(stats.successfulTransactions).toBe(1)
|
|
|
|
|
|
expect(stats.failedTransactions).toBe(1)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('should track rolled back transactions', async () => {
|
|
|
|
|
|
await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
return async () => { /* rollback */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
throw new Error('Failed')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}).catch(() => {})
|
|
|
|
|
|
|
|
|
|
|
|
const stats = manager.getStats()
|
|
|
|
|
|
expect(stats.rolledBackTransactions).toBe(1)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('should calculate average execution time', async () => {
|
|
|
|
|
|
await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
|
|
|
|
return async () => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
|
|
|
|
return async () => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const stats = manager.getStats()
|
|
|
|
|
|
expect(stats.averageExecutionTimeMs).toBeGreaterThan(0)
|
|
|
|
|
|
expect(stats.totalTransactions).toBe(2)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('should calculate average operations per transaction', async () => {
|
|
|
|
|
|
await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const stats = manager.getStats()
|
|
|
|
|
|
expect(stats.averageOperationsPerTransaction).toBe(3) // (2 + 4) / 2
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('should allow resetting statistics', async () => {
|
|
|
|
|
|
await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
let stats = manager.getStats()
|
|
|
|
|
|
expect(stats.totalTransactions).toBe(1)
|
|
|
|
|
|
|
|
|
|
|
|
manager.resetStats()
|
|
|
|
|
|
|
|
|
|
|
|
stats = manager.getStats()
|
|
|
|
|
|
expect(stats.totalTransactions).toBe(0)
|
|
|
|
|
|
expect(stats.successfulTransactions).toBe(0)
|
|
|
|
|
|
expect(stats.failedTransactions).toBe(0)
|
|
|
|
|
|
expect(stats.rolledBackTransactions).toBe(0)
|
|
|
|
|
|
expect(stats.averageExecutionTimeMs).toBe(0)
|
|
|
|
|
|
expect(stats.averageOperationsPerTransaction).toBe(0)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
describe('Concurrent Transactions', () => {
|
|
|
|
|
|
it('should handle concurrent transactions', async () => {
|
|
|
|
|
|
const results = await Promise.all([
|
|
|
|
|
|
manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
return 1
|
|
|
|
|
|
}),
|
|
|
|
|
|
manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
return 2
|
|
|
|
|
|
}),
|
|
|
|
|
|
manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
return 3
|
|
|
|
|
|
})
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
expect(results).toEqual([1, 2, 3])
|
|
|
|
|
|
|
|
|
|
|
|
const stats = manager.getStats()
|
|
|
|
|
|
expect(stats.totalTransactions).toBe(3)
|
|
|
|
|
|
expect(stats.successfulTransactions).toBe(3)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('should track mixed success/failure in concurrent transactions', async () => {
|
|
|
|
|
|
const results = await Promise.allSettled([
|
|
|
|
|
|
manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
}),
|
|
|
|
|
|
manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
throw new Error('Failed')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}),
|
|
|
|
|
|
manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
})
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
expect(results[0].status).toBe('fulfilled')
|
|
|
|
|
|
expect(results[1].status).toBe('rejected')
|
|
|
|
|
|
expect(results[2].status).toBe('fulfilled')
|
|
|
|
|
|
|
|
|
|
|
|
const stats = manager.getStats()
|
|
|
|
|
|
expect(stats.totalTransactions).toBe(3)
|
|
|
|
|
|
expect(stats.successfulTransactions).toBe(2)
|
|
|
|
|
|
expect(stats.failedTransactions).toBe(1)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
describe('Error Context', () => {
|
|
|
|
|
|
it('should wrap non-TransactionError errors', async () => {
|
|
|
|
|
|
const error = await manager.executeTransaction(async () => {
|
|
|
|
|
|
throw new Error('Custom error')
|
|
|
|
|
|
}).catch(e => e)
|
|
|
|
|
|
|
|
|
|
|
|
expect(error).toBeInstanceOf(TransactionError)
|
|
|
|
|
|
expect(error.message).toContain('Custom error')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('should preserve TransactionError instances', async () => {
|
|
|
|
|
|
const error = await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
throw new Error('Operation failed')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}).catch(e => e)
|
|
|
|
|
|
|
|
|
|
|
|
expect(error).toBeInstanceOf(TransactionError)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
describe('Stats Immutability', () => {
|
|
|
|
|
|
it('should return immutable stats copy', async () => {
|
|
|
|
|
|
await manager.executeTransaction(async (tx) => {
|
|
|
|
|
|
tx.addOperation({ execute: async () => undefined })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const stats1 = manager.getStats()
|
|
|
|
|
|
const stats2 = manager.getStats()
|
|
|
|
|
|
|
|
|
|
|
|
expect(stats1).not.toBe(stats2) // Different objects
|
|
|
|
|
|
expect(stats1).toEqual(stats2) // Same values
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2026-07-17 16:49:38 -07:00
|
|
|
|
|
|
|
|
|
|
describe('Timeout budget + telemetry', () => {
|
|
|
|
|
|
it('transactTimeoutBudget: explicit override wins; default scales with batch size', () => {
|
|
|
|
|
|
expect(transactTimeoutBudget(1)).toBe(30_000) // small batches keep the 30s floor
|
|
|
|
|
|
expect(transactTimeoutBudget(15)).toBe(30_000) // the old flat cap's break-even point
|
|
|
|
|
|
expect(transactTimeoutBudget(100)).toBe(200_000) // 100 ops × 2s — bulk gets an honest budget
|
|
|
|
|
|
expect(transactTimeoutBudget(1000, 5_000)).toBe(5_000) // caller override is untouched
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('a tripped budget rolls back and names the operation, progress, and budget', async () => {
|
|
|
|
|
|
const rolledBack: string[] = []
|
|
|
|
|
|
|
|
|
|
|
|
const failing = manager.executeTransaction(
|
|
|
|
|
|
async (tx) => {
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
name: 'slow-first-op',
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 30))
|
|
|
|
|
|
return async () => {
|
|
|
|
|
|
rolledBack.push('slow-first-op')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
tx.addOperation({
|
|
|
|
|
|
name: 'never-reached',
|
|
|
|
|
|
execute: async () => undefined
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
{ timeout: 5 } // the first op's 30ms sleep guarantees the pre-op-2 check trips
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await expect(failing).rejects.toThrow(TransactionError)
|
|
|
|
|
|
const err = await failing.catch((e) => e)
|
|
|
|
|
|
expect(err.name).toBe('TransactionTimeoutError')
|
|
|
|
|
|
expect(err.message).toContain('operation 1/2') // which op, of how many
|
|
|
|
|
|
expect(err.message).toContain("('never-reached')") // its name
|
|
|
|
|
|
expect(err.message).toContain('budget 5ms') // the budget that tripped
|
|
|
|
|
|
expect(err.message).toContain('rolled back') // the retryability statement
|
|
|
|
|
|
expect(rolledBack).toEqual(['slow-first-op']) // the applied op was undone
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2025-11-14 10:26:23 -08:00
|
|
|
|
})
|