Repository navigation
fix: give the connection back when a transaction fails to begin - #1923
Merged
Merged
Conversation
…ady closed The pool destroys a tedious connection that has already closed without closing it again, and skipped publishing mssql:connection:destroy for it. A connection whose socket ends is closed by tedious itself, so a subscriber counting the pool's connections from the create and destroy events counted it as open for good. The destroy is now published either way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The proxy integration test fails for valid configurations that omit the port, and cross-driver lifecycle behavior still needs live integration confirmation.
1 open finding
What changed in this PR
Fixes connection leaks after failed transaction begins across both drivers, preventing pool exhaustion and reuse of potentially open transactions.
Changes:
- Releases borrowed connections and discards uncertain sessions after begin failures.
- Publishes destroy diagnostics for already-closed tedious connections.
- Adds regression tests and documents recovery behavior.
| File | Description |
|---|---|
| test/tedious/tedious.js | Registers recovery integration tests. |
| test/prepare.sql | Creates the transaction persistence fixture. |
| test/msnodesqlv8/msnodesqlv8.js | Registers recovery coverage and skips the timeout case. |
| test/common/unit.js | Tests begin failures and connection disposal. |
| test/common/tests.js | Adds recovery and timeout integration scenarios. |
| test/common/diagnostics.js | Tests already-closed connection diagnostics. |
| test/cleanup.sql | Removes the new fixture. |
| README.md | Documents failed-begin behavior. |
| lib/tedious/transaction.js | Cleans up failed begins and disposes uncertain sessions. |
| lib/tedious/connection-pool.js | Publishes destroy events for closed connections. |
| lib/msnodesqlv8/transaction.js | Validates before borrowing and marks failed connections unusable. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
When tedious failed to begin a transaction, the transaction kept the connection it had borrowed, and its rollbackTransaction listener, and nothing gave the connection back to the pool. As many failures as the pool has connections used it up: every later request waited for a connection until it timed out, and pool.close() never finished. The transaction could not be begun again either, as it still held the connection. Two kinds of failure did this. tedious throws while it validates the arguments, for example for an isolationLevel property set to a level that does not exist; the throw reached the pool's acquire callback, which was called again with it as an acquire error. And a request to begin that failed, with a server error or a timeout, was reported with nothing given back. Either way the connection now goes back to the pool, the transaction can be begun again, and the error is reported once, as a TransactionError that keeps the original as its originalError. A connection that may have sent the request is closed first. A request can time out after the server has run it, leaving a transaction open on the connection; lent out as it was, it would nest the next borrower's work in that transaction, whose commit would return without anything it wrote being kept. The callback is called on a later tick, outside the pool's promise chain, so that anything it throws is not passed back to the acquire callback as a second error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o begin An isolationLevel property set to a level that does not exist made the msnodesqlv8 transaction throw while it built its query, after it had borrowed a connection, and the connection was never given back. The query is now built before a connection is borrowed. A query to begin that failed gave the connection back as it was, though the query can time out after the server has run it, leaving a transaction open on the connection that the next borrower's work would be nested in. The connection is now marked as failed instead, so the pool closes it rather than lend it out again. It is not closed here, as msnodesqlv8 never calls back from closing a connection that is already closed, and the pool would wait for it to. The error is now a TransactionError, as it is with tedious and for msnodesqlv8 commits and rollbacks, with the request error as its originalError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…begin Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dhensby
force-pushed
the
fix/transaction-begin-failure
branch
from
October 14, 2026 17:48
ad96456 to
7e8825e
Compare
dhensby
marked this pull request as ready for review
October 14, 2026 17:48
|
🎉 This PR is included in version 12.7.7 🎉 The release is available on: Your semantic-release bot 📦🚀 |
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.

What's wrong
When
Transaction.begin()fails, the connection the transaction borrowed from the pool is never given back. This has been the case since v6.On tedious,
_beginborrows a connection, records it, attaches therollbackTransactionlistener, and callsconnection.beginTransaction(). It can fail in two ways, and neither gives the connection back:tx.isolationLevelto a level that doesn't exist and callingbegin()with no argument: the base class checks only the argument. The throw happens inside the pool's acquire callback.ConnectionPool.acquire()chains.then(cb).catch(cb), so the same callback runs again with the error, andbegin()rejects with tedious's ownRangeError/TypeError.TransactionErrorand nothing else happens.Either way the connection stays borrowed for good. tarn never reclaims a borrowed resource, so as many failures as the pool has connections use it up. After that, every request waits for a connection until it times out, and
pool.close()never finishes, because tarn'sdestroy()waits for borrowed resources to come back. The transaction keeps_acquiredConnectiontoo, sobegin()again givesEALREADYBEGUN.msnodesqlv8 has the same problem:
isolationLevelDeclaration()throws inside the acquire callback, before the release-on-error code, so the connection leaks the same way.Why the connection is closed rather than just given back
Once the request to begin has been sent, nothing tells us whether the server began the transaction. Reproduced locally with a proxy that delays what the server sends:
begin()times out after the server has already run it.begin(), inserts andcommit()all resolve, but their transaction is nested inside the orphaned one (@@trancountis still 1 after their commit).So a connection that may have sent the request is not lent out again. I didn't try to infer the session state from error codes: a failed begin is rare and a reconnect is cheap.
Changes
rollbackTransactionlistener and clears its connection and config. It gives the connection back to the pool, and calls back once with aTransactionErrorthat keeps the original error asoriginalError. The transaction can then be begun again.closedstraight away,_poolValidaterejects a closed connection whatevervalidateConnectionis, and_poolDestroyalready handles one that is closed.cleanupConnection()calls the request's callback before it marks the connection closed, so closing it from inside that callback would call it a second time. The deferral also keeps the caller's callback out of the pool's promise chain, so anything it throws isn't passed back in as a second error.hasErrorand given back, so the pool closes it rather than lending it out again. It isn't closed directly: msnodesqlv8'sclose()never calls back for a connection that's already closed (2.x to 5.x), so the pool's own destroy, andpool.close(), would hang.TransactionError, as it is with tedious and for msnodesqlv8's commit and rollback._poolDestroynow publishesmssql:connection:destroyfor a connection that has already closed, as well as for one it closes itself. Before this, subscribers never saw the destroy for a connection that tedious closed when its socket ended. This branch makes that happen after every failed begin, since the transaction now closes the connection itself. This is its own commit, before the fix.begin()fails.Tests
requirestub, both going through the pool's realacquire(). They cover:begin(cb)being called once;_poolValidaterefuses the returned connection whethervalidateConnectionis on or off.isolationLevelwith a pool of max 1, after which the next query should succeed andpool.close()should resolve.requestTimeout: 300, pool max 1,validateConnection: 'socket'). The next borrower's begin, insert and commit should leave@@trancountat 0, and the row should still be there when read back on a fresh connection. It's registered as skipped in the msnodesqlv8 suite, because msnodesqlv8 timeouts are whole seconds, so msnodesqlv8's failed-query path is covered by the unit tests only._poolDestroyshould publish the destroy for a connection that has already closed.master. The timeout test also fails against a version that gives the connection back without closing it (@@trancountis 1).user defined types, and the spatial and hierarchyid bulk loads). The msnodesqlv8 live tests are left to the Windows CI jobs.Not changed
begin()still checks only the isolation level it's given, not theisolationLevelproperty. Checking the property up front looks tidy, but it would reject values tedious accepts today:undefined, which tedious replaces with the pool'soptions.isolationLevel, and0, its "no change" level. I left that for a separate change if it's wanted.ConnectionPool.acquire()'s.then(cb).catch(cb)runs the same callback a second time. That's a separate issue and is left alone here. The transaction no longer throws into it.🤖 Generated with Claude Code