Concurrent publish attempts create duplicate Lemmy posts #123

Closed
opened 2026-08-02 10:12:34 +02:00 by myrmidex · 4 comments
Owner

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:

[info]    Published to channel            {"article_id":97,"channel_id":2,"channel_name":"news"}
[warning] Failed to publish to channel    {"article_id":97,"error":"SQLSTATE[23000]: Integrity
                                           constraint violation: 1062 Duplicate entry
                                           '97-lemmy-2' for key 'article_pub_unique'"}
[info]    Published approved article      x2
[warning] No publication created          x2

Resulting state:

  • Lemmy: posts 2005693 and 2005694 — consecutive ids, same community
  • FFR: one article_publications row (pub#3, post_id 2005693)
  • route_articles: ra#47, approval_status=approved, publish_status=error

Defect 1 — check-then-act race (root cause)

Two PublishApprovedArticleListener instances executed concurrently. Both passed this guard before either had written its publication row:

if ($article->articlePublications()
    ->where('platform_channel_id', $routeArticle->platform_channel_id)
    ->exists()
) {
    return;
}

Both then called Lemmy — creating both posts — and both attempted to insert. The unique index article_pub_unique on (article_id, platform, platform_channel_id) rejected the loser with SQLSTATE 23000.

The constraint protects our data, not the remote side effect. By the time it fires, both Lemmy posts already exist.

PublishNextArticleJob carries ShouldBeUnique, 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_after is 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() calls PlatformChannelPost::duplicateExists() before publishing, which queries the local platform_channel_posts mirror 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() returns null both when it skips a duplicate and when it catches an exception. Callers cannot tell them apart:

  • PublishApprovedArticleListener:54 — sets publish_status = ERROR on any null
  • PublishNextArticleJob:89 — same

This is why the log shows both "Published approved article" and "No publication created" for the same article, and why ra#47 reads error despite 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_article concurrently — exercises all three: the race, the backstop that should have caught it, and the resulting status reporting.

Acceptance criteria

  • Root cause of the concurrent listener execution identified and recorded here
  • Concurrent publish attempts for the same (article, channel) result in exactly one Lemmy post
  • Root cause of the empty platform_channel_posts identified; the mirror populates so duplicateExists() can function
  • Skipped-as-duplicate is distinguishable from publish failure, in both publish_status and logs
  • Regression test reproducing concurrent publishing
  • Tests work offline with mocked Lemmy calls (project rule)
  • Existing publish tests still pass

Manual cleanup required

Lemmy post 2005694 is live with no corresponding FFR record and must be deleted by hand. FFR will not clean it up.

  • #115 — Belga discovery; this surfaced while verifying that deploy
  • #118 — E2E tests; asserting the publish payload and its side effects is in scope there
## 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: ``` [info] Published to channel {"article_id":97,"channel_id":2,"channel_name":"news"} [warning] Failed to publish to channel {"article_id":97,"error":"SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '97-lemmy-2' for key 'article_pub_unique'"} [info] Published approved article x2 [warning] No publication created x2 ``` Resulting state: - **Lemmy**: posts `2005693` and `2005694` — consecutive ids, same community - **FFR**: one `article_publications` row (`pub#3`, post_id 2005693) - **`route_articles`**: `ra#47`, `approval_status=approved`, `publish_status=error` ## Defect 1 — check-then-act race (root cause) Two `PublishApprovedArticleListener` instances executed concurrently. Both passed this guard before either had written its publication row: ```php if ($article->articlePublications() ->where('platform_channel_id', $routeArticle->platform_channel_id) ->exists() ) { return; } ``` Both then called Lemmy — creating both posts — and both attempted to insert. The unique index `article_pub_unique` on `(article_id, platform, platform_channel_id)` rejected the loser with `SQLSTATE 23000`. **The constraint protects our data, not the remote side effect.** By the time it fires, both Lemmy posts already exist. `PublishNextArticleJob` carries `ShouldBeUnique`, 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_after` is 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()` calls `PlatformChannelPost::duplicateExists()` before publishing, which queries the local `platform_channel_posts` mirror 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()` returns `null` both when it skips a duplicate and when it catches an exception. Callers cannot tell them apart: - `PublishApprovedArticleListener:54` — sets `publish_status = ERROR` on any null - `PublishNextArticleJob:89` — same This is why the log shows both "Published approved article" and "No publication created" for the same article, and why `ra#47` reads `error` despite 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_article` concurrently — exercises all three: the race, the backstop that should have caught it, and the resulting status reporting. ## Acceptance criteria - [ ] Root cause of the concurrent listener execution identified and recorded here - [ ] Concurrent publish attempts for the same `(article, channel)` result in exactly **one** Lemmy post - [ ] Root cause of the empty `platform_channel_posts` identified; the mirror populates so `duplicateExists()` can function - [ ] Skipped-as-duplicate is distinguishable from publish failure, in both `publish_status` and logs - [ ] Regression test reproducing concurrent publishing - [ ] Tests work offline with mocked Lemmy calls (project rule) - [ ] Existing publish tests still pass ## Manual 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 - #115 — Belga discovery; this surfaced while verifying that deploy - #118 — E2E tests; asserting the publish payload and its side effects is in scope there
myrmidex added this to the v1.3.6 milestone 2026-08-02 10:12:34 +02:00
myrmidex added the
bug
label 2026-08-02 10:12:34 +02:00
myrmidex self-assigned this 2026-08-02 10:12:34 +02:00
Author
Owner

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):

public function approve(): void
{
    $this->update(['approval_status' => ApprovalStatusEnum::APPROVED]);
    event(new RouteArticleApproved($this));
}

Both callers invoke it without checking current status:

  • app/Livewire/Articles.php:36RouteArticle::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 outconfig/horizon.php:192 sets tries => 1, so a failed job is not re-run. The publishing queue allows maxProcesses: 10, so two dispatched listeners execute in parallel.

Chain: two approve() calls → two RouteArticleApproved events → two queued listeners → both pass the articlePublications()->exists() check before either writes → two Lemmy posts.

Defect 2: the mirror table has a key mismatch as well as being empty

SyncChannelPostsJob is scheduled (routes/console.php:10-12, every ten minutes) and does carry ShouldBeUnique. So "never scheduled" is not the explanation.

More importantly, the write and read keys differ:

Path Value used for channel_id
Write — SyncChannelPostsJob:71-73LemmyApiService::syncChannelPosts()PlatformChannelPost::storePost(..., (string) $platformChannelId, ...) numeric Lemmy community id, from resolveCommunityId()
Read — ArticlePublishingService:73PlatformChannelPost::duplicateExists(..., (string) $channel->channel_id, ...) community slug (CreateChannelAction copies name into channel_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:

  1. approve() has no re-approval guard, and the publish path is check-then-act with no lock
  2. platform_channel_posts is empty — sync failing (SyncChannelPostsJob, 2026-03-08, still in the failed queue; needs its own root cause)
  3. platform_channel_posts is keyed inconsistently — written by numeric community id, read by slug
  4. publishToChannel() returns null for both skip and failure, so callers record both as ERROR

Item 3 is new and was not visible from the production symptoms alone.

## 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`): ```php public function approve(): void { $this->update(['approval_status' => ApprovalStatusEnum::APPROVED]); event(new RouteArticleApproved($this)); } ``` 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:192` sets `tries => 1`, so a failed job is not re-run. The publishing queue allows `maxProcesses: 10`, so two dispatched listeners execute in parallel. Chain: two `approve()` calls → two `RouteArticleApproved` events → two queued listeners → both pass the `articlePublications()->exists()` check before either writes → two Lemmy posts. ### Defect 2: the mirror table has a key mismatch as well as being empty `SyncChannelPostsJob` **is** scheduled (`routes/console.php:10-12`, every ten minutes) and does carry `ShouldBeUnique`. So "never scheduled" is not the explanation. More importantly, **the write and read keys differ**: | Path | Value used for `channel_id` | |---|---| | Write — `SyncChannelPostsJob:71-73` → `LemmyApiService::syncChannelPosts()` → `PlatformChannelPost::storePost(..., (string) $platformChannelId, ...)` | **numeric** Lemmy community id, from `resolveCommunityId()` | | Read — `ArticlePublishingService:73` → `PlatformChannelPost::duplicateExists(..., (string) $channel->channel_id, ...)` | **community slug** (`CreateChannelAction` copies `name` into `channel_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: 1. `approve()` has no re-approval guard, and the publish path is check-then-act with no lock 2. `platform_channel_posts` is empty — sync failing (`SyncChannelPostsJob`, 2026-03-08, still in the failed queue; needs its own root cause) 3. **`platform_channel_posts` is keyed inconsistently** — written by numeric community id, read by slug 4. `publishToChannel()` returns null for both skip and failure, so callers record both as `ERROR` Item 3 is new and was not visible from the production symptoms alone.
Author
Owner

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:

Call site channel_id argument
app/Modules/Lemmy/Services/LemmyApiService.php:145 (production) (string) $platformChannelId — the numeric Lemmy community id
tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php:150, 177, 204 (string) $channel->channel_id — the slug

The tests hand-seed the mirror using the same key duplicateExists() reads with, so they pass. Production seeds it via SyncChannelPostsJob using 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:

  1. Re-approval guardRouteArticle::approve() should be a no-op (no event dispatch) when already approved. Cheapest fix, closes the common double-click path.
  2. Atomic publish — a cache lock keyed on (article_id, platform_channel_id) around the check-publish-record sequence in ArticlePublishingService::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.
  3. Consistent mirror key — pick one value and use it on both sides. The slug reads more naturally and matches channel->channel_id, but the numeric id is what the Lemmy API returns. Either works provided it is applied to write, read, and tests.
  4. Distinguish skip from failure — return something other than bare null for "skipped as duplicate", so callers stop recording it as ERROR.
  5. Tests must exercise the real write path, not a hand-seeded row with the convenient key. Otherwise item 3 regresses silently.

Still to determine: why SyncChannelPostsJob failed on 2026-03-08 and whether it has run successfully since.

## 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: | Call site | `channel_id` argument | |---|---| | `app/Modules/Lemmy/Services/LemmyApiService.php:145` (production) | `(string) $platformChannelId` — the **numeric** Lemmy community id | | `tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php:150, 177, 204` | `(string) $channel->channel_id` — the **slug** | The tests hand-seed the mirror using the same key `duplicateExists()` reads with, so they pass. Production seeds it via `SyncChannelPostsJob` using 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: 1. **Re-approval guard** — `RouteArticle::approve()` should be a no-op (no event dispatch) when already approved. Cheapest fix, closes the common double-click path. 2. **Atomic publish** — a cache lock keyed on `(article_id, platform_channel_id)` around the check-publish-record sequence in `ArticlePublishingService::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. 3. **Consistent mirror key** — pick one value and use it on both sides. The slug reads more naturally and matches `channel->channel_id`, but the numeric id is what the Lemmy API returns. Either works provided it is applied to write, read, **and** tests. 4. **Distinguish skip from failure** — return something other than bare `null` for "skipped as duplicate", so callers stop recording it as `ERROR`. 5. **Tests must exercise the real write path**, not a hand-seeded row with the convenient key. Otherwise item 3 regresses silently. Still to determine: why `SyncChannelPostsJob` failed on 2026-03-08 and whether it has run successfully since.
Author
Owner

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_channels

Decision: key the mirror by numeric community id, and store that id locally rather than resolving it on the read path.

Why numeric over slug:

  • The numeric id is stable; a community rename silently breaks every slug-keyed row — the same class of failure being fixed here
  • It is what the Lemmy API returns and what resolveCommunityId() exists to produce

Why persist rather than resolve inline:

  • duplicateExists() currently reads $channel->channel_id with 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 unreachable
  • resolveCommunityId() currently runs on every publish and every sync for a mapping that effectively never changes
  • Finishes the work #106 started — that ticket added channel_id integrity and extracted resolveCommunityId() because slug-vs-numeric ambiguity kept causing bugs. Storing the resolved id removes the ambiguity rather than adding a fourth place it lives

Implementation:

  • Add a nullable column (e.g. platform_channels.remote_community_id)
  • Populate on channel creation, and lazily on first successful resolve for existing rows — no migration backfill, so it does not require Lemmy to be reachable at deploy time
  • Both LemmyApiService::syncChannelPosts() (write) and ArticlePublishingService::publishToChannel() (read) use it

2. Re-approval guard

RouteArticle::approve() (app/Models/RouteArticle.php:93-98) should be a no-op — no event dispatch — when approval_status is already APPROVED. 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 in ArticlePublishingService::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 bare null for both "skipped as duplicate" and "publish failed", so PublishApprovedArticleListener:54 and PublishNextArticleJob:89 record both as publish_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_posts by calling storePost() directly with the slug — the same key duplicateExists() 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

  • Why SyncChannelPostsJob failed on 2026-03-08, and whether it has succeeded since. The failed job is still in the queue (398a29c9-8da2-49e9-a758-d23779e72346).
  • Whether platform_channel_posts stays 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 2005694 needs manual deletion.

## 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_channels` **Decision: key the mirror by numeric community id, and store that id locally rather than resolving it on the read path.** Why numeric over slug: - The numeric id is stable; a community rename silently breaks every slug-keyed row — the same class of failure being fixed here - It is what the Lemmy API returns and what `resolveCommunityId()` exists to produce Why persist rather than resolve inline: - `duplicateExists()` currently reads `$channel->channel_id` with 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 unreachable - `resolveCommunityId()` currently runs on **every publish and every sync** for a mapping that effectively never changes - Finishes the work #106 started — that ticket added `channel_id` integrity and extracted `resolveCommunityId()` because slug-vs-numeric ambiguity kept causing bugs. Storing the resolved id removes the ambiguity rather than adding a fourth place it lives Implementation: - Add a nullable column (e.g. `platform_channels.remote_community_id`) - Populate on channel creation, and lazily on first successful resolve for existing rows — **no migration backfill**, so it does not require Lemmy to be reachable at deploy time - Both `LemmyApiService::syncChannelPosts()` (write) and `ArticlePublishingService::publishToChannel()` (read) use it ### 2. Re-approval guard `RouteArticle::approve()` (`app/Models/RouteArticle.php:93-98`) should be a no-op — no event dispatch — when `approval_status` is already `APPROVED`. 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 in `ArticlePublishingService::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 bare `null` for both "skipped as duplicate" and "publish failed", so `PublishApprovedArticleListener:54` and `PublishNextArticleJob:89` record both as `publish_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_posts` by calling `storePost()` directly with the slug — the same key `duplicateExists()` 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 - Why `SyncChannelPostsJob` failed on 2026-03-08, and whether it has succeeded since. The failed job is still in the queue (`398a29c9-8da2-49e9-a758-d23779e72346`). - Whether `platform_channel_posts` stays 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 `2005694` needs manual deletion**.
myrmidex modified the milestone from v1.3.6 to v1.4.0 2026-08-02 14:42:53 +02:00
myrmidex modified the milestone from v1.4.0 to v1.3.7 2026-08-02 14:59:53 +02:00
Author
Owner

Root causes

Recording these against the acceptance criteria. Shipped on release/v1.3.7 in b2d504f, b527813, 358171c.

Defect 1 — concurrent listener execution

RouteArticle::approve() had no idempotency guard. Before b2d504f it dispatched RouteArticleApproved unconditionally 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:

  1. approve() returns early if already approved (b2d504f) — stops the duplicate event being dispatched at all, which covers the ordinary double-click.
  2. Cache::lock("publish:{article_id}:{channel_id}") (180s TTL, 15s wait) in ArticlePublishingService::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_events reproduces the original race — two separately loaded instances, both pending, both approving, 2 events dispatched — and test_two_stale_approvals_still_create_only_one_remote_post covers 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 local platform_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, so SyncChannelPostsJob was never dispatched. Fixed in 358171c by adding php 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 bare null for both. Replaced with a PublishOutcome value object (published / skipped / failure) and a new PublishStatusEnum::SKIPPED, so callers can distinguish the two.

Coverage

DuplicatePublishTest (6 tests) and MirrorDuplicateDetectionTest (5 tests), including the mutation-style check above. Full suite green offline.

## Root causes Recording these against the acceptance criteria. Shipped on `release/v1.3.7` in `b2d504f`, `b527813`, `358171c`. ### Defect 1 — concurrent listener execution **`RouteArticle::approve()` had no idempotency guard.** Before `b2d504f` it dispatched `RouteArticleApproved` unconditionally 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: 1. **`approve()` returns early if already approved** (`b2d504f`) — stops the duplicate event being dispatched at all, which covers the ordinary double-click. 2. **`Cache::lock("publish:{article_id}:{channel_id}")`** (180s TTL, 15s wait) in `ArticlePublishingService::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_events` reproduces the original race — two separately loaded instances, both pending, both approving, 2 events dispatched — and `test_two_stale_approvals_still_create_only_one_remote_post` covers 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 local `platform_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`, so `SyncChannelPostsJob` was never dispatched. Fixed in `358171c` by adding `php 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 bare `null` for both. Replaced with a `PublishOutcome` value object (`published` / `skipped` / `failure`) and a new `PublishStatusEnum::SKIPPED`, so callers can distinguish the two. ### Coverage `DuplicatePublishTest` (6 tests) and `MirrorDuplicateDetectionTest` (5 tests), including the mutation-style check above. Full suite green offline.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lvl0/fedi-feed-router#123
No description provided.