Concurrent publish attempts create duplicate Lemmy posts #123
Labels
No labels
bug
devops
duplicate
enhancement
good first issue
layout
next major release
next minor release
question
research
testing
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lvl0/fedi-feed-router#123
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
An article was posted twice to the same Lemmy community. Lemmy displays the second as "cross-posted to: news". FFR's own records show only one publication, so the duplicate is invisible from the app.
Three linked defects are involved. They share a root cause path and a test setup, so they are tracked together.
Evidence (production, 2026-08-02 08:01:34)
Every log line is doubled, all within the same second:
Resulting state:
2005693and2005694— consecutive ids, same communityarticle_publicationsrow (pub#3, post_id 2005693)route_articles:ra#47,approval_status=approved,publish_status=errorDefect 1 — check-then-act race (root cause)
Two
PublishApprovedArticleListenerinstances executed concurrently. Both passed this guard before either had written its publication row:Both then called Lemmy — creating both posts — and both attempted to insert. The unique index
article_pub_uniqueon(article_id, platform, platform_channel_id)rejected the loser withSQLSTATE 23000.The constraint protects our data, not the remote side effect. By the time it fires, both Lemmy posts already exist.
PublishNextArticleJobcarriesShouldBeUnique, but that only dedupes that job against itself — it does not coordinate with the listener, and the listener has no uniqueness constraint at all.Not yet established: why two listener instances ran. Candidates are the approval event dispatching twice, or a queue retry re-running the listener after a partial success.
retry_afteris 90s, so a timeout retry is unlikely at same-second granularity. This needs confirming before the fix is chosen — a lock and an idempotency key address different mechanisms.Defect 2 — the duplicate backstop has never worked
ArticlePublishingService::publishToChannel()callsPlatformChannelPost::duplicateExists()before publishing, which queries the localplatform_channel_postsmirror table.That table has 0 rows in production. The guard therefore always returns false and has apparently never functioned.
SyncChannelPostsJob(which populates the mirror) failed on 2026-03-08 and still sits unretried in the failed-jobs queue. Likely related, but unconfirmed — the table could also be empty because the job never runs, runs without writing, or was never scheduled. Root cause to be established as part of this work.Defect 3 — skip and failure are indistinguishable
publishToChannel()returnsnullboth when it skips a duplicate and when it catches an exception. Callers cannot tell them apart:PublishApprovedArticleListener:54— setspublish_status = ERRORon any nullPublishNextArticleJob:89— sameThis is why the log shows both "Published approved article" and "No publication created" for the same article, and why
ra#47readserrordespite the post succeeding. It made diagnosis materially harder.Why one ticket
The fixes touch the same call path, and a single regression test — publishing the same
route_articleconcurrently — exercises all three: the race, the backstop that should have caught it, and the resulting status reporting.Acceptance criteria
(article, channel)result in exactly one Lemmy postplatform_channel_postsidentified; the mirror populates soduplicateExists()can functionpublish_statusand logsManual cleanup required
Lemmy post 2005694 is live with no corresponding FFR record and must be deleted by hand. FFR will not clean it up.
Related
Investigation — root causes established
Defect 1: why two listener instances ran
RouteArticle::approve()dispatches unconditionally, with no re-approval guard (app/Models/RouteArticle.php:93-98):Both callers invoke it without checking current status:
app/Livewire/Articles.php:36—RouteArticle::findOrFail($routeArticleId)->approve();app/Http/Controllers/Api/V1/RouteArticlesController.php:46—$routeArticle->approve();So approving an already-approved record fires the event again. A double-click, or a UI action plus an API call, queues two listener instances.
Retry is ruled out —
config/horizon.php:192setstries => 1, so a failed job is not re-run. The publishing queue allowsmaxProcesses: 10, so two dispatched listeners execute in parallel.Chain: two
approve()calls → twoRouteArticleApprovedevents → two queued listeners → both pass thearticlePublications()->exists()check before either writes → two Lemmy posts.Defect 2: the mirror table has a key mismatch as well as being empty
SyncChannelPostsJobis scheduled (routes/console.php:10-12, every ten minutes) and does carryShouldBeUnique. So "never scheduled" is not the explanation.More importantly, the write and read keys differ:
channel_idSyncChannelPostsJob:71-73→LemmyApiService::syncChannelPosts()→PlatformChannelPost::storePost(..., (string) $platformChannelId, ...)resolveCommunityId()ArticlePublishingService:73→PlatformChannelPost::duplicateExists(..., (string) $channel->channel_id, ...)CreateChannelActioncopiesnameintochannel_id)Even with a fully populated mirror,
duplicateExists()would never match, because rows are stored under the numeric id and queried by slug.The empty table masked this second defect — fixing the sync alone would not have restored the guard.
Revised scope
Three defects becomes four:
approve()has no re-approval guard, and the publish path is check-then-act with no lockplatform_channel_postsis empty — sync failing (SyncChannelPostsJob, 2026-03-08, still in the failed queue; needs its own root cause)platform_channel_postsis keyed inconsistently — written by numeric community id, read by slugpublishToChannel()returns null for both skip and failure, so callers record both asERRORItem 3 is new and was not visible from the production symptoms alone.
Why the existing tests did not catch the key mismatch
PlatformChannelPost::storePost()has one production call site and three test call sites, and they disagree:channel_idargumentapp/Modules/Lemmy/Services/LemmyApiService.php:145(production)(string) $platformChannelId— the numeric Lemmy community idtests/Unit/Services/Publishing/ArticlePublishingServiceTest.php:150, 177, 204(string) $channel->channel_id— the slugThe tests hand-seed the mirror using the same key
duplicateExists()reads with, so they pass. Production seeds it viaSyncChannelPostsJobusing the numeric id, so the lookup never matches.The tests assert the read path against a fixture that production never produces — which is exactly why this survived.
Fix approach
Proposed, for discussion during implementation:
RouteArticle::approve()should be a no-op (no event dispatch) when already approved. Cheapest fix, closes the common double-click path.(article_id, platform_channel_id)around the check-publish-record sequence inArticlePublishingService::publishToChannel(), so concurrent attempts serialise and the second sees the first's publication row. The guard and the lock address different windows; both are wanted.channel->channel_id, but the numeric id is what the Lemmy API returns. Either works provided it is applied to write, read, and tests.nullfor "skipped as duplicate", so callers stop recording it asERROR.Still to determine: why
SyncChannelPostsJobfailed on 2026-03-08 and whether it has run successfully since.Agreed approach (decided 2026-08-02) — start here
All four defects are fixed together in v1.3.6. They are one failure with three missing defences: the race is the cause, the mirror is the backstop that never worked, and the reporting bug is why it stayed invisible. Fixing the race alone would leave duplicate detection still broken with no way to tell whether the lock ever misses.
1. Persist the resolved Lemmy community id on
platform_channelsDecision: key the mirror by numeric community id, and store that id locally rather than resolving it on the read path.
Why numeric over slug:
resolveCommunityId()exists to produceWhy persist rather than resolve inline:
duplicateExists()currently reads$channel->channel_idwith no network call. Resolving inline would put an HTTP call inside the duplicate guard, so the safety net fails exactly when Lemmy is slow or unreachableresolveCommunityId()currently runs on every publish and every sync for a mapping that effectively never changeschannel_idintegrity and extractedresolveCommunityId()because slug-vs-numeric ambiguity kept causing bugs. Storing the resolved id removes the ambiguity rather than adding a fourth place it livesImplementation:
platform_channels.remote_community_id)LemmyApiService::syncChannelPosts()(write) andArticlePublishingService::publishToChannel()(read) use it2. Re-approval guard
RouteArticle::approve()(app/Models/RouteArticle.php:93-98) should be a no-op — no event dispatch — whenapproval_statusis alreadyAPPROVED. Closes the common double-click path at its source.3. Atomic publish
Cache lock keyed on
(article_id, platform_channel_id)around the check-publish-record sequence inArticlePublishingService::publishToChannel(), so concurrent attempts serialise and the second sees the first's publication row. The guard (2) and the lock (3) close different windows — both are wanted.4. Distinguish skip from failure
publishToChannel()returns barenullfor both "skipped as duplicate" and "publish failed", soPublishApprovedArticleListener:54andPublishNextArticleJob:89record both aspublish_status = ERROR. Return a distinguishable result so skips stop being reported as errors.5. Tests must exercise the real write path
The existing tests seed
platform_channel_postsby callingstorePost()directly with the slug — the same keyduplicateExists()reads — so they pass while production (which writes the numeric id) fails. Do not repeat this. Tests should drive the mirror through the sync path, or at minimum assert that the write and read keys agree.One concurrent-publish test should cover the lock, the mirror lookup, and the status reporting together.
Still to investigate
SyncChannelPostsJobfailed on 2026-03-08, and whether it has succeeded since. The failed job is still in the queue (398a29c9-8da2-49e9-a758-d23779e72346).platform_channel_postsstays empty for a reason beyond the key mismatch.Noted risk
This is larger than a typical patch — a migration, a lock, a key change across write/read/tests, and a status refactor. Accepted deliberately because it is an active data-integrity bug and the backstop is what verifies the primary fix.
Current state
Nothing implemented yet. Investigation complete; no code written. Production still has the duplicate — Lemmy post
2005694needs manual deletion.Root causes
Recording these against the acceptance criteria. Shipped on
release/v1.3.7inb2d504f,b527813,358171c.Defect 1 — concurrent listener execution
RouteArticle::approve()had no idempotency guard. Beforeb2d504fit dispatchedRouteArticleApprovedunconditionally on every call, so a double approval queued two listeners. Both passed the check-then-act guard in the listener before either had written its publication row, both called Lemmy, and the unique index rejected the loser — by which point both posts already existed.The fix is layered, at source and at the race window:
approve()returns early if already approved (b2d504f) — stops the duplicate event being dispatched at all, which covers the ordinary double-click.Cache::lock("publish:{article_id}:{channel_id}")(180s TTL, 15s wait) inArticlePublishingService::publishToChannel(), re-checking for an existing publication inside the lock. This covers the case the guard cannot: two stale in-memory instances of the same row, neither aware the other approved.test_without_the_approved_guard_two_approvals_dispatch_two_eventsreproduces the original race — two separately loaded instances, both pending, both approving, 2 events dispatched — andtest_two_stale_approvals_still_create_only_one_remote_postcovers the layer below it.Defect 2 — empty
platform_channel_posts(two independent causes)1. Key mismatch. The mirror was written with Lemmy's numeric community id and read with the community slug, so
duplicateExists()never matched — the guard could not have worked regardless of the table's contents. Fixed by keying the mirror on the localplatform_channels.id, which also removes the per-instance collision both remote identifiers share (2024_01_01_000013_key_platform_channel_posts_by_local_channel).2. The scheduler was never running in production. The container started Horizon but not
schedule:work, soSyncChannelPostsJobwas never dispatched. Fixed in358171cby addingphp artisan schedule:work &to the Dockerfile.This revises the ticket's original assumption: the 2026-03-08 failed job was a symptom, not the cause. The table was empty primarily because nothing scheduled the sync, and the key mismatch meant the guard would have failed even had it been populated.
Defect 3 — skip vs failure
publishToChannel()returned a barenullfor both. Replaced with aPublishOutcomevalue object (published/skipped/failure) and a newPublishStatusEnum::SKIPPED, so callers can distinguish the two.Coverage
DuplicatePublishTest(6 tests) andMirrorDuplicateDetectionTest(5 tests), including the mutation-style check above. Full suite green offline.