Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
293e314
fix(csc): avoid draining checked-out cache connections
Grivn Oct 16, 2026
8758e68
fix(csc): preserve concurrent cache hits for busy owners
Grivn Oct 16, 2026
56d0728
Merge upstream master for cache response cloning
Grivn Oct 16, 2026
4cc62a1
fix(csc): initialize grace before publishing cache entries
Grivn Oct 16, 2026
b229c11
Merge master and preserve cache owner protection
Grivn Oct 27, 2026
53b86ce
Reduce cache checkout lock contention
Grivn Oct 27, 2026
1afc08d
Protect idle cached connections during maintenance
Grivn Nov 4, 2026
4236732
Fix cache ownership across maintenance and shutdown
Grivn Nov 4, 2026
4b7c518
fix(csc): coordinate maintenance close and interrupted checkout
Grivn Nov 4, 2026
b34798d
fix(csc): preserve shutdown for custom connection classes
Grivn Nov 4, 2026
185793e
fix(csc): streamline own hits and defer idle close callbacks
Grivn Nov 4, 2026
d75d36c
fix(csc): defer push listeners past idle owner drains
Grivn Nov 4, 2026
dcb8ceb
fix(csc): coordinate migration cleanup and reauthentication
Grivn Nov 5, 2026
e3de6a2
fix(csc): release callbacks outside the pool lock
Grivn Nov 5, 2026
2eb32d3
Merge master into fix/cache-busy-owner-invalidation
Grivn Nov 5, 2026
e93b9c8
fix(csc): finish removed-owner disconnect after draining
Grivn Nov 5, 2026
4a6640a
fix(csc): dispatch TTL completions outside handler locks
Grivn Nov 5, 2026
0e46407
fix(csc): preserve maintenance event order outside locks
Grivn Nov 5, 2026
5e30d88
fix(csc): recheck Sentinel ownership and order disconnect events
Grivn Nov 5, 2026
4fca5bd
fix(csc): defer topology refresh beyond idle owner drains
Grivn Nov 5, 2026
dce6482
fix(csc): release idle AUTH ownership before topology callbacks
Grivn Nov 6, 2026
3c40947
fix(csc): preserve maintenance callbacks across reconfiguration
Grivn Nov 6, 2026
408c694
fix(csc): serialize maintenance transitions with handoffs
Grivn Nov 6, 2026
666f78d
fix(csc): finish idle disconnects before connection reuse
Grivn Nov 6, 2026
85488e4
fix(csc): avoid maintenance release and finalizer deadlocks
Grivn Nov 6, 2026
94aafff
fix(pool): release maintenance lock before waiting for a slot
Grivn Nov 6, 2026
bb486d2
fix(csc): refresh reused owners and guard rejected releases
Grivn Nov 7, 2026
984fe9b
fix(csc): clear ownership when discarding cached connections
Grivn Nov 7, 2026
0beab63
fix(csc): keep idle invalidation polls nonblocking during maintenance
Grivn Nov 7, 2026
943f490
fix(csc): defer reentrant finalizer cleanup until idle drain completes
Grivn Nov 7, 2026
426b0f7
fix(csc): finish reentrant pool shutdown outside AUTH locking
Grivn Nov 7, 2026
7db6bd6
Fix cached pool teardown reentry and cancelled maintenance delivery
Grivn Nov 8, 2026
0b408fb
fix(csc): defer nested shutdown during final cached release
Grivn Nov 9, 2026
887b027
Merge current master and preserve cached-pool ownership ordering
Grivn Nov 9, 2026
342579e
fix: defer cached teardown during pool snapshot and rejected release
Grivn Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 55 additions & 10 deletions redis/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -2056,6 +2056,9 @@ class CacheProxyConnection(MaintNotificationsAbstractConnection, ConnectionInter
DUMMY_CACHE_VALUE = b"foo"
MIN_ALLOWED_VERSION = "7.4.0"
DEFAULT_SERVER_NAME = "redis"
# Bound peer reads when a checked-out owner stops processing invalidations.
# Active readers renew this window without extra network requests.
_BUSY_CACHE_GRACE_SECONDS = 0.1

def __init__(
self,
Expand All @@ -2072,6 +2075,10 @@ def __init__(
self._event_dispatcher = self._conn._event_dispatcher
self.credential_provider = conn.credential_provider
self._pool_lock = pool_lock
# Only a pool release makes the connection available for another
# connection's invalidation drain. Pools update this under _pool_lock.
self._in_use = True
self._busy_cache_deadline = 0.0
self._cache = cache
self._cache_lock = threading.RLock()
self._current_command_cache_key = None
Expand Down Expand Up @@ -2168,6 +2175,7 @@ def on_connect(self):
self._conn.on_connect()

def disconnect(self, *args, **kwargs):
self._busy_cache_deadline = 0.0
with self._cache_lock:
self._cache.flush()
# Both flags describe an exchange on the socket that is about to die with it. A stale
Expand Down Expand Up @@ -2200,6 +2208,7 @@ def send_packed_command(self, command, check_health=True):
self._pending_caching_reply = False
self._skip_next_caching = False
self._pending_cache_hit = _NO_PENDING_HIT
self._busy_cache_deadline = 0.0
self._conn.send_packed_command(command)

def send_command(self, *args, **kwargs):
Expand All @@ -2219,6 +2228,7 @@ def send_command(self, *args, **kwargs):
# slot, and the attempt is tracked by default under optout anyway.
self._skip_next_caching = False
self._current_command_cache_key = None
self._busy_cache_deadline = 0.0
self._conn.send_command(*args, **kwargs)
return

Expand Down Expand Up @@ -2262,6 +2272,9 @@ def send_command(self, *args, **kwargs):

if not store:
self._current_command_cache_key = None
# PubSub, blocking commands and unknown commands may hold this
# connection indefinitely without processing invalidations.
self._busy_cache_deadline = 0.0

# One path for keyless and ineligible commands alike, so an eligible read whose
# command method never plumbed ``keys=`` still gets its optout ``NO``. That is the
Expand Down Expand Up @@ -2296,12 +2309,23 @@ def send_command(self, *args, **kwargs):
# (which returns non-push replies too) would consume either one.
entry = self._cache.get(self._current_command_cache_key)
if entry is not None and entry.status != CacheEntryStatus.IN_PROGRESS:
with self._pool_lock:
self._drain_invalidations(entry.connection_ref)

# Re-check: the entry may have been invalidated during the drain, or filled
# in by the connection that was fetching it.
entry = self._cache.get(self._current_command_cache_key)
owner = entry.connection_ref
if isinstance(owner, CacheProxyConnection):
with owner._pool_lock:
if owner is self or not owner._in_use:
self._drain_invalidations(owner._conn)
# The drain may have invalidated the cached value.
entry = self._cache.get(self._current_command_cache_key)
elif time.monotonic() >= owner._busy_cache_deadline:
# Never touch a checked-out owner's socket. Recent
# cache reads can share its value, but a pending wire
# reply or an owner that stopped reading needs a fresh
# read on our connection. Hits do not move ownership.
entry = None
else:
# A custom entry without pool ownership information cannot
# be drained safely.
entry = None

# Whether this read is served locally is decided here, once, and the value is
# carried to ``read_response`` - which must not re-derive it from cache state.
Expand Down Expand Up @@ -2330,14 +2354,18 @@ def send_command(self, *args, **kwargs):
cache_key=self._current_command_cache_key,
cache_value=self.DUMMY_CACHE_VALUE,
status=CacheEntryStatus.IN_PROGRESS,
connection_ref=self._conn,
connection_ref=self,
)
)

if is_hit:
self._pending_cache_hit = _clone_cache_value(cached_value)
return

# A wire reply may be followed by invalidations we have not read yet.
# Keep peers on the fresh-read path until this exchange completes.
self._busy_cache_deadline = 0.0

# Send command over socket only if it's allowed
# read-only command that not yet cached. Under optin the server remembers nothing
# unless ``CLIENT CACHING YES`` comes right before the read, so this - the one place
Expand Down Expand Up @@ -2456,13 +2484,16 @@ def read_response(
if (
cache_entry is not None
and cache_entry.status == CacheEntryStatus.IN_PROGRESS
and cache_entry.connection_ref is self._conn
and cache_entry.connection_ref is self
):
cache_entry.cache_value = cloned_response
cache_entry.status = CacheEntryStatus.VALID
self._cache.set(cache_entry)

self._current_command_cache_key = None
self._busy_cache_deadline = (
time.monotonic() + self._BUSY_CACHE_GRACE_SECONDS
)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

return response

Expand Down Expand Up @@ -2492,7 +2523,7 @@ def _drop_own_placeholder(self) -> None:
if (
entry is not None
and entry.status == CacheEntryStatus.IN_PROGRESS
and entry.connection_ref is self._conn
and entry.connection_ref is self
):
self._cache.delete_by_cache_keys([key])

Expand Down Expand Up @@ -2765,7 +2796,7 @@ def _enable_tracking_callback(self, conn: ConnectionInterface) -> None:
if (
entry is not None
and entry.status == CacheEntryStatus.IN_PROGRESS
and entry.connection_ref is self._conn
and entry.connection_ref is self
):
own_placeholder = entry
self._cache.flush()
Expand Down Expand Up @@ -2812,6 +2843,7 @@ def _drain_invalidations(self, conn: ConnectionInterface):

def _process_pending_invalidations(self):
self._drain_invalidations(self._conn)
self._busy_cache_deadline = time.monotonic() + self._BUSY_CACHE_GRACE_SECONDS

def _on_invalidation_callback(self, data: List[Union[str, Optional[List[bytes]]]]):
with self._cache_lock:
Expand Down Expand Up @@ -4133,6 +4165,8 @@ def get_connection(self, command_name=None, *keys, **options) -> "Connection":
connection = self.make_connection()
is_created = True
self._in_use_connections.add(connection)
if isinstance(connection, CacheProxyConnection):
connection._in_use = True

# Record state transition for observability
pool_name = get_pool_name(self)
Expand Down Expand Up @@ -4248,6 +4282,8 @@ def release(self, connection: "Connection") -> None:
f"{connection}, {connection.extract_connection_details()}"
)
connection.disconnect()
if isinstance(connection, CacheProxyConnection):
connection._in_use = False
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
self._available_connections.append(connection)
self._event_dispatcher.dispatch(
AfterConnectionReleasedEvent(connection)
Expand Down Expand Up @@ -4612,6 +4648,12 @@ def get_connection(self, command_name=None, *keys, **options):
pass
self._locked = False

if isinstance(connection, CacheProxyConnection):
# Queue checkout is independent of _lock. Join the invalidation
# drain's critical section before connect/can_read can use the socket.
with self._lock:
connection._in_use = True

# Record state transition for observability
pool_name = get_pool_name(self)
if is_created:
Expand Down Expand Up @@ -4696,6 +4738,9 @@ def release(self, connection):
)
connection.disconnect()
# Put the connection back into the pool.
if isinstance(connection, CacheProxyConnection):
with self._lock:
connection._in_use = False
pool_name = get_pool_name(self)
try:
self.pool.put_nowait(connection)
Expand Down
Loading