Issue Description
When Redis is used as Stalwart’s in-memory store, a task lock abandoned by a
terminated worker may never expire while other task-manager instances continue
trying to acquire it.
Redis lock acquisition increments the lock key and grants the lock only when the
returned counter is 1. The same atomic Redis pipeline also executes EXPIRE
on every acquisition attempt. Consequently, a failed contender that receives
counter value 2 or greater still resets the lock TTL.
In a multi-node deployment, staggered task-manager retries can therefore keep an
abandoned lock alive indefinitely. The corresponding tasks remain pending even
though no worker can acquire them.
This is reproducible in the current release, v0.16.15
Expected Behavior
- A failed lock contender must not change the existing owner’s lease expiry.
- If a worker terminates without releasing its lock, the lock must expire at
the deadline established by the successful acquisition. - After expiry, another task manager must be able to acquire and process the
still-pending task.
Actual Behavior
- Every failed Redis acquisition increments the key and resets its TTL.
- Abandoned task locks can remain alive indefinitely in a multi-replica
deployment. - Restarting task processors can produce more failed acquisitions and renewed
TTLs rather than recovering the tasks. - Pending maintenance and account tasks remain blocked until task processing is
stopped long enough for expiry or the locks are cleared operationally.
Reproduction Steps
The attached Docker Compose reproducer exercises, see: bootstrap.ndjson · GitHub the real Stalwart task-manager
path. It does not seed or modify a Redis lock directly.
It uses the exact application and Redis versions from the incident:
stalwartlabs/stalwart:v0.16.7at digest
sha256:6a8ddaa5728a5e78a8611085069f63414cd43c3a669471785dd41aad1ca16e63;bitnami/redis:8.0.1-debian-12-r2at digest
sha256:333cd28208f4196c2cf9e7a31c9334abdd7fcfa42438c7f6849a8f0d0805a00b.
The reproducer consists of four files:
- compose.yaml - defines Redis, Stalwart bootstrap/control/worker roles, the Stalwart CLI, and a small TCP service that deliberately keeps the synthetic rules download
open; - config.json - provides the disposable RocksDB metadata store;
- bootstrap.ndjson - selects Redis as the in-memory store and separates the API-only control role from the task-processing worker role;
- reproduce.sh - creates a real
SpamFilterMaintenance(updateRules)task, observes its binaryKV_LOCK_TASKRedis key, terminates the lock owner, starts a replacement
worker, and asserts the task remains pending.
Run it on a machine with Bash, curl, Docker, and Docker Compose:
chmod +x reproduce.sh
./reproduce.sh
The script uses the fixed Compose project name stalwart-lock-repro, binds the
temporary Stalwart HTTP listener only to 127.0.0.1:18080, and removes its
containers, network, and volume on exit.
The sequence is:
- Bootstrap a disposable Stalwart v0.16.7 instance using Redis as its
in-memory store. - Create a due
SpamFilterMaintenance(updateRules)task through Stalwart’s
management API. - Start a task-processing Stalwart worker. Its rules request is intentionally
kept open so that it remains inside task execution after acquiring the lock. - Read the task’s Redis lock and verify counter
1with a TTL close to the
one-hourDEFAULT_LOCK_EXPIRY. - Send
SIGKILLto that Stalwart worker before it can call
remove_index_lock. - Start a replacement Stalwart worker before the abandoned lease expires.
- Verify that the failed contender changes the counter from
1to2and
moves the TTL back toward one hour. - Query the task through Stalwart’s management API and verify that it remains
Pending
Relevant Log Output
Created Task i0cfketiaaab
before_crash=1,3599675
after_owner_crash=1,3595872
after_contender_restart=2,3599426
task={“maintenanceType”:“updateRules”,“status”:{“createdAt”:“2026-07-29T11:29:11Z”,“due”:“2026-07-29T11:29:11Z”,“@type”:“Pending”},“@type”:“SpamFilterMaintenance”,“due”:“2026-07-29T11:29:11Z”,“id”:“i0cfketiaaab”}
REPRODUCED: failed Stalwart contender incremented the abandoned lock and reset its TTL.
Stalwart Version
v0.16.x
Installation Method
Docker
Database Backend
FoundationDB
Blob Storage
S3-compatible
Search Engine
Internal
Directory Backend
Internal
I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.
on
I have searched this support forum (open and closed topics) and confirm this is not a duplicate.
on
I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.
on