/** * @module tests/unit/transaction/budget-commit-completed-work * @description Regression coverage for the "commit-completed-work" transaction * budget semantics: the budget gates STARTING the next operation; it never * converts already-completed work into a rollback. Concretely: * * - A single-op transaction can never time out post-hoc — its one operation * either runs (and the transaction commits, however long it took) or it * never gets to start (which cannot happen: there is no operation before it * to have overrun the budget). * - A multi-op transaction whose op `i` overruns the budget stops op `i+1` * from starting: everything applied so far rolls back atomically and a * `TransactionTimeoutError` is thrown — the mid-batch zero-loss guarantee is * unchanged. * - `transactTimeoutBudget`'s scaling floor (`max(floorMs, opCount * 2000)`) * is overridable per call, the seam `BrainyConfig.transactionBudgetFloorMs` * feeds at the brainy.ts read sites. * * Uses a real class implementing the `Operation` interface (not an anonymous * object literal, not a mock of Transaction internals) so the exercised path * is exactly what a real caller's operation looks like. */ import { describe, it, expect } from 'vitest' import { Transaction, transactTimeoutBudget } from '../../../src/transaction/Transaction.js' import type { Operation, RollbackAction } from '../../../src/transaction/types.js' import { TransactionTimeoutError } from '../../../src/transaction/errors.js' const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) /** * A real `Operation` implementation that sleeps for `delayMs` before applying * a write to `log`, and returns a rollback action that removes it again. Used * to deterministically make one operation "slow" relative to a transaction's * budget without touching any Transaction internals. */ class SlowOperation implements Operation { readonly name: string executed = false rolledBack = false constructor( private readonly log: string[], label: string, private readonly delayMs: number ) { this.name = label } async execute(): Promise { this.executed = true if (this.delayMs > 0) await sleep(this.delayMs) this.log.push(this.name) return async () => { this.rolledBack = true const idx = this.log.indexOf(this.name) if (idx >= 0) this.log.splice(idx, 1) } } } describe('Transaction budget — commit-completed-work semantics', () => { it('(a) single-op transaction whose op overruns the budget COMMITS — no rollback, no throw', async () => { const log: string[] = [] const op = new SlowOperation(log, 'slow-single-op', 40) // Budget (5ms) is far smaller than the op's 40ms — under the OLD // (post-completion-check) semantics this would have thrown and rolled // back after the op finished. Under the new semantics there is no // operation after it to gate, so it commits. const tx = new Transaction({ timeout: 5 }) tx.addOperation(op) await expect(tx.execute()).resolves.toBeUndefined() expect(tx.getState()).toBe('committed') expect(op.executed).toBe(true) expect(op.rolledBack).toBe(false) expect(log).toEqual(['slow-single-op']) }) it('(b) two-op transaction: op0 overruns the budget → op1 never starts, op0 rolls back, throws TransactionTimeoutError', async () => { const log: string[] = [] const op0 = new SlowOperation(log, 'op0-overruns', 40) const op1 = new SlowOperation(log, 'op1-never-starts', 0) const tx = new Transaction({ timeout: 5 }) tx.addOperation(op0) tx.addOperation(op1) const error = await tx.execute().catch((e) => e) expect(error).toBeInstanceOf(TransactionTimeoutError) expect(op0.executed).toBe(true) expect(op0.rolledBack).toBe(true) expect(op1.executed).toBe(false) expect(op1.rolledBack).toBe(false) expect(log).toEqual([]) // op0's write was undone; op1 never wrote expect(tx.getState()).toBe('rolled_back') }) it('a single-op transaction never checks the budget before its only operation starts', async () => { // Budget of 0ms: under the old "check before every op including 0" code // this could theoretically trip before op 0 even started (if any // measurable time elapsed between startTime capture and the check). The // documented contract is stronger: operation 0 ALWAYS gets to start. const log: string[] = [] const op = new SlowOperation(log, 'only-op', 5) const tx = new Transaction({ timeout: 0 }) tx.addOperation(op) await expect(tx.execute()).resolves.toBeUndefined() expect(tx.getState()).toBe('committed') expect(op.executed).toBe(true) }) it('(c) transactTimeoutBudget: an explicit floorMs overrides the 30s default floor', () => { // Below the per-op scaling term, a raised floor wins. expect(transactTimeoutBudget(1, undefined, 5_000)).toBe(5_000) // 1 op × 2000 = 2000 < 5000 floor expect(transactTimeoutBudget(1)).toBe(30_000) // unchanged default when floorMs omitted // Above the floor, opCount * 2000 still wins over a smaller floor. expect(transactTimeoutBudget(10, undefined, 5_000)).toBe(20_000) // 10 × 2000 = 20000 > 5000 floor // A full `override` always wins, regardless of floorMs. expect(transactTimeoutBudget(10, 999, 5_000)).toBe(999) }) it('(c) a configured floor changes real Transaction behavior end-to-end via TransactionManager-style options', async () => { // Simulates how brainy.ts wires `this.config.transactionBudgetFloorMs` // into `transactTimeoutBudget()`: opCount 0 isolates the floor term // (0 × 2000 = 0), so a lowered floor makes a normally-generous budget // fail-fast for a real 2-op Transaction. const lowFloorBudget = transactTimeoutBudget(0, undefined, 10) // 10ms floor expect(lowFloorBudget).toBe(10) const log: string[] = [] const op0 = new SlowOperation(log, 'op0', 30) const op1 = new SlowOperation(log, 'op1-gated-by-lowered-floor', 0) const tx = new Transaction({ timeout: lowFloorBudget }) tx.addOperation(op0) tx.addOperation(op1) const error = await tx.execute().catch((e) => e) expect(error).toBeInstanceOf(TransactionTimeoutError) expect(op1.executed).toBe(false) }) })