Files
coolify/app/Jobs/ScheduledJobManager.php
T
Andras Bacsai 13db52f730 fix(scheduler): accept ScheduledJobManager jobs queued before the upgrade
Jobs that v4.3.23 put in the queue have no type property. The promoted
constructor property had no default, so these jobs failed with "must not
be accessed before initialization" after the upgrade.

- Declare type as a normal property with a null default
2026-10-05 21:18:48 +02:00

889 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')) {
$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'),
];
}
}