A failed publish silently retries instead of returning the article to the list #142
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#142
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?
Problem
When publishing an approved article fails, the article vanishes from the UI and is retried in the background with no visible indication. The user approved it, it never appeared on the platform, and nothing in the interface says why or when it will be tried again.
Observed 2026-08-13: publishing failed with
couldnt_find_community(stale channel id). The article left the pending list and the only trace was alogsrow.Current behaviour
PublishRouteArticleActionsetspublish_status = ERRORand callsRouteArticle::recordPublishAttemptFailed(), which schedules a retry:RETRY_BACKOFF_MINUTES = [5, 30, 120, 360]MAX_PUBLISH_ATTEMPTS = 4The Articles page's pending tab filters on
approval_status = PENDING. A failed publish leavesapproval_status = APPROVEDand only changespublish_status, so the article is no longer pending and no longer shown. After four failed attempts it stops retrying and sits inERRORindefinitely, still invisible.Desired behaviour
A failed publish should put the article back in front of the user rather than disappearing into a background retry.
Open questions — resolved
approval_status? → It does not return to pending.approval_statusanddecided_atare never touched, so #83's approval-rate chart is unaffected. Failed articles surface in a dedicated tab instead.ERROR? → Moot in practice: there were none left by implementation time (CleanupArticlesJobhad pruned them). Any that do exist surface on the Failed tab with no reason text, since there is nothing to backfill from.Acceptance criteria
logsapproval_status/decided_atare never written on failure or retry, pinned by two testspublish_status = ERRORrows are surfaced rather than stranded — they appear on the Failed tab, though rows predating the migration show no reason textNotes
PublishRouteArticleActionwritesERRORat three sites; all now route throughrecordPublishFailed().Delivered in
c7b9cd6.What shipped
route_articles.publish_error(nullable text) stores the failure reason. Migration2024_01_01_000022also dropspublish_attemptsandnext_attempt_at.RETRY_BACKOFF_MINUTES,MAX_PUBLISH_ATTEMPTS,recordPublishAttemptFailed(),clearPublishAttempts()andhasExhaustedPublishAttempts()are gone, replaced byrecordPublishFailed(string $reason)/clearPublishFailure().The part that needed care
scopeDueForPublishing()previously gated on attempt count and backoff time. It now gates onpublish_status != ERROR, and that exclusion is load-bearing — it is not incidental to removing the backoff.PublishNextArticleJobpublishes one article per five-minute tick, taking the oldest approved-but-unpublished candidate. Remove the backoff without an exclusion and a permanently-failing article becomes the oldest candidate on every tick forever: retried constantly, and blocking every article behind it.PublishNextArticleJobTest::test_handle_publishes_a_later_article_when_the_oldest_has_failedpins that an older failed article is skipped and the next one publishes.What was deliberately not touched
approval_statusanddecided_atare never written on failure or retry. Returning a failed article to PENDING would have been the obvious implementation, but it would cleardecided_at— which #83's approval-rate chart buckets on — and retroactively change a past day's percentage. Two tests pin the invariant:RouteArticleRetryTest::test_a_failure_does_not_change_the_approval_decisionandArticlesTest::test_retrying_keeps_the_approval_decision_intact.Safety
A manual retry cannot double-post.
ArticlePublicationrows are created only after a successful platform post, and the job'swhereDoesntHave('article.articlePublications', …)guard keys off those rows rather thanpublish_status; there is a second check under a cache lock inArticlePublishingService.retryPublish()scopes throughRouteArticle::failed()->find()rather thanfindOrFail, so a replayed request or an id in any other state silently no-ops.Verification
1196 tests / 2898 assertions green, Pint clean (341 files), PHPStan clean.
code-reviewer(pre-commit) andpr-reviewer(full diff) both returned no critical/must-fix issues. Migration applied to dev and both new scopes confirmed executing against the real schema.Not verified in a browser — dev currently has no failed articles, so the tab renders its empty state.
Left open deliberately
NotificationTypeEnum::PUBLISH_FAILEDhas no dedup, unlikeFEED_STALE/FEED_EMPTY. Before this change one failure could fire up to four notifications; now it fires one, which is strictly better — but a user repeatedly retrying a permanently-broken channel gets one each time. Out of scope here; worth its own ticket if it becomes noticeable.publish_statusis not indexed. Pre-existing, but more load-bearing now that it filters every scheduler tick and every Articles page render. Premature at current table size.Browser-verified 2026-08-13: the Failed tab renders on
/articlesand correctly shows its empty state — dev has no articles inpublish_status = ERROR.Updates the "Not verified in a browser" caveat in the previous comment. The populated case (failure reason text, red badge count, Retry button) is still only covered by the feature tests, since reproducing it would mean deliberately breaking a channel's community id.