Three pieces addressing the cold-restart-write incident where a production deployment's first writes after every restart (33-35s each on a cold page cache) blew the op-count-scaled transact budget mid-batch: every write is itself a multi-op transaction, so one cold operation consumed the whole budget, the gate before the next operation tripped, and the write rolled back atomically - refused, retried, and refused again until the page cache warmed passively. - The budget's start-gating contract is now explicit and pinned: it gates STARTING the next operation, never rolling back completed work for elapsed time (the shipped schedule since 8.7.0, now stated in contract JSDoc, guarded by code for operation 0, and enforced by regression tests). The 30s floor is configurable via transactionBudgetFloorMs for stores whose cold operations legitimately run long. - New brain.warm() eagerly loads the vector index, metadata index, and graph adjacency so first operations after a cold restart run at steady-state cost. Returns a WarmReport with an honest per-surface outcome (warmed / probed / unavailable) - never reports a probe as a warm. warmOnOpen: true runs it during init(). New optional provider hook warm() on the vector and graph plugin contracts. - Vector-index transaction op classes renamed from the backend-specific AddToHNSWOperation / RemoveFromHNSWOperation to backend-neutral AddToVectorIndexOperation / RemoveFromVectorIndexOperation, stamping the active backend into the emitted op-name string (AddToVectorIndex(js-hnsw) vs a native provider's own identity) so journals never misdirect an operator toward an index that isn't running.
149 lines
6.2 KiB
TypeScript
149 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)
|
||
})
|
||
})
|