feat(storage): bound stalled IndexedDB transactions with an opt-in guard (DAB-1177) - #32
Open
matalina wants to merge 1 commit into
Open
feat(storage): bound stalled IndexedDB transactions with an opt-in guard (DAB-1177)#32matalina wants to merge 1 commit into
matalina wants to merge 1 commit into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A stalled IndexedDB transaction fires no event at all — not
complete, noterror, notabort.requestToPromisesettles 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 theget/getAll/countreads 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:
slowTransactionTimeout0onSlowStorageand leaves the operation untouchedtransactionTimeout0StorageTimeoutErroronSlowStorageconsole.warnBoth 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.
slowTransactionTimeoutis safe to enable on its own to get that distribution first;transactionTimeoutshould only be set against it.Ported from the @dabble/patches guard
lateFireis reported alongside every duration, so a caller's stats stay separable — without it a mostly-frozen figure reads as a catastrophic hang.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.StorageTimeoutErrorreuses 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
error.dbNameproperty 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.Testing
src/storageGuard.spec.ts, 9 tests, driving a hand-built connection whose transactions never fire — following the idiom the existingopenlifecycle guard tests use. The suspended-tab cases work by spying onDate.nowrather than using fake timers, which keeps them clear of thesetSystemTimetrap 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;
lateFireis 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 stalledgetAllwith no transaction promise is bounded; and clones share their parent's storage context.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, sonode_modules/anddist/are untracked but not ignored — onegit add -Aaway from committing the tree. Happy to split it out if you would rather.