A Redis list is an ordered sequence of byte values with operations at both ends. Choosing the push and pop ends determines whether it behaves as a queue or a stack. Blocking operations wait for data; they are not distributed locks, acknowledgements, or a complete message-processing protocol.
Choose the ends deliberately
RPUSH followed by LPOP provides FIFO removal: older entries leave first. RPUSH followed by RPOP provides LIFO removal. Multiple values in one LPUSH are inserted at the left in argument order, so pushing a, b, c leaves c, b, a from left to right. Test actual producer commands, not just the queue diagram.
Lists allow duplicate values. Two identical messages occupy two entries; list insertion does not provide deduplication. Use distinct job identifiers when separate deliveries must remain distinguishable. LRANGE uses inclusive endpoints, with -1 denoting the final element. Removing the last entry removes the list key, and popping a missing list returns nil rather than creating an empty placeholder.
End operations are inexpensive per element, but returning a large range or removing many entries still requires proportional work. LTRIM can retain a recent window, but truncating pending work deliberately discards entries. A producer push and a separate trim are not one atomic operation. Select retention boundaries according to the data’s meaning.
Wait without monopolizing the server
BLPOP waits on the connection until an item becomes available or the timeout expires. Other connections continue operating, including the producer that supplies the item. With redis-py, success returns the key and value; timeout returns None. A zero timeout waits indefinitely. A client’s socket deadline must allow the intended server wait and response.
Blocking consumers occupy connections even when they do no work. Allocate a bounded connection budget and avoid sharing one connection between a waiting consumer and its producer. A FIFO list also orders removal, not completion: parallel workers can finish later jobs first. Blocking behavior alone does not enforce exclusive ownership of a business resource.
When it breaks
LPOP and BLPOP remove a job before its business work finishes. If the consumer crashes or loses the response, the job is no longer on the ready list. Redis does not automatically restore it or require acknowledgement. Persistence settings do not repair this application-level handoff by themselves.
LMOVE, available since Redis 6.2, atomically transfers an item between a ready list and a processing list. BLMOVE is its blocking variant. After successful processing, LREM can acknowledge one uniquely identified job by removing it from processing. The application still needs a recovery policy for abandoned work; a list does not attach a lease, timestamp, or retry count to each item.
If work succeeds but acknowledgement fails, recovery can repeat the side effect. Unique job identifiers, idempotent processing, and a policy for late workers matter; moving between lists does not establish exactly-once processing. The exercise deliberately requeues one known job to demonstrate the mechanics, not an automatic failure detector or production recovery scheduler.
Check it quickly
Use a POSIX environment with a C compiler, make, curl, tar, and Python 3.10 or newer. This ran on macOS with Python 3.14.4, Redis 8.10.1, and redis-py 8.1.0. These commands build Redis core with libc and install the client in a temporary virtual environment, without optional modules or system-wide installation.
redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0
Run the Python block with "$redis_lab/venv/bin/python" in the same shell. It creates a private Redis Unix socket under /tmp, disables TCP, scheduled snapshots, and append-only persistence, then terminates only its own process and removes its runtime directory. Dependency downloads need network access. No default local Redis is contacted.
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry
@contextmanager
def instance():
with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
endpoint = str(Path(directory) / "redis.sock")
process = subprocess.Popen([
os.environ["REDIS_SERVER"], "--port", "0",
"--unixsocket", endpoint, "--unixsocketperm", "700",
"--save", "", "--appendonly", "no", "--dir", directory,
"--maxmemory-policy", "noeviction",
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
socket_timeout=2, retry=Retry(NoBackoff(), 0))
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "Redis exited during startup"
try:
if client.ping():
break
except redis.ConnectionError:
if time.monotonic() >= deadline:
raise
time.sleep(0.02)
yield client
finally:
client.close()
if process.poll() is None:
process.terminate()
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode == 0, errors.decode()
with instance() as client:
client.rpush("fifo", b"a", b"b", b"c")
assert [client.lpop("fifo") for _ in range(3)] == [b"a", b"b", b"c"]
assert client.exists("fifo") == 0
assert client.lpop("fifo") is None
client.rpush("stack", b"a", b"b", b"c")
assert [client.rpop("stack") for _ in range(3)] == [b"c", b"b", b"a"]
client.lpush("left", b"a", b"b", b"c")
assert client.lrange("left", 0, -1) == [b"c", b"b", b"a"]
client.rpush("duplicates", b"same", b"same")
assert client.llen("duplicates") == 2
print("FIFO a,b,c; LIFO c,b,a; multi-value LPUSH reverses argument order; duplicates stay")
def consume():
with client.client() as connection:
connection.client_setname("list-lab-consumer")
return connection.blpop("channel", timeout=1)
with ThreadPoolExecutor(max_workers=1) as worker:
future = worker.submit(consume)
deadline = time.monotonic() + 1
while not any(
entry["name"] == "list-lab-consumer" and "b" in entry["flags"]
for entry in client.client_list()
):
assert time.monotonic() < deadline, "consumer never blocked"
time.sleep(0.01)
client.rpush("channel", b"job:1")
assert future.result(timeout=2) == (b"channel", b"job:1")
assert client.blpop("empty", timeout=0.1) is None
print("observed a blocked connection; RPUSH wakes it; empty BLPOP times out")
client.rpush("ready", b"job:2", b"job:3")
job = client.lmove("ready", "processing", "LEFT", "RIGHT")
assert job == b"job:2"
assert client.lrange("ready", 0, -1) == [b"job:3"]
assert client.lrange("processing", 0, -1) == [b"job:2"]
assert client.lmove("processing", "ready", "LEFT", "LEFT") == job
assert client.lrange("ready", 0, -1) == [b"job:2", b"job:3"]
assert client.lmove("ready", "processing", "LEFT", "RIGHT") == job
assert client.lrem("processing", 1, job) == 1
assert client.exists("processing") == 0
print("LMOVE retains an in-flight job; explicit requeue and LREM acknowledgement work")
client.set("wrong", b"not a list")
try:
client.lmove("ready", "wrong", "LEFT", "RIGHT")
except redis.ResponseError as error:
assert "WRONGTYPE" in str(error)
else:
raise AssertionError("LMOVE must reject a non-list destination")
assert client.lrange("ready", 0, -1) == [b"job:3"]
assert client.get("wrong") == b"not a list"
client.rpush("recent", b"1", b"2", b"3", b"4")
client.ltrim("recent", -2, -1)
assert client.lrange("recent", 0, -1) == [b"3", b"4"]
print("wrong-type move preserves source; LTRIM keeps only the requested window")
The first assertions distinguish FIFO, LIFO, multi-value LPUSH order, and duplicate entries. The producer waits until CLIENT LIST actually reports a blocked consumer before submitting work; a separate empty pop returns None after its finite timeout. This observes real blocking without asserting exact scheduler latency.
LMOVE leaves the claimed job in processing, an explicit reverse move requeues it, and LREM removes the later acknowledgement. A wrong-type destination is rejected without losing the source entry. Finally, LTRIM retains two selected values. These checks verify command behavior, not crash durability, automatic recovery, throughput, or distributed locking.
Sources
- Redis LPUSH, multi-value insertion order, checked 2026-09-08.
- Redis LRANGE, inclusive range bounds and operation cost, checked 2026-09-08.
- Redis BLPOP, connection blocking, timeouts, and destructive delivery, checked 2026-09-08.
- Redis LMOVE, atomic transfer and the processing-list pattern, checked 2026-09-08.
- Redis LREM, removal by value and count, checked 2026-09-08.
- Redis 8.10.1 build instructions, core-only compilation and allocator selection, checked 2026-09-08.