SQLAlchemy: Idempotency guard defeated by deferred commit in SSE stream
Atomic idempotency guard defeated by deferred commit: tool mutations inside an SSE stream roll back after non-transactional side effects already escaped.
A registration flow driven by an LLM tool call inside a server-sent-event stream produced a full duplicate side-effect bundle (welcome email, analytics events fired twice) even though the registration claim was a textbook atomic guard: UPDATE user SET registered_at=now() WHERE user_id=:id AND registered_at IS NULL, rowcount-checked, with belt-and-suspenders re-checks at the tool layer.
The guard logic was never the problem; the transaction TIMING was:
- Request middleware commits the session at
http.response.start(before streaming begins), so tool-induced DB mutations during the stream are committed by an explicitdb.commit()AFTER the stream finishes. - Inside the stream, the registration tool fired non-transactional side effects (email send, PostHog/CAPI captures) at call time.
- A worker RSS-recycle SIGTERM killed the stream between the tool call and the post-stream commit. The session rolled back — marker and created rows gone — while the email and analytics events had already escaped.
- On reload, the resume path re-ran registration against a genuinely-unregistered DB row. The atomic guard matched 1 row (correctly, from its point of view) and re-fired everything. Observability showed two complete
user_registeredbundles; the DB held one.
General form: an idempotency marker is only as strong as its DURABILITY at the moment the unrecoverable side effects fire. WHERE marker IS NULL semantics are irrelevant if the marker's commit is deferred past the side effects and the process can die in between (worker recycling, scale-in, deploys). Streaming endpoints stretch this window from milliseconds to minutes.
Commit the claim transaction at the moment the side effects fire, not at the end of the request/stream. In the tool that performs the step, call session.commit() immediately after the step function returns, so the atomic marker + created rows are durable before the stream continues. Keep the end-of-stream commit for side-effect-free mutations.
Practical notes:
- Compatible with transaction-per-test fixtures: SQLAlchemy 2.0's
Session(bind=connection)with an already-begun outer transaction defaults tojoin_transaction_mode='conditional_savepoint', so the in-requestcommit()releases a SAVEPOINT and the fixture's outertransaction.rollback()still cleans up. If your fixture predates this, run the step on a short-lived dedicated session andexpire()the stale object in the request session. - Write the durability test as: perform step ->
session.rollback()-> assert the marker and rows survived. Under savepoint-join fixtures the rollback only undoes post-commit work, so the test meaningfully fails on the old code and passes on the fix. - Diagnostic tell for this bug class: analytics shows N complete side-effect bundles while the DB shows fewer rows than N implies — non-transactional emissions escaped a rolled-back transaction.