Celery ignored task_task_routes: silent failures from growing a crawler on Redis

·Updated ·5 mins

Update 2026-11-08: rebuilt every case on Celery 5.2.7 and 5.7.0.

This sat in my crawler’s Celery config:

task_task_routes = {
    "tasks.urgent": {"queue": "celery"},
    "tasks.post": {"queue": "celery:2"},
}

The setting is called task_routes. Celery doesn’t check setting names, so the app started normally, app.conf.task_routes stayed None, and three different tasks all landed in the default queue:

{'celery:1': 3}

That typo arrived with the commit that moved the crawler to Redis priority queues, and it wasn’t the only failure from that stretch that raised nothing. Two small changes catch most of them: compare your settings against Celery’s list of known names, and set task_create_missing_queues = False.

I found these by rereading the code and rebuilt each one against a private Redis, on Python 3.11 with the locked versions (Celery 5.2.7, kombu 5.2.4, redis-py 4.5.4) and on Celery 5.7.0. I have no production logs from that time, so this shows what the code does and leaves open what it cost.

How the crawler got here

DateChange
2018Celery on Redis, with the crawl state in Redis too
2020Mongo for reports; separate priority and regular tasks
2021-09A monitor task that decides when the crawl is done
2023-02-03Large structures move from Redis to SQLite, then to Postgres the same night
2023-02-07Mongo dropped
2023-02-10Bulk inserts from generators
2023-02-12Named priority queues; each input parsed into an on-disk graph in a temp directory

Redis ended up mostly as the broker and a few caches. The traps below surfaced in the 2023 part of that table.

Celery ignores settings it doesn’t know

A test finds the typo. DEFAULTS holds every setting name Celery recognizes:

from types import ModuleType

from celery.app.defaults import DEFAULTS

import celeryconfig


def test_celery_settings_are_known():
    unknown = {
        name for name, value in vars(celeryconfig).items()
        if name.islower() and not name.startswith("_")
        and not isinstance(value, ModuleType)
        and name not in DEFAULTS
    }
    assert not unknown

On my config, it reports {'task_task_routes'}, on both Celery versions.

A queue with no consumer looks like a finished crawl

The same commit changed what the worker reads, and the post-processing chain kept an old queue name:

celery -A app worker -Q celery,celery:1,celery:2
chain(step_one.si(), step_two.si()).apply_async(queue="query")

task_create_missing_queues is on by default, so sending to query creates it. In the rebuild, the message sat in that key, and no step ran. Declare your queues and turn creation off:

from kombu import Queue

task_create_missing_queues = False
task_queues = [Queue("celery"), Queue("bulk")]
celery.exceptions.QueueNotFound: "Queue 'query' missing from task_queues"

That protects the sending side only. The -Q list on the worker is still a separate copy, so build it from the same list.

Redis priority steps and queue names share a namespace

broker_transport_options = {
    "priority_steps": [0, 1, 2],
    "sep": ":",
    "queue_order_strategy": "priority",
}

With these options, kombu stores priority 1 of queue celery queue under the Redis key celery:1. I also named a queue celery:1. A message sent with queue="celery", priority=1 showed up in that key. Priority 0 gets no suffix, so celery:0 never exists.

Pick one scheme: a single queue with priority=, or named queues whose names don’t contain the separator.

LLEN 0 doesn’t mean the crawl is done

Every ten minutes, the monitor computed this and declared the crawl done at zero:

enqueued = red.llen("celery") + red.llen("celery:0") + red.llen("celery:1")

It counts the key that never exists and skips celery:2. Even with the right keys, a list length misses work:

StateMonitorLists + HLEN unacked
Task waiting in celery:201
Task running, task_acks_late=True01
Retry waiting on a 6 s countdown01
Chain stranded in query00
def outstanding(client, queues=("celery", "celery:1", "celery:2")):
    with client.pipeline() as pipe:
        for queue in queues:
            pipe.llen(queue)
        pipe.hlen("unacked")
        return sum(pipe.execute())

unacked is kombu’s internal bookkeeping hash, and it shows running tasks only because my config sets task_acks_late=True. With early acknowledgment, a running task has already left it. The last row shows the limit of any count: it can’t see a queue you forgot to list.

When you know the fan-out up front, a chord gives a real completion signal. The docs say members must not ignore their results, and I copied that warning into a comment. On the Redis backend, a chord of ignore_result=True members still ran their bodies with all three values, on both versions. I’d follow the docs anyway, since I tested one backend. My tasks also spawned more tasks, which a chord can’t express.

decode_responses=True on a shared pool breaks a binary cache

In 2021, my HTTP cache shared the application’s Redis pool, which decoded responses to strings. requests-cache stores pickles, and a pickle starts with byte 0x80. Two identical GETs:

requests-cacheDecoding poolSeparate pool
0.9.82 origin hits, one log line1 origin hit
1.3.3Second GET raises UnicodeDecodeError1 origin hit

The log line on the old version is all you get:

Unable to deserialize response with key 53630720a0b4bd02: 'utf-8' codec can't decode byte 0x80 in position 0: invalid start byte

The commit that fixed it says what to do: “a separate Redis pool without decoding”:

app_pool = redis.ConnectionPool.from_url(url, decode_responses=True)
cache_pool = redis.ConnectionPool.from_url(url)

execute() drains a generator, group() doesn’t

I passed generators to session.execute(insert(Row), rows()) to save memory. SQLAlchemy reads them to the end first: on 2.0.7 and 2.1.8, with a SQLite engine, the generator had yielded 5,000 of 5,000 rows before the first INSERT. Batch it yourself:

from itertools import islice


def batches(items, size=1000):
    items = iter(items)
    while batch := list(islice(items, size)):
        yield batch


for batch in batches(rows()):
    session.execute(insert(Row), batch)
session.commit()

With 2,500 rows, the three INSERTs fired after 1,000, 2,000, and 2,500 yields.

group(generator).apply_async() works the other way and publishes as it iterates. With a generator that yielded two signatures and then raised, one task was already in Redis, and the caller got the exception with no GroupResult. Make those tasks safe to run twice. I still prefer group to chunks, which loses retries .

Takeaway

Most of this was introduced or fixed during ten days of refactoring in February 2023, and almost none of it raised an error then. A queue migration needs a written list of each queue, who sends to it, who reads it, and what counts it.

Try the settings test above on your own Celery config.