Skip to content

feat(storage): bound stalled IndexedDB transactions with an opt-in guard (DAB-1177) - #32

Open
matalina wants to merge 1 commit into
masterfrom
dab-1177-browserbase-transaction-stall-guard
Open

feat(storage): bound stalled IndexedDB transactions with an opt-in guard (DAB-1177)#32
matalina wants to merge 1 commit into
masterfrom
dab-1177-browserbase-transaction-stall-guard

Conversation

@matalina

Copy link
Copy Markdown
Member

A stalled IndexedDB transaction fires no event at all — not complete, not error, not abort. requestToPromise settles only on those four, so its promise never settles, no error is dispatched, and the caller waits forever with nothing to react to. open() has been guarded against exactly this since 3.1.0 ("Some browsers never fire any event on the open request, leaving callers hanging forever"); the transaction path never was.

Filed as DAB-1177.

What this adds

The guard goes in requestToPromise — the single choke point all fifteen promise-returning operations flow through, including the get/getAll/count reads that pass no transaction and so have no transaction promise to fall back on. It arms only on that transaction-less shape: with a transaction, the promise is chained onto the transaction's own promise, which was armed when it was created, so arming again would put two deadlines on one stall.

Three statics:

static default effect
slowTransactionTimeout 0 reports via onSlowStorage and leaves the operation untouched
transactionTimeout 0 aborts the transaction and rejects with a StorageTimeoutError
onSlowStorage console.warn swap to route reports at telemetry, or null to drop them

Both timers default to disabled, so this release changes no existing behavior. That is deliberate: dabble-writer (legacy 2.5) also consumes browserbase on a caret range and would otherwise start reporting on a problem nobody there is working. Consumers opt in.

The split matters for the ordering of the work. Nothing currently measures how long legitimate bulk storage work takes, and a hard deadline set below that converts working writes into failures. slowTransactionTimeout is safe to enable on its own to get that distribution first; transactionTimeout should only be set against it.

Ported from the @dabble/patches guard

  • Late-fire re-arm. A backgrounded tab freezes timers, so a guard that wakes to find far more wall-clock elapsed than it asked for measured suspension, not the connection. It re-arms once and gives the operation a fair window awake. lateFire is reported alongside every duration, so a caller's stats stay separable — without it a mostly-frozen figure reads as a catastrophic hang.
  • Defer window. A connection-wide lastSettleAt, shared between a Browserbase and its transaction clones, lets sibling work vouch for a slow transaction. Bounded at 30 windows so live traffic cannot postpone a genuine stall forever. Aborting a healthy-but-queued transaction is its own bug (DAB-834); this is what prevents it.

StorageTimeoutError reuses the name patches gives the same condition, so a consumer watching both storage paths classifies them identically with no extra mapping.

Two details worth a look in review

  • The database name is kept out of the error message and carried as an error.dbName property instead. Names routinely embed a user id, which would hand every user a private error group in Sentry and make the class uncountable. There is a test asserting it stays out.
  • A stalled transaction is aborted, not just abandoned. Left live it may still commit long after its caller gave up and moved on, which is worse than the stall.

Testing

src/storageGuard.spec.ts, 9 tests, driving a hand-built connection whose transactions never fire — following the idiom the existing open lifecycle guard tests use. The suspended-tab cases work by spying on Date.now rather than using fake timers, which keeps them clear of the setSystemTime trap where pending timers move with the clock.

Covered: disabled-by-default leaves a never-settling transaction alone; the slow report fires without disturbing the operation; lateFire is flagged when the clock jumped; the hard timeout rejects, aborts and dispatches; a completed transaction never fires; the defer window holds while siblings settle and lands once the connection goes quiet; the late-fire re-arm happens once and then fires; a stalled getAll with no transaction promise is bounded; and clones share their parent's storage context.

tsc --noEmit   clean
npm test       43 passed | 3 skipped (2 files)

Baseline before the change was 34 passed | 3 skipped, and all 34 still pass untouched.

Also in this PR

A two-line .gitignore. The repo has none, so node_modules/ and dist/ are untracked but not ignored — one git add -A away from committing the tree. Happy to split it out if you would rather.

…ard (DAB-1177)

A stalled IndexedDB transaction fires no event at all — not complete, not
error, not abort — so `requestToPromise` never settles and its caller waits
forever with nothing dispatched and nothing to recover from. `open()` has
been guarded against exactly this since 3.1.0; the transaction path never was.

Add the same shape to `requestToPromise`, the single choke point every
promise-returning operation flows through, including the get/getAll/count
reads that carry no transaction promise to fall back on:

- `slowTransactionTimeout` reports a long-running operation via `onSlowStorage`
  and leaves it completely alone, so consumers can measure what healthy
  storage costs before choosing a deadline.
- `transactionTimeout` aborts and rejects with a `StorageTimeoutError`, the
  name @dabble/patches gives the same condition so a consumer watching both
  storage paths classifies them alike.

Both default to 0 (disabled), so this release changes no existing behavior.

Ported from the patches guard: a timer that wakes to find far more wall-clock
elapsed than it asked for measured a suspended tab rather than the connection,
so it re-arms once; and a connection-wide `lastSettleAt` lets sibling work
vouch for a slow transaction, bounded at 30 windows so live traffic cannot
postpone a genuine stall forever. Aborting a healthy-but-queued transaction is
its own bug, and that defer window is what prevents it.

The database name is kept out of the error message and carried as a property:
names routinely embed a user id, which would give every user a private error
group in aggregators and make the class uncountable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant