feat(scheduled-tasks): disable tasks that keep failing

Disable a scheduled task after at least 5 failed runs in a row with no
success for 3 days, notify the team, and show the reason on the task page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-05 12:17:49 +01:00
co-authored by Claude Opus 5.5
parent f7867923b7
commit 4a1228c611
11 changed files with 259 additions and 2 deletions
+1
View File
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Add SMTP templates for common email providers, such as PostShiba, Amazon SES, Mailgun, SendGrid, Postmark, and Brevo, which fill in the host, port, and encryption
- Save more than one named SMTP server on the transactional email and email notification pages, and switch between them
- Give each SMTP server and Resend its own sender name and address, because each provider sends from its own verified domain
- Disable a scheduled task automatically when it fails at least 5 times in a row and has had no successful run for 3 days. Coolify sends a notification on the scheduled task failure channels and shows the reason on the task page until you enable the task again
### Changed
+12
View File
@@ -11,6 +11,7 @@ use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\Service;
use App\Models\Team;
use App\Notifications\ScheduledTask\TaskAutoDisabled;
use App\Notifications\ScheduledTask\TaskFailed;
use App\Notifications\ScheduledTask\TaskSuccess;
use App\Services\ScheduledJobDeliveryService;
@@ -199,6 +200,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
'status' => 'success',
'message' => $this->task_output,
]);
$this->task->recordRunSuccess();
$this->team?->notify(new TaskSuccess($this->task, $this->task_output));
@@ -346,5 +348,15 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
// Notify team about permanent failure
$this->team?->notify(new TaskFailed($this->task, $safeErrorMessage));
if ($this->task->recordRunFailure()) {
auditLog('scheduled_task.auto_disabled', [
'team_id' => $this->task->team_id,
'scheduled_task_uuid' => $this->task->uuid,
'scheduled_task_name' => $this->task->name,
'failure_count' => $this->task->failure_count,
], 'warning');
$this->team?->notify(new TaskAutoDisabled($this->task));
}
}
}
+63
View File
@@ -20,6 +20,9 @@ use OpenApi\Attributes as OA;
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.'],
'failure_count' => ['type' => 'integer', 'description' => 'The number of failed runs in a row.'],
'failing_since' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'The date and time of the first failed run in the current series of failures.'],
'auto_disabled_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'The date and time when Coolify disabled the task because it kept failing.'],
'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was created.'],
'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was last updated.'],
],
@@ -42,6 +45,31 @@ class ScheduledTask extends BaseModel
'service_id',
];
/**
* A task is disabled automatically when it has failed at least this many times in a row...
*/
public const AUTO_DISABLE_MIN_FAILURES = 5;
/**
* ...and has not had a successful run for at least this many days. Both limits must be
* reached, so a short outage does not disable a task that runs every minute.
*/
public const AUTO_DISABLE_MIN_DAYS = 3;
/**
* A task that is enabled again starts with a clean failure series.
*/
protected static function booted(): void
{
static::saving(function (self $task) {
if ($task->isDirty('enabled') && $task->enabled) {
$task->failure_count = 0;
$task->failing_since = null;
$task->auto_disabled_at = null;
}
});
}
public static function ownedByCurrentTeamAPI(int $teamId)
{
return static::where('team_id', $teamId)->orderBy('created_at', 'desc');
@@ -52,9 +80,44 @@ class ScheduledTask extends BaseModel
return [
'enabled' => 'boolean',
'timeout' => 'integer',
'failure_count' => 'integer',
'failing_since' => 'datetime',
'auto_disabled_at' => 'datetime',
];
}
public function recordRunSuccess(): void
{
if ($this->failure_count > 0 || $this->failing_since !== null) {
$this->failure_count = 0;
$this->failing_since = null;
$this->save();
}
}
/**
* Count a failed run and disable the task when it keeps failing.
*
* @return bool True when this failure disabled the task.
*/
public function recordRunFailure(): bool
{
$this->refresh();
$this->increment('failure_count', 1, ['failing_since' => $this->failing_since ?? now()]);
if (! $this->enabled
|| $this->failure_count < self::AUTO_DISABLE_MIN_FAILURES
|| $this->failing_since->gt(now()->subDays(self::AUTO_DISABLE_MIN_DAYS))) {
return false;
}
$this->enabled = false;
$this->auto_disabled_at = now();
$this->save();
return true;
}
public function service()
{
return $this->belongsTo(Service::class);
@@ -0,0 +1,126 @@
<?php
namespace App\Notifications\ScheduledTask;
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
class TaskAutoDisabled extends CustomEmailNotification
{
public ?string $url = null;
public string $summary;
public function __construct(public ScheduledTask $task)
{
$this->onQueue('high');
$resource = $task->application ?? $task->service;
if ($resource) {
$type = $resource instanceof Application ? 'application' : 'service';
$this->url = base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/{$type}/{$resource->uuid}/tasks/{$task->uuid}";
}
$this->summary = "Scheduled task ({$task->name}) was disabled after {$task->failure_count} failed runs in a row since {$task->failing_since?->toDayDateTimeString()} UTC. Fix the task and enable it again.";
}
public function via(object $notifiable): array
{
return $notifiable->getEnabledChannels('scheduled_task_failure');
}
public function toMail(): MailMessage
{
$mail = new MailMessage;
$mail->subject("Coolify: [ACTION REQUIRED] Scheduled task ({$this->task->name}) was disabled.");
$mail->view('emails.scheduled-task-auto-disabled', [
'summary' => $this->summary,
'url' => $this->url,
]);
return $mail;
}
public function toDiscord(): DiscordMessage
{
$message = new DiscordMessage(
title: ':no_entry: Scheduled task disabled',
description: $this->summary,
color: DiscordMessage::errorColor(),
);
if ($this->url) {
$message->addField('Scheduled task', '[Link]('.$this->url.')');
}
return $message;
}
public function toTelegram(): array
{
return [
'message' => "Coolify: {$this->summary}",
];
}
public function toPushover(): PushoverMessage
{
$buttons = [];
if ($this->url) {
$buttons[] = [
'text' => 'Open task in Coolify',
'url' => (string) $this->url,
];
}
return new PushoverMessage(
title: 'Scheduled task disabled',
level: 'error',
message: $this->summary,
buttons: $buttons,
);
}
public function toSlack(): SlackMessage
{
$description = $this->summary;
if ($this->url) {
$description .= "\n\n*Task URL:* {$this->url}";
}
return new SlackMessage(
title: 'Scheduled task disabled',
description: $description,
color: SlackMessage::errorColor()
);
}
public function toWebhook(): array
{
$data = [
'success' => false,
'message' => 'Scheduled task disabled',
'event' => 'task_auto_disabled',
'task_name' => $this->task->name,
'task_uuid' => $this->task->uuid,
'failure_count' => $this->task->failure_count,
'failing_since' => $this->task->failing_since?->toIso8601String(),
];
if ($this->task->application) {
$data['application_uuid'] = $this->task->application->uuid;
} elseif ($this->task->service) {
$data['service_uuid'] = $this->task->service->uuid;
}
if ($this->url) {
$data['url'] = $this->url;
}
return $data;
}
}
@@ -382,6 +382,9 @@ class ServerTransferBundle
self::check($errors, "{$at}.scheduled_tasks.{$i}.command", $task['command'] ?? null, ['required', 'string']);
self::check($errors, "{$at}.scheduled_tasks.{$i}.container", $task['container'] ?? null, ['nullable', ...ValidationPatterns::containerNameRules()]);
self::checkCron($errors, "{$at}.scheduled_tasks.{$i}.frequency", $task['frequency'] ?? '');
self::check($errors, "{$at}.scheduled_tasks.{$i}.failure_count", $task['failure_count'] ?? null, ['nullable', 'integer', 'min:0']);
self::check($errors, "{$at}.scheduled_tasks.{$i}.failing_since", $task['failing_since'] ?? null, ['nullable', 'date']);
self::check($errors, "{$at}.scheduled_tasks.{$i}.auto_disabled_at", $task['auto_disabled_at'] ?? null, ['nullable', 'date']);
}
}
@@ -1336,6 +1336,9 @@ class ServerTransferExporter
'container' => $task->container,
'timeout' => $task->timeout,
'enabled' => (bool) ($task->enabled ?? true),
'failure_count' => (int) $task->failure_count,
'failing_since' => $task->failing_since?->toIso8601String(),
'auto_disabled_at' => $task->auto_disabled_at?->toIso8601String(),
];
}
@@ -1688,6 +1688,9 @@ class ServerTransferImporter
'container' => data_get($task, 'container'),
'timeout' => data_get($task, 'timeout'),
'enabled' => (bool) data_get($task, 'enabled', true),
'failure_count' => (int) data_get($task, 'failure_count', 0),
'failing_since' => data_get($task, 'failing_since'),
'auto_disabled_at' => data_get($task, 'auto_disabled_at'),
'team_id' => $teamId,
];
@@ -1697,7 +1700,7 @@ class ServerTransferImporter
$payload['service_id'] = $resource->id;
}
ScheduledTask::create($payload);
ScheduledTask::forceCreate($payload);
}
}
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* A scheduled task that keeps failing is disabled automatically. See ScheduledTask::recordRunFailure().
*/
public function up(): void
{
if (Schema::hasColumn('scheduled_tasks', 'failure_count')) {
return;
}
Schema::table('scheduled_tasks', function (Blueprint $table) {
$table->unsignedInteger('failure_count')->default(0);
$table->timestampTz('failing_since')->nullable();
$table->timestampTz('auto_disabled_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('scheduled_tasks', function (Blueprint $table) {
$table->dropColumn(['failure_count', 'failing_since', 'auto_disabled_at']);
});
}
};
+1 -1
View File
@@ -101,7 +101,7 @@ When you add a model or a setting to the transfer, add it to this file too.
| ✅ | Persistent volume | the seed has one; add a host-path bind mount and preview suffix on |
| ✅ | File mount + directory mount | content, chown, chmod |
| ✅ | Volume backup | on the persistent volume: S3 on, stop during backup, all retention values, timeout, missing-backup days |
| ✅ | Scheduled tasks | the seed has one; add a disabled one with container + timeout |
| ✅ | Scheduled tasks | the seed has one; add a disabled one with container + timeout, and one that Coolify disabled automatically (the "Disabled automatically" warning shows on the target) |
| ✅ | Tag | |
| ✅ | Preview deployment | PR preview with domain port overrides + max restart count + keeps production values (previews from before v4.5.1) (needs the GitHub App, or create it in the database) |
| ✅ | Secret manager link | the app loads variables from the secret manager token |
@@ -0,0 +1,5 @@
<x-emails.layout>
{{ $summary }}
Click [here]({{ $url }}) to view the task.
</x-emails.layout>
@@ -14,6 +14,13 @@
<form x-show="activeSection === 'general'" wire:submit="submit" class="application-settings-form flex flex-col">
<x-unsaved-bar action="submit" />
@if (! $isEnabled && $task->auto_disabled_at)
<x-callout type="danger" title="Disabled automatically" class="mb-4">
Coolify disabled this task on {{ $task->auto_disabled_at->toDayDateTimeString() }} UTC after
{{ $task->failure_count }} failed runs in a row since {{ $task->failing_since?->toDayDateTimeString() }} UTC.
Check the executions, fix the task, and enable it again.
</x-callout>
@endif
<x-application.settings-section id="task-configuration-section" title="Task configuration"
helper="Configure when this task runs and which command Coolify executes.">
<x-slot:actions>