fix: transaction timeouts are a typed no-hot-retry contract; engine-side non-retry pinned; dead transaction path removed

A production incident: a native-provider op ground 38-40s inside a transaction,
blew the apply-phase budget, rolled back, and a downstream pipeline hot-retried
the identical operation into a 6-minute CPU storm. Brainy itself never
auto-retried the timeout; the gap was that TransactionTimeoutError only said
"retryable" in prose, with nothing machine-readable for a caller to branch on.

- TransactionTimeoutError gains two typed, always-true fields: retryable
  (a later attempt may succeed once the slowness resolves or the budget is
  raised) and hotRetryUnsafe (an immediate identical retry re-pays the full
  cost that just timed out and can cascade into a CPU storm -- callers must
  latch and back off, never loop). context's existing telemetry fields
  (timeoutMs, operationIndex, elapsedMs, totalOperations, operationName) are
  now documented as the caller's backoff inputs.
- Updated the "retryable" doc-prose sites (transact()'s timeoutMs option,
  transactionBudgetFloorMs, Transaction.execute()'s contract) to point at
  the new fields instead of bare prose.
- Regression pin (tests/unit/transaction/timeout-never-internally-retried.test.ts):
  an execution counter proves the engine never re-drives a timed-out
  operation, through both the single-op engine TransactionManager/Transaction
  drives for every single-record write, and add()'s upsert-race retry loop
  (which must exit on the first TransactionTimeoutError, never treat it like
  the lost-insert-race signal it retries on).
- Removed TransactionManager.executeTransactionWithResult -- zero callers
  anywhere in the codebase.
This commit is contained in:
David Snelling 2026-07-24 16:01:41 -07:00
parent 22702b81c0
commit 003e2a74ea
7 changed files with 199 additions and 76 deletions

View file

@ -5,7 +5,6 @@
* - High-level transaction API
* - Statistics tracking
* - Error handling
* - Result wrapping
*/
import { describe, it, expect, beforeEach } from 'vitest'
@ -84,42 +83,6 @@ describe('TransactionManager', () => {
})
})
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) => {