Files
coolify/app/Jobs/ScheduledJobManager.php
T
e3d4411ff2 chore: merge main into release/v4.5 (#12192)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Furox <177975472+Furox-Art@users.noreply.github.com>
Co-authored-by: johandenhollander <johan.den.hollander@finalist.nl>
Co-authored-by: João Cruz <161638790+SrJooJ@users.noreply.github.com>
Co-authored-by: Saim Hashmi <hashmisaim037@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-18 23:43:20 +02:00

890 lines
32 KiB
PHP

<?php
namespace App\Jobs;
use App\Actions\Shared\DeleteScheduledVolumeBackup;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use App\Models\ScheduledVolumeBackup;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\ServiceDatabase;
use App\Models\Subscription;
use App\Models\Team;
use App\Services\ScheduledJobDeliveryService;
use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class ScheduledJobManager implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const CHUNK_SIZE = 100;
/**
* Pending volume backup recovery is retried at most once in this interval for each execution.
*/
private const VOLUME_BACKUP_RECOVERY_INTERVAL_MINUTES = 5;
/**
* Bounds the volume backup recovery jobs dispatched in one scheduler run.
*/
private const VOLUME_BACKUP_RECOVERY_MAX_DISPATCHES = 100;
/**
* The schedule types that run as separate dispatchers, with the delivery job types they own.
* Each type has its own overlap lock, so a slow type cannot make another type skip a run.
*/
public const TYPES = [
'backups' => ['database-backup'],
'tasks' => ['scheduled-task'],
'volume-backups' => ['volume-backup'],
'docker-cleanups' => ['docker-cleanup'],
];
/**
* The time when this job execution started.
* Used to ensure all scheduled items are evaluated against the same point in time.
*/
private ?Carbon $executionTime = null;
private int $dispatchedCount = 0;
private int $skippedCount = 0;
/**
* One key of TYPES, or null for all types.
*
* Declared with a default instead of promoted, so jobs queued by older versions without this property still run.
*/
public ?string $type = null;
/**
* @param string|null $type One key of TYPES, or null for all types.
*/
public function __construct(?string $type = null)
{
if ($type !== null && ! array_key_exists($type, self::TYPES)) {
throw new \InvalidArgumentException("Unknown scheduled job type [{$type}].");
}
$this->type = $type;
$this->onQueue(crons_queue());
}
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
// Self-healing: clear any stale lock before WithoutOverlapping tries to acquire it.
// Stale locks (TTL = -1) can occur during upgrades, Redis restarts, or edge cases.
// @see https://github.com/coollabsio/coolify/issues/8327
$this->clearStaleLockIfPresent();
return [
(new WithoutOverlapping($this->lockName()))
->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
->dontRelease(), // Don't re-queue on lock conflict
];
}
/**
* Clear a stale WithoutOverlapping lock if it has no TTL (TTL = -1).
*
* This provides continuous self-healing since it runs every time the job is dispatched.
* Stale locks permanently block all scheduled job executions with no user-visible error.
*/
private function clearStaleLockIfPresent(): void
{
try {
$cachePrefix = config('cache.prefix', '');
$lockKey = $cachePrefix.'laravel-queue-overlap:'.self::class.':'.$this->lockName();
$ttl = Redis::connection('default')->ttl($lockKey);
if ($ttl === -1) {
Redis::connection('default')->del($lockKey);
Log::channel('scheduled')->warning('Cleared stale ScheduledJobManager lock', [
'lock_key' => $lockKey,
]);
}
} catch (\Throwable $e) {
// Never let lock cleanup failure prevent the job from running
Log::channel('scheduled-errors')->error('Failed to check/clear stale lock', [
'error' => $e->getMessage(),
]);
}
}
private function lockName(): string
{
return $this->type === null ? 'scheduled-job-manager' : "scheduled-job-manager:{$this->type}";
}
private function includes(string $type): bool
{
return $this->type === null || $this->type === $type;
}
public function handle(): void
{
// Freeze the execution time at the start of the job
$this->executionTime = Carbon::now();
$this->dispatchedCount = 0;
$this->skippedCount = 0;
$this->logStart();
$jobTypes = $this->type === null ? null : self::TYPES[$this->type];
$deliveries = app(ScheduledJobDeliveryService::class);
foreach ([
'Failed to recover stale enqueued occurrences' => fn () => $deliveries->recoverStaleEnqueued($jobTypes),
'Failed to recover interrupted occurrences' => fn () => $deliveries->failInterruptedClaims($jobTypes),
'Failed to publish pending occurrences' => fn () => $deliveries->publishPending($jobTypes),
] as $errorMessage => $recover) {
try {
$recover();
} catch (\Throwable $e) {
Log::channel('scheduled-errors')->error($errorMessage, [
'error' => $e->getMessage(),
]);
}
}
// Process scheduled backups and tasks together so neither type starves the other.
try {
if ($this->includes('backups') || $this->includes('tasks')) {
$this->processScheduledBackupsAndTasks($this->includes('backups'), $this->includes('tasks'));
}
} catch (\Throwable $e) {
Log::channel('scheduled-errors')->error('Failed to process scheduled backups and tasks', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
try {
if ($this->includes('volume-backups')) {
$this->recoverStoppedVolumeBackupContainers();
$this->processScheduledVolumeBackups();
}
} catch (\Throwable $e) {
Log::channel('scheduled-errors')->error('Failed to process scheduled volume backups', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
// Process Docker cleanups - don't let failures stop the job manager
try {
if ($this->includes('docker-cleanups')) {
DockerCleanupJob::failInterruptedExecutions();
$this->processDockerCleanups();
}
} catch (\Throwable $e) {
Log::channel('scheduled-errors')->error('Failed to process docker cleanups', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
Log::channel('scheduled')->info('ScheduledJobManager completed', [
'type' => $this->type ?? 'all',
'execution_time' => $this->executionTime->toIso8601String(),
'duration_ms' => $this->executionTime->diffInMilliseconds(Carbon::now()),
'dispatched' => $this->dispatchedCount,
'skipped' => $this->skippedCount,
'host' => gethostname(),
'pid' => getmypid(),
'memory_mb' => round(memory_get_usage(true) / 1048576, 1),
'memory_peak_mb' => round(memory_get_peak_usage(true) / 1048576, 1),
'open_occurrences_over_15_minutes' => rescue(fn () => app(ScheduledJobDeliveryService::class)->staleOpenOccurrenceCounts(), [], report: false),
]);
// Write heartbeat so the UI can detect when the scheduler has stopped
try {
Cache::put('scheduled-job-manager:heartbeat', now()->toIso8601String(), 300);
} catch (\Throwable) {
// Non-critical; don't let heartbeat failure affect the job
}
}
/**
* Log which worker runs the manager and how late it starts. The manager only catches up
* occurrences from the last CATCH_UP_WINDOW_MINUTES, so a larger delay skips them silently.
*/
private function logStart(): void
{
$lastStartedKey = $this->lockName().':last-started-at';
$previousStartedAt = rescue(fn () => Cache::get($lastStartedKey), report: false);
rescue(fn () => Cache::put($lastStartedKey, $this->executionTime->toIso8601String(), 86400), report: false);
$queuedAt = data_get($this->job?->payload(), 'createdAt');
$context = [
'type' => $this->type ?? 'all',
'execution_time' => $this->executionTime->toIso8601String(),
'host' => gethostname(),
'pid' => getmypid(),
'queue_wait_seconds' => $queuedAt ? $this->executionTime->timestamp - (int) $queuedAt : null,
'seconds_since_previous_start' => $previousStartedAt ? (int) Carbon::parse($previousStartedAt)->diffInSeconds($this->executionTime) : null,
'memory_mb' => round(memory_get_usage(true) / 1048576, 1),
];
Log::channel('scheduled')->info('ScheduledJobManager started', $context);
$catchUpWindowSeconds = ScheduledJobDeliveryService::CATCH_UP_WINDOW_MINUTES * 60;
if ($context['queue_wait_seconds'] > $catchUpWindowSeconds || $context['seconds_since_previous_start'] > $catchUpWindowSeconds) {
Log::channel('scheduled-errors')->warning('ScheduledJobManager started late; occurrences due before the catch-up window were not run', $context);
}
}
private function processScheduledBackupsAndTasks(bool $withBackups, bool $withTasks): void
{
$lastBackupId = null;
$lastTaskId = null;
do {
$backups = $withBackups ? $this->scheduledBackupQuery($lastBackupId)->get() : collect();
$tasks = $withTasks ? $this->scheduledTaskQuery($lastTaskId)->get() : collect();
if ($backups->isNotEmpty()) {
$lastBackupId = $backups->last()->id;
}
if ($tasks->isNotEmpty()) {
$lastTaskId = $tasks->last()->id;
}
$this->processInterleavedDueSchedules(
$this->dueScheduledBackups($backups),
$this->dueScheduledTasks($tasks),
);
} while ($backups->isNotEmpty() || $tasks->isNotEmpty());
}
/**
* @param array<int, array{backup: ScheduledDatabaseBackup, server: Server}> $dueBackups
* @param array<int, array{task: ScheduledTask, server: Server}> $dueTasks
*/
private function processInterleavedDueSchedules(array $dueBackups, array $dueTasks): void
{
$maxCount = max(count($dueBackups), count($dueTasks));
for ($index = 0; $index < $maxCount; $index++) {
if (isset($dueBackups[$index])) {
$this->processScheduledBackup($dueBackups[$index]['backup'], $dueBackups[$index]['server']);
}
if (isset($dueTasks[$index])) {
$this->processScheduledTask($dueTasks[$index]['task'], $dueTasks[$index]['server']);
}
}
}
/**
* Load the server chain of each backup with the chunk. Otherwise $backup->server()
* queries the destination, service, server, and settings once for each backup.
*/
private function scheduledBackupQuery(?int $lastBackupId): Builder
{
return ScheduledDatabaseBackup::with([
'team.subscription',
'database' => fn (MorphTo $morphTo) => $morphTo->morphWith([
ServiceDatabase::class => ['service.destination.server.settings', 'service.destination.server.team.subscription'],
...array_fill_keys(STANDALONE_DATABASE_MODELS, ['destination.server.settings', 'destination.server.team.subscription']),
]),
])
->where('enabled', true)
->when($lastBackupId !== null, fn (Builder $query) => $query->where('id', '>', $lastBackupId))
->orderBy('id')
->limit(self::CHUNK_SIZE);
}
private function scheduledTaskQuery(?int $lastTaskId): Builder
{
return ScheduledTask::with([
'service.destination.server.settings',
'service.destination.server.team.subscription',
'application.destination.server.settings',
'application.destination.server.team.subscription',
])
->where('enabled', true)
->when($lastTaskId !== null, fn (Builder $query) => $query->where('id', '>', $lastTaskId))
->orderBy('id')
->limit(self::CHUNK_SIZE);
}
/**
* @param iterable<ScheduledDatabaseBackup> $backups
* @return array<int, array{backup: ScheduledDatabaseBackup, server: Server}>
*/
private function dueScheduledBackups(iterable $backups): array
{
$dueBackups = [];
foreach ($backups as $backup) {
try {
$server = $backup->server();
if (blank(data_get($backup, 'database')) || blank($server)) {
$this->processScheduledBackup($backup, $server);
continue;
}
if ($this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server)) {
$dueBackups[] = [
'backup' => $backup,
'server' => $server,
];
}
} catch (\Throwable $e) {
Log::channel('scheduled-errors')->error('Error prechecking backup', [
'backup_id' => $backup->id,
'error' => $e->getMessage(),
]);
}
}
return $dueBackups;
}
/**
* @param iterable<ScheduledTask> $tasks
* @return array<int, array{task: ScheduledTask, server: Server}>
*/
private function dueScheduledTasks(iterable $tasks): array
{
$dueTasks = [];
foreach ($tasks as $task) {
try {
$server = $task->server();
if (blank($server) || (! $task->service && ! $task->application)) {
$this->processScheduledTask($task, $server);
continue;
}
if ($this->isDueCandidateBeforeExpensiveChecks($task->frequency, $server)) {
$dueTasks[] = [
'task' => $task,
'server' => $server,
];
}
} catch (\Throwable $e) {
Log::channel('scheduled-errors')->error('Error prechecking task', [
'task_id' => $task->id,
'error' => $e->getMessage(),
]);
}
}
return $dueTasks;
}
private function processScheduledBackup(ScheduledDatabaseBackup $backup, ?Server $precheckedServer = null): void
{
try {
$server = $precheckedServer ?? $backup->server();
$skipReason = $this->getBackupSkipReason($backup, $server);
if ($skipReason !== null) {
if ($server === null || $this->recordSkippedOccurrence($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
$this->skippedCount++;
$this->logBackupSkip($backup, $skipReason, $server);
}
return;
}
if ($this->dispatchOccurrence(
$backup->frequency,
$server,
"scheduled-backup:{$backup->id}",
'database-backup',
$backup->id,
)) {
$this->dispatchedCount++;
Log::channel('scheduled')->info('Backup dispatched', [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
'server_id' => $server->id,
]);
}
} catch (\Throwable $e) {
Log::channel('scheduled-errors')->error('Error processing backup', [
'backup_id' => $backup->id,
'error' => $e->getMessage(),
]);
}
}
private function processScheduledTask(ScheduledTask $task, ?Server $precheckedServer = null): void
{
try {
$server = $precheckedServer ?? $task->server();
$criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
if ($criticalSkip !== null) {
if ($server === null || $this->recordSkippedOccurrence($task->frequency, $server, "scheduled-task:{$task->id}")) {
$this->skippedCount++;
$this->logTaskSkip($task, $criticalSkip, $server);
}
return;
}
$runtimeSkip = $this->getTaskRuntimeSkipReason($task);
if ($runtimeSkip !== null) {
if ($this->recordSkippedOccurrence($task->frequency, $server, "scheduled-task:{$task->id}")) {
$this->skippedCount++;
$this->logTaskSkip($task, $runtimeSkip, $server);
}
return;
}
if (! $this->dispatchOccurrence(
$task->frequency,
$server,
"scheduled-task:{$task->id}",
'scheduled-task',
$task->id,
)) {
return;
}
$this->dispatchedCount++;
Log::channel('scheduled')->info('Task dispatched', [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server->team_id,
'server_id' => $server->id,
]);
} catch (\Throwable $e) {
Log::channel('scheduled-errors')->error('Error processing task', [
'task_id' => $task->id,
'error' => $e->getMessage(),
]);
}
}
private function processScheduledVolumeBackups(): void
{
ScheduledVolumeBackup::query()
->with(['backupable', 'team.subscription'])
->where('enabled', true)
->chunkById(self::CHUNK_SIZE, function ($backups): void {
foreach ($backups as $backup) {
$this->processScheduledVolumeBackup($backup);
}
});
}
private function recoverStoppedVolumeBackupContainers(): void
{
$dispatched = 0;
ScheduledVolumeBackupExecution::query()
->where(fn (Builder $query) => $query
->where('stop_recovery_pending', true)
->orWhere('s3_cleanup_pending', true))
->where('recovery_needs_attention', false)
->with('scheduledVolumeBackup.backupable.resource')
->chunkById(self::CHUNK_SIZE, function ($executions) use (&$dispatched): bool {
foreach ($executions as $execution) {
if ($dispatched >= self::VOLUME_BACKUP_RECOVERY_MAX_DISPATCHES) {
return false;
}
if ($execution->stop_recovery_pending && $execution->scheduledVolumeBackup?->server()?->isFunctional() === false) {
continue;
}
if (Cache::add(VolumeBackupRecoveryJob::dispatchCacheKey($execution->id), true, now()->addMinutes(self::VOLUME_BACKUP_RECOVERY_INTERVAL_MINUTES))) {
VolumeBackupRecoveryJob::dispatch($execution);
$dispatched++;
}
}
return true;
});
}
private function processScheduledVolumeBackup(ScheduledVolumeBackup $backup): void
{
try {
$server = $backup->server();
if ($backup->executions()->where('stop_recovery_pending', true)->exists()) {
$this->skippedCount++;
$this->logSkip('volume_backup', 'stopped_container_recovery_pending', [
'backup_id' => $backup->id,
'team_id' => $backup->team_id,
]);
return;
}
if (! $backup->backupable) {
DeleteScheduledVolumeBackup::run($backup, $server);
$this->skippedCount++;
$this->logSkip('volume_backup', 'resource_deleted', [
'backup_id' => $backup->id,
'team_id' => $backup->team_id,
]);
return;
}
if (! $server) {
$this->skippedCount++;
$this->logSkip('volume_backup', 'server_missing', [
'backup_id' => $backup->id,
'team_id' => $backup->team_id,
]);
return;
}
if (! $this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server)) {
return;
}
if (! $server->isFunctional()) {
if ($this->recordSkippedOccurrence($backup->frequency, $server, "scheduled-volume-backup:{$backup->id}")) {
$this->skippedCount++;
$this->logSkip('volume_backup', 'server_not_functional', [
'backup_id' => $backup->id,
'team_id' => $backup->team_id,
...$this->serverStateContext($server),
]);
}
return;
}
if (isCloud() && $backup->team_id !== 0 && ! data_get($backup, 'team.subscription.stripe_invoice_paid', false)) {
if ($this->recordSkippedOccurrence($backup->frequency, $server, "scheduled-volume-backup:{$backup->id}")) {
$this->skippedCount++;
$this->logSkip('volume_backup', 'subscription_unpaid', [
'backup_id' => $backup->id,
'team_id' => $backup->team_id,
...$this->serverStateContext($server),
]);
}
return;
}
if ($this->dispatchOccurrence(
$backup->frequency,
$server,
"scheduled-volume-backup:{$backup->id}",
'volume-backup',
$backup->id,
)) {
$this->dispatchedCount++;
Log::channel('scheduled')->info('Volume backup dispatched', [
'backup_id' => $backup->id,
'backupable_type' => $backup->backupable_type,
'backupable_id' => $backup->backupable_id,
'team_id' => $backup->team_id,
'server_id' => $server->id,
]);
}
} catch (\Throwable $e) {
Log::channel('scheduled-errors')->error('Error processing volume backup', [
'backup_id' => $backup->id,
'error' => $e->getMessage(),
]);
}
}
private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string
{
if (blank(data_get($backup, 'database'))) {
$backup->delete();
return 'database_deleted';
}
if (blank($server)) {
$backup->delete();
return 'server_deleted';
}
if ($server->isFunctional() === false) {
return 'server_not_functional';
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
return 'subscription_unpaid';
}
return null;
}
private function getTaskCriticalSkipReason(ScheduledTask $task, ?Server $server): ?string
{
if (blank($server)) {
$task->delete();
return 'server_deleted';
}
if ($server->isFunctional() === false) {
return 'server_not_functional';
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
return 'subscription_unpaid';
}
if (! $task->service && ! $task->application) {
$task->delete();
return 'resource_deleted';
}
return null;
}
private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string
{
if ($task->application && str($task->application->status)->contains('running') === false) {
return 'application_not_running';
}
if ($task->service && str($task->service->status)->contains('running') === false) {
return 'service_not_running';
}
return null;
}
private function processDockerCleanups(): void
{
$this->getServersForCleanupQuery()
->chunkById(self::CHUNK_SIZE, function ($servers): void {
foreach ($servers as $server) {
$this->processDockerCleanup($server);
}
});
}
private function processDockerCleanup(Server $server): void
{
try {
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
if (! $this->isDueCandidateBeforeExpensiveChecks($frequency, $server)) {
return;
}
$skipReason = $this->getDockerCleanupSkipReason($server);
if ($skipReason !== null) {
if ($this->recordSkippedOccurrence($frequency, $server, "docker-cleanup:{$server->id}")) {
$this->skippedCount++;
$this->logSkip('docker_cleanup', $skipReason, [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]);
}
return;
}
if ($this->dispatchOccurrence(
$frequency,
$server,
"docker-cleanup:{$server->id}",
'docker-cleanup',
$server->id,
[
'delete_unused_volumes' => $server->settings->delete_unused_volumes,
'delete_unused_networks' => $server->settings->delete_unused_networks,
],
)) {
$this->dispatchedCount++;
Log::channel('scheduled')->info('Docker cleanup dispatched', [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]);
}
} catch (\Throwable $e) {
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
]);
}
}
private function getServersForCleanupQuery(): Builder
{
$query = Server::with('settings')
->whereNotNull('ip')
->where('ip', '!=', '')
->whereNotIn('ip', Server::PLACEHOLDER_IPS);
if (isCloud()) {
$query
->with('team.subscription')
->where(function (Builder $query): void {
$query
->where('team_id', 0)
->orWhereRelation('team.subscription', 'stripe_invoice_paid', true);
});
}
return $query;
}
private function getDockerCleanupSkipReason(Server $server): ?string
{
if (! $server->isFunctional()) {
return 'server_not_functional';
}
// In cloud, check subscription status (except team 0)
if (isCloud() && $server->team_id !== 0) {
if (data_get($server->team->subscription, 'stripe_invoice_paid', false) === false) {
return 'subscription_unpaid';
}
}
return null;
}
private function logSkip(string $type, string $reason, array $context = []): void
{
Log::channel('scheduled')->info(ucfirst(str_replace('_', ' ', $type)).' skipped', array_merge([
'type' => $type,
'skip_reason' => $reason,
'execution_time' => $this->executionTime?->toIso8601String(),
], $context));
}
private function dispatchOccurrence(
string $frequency,
Server $server,
string $scheduleKey,
string $jobType,
int $resourceId,
array $payload = [],
): bool {
return app(ScheduledJobDeliveryService::class)->recordAndPublish(
$scheduleKey,
$frequency,
$this->serverTimezone($server),
$jobType,
$resourceId,
$payload,
$this->executionTime,
);
}
private function recordSkippedOccurrence(string $frequency, Server $server, string $scheduleKey): bool
{
return app(ScheduledJobDeliveryService::class)->recordSkipped(
$scheduleKey,
$frequency,
$this->serverTimezone($server),
$this->executionTime,
);
}
private function isDueCandidateBeforeExpensiveChecks(string $frequency, Server $server): bool
{
$cron = new CronExpression($this->normalizeFrequency($frequency));
$executionTime = ($this->executionTime ?? Carbon::now())->copy()->setTimezone($this->serverTimezone($server));
$previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
return $previousDue->gte(
$executionTime->copy()->subMinutes(ScheduledJobDeliveryService::CATCH_UP_WINDOW_MINUTES)
);
}
private function normalizeFrequency(string $frequency): string
{
return VALID_CRON_STRINGS[$frequency] ?? $frequency;
}
private function serverTimezone(Server $server): string
{
$timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
return validate_timezone($timezone) ? $timezone : config('app.timezone');
}
private function logBackupSkip(ScheduledDatabaseBackup $backup, string $reason, ?Server $server): void
{
$this->logSkip('backup', $reason, [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
...$this->serverStateContext($server),
]);
}
private function logTaskSkip(ScheduledTask $task, string $reason, ?Server $server): void
{
$this->logSkip('task', $reason, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server?->team_id,
...$this->serverStateContext($server),
]);
}
/**
* Compare the server state that decided the skip with the current database state.
* A difference shows that the worker used stale server data.
*
* @return array<string, mixed>
*/
private function serverStateContext(?Server $server): array
{
if ($server === null) {
return [];
}
$settings = ServerSetting::query()
->where('server_id', $server->id)
->first(['is_reachable', 'is_usable', 'force_disabled']);
return [
'server_id' => $server->id,
'server_ip_is_placeholder' => $server->hasPlaceholderIp(),
'used_is_reachable' => data_get($server->settings, 'is_reachable'),
'used_is_usable' => data_get($server->settings, 'is_usable'),
'used_force_disabled' => data_get($server->settings, 'force_disabled'),
'used_invoice_paid' => data_get($server->team?->subscription, 'stripe_invoice_paid'),
'db_is_reachable' => $settings?->is_reachable,
'db_is_usable' => $settings?->is_usable,
'db_force_disabled' => $settings?->force_disabled,
'db_invoice_paid' => Subscription::query()->where('team_id', $server->team_id)->value('stripe_invoice_paid'),
];
}
}