fix(email): atomically claim scheduled emails before sending (#5110)

_scheduled_poll_once selected rows WHERE status='pending' and only wrote
status='sent'/'failed' after the SMTP send and IMAP append completed -
no atomic claim in between. Two overlapping callers (the in-process 30s
poller and an externally cron/systemd-driven 'odysseus-mail
poll-scheduled', or the CLI run manually) can both SELECT the same
pending row before either UPDATEs it, and both send it. _start_poller's
own docstring already names this exact risk ('avoid two copies of
_scheduled_poll_once racing on the same SQLite') but nothing in the code
enforced it - it was advisory only.

Add an atomic per-row claim: UPDATE ... SET status='sending' WHERE
id=? AND status='pending', proceeding only when rowcount == 1. The
loser of the race sees rowcount == 0 and skips the row instead of
sending a duplicate.

Adds a regression test that drives two real threads through the real
_scheduled_poll_once against a shared SQLite file, synchronized with a
barrier and a widened send-path window, and asserts exactly one send
fires. Reverting the fix makes the test fail reliably (5/5 runs); with
the fix it passes reliably (5/5 runs).

Fixes #5109
This commit is contained in:
jagadish-zentiti
2026-07-11 08:53:36 +05:30
committed by GitHub
parent e0fd68160f
commit 21c7bf802e
2 changed files with 138 additions and 0 deletions
+22
View File
@@ -1068,6 +1068,28 @@ def _scheduled_poll_once() -> dict:
for r in rows:
sid = r[0]
try:
# Atomically claim this row before doing any work. Two
# pollers can race here (the in-process asyncio task and an
# externally cron-driven `odysseus-mail poll-scheduled`, or
# an admin running the CLI manually alongside the in-process
# one despite the ODYSSEUS_INPROCESS_POLLERS=0 guidance) -
# both can SELECT the same 'pending' row before either has
# updated its status. The UPDATE...WHERE status='pending' is
# the atomicity boundary: only the poller whose UPDATE
# actually changes a row (rowcount == 1) proceeds to send;
# a loser sees rowcount == 0 and skips it instead of sending
# a duplicate.
claim_conn = sqlite3.connect(SCHEDULED_DB)
claim_cur = claim_conn.execute(
"UPDATE scheduled_emails SET status='sending' WHERE id=? AND status='pending'",
(sid,),
)
claim_conn.commit()
claimed = claim_cur.rowcount == 1
claim_conn.close()
if not claimed:
continue
attachments = json.loads(r[8] or "[]")
row_account_id = r[9] if len(r) > 9 else None
odysseus_kind = r[10] if len(r) > 10 else "scheduled"