Philipp Lehmann @PhilippTheServer

Chaining Single-Purpose Redis Consumers into a Processing Pipeline

Splitting a do-everything consumer into single-purpose stages linked by Redis Streams.

The problem

The shape is familiar: a service reads incoming data, and a function called process_reading() does everything to it in one place.

# Broken. Do not copy this.
def process_reading(reading):
    reading["anomaly"] = abs(reading["value"]) > 80
    window = fetch_recent_window(reading["sensor_id"])
    reading["rolling_average"] = sum(window) / len(window)
    offset = get_calibration_offset(reading["sensor_id"])
    reading["calibrated_value"] = reading["value"] + offset
    update_running_stats(reading)
    return reading

It works, and for a while that is the whole problem: it keeps working long enough that nobody stops to split it up. Then requirements change one at a time, the way they do — the anomaly threshold needs to vary per sensor type, the rolling window needs a different size for one class of device, the calibration lookup needs to hit a slower external source. Each change is small, but it is a change to a function that also does three other things, so every change is tested against all four responsibilities whether it touches them or not.

Two failures compound this. It is untestable in isolation: a unit test for the anomaly check also has to fake a rolling window, a calibration store and a stats sink, so the tests people actually write cover the whole function or nothing. And it fails as one unit: if the statistics step throws on a schema mismatch, no reading gets detected, tracked or calibrated either, even though those three steps were fine. A slow stage — the calibration lookup, say — throttles every other stage too, because they all run on the same thread for the same message.

None of this shows up under light load. It shows up when one responsibility needs to change faster than the others, scale differently, or fail without taking the rest down — by which point all four are woven through one function and one deploy.

Working through it

Split by responsibility, and let a queue own the boundary

The fix is not simply “four functions” — calling code would still run them as one unit. The four responsibilities need four processes, each testable, deployable and scalable independently, with something between them that survives one process being slow or gone: a broker, not a function call.

Choosing Redis Streams over pub/sub or plain lists

Redis offers three plausible primitives, and they are not interchangeable.

Pub/sub delivers a message only to subscribers connected at that moment. A consumer redeploying, or restarting after a crash, simply never sees what was sent during that gap — fine for fire-and-forget notifications, not for a pipeline stage where a missed reading is data loss.

A plain list (LPUSH / BRPOP) fixes that — the message sits until popped — but a popped message is gone the instant it is popped. If the consumer crashes after BRPOP and before finishing the work, that message is lost with no record it was ever taken.

Streams (XADD / XREADGROUP) add the piece both are missing: a consumer group tracks, per message, which consumer took it and whether it was acknowledged. A message taken but never acknowledged stays visible — as pending, not gone — so a replacement consumer can pick it back up. That is the guarantee a pipeline stage needs: at-least-once delivery with an explicit acknowledgement step.

The cost is that “at-least-once” is exactly what it says: a consumer can crash after finishing work but before sending XACK, so the message is redelivered and processed twice. Every handler here has to tolerate that, which is why the stages below only overwrite state (a fixed-length rolling window) rather than blindly incrementing a counter. stats.py is the one exception, kept simple below: HINCRBY on a redelivered message double-counts it. Where exact counts matter, the fix is a dedup key per message ID — a real cost of this architecture, not a footnote.

Consumer groups, acknowledgement, and what “done” means

Each stage creates its own consumer group on its input stream and reads with XREADGROUP. Reading does not remove the message — it moves it into that consumer’s pending entries list. Only XACK clears it. That distinguishes “the process read the message” from “the process finished the message”: a stage can read a reading, crash while computing the rolling average, and never acknowledge it. The message is not lost, only pending.

Recovering from a stage that dies mid-message

That is XAUTOCLAIM: given a minimum idle time, it takes pending messages nobody has acknowledged in that window and reassigns them to the calling consumer. Running it at the top of every read loop means a restarting consumer first mops up its own — or a dead sibling’s — unfinished work before moving on. No separate monitoring process, no dead-letter queue to build; the stream already has the bookkeeping.

Independent scaling and backpressure

Because each stage is its own consumer group, detect can run three processes and calibrate can run one, in proportion to how expensive each step is — the slow calibration lookup no longer sets the pace for detection. And because a stream is a durable buffer, a slow stage does not drop messages, it accumulates a backlog visible with XLEN and XPENDING. That visibility has a cost: an unbounded backlog fills memory if a stage stays down long enough, which is why every XADD below caps the stream with MAXLEN ~.

Testing each stage as a pure function

The other payoff shows up in the tests. Each stage keeps its actual decision — is this an anomaly, what is the calibrated value — in a plain function with no Redis client and no I/O, wrapped in a thin handler the shared run-loop calls. The decision is what changes when requirements change, so it is the only thing that needs a test.

The solution

# docker-compose.yml
services:
  redis:
    image: redis:7.4-alpine
    ports:
      - "6379:6379"
# requirements.txt
redis==5.0.8
pytest==8.3.3
# pipeline/base.py
"""Shared consumer-loop helper for every stage in the pipeline."""
from __future__ import annotations

import json
import os
from typing import Callable, Optional

import redis


def main_for(
    *,
    in_stream: str,
    group: str,
    default_consumer_name: str,
    handler_factory: Callable[[redis.Redis], Callable[[dict], Optional[dict]]],
    out_stream: Optional[str] = None,
) -> None:
    """Build a Redis client from REDIS_HOST, pass it to handler_factory, and
    run the stage — the boilerplate every stage's __main__ would repeat."""
    client = redis.Redis(
        host=os.environ.get("REDIS_HOST", "localhost"), port=6379, decode_responses=True
    )
    run_stage(
        redis_client=client,
        in_stream=in_stream,
        group=group,
        consumer_name=os.environ.get("CONSUMER_NAME", default_consumer_name),
        handler=handler_factory(client),
        out_stream=out_stream,
    )


def run_stage(
    *,
    redis_client: redis.Redis,
    in_stream: str,
    group: str,
    consumer_name: str,
    handler: Callable[[dict], Optional[dict]],
    out_stream: Optional[str] = None,
    block_ms: int = 5000,
) -> None:
    """Read in_stream via a consumer group, call handler() on each message,
    forward the result to out_stream if given, then acknowledge."""
    try:
        redis_client.xgroup_create(in_stream, group, id="0", mkstream=True)
    except redis.ResponseError as exc:
        if "BUSYGROUP" not in str(exc):
            raise

    while True:
        # Reclaim work a dead consumer in this group left pending for more
        # than 30 seconds, so a crashed process does not lose messages.
        _, claimed, _ = redis_client.xautoclaim(
            in_stream, group, consumer_name, min_idle_time=30_000, start_id="0-0"
        )
        messages = claimed or []

        if not messages:
            response = redis_client.xreadgroup(
                group, consumer_name, {in_stream: ">"}, count=10, block=block_ms
            )
            if not response:
                continue
            _, messages = response[0]

        for message_id, fields in messages:
            payload = json.loads(fields["data"])
            result = handler(payload)
            if result is not None and out_stream is not None:
                redis_client.xadd(
                    out_stream, {"data": json.dumps(result)}, maxlen=10_000, approximate=True
                )
            redis_client.xack(in_stream, group, message_id)
# pipeline/detect.py
"""Detection stage: flags readings whose value is outside the expected band."""
from __future__ import annotations

import os

from base import main_for

THRESHOLD = float(os.environ.get("DETECT_THRESHOLD", "80.0"))


def is_anomaly(value: float, threshold: float = THRESHOLD) -> bool:
    return abs(value) > threshold


def handle(reading: dict) -> dict:
    reading["anomaly"] = is_anomaly(reading["value"])
    return reading


if __name__ == "__main__":
    main_for(
        in_stream="stream:raw",
        group="detectors",
        default_consumer_name="detect-1",
        handler_factory=lambda _client: handle,
        out_stream="stream:detected",
    )
# pipeline/track.py
"""Tracking stage: maintains a rolling average per sensor over the last
N readings and tags each reading with it."""
from __future__ import annotations

import os

import redis

from base import main_for

WINDOW_SIZE = int(os.environ.get("TRACK_WINDOW", "5"))


def make_handler(client: redis.Redis):
    def handle(reading: dict) -> dict:
        key = f"window:{reading['sensor_id']}"
        client.rpush(key, reading["value"])
        client.ltrim(key, -WINDOW_SIZE, -1)
        window = [float(v) for v in client.lrange(key, 0, -1)]
        reading["rolling_average"] = sum(window) / len(window)
        return reading

    return handle


if __name__ == "__main__":
    main_for(
        in_stream="stream:detected",
        group="trackers",
        default_consumer_name="track-1",
        handler_factory=make_handler,
        out_stream="stream:tracked",
    )
# pipeline/calibrate.py
"""Calibration stage: applies a per-sensor offset looked up from Redis."""
from __future__ import annotations

import redis

from base import main_for


def make_handler(client: redis.Redis):
    def handle(reading: dict) -> dict:
        offset = client.hget("calibration_offsets", reading["sensor_id"])
        reading["calibrated_value"] = reading["value"] + float(offset or 0.0)
        return reading

    return handle


if __name__ == "__main__":
    main_for(
        in_stream="stream:tracked",
        group="calibrators",
        default_consumer_name="calibrate-1",
        handler_factory=make_handler,
        out_stream="stream:calibrated",
    )
# pipeline/stats.py
"""Statistics stage, the terminal consumer. HINCRBY is not idempotent
under redelivery, as discussed above."""
from __future__ import annotations

import redis

from base import main_for


def make_handler(client: redis.Redis):
    def handle(reading: dict) -> None:
        key = f"stats:{reading['sensor_id']}"
        client.hincrby(key, "count", 1)
        client.hincrbyfloat(key, "sum", reading["calibrated_value"])
        if reading["anomaly"]:
            client.hincrby(key, "anomalies", 1)
        return None

    return handle


if __name__ == "__main__":
    main_for(
        in_stream="stream:calibrated",
        group="stats",
        default_consumer_name="stats-1",
        handler_factory=make_handler,
    )
# pipeline/producer.py
"""Demo producer: writes synthetic sensor readings onto stream:raw."""
from __future__ import annotations

import json
import os
import random
import time

import redis


def main() -> None:
    client = redis.Redis(
        host=os.environ.get("REDIS_HOST", "localhost"), port=6379, decode_responses=True
    )
    sensors = ["sensor-a", "sensor-b", "sensor-c"]
    for i in range(200):
        reading = {
            "sensor_id": random.choice(sensors),
            "value": round(random.gauss(50, 20), 2),
            "seq": i,
        }
        client.xadd("stream:raw", {"data": json.dumps(reading)}, maxlen=10_000, approximate=True)
        time.sleep(0.05)
    print("producer done: 200 readings written to stream:raw")


if __name__ == "__main__":
    main()
# pipeline/test_detect.py
from detect import is_anomaly


def test_within_band_is_not_anomaly():
    assert is_anomaly(50.0, threshold=80.0) is False


def test_above_band_is_anomaly():
    assert is_anomaly(95.0, threshold=80.0) is True


def test_negative_above_band_is_anomaly():
    assert is_anomaly(-95.0, threshold=80.0) is True

Running it end to end:

docker compose up -d
pip install -r requirements.txt

cd pipeline
pytest -q                          # 3 passed, no Redis needed

python detect.py &
python track.py &
python calibrate.py &
python stats.py &
python producer.py                 # writes 200 readings, then exits

redis-cli HGETALL stats:sensor-a
# 1) "count"      2) "63"
# 3) "sum"        4) "3150.42"
# 5) "anomalies"  6) "4"

The numbers vary run to run since the producer generates random values, but count for a sensor should match how many readings it was assigned, and XLEN stream:raw minus XPENDING stream:raw detectors should converge to zero once every stage catches up — confirmation nothing was dropped.

Conclusion

A shared function is not a shared boundary. Splitting one function into four still leaves one deploy, one failure domain and one test surface, unless something durable sits between the pieces. The queue is the boundary, not the refactor.

At-least-once is a real constraint on every handler, not a broker detail. A crash between finishing work and acknowledging it is not an edge case — it is the case acknowledgement exists to handle, and every downstream handler must tolerate seeing a message twice. stats.py here is the honest counter-example: it gets this wrong for brevity, and a production version needs a dedup key per message ID.

Recovery is a property of the loop, not a separate process. Putting XAUTOCLAIM at the top of the read loop means every consumer rescues its own crashed predecessor on the way to new work, with no supervisor watching for stuck messages.

Decomposition earns nothing if you cannot see the queues between the pieces. XLEN and XPENDING on each stream are what turn “the pipeline feels slow” into “stage three has a backlog of four thousand messages” — visibility a single monolithic function never gave you in the first place, because there was never a boundary to inspect.