Skip to content

Cluster result writes disconnect concurrent reads by closing the shared client #145

Open

Description

@cw-jlu

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions