Repository navigation
Description
RedisAsyncClusterResultBackend.set_result() and set_progress() enter the backend's shared self.redis client as an async context manager. On exit, redis-py closes all of its node connections. Concurrent get_result(), get_progress() and is_result_ready() calls use the same client without entering that context, so a completed write can disconnect an unrelated reader that is still waiting for its response.
Actual reproduction
I ran the following against an isolated, real three-primary Redis Cluster (all 16384 slots covered), using main 8f70bc85dd6a469cae7d3b467af7dea720c92434, Windows 11, Python 3.14.7, taskiq 0.13.0 and redis-py 8.0.0. The Redis server is the local Docker redis:8.2 image. TEST_REDIS_CLUSTER_URL points at a reachable startup node; all advertised node addresses must be reachable too.
The script pauses one node, starts a result read on it, then writes a result on a different, unpaused primary. The pause controls the overlap; retries are explicitly disabled to expose the unexpected disconnect. It unpauses the node, deletes only its unique test keys and closes its clients afterward. This requires a disposable test cluster because CLIENT PAUSE affects its target node.
import asyncio,json,os,uuid
from redis.asyncio import Redis
from redis.asyncio.retry import Retry
from redis.backoff import NoBackoff
from taskiq import TaskiqResult
from taskiq_redis import RedisAsyncClusterResultBackend
async def main():
backend=RedisAsyncClusterResultBackend(os.environ['TEST_REDIS_CLUSTER_URL'],retry=Retry(NoBackoff(),0))
token=uuid.uuid4().hex
await backend.redis.initialize()
key=f'{token}:{{0}}'
node=backend.redis.get_node_from_key(key)
other_key=next(f'{token}:{{{n}}}' for n in range(1,100) if backend.redis.get_node_from_key(f'{token}:{{{n}}}').port!=node.port)
result=TaskiqResult(is_err=False,return_value=42,execution_time=0)
observer=Redis(host=node.host,port=node.port)
pending=None
try:
await backend.set_result(key,result)
await backend.get_result(key)
await observer.client_pause(10000,'ALL')
pending=asyncio.create_task(backend.get_result(key))
await asyncio.sleep(.1)
await backend.set_result(other_key,result)
await asyncio.sleep(.1)
interrupted=pending.done() and pending.exception() is not None
print(json.dumps({'read_interrupted_by_unrelated_write':interrupted,'error':repr(pending.exception()) if interrupted else None}),flush=True)
await observer.client_unpause()
try:
recovered=await asyncio.wait_for(pending,5)
print('original read result:',recovered.return_value,flush=True)
except Exception as exc:print('original read failed:',repr(exc),flush=True)
print('later read recovery:',(await backend.get_result(key)).return_value,flush=True)
finally:
await observer.client_unpause()
if pending is not None:
pending.cancel()
await asyncio.gather(pending,return_exceptions=True)
for target in (key,other_key):await backend.redis.delete(target)
await observer.aclose()
await backend.shutdown()
asyncio.run(main())Actual output:
{"read_interrupted_by_unrelated_write": true, "error": "network:ConnectionError"}
original read failed: network:ConnectionError
later read recovery: 42
Expected: writing another task's result/progress must not tear down an in-flight read. The shared cluster client should remain owned by the backend until its existing shutdown() closes it. This also avoids rebuilding the cluster connections after every result/progress update.
Proposed scope
I would like to remove the per-write client contexts in these two methods, keeping the existing TTL/serialization logic and backend shutdown. I will add real-Cluster regression coverage for overlapping public reads/writes, with an event gate at the response-read boundary rather than pausing a shared CI node, plus normal round-trip and cleanup checks. No public API change is needed.
I checked open and recent closed issues/PRs (including cluster/result searches) and found no overlapping lifecycle fix. This is separate from the PubSub listener's dedicated-connection cleanup in #143/#144: it concerns the result backend's shared Cluster client.
Could a maintainer assign this to cw-jlu? Investigation, reproduction and this report used OpenAI Codex assistance. I plan to submit a Draft PR for independent and account-owner review.
Activity