PublishNextArticleJob blocks on the first candidate — iterate to find a publishable one #133
Labels
No labels
bug
devops
duplicate
enhancement
good first issue
layout
next major release
next minor release
question
research
testing
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lvl0/fedi-feed-router#133
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
PublishNextArticleJobselects the single oldest eligibleRouteArticleand publishes it. If that article cannot be published (skipped as duplicate, channel has no account, channel is throttled), the job ends. On the next tick the same article is still the oldest — and if it remains unpublishable, every other channel stalls behind it indefinitely.Scenarios that trigger blocking
1. Skipped-as-duplicate (in the code today)
When
ArticlePublishingServicereturnsPublishOutcome::skipped(),PublishRouteArticleAction::recordSkipped()setspublish_status = SKIPPEDbut does not callrecordPublishAttemptFailed(). Sopublish_attemptsstays 0 andnext_attempt_atstays null —scopeDueForPublishing()still considers the row eligible. Every tick picks it, skips it, and returns. Every other route article is permanently blocked.2. Channel with no active account (no backoff today)
ArticlePublishingService::publishRouteArticle()returnsPublishOutcome::failure('No active account for channel'). This triggers backoff (5 → 30 → 120 → 360 min), so the article drops out of eligibility temporarily. After 4 attemptshasExhaustedPublishAttempts()returns true and it is permanently excluded — silently dead, no notification. Blocking is temporary but the final state is unreported.3. Per-channel publish interval (future — #130)
When #130 adds per-channel interval throttling, a channel inside its interval must be skipped over. The current single-candidate selection cannot do this.
Design
Replace the single
->first()with an iteration loop over the N oldest candidates, publishing the first one that succeeds and skipping over failures.Limit
A
limit(N)with a small N (e.g., 10) prevents the loop from scanning the entire table if all candidates are blocked. 10 candidates × 5 min ticks = 50 minutes before the loop exhausts, which is plenty of time for transient issues to resolve or for a human to notice.PublishOutcome semantics
The loop needs to distinguish three outcomes:
succeeded()wasSkipped()RouteArticleas resolved so it never blocks the queue again. Continue to next candidate.failed()recordPublishAttemptFailed(). Continue to next candidate.Marking skipped rows as terminal
A skipped-as-duplicate
RouteArticleshould not be retried indefinitely. Options:publish_status = SKIPPEDandpublish_attempts = MAXsodueForPublishing()excludes it permanently. Simplest.publish_status = TERMINAL_SKIPPEDenum value distinct fromSKIPPED(which today means "skipped this attempt, try again"). More precise but adds a migration.Recommend A —
SKIPPEDalready means "this will never publish" in practice (it is a duplicate), so making it terminal is correct. The existingSKIPPEDtests would need updating to assert the terminal state.Daily cap interaction
The daily cap check at the top of
handle()gates on total publications today. The loop publishes at most one article, so the cap still applies — no change needed. The cap gates the entire job, not per-candidate.Tasks
->first()+ single publish with a capped iteration looppublish_attempts = MAXsodueForPublishing()excludes itINFOso the sequence is traceablenext_attempt_atis in the future, then retriedEdge cases
RouteArticleis approved while the loop runs. It won't be in the fetched batch and will be picked on the next tick. Acceptable.ShouldBeUnique: the job holds the unique lock foruniqueFor = 300. The loop takes < 1s, well within the lock window.ShouldBeUniqueearly-return bug: if the loop returns without publishing (all candidates blocked), the unique lock still releases normally —ShouldBeUniquereleases onhandle()completion, not on side effects.Acceptance criteria
Related
scopeDueForPublishingis the single source of publish eligibility; this ticket's terminal-skip relies on it.claude/PLATFORM.md— head-of-line blocking is already documented as a known risk