brainy/tests/transaction/TransactionManager.unit.test.ts
David Snelling 6ef9fcb7a2 feat: scaled transact budgets + labeled timeout diagnostics + envelope docs
- The transact apply budget scales with the batch — max(30s, opCount x 2s) —
  or is exactly the caller's new TransactOptions.timeoutMs. A flat 30s cap
  silently limited honest bulk work to ~15 operations on network-attached
  storage (~2s/op measured in a production import) while looking generous for
  small batches. Internal batch paths (removeMany chunks) get the same
  scaling via the shared transactTimeoutBudget helper.
- TransactionTimeoutError now reports the operation it stopped at as i/N with
  the operation's name, elapsed vs budgeted time, and states the batch rolled
  back atomically and is retryable; context carries the fields
  programmatically.
- The transact operational envelope is documented (optimistic-concurrency
  guide): budget math, chunking with ifAbsent idempotency, when to reach for
  addMany vs transact, and the precompute pattern (embedBatch + per-op
  vector) that keeps inference out of the commit path.
- Embedding claims made honest per an end-to-end probe of the built dist:
  batch and single embedding paths produce bit-identical vectors and a
  vector-supplied add is fully searchable, but batch throughput on the
  default WASM engine measures comparable to sequential (~160ms/text) — the
  5-10x speedup claim in the addMany JSDoc is replaced with the measured
  reality and the actual win (inference outside the budgeted write path).
2026-07-17 16:49:38 -07:00

373 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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'
import { transactTimeoutBudget } from '../../src/transaction/Transaction.js'
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 () => {
await new Promise(resolve => setTimeout(resolve, 25))
return async () => {}
}
})
return 'done'
})
// 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)
})
})
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
})
})
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
})
})
})