150 lines
6.2 KiB
TypeScript
150 lines
6.2 KiB
TypeScript
|
|
/**
|
|||
|
|
* @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<void>((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<RollbackAction | undefined> {
|
|||
|
|
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)
|
|||
|
|
})
|
|||
|
|
})
|