Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
76 changes: 63 additions & 13 deletions redis/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -2058,6 +2058,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.
# Only the owner's commands renew this window; peer hits do not extend it.
_BUSY_CACHE_GRACE_SECONDS = 0.1

def __init__(
self,
Expand All @@ -2074,6 +2077,11 @@ def __init__(
self._event_dispatcher = self._conn._event_dispatcher
self.credential_provider = conn.credential_provider
self._pool_lock = pool_lock
# Checkout and peer invalidation drains share this connection's lock.
# Release clears _in_use after the last socket access, before enqueueing.
self._in_use_lock = threading.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 @@ -2170,6 +2178,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 @@ -2202,6 +2211,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 @@ -2221,6 +2231,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 @@ -2264,6 +2275,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 @@ -2303,25 +2317,36 @@ 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 candidate is not None:
# Drain the connection that cached the entry so invalidations do not
# accumulate on idle connections. This must run outside our cache lock: the
# drain takes the pool lock, and invalidations call the owner's callback
# under its cache lock. Holding ours would order a cache lock before the
# pool lock and another after it, deadlocking connections draining each
# other or a pool.disconnect() holding the pool lock while waiting for ours.
with self._pool_lock:
self._drain_invalidations(candidate.connection_ref)
# drain takes the owner's checkout lock, and invalidations call the
# owner's callback under its cache lock. Holding ours could deadlock
# two connections draining each other.
owner = candidate.connection_ref
can_serve = False
if isinstance(owner, CacheProxyConnection):
with owner._in_use_lock:
Comment thread
Grivn marked this conversation as resolved.
Resolved
Comment thread
Grivn marked this conversation as resolved.
Resolved
if owner is self or not owner._in_use:
self._drain_invalidations(owner._conn)
Comment thread
Grivn marked this conversation as resolved.
Resolved
Comment thread
Grivn marked this conversation as resolved.
Resolved
can_serve = True
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
elif time.monotonic() < owner._busy_cache_deadline:
# Never touch a checked-out owner's socket. Recent cache
# reads can share its value until its grace period expires.
# Hits do not move ownership or extend that deadline.
can_serve = True
# Custom entries without pool ownership information cannot be drained
# safely and must use a fresh read on our connection.

with self._cache_lock:
# Re-check: the drained entry may have been invalidated or replaced.
entry = self._cache.get(self._current_command_cache_key)

# A hit requires this very entry object to have been drained. A
# A hit requires this very entry object to have been checked. A
# replacement filled during the drain may belong to another connection
# with pending invalidations and is not trusted, even if it is VALID by
# the time we check here.
Expand All @@ -2336,7 +2361,7 @@ def send_command(self, *args, **kwargs):
# and never read the reply we did send;
# - an invalidation removes it, so a re-derived check would find nothing
# and read a reply for a command we never sent.
if entry is candidate:
if can_serve and entry is candidate:
cached_value = entry.cache_value
self._current_command_cache_key = None
is_hit = True
Expand All @@ -2347,14 +2372,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 @@ -2466,14 +2495,20 @@ def read_response(

cache_entry = self._cache.get(self._current_command_cache_key)

# Peers use independent cache locks. Start the sharing window before
# making the reply visible as VALID to avoid unnecessary misses.
self._busy_cache_deadline = (
time.monotonic() + self._BUSY_CACHE_GRACE_SECONDS
)

# Promote only the placeholder this connection staked. It may have been
# invalidated in the meantime, or replaced by another connection's placeholder
# for the same key - promoting that one would bind our reply to its
# ``connection_ref``, and a later hit would drain that connection's own reply.
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
Expand Down Expand Up @@ -2509,7 +2544,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 @@ -2782,7 +2817,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 @@ -2829,6 +2864,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 @@ -4185,6 +4221,11 @@ def get_connection(self, command_name=None, *keys, **options) -> "Connection":
is_created = True
self._in_use_connections.add(connection)

if isinstance(connection, CacheProxyConnection):
# Wait only for this connection's drain, without holding the pool lock.
with connection._in_use_lock:
connection._in_use = True

# Record state transition for observability
pool_name = get_pool_name(self)
if is_created:
Expand Down Expand Up @@ -4299,6 +4340,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 @@ -4687,6 +4730,11 @@ def get_connection(self, command_name=None, *keys, **options):
pass
self._locked = False

if isinstance(connection, CacheProxyConnection):
# Join this connection's drain before connect/can_read uses the socket.
with connection._in_use_lock:
connection._in_use = True

# Record state transition for observability
pool_name = get_pool_name(self)
if is_created:
Expand Down Expand Up @@ -4771,6 +4819,8 @@ def release(self, connection):
)
connection.disconnect()
# Put the connection back into the pool.
if isinstance(connection, CacheProxyConnection):
connection._in_use = False
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
pool_name = get_pool_name(self)
try:
self.pool.put_nowait(connection)
Expand Down
Loading