Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.4.0] - 2026-08-15¶
Changed¶
- BREAKING:
_kombu.binding.{exchange}is now a sorted set instead of a plain set, scored with the unix time each binding goes stale. Bindings were never removed: Redis cannot expire an individual member, and onlyqueue_deleteremoves one, which reaches just the bindings the calling process declared itself. So the routing table of a long-lived exchange grew for the life of the deployment, and a celery control client, which binds a fresh reply queue per call and does not always get to unbind it, drove that growth. The deadline isx-expiresafter the last refresh and never less thanMIN_BINDING_LIFETIME(300 seconds); a queue withoutx-expiresis scored+infand still only goes away on an explicit unbind. Declaring, refreshing and publishing all rescore, andget_tabledrops whatever has aged out before it reads, so cleanup rides the read path and nothing has to sweep. Rescores useZADD GT, so a channel with a shortx-expireswindow cannot pull back a deadline that a channel with a longer window pushed further out. The first_queue_bindconverts an inherited set in place, keeping every member and scoring it+inf. The conversion is one-way and the key name is shared with kombu's own Redis transport, so the two can no longer declare against the same exchange: runcelery.contrib.migrate.migrate_tasksbefore deploying this version, orDELthe binding keys if you already did. See the migration guide - BREAKING: the involuntary-redelivery cap now follows RabbitMQ quorum queues. The
max_restore_counttransport option is nowdelivery_limit, therestore_countmessage hash field is nowdelivery_count, and thex-restore-countheader is nowx-delivery-count. The default changed from no limit to20, which is what RabbitMQ quorum queues have applied since 4.0, and the counter now counts delivery attempts rather than redeliveries, so a message is dropped on its 20th delivery. Setdelivery_limit: Noneinbroker_transport_optionsto keep the old unlimited behaviour. A message published by an older version has nodelivery_countfield, which reads as0, so it simply starts over - BREAKING: fanout bindings are no longer written to
_kombu.binding.{exchange}. Fanout routing never reads the table: publishing is one XADD to the exchange's stream, and consumers follow the streams their own channel subscribed to, so the members only piled up. kombu's genericexchange_deleteandlist_bindingsno longer see fanout bindings; a binding key left behind by an earlier version is deleted on the first fanout declare of the new version, since nothing would ever read or rescore it again Channel.enqueue_due_messagesnow returns aSweepStatsNamedTuple(enqueued, dropped, redelivered, orphaned)instead of a bare count
Added¶
blocking_timeouttransport option (default10), the seconds BZMPOP and XREAD block on the server per poll. This waspolling_interval, which in kombu means the sleep between unsuccessful polls, so one attribute drove two opposite mechanisms:kombu.transport.virtual.Transport.drain_eventsslept 10 seconds after any poll that came up empty, and the sleep was clamped to the caller's drain timeout rather than skipped. kombu's own Redis transport setspolling_interval = Noneand keepsbrpop_timeoutseparate for exactly this reason, and this transport now does the same. Settingpolling_intervalstill works, read asblocking_timeoutwith a deprecation warning and with the sleep left disabled. Keep it belowsocket_timeoutif you set one;0is passed through as-is and blocks each poll until a message arrivesqueue_expirestransport option (defaultNone): expiry in seconds for every queue declared without its ownx-expires. With it set, binding tables and fanout streams carry TTLs too, refreshed by the same declares, publishes and periodic refreshes that keep queues alive, so an abandoned deployment's queues, indexes, binding tables and streams all expire on their own. Message hashes are the exception: they followmessage_ttl/x-message-ttlonly, and an expired index leaves them unreachable, so pair the two options if unconsumed payloads must not outlive their queue. A per-queuex-expiresstill wins and the 10-second floor applies. Binding-key TTLs only ever grow (PEXPIRE GT), so a queue with a short window cannot cut down what another queue's touch pushed out. Set it deployment-wide: a process without the option neither writes nor refreshes these TTLs, so its routes could expire from under it
Documentation & Diagnostics¶
- Documented the
septransport option, which was accepted but never listed. A deployment migrating from the standard Redis transport has to carry over whateversepit configured there, because_kombu.binding.{exchange}is the one piece of broker state the two transports share a key name for - Added a "Carry over a custom
sep" section to the migration guide covering both failure modes of a mismatch: kombu raisingValueError: not enough values to unpack (expected 3, got 1)on every publish, and this transport padding the member to(member, "", "")so routing silently matches nothing get_tablenow logs a warning (once per process) naming the exchange and the offending member when a binding does not split into three parts. Padding behaviour is unchanged, so nothing starts raisingget_tablenow names the abandoned bindings it prunes (INFO), so an aged-out route can be told apart from one that never existed- The requeue sweep now reports what it did. Messages dropped at the delivery limit are named in the error log (task name and id, up to 10 per queue per sweep); the drop deletes the message hash, so that log line is the last trace of the message. Redeliveries and orphaned index entries are counted and logged at INFO
Fixed¶
- Publishing to a durable direct exchange whose binding table is empty now raises
InconsistencyErrorinstead of discarding the message. kombu made the empty table a silent no-op in 5.2 (PR #1404), which is right for topic and fanout but not for durable direct, where the binding is known to exist and, withx-expires, may simply have aged out.InconsistencyErroris inconnection_errors, so kombu redeclares the binding and retries. The visible symptom was pidbox replies vanishing after a control queue expired. A transient direct exchange keeps kombu's drop, with an INFO log: a pidbox reply exchange loses its bindings the moment its control client leaves, and the publisher redeclaring its own entities cannot recreate a binding that belonged to someone else, so raising there only churned through a pointless retry loop x-expiresandx-message-ttlnow apply to publishes made on a channel that did not declare the queue itself. kombu caches declarations per connection, so only the first channel to declare a queue ever sees its arguments, while any channel of that connection may be the one publishing. The TTL registries are now shared by all channels of a connection instead of being per-channel- Acking a message now removes it from
queue:{name}as well as frommessages_index:{name}. A message whose visibility timeout had already restored it left the restored copy behind, so it was delivered again after being acked - A consumed message always gets a visibility deadline. Both consume paths refreshed the index entry with
ZADD ... XX, which is a no-op when the entry is gone, so such a message was never recovered if its worker died - A queue backlog is no longer counted as a redelivery.
enqueue_due_messagesgates the counter on theZADD NXresult, so a message still sitting in its queue past its deadline is re-dated but neither counted nor dropped. Without this, a queue slower thanvisibility_timeoutwould have eaten its own backlog oncedelivery_limitgained a default delivery_info["redelivered"]and thex-delivery-countheader are now derived from the delivery counter at consume time.redeliveredused to be a hash field that was written but never read, so Celery'sworker_deduplicate_successful_tasksnever saw a redelivery. The header goes into the message's top-levelheadersmap, which is where kombu reads headers from when it rebuilds a message;properties["headers"], where it went first, never reaches the consumer- Messages consumed with
no_ack(pidbox control and reply queues, andbasic_get(..., no_ack=True)) are now dequeued inside the atomic pop instead of being given a visibility deadline. Nothing ever acks a no_ack delivery, so its index entry and hash survived until the requeue sweep re-enqueued the message on its deadline, and a control command could fire a second timevisibility_timeoutlater x-expiresis now refreshed on connections that have no event loop. The refresh only ever ran off a timer inside a worker's hub, so a celery control client waiting for replies, a Flower event receiver and a gevent worker's synloop all let their own queues, and now their bindings, age out from under them. They drain events instead, so the drain path refreshes at the same interval the timer would have used- The queue expires refresh timer now starts for queues declared before the event loop existed.
register_with_event_loopnever called_update_expires_timerafter attaching the loop, so a worker that declared all its queues at startup refreshed none of their TTLs and its queues expired underneath it QoS.restore_unacked_onceno longer shuts the worker thread pool down on broker reconnects. kombu calls it fromChannel.close(), which also runs when the consumer reconnects, so every broker blip permanently disabled the pool (latersubmit()calls raisedRuntimeErrorwhile the worker kept answeringinspect ping). It is now gated on the worker blueprint having enteredCLOSE/TERMINATE- Reconnects no longer requeue messages whose tasks are still running. Those messages stay in
messages_indexand are redelivered on their visibility deadline instead - Worker lookup no longer relies on
channel.connection.client.app, which never resolves (kombu'sConnectionhas noappattribute) and made the lookup raiseAttributeErroron every call - The heartbeat, ack cleanup and requeue paths now track the queue a message was consumed from instead of assuming
delivery_info["routing_key"]names it. kombu stamps the publish-time routing key intodelivery_infoand never rewrites it on delivery, so for a queue bound under a routing key that is not its name the heartbeat pushed the deadline of a nonexistent index entry and a long-running task was redelivered mid-run, acking left the real index entry (and a restored queue copy) behind, and reject-with-requeue looked up the per-queuex-message-ttlunder the wrong name. The consume paths now record the queue indelivery_info["queue"]at pop time delivery_limitis now enforced on reject-with-requeue too. The requeue script counted the redelivery but left the drop to the requeue sweep, and a live reject loop re-stamps the index deadline on every consume, so the sweep never saw the entry come due and the message bounced at the front of its queue forever. The requeue script now drops at the limit with the sweep's attempt counting; the dropped message is named in the error log, which is its last trace. Like the sweep, it gates the count on theZADD NXresult, so a reject arriving after the sweep already restored the same delivery neither counts it a second time nor drops a message the limit still allows- Timed-out and delayed messages are now recovered on connections that have no event loop. The requeue sweep and the visibility heartbeat only ever ran off timers inside a worker's hub, so a gevent or eventlet worker never restored a crashed worker's messages, never delivered native-delayed messages, and let its own in-flight messages hit their visibility deadline mid-task. The drain path now runs both at the intervals the timers would have used, as it already did for the
x-expiresrefresh - Cancelling the last consumer while a poll is in flight no longer wedges the channel. The reply that arrived after the cancel left the channel claiming a command was still on the wire when none was, so the next
basic_consumenever started a poll and the consumer starved, and closing the channel blocked forever waiting for the phantom reply. Consuming again after such a spell also restarts in the atomic FAST mode: the non-atomic BZMPOP path is only used straight after FAST confirmed the queues empty, a fact an idle spell no longer vouches for - The visibility heartbeat no longer skips a channel with no consumers. Unacked deliveries outlive consumption: after
cancel_consumer, a still-runningacks_latetask kept its message in flight but the heartbeat stopped pushing its deadline, so the message hit its visibility timeout mid-run and another worker started it a second time
[0.3.0] - 2026-02-14¶
Added¶
- Queue TTL (
x-expires): queues auto-expire when no worker refreshes them, via periodic PEXPIRE with dynamic interval (TTL/2) - Message TTL (
x-message-ttl): per-queue message expiry via shorter EXPIRE on message hashes prepare_queue_argumentsoverride using kombu'sto_rabbitmq_queue_argumentsfor RabbitMQ-compatible queue argument handling
Changed¶
- Split global
messages_indexsorted set into per-queuemessages_index:{queue}keys for scoped recovery, clean queue lifecycle, and correctglobal_keyprefixbehavior with Lua scripts - Renamed internal redis-specific naming to client-library-agnostic (
client_lib,_client_exceptions) for better redis-py/valkey-py compatibility - Default message TTL changed from 3 days to
-1(no TTL); configurable viamessage_ttlchannel attribute - CI/CD: tag workflow now gates on CI success instead of running on every push
Fixed¶
EXPIREandPEXPIREcommands now correctly prefixed whenglobal_keyprefixis set_bzmpop_readand_getnow skip expired message hashes and try the next message instead of raisingEmptyx-expiresbelow minimum (10s) now clamped with warning instead of raisingValueError- Removed redundant redis-specific getter functions (
get_redis_error_classes,get_redis_ConnectionError,_get_response_error)
[0.2.5] - 2026-02-14¶
Fixed¶
- Fanout/broadcast (events, Flower) now works: added dedicated subclient for XREAD and fixed per-routing-key stream splitting
Added¶
- Example project in
examples/simple/demonstrating tasks, delayed delivery, priority, retries, and Flower
[0.2.4] - 2026-01-31¶
Added¶
- Migration support from standard Redis transport
Fixed¶
- Simplified transport configuration in docs
[0.2.3] - 2026-01-29¶
Added¶
- Support for both redis-py and valkey-py client libraries (optional dependencies)
valkey://andvalkeys://URL scheme support for easier configuration- SSL/TLS detection from
valkeys://URL scheme - Priority clamping for out-of-range values (clamps to 0-255 range with warning)
Fixed¶
- Documentation site 404 by setting dev as default version
[0.2.2] - 2025-01-22¶
Changed¶
- Updated celery-types-ng to 0.25.4 and fixed typing errors
[0.2.1] - 2025-01-21¶
Changed¶
- Added
queue:prefix to avoid collision with list-based queues
[0.2.0] - 2025-01-20¶
Added¶
- Native delayed delivery support
- Full priority support (0-255)
- Reliable fanout via Redis Streams
- Visibility timeout tracking
Changed¶
- Switched from Redis lists to sorted sets for queues
- Improved message reliability with per-message hashes
[0.1.0] - 2025-01-15¶
Added¶
- Initial release
- Custom Kombu transport for Redis/Valkey
- Basic queue operations with sorted sets