Send the Email After the Commit
A publish-notification double-fire in KittyLog, the fix, and finding the exact same bug in a sibling code path 49 minutes later.
On July 21, 2026, at 12:54, we merged a fix for a notification bug in KittyLog's publish flow. At 13:43 the same day, we merged the same fix again. Same bug, same shape, different file.
The second merge is the one worth writing about, but it only makes sense if I rewind to the first.
The original bug
When a changelog entry gets published in KittyLog, a few things are supposed to happen: the owner gets an email, a Discord webhook may fire, the public page cache gets invalidated, an analytics event gets recorded. The publish service in changelog_publish_service.py did all of that, with two problems that had been sitting quietly for a while.
First, publish emails were being sent twice. The service called the notification sender directly, and it also emitted a ChangelogPublishedEvent on our internal event bus, where an email handler picked it up and sent the same email again. The only thing standing between users and duplicate inboxes was a five-minute dedup window in the notification queue. The dedup held, so nobody ever saw a double email. Which sounds like good news until you notice what it means: a regression in the dedup layer would have started double-emailing every publisher, and no test would have said why.
Second, the events were emitted inside the database connection block. The async with get_db_connection() context was still open while notification handlers ran. A slow email handler meant a connection held longer than it needed to be, and a handler that touched the database itself was one refactor away from nested-transaction surprises.
Neither problem was user-visible. The dedup queue was doing its job, and the connection pool was big enough that nobody noticed the held connections. This was filed as issue #1330 with "low urgency" in the text. It got found at all because someone was reading the publish path for an unrelated feature, the Discord opt-out on Publish All, and left a note rather than walking past it.
Why the order matters
An email is not a database row. You cannot roll it back. If you send notifications while the transaction that justifies them is still in flight, you can end up announcing state that never became durable: the handler fires, the commit fails, and now a user has an email about a changelog that does not exist. The reverse failure is milder. If you commit first and the email fails, you have durable state and a missing notification, which is annoying but honest, and retryable.
So the rule the fix encoded is simple: do the database work, close the connection, then tell people. PR #1338 rewrote the publish service that way. Notifications flow through exactly one path now, the event bus handler, and a helper called _emit_publish_events runs after the connection context has exited. The conditional UPDATE that flips only still-unpublished rows also returns which rows actually transitioned, so a concurrent auto-publish cannot cause a double emit at the source rather than relying on dedup to mop it up. Tests pin all of it.
That was the 12:54 merge.
The second occurrence
A pattern like "send directly AND emit an event, inside the connection block" does not appear in one file by accident. Someone wrote it once because it seemed reasonable, and then it got copied, because copying the nearest working example is how most code gets written. I do it too.
So after #1338 landed, the obvious next step was to go looking for siblings. The search did not take long. draft_auto_publish.py, the scheduler sweep that publishes drafts left unreviewed for 24 hours when a review-mode repo has auto-publish left on (it defaults on), had the identical structure: a direct call to send_changelog_publish_notifications (this one not even dedup-guarded by the queue) plus an event emit, both inside the per-entry connection block. Issue #1341 was filed describing it as exactly the #1330 pattern, and PR #1342 merged the mirrored fix 49 minutes after the first one.
The mirrored version is arguably cleaner than the original. _publish_entry now does only the conditional UPDATE and returns an event object or None. It performs zero side effects, and there is a test asserting precisely that. The sweep collects the events, and a separate test counts open connections and requires the count to hit zero before any event fires. The emit helper is reused from the publish service, so there is one place that knows how to do this correctly instead of two places that mostly agree.
The general habit I would recommend from this: when you fix a bug, spend the next half hour hunting the pattern rather than admiring the fix. Grep for the shape of the mistake. In our case the shape was greppable, side effects under get_db_connection(), and the second instance was sitting right there.
We kept the five-minute dedup window, by the way. On the publish path it now guards against a double-fire that no code produces. It is nice when the safety net is bored.
Source records
- Issue #1330 and PR #1338 (commit fc65e308), the publish-service consolidation.
- Issue #1341 and PR #1342 (commit 858663c7), the same fix in the draft auto-publish sweep.
api/services/changelog_publish_service.pyandapi/services/scheduler/processing/draft_auto_publish.py, the current code paths.