Skip to content

UN-3695 [FIX] PG consumer — renewable lease (dead worker redelivers in minutes, not the full VT)#2154

Merged
muhammad-ali-e merged 5 commits into
feat/UN-3445-pg-queue-integrationfrom
fix/UN-3695-heartbeat-lease
Jul 8, 2026
Merged

UN-3695 [FIX] PG consumer — renewable lease (dead worker redelivers in minutes, not the full VT)#2154
muhammad-ali-e merged 5 commits into
feat/UN-3445-pg-queue-integrationfrom
fix/UN-3695-heartbeat-lease

Conversation

@muhammad-ali-e

Copy link
Copy Markdown
Contributor

What

Follow-on to the merged grace=VT fix (#2153). That covered graceful kills (drain on SIGTERM). This covers hard kills (OOM / node loss), where the worker gets no SIGTERM: the message was claimed for the full visibility timeout (VT=9060s / 2.5h for file-processing), so a hard-killed worker stranded its in-flight message for ~2.5h before the vt expired and it redelivered.

  • New LEASE_SECONDS (default 120): the message is claimed for the short lease, and a daemon thread renews it (client.set_vt) every ~lease/3 while the task runs. A live-but-slow task keeps its claim; a dead worker's renewal stops with the process, so its lease expires in ~lease → redelivery in minutes, not 2.5h.
  • VT_SECONDS is retained as the drain / max-runtime bound — it still drives PR A's shutdown grace and the chart's health-stale / render guards. It is no longer the claim window; the claim now uses LEASE_SECONDS (capped at VT).
  • Safety: the renewal thread is started only around task.apply, joined before the ack so the two never race the row; it uses its own DB connection (the main thread owns the claim/ack connection); it's best-effort — a transient set_vt failure retries within the ~2× slack the lease/3 interval leaves before expiry, and a False return (row already acked/expired) stops it. The renew connection is opened only on the first actual renewal, so a task shorter than the interval opens no second connection.

Why

PR A made deploy/scale-down orphans impossible, but OOM/node-death (no SIGTERM) still stranded a batch for the full VT. Shortening recovery to minutes closes the hard-kill gap and brings PG closer to Celery's broker-requeue latency.

Scope / safety

  • PG-worker-only path (the consumer); Celery untouched.
  • Default LEASE_SECONDS=120 works with no chart change; the paired unstract-cloud change sets it explicitly per pg-worker + adds a LEASE ≤ VT render guard.

How tested

  • Unit (deterministic, controlling stop.wait): lease cap-at-VT / renew-interval (lease//3) / claim uses lease not VT / renew-until-stopped / stop-on-row-gone / swallow-transient-error-and-retry / ctx-sets-stop-on-exit. 78 consumer tests pass.
  • The end-to-end "dead worker redelivers in ~120s" wall-clock timing is validated in the integration environment (real Postgres + worker + kill), alongside the flag-ON regression.

Part of UN-3695. Depends on nothing; complements the merged #2153.

🤖 Generated with Claude Code

…rs in minutes, not the full VT

The claim was taken for the full visibility timeout (VT=9060s / 2.5h for
file-processing), so a HARD-killed worker (OOM / node loss) stranded its in-flight
message for ~2.5h before the vt expired and it redelivered. PR A fixed GRACEFUL
kills (drain on SIGTERM); this covers the hard-kill path.

- New LEASE_SECONDS (default 120): the message is claimed for the short lease, and a
  daemon thread renews it (client.set_vt) every ~lease/3 while the task runs. A
  live-but-slow task keeps its claim; a dead worker's renewal stops with the process
  so its lease expires in ~lease → redelivery in minutes. The lease is capped at
  vt_seconds; the renewal uses its OWN connection (the main thread owns the claim/ack
  connection) and is best-effort (a transient set_vt failure retries within the
  ~2x slack the lease/3 interval leaves before expiry; a False return = row already
  gone → stop).
- VT_SECONDS is retained as the drain / max-runtime bound (it drives PR A's grace and
  the chart's health-stale / guards) — it is no longer the claim window. The claim now
  uses LEASE_SECONDS.
- The renewal thread is started only around the task run, joined before the ack so the
  two never race the row, and creates its connection only on the first actual renewal
  (a task shorter than the interval opens no second connection).

Paired chart change (unstract-cloud): sets WORKER_PG_QUEUE_CONSUMER_LEASE_SECONDS +
a render guard LEASE <= VT.

Tests: lease cap / renew-interval / claim-uses-lease / renew-until-stopped /
stop-on-row-gone / swallow-transient-error / ctx-sets-stop-on-exit. 78 consumer tests
pass. End-to-end dead-worker redelivery timing validated in the integration env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 8, 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: 29976293-959e-446e-a6ee-3c34c56475d6

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 fix/UN-3695-heartbeat-lease

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.

…Sonar S108)

The context-manager test entered `with c._lease_renewal(42): pass` — an empty
nested block (python:S108). Replace the bare pass with an observable statement
(entered = True) asserted after, so the block is non-empty and still verifies the
CM body runs while the renewal thread is live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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 review (6 agents)

The renewable-lease change is well-structured overall: the daemon-thread + Event + context-manager shape is idiomatic, the lazy second connection and join-before-ack are well-reasoned, and the happy-path tests are solid. Findings are grouped below and posted as inline comments.

High

  • BATCH>1 tail double-processing (consumer.py:302) + now-stale mitigation comment (consumer.py:59)
  • Renewal error handling: over-broad except + no escalation on prolonged failure (consumer.py:358)
  • set_vtFalse mid-run is an unlogged double-run signal (consumer.py:356)

Medium

  • Wedged-join not logged + shared _renew_client reuse race (consumer.py:339)
  • Silent lease>vt cap + factually wrong 'fires rarely' comment (consumer.py:265)
  • _renew_client never closed (consumer.py:270)
  • Test gaps: lazy-init, _handle integration, close, boundaries (test file)

Low

  • 'never race' docstring overstated (consumer.py:325)
  • Missing -> Iterator[None] (consumer.py:316)
  • README: LEASE default vs default VT (README.md:31); lease is not the max-runtime bound (README.md:78)

The default config (BATCH=1) is safe from the top item; the batch and error-handling findings bite specifically under the file-processing profile this feature targets (large VT, batching, long tasks).

try:
messages = self._client.read(
queue_name, vt_seconds=self.vt_seconds, qty=self.batch_size
queue_name, vt_seconds=self.lease_seconds, qty=self.batch_size

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] BATCH>1 tail messages can double-process. read() now claims the whole batch for lease_seconds, but _handle processes messages sequentially and _lease_renewal only renews the in-flight msg_id. Messages #2..N sit in the local list unrenewed; if the head task runs longer than lease_seconds (routine for file-processing) their vt expires, another consumer reclaims them, and they run twice. Before this PR the tail was hidden for the full vt_seconds (~2.5h). Safe at the default BATCH=1, but BATCH is a supported knob on exactly the long-task/large-VT profile. Fix options: renew every still-unacked msg_id in the batch; or claim the tail for vt_seconds; or force batch_size=1 when lease_seconds < vt_seconds. Coupled to the stale comment at line 59.

@@ -57,6 +59,15 @@
# raise it, keep vt_seconds > batch_size x worst-case task duration.

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] This mitigation note is now wrong and will mislead operators. The batch claim window is lease_seconds (line 302), not vt_seconds, so 'keep vt_seconds > batch_size × worst-case task duration' no longer protects the batch tail — raising VT_SECONDS does nothing for queued-but-unstarted messages once the lease caps the claim window. Update to reference LEASE_SECONDS and tie it to the batch-renewal fix (line 302).

self._renew_client = PgQueueClient()
if not self._renew_client.set_vt(msg_id, self.lease_seconds):
return # row gone — nothing left to renew
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.

[High] except Exception is too broad, and repeated failures never escalate. (1) A deterministic bug (an AttributeError from a rename, or set_vt's own ValueError) is caught every tick and logged as a self-healing 'blip' — it never self-heals, the lease is never renewed, and every long task silently double-runs. Narrow the catch to the connection-dead types (_CONN_DEAD_ERRORS) and log unexpected types at ERROR, then stop retrying. (2) Even for genuine transient failures: if the DB is down longer than lease_seconds the lease is lost and the task double-runs, yet every tick emits the same reassuring WARNING ('a dead connection self-heals on the next tick'). Track last-success time and escalate to ERROR with a distinct, greppable message once the failure window exceeds lease_seconds (the point past which a double-run is provably possible).

# task shorter than the interval opens no second connection.
if self._renew_client is None:
self._renew_client = PgQueueClient()
if not self._renew_client.set_vt(msg_id, self.lease_seconds):

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] set_vt returning False mid-run is an unlogged double-run signal. The renewal thread is joined before the ack (line 339; the delete happens after the with block exits), so it can never observe our own ack — the docstring's 'acked by us' case is impossible in this thread. Mid-run, False means the lease already expired and another worker reclaimed the row, i.e. the task is double-running right now. Yet the loop just returns silently with no log. Emit a WARNING (arguably ERROR) before returning, and correct the docstring at lines 347-348 — 'acked by us' should read 'expired + reclaimed → likely double-running'.

yield
finally:
stop.set()
thread.join(timeout=_LEASE_JOIN_TIMEOUT_SECONDS)

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] Wedged renewal thread: the join can time out silently, and the shared _renew_client can then be used by two threads. If set_vt is blocked in a hung DB call, join(timeout=10) returns with the thread still alive and execution falls through to the ack with no is_alive() check and no log. Compounding hazard: the next message starts a new renewal thread that reuses the same self._renew_client connection (line 355) — two threads on one non-thread-safe psycopg connection → protocol corruption that surfaces later on an unrelated statement. Add an if thread.is_alive(): ERROR log after the join, and consider not sharing _renew_client across tasks while a prior thread may still be alive.

dies with it → the lease expires in ~``lease_seconds`` → the message
redelivers in minutes instead of the full VT. The renewal uses its OWN
connection (``_renew_client``); the main thread owns ``_client`` for the ack,
and the thread is joined here before that ack so the two never race the row.

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] Docstring overstates the guarantee. 'the thread is joined here before that ack so the two never race the row' — the join has a 10s timeout (line 339); on timeout the thread is still alive and set_vt can run after the delete. The race is benign (set_vt on a deleted row updates 0 rows → False), but 'never race' contradicts the honest constant comment at lines 66-67. Soften to: 'joined best-effort, bounded by _LEASE_JOIN_TIMEOUT_SECONDS; a late set_vt on the acked row no-ops.'

return total

@contextlib.contextmanager
def _lease_renewal(self, msg_id: int):

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] Missing return-type annotation. This is the only method in the file without one. A @contextmanager generator should be -> Iterator[None] (add Iterator to the collections.abc import at line 30).

| `CONCURRENCY` | Prefork consumer children per pod (1 = plain single process) | `1` |
| `VT_SECONDS` | **Visibility timeout** — how long a claimed message stays hidden before it can redeliver | `30` (chart: `9060` for file-processing ≈ 2.5h) |
| `VT_SECONDS` | Since PR C, the **drain / max-runtime bound** (drives `SHUTDOWN_GRACE`, `HEALTH_STALE`, the chart guards) — the *claim* window is `LEASE_SECONDS`, not this | `30` (chart: `9060` for file-processing ≈ 2.5h) |
| `LEASE_SECONDS` | **Renewable claim window** — a claimed message is hidden only this long and renewed every ~LEASE/3 while the task runs; a dead worker's claim expires in ~LEASE → fast redelivery (capped at VT) | `120` |

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] LEASE_SECONDS default 120 is misleading under the default VT. Because the effective lease is min(LEASE, VT), with the default VT=30 the lease clamps to 30 and the renew interval to 10s — not the 120 / ~40s a reader infers from this row. The minutes-not-hours benefit only materializes once VT is raised (the 9060 file-processing chart). Add a parenthetical, e.g. 120 (effective = min(LEASE, VT); with the default VT=30 this clamps to 30).

**renews** it (`set_vt`) every ~`LEASE/3` while the task runs. A live-but-slow task
keeps its claim; a **dead** worker's renewal stops, so its `vt` expires in ~`LEASE` →
redelivery in **minutes, not hours**. `VT_SECONDS` is retained as the *drain /
max-runtime bound* (grace, health-stale), and the lease is capped at it. The renewal

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] The lease does not self-cap a live-but-hung task. The renewal loop has no absolute deadline — a live-but-wedged task renews forever and is never redelivered, so VT_SECONDS is not a max-runtime bound via the lease. In practice task.apply runs synchronously on the poll thread, so a hung task stalls the consumer and the external health-stale / liveness probe kills the pod (process death stops renewal → redelivery). Consider rewording so operators understand the bound is enforced by the probe killing the pod, not by the lease self-capping.

)


class TestLeaseRenewal:

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] Test gaps in the new suite. Every test pre-sets _renew_client = MagicMock(), so the load-bearing paths are untested: (1) lazy PgQueueClient() creation — that it's built once and not built for a sub-interval task (the 'no second connection' guarantee); (2) _handle integration — that task.apply runs inside _lease_renewal and the thread is joined before the ack (deleting the with wrapper wouldn't fail any current test); (3) the join-timeout / wedged-thread path; (4) _renew_client close on shutdown (see consumer.py:270); (5) boundary values — lease_seconds<=0 rejected at construction, lease == vt, and the tiny-lease max(1, //3) no-slack edge. Also stop.wait is a bare mock, so the timing invariant _lease_renew_interval < lease_seconds is never asserted.

@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

Ran 6 specialized agents (code review, silent-failure hunt, type design, test coverage, comment accuracy, simplification) over the renewable-lease change. The design is sound and the happy path (live worker renews / dead worker's lease expires) is correct and well-tested. Findings below are grouped as inline comments.

Priority order:

  1. Important — shared _renew_client concurrency race when the bounded join times out (2 agents found this independently).
  2. Important — three silent/under-observed unhappy-path renewal failures (row-gone return, sustained-failure escalation, wedged-thread) — a lost lease here means duplicate execution / double-billing, so these deserve a log + metric.
  3. Important — an inaccurate code comment + misleading README/default interaction around min(lease, vt).
  4. Suggestions/nits — metrics wiring, connection close, comment precision, return annotation.
  5. Tests — integration/ordering/edge-case coverage gaps.

Note: one agent's finding that a dead _renew_client "never rebuilds / self-heal is false" was verified and dropped_cursor() (client.py:219-222) discards a dead owned connection, so set_vt does reconnect on the next tick; the comment is accurate.

yield
finally:
stop.set()
thread.join(timeout=_LEASE_JOIN_TIMEOUT_SECONDS)

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.

[Important] Bounded join can leave a wedged renewal thread sharing _renew_client with the next task's thread → concurrent use of one psycopg2 connection.

If a renewal thread is blocked inside set_vt on a stalled connection (DB failover / PgBouncer reap / network partition — exactly the window this feature targets) for >10s, join(timeout=…) returns silently and _handle proceeds. The main loop then advances to the next message, whose renewal thread reuses the same self._renew_client (if self._renew_client is None at :354 is already non-None). Two threads issuing set_vt on one libpq connection is undefined behavior (protocol desync), which can then make renewals fail and reintroduce the very redelivery this PR prevents. The docstring's "joined here before that ack so the two never race the row" (:325) also does not hold once the join times out.

Suggested fix: give each renewal its own PgQueueClient scoped to _lease_renewal (and close() it), or guard _renew_client use with a threading.Lock. Also log + increment a metric when thread.is_alive() after the join — an abandoned thread is currently completely silent.

if self._renew_client is None:
self._renew_client = PgQueueClient()
if not self._renew_client.set_vt(msg_id, self.lease_seconds):
return # row gone — nothing left to renew

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.

[Important] set_vtFalse (row gone) returns with zero logging — the most definitive double-run signal is silent.

During an in-flight renewal the ack has not run yet (the thread is joined before the ack at :504). Verified against client.py: read() reclaim only UPDATEs vt/read_ct, it does not delete — so set_vt returns False only after the row was actually DELETED, i.e. another worker already reclaimed and acked it → this task is being/has-been double-processed. Contrast the ack path at :504-510, which logs a WARNING for the same condition. Here it's a bare return.

Suggested fix: logger.error(...) naming msg_id + a lease_lost counter before returning.

Also: the docstring at :347 lists "acked by us" as a cause of False — that's impossible inside the loop (ack happens after join), and "expired + reclaimed" does not by itself return False (reclaim doesn't delete). Reword to: "False = the row was deleted (acked/dropped by its current owner)."

if not self._renew_client.set_vt(msg_id, self.lease_seconds):
return # row gone — nothing left to renew
except Exception:
logger.warning(

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.

[Important] The loop can't distinguish a transient blip from a definitively-lost lease — sustained failure silently allows a concurrent double-run.

Every set_vt exception logs an identical WARNING and loops with no state about how long renewals have been failing. If set_vt fails on every tick for longer than lease_seconds (dead conn / failover), the lease expires while task.apply is still running, another worker reclaims the row, and the task runs twice — the worker that lost the lease emits only look-alike WARNINGs, never escalates. Given this subsystem's own delete() docstring ties a lost lease to duplicate webhooks + subscription-usage billing, a blip and a total loss should not be indistinguishable in the logs.

Suggested fix: track last_ok = time.monotonic(); once now - last_ok >= lease_seconds, escalate to logger.error + a lease_renewal_giveup metric so it pages, while keeping the sub-lease case at WARNING.

# Renewable lease (UN-3695 PR C): claim for lease_seconds (short) and renew
# every ~lease/3 while a task runs. Capped at vt_seconds so the lease is
# genuinely the shorter window; setting lease >= vt degrades to the old
# single-long-claim behaviour (renewal fires rarely).

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.

[Important] This comment is factually wrong. After self.lease_seconds = min(lease_seconds, vt_seconds) (:265) the effective lease can never exceed vt, so _lease_renew_interval = vt//3 and renewal fires every vt/3frequently, re-parking on every tick. That is neither "single-long-claim behaviour" (which never renews) nor "renewal fires rarely."

Suggested: "setting lease >= vt just clamps the lease to vt; renewal still fires every vt/3 (redundantly, each extending by vt)."

# every ~lease/3 while a task runs. Capped at vt_seconds so the lease is
# genuinely the shorter window; setting lease >= vt degrades to the old
# single-long-claim behaviour (renewal fires rarely).
self.lease_seconds = min(lease_seconds, vt_seconds)

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.

[Minor] With the shipped code defaults the feature is inert. _DEFAULT_LEASE_SECONDS = 120 (:67) but _DEFAULT_VT_SECONDS = 30 (:66-ish), so a default-constructed consumer gets min(120, 30) = 30 — the advertised 120s lease is silently discarded and lease == vt. It only does something once VT_SECONDS is raised well above the lease (which the chart does in prod: VT=9060). This is a dev/local sharp edge, not a prod bug — but worth either making the defaults coherent (lower the default lease below the default VT, or raise the default VT to match its new "max-runtime bound" role) or logger.warning when lease_seconds > vt_seconds so the clamp is visible. Note the constructor rejects backoff_max < poll_interval explicitly (:236) yet clamps this cross-field invariant silently — inconsistent.

self._lease_renew_interval = max(1, self.lease_seconds // 3)
# Second client (own connection) for lease renewals — the main thread owns
# self._client for the read/ack, so a renewal must not share that connection.
# Lazily created on the first task run.

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.

[Nit] Imprecise + duplicated comment. "Lazily created on the first task run" is slightly wrong — it's created on the first renewal (:352-353 states this accurately), which only happens when a task outlives the interval; a short task opens no second connection. Suggest: "Lazily created on the first lease renewal (never for tasks shorter than the interval)." Keep only one authoritative statement of this — :352-353 already has it.

return total

@contextlib.contextmanager
def _lease_renewal(self, msg_id: int):

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.

[Nit] Missing return-type annotation. The rest of the file annotates returns (poll_once -> int, _renew_lease_loop -> None); this contextmanager omits it. Add -> Iterator[None] (from collections.abc import Iterator) so the generator contract is expressed and type-checkable.

)


class TestLeaseRenewal:

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.

[Tests] Good loop-logic coverage; the gaps are at the seams. The state-machine tests (cap, interval, claim-uses-lease, renew-until-stopped, stop-on-row-gone, swallow-and-retry, ctx stop-on-exit) are solid. Missing, roughly in priority order:

  1. Integration — nothing drives _handle/poll_once to assert task.apply is actually wrapped in _lease_renewal(msg_id) (consumer.py:424-430). A refactor dropping the with would keep all 7 tests green.
  2. Ordering — the "joined before ack" safety claim (thread stopped/joined at :338-339 before delete at :504) is unasserted.
  3. Lazy _renew_client — every loop test pre-sets c._renew_client = MagicMock(), so the if self._renew_client is None: PgQueueClient() branch (:354-355) and the "no connection for short tasks" promise have zero coverage.
  4. lease_seconds <= 0 validationTestConstruction.test_rejects_non_positive_params enumerates every other positive-int param but omits lease_seconds; add 0 and -5 cases (trivial, guards the zero-lease continuous-double-run foot-gun).
  5. LEASE_SECONDS env wiringbuild_consumer_from_env (consumer.py:999) has no test at all; the operator-facing knob could be mis-spelled/dropped with CI green.
  6. Default cap 120→30 and the max(1, lease//3) floor for tiny leases (1/2 → 1) and the lease == vt boundary are untested.
  7. Sustained set_vt failure past the lease (the double-run boundary) is untested.

|---|---|---|
| `CONCURRENCY` | Prefork consumer children per pod (1 = plain single process) | `1` |
| `VT_SECONDS` | **Visibility timeout** — how long a claimed message stays hidden before it can redeliver | `30` (chart: `9060` for file-processing ≈ 2.5h) |
| `VT_SECONDS` | Since PR C, the **drain / max-runtime bound** (drives `SHUTDOWN_GRACE`, `HEALTH_STALE`, the chart guards) — the *claim* window is `LEASE_SECONDS`, not this | `30` (chart: `9060` for file-processing ≈ 2.5h) |

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.

[Docs] These two rows are misleading for the default/local config. VT_SECONDS row asserts "the claim window is LEASE_SECONDS, not this" — but with the default VT=30 < LEASE=120 the effective claim window is VT (30), because of min(lease, vt). And the LEASE_SECONDS Default column says 120 while the effective default is 30. Suggest: VT row → "the claim window is min(LEASE_SECONDS, VT_SECONDS)"; LEASE row Default → "120 (effective = min(LEASE, VT); with the default VT=30, 30)".

Also: "Since PR C" / "UN-3695 PR C" shorthand (:30, :73, and consumer.py:62/261/317/423) will rot after merge — no artifact called "PR C" survives. Prefer the UN-3695 Jira ref or describe the behavior directly, especially in the README where a standalone reader can't resolve it.

uses its own DB connection and is best-effort (a transient failure retries within the
`~2×` slack the `LEASE/3` interval leaves before expiry).

**Claim** — an atomic `SELECT … FOR UPDATE SKIP LOCKED` that hides up to `BATCH`

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.

[Docs] Stale glossary entry. The "Claim" definition still says it hides rows "for VT seconds," but poll_once now reads with vt_seconds=self.lease_seconds (consumer.py:302). Update to "...hides up to BATCH ready rows for the lease window (min(LEASE, VT))..." so it matches the PR-C model introduced just above.

…servable failures, batch guard

Review round on the renewable-lease change (#2154):
- Race: the renewal thread no longer shares self._renew_client. It owns a
  thread-local connection built lazily on the first renewal and closed in a finally,
  so a join-timeout wedge can't leave the next message's thread reusing a
  half-used psycopg2 connection. _make_renew_client() is the (patchable) factory.
- Wedge is no longer silent: after the bounded join, a still-alive thread is logged
  at ERROR (connection abandoned until process exit).
- set_vt -> False mid-run is a double-run signal (the ack runs only after this thread
  is joined, so the row can only be gone if another consumer reclaimed+acked it) —
  now logged, and the docstring corrected (was wrongly "acked by us").
- Narrowed the renewal except to CONN_DEAD_ERRORS (a non-connection error propagates
  instead of being swallowed as a "self-healing blip" forever) and added escalation:
  once renewal keeps failing past lease_seconds the lease is genuinely lost → ERROR.
- BATCH_SIZE forced to 1 when lease < vt: renewal only covers the in-flight message,
  so a batch tail could lapse and double-run. All shipped workers are batch=1; this
  guards the knob.
- Clamp is loud: lease > vt now warns (was a silent min()); comment corrected.
- Iterator return annotation on the context manager; "PR C" shorthand → UN-3695.
- README: effective claim window is min(LEASE, VT); lease is not a hard runtime cap
  (liveness probe is the backstop); batch-forced-to-1 note.
- Tests: +9 (loud clamp / tiny-interval floor / non-positive reject / batch-forced /
  batch-kept-at-lease==vt / env wiring / renew+close / no-conn-for-sub-interval /
  row-gone-logs / conn-death-retry-then-escalate / non-conn-propagates / wedged-log /
  _handle-wraps-in-lease). 87 consumer tests pass.

Deferred: lease metrics counters (ConsumerMetrics isn't wired to the consumer
instance) — a separate small observability change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@muhammad-ali-e

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough pass — addressed the review in fbbf03ef5. Summary mapped to the findings:

Correctness / safety (the top-3 + high items)

Docs / nits

Tests (#13): +9 covering loud clamp / tiny-interval floor / non-positive reject / batch-forced / batch-kept-at-lease==vt / LEASE_SECONDS env wiring / renew+close / no-connection-for-sub-interval-task / row-gone-logs / conn-death-retry-then-escalate / non-connection-propagates / wedged-thread-log / _handle-actually-wraps-in-_lease_renewal. 87 consumer tests pass.

Deferred (1): lease metrics counters (#8) — ConsumerMetrics isn't wired to the consumer instance (it's constructed in run() around the liveness gauge), so emitting pg_lease_lost_total etc. is a separate small observability change rather than squeezing a metrics hook in here. Happy to file it as a fast follow.

The one round-1 finding your verifier already dropped ("dead client never self-heals") stays dropped — _cursor() discards a dead owned connection so set_vt reconnects next tick.

…hrow pytest.raises, empty block

- consumer.py: escalation log uses logger.exception() (ERROR + traceback) instead of
  logger.error(..., exc_info=True) in the renewal except block.
- tests: one throwing call per pytest.raises block; fill the empty _lease_renewal
  context-manager with-block (S108).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@muhammad-ali-e muhammad-ali-e marked this pull request as ready for review July 8, 2026 08:43
@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a renewable lease mechanism for the PG-queue consumer (UN-3695) to reduce hard-kill recovery time from the full visibility timeout (~2.5 h) down to the short lease window (~120 s). Messages are now claimed for a configurable LEASE_SECONDS window and a daemon thread renews the claim every lease/3 seconds; a live worker keeps its message; a dead worker's thread stops with the process, so the lease expires and the message redelivers in minutes.

  • Renewable lease loop (_renew_lease_loop): A per-message daemon thread owned by _lease_renewal context manager issues set_vt renewals every lease/3 seconds using its own DB connection (lazily opened on the first tick, closed on exit). VT_SECONDS is retained as the drain/max-runtime bound and is no longer the claim window.
  • Safety guards: Batch size is forced to 1 when lease < VT (the tail would expire unrenewed); a lease > VT is clamped loudly; the renewal is joined before the ack so the two never race the row; a wedged thread is logged and abandoned (it's a daemon and dies with the process).
  • 78 consumer tests cover the new lease construction, renewal loop, context manager, and end-to-end _handle wiring.

Confidence Score: 5/5

Safe to merge; the renewable lease is correctly scoped to the PG consumer path, well-tested, and the join-before-ack ordering prevents any race between set_vt and delete.

The core invariants are solid: the claim uses lease_seconds, the renewal thread is joined before the ack, the connection is isolated to the thread and lazily opened, batch_size is correctly forced to 1 when the short lease is active, and the clamping is loudly logged. The 17 new deterministic unit tests cover all the key branches. The one P2 comment is about logging clarity after ERROR escalation — it does not affect correctness.

No files require special attention; all three changed files are in good shape.

Important Files Changed

Filename Overview
workers/queue_backend/pg_queue/consumer.py Core change: adds lease_seconds param, _lease_renewal context manager, and _renew_lease_loop daemon thread; batch forced to 1 when lease < VT; claim uses lease_seconds instead of vt_seconds. Logic is sound: join-before-ack ensures no set_vt/delete race; lazy connection avoids wasted resources for short tasks; clamping and batch guard are correct.
workers/tests/test_pg_queue_consumer.py 17 new TestLeaseRenewal tests cover: construction/clamping, env wiring, claim uses lease not VT, loop renews-until-stopped/conn-death-retry/escalation/early-stop-on-row-gone, context manager stop+join, wedged-thread log, and _handle integration guard. Deterministic control of stop.wait and time.monotonic is well-structured.
workers/queue_backend/pg_queue/README.md Documentation updated to reflect LEASE_SECONDS env var, the new role of VT_SECONDS as a drain bound, and the renewable-lease glossary entry explaining the fast-redelivery mechanic. Changes are accurate and thorough.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant M as Main Thread
    participant R as Renewal Thread (daemon)
    participant DB as Postgres

    M->>DB: "read(vt=lease_seconds) — claim msg"
    DB-->>M: QueueMessage(msg_id)
    M->>R: start _lease_renewal(msg_id)
    Note over R: stop.wait(lease/3) — sleeping
    M->>M: task.apply() — running task

    loop Every lease/3 seconds while task runs
        R->>DB: set_vt(msg_id, lease_seconds)
        DB-->>R: True (row still exists)
    end

    M-->>M: task returns
    M->>R: stop.set()
    R-->>R: stop.wait() returns True → exit loop
    R->>DB: client.close()
    M->>M: thread.join(10s) — wait for renewal to finish
    M->>DB: delete(msg_id) — ack

    Note over M,DB: If worker dies hard (OOM/node loss):<br/>Renewal thread dies with process.<br/>VT expires in ~lease_seconds → redelivery
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant M as Main Thread
    participant R as Renewal Thread (daemon)
    participant DB as Postgres

    M->>DB: "read(vt=lease_seconds) — claim msg"
    DB-->>M: QueueMessage(msg_id)
    M->>R: start _lease_renewal(msg_id)
    Note over R: stop.wait(lease/3) — sleeping
    M->>M: task.apply() — running task

    loop Every lease/3 seconds while task runs
        R->>DB: set_vt(msg_id, lease_seconds)
        DB-->>R: True (row still exists)
    end

    M-->>M: task returns
    M->>R: stop.set()
    R-->>R: stop.wait() returns True → exit loop
    R->>DB: client.close()
    M->>M: thread.join(10s) — wait for renewal to finish
    M->>DB: delete(msg_id) — ack

    Note over M,DB: If worker dies hard (OOM/node loss):<br/>Renewal thread dies with process.<br/>VT expires in ~lease_seconds → redelivery
Loading

Reviews (2): Last reviewed commit: "UN-3695 [FIX] startup log shows lease (t..." | Re-trigger Greptile

… (Greptile P2)

vt_seconds is now the drain/max-runtime bound, not the claim window — the startup
log showing only vt=%ss misleads an operator into thinking the claim window is 9060s
when it's actually lease_seconds (e.g. 120s). Add lease=%ss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jul 8, 2026

Copy link
Copy Markdown

@muhammad-ali-e

Copy link
Copy Markdown
Contributor Author

Thanks Greptile (4/5, safe-to-merge) — addressed the one P2 in e3d72d625: the startup log in run() now shows lease=%ss alongside vt=%ss, so an operator sees the real claim window (e.g. 120s) rather than assuming the 9060s VT is the claim window. VT_SECONDS is now the drain/max-runtime bound, not the claim window — the log wording matches that.

@muhammad-ali-e muhammad-ali-e merged commit f9b8da1 into feat/UN-3445-pg-queue-integration Jul 8, 2026
6 checks passed
@muhammad-ali-e muhammad-ali-e deleted the fix/UN-3695-heartbeat-lease branch July 8, 2026 09:04
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