Route every non-ORM Redis client through utils/redis_client.py - #3171
tomcounsell wants to merge 11 commits into
Conversation
…mport Hotfix 3c77e1e rewrote bridge/telegram_relay.py::_get_redis_connection from a REDIS_URL-built client to `from utils.redis_client import text_redis`, along with four other call sites (tools/send_message.py:84, tools/valor_telegram.py:723, agent/session_completion.py:411 and :573). The module itself lives only on redis-client-accessor-3003, the head of the still-open PR #3171, so main has carried five imports of a file that is not on main since 2026-09-05. The import is lazy (inside the function body) and process_outbox wraps its whole body in a broad except, so the bridge booted clean and reported healthy while every poll raised ModuleNotFoundError and logged one line. Result: telegram:outbox:* never drained for ~26 hours. Every agent-authored Telegram reply queued, sat, and expired on the 1-hour TTL. No dead letter either — the DLQ placement path is inside the same failing try block. This lands the accessor module verbatim from #3171 and nothing else. The consumer rewrites are already on main; the tests and the remaining ~15 call-site conversions stay with the PR, which now merges as a superset. Refs #3003
ba7d82f to
4a51b55
Compare
Review (Judge code-quality):Verdict: CHANGES REQUESTED — head Blockers1. The removed Measured on this branch: All production constructor sites are clean ( Tech Debt2. 3. The module docstring and the shipped doc overclaim what a derived client inherits. 4. 5. The "one-line seam per module" claim doesn't hold for Nits6. 7. 8. 9. The AST guard's alias handling is narrower than its docstring. Verified clean
|
Review (Judge risk):Verdict: CHANGES REQUESTED — head Blockers1. # bridge/email_relay.py:261-262
r = await asyncio.to_thread(_get_redis_connection)
keys = await asyncio.to_thread(r.keys, EMAIL_OUTBOX_KEY_PATTERN)
# bridge/email_relay.py:301-303
except Exception as e:
logger.error(f"Email relay: outbox processing error: {e}", exc_info=True)
return sent
One honest mitigation, since it bears on severity: the heartbeat write at line 267-272 comes after the The PR body's disclosure ("No converted site issues a blocking list op") is accurate as far as it goes — there is no Tech Debt2. Related: 3. 4. Nits5. Deploy note. Verified clean
|
valorengels
left a comment
There was a problem hiding this comment.
Review: CHANGES REQUESTED
Head 50145291b. Mode: independent roster (2 judges) — code-quality and risk dispatched as separate agents with the same PR context and no sight of each other's findings; per-judge comments posted above. Consensus rule any-blocker-wins. No plan document exists for #3003 (bug-backlog wave item), so this was reviewed against the issue and the PR body.
The core of this change is right and the sweep is genuinely complete. Two blockers stand between it and merge, one of which is mechanical.
Independent sweep — confirms the claim
Re-run on this branch rather than trusting the PR's count. Across all thirteen production packages (agent analytics bridge config mcp_servers models monitoring reflections scripts tools ui utils worker):
- Raw client construction outside
utils/redis_client.py: zero. - Ad-hoc
REDIS_URLreads in production code: zero. The only remaining mentions are two CLI error strings (tools/valor_email.py:472,tools/valor_telegram.py:1123), the settings field description,config/redis_bootstrap.py(the sanctioned bootstrap), and docstrings.
I can't independently reproduce "24" as a historical total — six sites landed on main separately as b1a506770 before the rebase. What this diff converts is 14 seams across 13 modules, and after it, nothing in the tree builds its own client. That is the claim that matters and it holds.
The recurrence guard fires
Tested rather than read. Appending a raw client to analytics/__init__.py and re-running the scan:
E AssertionError: Raw Redis client construction outside utils/redis_client.py. ... analytics/__init__.py:20
Reverted immediately. test_the_scan_covers_every_production_package is the right fix for the monitoring/ miss — it is what turns a hand-maintained list from a silent blind spot into a failing test. No false positives: the full 13-package scan is green on this branch, and the only exemption is utils/redis_client.py by explicit path.
tests/unit/test_redis_client_accessor.py: 13 passed. The PR's claimed set (test_bridge_watchdog, test_bridge_liveness, test_dedup, test_ui_app, test_email_relay, test_email_history, test_valor_email, test_react_with_emoji, test_dead_letters, test_tool_call_delivery, test_session_executor_runner_dispatch, test_email_bridge, test_routing) reproduces exactly: 525 passed, 1 skipped. ruff check and ruff format --check both clean.
Blockers
1. The removed redis_url kwarg breaks 13 test call sites (code-quality judge). tests/unit/output_handler/ — the suite that most directly exercises the class this PR changed — is absent from the PR's test table, and fails 61 tests with TypeError: TelegramRelayOutputHandler.__init__() got an unexpected keyword argument 'redis_url'. Two more sites in tests/integration/test_message_drafter_integration.py:134,297. Every production constructor site is clean, so this is a test fix, not a design problem — but the PR's own numbers were measured on a set that skipped it.
2. bridge/email_relay.py:262 — KEYS over the full keyspace now runs under a 5s socket timeout (risk judge). text_redis() adds socket_timeout=5.0 where from_url had none. KEYS is O(keyspace) on a Redis that has historically held millions of keys, and a TimeoutError there is swallowed by the blanket handler at :301-303, stalling the email send path in a loop that keeps looking alive. Detectable — the heartbeat write sits after the KEYS, so email-status would go stale — but it is an undisclosed behavior change on the outbound path, and the fix is one line (scan_iter with a bound, or derived_redis(..., socket_timeout=None) for this call).
The PR's disclosure sentence is accurate but too narrow. There is genuinely no blpop/brpop/blmove anywhere in the converted modules — I checked all fourteen — but a blocking list op is not the only way to exceed a request timeout.
Behavior preservation — per site
Spot-checked every converted site, not a sample. No TTL, ex=, expire(), or decode_responses value changed anywhere in the diff. bridge/routing.py's decode_responses=False is preserved through bytes_redis() (popoto's client carries no decode_responses kwarg, so it defaults to False). The only pipeline user, bridge/email_bridge.py:786,798 (transaction=True), is safe under a shared client since pipelines check out their own connection. No converted site closes the client it is handed. The text_redis() cache cannot serve a stale client across a test-db repoint: the identity half of the cache key fully determines the connection target, so an id() collision after GC still misses.
The drift that is real is all in connection policy, and it is undisclosed in both the PR body and docs/features/redis-client-accessor.md: the 5s timeout (named, but only for text_redis), bridge/routing.py moving onto popoto's BlockingConnectionPool(128, timeout=20) from an async call site, and text_redis() getting redis-py's unbounded default pool with no health_check_interval — dropping the cap popoto sets deliberately. Details in the two judge comments.
Tech Debt
_identity_kwargs(utils/redis_client.py:66-79) silently drops TLS —rediss://is expressed inconnection_class, notconnection_kwargs, so every converted site would build a plaintext client against a TLS port. Unreachable on today's localhost deployment; this PR is what makes the module the fleet-wide chokepoint.- The module docstring and
docs/features/redis-client-accessor.md:16-21claim a derived client follows the bootstrap's retry policy "for free". It doesn't — only the repoint. Fifteen lines later the same file says each accessor chooses its own retry policy, and none does. _IDENTITY_KEYS(utils/redis_client.py:53) is dead — referenced nowhere in the repo, and it disagrees with the inline handling in_identity_kwargsit purports to describe.bridge/routing.pypool sharing andtext_redis()'s unbounded pool, above.bridge/email_dead_letter.py:82— unbounded full-keyspacescan_iterwith nocount=, now on the shared client.- The "one-line
_get_redis()seam per module" claim in the PR body and the doc doesn't hold foragent/output_handler.py.
Nits
ui/app.py:29-33— the seam is wedged between the imports andlogger.self._redison both handlers is a test-injection point never written by production code; the comment should say so.tests/unit/test_redis_client_accessor.py:63-68mutates module globals by bare assignment with no restore, leaking the evicted client's pool.- The AST guard's alias handling is narrower than its docstring:
from redis import Redis,redis.asyncio.Redis, andConnectionPool.from_urlall pass. No current offenders, so latent. - Deploy:
./scripts/valor-service.sh restartcovers the bridge and watchdog only. This diff also touches the separateemail-bridgeservice,worker/, and the dashboard —email-restartandworker-restartbelong in the merge step.
Category judgment
Confirmed: no Popoto model was invented to satisfy the rule. Every converted key is genuinely freeform — outbox lists, liveness stamps, dedup claims, resolver cache, dead letters, email history. bridge/dedup.py already reads LastProcessedRecord through the ORM and its raw client only ever served bridge:last_event / msgclaim / dm_coverage_epoch. bytes_redis() for bridge/routing.py and derived_redis() for the two pubsub connections are the right categories rather than a forced text_redis().
The monitoring/ finding in this PR is the best part of it: a guard exactly as wide as the list that produced it, certifying its own blind spot. test_the_scan_covers_every_production_package is the durable fix.
…est seams, event-loop safety Blocker 1: TelegramRelayOutputHandler.__init__ no longer takes redis_url=, but 13 call sites still passed it (11 in tests/unit/output_handler/, 2 in tests/integration/test_message_drafter_integration.py). All updated to the test-injection seam. Blocker 2: both outbox relays swept keys with a full-keyspace KEYS, which now inherits the shared client's 5s socket timeout, and caught the resulting TimeoutError in a blanket handler that returned 0 -- indistinguishable from "nothing to send". That is the shape that dropped every Telegram reply for 26 hours while health signals stayed green. - utils/redis_client.scan_keys(): cursor-based SCAN, bounded on page size (REDIS__SCAN_COUNT) and total keys (REDIS__SCAN_KEY_LIMIT), returns a truncation flag. - bridge/relay_errors.py: OutboxUnavailableError + report_send_path_failure() (ERROR log + Sentry). A RedisError from the sweep now raises and is reported instead of being folded into the return value; per-message failures stay tolerant as before. - The review named only bridge/email_relay.py:262. bridge/telegram_relay.py:1332 had the identical shape on the Telegram send path; both are fixed, plus the unbounded scan_iter in bridge/email_dead_letter.py. - Relay loops count consecutive outages via an explicit except branch, and the email heartbeat now stamps after the sweep, so a cycle that could not read the outbox does not report itself healthy. Connection-policy drift, previously undisclosed: - bridge/routing.py's resolve_customer ran sync Redis (and a sync IMAP STORE) on popoto's BlockingConnectionPool from inside async def; a pool checkout can block the bridge event loop for up to the 20s pool timeout and socket_timeout does not cover it. All four sites now go through asyncio.to_thread. - text_redis() builds its own bounded pool (REDIS__MAX_CONNECTIONS, REDIS__HEALTH_CHECK_INTERVAL_S). - _identity_kwargs now derives from popoto's sibling_client_kwargs whitelist plus TLS and unix-socket, which the whitelist cannot see. TLS lives in connection_class, not connection_kwargs, so rediss:// was silently losing its SSLConnection. tests/unit/test_relay_send_path_outage.py locks all of it in, including an AST guard proven red against pre-patch HEAD at all three sites. The guard matches `.keys` as a bare attribute: both relays spelled it asyncio.to_thread(r.keys, PATTERN), so a call-only matcher reported the send paths clean.
Twenty-three sites across seventeen production modules built their own
redis client from REDIS_URL at call time, with a hardcoded fallback to
production db 0. Popoto's pool, the one connection tests/conftest.py
repoints at the claimed test database and config/redis_bootstrap.py
rebuilds with retry policy, never reached them; under test they wrote
to production.
utils/redis_client.py is now the one place production code obtains a
client for non-ORM keys, derived from POPOTO_REDIS_DB's live pool:
text_redis() decode_responses=True client on popoto's host/port/
db/auth with socket timeouts from
settings.timeouts.redis_socket_s; cached, rebuilt
when popoto's pool identity changes
bytes_redis() POPOTO_REDIS_DB itself
derived_redis() fresh client on popoto's identity with caller kwargs
Each module keeps its one-line _get_redis()/_get_redis_connection()
seam delegating to the accessor, so the ~80 existing test patches on
those names still hold. The redis_url constructor parameter on
TelegramRelayOutputHandler and EmailOutputHandler is gone: no
production caller passed it; both honour an explicitly assigned
self._redis and otherwise resolve through text_redis().
Classification of the 23 constructions (grep -rn -e "redis.Redis(" -e
"redis.from_url(" -e "Redis.from_url(" -e "StrictRedis(" over agent/
tools/ reflections/ ui/ bridge/ at 45d5d42). Every site reaches
genuinely non-ORM keys; none touched a Popoto-managed key raw, so no
ORM migration was needed. bridge/dedup.py, which #3003 suspected,
already reads LastProcessedRecord through the ORM; its raw client only
served bridge:last_event/msgclaim/dm_coverage_epoch.
site keys now
agent/agent_session_queue.py:1016 pubsub NUMSUB probe derived_redis(decode_responses=False, socket_timeout=redis_socket_s)
agent/agent_session_queue.py:1122 pubsub listener derived_redis(decode_responses=False, socket_timeout=None, socket_connect_timeout=None)
agent/output_handler.py:498 telegram:outbox:*, email:outbox:*, metrics:* text_redis() via handler _get_redis
agent/session_completion.py:425 telegram:outbox:* LLEN text_redis()
agent/session_completion.py:610 telegram:outbox:* RPUSH text_redis()
tools/react_with_emoji.py:66 telegram:outbox:* text_redis()
tools/valor_telegram.py:726 telegram:outbox:* text_redis()
tools/send_message.py:87 telegram:outbox:*, email:outbox:* text_redis()
tools/valor_email.py:66 email:outbox:* text_redis()
tools/email_history/__init__.py:42 email:history:*, email:threads text_redis()
reflections/pm_briefings/delivery.py:79 telegram:outbox:* text_redis()
ui/app.py:461 {project}:session-health:slot_reclaims text_redis() via module _get_redis
ui/app.py:509 worker:slot:leases:*, worker:watchdog:actions:* same
ui/app.py:735 email:auth_failed, email:resolver_unavailable, email:last_poll_ts same
bridge/liveness.py:67 bridge:last_update_received, last_probe_ok, last_missed_recovery text_redis()
bridge/email_relay.py:72 email:outbox:*, email:relay:last_poll_ts text_redis()
bridge/dedup.py:132 bridge:last_event:*, msgclaim:*, dm_coverage_epoch:* text_redis()
bridge/telegram_relay.py:116 telegram:outbox:* text_redis()
bridge/routing.py:1450 customer_resolver:*, resolver:failures:* bytes_redis() (decodes its own values)
bridge/email_dead_letter.py:36 email:dead_letter:* text_redis()
bridge/email_bridge.py:148 email:last_poll_ts, auth_failed, history:*, msgid:* text_redis()
bridge/email_bridge.py:837 email:dead_letter via EmailOutputHandler handler _get_redis -> module _get_redis
One behaviour change worth naming: text_redis() carries a request/
response socket timeout (redis_socket_s, 5s default) where from_url
carried none. No converted site issues a blocking list op (no
blpop/brpop/blmove in any of the seventeen modules).
Recurrence guard: tests/unit/test_redis_client_accessor.py walks agent/
bridge/ tools/ reflections/ ui/ worker/ models/ config/ by AST and fails
on any redis.Redis / StrictRedis / from_url call, under any alias,
outside utils/redis_client.py. The same file proves the contract end to
end: with REDIS_URL pointed at db 0 for the call, bridge.liveness and
TelegramRelayOutputHandler still write to the claimed test db (red on
the old code, green now), text_redis() follows a pool swap, and
derived_redis() honours its overrides.
Tests touched: test_ui_app.py patches ui.app._get_redis instead of
redis.Redis.from_url; two handler constructions drop redis_url; the
stall-detection suppress-mirror test patches utils.redis_client.text_redis
instead of sys.modules["redis"].
Docs: docs/features/redis-client-accessor.md (indexed in README).
Closes #3003
The recurrence guard scanned eight packages and monitoring/ was not among them, so bridge_watchdog.py kept building its own client from REDIS_URL -- the exact shape #3003 exists to remove, surviving a sweep that reported itself exhaustive. The watchdog reads bridge:last_update_received and friends, freeform keys with no Popoto model, so it moves to text_redis() rather than the ORM. The list is now enumerated from the tree instead of hand-picked, and a new test asserts it still covers every top-level production package, so the next package added to the repo cannot silently escape the scan. Adding utils/ to that list means the sanctioned constructor is exempted by path rather than by being omitted from the scan. Refs #3003
…est seams, event-loop safety Blocker 1: TelegramRelayOutputHandler.__init__ no longer takes redis_url=, but 13 call sites still passed it (11 in tests/unit/output_handler/, 2 in tests/integration/test_message_drafter_integration.py). All updated to the test-injection seam. Blocker 2: both outbox relays swept keys with a full-keyspace KEYS, which now inherits the shared client's 5s socket timeout, and caught the resulting TimeoutError in a blanket handler that returned 0 -- indistinguishable from "nothing to send". That is the shape that dropped every Telegram reply for 26 hours while health signals stayed green. - utils/redis_client.scan_keys(): cursor-based SCAN, bounded on page size (REDIS__SCAN_COUNT) and total keys (REDIS__SCAN_KEY_LIMIT), returns a truncation flag. - bridge/relay_errors.py: OutboxUnavailableError + report_send_path_failure() (ERROR log + Sentry). A RedisError from the sweep now raises and is reported instead of being folded into the return value; per-message failures stay tolerant as before. - The review named only bridge/email_relay.py:262. bridge/telegram_relay.py:1332 had the identical shape on the Telegram send path; both are fixed, plus the unbounded scan_iter in bridge/email_dead_letter.py. - Relay loops count consecutive outages via an explicit except branch, and the email heartbeat now stamps after the sweep, so a cycle that could not read the outbox does not report itself healthy. Connection-policy drift, previously undisclosed: - bridge/routing.py's resolve_customer ran sync Redis (and a sync IMAP STORE) on popoto's BlockingConnectionPool from inside async def; a pool checkout can block the bridge event loop for up to the 20s pool timeout and socket_timeout does not cover it. All four sites now go through asyncio.to_thread. - text_redis() builds its own bounded pool (REDIS__MAX_CONNECTIONS, REDIS__HEALTH_CHECK_INTERVAL_S). - _identity_kwargs now derives from popoto's sibling_client_kwargs whitelist plus TLS and unix-socket, which the whitelist cannot see. TLS lives in connection_class, not connection_kwargs, so rediss:// was silently losing its SSLConnection. tests/unit/test_relay_send_path_outage.py locks all of it in, including an AST guard proven red against pre-patch HEAD at all three sites. The guard matches `.keys` as a bare attribute: both relays spelled it asyncio.to_thread(r.keys, PATTERN), so a call-only matcher reported the send paths clean.
cb2013a to
50480aa
Compare
A semantic conflict that git merged without a textual one. This branch's d0cbb4d removed `import os` from bridge/liveness.py -- correctly, since the only `os` use was the `os.environ["REDIS_URL"]` raw client the accessor replaced. Main then added two new `os` call sites to the same module (`_run_continuity_seconds`, `record_scan_outcome` via the reconciler scan-health work). Both sides touched disjoint lines, so the merge was conflict-free and produced a module that raises NameError on import-time-clean but call-time- broken paths. Caught by ruff F821 and reproduced at runtime: NameError: name 'os' is not defined tests/unit/test_reconciler_scan_health.py goes 12 failed / 16 passed without the import and 28 passed with it, so the existing coverage is adequate and no new test is warranted.
The stage-artifact probe derived the lane branch as `session/{slug}` and
treated a miss as proof the branch was never pushed. That derivation is an
assumption about how the branch was created, and the probe is fail-closed,
so a lane branched outside the convention is indistinguishable from one that
never pushed: G8 re-dispatches /do-patch against work that is pushed and
green until G4's oscillation cap hard-blocks the lane.
PR #3171 is the live case -- recorded slug `sdlc-3003`, actual branch
`redis-client-accessor-3003`. No slug value can reproduce that name, so the
lane could not advance past a guard whose premise was false.
When a PR number is recorded the PR itself is authoritative, so ask it for
headRefName and probe that. A failed lookup falls back to the derived name,
keeping the prior behaviour rather than trading a false refusal for a false
pass.
This is one of the `session/{slug}` derivation sites #3301 tracks as a class;
it reaches further than cleanup, into the router's own verification guard.
Refs #3301
Review (Judge code-quality):Head reviewed: Blockers
Tech Debt1. keys: list[Any] = []
cursor = 0
while True:
cursor, batch = client.scan(cursor=cursor, match=match, count=count)
keys.extend(batch)
if len(keys) >= limit:
return keys[:limit], True
if cursor == 0:
return keys, FalseThe only exit other than a completed cursor is 2. _arm_resolver_unavailable_alert_if_persistent(project_key, message_id)That sync function ( 3. 4. if isinstance(node, ast.Attribute) and node.attr == "keys":
offenders.append(f"{module_path}:{node.lineno} KEYS")The bare-attribute match is correct and deliberate (the comment at 5. Nits6. False 7. The autouse cache reset leaks the client it evicts. 8. The doc asserts a per-module seam several modules do not have. Verified clean
|
Review (Judge risk):Head reviewed: The merge-shape sweep (the deliberate hunt)
Merge geometry established first: Sweeps run, and what each found:
One genuine survivor of the class, found by following the event-loop axis rather than the name axis — finding 1 below. Blockers1. A
_arm_resolver_unavailable_alert_if_persistent(project_key, message_id)That sync function ( failures = get_resolver_failure_count(project_key)which is The PR body states: " Blast radius, and why this is PR-introduced rather than pre-existing: before this change Tech Debt2. 3. The out-of-scope disclosure is incomplete. PR body: "the only remaining hits are for raw_key in r.scan_iter(f"{prefix}*"):No 4. The four new Nits5. False truncation on an exact boundary. 6. The send-path guard will false-positive on any dict. Judged against the outage historyThe specific 26-hour shape is closed. Heartbeat ordering in |
Review: CHANGES REQUESTEDHead reviewed: This is a re-review at a head that moved a long way: Both original blockers are closed
The merge-shape sweepThe highest-value question here was whether Four sweeps run over that surface and the whole tree — undefined-name, signature-change, raise-contract, and client-lifetime — all came back empty; details and method in the risk judge comment. Independently, every name this branch removes from a module namespace was extracted mechanically from the diff and checked for surviving uses: fourteen removed bindings across twelve modules, zero surviving references. One genuine survivor of the class was found, on the event-loop axis rather than the name axis — the blocker below. Both judges reached it independently. Blockers1.
_arm_resolver_unavailable_alert_if_persistent(project_key, message_id)That sync function ( The PR body states " This is PR-introduced, not pre-existing. The old Tech Debt2. 3. 4. The out-of-scope disclosure is incomplete. The PR body says the only remaining unbounded hits are in 5. The four new 6. 7. Nits8. False 9. The autouse cache-reset fixture leaks the client it evicts. 10. The doc asserts a per-module seam several modules do not have. Guards proven RED against known-bad, then restoredA guard certifying absence is worthless until it has failed on the bad input. All three were planted against and failed as designed, then the tree was restored to a clean
Measured test resultsTest set built from tests that actually import the production modules this branch changes ( Serial ( Gates: Visual proof gate: no-op. The diff contains no HTML, CSS, JS/TS, JSX/TSX, Vue, or template files. Deploy note (repo addendum): this touches Verdict: CHANGES REQUESTED — 1 blocker, 6 tech debt, 3 nits. The blocker is one line. The rest of the change is sound, and the send-path hardening is the right fix for the failure this repo has actually paid for. |
`_process_inbound_email` called `_arm_resolver_unavailable_alert_if_persistent` synchronously. That helper makes four Redis round trips, and the first reaches popoto's `BlockingConnectionPool` through `bridge/routing.py`'s `get_resolver_failure_count` -> `_get_redis()` -> `bytes_redis()`. A checkout on that pool *blocks* rather than raising and is not covered by `socket_timeout` (ceiling 20s), so this stalled the whole bridge event loop -- on the resolver-unavailable path, which only runs when the system is already degraded. PR-introduced: at the old base `routing._get_redis()` built a fresh `redis.Redis.from_url(...)` per call, with no shared pool to wait on. Wrapped the helper as a whole rather than only the counter leg: the other three round trips (`text_redis()` plus a get and a set) would otherwise have stayed on the loop. Guard: `TestBlockingPoolNeverReachesAnEventLoop` closes the class instead of pinning the line. It taints every sync function reaching `bytes_redis()`, transitively and across real import edges, then reports any call to one from an `async def` that is not handed to `asyncio.to_thread`. Taint is module-qualified -- six modules define `_get_redis` and only `bridge.routing`'s returns `bytes_redis()`, so a global name set reported five false positives. RED-proved both directions over all 13 production packages: exactly one offender with the fix reverted, zero with it applied. Three unit tests pin the transitive step, the offloaded-reference case, and the direct-call case. Also closes nit 9: the autouse cache-reset fixture now closes the client it evicts instead of leaking a live pool per test. Refs #3003
…7 + nit 8) TD3 + nit 8 -- `scan_keys`: * SCAN is at-least-once, not exactly-once: a key present for the whole iteration can be returned twice when the keyspace rehashes mid-sweep. Keys are now collapsed in first-seen order. Harmless for the relays (a repeat LPOP is a no-op) but `list_dead_letters` renders what it is handed, and `keys(pattern)` never produced duplicates. * The cursor check now precedes the limit check, so a sweep that completes on exactly `scan_key_limit` keys no longer reports `truncated=True`. Callers reasoning about the absence of a key are told to distrust a truncated result, and the relays logged a warning on it every poll. TD2 -- the module docstring claimed `scan_keys` made "neither failure mode reachable". It bounds per-round-trip work and keys returned, which is what closes the timeout-as-empty-queue mode; it does not bound the traversal. A sweep matching nothing still walks the whole keyspace, which on an idle outbox is the common case. Corrected the prose rather than adding a traversal budget: a budget would make an empty result ambiguous, and the absence contract is the half of this that a send path depends on. TD7 -- `TestNoProductionKeysCall` matched every `.keys` attribute, so the first ordinary `payload.keys()` in these modules would have gone red claiming a full-keyspace KEYS. The base must now resolve to a Redis-client-bound name. The bare-attribute match is kept: the defect's spelling was `asyncio.to_thread(r.keys, PATTERN)`, a reference and never an ast.Call. RED-proved on a real module, not only a synthetic tree: planting `r.keys(...)` and a `payload.keys()` on adjacent lines in bridge/email_relay.py reports the client-bound one and ignores the dict. Both scan_keys defects were proven red before the fix. Refs #3003
… nit 10) TD5 -- `.env.example` gained commented override lines for all four REDIS__* knobs, matching the TIMEOUTS__/CODEX__/MODELS__ precedent and step 3 of docs/features/config-timeout-catalog.md. Carries that doc's step 4 caveat too: launchd-managed processes skip the .env read, so "env-overridable" holds only off-launchd unless the key reaches the plist. No automated gate required this (the completeness check parses live `KEY=` declarations only) -- the precedent is uniform, which is reason enough. TD6 -- docs/features/redis-client-accessor.md was built around "the three accessors" and documented none of: `scan_keys` and its two-axis bound, `bridge/relay_errors.py`, `OutboxUnavailableError`, `report_send_path_failure`, the `settings.redis.*` knobs, or the guards. The send-path failure contract is the half of this change with a 26-hour outage behind it and it lived only in source comments. Added three sections and extended "What prevents recurrence" to cover both new guards. The scan_keys section states the traversal limitation plainly rather than repeating the old "neither failure mode is reachable" claim, and records why a traversal budget was rejected: it makes an empty result ambiguous between "no keys" and "gave up", and the absence contract is what a send path depends on. nit 10 -- the doc asserted "Each module keeps a one-line `_get_redis()` seam". Six modules call `text_redis()` inline with no injection point. Corrected to name both groups and say when the seam is worth adding, plus a warning that `_get_redis` is a name and not a contract: `bridge/routing.py`'s returns `bytes_redis()` and every other module's returns `text_redis()`, and those have materially different pool semantics. Refs #3003
Review: PR #3171 — re-review at
|
| # | Finding | Status |
|---|---|---|
| Blocker 1 | Blocking-pool checkout on the bridge event loop | Fixed, verified above; guard RED-proved here |
| TD 2 | Docstring overclaimed "neither failure mode is reachable" | Fixed — utils/redis_client.py:52-58 now states the traversal limit plainly and records why a traversal budget was rejected |
| TD 3 | SCAN duplicates reaching list_dead_letters |
Fixed — first-seen-order dedupe; measured ['a','b','a','c'] -> ['a','b','c'] |
| TD 4 | Incomplete out-of-scope disclosure | Fixed — agent/session_stall_classifier.py:182 now named in the body |
| TD 5 | Four REDIS__* knobs absent from .env.example |
Fixed — all four carry commented override lines, with the launchd caveat |
| TD 6 | Doc covered only half the PR | Fixed — three new sections cover scan_keys, the send-path failure contract, and the tunables table |
| TD 7 | TestNoProductionKeysCall matched every .keys |
Fixed — see RED proof below |
| Nit 8 | False truncated=True on an exact-boundary sweep |
Fixed — measured: exactly limit keys → truncated False; limit+1 → truncated True |
| Nit 9 | Autouse fixture leaked the evicted client | Fixed — :505-515 closes before dropping |
| Nit 10 | Doc claimed a seam several modules lack | Fixed — both groups named, plus the _get_redis-is-not-a-contract warning |
TD 7 RED-proved on a real module, not a synthetic tree. Planted _bad = await asyncio.to_thread(r.keys, 'email:outbox:*') and _ok = payload.keys() on adjacent lines immediately after the real sweep in bridge/email_relay.py:
clean module -> []
planted -> ['bridge/email_relay.py:276 KEYS']
The client-bound reference is reported; the dict call on the next line is ignored. Narrowing works as claimed.
The merge — tooling and plan docs only, confirmed
1937170e8 merged 24 commits of main. Checked on the axis that caught the last one (branch removes a name, main independently adds a use of it in the same module — clean textual merge, NameError at call time).
That failure requires a module touched by both sides. There is none:
- main side (
1937170e8^1...1937170e8^2) — 6 files:docs/plans/improvement-controller-lane-{3,5,6}-*.md,tools/sdlc_next_skill.py,tests/unit/test_sdlc_next_skill.py,tests/unit/test_sdlc_router_oscillation.py. - branch side — 31 files under
agent/ bridge/ config/ monitoring/ reflections/ tools/email_history/ tools/react_with_emoji.py tools/valor_email.py ui/ utils/plus tests anddocs/features/. - Intersection: empty.
tools/sdlc_next_skill.pyis the only production file main brought in, and it is not on the branch's list.
git show --stat on the merge commit equals the main-side diff exactly — the merge carries no resolution content of its own. Both merge-arrived test files are in the test set below and passed.
Measured results at 4b57cbd16
Test set rebuilt from tests that actually import the production modules changed in this delta (bridge/email_bridge.py, utils/redis_client.py) plus the merge-arrived tools/sdlc_next_skill.py, with the three .env declaration suites added for the .env.example change — 42 files, scripts/pytest-clean.sh:
1121 passed, 101 warnings in 82.77s (0:01:22) exit 0
Zero failures, so nothing needed adjudication against main. test_validate_no_module_scope_env.py::test_repo_census_is_a_monotonic_ratchet (#3313) is not in this set. The 7 test_sdlc_router_oscillation.py fixtures arrived green with the merge and are in the set.
Guard runs, in order: planted known-bad → 1 failed, 3 passed; restored, clean git status → 18 passed.
Gates: ruff check . — all checks passed. ruff format --check . — 1545 files already formatted.
Visual proof gate: no-op. The delta touches one Python call site, one Python helper, .env.example and two markdown files. No HTML, CSS, JS/TS, JSX/TSX, Vue or template files.
Deploy note (repo addendum): this touches bridge/, agent/, worker/ and both relays. After merge: ./scripts/valor-service.sh restart, plus worker-restart and email-restart.
Verdict: APPROVED — 0 blockers, 5 tech debt, 3 nits.
The blocker fix is correct on all three axes it had to be, and the guard behind it earns its place: it caught the live offender with no false positives across 13 packages, and it closes the class rather than pinning the line. Its breadth defects are all latent and all worth a follow-up — tech debt 2 most of all, because a guard that goes green on to_thread(sink, blocking_call()) will eventually certify the next instance of exactly this outage as clean.
…comment (Refs #3003) Three inaccuracies in prose only; no behavior change. scan_key_limit was documented as a "hard ceiling on keys returned". It is not. scan_keys checks the cursor before the limit, so a sweep whose cursor closes on the same round trip that carries it past the limit returns every key it saw, untruncated -- and scan_count is a hint, not a page-size guarantee, so the overshoot is not bounded to one key. Capping that list instead would turn a true report of a complete sweep into a silent partial result claiming completeness, which is worse than a few extra keys. The limit bounds the truncating path; config/settings.py, .env.example, the scan_keys Returns block, and the feature doc now say that. The blocking-pool comment in email_bridge claimed all four of the helper's Redis round trips reach popoto's BlockingConnectionPool. Only one does: bridge/routing.py::get_resolver_failure_count. The other three go through text_redis(), a bounded pool that raises on exhaustion and carries socket_timeout. Wrapping all four is still correct -- splitting the wrap would leave three synchronous round trips on the event loop for no benefit -- and the comment now says so, since a fix that is right for a wrong reason gets maintained wrongly.
Closes #3003
What
Production modules built their own redis client from
REDIS_URLat call time, with a hardcoded fallback to production db 0. Popoto's pool -- the one connectiontests/conftest.pyrepoints at the claimed test database andconfig/redis_bootstrap.pyrebuilds with retry policy -- never reached them; under test they wrote to production (measured in #2805).utils/redis_client.pyis now the one place production code obtains a client for non-ORM keys, derived fromPOPOTO_REDIS_DB's live pool:text_redis()decode_responses=Trueclient on popoto's host/port/db/auth, its own bounded pool, socket timeouts fromsettings.timeouts.redis_socket_s; cached, rebuilt when popoto's pool identity changesbytes_redis()POPOTO_REDIS_DBitselfderived_redis(**overrides)scan_keys(client, match)(keys, truncated)-- the sanctioned replacement forKEYSEach module keeps its one-line
_get_redis()/_get_redis_connection()seam delegating to the accessor, so existing test patches on those names still hold. Theredis_urlconstructor parameter onTelegramRelayOutputHandlerandEmailOutputHandleris gone (no production caller passed it); both honour an explicitly assignedself._redisand otherwise resolve throughtext_redis().Scope of this diff (corrected)
An earlier revision of this description claimed twenty-four sites across eighteen modules. That described the original pre-rebase change, not what is on the branch.
utils/redis_client.pyand the call sites that already import it landed onmainseparately asb1a506770during the 449-commit rebase.Measured against
origin/mainat the current head, this diff converts 13 raw-client seams across 13 modules:agent/output_handler.pytelegram:outbox:*,email:outbox:*,metrics:*text_redis()bridge/dedup.pybridge:last_event:*,msgclaim:*,dm_coverage_epoch:*text_redis()bridge/email_bridge.pyemail:last_poll_ts,auth_failed,history:*,msgid:*text_redis()bridge/email_dead_letter.pyemail:dead_letter:*text_redis()bridge/email_relay.pyemail:outbox:*,email:relay:last_poll_tstext_redis()bridge/liveness.pybridge:last_update_received,last_probe_ok,last_missed_recoverytext_redis()bridge/routing.pycustomer_resolver:*,resolver:failures:*bytes_redis()(decodes its own values)monitoring/bridge_watchdog.pybridge/liveness.pywritestext_redis()reflections/pm_briefings/delivery.pytelegram:outbox:*text_redis()tools/email_history/__init__.pyemail:history:*,email:threadstext_redis()tools/react_with_emoji.pytelegram:outbox:*text_redis()tools/valor_email.pyemail:outbox:*text_redis()ui/app.py{project}:session-health:slot_reclaims,worker:slot:leases:*,worker:watchdog:actions:*,email:*text_redis()(one module seam, three call sites)bridge/telegram_relay.pyis also touched, but its accessor seam landed withb1a506770; what this diff changes there is the send path (below). The review's independent sweep put the figure at fourteen seams; the count above isgit diff -U0 origin/main...HEADfiltered to added accessor returns, one per module, and I could not reproduce a fourteenth. If the reviewer's fourteenth wasagent/output_handler.py's second handler class, both handlers do resolve through the single_get_redisseam counted here.monitoring/bridge_watchdog.pywas found by re-sweeping, not by working the issue's list. It survived because the recurrence guard scanned eight hand-picked packages andmonitoring/was not among them: the guard was exactly as wide as the list that produced it, so it certified its own blind spot as clean. The watchdog reads the freeform liveness keysbridge/liveness.pywrites -- precisely this issue's failure -- so it could read production while the bridge under test wrote to the claimed db, and reach a wedge verdict on the wrong database's data.Every site reaches genuinely non-ORM keys; none touched a Popoto-managed key raw, so no ORM migration was needed.
bridge/dedup.py, which the issue suspected, already readsLastProcessedRecordthrough the ORM.Send-path outage visibility
Both outbox relays swept their queue with a full-keyspace
KEYS, which under the accessor now inheritstext_redis()'s 5s socket timeout. The resultingTimeoutErrorwas caught by a blanketexcept Exceptionandprocess_outboxreturned0-- indistinguishable from "nothing to send". The relay would keep looping and every health signal would stay green. This system has already paid for that shape once: a swallowedprocess_outboxexception dropped every Telegram reply for 26 hours.utils/redis_client.scan_keys()replacesKEYS: cursor-basedSCAN, bounded per round trip byREDIS__SCAN_COUNTand in total byREDIS__SCAN_KEY_LIMIT, returning a truncation flag the caller logs.bridge/relay_errors.py(new) holds the failure contract:OutboxUnavailableErrorplusreport_send_path_failure(), which logs at ERROR and captures to Sentry, and never raises (a reporting failure must not become a second outage). ARedisErrorfrom the sweep now raises and is reported rather than folded into the return value. Per-message failures -- malformed payload, rejected recipient, failed SMTP -- stay tolerant exactly as before; only a failure to reach the queue is an outage.except OutboxUnavailableErrorbranch counting consecutive outages.The review named only
bridge/email_relay.py:262.bridge/telegram_relay.py:1332had the identicalKEYS-under-blanket-handler shape on the Telegram send path -- literally the surface of the 26-hour outage. Both are fixed, as is the unboundedscan_iter(nocount=, so bounded per round trip but unbounded in wall time) inbridge/email_dead_letter.py:82.Sweep for other unbounded operations under a blanket handler across
agent/ bridge/ tools/ ui/ monitoring/ reflections/ worker/: the remaining hits areagent/session_health.py(POPOTO_REDIS_DB.keys(...)and ansmembers) andagent/session_stall_classifier.py:182(an unbounded cursor iteration with nocount=, onPOPOTO_REDIS_DB, under a blanketexcept Exception) -- the same shape as thebridge/email_dead_letter.pycase this PR fixes. The third was missed by the original sweep and is added here on re-review. Those run on popoto's own client, are not converted by this PR, and so do not inherit the new socket timeout -- out of scope here, but worth a follow-up.Connection-policy drift (previously undisclosed)
bridge/routing.pyon the event loop.resolve_customerisasync defand called sync Redis throughbytes_redis(), which is popoto's client on aBlockingConnectionPool(max_connections=128, timeout=20). A pool checkout under exhaustion blocks the calling thread -- andsocket_timeoutdoes not cover a checkout, so the ceiling is the pool's 20s timeout, on the bridge's event loop._on_resolver_failurecompounds it with a synchronous IMAPSTORE. All four sites (get, the failure handler,setex, the twodeletes) now go throughasyncio.to_thread.invalidate_customer_cacheis a sync function with no production caller at all and is unchanged. Correction (re-review): this PR originally claimed the same ofget_resolver_failure_count, and that was wrong. It has exactly one production caller --bridge/email_bridge.py's_arm_resolver_unavailable_alert_if_persistent, which_process_inbound_email(async def) called synchronously. So this PR did move a blocking-pool checkout onto the bridge event loop, one frame above the four sites wrapped here, on the already-degraded resolver-unavailable path. Fixed: the whole helper now goes throughasyncio.to_thread, which also moves its other three Redis round trips off the loop.TestBlockingPoolNeverReachesAnEventLoopnow closes the class -- it taints every sync function reachingbytes_redis(), transitively and across import edges, and fails on any unwrapped call from a coroutine.text_redis()builds its own bounded pool,max_connections=REDIS__MAX_CONNECTIONS(128) withhealth_check_interval=REDIS__HEALTH_CHECK_INTERVAL_S(30), rather than inheriting popoto's blocking pool.text_redis()carries a request/response timeout (redis_socket_s, 5s) wherefrom_urlcarried none. No converted site issues a blocking list op (noblpop/brpop/blmoveanywhere in the converted modules)._identity_kwargsnow derives from popoto'ssibling_client_kwargswhitelist plus the two things that whitelist cannot see: TLS and unix sockets. TLS lives inconnection_pool.connection_class(SSLConnection), not inconnection_kwargs, so a kwargs-copying derivation silently dropped it -- arediss://popoto pool produced a plaintext sibling. Verified by hand acrossredis://,rediss://andunix://.New settings on
RedisSettings, all env-overridable and provisional:REDIS__MAX_CONNECTIONS,REDIS__HEALTH_CHECK_INTERVAL_S,REDIS__SCAN_COUNT,REDIS__SCAN_KEY_LIMIT.Recurrence guards
tests/unit/test_redis_client_accessor.py::TestNoRawClientsInProductionwalks every top-level production package by AST and fails on anyredis.Redis/StrictRedis/from_urlcall, under any alias, outsideutils/redis_client.py. The package list is enumerated from the tree rather than hand-picked -- thirteen packages, addinganalytics,mcp_servers,monitoring,scripts,utilsto the original eight -- andtest_the_scan_covers_every_production_packageasserts the list still covers the repo. That companion test is the actual fix for themonitoring/miss: a hand-maintained list fails silently when a package is added, and its failure mode is a guard reporting green over code it never opened.tests/unit/test_relay_send_path_outage.py::TestNoProductionKeysCallis the second guard: no send-path module may callKEYSor an unboundedscan_iter. It matches.keysas a bare attribute, not as a call -- both relays spelled the defectasyncio.to_thread(r.keys, PATTERN), a reference handed to the threadpool, so a call-only matcher reported both send paths clean while the full-keyspaceKEYSsat in plain sight. That miss is recorded in an inline comment.Why not widen
.claude/hooks/validators/validate_no_raw_redis_delete.pyinstead. That validator is aPreToolUsehook matching the text of a Bash command, catching raw Redis typed against Popoto-managed keys at the session boundary. It never reads the repo's source. The defect here is a committed Python module constructing a client, which no command-text matcher can see and which must fail in CI, not in one agent's session. Complementary surfaces; both stay.Tests
tests/unit/test_redis_client_accessor.py(13 tests);tests/unit/test_relay_send_path_outage.py(10 tests).tests/unit/output_handler/(11 constructor call sites) andtests/integration/test_message_drafter_integration.py(2) drop the removedredis_url=kwarg;tests/unit/test_bridge_relay.py(21 sites) mocksscaninstead ofkeys;test_ui_app.pypatchesui.app._get_redisinstead ofredis.Redis.from_url.All runs serial (
-n 0) viascripts/pytest-clean.sh, because the machine's fifteen test-DB slots are contended by sibling lanes. Measured after the rebase onto8e6a0f607and afteruv sync --extra dev(pydantic-ai-slim / pydantic-graph 2.40.0 -> 2.41.0, confirmed installed in the worktree venv). Exit codes captured, not inferred; every set exited 0.tests/unit/output_handler/test_relay_send_path_outage.py,test_redis_client_accessor.py,test_bridge_relay.py,test_dead_letters.pytest_bridge_watchdog.py,test_bridge_liveness.py,test_ui_app.py,test_dedup.py,test_routing.py,test_email_bridge.pytest_email_history.py,test_valor_email.py,test_react_with_emoji.py,test_tool_call_delivery.py,test_session_executor_runner_dispatch.py,test_settings.py,test_env_completeness.pytests/integration/test_message_drafter_integration.pytests/unit/output_handler/was absent from this table in the earlier revision, which is exactly why a directory with 61 failures reached review. It is green now.Red-before-green on both guards, replayed against the current base
8e6a0f607: the widened package scan reports a raw construction inmain'smonitoring/bridge_watchdog.pyand none against this branch. The send-path AST guard reportsbridge/email_relay.py:263 KEYS,bridge/telegram_relay.py:1332 KEYS, andbridge/email_dead_letter.py:84 unbounded scan_iteron the base; all three are clean on this branch.Base
Rebased onto
8e6a0f607(after #3168). No textual conflict, and no semantic one: #3168 rewrotemonitoring/bridge_watchdog.py'sis_bridge_running(pgrep ->tools.process_lookup.find_python_service_pids) and annotatedkill_stale_processes, while this PR's only edit to that file is_get_watchdog_redis.ui/app.pyis not in #3168's diff.Deploy note
This changes bridge, worker and relay code. After merge:
./scripts/valor-service.sh restart, plusworker-restartandemail-restart.Docs
docs/features/redis-client-accessor.md, indexed indocs/features/README.md; the #3003 row leavesdocs/bug-backlog-waves.md.