From f5892fdb14cb1f631f414a708b82685da8527d6e Mon Sep 17 00:00:00 2001 From: wellpelomundo Date: Fri, 24 Jul 2026 14:33:10 -0300 Subject: [PATCH] fix(whatsmeow): reuse shared sqlstore.Container instead of recreating it on every reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StartClient() called sqlstore.New(...) fresh on every reconnection attempt (both the internal auto-reconnect "attempt 1/2, 2/2" retry and any external POST /instance/connect), opening a brand new Postgres connection pool each time via sql.Open. The previous container was never closed (no container.Close() call anywhere in the file), so every reconnect cycle left a whole pool (up to 25 connections, per the limits sqlstore.New already sets) orphaned against Postgres. Under frequent reconnection this exhausts max_connections even with a high limit configured, and restarting only the app (not Postgres) doesn't clear it since the leaked connections live on the database side. Cache a single shared *sqlstore.Container (same PostgresAuthDB/DSN for every instance, so one container per process is correct, not one per instance) in a new containerPointer map, created lazily on first use and reused by every subsequent StartClient() call. Uses the same map-as-reference-type pattern already established by clientPointer, so it works correctly even though StartClient (like most methods here) uses a value receiver. Guarded by a *sync.Mutex (pointer, not embedded value — go vet correctly flags an embedded sync.Mutex here as "passes lock by value" given the value-receiver pattern). Verified: `go build ./pkg/whatsmeow/...` and `go vet ./pkg/whatsmeow/...` both clean. Not able to run the full binary/integration build locally (unrelated CGO failure in github.com/chai2010/webp on this machine), so only the package containing the change was verified to compile — happy to run any additional checks the maintainers want. Observed in production (self-hosted, Postgres 16): this same `too many clients already` exhaustion pattern recurred 3 times over two weeks (max_connections raised 60->200 after the first occurrence, which only delayed recurrence). Support recommended idle_session_timeout + periodic restarts as an operational mitigation, which we've applied, but this fix addresses the actual leak at its source. --- pkg/whatsmeow/service/whatsmeow.go | 105 +++++++++++++++++++---------- 1 file changed, 69 insertions(+), 36 deletions(-) diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index 78ec6c17..e3ad894c 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -69,6 +69,11 @@ type clientVersion struct { Patch int } +// sharedContainerKey é a única chave usada em containerPointer — o +// container do sqlstore é compartilhado entre TODAS as instâncias (mesmo +// PostgresAuthDB/DSN pra todas), então não faz sentido um por instância. +const sharedContainerKey = "shared" + type whatsmeowService struct { instanceRepository instance_repository.InstanceRepository authDB *sql.DB @@ -80,15 +85,23 @@ type whatsmeowService struct { userInfoCache *cache.Cache clientPointer map[string]*whatsmeow.Client myClientPointer map[string]*MyClient - rabbitmqProducer producer_interfaces.Producer - webhookProducer producer_interfaces.Producer - websocketProducer producer_interfaces.Producer - sqliteDB *sql.DB - exPath string - mediaStorage storage_interfaces.MediaStorage - processedMessages *cache.Cache - natsProducer producer_interfaces.Producer - loggerWrapper *logger_wrapper.LoggerManager + containerPointer map[string]*sqlstore.Container + // Ponteiro, não valor: StartClient (e a maioria dos métodos deste + // struct) usa value receiver, então um sync.Mutex embutido por valor + // seria copiado a cada chamada e perderia a exclusão mútua entre + // goroutines concorrentes (apontado pelo `go vet`: "passes lock by + // value"). Um ponteiro continua apontando pro mesmo mutex mesmo + // depois da cópia — mesmo padrão já usado por authDB (*sql.DB) etc. + containerInitMu *sync.Mutex + rabbitmqProducer producer_interfaces.Producer + webhookProducer producer_interfaces.Producer + websocketProducer producer_interfaces.Producer + sqliteDB *sql.DB + exPath string + mediaStorage storage_interfaces.MediaStorage + processedMessages *cache.Cache + natsProducer producer_interfaces.Producer + loggerWrapper *logger_wrapper.LoggerManager } type MyClient struct { @@ -292,23 +305,41 @@ func (w whatsmeowService) StartClient(cd *ClientData) { } } - var container *sqlstore.Container - - if w.config.WaDebug != "" { - dbLog := waLog.Stdout("Database", w.config.WaDebug, true) - if w.config.PostgresAuthDB != "" { - container, err = sqlstore.New(context.Background(), "postgres", w.config.PostgresAuthDB, dbLog) - } else { - dsn := fmt.Sprintf("file:%s/dbdata/main.db?_pragma=foreign_keys(1)&_busy_timeout=5000&cache=shared&mode=rwc&_journal_mode=WAL", w.exPath) - container, err = sqlstore.New(context.Background(), "sqlite", dsn, dbLog) - } - } else { - if w.config.PostgresAuthDB != "" { - container, err = sqlstore.New(context.Background(), "postgres", w.config.PostgresAuthDB, nil) - } else { - dsn := fmt.Sprintf("file:%s/dbdata/main.db?_pragma=foreign_keys(1)&_busy_timeout=5000&cache=shared&mode=rwc&_journal_mode=WAL", w.exPath) - container, err = sqlstore.New(context.Background(), "sqlite", dsn, nil) + // Reusa um único *sqlstore.Container compartilhado entre todas as + // instâncias e todas as tentativas de reconexão, em vez de abrir um + // container/pool novo a cada chamada de StartClient. Antes, toda + // reconexão (inclusive o auto-reconnect interno "attempt 1/2, 2/2") + // chamava sqlstore.New(...) de novo, que abre um *sql.DB novo via + // sql.Open — o container anterior nunca era fechado (nenhum + // container.Close() em todo o arquivo), então cada ciclo de + // reconexão deixava um pool inteiro (até 25 conexões, pelo limite + // que o próprio sqlstore.New já configura) pendurado no Postgres. + // Com uso frequente de reconexão isso esgota max_connections mesmo + // com o limite alto. Container é seguro pra reusar entre instâncias: + // é o mesmo banco/DSN (PostgresAuthDB) pra todas, e o cache abaixo + // funciona corretamente mesmo com StartClient usando value receiver + // porque containerPointer é um map (tipo referência), igual ao + // clientPointer já existente. + container := w.containerPointer[sharedContainerKey] + if container == nil { + w.containerInitMu.Lock() + container = w.containerPointer[sharedContainerKey] + if container == nil { + var dbLog waLog.Logger + if w.config.WaDebug != "" { + dbLog = waLog.Stdout("Database", w.config.WaDebug, true) + } + if w.config.PostgresAuthDB != "" { + container, err = sqlstore.New(context.Background(), "postgres", w.config.PostgresAuthDB, dbLog) + } else { + dsn := fmt.Sprintf("file:%s/dbdata/main.db?_pragma=foreign_keys(1)&_busy_timeout=5000&cache=shared&mode=rwc&_journal_mode=WAL", w.exPath) + container, err = sqlstore.New(context.Background(), "sqlite", dsn, dbLog) + } + if err == nil { + w.containerPointer[sharedContainerKey] = container + } } + w.containerInitMu.Unlock() } if err != nil { @@ -1577,17 +1608,17 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { buttonClickMap := map[string]interface{}{ "event": "ButtonClick", "data": map[string]interface{}{ - "buttonId": buttonClickData["buttonId"], - "buttonText": buttonClickData["buttonText"], - "type": buttonClickData["type"], - "phone": dataMap["Sender"], - "jid": dataMap["Sender"], - "pushName": dataMap["PushName"], - "messageId": dataMap["ID"], - "chat": dataMap["Chat"], - "fromMe": dataMap["FromMe"], - "timestamp": evt.Info.Timestamp.Unix(), - "extraData": buttonClickData, + "buttonId": buttonClickData["buttonId"], + "buttonText": buttonClickData["buttonText"], + "type": buttonClickData["type"], + "phone": dataMap["Sender"], + "jid": dataMap["Sender"], + "pushName": dataMap["PushName"], + "messageId": dataMap["ID"], + "chat": dataMap["Chat"], + "fromMe": dataMap["FromMe"], + "timestamp": evt.Info.Timestamp.Unix(), + "extraData": buttonClickData, }, "instanceToken": mycli.token, "instanceId": mycli.userID, @@ -2677,6 +2708,8 @@ func NewWhatsmeowService( userInfoCache: cache.New(5*time.Minute, 10*time.Minute), clientPointer: clientPointer, myClientPointer: make(map[string]*MyClient), + containerPointer: make(map[string]*sqlstore.Container), + containerInitMu: &sync.Mutex{}, rabbitmqProducer: rabbitmqProducer, webhookProducer: webhookProducer, websocketProducer: websocketProducer,