Skip to content

fix(database): make runTransaction errors reject the returned promise - #10324

Open
bhavya091213 wants to merge 3 commits into
firebase:mainfrom
bhavya091213:fix/rtdb-transaction-error-not-catchable
Open

fix(database): make runTransaction errors reject the returned promise#10324
bhavya091213 wants to merge 3 commits into
firebase:mainfrom
bhavya091213:fix/rtdb-transaction-error-not-catchable

Conversation

@bhavya091213

Copy link
Copy Markdown

Discussion

Fixes #7919 (reported Jan 2024, confirmed reproducible by @jbalidiong in Apr 2026).

Approach was proposed and discussed first in #7919 (comment) per CONTRIBUTING.md. There is one open question from that comment that I'd still like your call on — see Open question at the bottom.

No public API surface changes: no signatures, types or exports are affected.

Problem

Errors raised by the transaction update function, or by validation of the data it returns, could escape the promise runTransaction() returns. Two independent paths:

1. The rerun path (the bug reported in #7919). When a transaction is rerun, the update function and validateFirebaseData() were called bare inside repoRerunTransactionQueue, which runs from server data-update callbacks (repoRerunTransactions is invoked from six call sites in Repo.ts, all reached via onDataUpdate). A throw there unwound through the WebSocket message handler instead of the promise:

Uncaught Error: transaction failed: Data returned contains NaN in property '<path>.counter'
    at repoRerunTransactionQueue (src/core/Repo.ts:1297:31)
    at repoRerunTransactions (src/core/Repo.ts:1223:3)
    at PersistentConnection.onDataMessage_ (src/core/PersistentConnection.ts:650:9)
    at Connection.onDataMessage_ (src/realtime/Connection.ts:321:10)
    at WebSocketConnection.onMessage (src/realtime/Connection.ts:210:16)

The Deferred was never settled, so the promise stayed pending forever — it neither resolved nor rejected — while the error surfaced as an uncaught exception. That is why the reporter's try/catch never fired.

This also explains why the original repro must start with an empty database: the first run sees a not-yet-populated cache and returns valid data, so the transaction is queued; the NaN is only produced on the rerun, once the server value arrives.

2. The initial-run path. The first run happens synchronously inside repoStartTransaction, so an error there was thrown out of runTransaction() rather than rejecting. try { await runTransaction(...) } catch caught it, but runTransaction(...).catch(fn) did not — the call throws before returning a promise, so .catch is never reached.

Changes

  • core/Repo.ts — in repoRerunTransactionQueue, wrap the update call and validateFirebaseData so a throw becomes a normal transaction abort. This reuses the existing abort machinery, including syncTreeAckUserWrite(..., true) to revert the pending local write, so it behaves consistently with the existing maxretry / nodata aborts. The original error is reported through onComplete with its stack preserved.
  • api/Transaction.ts — catch a synchronous failure from repoStartTransaction and reject the deferred, so errors are always delivered through the returned promise.
  • core/Repo.ts — call unwatcher() before an initial-run error propagates. Previously the onValue listener registered for the transaction leaked whenever the update function or validation threw before the transaction was queued.

Deliberately left alone: the argument-validation throws at the top of runTransaction (validateWritablePath, and the .length / .keys read-only check). Those are programmer errors rather than transaction failures, and throwing synchronously is consistent with the rest of the SDK.

Testing

Four regression tests added to packages/database/test/exp/integration.test.ts. I verified each one fails before the change and passes after (for the two initial-run tests, by reverting Transaction.ts alone and watching them fail with the throw escaping at Transaction.ts:134):

  • a rerun that produces NaN rejects the promise — the exact scenario in runTransaction errors are not catchable #7919
  • a rerun where the update function throws rejects the promise
  • an initial run where the update function throws rejects the promise
  • an initial-run failure is observable via .catch() rather than throwing synchronously

Full @firebase/database node suite against the RTDB emulator goes from 266 passing to 270 passing, 0 failing. tsc --noEmit, eslint and prettier are all clean. Changeset included (@firebase/database: patch).

Worth noting: integration.test.ts was the only test file in the package touching runTransaction at all, which is likely why this went unnoticed for so long.

Open question

Change 2 (initial run: synchronous throw → promise rejection) is technically a semantic change for anyone currently relying on runTransaction() throwing synchronously. I believe rejecting is correct for a function documented to return a Promise, and it makes both paths behave identically — but I'm happy to split it into a separate PR and land only the rerun fix here if you'd rather handle that change on its own. Just say the word.

Errors raised by the transaction update function, or by validation of the
data it returns, could escape the promise returned by runTransaction().

When a transaction is rerun, the update function and validateFirebaseData()
were called from repoRerunTransactionQueue, which runs inside server
data-update callbacks. A throw there unwound through the WebSocket message
handler rather than the promise, so the Deferred was never settled: the
promise stayed pending forever while the error surfaced as an uncaught
exception. Wrap both calls and turn a throw into a normal transaction abort,
reusing the existing abort path (including reverting the pending local write)
and reporting the original error through onComplete.

The initial run happens synchronously inside repoStartTransaction, so errors
there were thrown out of runTransaction() instead of rejecting. try/catch
around an await caught them, but runTransaction(...).catch(...) did not, since
the call threw before returning a promise. Reject the deferred instead.

Also call unwatcher() before an initial-run error propagates. Previously the
onValue listener registered for the transaction leaked whenever the update
function or validation threw before the transaction was queued.

Fixes firebase#7919
@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d370581

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@firebase/database Patch
@firebase/database-compat Patch
firebase Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request improves error handling in runTransaction() to ensure that errors thrown by the transaction update function or during data validation (both in the initial synchronous run and during subsequent reruns) always reject the returned promise instead of causing uncaught exceptions or leaving the promise pending. It also adds corresponding integration tests. The reviewer noted that during a transaction rerun, an invalid .priority in the returned data is not validated inside the new try-catch block, which could still lead to an uncaught exception, and suggested validating the priority within the try-catch block.

Comment thread packages/database/src/core/Repo.ts
An invalid `.priority` returned by a rerun was only rejected by
nodeFromJSON(), which ran after the update function and outside the guard
added in the previous commit. It therefore escaped the same way the NaN case
did: an uncaught INTERNAL ASSERT, with the returned promise left pending.

Verified against the emulator: `.priority: NaN` was already rejected via
validateFirebaseData, but `.priority: {}` and `.priority: true` both threw
out of nodeFromJSON.

Add the same isValidPriority() assertion the initial run already performs in
repoStartTransaction(), and move nodeFromJSON() inside the guard so any other
validation it performs is covered too.
@bhavya091213

Copy link
Copy Markdown
Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request fixes runTransaction() in @firebase/database to ensure that errors thrown during the transaction update function or data validation consistently reject the returned promise instead of throwing synchronously or leaking uncaught exceptions. The review feedback highlights two important edge cases where errors could still cause listener leaks or leave transactions in a broken state: first, by suggesting to call unwatcher() in the catch block of runTransaction to clean up the listener if repoStartTransaction fails synchronously; and second, by recommending that all validations (including priority checks) be performed before queueing the transaction to prevent it from getting stuck in a broken RUN state.

Comment thread packages/database/src/api/Transaction.ts
Comment thread packages/database/src/core/Repo.ts
repoStartTransaction() pushed the transaction onto the queue before
validating the priority returned by the update function. An invalid
`.priority` therefore threw from the assert *after* queueing, leaving a
stranded entry in the queue in RUN status that a later server update
would try to rerun, and skipping the unwatcher() cleanup for the
onValue() listener registered by runTransaction().

Move the priority computation and its isValidPriority() assert up next
to the validateFirebaseData() call, inside the try block that unwatches
and rethrows, so every initial-run validation happens before the
transaction is queued.

Also call unwatcher() in runTransaction()'s catch as defense in depth
for anything that escapes from outside those try blocks. Removing an
event registration twice is a no-op: viewRemoveEventRegistration()
matches by callback identity, so the second removal finds nothing.

Both issues were raised in review on firebase#10324.
@bhavya091213

Copy link
Copy Markdown
Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request fixes runTransaction() in @firebase/database to ensure that errors thrown during the transaction update function or data validation always reject the returned promise, rather than throwing synchronously or leaking as uncaught exceptions. It introduces proper error handling and cleanup (unwatching) during both the initial run and subsequent reruns of transactions, and includes comprehensive integration tests to verify these behaviors. There are no review comments, so I have no feedback to provide.

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.

runTransaction errors are not catchable

1 participant