Skip to content

Never publish the same post to the same channel twice - #10

Open
pallaoro wants to merge 2 commits into
per-channel-contentfrom
idempotent-publish
Open

Never publish the same post to the same channel twice#10
pallaoro wants to merge 2 commits into
per-channel-contentfrom
idempotent-publish

Conversation

@pallaoro

@pallaoro pallaoro commented Sep 3, 2026

Copy link
Copy Markdown
Member

Stacked on #9 (per-channel-content), which this builds on.

The problem

Publishing was safe to run once. Three ordinary things run it again:

  • the scheduler that fires a post at its scheduled time delivers at least once, so a delivery whose response is lost comes back;
  • the queue view flags a half-delivered post as needing attention, and the obvious answer is to retry it;
  • someone edits a post that has already gone out and saves it.

In each case publishPost() walked every channel on the post and sent again, so channels that had already succeeded got a second live post. There is no undo on a published post, so this could not be cleaned up afterwards.

The fix

Delivery is claimed per channel before anything is sent:

  • a channel already marked published is reported with the post id and link it got the first time, and nothing is sent;
  • the rest are claimed with a compare-and-swap on the attempts counter (a column that was written but never read), so two deliveries racing each other resolve to exactly one send. A crashed run wedges nothing — the row is still pending/failed, so the next retry claims it again;
  • the post's own status is rolled up by re-reading post_channels, because a retry only touches part of it and a concurrent delivery may have settled the rest.

Editing a post no longer deletes and re-inserts its channel rows. That row is where the delivery state lives, so re-inserting it dropped the link to a post that was already live, reset the channel to pending, and re-armed the duplicate. Channels that stay on the post are updated in place; only the ones actually removed are deleted.

With that, Retry finally works on a partial post — the status that most needs it, and the one the button used to skip.

What this deliberately does not cover: a crash after the platform accepted the post but before the result was written. Only a platform-side idempotency key could, and none of these APIs offers one. It's called out in a comment rather than left implied.

Verifying it

Adds pnpm test — the real Hono API against an in-memory SQLite database, in process, with no dev server and nothing to deploy. It counts the calls the app actually makes to each platform, so "did this post go out twice" is a fact rather than an inference.

A redelivery of the same post
  ok  sends nothing the second time
  ok  still reports both channels delivered
  ok  reports the original post ids, not new ones
  ok  leaves the post published
  ok  does not count as another attempt

Retrying a post that only partly went out
  ok  one channel failing makes the post partial
  ok  does not post again to the channel that succeeded
  ok  does post to the channel that failed
  ok  rolls the post up to published
  ok  clears the recovered channel's error

Editing a post that is already live
  ok  keeps the channel marked published
  ok  keeps the link to the live post
  ok  publishing the edit does not post a second time
  ok  still saves a per-channel rewrite

Two deliveries arriving at once
  ok  only one of them sends
  ok  the post still ends up published

Removing a channel from a post
  ok  drops only the channel that was removed
  ok  clearing every channel leaves no rows

18/18 checks passed

Nine of those eighteen fail on the previous code — including a redelivery posting a second tweet and a second LinkedIn post, a retry re-tweeting, and an edit blanking the live post's link.

Needs Node 22.5+ for node:sqlite.

Publishing was safe to run once. Three ordinary things run it again:

  - the scheduler that fires a post at its scheduled time delivers at least
    once, so a delivery whose response is lost comes back;
  - the queue view flags a half-delivered post as needing attention, and the
    obvious answer is to retry it;
  - someone edits a post that has already gone out and saves it.

In each case publishPost() walked every channel on the post and sent again,
so the channels that had already succeeded got a second live post. There is
no undo on a published post, so this could not be cleaned up afterwards.

Delivery is now claimed per channel before anything is sent:

  - a channel already marked published is reported with the post id and link
    it got the first time, and nothing is sent;
  - the rest are claimed with a compare-and-swap on the attempts counter (a
    column that was written but never read), so two deliveries racing each
    other resolve to exactly one send. A crashed run wedges nothing: the row
    is still pending or failed, so the next retry claims it again;
  - the post's own status is rolled up by re-reading post_channels, because a
    retry only touches part of it and a concurrent delivery may have settled
    the rest.

Editing a post no longer deletes and re-inserts its channel rows either. That
row is where the delivery state lives, so re-inserting it dropped the link to
a post that was already live, reset the channel to pending, and re-armed the
duplicate. Channels that stay on the post are updated in place; only the ones
actually removed are deleted.

With that, Retry finally works on a partial post — the status that most needs
it, and the one the button used to skip.

Adds `pnpm test`: the real API against an in-memory SQLite database, in
process, counting the calls made to each platform. All nine of these defects
reproduce on the previous code.
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