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

fix(whatsmeow): reuse a single capped sqlstore container (fixes Postgres connection leak)#117
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.

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.

2 participants