Repository navigation
fix(connection): convert TLS and unix-socket clients to async - #792
Conversation
paoValle
left a comment
There was a problem hiding this comment.
On the defaults problem you found at connection.py:350, a variant that fixes it without enumerating keywords: compare the value against the sync class's own default instead of against (None, False).
inspect.signature(SyncConnection.__init__) (walked the same way _accepted_init_kwargs already walks the async class) gives the default each keyword has for the class the pool was built from. A keyword the pool injected, such as maint_notifications_config or orig_host_address, arrives with exactly that default and is dropped; one the user set, even to a falsy value, differs and still raises. That covers redis-py 7.4.1 and 8.0.1 without a version list, and it removes the second thing this line gets wrong: value not in (None, False) compares with ==, so 0 is treated as unset alongside False.
Two smaller notes, both cheap:
test_tls_url_client_still_convertsdoes not test anything the keyword tests do not, and it passes onmain; the unix-socket URL form (Redis.from_url("unix:///tmp/redis.sock")) is the one that is not covered and is a different code path in redis-py (it setsconnection_class, notunix_socket_path)._accepted_init_kwargsstops at the first__init__without**kwargs, which is right for redis-py's own chain; for a user subclass whose__init__takes explicit keywords and no**kwargs, the loop stops there and drops the parent's keywords. Worth a one-line comment saying that is deliberate, or continue walking and intersect instead.
sync_to_async_redis passed the sync pool's connection_kwargs straight to the async pool and picked the TLS class with an exact-type check, so a client built with Redis(ssl=True, ...), Redis(unix_socket_path=...) or Sentinel.master_for(...) raised TypeError on its first async command, and a subclass of SSLConnection got a plaintext async connection. The async connection class is now chosen with issubclass for TLS and unix-socket connections, keywords the async class does not accept are dropped when they hold their default, a sync-only setting that is set (such as OCSP validation) raises a clear ValueError instead of being lost, and Sentinel-managed pools are rejected up front like RedisCluster. Closes redis#789
… to their own defaults The sync pool injects maintenance-notification keywords (the pool handler, maint_notifications_config and the orig_* copies of host and timeouts) for its own connections. Older async classes don't accept them, so on redis-py 7.4 with RESP3, and on 8.0 for every client, the conversion raised naming settings the user never touched. Those keywords describe the sync pool, not the user's settings, and the async pool computes its own, so they are never carried over; maint_notifications_config is dropped unless the user enabled notifications explicitly. A remaining sync-only keyword now counts as unset when it still holds the sync class's own declared default, found by inspect.signature, rather than when it is None or False. That stops 0 being treated as unset and covers keywords whose default is not falsy. Also trims the SemanticRouter.aroute caveat to Sentinel and Cluster now that redis#784 is in, notes that a custom SSLConnection subclass's behaviour is not carried over, and folds the tests into two parametrised ones with RESP3 and unix-URL cases.
d23003e to
7c856ed
Compare
|
Thanks, the signature-default comparison is what 7c856ed does now, with one addition: the keywords the sync pool computes (the pool handler and the |
Seed the other session through astore, block every sync round trip by patching execute_command and pipeline on the sync client, and pin a tool message's tool_call_id and metadata through an async write, adrop(id), the reused async index and adisconnect closing the converted client. adrop now goes through AsyncSearchIndex.drop_keys rather than the private client, and the connection caveat drops SSL and unix sockets, which convert since redis#792.
…edis (#817) Follow-up to #792, from [this review comment](#792 (comment)). ## Motivation `sync_to_async_redis` passed the sync client's `retry` keyword through unchanged. The async connection awaits `retry.call_with_retry(...)`, and a sync `redis.retry.Retry` returns the coroutine without awaiting it, so the retry policy never ran: a converted client failed on the first command after `CLIENT KILL`, where a native async client recovers. ## Changes - `_async_connection_kwargs` rebuilds a sync `Retry` as `redis.asyncio.retry.Retry` with the same backoff object, retry count and supported error set (`_async_retry`). `retry=None` and a missing keyword are left alone. - Unit tests in `tests/unit/test_sync_to_async_redis.py` pin the conversion; an integration test in `tests/integration/test_connection.py` kills the converted client's connection and checks the next command succeeds. ## Release Notes Async paths that convert a caller's sync client (`SemanticCache`, `AsyncSearchIndex`, and so on) now keep that client's retry policy, so they reconnect after a dropped connection the way a native async client does. ## Reviewer Brief **Start here:** `redisvl/redis/connection.py`, `_async_retry`. A client converted from sync to async keeps its retry policy only if this rebuilds it, and `_async_connection_kwargs` calls it for every conversion, including a plain `Redis()`, which carries a `Retry` by default. **Where it could fail** - `redisvl/redis/connection.py:383`: `_async_retry` reads `Retry._backoff` and `Retry._supported_errors`, private attributes with no public getter in redis-py 6.3 through 8.1; if a future release renames one, every conversion raises `AttributeError` instead of quietly dropping the policy. - `redisvl/redis/connection.py:421`: a user's `Retry` subclass is rebuilt as the stock async `Retry`, so an overridden `call_with_retry` is not carried over (it could not have run on the async connection before this change either). **Not verified:** cluster and Sentinel clients (both are rejected before this code runs); redis-py versions between 6.3.0 and 8.1.0 other than 7.4.1 and 8.0.1. **Beyond CI:** `test_converted_client_recovers_after_client_kill` failed 20 of 20 runs on `main` and passed 20 of 20 with this change, on both redis-py 6.3.0 and 8.1.0. The unit tests pass on redis-py 6.3.0, 7.4.1, 8.0.1 and 8.1.0, and hardcoding the retry count in `_async_retry` makes `test_sync_retry_is_rebuilt_as_the_async_retry` fail. A sync client built with `retry_on_timeout=True` or `retry_on_error=[BusyLoadingError]` converts to an async connection whose `Retry` includes those errors, on 6.3.0 and 8.1.0. **Since the last brief:** `57e898e` tightens the tests only: a non-default retry count of 5, two redundant tests and assertions dropped, and a clearer CLIENT KILL test docstring. <sub>Reviewed `57e898e` · Claude Code (claude-opus-5-5) · lenses: correctness, security, design, tests, docs, Redis behaviour</sub> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Reliability fix on the sync-to-async path used by async cache/index flows; conversion relies on redis-py private **`Retry`** fields that could break on a future redis-py release. > > **Overview** > **`sync_to_async_redis`** no longer forwards the sync client’s **`Retry`** unchanged. It now rebuilds it as **`redis.asyncio.retry.Retry`** via **`_async_retry`**, preserving backoff, retry count, and supported errors so reconnect-and-retry actually runs on async connections (sync **`Retry`** would leave coroutines unawaited and skip the policy). > > **`_async_connection_kwargs`** applies that conversion when **`retry`** is a sync **`Retry`**; **`retry=None`** or a missing keyword is unchanged. Docs for **`sync_to_async_redis`** note the behavior. > > Unit tests assert the async **`Retry`** matches the sync policy; an integration test **`CLIENT KILL`**s a converted client and expects the next **`ping`** to succeed. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 57e898e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
|
🚀 PR was released in |
Branch: `feat/message-history-async` (fork `Yarmoluk/redis-vl-python`), rebased onto `main` at `506b961` (includes #792 and #785). Commits: `82f91e4` feat, `93657d7` docs, `c1b5d75` test. Label: `auto:minor`. ## Summary Follow-up to #784, which gave `SemanticRouter` `aroute` / `aroute_many`. `MessageHistory` had the same gap: an agent running on asyncio blocked its event loop on every history read or write. This PR adds the async twins -- `aget_recent`, `astore`, `aadd_messages`, `aadd_message`, `acount`, `adrop`, `aclear`, `adisconnect` and an `aindex` property -- using the same pattern #784 landed: - The `AsyncSearchIndex` is built lazily on the first async call, from the sync index's connection settings. A history built from `redis_url` gets its own async connection from that URL and `connection_kwargs`; a history given a sync `redis_client` gets an async client converted from that client's connection settings by `RedisConnectionFactory.sync_to_async_redis`, owned by the async index, so `adisconnect` closes only the converted client and the caller's sync client is never touched or rebuilt. - Every async method goes through the `AsyncSearchIndex`; none reaches into a Redis client directly (`adrop` deletes with `aindex.drop_keys(aindex.key(id))`). - Query building (`_recent_query`, `_count_query`) and message construction (`_chat_messages`, `_exchange`) move into helpers shared by the sync and async paths, so the two cannot drift. - The docstrings say what is and is not supported, in the router's wording: since #792 a sync client over TLS or a unix socket converts; Sentinel and Redis Cluster clients do not (pass `redis_url` instead); the async client is bound to the loop that made the first call, so call `adisconnect` before switching loops. No sync behaviour changes. `SemanticMessageHistory` is not covered here (see Notes). ## Reviewer Brief **Start here:** `redisvl/extensions/message_history/message_history.py`, `_get_async_index()` (line 418). Every async method goes through it, and it decides whether the async index connects from the URL the sync index owns or from a client converted from the caller's sync client; it is the same code as `SemanticRouter._get_async_index`. **Where it could fail** - `message_history.py:432`: a caller-supplied Sentinel or Cluster client, or one with a sync-only setting such as `ssl_validate_ocsp=True`, raises `ValueError` from `sync_to_async_redis` at the first async call; the `aget_recent` docstring names Sentinel and Cluster but, like the router's, not the sync-only-setting case. - `message_history.py:154`: `adrop` removes the key with `UNLINK` (via `drop_keys`) where sync `drop` uses `DEL`; both are `@keyspace @write`, but an ACL that grants `+del` by name and not `+unlink` would allow `drop` and deny `adrop` ([UNLINK](https://redis.io/docs/latest/commands/unlink/), [DEL](https://redis.io/docs/latest/commands/del/)). - `message_history.py:449`: the async client belongs to the loop that made the first call; a second `asyncio.run()` on the same history without `adisconnect` in between fails, the same contract `SemanticRouter` has shipped since #784. **Not verified:** Redis Cluster and Sentinel (no cluster tests run locally or in CI); the TLS and unix-socket conversion paths were not exercised end to end through `MessageHistory` (they are covered by #792's own tests); `connection_kwargs` and `lib_name` forwarding on the URL path is not asserted by a test. **Beyond CI:** each of these mutations makes `test_async_message_history_matches_sync` fail: `astore` ignoring `session_tag`, `adrop` going through the sync client, `adrop(id)` ignoring `id`, the async index rebuilt per call, and `adisconnect` not closing the converted client. The two new integration tests pass locally against `redis:8.4` via testcontainers. **Since the last brief:** rebased onto `506b961` (#792 now converts TLS and unix-socket clients, so the caveat narrows to Sentinel and Cluster); `adrop` deletes through `aindex.drop_keys` instead of the async client; the sync-equivalence test now blocks the sync client's `execute_command` and `pipeline`, seeds a second session with `astore`, and pins `tool_call_id`/metadata, `adrop(id)`, async-index reuse and `adisconnect` closing the converted pool. <sub>Reviewed `c1b5d75` · Claude Code (claude-opus-5-5) · lenses: correctness, security, design, tests, docs, Redis behaviour</sub> ## Test plan Run on the rebased branch at `c1b5d75`, macOS, `uv`, Docker running (testcontainers starts `redis:8.4`): ```bash make format # All done! ✨ 🍰 ✨ # 272 files left unchanged. rm -rf .mypy_cache && make check-types # Success: no issues found in 122 source files uv run pytest tests/integration/test_message_history.py -k async_message_history -q -p no:cacheprovider # 2 passed, 33 deselected uv run pytest -n auto tests/integration/test_message_history.py tests/unit -q -p no:cacheprovider # 3 failed, 1500 passed, 9 skipped, 53 errors ``` The 3 failures and 53 errors are the identical set on `main` at `506b961`: every one is `ModuleNotFoundError: sentence_transformers` (the HuggingFace vectorizer is not installed locally), in `SemanticMessageHistory` tests this PR does not touch. `test_async_message_history_matches_sync` takes its expected values from the sync methods, then patches `execute_command` and `pipeline` on the sync index's Redis client to raise, so any async method that made a blocking call would fail it. `test_async_message_history_with_redis_url` checks that a URL-built history builds its async index from that URL rather than converting the client. ## Notes - `SemanticMessageHistory` gets no async methods in this PR. Its `get_relevant` embeds through the vectorizer, which brings in the `aembed` caveats #784 documented for the router; it is a separate change with its own brief. - No `adelete`: dropping the index is setup/teardown rather than a per-turn call, and #784 drew the same line for the router. No async form of the `messages` property either, since a property cannot be awaited; `aget_recent` with a large `top_k` is the async read. - Loop binding follows #784's `adisconnect` contract rather than the rebuild-on-new-loop #785 added for Cohere's async client. The async index owns a connection pool, and rebuilding it silently on a loop change would leak the previous pool; an explicit `adisconnect` keeps the two extensions consistent and the close visible. - `_get_async_index`, `aindex` and `adisconnect` match `SemanticRouter`'s line for line; they are kept as two copies rather than a shared helper, since extracting one would mean changing the merged router code in this PR. - `count`/`acount` keep the sync method's unannotated signature rather than adding `-> int` to one twin only. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > New async Redis connection lifecycle (lazy index, loop binding, client conversion) could misbehave for Sentinel/Cluster/unix-socket setups; sync API and data paths are unchanged. > > **Overview** > Adds **async counterparts** to `MessageHistory` read/write/clear/drop operations (`aget_recent`, `astore`, `aadd_messages`, `aadd_message`, `acount`, `adrop`, `aclear`, plus `adisconnect` and an `aindex` property) so asyncio agents can use history without blocking the event loop. > > Async work goes through a **lazily created** `AsyncSearchIndex` that reuses the sync index’s schema and connection settings: URL-owned clients open a separate async connection from `redis_url`; caller-supplied sync clients get a derived async client via `RedisConnectionFactory.sync_to_async_redis`, closed only by `adisconnect`. Docstrings document loop binding and that Sentinel/Cluster sync clients need `redis_url` instead. > > Sync behavior is preserved by **extracting shared helpers** (`_recent_query`, `_recent_result`, `_count_query`, `_exchange`, `_chat_messages`) so sync and async paths share query and message construction. Two integration tests assert parity with sync (including a monkeypatch that fails if async calls hit the sync Redis client) and cover the `redis_url` async-index path. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c1b5d75. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Closes #789.
Motivation
RedisConnectionFactory.sync_to_async_redispassed the sync pool'sconnection_kwargsstraight intoAsyncConnectionPooland picked the TLS class with an exact-type check. A client built withRedis(ssl=True, ...),Redis(unix_socket_path=...)orSentinel.master_for(...)therefore raisedTypeErroron its first async command (SemanticCache.acheck, andSemanticRouter.arouteonce #784 lands), and a subclass ofSSLConnectiongot a plaintext async connection with the password still set.Changes
issubclass: TLS connections (and subclasses) map toAsyncSSLConnection, unix-socket connections to the asyncUnixDomainSocketConnection, everything else toAsyncConnection._async_connection_kwargskeeps only the keywords the chosen async class accepts, found by walking its__init__chain (_accepted_init_kwargs), so the filter follows whatever redis-py version is installed rather than a hard-coded key list. A sync-only keyword at its default (NoneorFalse) is dropped; one that is set, such asssl_validate_ocsp=True, raises aValueErrornaming it, because the async client cannot honour it and dropping it would silently weaken TLS verification.ValueError, asRedisClusteralready was.tests/unit/test_sync_to_async_redis.py, none needing a server; five fail onmain.Release Notes
SemanticCache,AsyncSearchIndexand any other async path that converts a caller's sync client now work with clients configured with TLS keywords (Redis(ssl=True, ...)), unix sockets, and customSSLConnectionsubclasses. Sentinel-managed clients,RedisClusterclients, and clients with a sync-only setting enabled (OCSP validation) raiseValueErrorat conversion time with a pointer toredis_url; previously they failed withTypeErroron the first command.Reviewer Brief
Start here:
redisvl/redis/connection.py,_async_connection_kwargs. It decides which of the sync pool's settings reach the async connection and which abort the conversion;sync_to_async_redisonly picks the class and calls it.Where it could fail
redisvl/redis/connection.py,_SYNC_POOL_PLUMBING: these four keywords are dropped by name because the sync pool computes them from the live settings; if a future redis-py adds another pool-computed keyword, every conversion on a version whose async class rejects it would raise again. Check against redis-py'sConnectionPool._update_connection_kwargs.redisvl/redis/connection.py,_is_unset:maint_notifications_configis dropped unlessenabled is True, so a user who setenabled="auto"on purpose gets an async client without notifications on 7.4/8.0; on 8.1 it passes through.redisvl/redis/connection.py,_init_parameters: stops at the first__init__without**kwargs; for the sync side that is the user'sconnection_class, so a subclass with an explicit-keyword__init__contributes only its own defaults.Not verified: cluster mode (unchanged path, raises as before); a live TLS or unix-socket server (the tests stop at
make_connection(), where every reported failure occurred); the syncRetrypass-through at the end of the function is pre-existing and left for a follow-up.Beyond CI: the ten tests pass on redis-py 7.4.1, 8.0.1 and 8.1.0; the
resp3case fails on 7.4.1 and 8.0.1 without the fix, and thetls-subclasscase fails on 8.0.1 when either the plumbing filter or themaint_notifications_configrule is removed.Since the last brief: 7c856ed rebases on 0.30.0, drops the pool-computed keywords, compares sync-only keywords to their own defaults, trims the
aroutecaveat, and folds the tests into two parametrised ones.Reviewed
7c856ed· Claude Code (claude-fable-5-1) · lenses: correctness, security, design, tests, docsNotes
AsyncSentinel, as the issue suggests. The pool does carrysentinel_managerandservice_name, so a later change could rebuild it; that is a separate feature.SemanticRouterdocstring reduces to Sentinel and cluster; I'll trim it there.🤖 Generated with Claude Code
Note
Medium Risk
Changes how sync clients are translated into async pools across index/cache/router async entry points, including TLS and socket settings; incorrect filtering could misconfigure connections, though explicit errors protect sync-only security options.
Overview
Fixes
RedisConnectionFactory.sync_to_async_redisso async paths (SemanticRouter.aroute,AsyncSearchIndex, semantic caches) can reuse a caller’s sync client when it uses TLS, TLS subclasses, or unix sockets, instead of failing on the first command withTypeErroror building a plaintext async pool.Conversion now picks the async connection class with
issubclass(TLS →AsyncSSLConnection, unix →AsyncUnixDomainSocketConnection), filters pool kwargs through_async_connection_kwargs(version-aware via_init_parameters, strips RESP3 pool plumbing, drops sync-only defaults), and raisesValueErrorfor Sentinel-managed pools and for user-set sync-only TLS options (e.g.ssl_validate_ocsp) rather than silently weakening security.SemanticRouter.aroutedocs now say only Sentinel and Cluster needredis_urlfor async routing.Adds
tests/unit/test_sync_to_async_redis.pycovering successful mappings and failure modes without a live Redis server.Reviewed by Cursor Bugbot for commit 7c856ed. Bugbot is set up for automated code reviews on this repo. Configure here.