coolify/app/Jobs/ScheduledTaskJob.php

264 lines
9.5 KiB
PHP
Raw Permalink Normal View History

2024-01-02 02:23:58 +00:00
<?php
namespace App\Jobs;
use App\Events\ScheduledTaskDone;
use App\Exceptions\NonReportableException;
2024-06-10 20:43:34 +00:00
use App\Models\Application;
2024-01-02 02:23:58 +00:00
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
2024-01-02 02:23:58 +00:00
use App\Models\Server;
use App\Models\Service;
use App\Models\Team;
use App\Notifications\ScheduledTask\TaskFailed;
use App\Notifications\ScheduledTask\TaskSuccess;
use Carbon\Carbon;
2024-01-02 02:23:58 +00:00
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
2024-01-02 02:23:58 +00:00
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
2024-01-02 02:23:58 +00:00
class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
2024-01-02 02:23:58 +00:00
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public $tries = 3;
/**
* The maximum number of unhandled exceptions to allow before failing.
*/
public $maxExceptions = 1;
/**
* The number of seconds the job can run before timing out.
*/
public $timeout = 300;
public Team $team;
2024-06-10 20:43:34 +00:00
public ?Server $server = null;
2024-06-10 20:43:34 +00:00
2024-01-02 02:23:58 +00:00
public ScheduledTask $task;
2024-06-10 20:43:34 +00:00
2024-01-02 02:23:58 +00:00
public Application|Service $resource;
public ?ScheduledTaskExecution $task_log = null;
2024-06-10 20:43:34 +00:00
/**
* Store execution ID to survive job serialization for timeout handling.
*/
protected ?int $executionId = null;
2024-01-02 02:23:58 +00:00
public string $task_status = 'failed';
2024-06-10 20:43:34 +00:00
public ?string $task_output = null;
2024-06-10 20:43:34 +00:00
public array $containers = [];
2024-01-02 02:23:58 +00:00
public string $server_timezone;
2024-01-02 02:23:58 +00:00
public function __construct($task)
{
$this->onQueue('high');
2024-01-02 02:23:58 +00:00
$this->task = $task;
if ($service = $task->service()->first()) {
$this->resource = $service;
2024-06-10 20:43:34 +00:00
} elseif ($application = $task->application()->first()) {
2024-01-02 02:23:58 +00:00
$this->resource = $application;
2024-01-24 10:57:51 +00:00
} else {
throw new \RuntimeException('ScheduledTaskJob failed: No resource found.');
2024-01-02 02:23:58 +00:00
}
$this->team = Team::findOrFail($task->team_id);
2024-08-16 14:01:41 +00:00
$this->server_timezone = $this->getServerTimezone();
// Set timeout from task configuration
$this->timeout = $this->task->timeout ?? 300;
2024-08-16 14:01:41 +00:00
}
private function getServerTimezone(): string
{
if ($this->resource instanceof Application) {
2024-10-31 17:20:11 +00:00
return $this->resource->destination->server->settings->server_timezone;
} elseif ($this->resource instanceof Service) {
return $this->resource->server->settings->server_timezone;
2024-08-16 14:01:41 +00:00
}
2024-09-23 17:51:31 +00:00
return 'UTC';
2024-01-02 02:23:58 +00:00
}
public function handle(): void
{
$startTime = Carbon::now();
2024-01-02 02:23:58 +00:00
try {
$this->task_log = ScheduledTaskExecution::create([
'scheduled_task_id' => $this->task->id,
'started_at' => $startTime,
'retry_count' => $this->attempts() - 1,
]);
// Store execution ID for timeout handling
$this->executionId = $this->task_log->id;
$this->server = $this->resource->destination->server;
if ($this->resource->type() === 'application') {
$containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0);
if ($containers->count() > 0) {
$containers->each(function ($container) {
$this->containers[] = str_replace('/', '', $container['Names']);
});
}
} elseif ($this->resource->type() === 'service') {
$this->resource->applications()->get()->each(function ($application) {
if (str(data_get($application, 'status'))->contains('running')) {
2024-09-23 17:51:31 +00:00
$this->containers[] = data_get($application, 'name').'-'.data_get($this->resource, 'uuid');
}
});
$this->resource->databases()->get()->each(function ($database) {
if (str(data_get($database, 'status'))->contains('running')) {
2024-09-23 17:51:31 +00:00
$this->containers[] = data_get($database, 'name').'-'.data_get($this->resource, 'uuid');
}
});
}
if (count($this->containers) == 0) {
throw new \Exception('ScheduledTaskJob failed: No containers running.');
}
if (count($this->containers) > 1 && empty($this->task->container)) {
throw new \Exception('ScheduledTaskJob failed: More than one container exists but no container name was provided.');
}
foreach ($this->containers as $containerName) {
if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) {
2024-09-23 17:51:31 +00:00
$cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'";
$exec = "docker exec {$containerName} {$cmd}";
// Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently
// See: https://github.com/coollabsio/coolify/issues/6736
$this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->task_log->update([
'status' => 'success',
'message' => $this->task_output,
]);
2024-06-10 20:43:34 +00:00
$this->team?->notify(new TaskSuccess($this->task, $this->task_output));
return;
}
}
// No valid container was found.
throw new NonReportableException('ScheduledTaskJob failed: No valid container was found. Is the container name correct?');
} catch (\Throwable $e) {
if ($this->task_log) {
$this->task_log->update([
'status' => 'failed',
'message' => $this->task_output ?? $e->getMessage(),
]);
}
// Log the error to the scheduled-errors channel
Log::channel('scheduled-errors')->error('ScheduledTask execution failed', [
'job' => 'ScheduledTaskJob',
'task_id' => $this->task->uuid,
'task_name' => $this->task->name,
'server' => $this->server?->name ?? 'unknown',
'attempt' => $this->attempts(),
'error' => $e->getMessage(),
]);
// Only notify and throw on final failure
// Re-throw to trigger Laravel's retry mechanism with backoff
2024-01-02 02:23:58 +00:00
throw $e;
} finally {
ScheduledTaskDone::dispatch($this->team->id);
if ($this->task_log) {
$finishedAt = Carbon::now();
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
$this->task_log->update([
'finished_at' => $finishedAt->toImmutable(),
'duration' => $duration,
]);
}
2024-01-02 02:23:58 +00:00
}
}
/**
* Calculate the number of seconds to wait before retrying the job.
*/
public function backoff(): array
{
return [30, 60, 120]; // 30s, 60s, 120s between retries
}
/**
* Handle a job failure.
*/
public function failed(?\Throwable $exception): void
{
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
'job' => 'ScheduledTaskJob',
'task_id' => $this->task->uuid,
'task_name' => $this->task->name,
'server' => $this->server?->name ?? 'unknown',
'total_attempts' => $this->attempts(),
'error' => $exception?->getMessage(),
'trace' => $exception?->getTraceAsString(),
]);
// Reload execution log from database
// When a job times out, failed() is called in a fresh process with the original
// queue payload, so $executionId will be null. We need to query for the latest execution.
$execution = null;
// Try to find execution using stored ID first (works for non-timeout failures)
if ($this->executionId) {
$execution = ScheduledTaskExecution::find($this->executionId);
}
// If no stored ID or not found, query for the most recent execution log for this task
if (! $execution) {
$execution = ScheduledTaskExecution::query()
->where('scheduled_task_id', $this->task->id)
->orderBy('created_at', 'desc')
->first();
}
// Last resort: check task_log property
if (! $execution && $this->task_log) {
$execution = $this->task_log;
}
if ($execution) {
$errorMessage = 'Job permanently failed after '.$this->attempts().' attempts';
if ($exception) {
$errorMessage .= ': '.$exception->getMessage();
}
$execution->update([
'status' => 'failed',
'message' => $errorMessage,
'error_details' => $exception?->getTraceAsString(),
'finished_at' => Carbon::now()->toImmutable(),
]);
} else {
Log::channel('scheduled-errors')->warning('Could not find execution log to update', [
'execution_id' => $this->executionId,
'task_id' => $this->task->uuid,
]);
}
// Notify team about permanent failure
$this->team?->notify(new TaskFailed($this->task, $exception?->getMessage() ?? 'Unknown error'));
}
2024-08-16 19:22:06 +00:00
}