From c7b9cd6d0c296613d721e1ebba82d8fa9138bc56 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Thu, 13 Aug 2026 21:36:57 +0200 Subject: [PATCH] 142 - Surface failed publishes and replace auto-retry with a manual one --- app/Actions/PublishRouteArticleAction.php | 15 ++-- app/Livewire/Articles.php | 25 ++++++ app/Models/RouteArticle.php | 46 +++++----- ...lace_publish_retry_with_failure_reason.php | 30 +++++++ .../components/route-article-card.blade.php | 23 +++++ resources/views/livewire/articles.blade.php | 15 ++++ tests/Feature/Livewire/ArticlesTest.php | 89 +++++++++++++++++++ .../Actions/PublishRouteArticleActionTest.php | 26 +++--- tests/Unit/Jobs/PublishNextArticleJobTest.php | 35 ++------ tests/Unit/Models/RouteArticleRetryTest.php | 86 ++++++++++-------- 10 files changed, 279 insertions(+), 111 deletions(-) create mode 100644 database/migrations/2024_01_01_000022_replace_publish_retry_with_failure_reason.php diff --git a/app/Actions/PublishRouteArticleAction.php b/app/Actions/PublishRouteArticleAction.php index 2185d3c8..185eb0cc 100644 --- a/app/Actions/PublishRouteArticleAction.php +++ b/app/Actions/PublishRouteArticleAction.php @@ -42,8 +42,7 @@ public function execute(RouteArticle $routeArticle): PublishOutcome ? $this->publishingService->publishRouteArticle($routeArticle, $extractedData) : PublishOutcome::failure('Could not recover the article content to publish'); } catch (Exception $e) { - $routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]); - $routeArticle->recordPublishAttemptFailed(); + $routeArticle->recordPublishFailed($e->getMessage()); ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [ 'article_id' => $article->id, @@ -103,8 +102,10 @@ private function resolvePublishData(Article $article): array private function recordPublished(RouteArticle $routeArticle): void { - $routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]); - $routeArticle->clearPublishAttempts(); + $routeArticle->update([ + 'publish_status' => PublishStatusEnum::PUBLISHED, + 'publish_error' => null, + ]); ActionPerformed::dispatch('Published article', LogLevelEnum::INFO, [ 'article_id' => $routeArticle->article->id, @@ -141,20 +142,18 @@ private function recordFailed(RouteArticle $routeArticle, PublishOutcome $outcom { $article = $routeArticle->article; - $routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]); - $routeArticle->recordPublishAttemptFailed(); + $routeArticle->recordPublishFailed($outcome->reason ?? 'Publishing failed'); ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [ 'article_id' => $article->id, 'title' => $article->title, 'reason' => $outcome->reason, - 'attempt' => $routeArticle->publish_attempts, ]); ActivityLogged::dispatch( ActivityTypeEnum::ERROR, "Failed to publish \"{$article->title}\"", - ['reason' => $outcome->reason, 'attempt' => $routeArticle->publish_attempts], + ['reason' => $outcome->reason], $article, ); diff --git a/app/Livewire/Articles.php b/app/Livewire/Articles.php index ea21884d..909624a0 100644 --- a/app/Livewire/Articles.php +++ b/app/Livewire/Articles.php @@ -121,9 +121,28 @@ public function refresh(): void $this->dispatch('refresh-started'); } + public function retryPublish(int $routeArticleId): void + { + $routeArticle = RouteArticle::failed()->find($routeArticleId); + + if (! $routeArticle instanceof RouteArticle) { + return; + } + + $routeArticle->clearPublishFailure(); + + ActivityLogged::dispatch( + ActivityTypeEnum::PUBLISH, + "Queued \"{$routeArticle->article->title}\" for another publish attempt", + ['route_article_id' => $routeArticle->id], + $routeArticle->article, + ); + } + public function render(): View { $pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count(); + $failedCount = RouteArticle::failed()->count(); if ($this->tab === 'pending') { return view('livewire.articles', [ @@ -131,6 +150,7 @@ public function render(): View 'pendingFeeds' => $this->pendingFeeds(), 'feedOptions' => $this->feedOptions(), 'pendingCount' => $pendingCount, + 'failedCount' => $failedCount, 'clearableCount' => $this->clearableQuery()->count(), ])->layout('layouts.app'); } @@ -138,6 +158,10 @@ public function render(): View $query = RouteArticle::with(['article.feed', 'feed', 'platformChannel']) ->orderBy('created_at', 'desc'); + if ($this->tab === 'failed') { + $query->failed(); + } + if ($this->feedId !== null) { $query->where('feed_id', $this->feedId); } @@ -155,6 +179,7 @@ public function render(): View 'pendingFeeds' => null, 'feedOptions' => $this->feedOptions(), 'pendingCount' => $pendingCount, + 'failedCount' => $failedCount, 'clearableCount' => 0, ])->layout('layouts.app'); } diff --git a/app/Models/RouteArticle.php b/app/Models/RouteArticle.php index 2af93d95..ec028ab8 100644 --- a/app/Models/RouteArticle.php +++ b/app/Models/RouteArticle.php @@ -21,8 +21,7 @@ * @property int $article_id * @property ApprovalStatusEnum $approval_status * @property PublishStatusEnum $publish_status - * @property int $publish_attempts - * @property Carbon|null $next_attempt_at + * @property string|null $publish_error * @property Carbon|null $validated_at * @property Carbon|null $decided_at * @property Carbon $created_at @@ -39,8 +38,7 @@ class RouteArticle extends Model 'article_id', 'approval_status', 'publish_status', - 'publish_attempts', - 'next_attempt_at', + 'publish_error', 'validated_at', 'decided_at', ]; @@ -48,8 +46,6 @@ class RouteArticle extends Model protected $casts = [ 'approval_status' => ApprovalStatusEnum::class, 'publish_status' => PublishStatusEnum::class, - 'publish_attempts' => 'integer', - 'next_attempt_at' => 'datetime', 'validated_at' => 'datetime', 'decided_at' => 'datetime', ]; @@ -142,41 +138,39 @@ public function reject(): void ); } - private const RETRY_BACKOFF_MINUTES = [5, 30, 120, 360]; - - public const MAX_PUBLISH_ATTEMPTS = 4; - - public function recordPublishAttemptFailed(): void + public function recordPublishFailed(string $reason): void { - $attempts = $this->publish_attempts + 1; - $backoff = self::RETRY_BACKOFF_MINUTES; - $this->update([ - 'publish_attempts' => $attempts, - 'next_attempt_at' => now()->addMinutes($backoff[$attempts - 1] ?? end($backoff)), + 'publish_status' => PublishStatusEnum::ERROR, + 'publish_error' => $reason, ]); } - public function clearPublishAttempts(): void + public function clearPublishFailure(): void { - $this->update(['publish_attempts' => 0, 'next_attempt_at' => null]); + $this->update([ + 'publish_status' => PublishStatusEnum::UNPUBLISHED, + 'publish_error' => null, + ]); } - public function hasExhaustedPublishAttempts(): bool + /** + * A failed article is never picked up again on its own; the user retries it from the Articles page. + * + * @param Builder $query + * @return Builder + */ + public function scopeDueForPublishing(Builder $query): Builder { - return $this->publish_attempts >= self::MAX_PUBLISH_ATTEMPTS; + return $query->where('publish_status', '!=', PublishStatusEnum::ERROR); } /** * @param Builder $query * @return Builder */ - public function scopeDueForPublishing(Builder $query): Builder + public function scopeFailed(Builder $query): Builder { - return $query->where('publish_attempts', '<', self::MAX_PUBLISH_ATTEMPTS) - ->where(function (Builder $query) { - $query->whereNull('next_attempt_at') - ->orWhere('next_attempt_at', '<=', now()); - }); + return $query->where('publish_status', PublishStatusEnum::ERROR); } } diff --git a/database/migrations/2024_01_01_000022_replace_publish_retry_with_failure_reason.php b/database/migrations/2024_01_01_000022_replace_publish_retry_with_failure_reason.php new file mode 100644 index 00000000..144a00a2 --- /dev/null +++ b/database/migrations/2024_01_01_000022_replace_publish_retry_with_failure_reason.php @@ -0,0 +1,30 @@ +text('publish_error')->nullable()->after('publish_status'); + + $table->dropIndex(['next_attempt_at']); + $table->dropColumn(['publish_attempts', 'next_attempt_at']); + }); + } + + public function down(): void + { + Schema::table('route_articles', function (Blueprint $table) { + $table->dropColumn('publish_error'); + + $table->unsignedTinyInteger('publish_attempts')->default(0)->after('publish_status'); + $table->timestamp('next_attempt_at')->nullable()->after('publish_attempts'); + + $table->index('next_attempt_at'); + }); + } +}; diff --git a/resources/views/components/route-article-card.blade.php b/resources/views/components/route-article-card.blade.php index e5256892..dc8f8f86 100644 --- a/resources/views/components/route-article-card.blade.php +++ b/resources/views/components/route-article-card.blade.php @@ -23,8 +23,31 @@ class="flex items-center flex-wrap gap-x-1.5 text-xs font-medium mb-1.5"
{{ $routeArticle->created_at->format('M d, Y H:i') }}
+ + @if ($routeArticle->publish_error !== null) +

+ {{ $routeArticle->publish_error }} +

+ @endif
+ @if ($tab === 'failed') + + Failed + + + + @endif + {{-- Status badge (All tab) --}} @if ($tab === 'all') @if ($routeArticle->isApproved()) diff --git a/resources/views/livewire/articles.blade.php b/resources/views/livewire/articles.blade.php index 7f9ae1ca..90facc5d 100644 --- a/resources/views/livewire/articles.blade.php +++ b/resources/views/livewire/articles.blade.php @@ -29,6 +29,17 @@ class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'p @endif +