Skip to content

fix(whatsmeow): reuse a single capped sqlstore container (fixes Postgres connection leak) - #117

Open
guilhermeCassettari wants to merge 3 commits into
evolution-foundation:developfrom
guilhermeCassettari:fix/whatsmeow-shared-pool-leak-develop
Open

guilhermeCassettari wants to merge 3 commits into
evolution-foundation:developfrom
guilhermeCassettari:fix/whatsmeow-shared-pool-leak-develop

Conversation

@guilhermeCassettari

@guilhermeCassettari guilhermeCassettari commented Jul 15, 2026

Copy link
Copy Markdown

Description

StartClient() called sqlstore.New() on every connect and every reconnect. Each call opens a fresh *sql.DB with unbounded defaults
(MaxOpenConns=0, ConnMaxLifetime=0) and the resulting *sqlstore.Container is never closed. Instances stuck in QR-expiry/reconnect loops leak one
connection pool per attempt until Postgres hits FATAL: sorry, too many clients already, after which no instance can connect.

This PR supersedes #102 by @Ay0rus, which targeted main — the same fix is rebased onto develop (applied cleanly, original authorship preserved in the
first commit), plus a hardening of the initialization path:

  • Commit 1 (@Ay0rus): shared, lazily-created sqlstore.Container with a capped pool (Postgres: 20 max open / 5 idle / 5min lifetime / 2min idle;
    SQLite: single connection), reused across all StartClient() calls — whatsmeow's container is designed to hold multiple devices.
  • Commit 2: replaces the original sync.Once with a mutex that memoizes only success. With sync.Once, a Postgres hiccup during the first
    StartClient cached the error forever, leaving the service unable to connect until a process restart. Now failures retry on the next call.

Reproduced locally before the fix: 15 calls to /instance/reconnect took evogo_auth from 1 to 16 connections, growing unbounded afterwards (reached 32+
within minutes via the internal QR-expiry restart loop). With the fix, the same procedure stays at 1-2 connections.

Related Issue

Closes #106, closes #109, closes #112

Supersedes #102 (same fix, retargeted at develop as requested, original commit authorship preserved)

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement

Testing

  • Manual testing completed
  • Functionality verified in development environment
  • No breaking changes introduced

Includes a self-contained regression test (auth_container_retry_test.go) using the SQLite path — no external services required, verified in a clean
golang:1.25 container. It asserts that (1) a failed container creation is not cached, (2) the next call retries and succeeds, and (3) subsequent calls
return the same shared container instance.

Manual verification against a local Postgres:

Scenario Connections on evogo_auth
Before fix — 15× /instance/reconnect 1 → 16 (unbounded growth afterwards)
After fix — same procedure stays at 1-2

Screenshots (if applicable)

N/A

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have tested my changes thoroughly
  • Any dependent changes have been merged and published

Additional Notes

Summary by Sourcery

Reuse a single shared, capped SQL auth container for all WhatsApp client starts to eliminate database connection leaks and improve connection handling reliability.

Bug Fixes:

  • Prevent Postgres connection pool leaks by avoiding creation of a new unbounded sqlstore container on every client start and reconnect.
  • Ensure transient database initialization failures do not permanently poison the auth container initialization so later calls can recover.

Enhancements:

  • Cap Postgres connection pool sizes and lifetimes for the shared auth container and restrict SQLite to a single connection for more predictable resource usage.

Tests:

  • Add a SQLite-based regression test verifying that auth container creation retries after failure and that successful initialization reuses the same shared container instance.

Ay0rus and others added 2 commits July 15, 2026 10:08
…ne per StartClient

StartClient() called sqlstore.New() on every connect AND every reconnect, each
opening a brand-new *sql.DB with MaxOpenConns=0 (unlimited) and ConnMaxLifetime=0
(never expires), and the container was never closed. With instances reconnecting
in a loop (QR expiry, network blips, CONNECT_ON_STARTUP) this leaks one connection
pool per attempt and eventually exhausts Postgres ("sorry, too many clients
already").

Create the auth-store container once (sync.Once) with a bounded pool via
sqlstore.NewWithDB(), and reuse it across all clients — whatsmeow's container is
designed to hold multiple devices. Direct DB connection, capped pool.
…aching the error

  sync.Once memorized the first failure forever: a Postgres hiccup at the
  first StartClient left every future connect returning the same stale
  error until process restart. Replace it with a mutex that only memoizes
  success — failures retry on the next call. Adds a self-contained
  regression test using the sqlite path (no external services required).
@sourcery-ai

sourcery-ai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR fixes a Postgres connection leak in the whatsmeow service by introducing a single, shared, capped sqlstore container that is lazily initialized and reused across all StartClient calls, and by ensuring only successful initializations are memoized; it also adds a regression test to validate retry and reuse semantics using the SQLite path.

Sequence diagram for shared capped sqlstore container initialization and reuse

sequenceDiagram
    participant WhatsmeowService as whatsmeowService
    participant StartClient as StartClient
    participant getAuthContainer as getAuthContainer
    participant DB as sql.DB
    participant Container as sqlstore.Container

    WhatsmeowService->>StartClient: StartClient(cd)
    StartClient->>getAuthContainer: getAuthContainer()
    activate getAuthContainer
    alt sharedAuthContainer already initialized
        getAuthContainer-->>StartClient: sharedAuthContainer
    else sharedAuthContainer is nil
        getAuthContainer->>DB: sql.Open(dialect, address)
        alt sql.Open error
            getAuthContainer-->>StartClient: error
        else sql.Open success
            alt dialect == postgres
                getAuthContainer->>DB: SetMaxOpenConns(20)
                getAuthContainer->>DB: SetMaxIdleConns(5)
                getAuthContainer->>DB: SetConnMaxLifetime(5min)
                getAuthContainer->>DB: SetConnMaxIdleTime(2min)
            else dialect == sqlite
                getAuthContainer->>DB: SetMaxOpenConns(1)
            end
            getAuthContainer->>Container: NewWithDB(DB, dialect, dbLog)
            getAuthContainer->>Container: Upgrade(context.Background())
            alt Upgrade error
                getAuthContainer->>DB: Close()
                getAuthContainer-->>StartClient: error
            else Upgrade success
                getAuthContainer-->>StartClient: sharedAuthContainer
            end
        end
    end
    deactivate getAuthContainer

    alt container returned
        StartClient->>Container: (reuse for device auth)
    else error returned
        StartClient->>WhatsmeowService: log "Failed to get auth container"
    end
Loading

File-Level Changes

Change Details Files
Introduce a lazily initialized, shared sqlstore container with capped connection pool reused across all StartClient calls instead of creating a new container per call.
  • Add sharedAuthContainer singleton and sharedAuthContainerMu mutex to guard access to the shared container.
  • Implement getAuthContainer method that selects dialect (Postgres vs SQLite), opens the database, configures connection pool limits, constructs a sqlstore.Container via NewWithDB, runs Upgrade, and memoizes the container only on success.
  • Wire StartClient to obtain the container via getAuthContainer instead of inlining sqlstore.New calls, and adjust error logging message accordingly.
pkg/whatsmeow/service/whatsmeow.go
Add regression test to verify that failed container creation is not cached and successful creation is shared across calls using the SQLite storage path.
  • Reset sharedAuthContainer state at test start to ensure isolation.
  • Simulate a failed container creation by pointing exPath to a non-existent directory and assert that getAuthContainer returns an error.
  • Simulate a successful initialization with a valid dbdata directory, assert that a subsequent call after failure succeeds, and confirm that multiple calls return the same container instance.
pkg/whatsmeow/service/auth_container_retry_test.go

Possibly linked issues

  • #: PR fixes the exact sqlstore connection-pool leak described, reusing a single capped container across reconnects.
  • #: PR changes whatsmeow to use a shared, capped sqlstore container, directly fixing the reported Postgres connection leak.
  • #0: PR changes whatsmeow to reuse a single capped auth sqlstore container, eliminating leaked Postgres connections during reconnects.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • The sharedAuthContainer singleton is initialized from whatever whatsmeowService instance happens to call getAuthContainer first, so if different instances have different config (e.g., WaDebug or PostgresAuthDB) the later ones silently reuse a container built with mismatched settings; consider making the singleton initialization config-independent or enforcing a single, well-defined source of configuration.
  • auth_container_retry_test mutates the package-level sharedAuthContainer state directly, which can create hidden coupling with other tests in this package; it would be safer to provide a reset helper or to structure getAuthContainer so its shared state can be injected/isolated for testing.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The sharedAuthContainer singleton is initialized from whatever whatsmeowService instance happens to call getAuthContainer first, so if different instances have different config (e.g., WaDebug or PostgresAuthDB) the later ones silently reuse a container built with mismatched settings; consider making the singleton initialization config-independent or enforcing a single, well-defined source of configuration.
- auth_container_retry_test mutates the package-level sharedAuthContainer state directly, which can create hidden coupling with other tests in this package; it would be safer to provide a reset helper or to structure getAuthContainer so its shared state can be injected/isolated for testing.

## Individual Comments

### Comment 1
<location path="pkg/whatsmeow/service/auth_container_retry_test.go" line_range="23-31" />
<code_context>
+
+	// Falha: exPath aponta pra diretório inexistente — o SQLite não cria
+	// diretórios intermediários, então o Upgrade falha na primeira conexão.
+	bad := whatsmeowService{
+		config: &config.Config{},
+		exPath: filepath.Join(t.TempDir(), "does-not-exist"),
+	}
+	if _, err := bad.getAuthContainer(); err == nil {
+		t.Fatal("esperava erro com diretório inexistente, veio nil")
+	}
</code_context>
<issue_to_address>
**suggestion (testing):** Assert that a failed getAuthContainer call does not populate the sharedAuthContainer singleton

Since this test is guarding the memoization bug, consider also asserting that `sharedAuthContainer` remains `nil` after the failed `getAuthContainer` call, e.g. `if sharedAuthContainer != nil { t.Fatalf("failed container should not be memoized") }`. That way the test explicitly verifies that only successful calls are cached.

```suggestion
	// Falha: exPath aponta pra diretório inexistente — o SQLite não cria
	// diretórios intermediários, então o Upgrade falha na primeira conexão.
	bad := whatsmeowService{
		config: &config.Config{},
		exPath: filepath.Join(t.TempDir(), "does-not-exist"),
	}
	if _, err := bad.getAuthContainer(); err == nil {
		t.Fatal("esperava erro com diretório inexistente, veio nil")
	}
	// Falha não deve ser memorizada no singleton
	sharedAuthContainerMu.Lock()
	if sharedAuthContainer != nil {
		sharedAuthContainerMu.Unlock()
		t.Fatalf("failed container should not be memoized (sharedAuthContainer = %#v)", sharedAuthContainer)
	}
	sharedAuthContainerMu.Unlock()
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread pkg/whatsmeow/service/auth_container_retry_test.go
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
@guilhermeCassettari

Copy link
Copy Markdown
Author
  • The sharedAuthContainer singleton is initialized from whatever whatsmeowService instance happens to call getAuthContainer first, so if different instances have different config (e.g., WaDebug or PostgresAuthDB) the later ones silently reuse a container built with mismatched settings; consider making the singleton initialization config-independent or enforcing a single, well-defined source of configuration.

Good catch in theory, but in practice NewWhatsmeowService is called exactly once in main.go, so the singleton is always built from the single process-wide config — same behavior as the original #102 patch. Making the initialization config-independent feels like a broader refactor than this leak fix should carry; happy to open a follow-up issue if the maintainers want it.

@guilhermeCassettari

Copy link
Copy Markdown
Author
  • auth_container_retry_test mutates the package-level sharedAuthContainer state directly, which can create hidden coupling with other tests in this package; it would be safer to provide a reset helper or to structure getAuthContainer so its shared state can be injected/isolated for testing.

The test already resets the singleton at the start (under the mutex), and with the inline suggestion applied it now asserts failures aren't memoized. I'd prefer to keep the fix minimal, but happy to extract a reset helper in a follow-up if preferred.

@Clalber

Clalber commented Jul 23, 2026

Copy link
Copy Markdown

I tested these commits on top of an Evolution Go 0.7.2-based production
branch. The pool regression test currently needs one small import update before
it can run against the repository's current module path:

- "github.com/EvolutionAPI/evolution-go/pkg/config"
+ "github.com/evolution-foundation/evolution-go/pkg/config"

Without that change, go test ./pkg/whatsmeow/service fails with “no required
module provides package”. With the import corrected, the package test passes.

The shared/capped auth container patch itself applied cleanly to our 0.7.2
branch. We are proceeding with a staged production validation and will report
the observed PostgreSQL connection count after deployment.

ecosb2b pushed a commit to ecosb2b/evo-go-v2 that referenced this pull request Aug 5, 2026
Merges the Athene fork, which sits 11 commits ahead of the same upstream base
and had already solved — in production — the three problems this fork was
working through, plus a batch of upstream PRs we had not picked up.

Only pkg/whatsmeow/service/whatsmeow.go conflicted (6 hunks); the other 63 files
merged cleanly. Every conflict was resolved in favour of the incoming version,
because each one was a place where both forks fixed the same bug and theirs is
the one we chose to keep:

- Connection leak: their getAuthContainer/sharedAuthContainer replaces our
  storeContainer/storeContainerHolder. Same fix, but theirs is a backport of
  upstream PR evolution-foundation#117, so it will converge with upstream instead of conflicting
  when that PR merges. It also caps SQLite at MaxOpenConns(1), which ours did
  not.
- Reconnection: their runtime supervisor replaces our EnableAutoReconnect=true.
  Ours stopped the automatic disconnect loop but left ReconnectClient reachable
  from the API endpoint and the send retry, so those paths could still spawn a
  second client. Their runtime token refuses to start a second runtime for an
  instance from any path, waits for the previous goroutine to exit, deduplicates
  concurrent reconnects, backs off exponentially with jitter, and probes to
  confirm the reconnect actually connected.
- Shared maps: ClientMapsMu closes the "fatal error: concurrent map writes"
  hole, which was still open on our side and kills the whole process rather than
  panicking a single request.

Also arriving with the merge: upstream PRs evolution-foundation#149, evolution-foundation#135, evolution-foundation#34, evolution-foundation#100, evolution-foundation#143, evolution-foundation#137,
evolution-foundation#120, evolution-foundation#130, evolution-foundation#151 and evolution-foundation#122; multi-webhook fan-out; POST /send/product;
POST /user/savecontact; GET /server/stats and the dashboard; the whatsmeow
update to 20260721; and removal of the 61 MB compiled binary that was tracked in
git.

Our GHCR workflow did not conflict and is preserved.

store_container_test.go was adapted to the incoming implementation: it now
exercises getAuthContainer, asserts against their MaxOpenConns of 20, and resets
sharedAuthContainer between tests, since that container is a package-level
global and would otherwise carry between tests and invalidate the pg_stat_activity
measurements. It is kept alongside their auth_container_retry_test.go because it
measures something theirs does not: the connection count the server actually
sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dev-guidolin

Copy link
Copy Markdown

We hit this exact leak in production on v0.7.2: the engine opened a new uncapped connection per operation until Postgres returned FATAL: sorry, too many clients already (100/100, all from evolution-go, ~50 leaked connections within 36 minutes under normal message traffic). Once the pool was exhausted, instances that dropped their websocket could not reconnect — all our instances stayed down for ~20h.

We applied this patch on top of v0.7.2 and have been running it in production since 2026-08-18: connection count is now stable at 4–7 with zero leaked idle connections. Confirms the fix works. Would be great to see this merged and released.

🤖 Generated with Claude Code

@pastoriniMatheus

pastoriniMatheus commented Sep 15, 2026

Copy link
Copy Markdown

Triage notes from a contributor doing a pass over the open PRs on this leak (no write access here, so this is input for whoever has the merge button, not a decision). Of the 10 open PRs addressing it, this is the one worth converging on — it is the only one that ships a test, and @dev-guidolin's production report above is the only field validation any of them has. Two things below, plus one correction to the record.

1. There is no import blocker on this PR

Our tracker claimed this PR was blocked because the new test imports github.com/EvolutionAPI/evolution-go/pkg/config while go.mod declares the evolution-foundation module. That is wrong for this PR and I want it on the record before anyone "fixes" it.

This PR targets develop, and develop's go.mod declares:

module github.com/EvolutionAPI/evolution-go

So the import matches. Measured on develop @ 706c9a4 with the patch applied:

$ go vet ./pkg/whatsmeow/...
$ echo $?
0

@Clalber's comment is correct in its own context and should not be read as contradicting this: they tested "on top of an Evolution Go 0.7.2-based production branch", and a 0.7.2-based branch does use the evolution-foundation module path. The import change is required there, not here. Please do not rewrite the import in this PR — it would break the build on the branch this PR actually targets.

2. The new test fails on every run on Windows

auth_container_retry_test.go asserts correctly, but the test binary fails during teardown: the sqlite *sql.DB stays open inside sharedAuthContainer, so t.TempDir()'s RemoveAll cannot delete the directory (unlinkat: file in use). Linux runners pass; Windows contributors see a red test that has nothing to do with their change.

One line, right after the if c1 == nil check:

t.Cleanup(func() { _ = c1.Close() })

Measured 3/3 green with it (0.337s / 0.293s / 0.621s). t.Cleanup is the right tool rather than closing at the end of the function body: cleanups run LIFO and t.TempDir() registers its own RemoveAll before this one, so the close runs first — and unlike a deferred close in the body, it still runs on the t.Fatal paths, which is exactly where someone would be reading the output.

No need to reset the package-level sharedAuthContainer afterwards — the test already zeroes it in its prologue and it is the only test in the package.

3. Postgres branch: reuse the pool instead of opening a second one

In the postgres branch, consider swapping the new sql.Open for the pool that is already open and already capped in cmd/evolution-go/main.go:304-312:

container := sqlstore.NewWithDB(w.authDB, "postgres", dbLog)

As it stands, this PR opens a second pool against the same DSN, which puts the ceiling at 25 + 20 = 45 connections against evogo_auth, and introduces a third set of pool numbers (the repo already uses 25/5/5min/1min in three places). This is effectively the postgres branch of #178 inside this PR's mutex + memoize-only-on-success structure, which is the better of the two — #178 runs Upgrade() on every StartClient, this one runs it once.

It also keeps this fix correct when develop moves to upstream whatsmeow: reusing the caller's *sql.DB is the pattern upstream asked for when it rejected the fork's pool patch.

The sqlite branch keeping its own *sql.DB with MaxOpenConns(1) is fine — w.sqliteDB points at users.db while the container uses dbdata/main.db, and we verified it does not deadlock. Worth a short comment in the code saying the cap is deliberate, since it is a new throughput ceiling that has not been measured.

Not in scope for this PR

StartClient writes w.clientPointer[cd.Instance.Id] = client without a lock and is always called as a goroutine (3 call sites). That is a concurrent map write — a fatal, unrecoverable error that takes down every instance in the process — and it has the same trigger as this leak: a reconnection storm. It already exists on develop and is not introduced here, so it gets its own issue. Flagging it so it does not get lost.

One nit on the description

The PR body says the pool is unbounded (MaxOpenConns=0). On today's develop that is not quite true: the pinned whatsmeow-lib fork carries a local patch capping postgres at 25/5/5min/1min, so what saturates the server is the number of leaked pools rather than one unbounded pool. On main (v0.7.2) the fork is gone and upstream whatsmeow caps nothing, so the description is accurate there. Not blocking — worth a one-line note so the distinction survives into the changelog.

@yurifoxx

Copy link
Copy Markdown

Another production confirmation on evoapicloud/evolution-go:0.7.2, with one data point that I think helps scope the fix: the leak is proportional to the number of client starts, not to uptime.

Setup: 5 instances, single Postgres (max_connections=100), POSTGRES_AUTH_DB set.

What we observed:

  1. One instance lost its device row (whatsmeow_device) and went into the QR loop: QR timeout — signaling kill channelLoggedOut → the unconditional w.StartClient(cd) in the kill-channel branch → new sqlstore.New(...) container, roughly every 40s.
  2. We also run an external watchdog that POST /instance/reconnects any instance reporting connected:false every 2 minutes. Each call goes ReconnectClientStartInstanceStartClient → yet another container.
  3. Within ~15h the auth DB was pinned at 100/100 with 98 idle connections, all from the API container. At that point even a superuser psql was refused, so the service could no longer read device rows: GetDevice fails → No store found. Creating new one → the instance asks for a QR → another restart. That feedback loop is what turns a single unpaired instance into a full outage: a second, healthy instance on the same account started flapping too, and its session was eventually invalidated for real (needed a QR re-pair).

The control case is what convinced me this PR is targeting the right place. After a container restart plus one manual /instance/reconnect per instance, with the watchdog patched to skip any instance that has a qrcode (i.e. no recoverable session, so reconnecting only spawns containers), the same deployment ran 16h with zero reconnects and stayed flat at 6 connections. No pooler, no code change — just no repeated StartClient.

So as a workaround for anyone hitting this before a fix lands: never let an unpaired instance sit in a reconnect loop. idle_session_timeout on the auth DB helps (it recycles the orphaned pools) but does not cure it, since a fast enough loop outruns the timeout.

Two notes on the fix itself, from this angle:

  • Sharing one capped container addresses all three entry paths at once (ReconnectClient, /instance/connect, and the internal kill-channel restart), which matches what we saw — the internal restart path alone was enough to exhaust the pool, with no external API calls involved.
  • Independently of pooling, the kill-channel branch restarting unconditionally means an instance with no valid session restarts forever. Capping the pool makes that survivable rather than fatal, which is the right call, but the restart loop itself may deserve a backoff in a follow-up.

Happy to test a build of this branch against the same workload if that is useful.

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.

6 participants