Skip to content

fix(data-source): prevent connection leaks in DataSource.initialize() - #12704

Merged
alumni merged 7 commits into
typeorm:masterfrom
IbrahimHafez1:fix/data-source-initialize-connection-leaks
Oct 7, 2026
Merged

alumni merged 7 commits into
typeorm:masterfrom
IbrahimHafez1:fix/data-source-initialize-connection-leaks

Conversation

@IbrahimHafez1

@IbrahimHafez1 IbrahimHafez1 commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #12705

Description of change

DataSource.initialize() could leak database connections in two ways:

  1. Concurrent calls both connected the driver. The isInitialized guard is only set after driver.connect() resolves, so two overlapping initialize() calls both passed the check and each opened a driver connection/pool — one of them leaked.
  2. A cache connection failure leaked the driver connection. queryResultCache.connect() runs after the driver connects but before the existing cleanup try/catch began, so if the cache failed to connect, initialize() rejected with the driver still connected and no way to release it.

New behavior:

  • A private isInitializing flag makes an overlapping initialize() call reject with CannotConnectAlreadyConnectedError — the same error already thrown when calling initialize() on an initialized DataSource, so no new API surface. The flag is cleared in a finally, so a failed initialize can still be retried, as before.
  • The entire post-connect sequence is now covered by the cleanup try/catch. On failure before isInitialized is set (a cache connect failure), the driver is disconnected directly — destroy() cannot be used on that path because it throws CannotExecuteNotConnectedError when the DataSource is not initialized. A best-effort queryResultCache.disconnect() runs first (errors ignored) to release providers that partially connected before throwing, without masking the original error. On failure after isInitialized is set, behavior is unchanged: destroy() is called and the error re-thrown.

Behavioral note for reviewers: code that previously raced two initialize() calls and appeared to work (while silently double-connecting the driver) will now get a rejection on the second call. Rejecting is consistent with the existing double-initialize semantics; if you'd rather coalesce (return the in-flight promise so concurrent callers all resolve together), I'm happy to switch — rejecting seemed like the more conservative default.

Verification: two new regression tests in test/functional/data-source/data-source.test.ts, both using better-sqlite3 in-memory so they need no external services, and both failing against master:

  • when cache initialization fails → should disconnect the database driver — asserts the driver and the cache provider are disconnected and isInitialized stays false.
  • while initialization is in progress → should reject another initialization attempt — gates driver.connect() to hold the first call mid-flight, asserts the second call rejects with CannotConnectAlreadyConnectedError and that connect() was only invoked once.

Pull-Request Checklist

  • Code is up-to-date with the master branch
  • This pull request links a relevant issue using a closing keyword:
    Fixes #NNNN, Closes #NNNN, or Resolves #NNNN — Fixes DataSource.initialize() leaks connections on concurrent calls and on cache connection failure #12705
  • There are new or updated tests validating the change (tests/**.test.ts)
  • Documentation has been updated to reflect this change (docs/docs/**.md) — noted in the DataSource docs that initialize() rejects concurrent or repeated calls and that a failed initialization can be retried

@github-actions github-actions Bot added needs-triage PR needs issue link triage linked-issue PR references an issue and removed needs-triage PR needs issue link triage labels Jul 17, 2026
@IbrahimHafez1
IbrahimHafez1 force-pushed the fix/data-source-initialize-connection-leaks branch from e627e42 to 0d8cfcd Compare July 17, 2026 23:44
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jul 17, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Failed cleanup makes retries leak pools 🐞 Bug ☼ Reliability
Description
initialize() swallows a rejection from driver.disconnect() and its finally still clears
isInitializing, leaving the data source eligible for another connection attempt even though the
prior driver resource was not released. For MySQL, a failed pool.end() leaves this.pool set
while the next connect() overwrites it with a new pool, so a cache-connect failure followed by a
cleanup error and retry loses the only handle to the old pool.
Code

packages/typeorm/src/data-source/DataSource.ts[R293-296]

+                    try {
+                        await this.driver.disconnect()
+                    } catch {
+                        // ignore
Evidence
The changed cleanup catches and discards every driver disconnection error, then clears the only
in-progress guard. MySQL only clears its pool field after pool.end() succeeds, while connect()
assigns a newly created pool to that same field; therefore retrying after a failed cleanup
overwrites the remaining reference to the prior pool.

packages/typeorm/src/data-source/DataSource.ts[241-244]
packages/typeorm/src/data-source/DataSource.ts[293-302]
packages/typeorm/src/driver/mysql/MysqlDriver.ts[378-392]
packages/typeorm/src/driver/mysql/MysqlDriver.ts[444-462]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A failed driver cleanup is ignored while the initialization guard is cleared, allowing a retry to overwrite the handle to a driver resource that may still be open.
## Fix Focus Areas
- packages/typeorm/src/data-source/DataSource.ts[241-244]
- packages/typeorm/src/data-source/DataSource.ts[293-302]
## Recommended Fix
Record unsuccessful initialization cleanup and prevent a new driver connection until that cleanup has subsequently succeeded. Preserve the original initialization error for the first caller, but retain the cleanup failure state so retries cannot silently connect over an unreleased resource.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Cache option changes leak connections 🐞 Bug ☼ Reliability
Description
initialize() reconnects through this.queryResultCache and later reads that mutable field again
when cleaning up a failed cache connection. If setOptions() replaces the cache provider while
connect() is pending, cleanup disconnects the replacement and leaves the originally connecting
provider alive.
Code

packages/typeorm/src/data-source/DataSource.ts[R286-288]

+                    if (this.queryResultCache) {
+                        try {
+                            await this.queryResultCache.disconnect()
Evidence
The connection attempt awaits the current cache field, while setOptions() can synchronously
replace that field with a newly created provider. The added catch block then reads the field again,
so it has no guarantee that it disconnects the provider whose connection failed; Redis providers
hold their client on the provider instance until that same instance is disconnected.

packages/typeorm/src/data-source/DataSource.ts[215-218]
packages/typeorm/src/data-source/DataSource.ts[255-257]
packages/typeorm/src/data-source/DataSource.ts[286-292]
packages/typeorm/src/cache/RedisQueryResultCache.ts[46-64]
packages/typeorm/src/cache/RedisQueryResultCache.ts[101-105]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`initialize()` reads the mutable `queryResultCache` field again during failure cleanup. A concurrent `setOptions()` call can replace that field while the original provider's `connect()` is pending, causing cleanup to disconnect the replacement and leak the original provider.
## Fix Focus Areas
- packages/typeorm/src/data-source/DataSource.ts[255-299]
## Recommended Fix
Capture `this.queryResultCache` in a local variable before invoking `connect()`, and use that same captured instance for both the connection attempt and pre-initialization failure cleanup. Add a regression test that replaces the configured provider while the original provider's failing connection is pending and verifies that the original provider is disconnected.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Docs forbid reinitialize 🐞 Bug ⚙ Maintainability
Description
The docs now claim initialize() “must only be called once” and that calling it again after success
rejects, but destroy() explicitly supports reopening the connection and resets isInitialized,
allowing a later initialize() to succeed. This mismatch can mislead users into thinking
reconnecting on the same DataSource is unsupported.
Code

docs/docs/data-source/1-data-source.md[R11-13]

+`initialize` must only be called once: calling it again while a previous call is still in progress, or after it has
+succeeded, rejects with a `CannotConnectAlreadyConnectedError`. If initialization fails, the partially established
+connection is cleaned up and `initialize` can safely be called again.
Evidence
The newly added docs text states repeated initialize after success rejects, but the DataSource API
explicitly allows reconnecting after destroy() by resetting isInitialized to false and noting
that you can open the connection again.

docs/docs/data-source/1-data-source.md[11-13]
src/data-source/DataSource.ts[240-305]
src/data-source/DataSource.ts[308-321]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The docs currently state `initialize()` must only be called once and that calling it again after it has succeeded rejects. In reality, calling `destroy()` resets the DataSource state and `initialize()` can be called again to reopen the connection.
### Issue Context
- `destroy()` documentation explicitly says you can open the connection again.
- `destroy()` sets `isInitialized` back to `false`, and `initialize()` only rejects when `isInitialized` or `isInitializing` are true.
### Fix Focus Areas
- docs/docs/data-source/1-data-source.md[11-13]
### Suggested change
Reword to something like:
- “`initialize()` must not be called concurrently. Calling it while initialization is in progress, or while the DataSource is already initialized, rejects with `CannotConnectAlreadyConnectedError`. After calling `destroy()`, `initialize()` may be called again to reconnect.”

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View review recommended (4)
4. Tests missing issue reference ✓ Resolved
Description
The new functional regression tests for the fix do not include a comment referencing the tracked
issue (#12705), which makes it harder to trace why these scenarios were added. This does not meet
the checklist expectation to include an issue reference in functional tests when applicable.
Code

test/functional/data-source/data-source.test.ts[R35-36]

+    describe("when cache initialization fails", () => {
+        it("should disconnect the database driver", async () => {
Evidence
PR Compliance ID 3 requires issue fixes to be represented in the functional test suite with an issue
reference comment when applicable. The newly added describe blocks introduce regression tests but
contain no #12705 reference comment near their definitions.

Rule 3: Prefer functional tests over per-issue tests
test/functional/data-source/data-source.test.ts[34-90]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New functional regression tests were added for an issue fix, but they do not include an issue reference comment (e.g., `// For github issue #12705`) near the new test cases.
## Issue Context
Compliance requires issue fixes to live in `test/functional` and include an issue reference in a test comment when applicable, to preserve traceability.
## Fix Focus Areas
- test/functional/data-source/data-source.test.ts[35-36]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. initialize() now rejects concurrently ✓ Resolved
Description
DataSource.initialize() now throws CannotConnectAlreadyConnectedError when called while
initialization is in progress, which can impact user code that previously raced concurrent calls.
Consider updating documentation to clarify that initialize() is not re-entrant / should be called
once (or that concurrent calls will reject).
Code

src/data-source/DataSource.ts[R241-245]

+        if (this.isInitialized || this.isInitializing)
+            throw new CannotConnectAlreadyConnectedError()
-        // validate isolationLevel before connecting
-        validateIsolationLevel(
-            this.driver.supportedIsolationLevels,
-            this.options.isolationLevel,
-        )
+        this.isInitializing = true
+        try {
Evidence
The PR changes initialize() to throw when isInitializing is true, introducing a new observable
rejection case for concurrent callers. The DataSource docs describe calling initialize() during
bootstrap but do not currently document this concurrency/re-entrancy constraint.

Rule 2: Docs updated for user-facing changes
src/data-source/DataSource.ts[241-245]
docs/docs/data-source/1-data-source.md[9-16]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`DataSource.initialize()` now rejects concurrent calls via the new `isInitializing` guard, which may be a user-visible behavioral change for applications that accidentally call `initialize()` twice concurrently.
## Issue Context
The docs currently show `initialize()` usage but do not mention re-entrancy/concurrency expectations or that a second call can throw `CannotConnectAlreadyConnectedError`.
## Fix Focus Areas
- docs/docs/data-source/1-data-source.md[9-16]
- src/data-source/DataSource.ts[241-245]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Connect failure still leaks ⊘ Outdated
Description
DataSource.initialize() does not attempt cleanup when driver.connect() rejects, so drivers that
open a connection and then throw during connection setup can leak resources. This is possible with
the SQLite drivers where a user hook can throw after the underlying DB handle is created.
Code

src/data-source/DataSource.ts[R252-255]

+            // connect to the database via its driver
+            await this.driver.connect()
-        // connect to the cache-specific database if cache is enabled
-        if (this.queryResultCache) await this.queryResultCache.connect()
+            try {
Evidence
initialize() awaits driver.connect() before entering the inner try/catch, so a rejection from
connect() bypasses cleanup. SQLite drivers can create a DB connection and then throw afterwards
(e.g., during prepareDatabase), meaning resources can remain open even though connect() rejects.

src/data-source/DataSource.ts[240-257]
src/driver/sqlite-abstract/AbstractSqliteDriver.ts[297-302]
src/driver/better-sqlite3/BetterSqlite3Driver.ts[126-171]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`DataSource.initialize()` only enters its cleanup `catch` after `await this.driver.connect()` resolves. If `driver.connect()` throws after partially creating resources, `initialize()` exits without calling `driver.disconnect()`, leaving an open handle/pool.
### Issue Context
This is a realistic scenario for SQLite-family drivers: `connect()` sets up the DB handle via `createDatabaseConnection()`, then may run additional setup that can throw.
### Fix Focus Areas
- src/data-source/DataSource.ts[240-299]
- src/driver/sqlite-abstract/AbstractSqliteDriver.ts[297-302]
- src/driver/better-sqlite3/BetterSqlite3Driver.ts[126-171]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Cleanup masks original error ✓ Resolved
Description
On failures before isInitialized is set, the new cleanup path awaits this.driver.disconnect()
without guarding it, so a disconnect failure can replace the original initialization error. This
changes observable behavior for callers/debugging and can hide the true root cause.
Code

src/data-source/DataSource.ts[R283-294]

+                } else {
+                    // the cache provider may have partially connected before
+                    // failing; clean it up without masking the original error
+                    if (this.queryResultCache) {
+                        try {
+                            await this.queryResultCache.disconnect()
+                        } catch {
+                            // ignore
+                        }
+                    }
+                    await this.driver.disconnect()
+                }
Evidence
The new failure cleanup branch calls await this.driver.disconnect() unguarded, so a rejection
there will escape and prevent rethrowing the original error. SQLite’s abstract driver disconnect
path can reject when the underlying close operation reports an error.

src/data-source/DataSource.ts[278-295]
src/driver/sqlite-abstract/AbstractSqliteDriver.ts[314-320]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
In the `!this.isInitialized` cleanup branch, `await this.driver.disconnect()` is not wrapped. If it rejects, the promise returned by `initialize()` rejects with the disconnect error instead of the original initialization error.
### Issue Context
At least one in-repo driver implementation (`AbstractSqliteDriver.disconnect`) explicitly rejects on close errors, so this masking is possible.
### Fix Focus Areas
- src/data-source/DataSource.ts[278-295]
- src/driver/sqlite-abstract/AbstractSqliteDriver.ts[314-320]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

8. Test cleanup leaks driver ✓ Resolved
Description
The new concurrent-initialize test only calls destroy() when isInitialized is true, so if the first
initialize() fails before setting isInitialized the driver connection can remain open and hang the
test runner. Since destroy() throws when not initialized, add a driver.disconnect() fallback in the
finally block (guarded so it doesn’t mask the original failure).
Code

test/functional/data-source/data-source.test.ts[R124-128]

+            } finally {
+                if (dataSource.isInitialized) {
+                    await dataSource.destroy()
+                }
+            }
Evidence
The test’s finally block only destroys when isInitialized is true, but destroy() cannot be
called when initialization fails early because it throws when isInitialized is false; this leaves
no cleanup path in that scenario.

test/functional/data-source/data-source.test.ts[116-128]
src/data-source/DataSource.ts[308-317]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new test `while initialization is in progress` only cleans up via `dataSource.destroy()` when `dataSource.isInitialized` is `true`. If `initialize()` fails before `isInitialized` is set, `destroy()` cannot be used (it throws), and the driver connection may remain open.
### Issue Context
`DataSource.destroy()` throws `CannotExecuteNotConnectedError` when `!isInitialized`, so tests that want best-effort cleanup must explicitly call `dataSource.driver.disconnect()` (preferably in a guarded `try/catch`) when `isInitialized` is false.
### Fix Focus Areas
- test/functional/data-source/data-source.test.ts[124-128]
- src/data-source/DataSource.ts[308-317]
### Suggested change
Update the test’s `finally` block to:
- `await dataSource.destroy()` when `dataSource.isInitialized`
- otherwise, best-effort `await dataSource.driver.disconnect()` inside `try/catch` to avoid masking the original test failure

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


  • Author self-review: I have reviewed the code review findings, and addressed the relevant ones.

Grey Divider

Tip of the day
💡 Did you know, you can route each severity your way: inline, summary, both, or drop

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@IbrahimHafez1

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in 9c06c0c:

  • Cleanup masks original error — fixed: the cleanup driver.disconnect() is now guarded so its failure cannot replace the original initialization error.
  • Test cleanup leaks driver — fixed: the concurrency test's finally now falls back to a best-effort driver.disconnect() when the DataSource never reached the initialized state.
  • Tests missing issue reference — added #12705 reference comments to both new regression tests.
  • initialize() now rejects concurrently — documented in docs/docs/data-source/1-data-source.md: initialize() rejects concurrent/repeated calls with CannotConnectAlreadyConnectedError, and a failed initialization can be retried.

On "Connect failure still leaks": that path (driver.connect() itself failing after partially creating resources) is pre-existing behavior this PR doesn't change — the old code had the identical gap. Cleaning up partial state from a failed connect() is only safely done inside each driver's connect(), where the driver knows what was actually created; a blanket disconnect() from DataSource is not safe across all 15+ drivers. Happy to tackle the sqlite-family case in a follow-up PR if maintainers want it.

@pkg-pr-new

pkg-pr-new Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
  • typeorm-sql-js-example

    npm i https://pkg.pr.new/typeorm@12704
    

commit: 9715e1d

@IbrahimHafez1

Copy link
Copy Markdown
Contributor Author

Rebased onto current master; CI is re-running. This closes the connection leak when initialize() fails partway through. @alumni would you have a moment to take a look when convenient? Happy to narrow the scope if the lifecycle change needs more discussion.

@IbrahimHafez1
IbrahimHafez1 force-pushed the fix/data-source-initialize-connection-leaks branch from 9c06c0c to ef18c8e Compare September 15, 2026 09:41
@michaelbromley

Copy link
Copy Markdown
Member

This repository was recently restructured into a pnpm workspace, and the typeorm package moved from the repo root into packages/typeorm (PR #12854). Everything moved as a pure rename with no content changes, so your branch should replay across the move cleanly once git's rename detection limit is raised:

git fetch origin
git -c merge.renameLimit=999999 -c diff.renameLimit=999999 rebase origin/master

If your branch has diverged enough that a full rebase isn't worth it, scripts/migrate-pr-to-packages-layout.sh --replay (run on top of master) rewrites the paths of your net diff instead. Both approaches, plus a path-mapping table and a prompt you can hand to a coding agent, are documented in scripts/migrate-pr-to-packages-layout.md.

Sorry for the disruption, and thanks for your patience.

Reject concurrent initialize() calls while one is already in flight,
instead of letting both pass the isInitialized check and connect the
driver twice, leaking a connection.

Disconnect the driver (and best-effort the cache provider) when
queryResultCache.connect() fails, instead of rejecting with the driver
still connected and no way to release it.
Guard the cleanup driver.disconnect() so a disconnect failure cannot
mask the original initialization error, harden the concurrency test
teardown for the not-initialized case, reference the tracked issue in
the new regression tests, and document that initialize() rejects
concurrent or repeated calls.
@IbrahimHafez1
IbrahimHafez1 force-pushed the fix/data-source-initialize-connection-leaks branch from ef18c8e to 1f991c7 Compare September 20, 2026 09:31

@alumni alumni left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for this!

I would also suggest implementing a single-flight pattern to initialize the DataSource in your application if you want to handle concurrency.

We removed the deprecated connection manager in 1.0 since TypeORM was doing a few too many things, but if we ever create a separate connection manager package, it would likely be using a single flight pattern to create and initialize them.

@michaelbromley
michaelbromley enabled auto-merge (squash) October 7, 2026 16:19

@gioboa gioboa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@alumni
alumni disabled auto-merge October 7, 2026 19:02
@alumni
alumni merged commit 67991e8 into typeorm:master Oct 7, 2026
45 checks passed
@github-actions github-actions Bot added this to the 2.0.next milestone Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

linked-issue PR references an issue

Development

Successfully merging this pull request may close these issues.

DataSource.initialize() leaks connections on concurrent calls and on cache connection failure

5 participants