Skip to content

UN-3712 [FEAT] PG-queue: Redis-signalled result delivery (remove result poll-storm)#2168

Merged
muhammad-ali-e merged 2 commits into
feat/UN-3445-pg-queue-integrationfrom
feat/UN-3445-pg-result-redis-signal
Jul 13, 2026
Merged

UN-3712 [FEAT] PG-queue: Redis-signalled result delivery (remove result poll-storm)#2168
muhammad-ali-e merged 2 commits into
feat/UN-3445-pg-queue-integrationfrom
feat/UN-3445-pg-result-redis-signal

Conversation

@muhammad-ali-e

Copy link
Copy Markdown
Contributor

What

Replace the per-in-flight-task DB poll in the PG-queue result backend with an optional Redis wake-up signal (RPUSH producer / BLPOP consumer), gated by PG_RESULT_SIGNAL_BACKEND (default poll = unchanged behaviour).

Why

Each in-flight file's wait_for_result SELECTs pg_task_result every ≤2s. Load-testing the API-deployment path at 400 concurrent users pinned the txn DB CPU at 100% — DB load scaled with concurrency, not throughput (~190 SELECTs/s of pure polling). Moving the "result is ready" edge off the DB makes DB load scale with throughput.

PG LISTEN/NOTIFY was evaluated and rejected: the receiver side can't survive transaction-pooled PgBouncer. Redis is already a hard dependency of the stack; only the wake-up signal moves — the queue and the durable result stay in Postgres.

How

  • store_result RPUSHes a tiny ready-token (reply key only, never the payload/PII) after the row commits; wait_for_result BLPOPs it, then does ONE authoritative SELECT.
  • Postgres remains the source of truth: an immediate check closes the publish-before-wait race; a slow fallback SELECT (30s) backstops a lost signal; any Redis error degrades to the existing backoff poll. Correctness never depends on Redis — a Redis restart/eviction costs latency, never a lost/stuck result.
  • Flag-gated (PG_RESULT_SIGNAL_BACKEND=redis|poll, default poll) — ships dark, flips per environment; producer/consumer roll independently.
  • docker/sample.env documents the flag.

Testing

  • workers/tests/test_pg_result_signal.py — 19 unit tests (flag routing, publish-before-wait race, Redis-down → poll fallback, poll-mode parity, signal-never-raises, no-PII-in-signal) + 1 real round-trip (live Redis RPUSH/BLPOP + real Postgres cross-connection). All green; existing test_pg_result_backend.py (poll path) still green.
  • Dev-tested locally against a running Postgres + Redis. Integration-env E2E to follow.

Ticket: UN-3712

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5fbcfb01-0598-4830-a23a-3e80c4c1224b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/UN-3445-pg-result-redis-signal

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.

@greptile-apps

greptile-apps Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds Redis-signalled delivery for PostgreSQL queue results. The main changes are:

  • Optional Redis RPUSH and BLPOP wake-up signals.
  • PostgreSQL remains the authoritative result store.
  • Polling remains the default and failure fallback.
  • Tests cover routing, recovery, missed signals, and a live round trip.
  • The sample environment documents the new backend setting.

Confidence Score: 5/5

This looks safe to merge.

  • The Redis socket timeout now exceeds the longest blocking wait.
  • Failed client construction is retried after a cooldown.
  • PostgreSQL polling continues to protect result delivery when Redis is unavailable.
  • No blocking issues were found in the updated code.

Important Files Changed

Filename Overview
workers/queue_backend/pg_queue/result_backend.py Adds Redis wake-up signaling, deadline-aware polling fallback, and recovery after client construction failures.
workers/tests/test_pg_result_signal.py Adds unit and integration coverage for signaling, fallback, timeout, and recovery behavior.
docker/sample.env Documents the optional Redis result-signal backend and its polling default.

Reviews (3): Last reviewed commit: "UN-3712 [FIX] address PR review — BLPOP ..." | Re-trigger Greptile

Comment thread workers/queue_backend/pg_queue/result_backend.py Outdated
Comment thread workers/queue_backend/pg_queue/result_backend.py Outdated
…lt poll-storm)

Replace the per-in-flight-task DB poll in the PG-queue result backend with an
optional Redis wake-up signal (RPUSH producer / BLPOP consumer), gated by
PG_RESULT_SIGNAL_BACKEND (default poll = unchanged behaviour). The result stays
durable in Postgres; only the reply key rides Redis, so DB load scales with
throughput instead of concurrency. Falls back to the backoff poll on any Redis
error, so correctness never depends on the signal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@muhammad-ali-e muhammad-ali-e force-pushed the feat/UN-3445-pg-result-redis-signal branch from bc63550 to 86f7565 Compare July 13, 2026 07:03

@muhammad-ali-e muhammad-ali-e left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

PR Review Toolkit — automated multi-agent review (code-reviewer, silent-failure-hunter, type-design-analyzer, test-analyzer, comment-analyzer, code-simplifier).

The design is solid: DB stays the source of truth, the signal is best-effort, and correctness never depends on Redis — a lost/absent token costs only latency. No correctness strand was found. The findings below are latency-regression, error-taxonomy, typing, test-coverage, and comment-accuracy issues.

Note on duplicates: the two existing greptile P2 comments — "Socket Timeout Aborts BLPOP" (line 191) and "Transient Failure Disables Signaling" (line 193) — are the two highest-impact issues here and I independently confirmed both (the BLPOP one against redis-py 5.2.1 Connection.read_response, which applies socket_timeout to blocking reads). I have not re-posted them to avoid duplication; the comment below on the round-trip test is the new companion finding to the socket-timeout issue.

Comments are grouped in this single review, ordered roughly by priority.

@pytest.fixture
def real_redis(self):
redis = pytest.importorskip("redis")
client = redis.Redis(host="localhost", port=6379, socket_connect_timeout=2)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[High] The only live-BLPOP test masks the socket_timeout=5 bug (companion to greptile's Socket Timeout Aborts BLPOP).

This round-trip test builds its own client redis.Redis(host=..., socket_connect_timeout=2) with no socket_timeout (defaults to None = block forever) and injects it, bypassing the production _get_result_redis_client() which carries socket_timeout=5. It also stores after only time.sleep(0.5), well inside 5s. So the test stays green while the production config aborts every BLPOP that blocks >5s.

Fix: exercise a client with the production socket_timeout=5 and add a case where the token arrives after socket_timeout seconds (store after ~6s, short fallback constant) to prove BLPOP still wakes rather than degrading to poll. That test fails today and pins the greptile fix.

client.blpop.return_value = None # BLPOP timed out, no token
monkeypatch.setattr(rb_mod, "_get_result_redis_client", lambda: client)
rb = _backend_with_get_result(None) # never ready
assert rb.wait_for_result("k", timeout=0.05) is None

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Medium] The central resilience property is untested: a missed signal must still deliver via the backstop SELECT.

test_timeout_returns_none has blpopNone and get_resultNone forever, so it only proves timeout. Nothing exercises the design's core claim (module docstring L30-33): BLPOP misses/returns None (lost token / fallback tick) but the authoritative get_result on the next loop iteration finds the committed row and returns it.

Add: client.blpop.return_value=None, get_result side_effect [None, _ROW], generous timeout → assert it returns _ROW and blpop was called (i.e. it went through the loop, not the immediate race check). This catches any refactor that treats a None BLPOP as "give up" and strands the caller.

row = self.get_result(task_id)
if row is not None:
return row
client = _get_result_redis_client()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Medium] First-call Redis client build runs inline on the hot path and can ignore the caller's timeout (Sentinel mode).

_get_result_redis_client() is called here (and in store_result_signal_ready after the DB commit). In Sentinel mode create_redis_client pings-and-retries for up to ~5 minutes before raising. So a wait_for_result(timeout=30) can actually block for minutes on its first wait during a sentinel blip, and store_result can stall the executor consumer for minutes after a commit — the build ignores the caller's deadline entirely.

Consider building the client at worker init (off the request path), or bounding the first build by remaining here. Best fixed together with the transient-disable issue greptile flagged on L193.

block = min(remaining, _REDIS_FALLBACK_POLL_SECONDS)
try:
client.blpop([key], timeout=block)
except Exception:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Medium] except Exception around BLPOP conflates expected timeouts and permanent misconfig, both logged as a transient "blip".

This catches redis.TimeoutError (normal: no token yet), but also AuthenticationError, ResponseError/WRONGTYPE/NOPERM, and unrelated programming errors — all degraded to poll with an identical WARNING. A systematic failure (wrong REDIS_PASSWORD, revoked ACL) then looks like a one-off and floods one misleading WARNING per wait while the fast path silently never works.

Catch redis.exceptions.RedisError (or TimeoutError+ConnectionError) instead of bare Exception, and treat an expected no-token TimeoutError as normal (do the backstop get_result and keep looping) rather than logging "BLPOP failed" and abandoning the fast path.

_redis_client_unavailable = False


def _get_result_redis_client() -> Any:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Low] Type the Redis client as redis_lib.Redis | None instead of Any.

Both _redis_client_singleton (L172) and this accessor's return are Any, yet the docstring says this mirrors redis_barrier._get_redis_client, which types the identical singleton concretely (redis_barrier.py:200,203), and create_redis_client already returns redis.Redis. Any is contagious and silences checking on every client.pipeline()/rpush/expire/blpop call (e.g. a blpop(key) vs blpop([key]) typo would be invisible). | None is also more honest — None (build-failed) is a real, branched-on state. Pure annotation change; redis_barrier already imports redis as redis_lib in this package.

failure on a fresh connection is a genuine error, not a stale reap. If a
long-lived backend ever gains a one-shot ``get_result`` lookup, revisit
this.
relies on: the connection is never idle long enough to be reaped when

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Low] Consider a PgResultRow TypedDict for the {status, result, error} row.

The non-obvious invariant this docstring explains in prose — a present row may still have result is None (a failed outcome or a forget-ed tombstone), disambiguated by status — is exactly what a type should carry. A TypedDict(status: str, result: dict|None, error: str) keeps the wire dict (zero runtime cost, the backend reader consumes it as a dict), names the shared return of get_result/wait_for_result/_wait_via_redis, and encodes the result: … | None nullability statically. Keep status: str (not the enum) to match the raw DB string the reader compares against.

relies on: the connection is never idle long enough to be reaped when
``get_result`` runs. In ``poll`` mode the wait polls every ~0.2–2s so the
connection stays warm; in ``redis`` mode :meth:`_wait_via_redis` ``close``s
the connection before each (possibly long) ``BLPOP``, so every

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Low] Comment accuracy: "every get_result runs on a freshly-opened connection" is overstated.

_wait_via_redis calls close() at the top of the loop, so the post-BLPOP read and the deadline check are fresh — but the immediate race-check get_result (L465) runs before the loop with no preceding close(). It's fresh only because the sole production caller constructs a new backend per wait (executor_rpc.py), not because _wait_via_redis closes before it. Suggest scoping the claim to post-BLPOP reads so the no-reconnect-retry justification stays airtight.

polling ``pg_task_result`` once per in-flight task scales DB load with
*concurrency*, not throughput (every waiter SELECTs on a backoff) — which
saturated the DB at high concurrency. When ``PG_RESULT_SIGNAL_BACKEND=redis``,
:meth:`store_result` RPUSHes a tiny ready-token (the reply key ONLY — never the

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Low] Comment accuracy: "does ONE authoritative SELECT" undercounts (and the opening "polls" line is now stale).

The normal miss-then-wake path does at least two SELECTs per wait — the publish-before-wait race check (L465) and the post-BLPOP read (L499) — plus one fallback SELECT per 30s on a long wait. The O(1)-per-wait-vs-O(N)-polls point is right, but "ONE" is literally inaccurate; suggest "an authoritative SELECT on wake (a small constant per wait, not one-per-poll)".

Relatedly, the module-overview line (top of file, not in this diff) still says "the blocking caller polls wait_for_result until the row appears", which now contradicts the event-driven redis path documented 17 lines below. Worth making mechanism-neutral while here.

…ch cooldown, narrowed redis except, resilience test

- Build the signal Redis client with socket_timeout=35s (>30s BLPOP block) so
  the blocking BLPOP is not cut short and forced to poll.
- Replace the permanent unavailable-latch with a 30s cooldown so a transient
  Redis restart/failover self-heals instead of disabling the signal for life.
- Narrow the BLPOP except to redis.exceptions.RedisError; non-redis errors
  propagate instead of silently degrading to poll.
- Type the client singleton as redis.Redis | None.
- Tests: missed-signal-still-delivers-via-fallback-SELECT, socket_timeout
  regression guard, cooldown retry, and non-redis-error propagation.
- Docstring accuracy fixes (event-driven wait, SELECT count, get_result conn).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@muhammad-ali-e

Copy link
Copy Markdown
Contributor Author

Addressed the review — thanks, pushed 0f5305f18.

P2/High — BLPOP socket-timeout (the important one): the signal client was built with socket_timeout=5, which aborts the up-to-30s BLPOP at 5s and silently degrades every wait to poll — defeating the feature. Now built with socket_timeout = _REDIS_FALLBACK_POLL_SECONDS + 5 (35s), so a full BLPOP completes and a normal empty BLPOP returns None (not an exception). Added test_socket_timeout_exceeds_blpop_block to guard the regression.

P2/Medium — transient failure permanently disabled signalling: replaced the permanent _redis_client_unavailable latch with a time.monotonic() last-failure stamp + a 30s cooldown; the build is retried after the cooldown, so a Redis blip no longer disables the signal for the process lifetime. Added test_build_failure_retries_after_cooldown.

Medium — except Exception around BLPOP: narrowed to except redis.exceptions.RedisError (→ degrade to poll); non-redis errors now propagate. Added test_blpop_non_redis_error_propagates.

Medium — resilience untested: added test_missed_signal_still_delivers_via_fallback_select (BLPOP always None, row lands later → returns the row via the backstop SELECT).

Low: concrete typing (redis.Redis | None, mirroring redis_barrier); corrected the get_result connection-freshness comment and the module-docstring "polls"/"ONE SELECT" wording.

Deferred: the PgResultRow TypedDict (noted for a follow-up). Tests: 47 pass.

@sonarqubecloud

Copy link
Copy Markdown

@muhammad-ali-e muhammad-ali-e merged commit 0c2334b into feat/UN-3445-pg-queue-integration Jul 13, 2026
6 checks passed
@muhammad-ali-e muhammad-ali-e deleted the feat/UN-3445-pg-result-redis-signal branch July 13, 2026 07:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant