fix(backend): the reminder and at-risk polls pass over what was already sent (#1115) #1118

Merged
claude-bot merged 1 commit from fix/1115-reminder-poll-rollback into main 2026-10-10 18:44:30 +00:00
Collaborator

Closes #1115.

Cause

Both polls claim an "already told" marker by inserting it and catching IntegrityError, and the handler called await db.rollback(). task_session() sets expire_on_commit=False, which does not cover rollback: every loaded row was expired, and the log line's session.id then raised MissingGreenlet and ended the task. "Already told" is what every poll after the first finds, so:

  • poll_session_at_risk (since 3.6.0) stopped at the first already-warned session, and the sessions after it in the list were not checked.
  • poll_session_reminders (since 1.1.0) did the same for the minutes after a reminder went out.
  • The delivery-failure branches had the same fault: a failed publish also ended the poll.

Seen on dev (1,152 failures of the at-risk poll in four days) and on prod 4.4.1 (15 of the reminder poll on 7 October).

Fix

  • The claim runs in a savepoint (db.begin_nested()): a duplicate undoes the insert and nothing else.
  • Both polls walk a list of ids and read each session as its turn comes, so a rollback in one cannot poison the next.
  • The delivery-failure branches keep their full rollback (the claim must go, so the next poll retries) and log from plain values; the reminder branch re-reads the session and campaign before that session's next offset.
  • The idempotency rule is unchanged: a marker commits with its notifications, after delivery was attempted.

Tests

tests/test_poll_marker_claims.py (new, four tests) commits real rows and lets the polls open the real task_session(). The existing tests could not see this: the test session aliases commit to flush, so the rows were never persistent, and a rollback expunged them with their values loaded instead of expiring them. On main's code all four fail with MissingGreenlet; with the fix they pass. test_at_risk_task_notifies_once_per_confirmed_time now asserts the marker survives the second run.

Run before this PR: the four new tests (fail on main, pass here), 202 related backend tests, ruff. The full suite is CI's.

Not in this PR: import_jobs.Heartbeat rolls back after a failed heartbeat write; whether anything reads an expired row afterwards was not traced.

🤖 Generated with Claude Code

Closes #1115. ## Cause Both polls claim an "already told" marker by inserting it and catching `IntegrityError`, and the handler called `await db.rollback()`. `task_session()` sets `expire_on_commit=False`, which does not cover rollback: every loaded row was expired, and the log line's `session.id` then raised `MissingGreenlet` and ended the task. "Already told" is what every poll after the first finds, so: - `poll_session_at_risk` (since 3.6.0) stopped at the first already-warned session, and the sessions after it in the list were not checked. - `poll_session_reminders` (since 1.1.0) did the same for the minutes after a reminder went out. - The delivery-failure branches had the same fault: a failed publish also ended the poll. Seen on dev (1,152 failures of the at-risk poll in four days) and on prod 4.4.1 (15 of the reminder poll on 7 October). ## Fix - The claim runs in a savepoint (`db.begin_nested()`): a duplicate undoes the insert and nothing else. - Both polls walk a list of ids and read each session as its turn comes, so a rollback in one cannot poison the next. - The delivery-failure branches keep their full rollback (the claim must go, so the next poll retries) and log from plain values; the reminder branch re-reads the session and campaign before that session's next offset. - The idempotency rule is unchanged: a marker commits with its notifications, after delivery was attempted. ## Tests `tests/test_poll_marker_claims.py` (new, four tests) commits real rows and lets the polls open the real `task_session()`. The existing tests could not see this: the test session aliases `commit` to `flush`, so the rows were never persistent, and a rollback expunged them with their values loaded instead of expiring them. On main's code all four fail with `MissingGreenlet`; with the fix they pass. `test_at_risk_task_notifies_once_per_confirmed_time` now asserts the marker survives the second run. Run before this PR: the four new tests (fail on main, pass here), 202 related backend tests, ruff. The full suite is CI's. Not in this PR: `import_jobs.Heartbeat` rolls back after a failed heartbeat write; whether anything reads an expired row afterwards was not traced. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(reminders): an already-sent marker no longer ends the poll (#1115)
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 36s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m4s
CI / Bot/backend version sync (pull_request) Successful in 29s
CI / Feedback site tests (pull_request) Successful in 45s
CI / Does this change touch the recording path? (pull_request) Successful in 19s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Bot tests and audit (pull_request) Successful in 1m59s
CI / Docker image build (pull_request) Successful in 4m1s
CI / Frontend (shard 2/2) (pull_request) Successful in 7m49s
CI / End-to-end journeys (phone) (pull_request) Successful in 12m19s
CI / End-to-end journeys (desktop) (pull_request) Successful in 13m26s
CI / End-to-end journeys (keyboard) (pull_request) Successful in 14m0s
CI / Frontend (shard 1/2) (pull_request) Successful in 19m18s
CI / Backend migration, tests, and audit (pull_request) Successful in 35m2s
a09d171035
poll_session_at_risk and poll_session_reminders claim an "already told"
marker by inserting it and taking a unique-constraint failure to mean it
was sent before. They answered that failure with db.rollback(), which
expires every row the session has loaded; the log line straight after
read session.id outside await and raised MissingGreenlet, ending the
task. "Already told" is what every poll after the first finds, so the
at-risk poll died at the first warned session on every run and never
reached the sessions after it.

The claim is now made in a savepoint, so a duplicate undoes the insert
and nothing else. The delivery-failure branches keep their full rollback
(the claim must go, so the next poll tries again) and no longer touch
what it expired: both polls walk session ids and read each session as
its turn comes, and the reminder poll reads the session and campaign
again before the same session's next offset.

The existing tests ran the polls on the test's own never-committed
transaction, where a rollback expunges the rows rather than expiring
them, so the crash could not happen there. The new tests commit their
rows and let the poll open its own task_session().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude-bot deleted branch fix/1115-reminder-poll-rollback 2026-10-10 18:44:31 +00:00
Sign in to join this conversation.
No description provided.