Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions handwritten/firestore/dev/src/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,15 @@ export class Transaction implements firestore.Transaction {
return r.transaction;
});

// Nothing awaits `_transactionIdPromise` until a subsequent read, a
// commit, or a rollback. When the first read is the operation that
// fails, `rollback()` returns early for read-only transactions and never
// awaits it, and a read-write transaction can leave it rejected across a
// macrotask boundary. Node then reports an unhandled rejection, which
// terminates the process under the default `--unhandled-rejections=throw`.
// Observe the rejection here; awaiters still see it.
void this._transactionIdPromise.catch(() => {});

return resultPromise.then(r => r.result);
}
}
Expand Down
31 changes: 31 additions & 0 deletions handwritten/firestore/dev/test/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,37 @@ describe('failed transactions', () => {
}
});

it('does not orphan the transaction ID promise when the first read fails', async () => {
// The transaction ID promise is derived from the first read. On this path
// nothing ever awaits it, so without an attached handler Node reports an
// unhandled rejection and terminates the process.
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) =>
unhandledRejections.push(reason);
process.on('unhandledRejection', onUnhandledRejection);

const serverError = new GoogleError('Test Error');
serverError.code = Status.UNAUTHENTICATED;

try {
await expect(
runTransaction(
/* transactionOptions= */ {readOnly: true},
(transaction, docRef) => transaction.get(docRef),
getDocument({newTransaction: {readOnly: {}}, error: serverError}),
// No rollback because the lazy-start operation failed
),
).to.eventually.be.rejected;

// Node reports unhandled rejections once the microtask queue drains, so
// yield a macrotask before asserting.
await new Promise(resolve => setImmediate(resolve));
expect(unhandledRejections).to.be.empty;
} finally {
process.removeListener('unhandledRejection', onUnhandledRejection);
}
});

it('retries commit for expired transaction', async () => {
// The transaction needs to perform a read or write otherwise it will be
// a no-op and will not retry
Expand Down
Loading