From 78f16fcb69f1e49e6f65babff7608e29fbc6d22f Mon Sep 17 00:00:00 2001 From: Elimeshi1 Date: Wed, 5 Aug 2026 19:13:17 +0300 Subject: [PATCH] Fix large status@broadcast sends timing out Sending a status to a large contact list failed with "timed out waiting for message send response". Shrinking the participants batch from 5000 to 500 did not fix it: it multiplied the number of stanzas by ten and traded the timeouts for "server returned error 429". There were two independent causes. The per-batch ack window. whatsmeow defaults to 75s, which a big status batch can exceed even when it is delivered, so the send is reported as a timeout. Batch size was never the problem: what trips WhatsApp's rate limiting is the number of stanzas, not the participants inside one. Set an explicit timeout (default 180s, WAHA_GOWS_STATUS_BATCH_TIMEOUT) and restore the 5000 default. Database lock contention. The sqlite device store was opened in the default rollback-journal mode, where one writer holds an exclusive lock. During a status broadcast the outgoing send and the flood of incoming decryptions (session/identity/sender-key writes) contend for it, exhaust the 30s busy_timeout and surface as "database is locked" - turning a ~4 minute send into a ~30 minute stall ending in a gRPC DEADLINE_EXCEEDED. Open it with WAL + synchronous=NORMAL + txlock=immediate, mirroring the GContainer store. Also detach the status send from the caller's context. A deadline on the caller side was aborting a send already live on WhatsApp, leaving the later batches undelivered while the caller saw a plain failure and re-sent the whole status - posting it two or three times. Fixes devlikeapro/waha#2096 --- src/env.go | 13 +++++++++++-- src/gows/gows.go | 9 ++++++++- src/gows/manager.go | 13 +++++++++++-- src/gows/status.go | 6 ++++++ src/main.go | 2 ++ src/server/session.go | 12 +++++++++++- 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/env.go b/src/env.go index f3c82b9..9fd4e91 100644 --- a/src/env.go +++ b/src/env.go @@ -24,8 +24,17 @@ func getClientConfig() ClientConfig { type StatusConfig struct { // ParticipantsBatchSize controls how many contacts are included per batch // when sending a status/story to status@broadcast. - // Lowered from 5000 to 500 to avoid gRPC batch timeout errors on large contact lists. - ParticipantsBatchSize int `env:"WAHA_GOWS_STATUS_PARTICIPANTS_BATCH_SIZE" envDefault:"500"` + // Kept large on purpose: what trips WhatsApp's rate limiting is the number of + // stanzas, not the number of participants inside one. Smaller batches turned + // the ack timeouts into "server returned error 429" instead of fixing them. + // Slow acks on a big batch are handled by BatchTimeout below. + ParticipantsBatchSize int `env:"WAHA_GOWS_STATUS_PARTICIPANTS_BATCH_SIZE" envDefault:"5000"` + // BatchTimeout bounds how long we wait for the server to acknowledge one + // batch. whatsmeow defaults to 75s, which is not enough for a large status + // batch and shows up as "timed out waiting for message send response" even + // though the batch is delivered. + // Go duration format: 90s, 180s, 5m. + BatchTimeout time.Duration `env:"WAHA_GOWS_STATUS_BATCH_TIMEOUT" envDefault:"180s"` } func getStatusConfig() StatusConfig { diff --git a/src/gows/gows.go b/src/gows/gows.go index e1e0c44..5081614 100644 --- a/src/gows/gows.go +++ b/src/gows/gows.go @@ -432,7 +432,14 @@ func (gows *GoWS) SendMessage(ctx context.Context, to types.JID, msg *waE2E.Mess if to.User == "status" && to.Server == types.BroadcastServer { // Broadcast messages (Status) - result, err := gows.SendStatusMessage(ctx, to, msg, extra) + // + // A status to a large audience can outlast the caller's deadline. Detach + // from its cancellation so a client-side timeout does not abort a send that + // is already live on WhatsApp: aborting it mid-batch leaves the remaining + // batches undelivered while the caller sees a plain failure and re-sends + // the whole status, posting it more than once. + sendCtx := context.WithoutCancel(ctx) + result, err := gows.SendStatusMessage(sendCtx, to, msg, extra) if err != nil { return nil, err } diff --git a/src/gows/manager.go b/src/gows/manager.go index d2488b9..e466398 100644 --- a/src/gows/manager.go +++ b/src/gows/manager.go @@ -85,13 +85,22 @@ func SetDeviceAndBrowser(device string, browser string) { } // statusParticipantsBatchSize is the number of contacts per batch when sending to status@broadcast. -// Set at startup via SetStatusParticipantsBatchSize; defaults to 500. -var statusParticipantsBatchSize = 500 +// Set at startup via SetStatusParticipantsBatchSize; defaults to 5000. +var statusParticipantsBatchSize = 5000 func SetStatusParticipantsBatchSize(n int) { statusParticipantsBatchSize = n } +// statusBatchTimeout is how long to wait for the server to ack one status batch. +// Set at startup via SetStatusBatchTimeout; defaults to 180s. Zero leaves +// whatsmeow's own default (75s) in place. +var statusBatchTimeout = 180 * time.Second + +func SetStatusBatchTimeout(d time.Duration) { + statusBatchTimeout = d +} + // SetKeepAliveInterval overrides whatsmeow's websocket keepalive ping interval. // Returns the resulting min/max so the caller can log what is in effect. func SetKeepAliveInterval(min time.Duration, max time.Duration) (time.Duration, time.Duration) { diff --git a/src/gows/status.go b/src/gows/status.go index 74180d5..9ea4ef6 100644 --- a/src/gows/status.go +++ b/src/gows/status.go @@ -56,6 +56,12 @@ func (gows *GoWS) SendStatusMessage(ctx context.Context, to types.JID, msg *waE2 for index, participants := range batches { batchExtra := extra batchExtra.Participants = participants + // Give the server room to acknowledge a large batch. Without this the + // whatsmeow default (75s) applies, and a batch that is actually delivered + // is reported as "timed out waiting for message send response". + if statusBatchTimeout > 0 { + batchExtra.Timeout = statusBatchTimeout + } _, err := gows.Client.SendMessage(ctx, to, msg, batchExtra) if err != nil { diff --git a/src/main.go b/src/main.go index 4680960..b88944a 100644 --- a/src/main.go +++ b/src/main.go @@ -138,6 +138,8 @@ func main() { statusCfg := getStatusConfig() log.Infof("Status broadcast participants batch size: %d", statusCfg.ParticipantsBatchSize) gows.SetStatusParticipantsBatchSize(statusCfg.ParticipantsBatchSize) + log.Infof("Status broadcast batch ack timeout: %s", statusCfg.BatchTimeout) + gows.SetStatusBatchTimeout(statusCfg.BatchTimeout) linkPreviewCfg := getLinkPreviewConfig() log.Infof("Link preview fetch timeout: %s", linkPreviewCfg.FetchTimeout) diff --git a/src/server/session.go b/src/server/session.go index da01aad..680b325 100644 --- a/src/server/session.go +++ b/src/server/session.go @@ -30,7 +30,17 @@ func (s *Server) StartSession(ctx context.Context, req *__.StartSessionRequest) case dialect == "sqlite3" || dialect == "sqlite": // busy_timeout to prevent "database is locked" errors // DO NOT add cache=shared, it's not safe - address = req.Config.Store.Address + "?_foreign_keys=on&_busy_timeout=30000" + // + // WAL is required here, not optional. In the default rollback-journal mode a + // single writer takes an exclusive lock that blocks every other reader and + // writer. During a status@broadcast the outgoing send and the flood of + // incoming decryptions (each a session/identity/sender-key write) contend for + // that one lock, exhaust the busy_timeout above and surface as "database is + // locked". WAL lets one writer proceed alongside readers, and + // _txlock=immediate takes the write lock up front so concurrent writers back + // off cleanly instead of deadlocking mid-transaction. + // (Mirrors the PRAGMAs already applied to the GContainer store.) + address = req.Config.Store.Address + "?_foreign_keys=on&_busy_timeout=30000&_journal_mode=WAL&_synchronous=NORMAL&_txlock=immediate" case dialect == "postgres": address = addApplicationName(req.Config.Store.Address, "GOWS") default: