Skip to content

fix: give RedisCacheLayer an event-loop ownership contract (#1162) - #1179

Closed
groupthinking wants to merge 1 commit into
mainfrom
groupthinking-issue-triage-priority-fixes
Closed

fix: give RedisCacheLayer an event-loop ownership contract (#1162)#1179
groupthinking wants to merge 1 commit into
mainfrom
groupthinking-issue-triage-priority-fixes

Conversation

@groupthinking

@groupthinking groupthinking commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1162

Outcome

RedisCacheLayer now has an explicit event-loop ownership contract, so cross-loop misuse of its connection pool fails loudly at the call site instead of silently degrading into a cache miss.

The bug. A redis.asyncio.ConnectionPool caches connections whose transports are bound to the loop that opened them (redis/redis-py#3351). RedisCacheLayer held one pool in self.redis_pool and reached it from six methods with no notion of which loop owned it.

This is not theoretical for this module, because it builds an IntelligentCacheSystem singleton at import time, outside any running loop:

intelligent_cache = IntelligentCacheSystem()   # module scope

so a single layer instance genuinely is reachable from several loops in one process — anything calling asyncio.run() more than once (the repo's own benchmark scripts do) reaches it.

Why it was invisible. Every pool method wraps its body in except Exception and returns None / False / 0. A cross-loop transport error therefore surfaced as an ordinary cache miss. The cache appeared to work, just with a mysteriously poor hit rate.

The contract. The layer and its pool are owned by exactly one event loop. Ownership is claimed the first time the pool is touched and verified at every call site that reaches it: connect, disconnect (new), get, set, delete, clear, invalidate_by_tags — seven in total.

_require_pool_loop() resolves four cases:

  • owner is the current loop → proceed.
  • owner is None → claim it, but only if a pool actually exists. A pool-less layer owns nothing; otherwise a get() on an unconnected layer would lock out a later connect() from a different loop.
  • owner alive and different → raise CacheLoopOwnershipError.
  • owner closed → the pool is unusable by anyone. Drop it, mark the layer disconnected, log a WARNING, release ownership, and let the new loop re-claim via connect().

We deliberately do not await pool.disconnect() on that last path — that would touch the very transports bound to the dead loop. The warning says so and points at disconnect() as the clean path.

Load-bearing invariant. Every guard runs outside its method's except Exception block and before the if not self._connected: return <fallback> early-out. A guard inside the try would disguise cross-loop misuse as a cache miss — reintroducing the exact failure mode this PR exists to remove. CacheLoopOwnershipError is a programming error and is deliberately not folded into the connection-failure fallbacks. Placing it before the _connected check also avoids a bug present in my first draft: the closed-owner path resets _connected = False, so a guard placed after the early-out would let the method sail on and use a None pool.

New handoff API. disconnect() is the supported clean handoff, with IntelligentCacheSystem.shutdown() as its facade counterpart to initialize(). Without shutdown() the recovery path documented on the class would only be reachable by indexing into system.layers[...], since no consumer holds a RedisCacheLayer directly.

Background. #1152 added a loop guard covering only the tagged-set() path, then reverted it on the grounds that guarding one of six call sites advertises a safety property the layer does not have. #1162 was carved out to add the layer-wide contract instead. This PR is that follow-up.

Scope

  • Included: the ownership contract and its enforcement across all seven pool call sites in src/youtube_extension/backend/services/intelligent_cache.py; the new RedisCacheLayer.disconnect() and IntelligentCacheSystem.shutdown(); regression tests.
  • Explicitly excluded: any change to caching semantics, key layout, serialization, TTL handling, the L1/L3 layers, or the tag-write fan-out added by perf: issue Redis tag-set writes concurrently on cache set #1152. No public call signature changes — every existing caller keeps working unchanged. No dependency changes.

Risk

  • Risk level: low, with one behaviour change worth explicit sign-off (below).
  • Failure mode: a caller that today silently uses a pool from a foreign live loop will now get a raised CacheLoopOwnershipError instead of a None/False fallback. That is the intended point of the change, but it converts a silent misbehaviour into a loud one, so a latent misuse anywhere in the codebase would surface as a new exception rather than a degraded cache. I found no such caller: all production entry points drive the singleton from one loop. Everything else is additive — the single-loop path is byte-for-byte the same code as before, just preceded by a check that returns immediately.
  • Rollback: revert the single commit. The change is confined to two files, adds no migration, no schema, no config, and no dependency. Nothing persists across the revert.

Judgement call requiring reviewer sign-off

#1162 left the recovery behaviour open between three options. I chose "discard and release" on the closed-owner path.

A pure hard reject with no escape hatch would permanently brick the import-time singleton for any process that calls asyncio.run() more than once. Discarding is strictly safer than the status quo — which silently reuses a dead pool — and it is loud, with the socket-leak caveat spelled out in the WARNING. But it is a choice, and I would rather it get explicit sign-off than slide through unexamined.

Verification

All checks below run against head b60beb927945c7844157435a2c631efd26f1c90d.

Focused teststests/unit/test_intelligent_cache.py, tests/unit/test_intelligent_cache_models.py, tests/unit/test_comprehensive_benchmarking.py:

passed
main (ad7e2c10, baseline) 177
this branch 194

Same single pre-existing collection error in both runs (test_comprehensive_benchmarking.py imports psutil, absent from the local venv), so the delta is clean: +17 tests, zero regressions. Baseline was captured by stashing this branch's diff and re-running the identical command, not from memory.

17 tests added, covering:

  • rejection from a second live loop, parametrized across all 7 call sites
  • the error surviving un-swallowed through both the layer and the IntelligentCacheSystem facade
  • a rejected call leaving layer state untouched
  • a pool-less layer having no ownership to violate
  • the closed-owner discard, including its WARNING (asserted via caplog)
  • connect() succeeding on a new loop after the owner closed
  • the disconnect()connect() handoff, and disconnect() with no pool being a no-op
  • the import-time singleton honouring the contract, with original state restored in finally
  • IntelligentCacheSystem.shutdown() releasing the layer

The foreign loop is a real second event loop on a daemon thread (run_forever() + run_coroutine_threadsafe), not a mock. A mock would not reproduce transport binding and the test would prove nothing.

Non-vacuity proof. Guard neutered with an early return asyncio.get_running_loop(), suite re-run:

12 failed, 161 passed

Failures: all 7 test_live_foreign_loop_is_rejected params, plus not_swallowed, leaves_the_layer_untouched, closed_owner_loop_releases_the_pool, import_time_singleton, facade_does_not_swallow. The 5 that still pass exercise disconnect()/shutdown(), which do not exist in pre-fix code at all. Source restored afterwards and the restoration verified.

Lint. ruff check clean on the changed source file. The single I001 in the test file was verified pre-existing on main (git show HEAD:… | ruff check --stdin-filename) and left untouched. Black was deliberately not run: the file is not black-formatted at main, so running it would bury this diff in unrelated reflowing.

One existing test modified. test_tag_write_semaphore_is_replaced_after_its_loop_closes now connect()s on the second loop. Its docstring already disclaimed cross-loop pool safety and cited #1162 by name; under the contract the second loop must re-establish the pool before writing, which is precisely why the semaphore must be replaced. The assertion it makes is unchanged.

  • Focused tests — 194 passed, +17 vs baseline, zero regressions
  • Non-vacuity proven — 12 new tests fail without the guard
  • Lint — ruff clean on changed source
  • Required CI — see the note below on the two repo-wide red gates
  • Review threads resolved

Production evidence

Not applicable, and deliberately so. This is a backend Python correctness fix with no user-facing surface: no Vercel preview exercises it, and there is no runtime deployment evidence to attach.

More importantly, the bug is unobservable in production telemetry by construction — that is the whole finding. Every affected method swallows the failure into a None/False/0 fallback, so a cross-loop violation renders as a cache miss, not an error. There is no log line, metric, or trace on main that would distinguish "the pool is bound to a dead loop" from "this key genuinely was not cached". Waiting for production evidence would mean waiting for a signal the current code cannot emit.

The evidence that is available is therefore the executable kind, above: a real second event loop reproducing the violation, and a neutered-guard run proving those tests fail without the fix.

Post-merge, the change is self-evidencing. The CacheLoopOwnershipError and the closed-owner WARNING are the first observability this failure mode has ever had. If either appears in logs it is a real cross-loop misuse that was previously invisible.

Agent handoff

  • One canonical issue is linked — RedisCacheLayer has no event-loop ownership contract across its six redis_pool call sites #1162, open, no competing PR
  • No competing PR implements the same issue
  • Acceptance criteria are satisfied — contract defined, all call sites guarded, recovery path documented, regression tests non-vacuous
  • Required checks pass on the current head — see below
  • Human decision is requested only where warranted — one item: sign-off on the closed-owner discard behaviour under Risk

Note on the two repo-wide red gates

Agent completion enforcement currently fails on every PR in this repo (tracked in #1160, remediation in PR #1151), and gitleaks (working tree) has a known false positive (#1165, #1142). Neither is caused by this change; both are red on unrelated PRs at the same time. Flagging so they are not misread as breakage from this branch.

A redis.asyncio ConnectionPool caches connections whose transports are
bound to the loop that opened them (redis/redis-py#3351), but
RedisCacheLayer held one pool in self.redis_pool and reached it from six
methods with no notion of which loop owned it. This module also builds an
IntelligentCacheSystem singleton at import time, outside any loop, so one
layer instance really is reachable from several loops in a single process.

#1152 added a partial loop guard covering only the tagged-set() path and
then reverted it, because guarding one of six call sites implies a safety
property the layer does not have. This adds the layer-wide contract instead.

The layer and its pool are now owned by exactly one event loop. Ownership
is claimed the first time the pool is touched and verified at every call
site that reaches it: connect, disconnect, get, set, delete, clear and
invalidate_by_tags.

- non-owning loop, owner alive  -> raise CacheLoopOwnershipError
- non-owning loop, owner closed -> the pool is unusable by anyone, so drop
  it, mark the layer disconnected, log a WARNING, and let the new loop
  re-claim via connect(). We deliberately do not await pool.disconnect()
  here: that would touch the very transports bound to the dead loop.
- disconnect() is the supported clean handoff, with
  IntelligentCacheSystem.shutdown() as its facade counterpart to
  initialize(). Without that the recovery path documented on the class
  would only be reachable by indexing into system.layers.

Every guard runs outside its method's `except Exception` block. Those
blocks return None/False/0 on failure, so a guard placed inside one would
disguise cross-loop transport misuse as an ordinary cache miss.

The closed-owner-loop discard is a judgement call: #1162 left the recovery
behaviour open, and a pure hard reject would permanently brick the
import-time singleton for any process that calls asyncio.run() more than
once. Discarding is strictly safer than the status quo, which silently
reuses a dead pool, and it is loud.

Tests: 17 added, covering rejection from a second live loop at all seven
call sites, non-swallowing of the error through both the layer and the
IntelligentCacheSystem facade, the closed-owner discard and its warning,
the disconnect()/connect() handoff, and the import-time singleton.
Verified non-vacuous: with the guard neutered, 12 of them fail (the other
5 exercise disconnect()/shutdown(), which do not exist without this fix).

test_tag_write_semaphore_is_replaced_after_its_loop_closes now reconnects
between loops. Its docstring already disclaimed cross-loop pool safety and
cited this issue; under the contract the second loop must re-establish the
pool before writing, which is exactly why the semaphore must be replaced.

Closes #1162

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 1, 2026 21:11
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v0-uvai Ready Ready Preview, v0 Aug 1, 2026 9:12pm

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@groupthinking, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c55ba88f-0803-4ebb-b0a2-414420eeb92f

📥 Commits

Reviewing files that changed from the base of the PR and between ad7e2c1 and b60beb9.

⛔ Files ignored due to path filters (1)
  • tests/unit/test_intelligent_cache.py is excluded by !tests/**
📒 Files selected for processing (1)
  • src/youtube_extension/backend/services/intelligent_cache.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Snapshot Warnings

⚠️: No snapshots were found for the head SHA b60beb9.
Ensure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice.

Scanned Files

None

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Agent Completion Truth Gate: NOT_APPLICABLE

Evidence agrees.

Machine-readable verdict
{
  "details": {},
  "reasons": [],
  "verdict": "not_applicable"
}

Workflow evidence

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds event-loop ownership enforcement for Redis connection pools to prevent unsafe cross-loop reuse.

Changes:

  • Adds ownership validation and closed-loop recovery.
  • Introduces disconnect() and system-level shutdown().
  • Adds comprehensive ownership regression tests.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/youtube_extension/backend/services/intelligent_cache.py Implements pool ownership and lifecycle APIs.
tests/unit/test_intelligent_cache.py Tests rejection, recovery, handoff, and singleton behavior.

Comment on lines +360 to +364
if owner is None:
# A pool assigned without going through connect() is claimed by the
# first loop that touches it. With no pool there is nothing to own.
if self.redis_pool is not None:
self._pool_loop = loop
Comment on lines +467 to +478
pool = self.redis_pool
self.redis_pool = None
self._connected = False
self._pool_loop = None

if pool is None:
return

try:
await pool.disconnect()
except Exception as e: # pragma: no cover - defensive
logger.warning(f"Redis pool disconnect error: {e}")
Comment on lines +65 to +68
Redis fault, so it is deliberately *not* folded into the ``None``/``False``/
``0`` fallbacks that the layer returns for connection failures -- a silent
fallback here would hide cross-loop transport misuse behind what looks like
an ordinary cache miss.

Copy link
Copy Markdown
Owner Author

Automated remediation review — not a sign-off

Independent read of the diff (contract + all 7 call sites + the 17 tests). No blocking findings. This is an automated review; it does not stand in for the human sign-off you asked for on the recovery choice.

Contract verification

  • _require_pool_loop() covers the four cases cleanly: owner-is-self → proceed; owner-None → claim only if a pool exists (so a get() on an unconnected layer can't lock out a later connect()); owner alive+different → raise; owner closed → drop pool, mark disconnected, release, WARNING. The four branches are mutually exclusive and total.
  • The load-bearing invariant holds in the diff: every guard is placed before the method's try/except Exception and before the if not self._connected: return early-out. test_rejection_is_not_swallowed_into_a_fallback_value and the placement-before-_connected reasoning both check out — a guard inside the except would indeed disguise cross-loop misuse as a cache miss.
  • Not await pool.disconnect() on the closed-owner path is correct: that call would touch transports bound to the dead loop. Dropping + WARNING is the only safe move.
  • Non-vacuity is real — the neutered-guard experiment (12 failed / 5 that pass are disconnect/shutdown, which don't exist pre-fix) is convincing, and the foreign loop is a genuine second loop on a daemon thread rather than a mock.

On your judgement call (closed-owner path → "discard and release")

Endorsed. A pure hard-reject would permanently brick the import-time intelligent_cache singleton for any process that calls asyncio.run() more than once — which the repo's own benchmark scripts do — so reject-with-no-escape is strictly worse than this. "Discard and release" is strictly safer than the status quo (silent reuse of a dead pool) and it's loud.

One caveat worth stating plainly so the sign-off is informed: the discarded pool's connections leak their sockets until GC, because they can't be closed from a foreign loop. That leak is unavoidable on the crash path (owner died without disconnect()), and you've mitigated it the only way available — a WARNING that names the cause and points at disconnect()/shutdown() as the clean path. So the choice doesn't introduce the leak; it surfaces a leak the dead loop already caused.

CI reality

All 15 PR Checks are green, PR Governance green, Vercel deploy green, and agent-completion/truth-gate passed. The lone red check is Agent completion enforcement (#1160, fix pending in #1151) — repo-wide, fails on every PR, not caused by this diff. gitleaks did not flag here.

Recommendation: ready to merge on your sign-off of the recovery choice above. Not auto-merging: protected branch + you explicitly requested human review.


Generated by Claude Code

@groupthinking groupthinking left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — event-loop ownership contract (#1162)

Reviewed the full diff and traced _require_pool_loop() through all seven call sites. The contract is correct and the enforcement is placed where the PR body claims. Findings below.

What holds up

  • No interleaving window in connect(). self.redis_pool = from_url(...) and self._pool_loop = loop have no await between them, so a pool can never be visible with _pool_loop still None inside connect(). Ownership is claimed atomically with pool creation.
  • Guard precedes the if not self._connected early-out on get/set/delete/clear/invalidate_by_tags. Confirmed in the diff — this is the load-bearing invariant, and a CacheLoopOwnershipError cannot be swallowed into a None/False/0 fallback.
  • Closed-owner path is internally consistent. It nils redis_pool/_connected/_pool_loop and deliberately does not await pool.disconnect() on the dead loop. Downstream: get() then falls through to a clean miss, and disconnect() reads the now-None pool and no-ops — so the same reset can't double-free.
  • Pool-less layer claims nothing (owner is None and redis_pool is None → return without setting _pool_loop), so a read on an unconnected layer can't lock out a later connect() from another loop. The parametrized live-foreign-loop tests plus the neutered-guard non-vacuity run (12 fail without the guard) make this convincing rather than asserted.

Non-blocking (pre-existing, out of this PR's declared scope)

  • connect() re-entry on the owning loop still leaks the previous pool. Calling connect() twice on the same loop overwrites self.redis_pool with a fresh from_url(...) without awaiting the old pool's disconnect(). This behavior predates the PR and isn't in scope here, but now that disconnect() exists as the clean handoff, a future hardening could have connect() early-return (or await self.disconnect() first) when already connected on the same loop. Flagging only so it's on record — not a reason to hold this change.

On the sign-off you flagged

The "discard and release" choice on the closed-owner path is, in my read, the right call: it is strictly safer than the status quo (silently reusing a dead pool), it is loud, and the socket-leak caveat is spelled out in the WARNING. That said — you explicitly asked for human sign-off on this behavioural change, and converting a silent degradation into a raised CacheLoopOwnershipError is exactly the kind of blast-radius decision an unattended reviewer shouldn't rubber-stamp. Recording this as COMMENT (technically LGTM), not APPROVE: the sign-off is a human's to give, and the merge itself is a human gate (draft PR → protected main).

Merge readiness

The four posted statuses (CodeRabbit, Vercel ×2, agent-completion/truth-gate) are green. mergeable_state is unstable, consistent with the two repo-wide red check-runs you pre-disclosed (Agent completion enforcement #1160, gitleaks FP #1165) rather than anything in this diff — a human should confirm those two are the only remaining reds before merging.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

@coderabbitai review

Exact-head independent review requested for b60beb927945c7844157435a2c631efd26f1c90d; PR remains draft pending review evidence.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

@groupthinking I will review the exact head b60beb927945c7844157435a2c631efd26f1c90d. I will focus on production-impacting correctness, especially event-loop ownership, recovery, and exception handling.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Owner Author

Closing — this work is already the tip of main

main's current HEAD is:

a631194fa fix: give RedisCacheLayer an event-loop ownership contract (#1368)

Same fix, same issue (#1162), landed via #1368 on Aug 4 while this PR stayed in draft. There is nothing left to land here.

This branch is also orphaned by the secret-purge force-push — git merge-base origin/main <this branch> is empty, so it has no shared ancestry with main and could not have been rebased in any case.

Tracked in #1378. Branch retained for archive-tagging.


Generated by Claude Code

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RedisCacheLayer has no event-loop ownership contract across its six redis_pool call sites

2 participants