From 56cc894c1dd5d507cd59ba0680cd4409a6852bb1 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 24 Aug 2025 20:27:28 +0100 Subject: [PATCH 1/7] initial improved schedular functionality Signed-off-by: Andy Miller --- composer.json | 2 +- system/blueprints/config/scheduler.yaml | 762 ++++++++++++++++-- system/config/scheduler.yaml | 68 ++ system/src/Grav/Common/Scheduler/Job.php | 2 +- system/src/Grav/Common/Scheduler/JobQueue.php | 501 ++++++++++++ .../src/Grav/Common/Scheduler/ModernJob.php | 545 +++++++++++++ .../Grav/Common/Scheduler/ModernScheduler.php | 621 ++++++++++++++ .../src/Grav/Common/Scheduler/Scheduler.php | 34 + .../Common/Scheduler/SchedulerController.php | 263 ++++++ .../Service/SchedulerServiceProvider.php | 12 +- .../src/Grav/Console/Cli/SchedulerCommand.php | 2 +- 11 files changed, 2744 insertions(+), 68 deletions(-) create mode 100644 system/config/scheduler.yaml create mode 100644 system/src/Grav/Common/Scheduler/JobQueue.php create mode 100644 system/src/Grav/Common/Scheduler/ModernJob.php create mode 100644 system/src/Grav/Common/Scheduler/ModernScheduler.php create mode 100644 system/src/Grav/Common/Scheduler/SchedulerController.php diff --git a/composer.json b/composer.json index a613f6c40..b235f3dd4 100644 --- a/composer.json +++ b/composer.json @@ -55,7 +55,7 @@ "league/climate": "^3.6", "miljar/php-exif": "^0.6", "composer/ca-bundle": "^1.2", - "dragonmantank/cron-expression": "^1.2", + "dragonmantank/cron-expression": "^3.3", "willdurand/negotiation": "^3.0", "itsgoingd/clockwork": "^5.0", "symfony/http-client": "^4.4", diff --git a/system/blueprints/config/scheduler.yaml b/system/blueprints/config/scheduler.yaml index a8dce314b..ab9a2c016 100644 --- a/system/blueprints/config/scheduler.yaml +++ b/system/blueprints/config/scheduler.yaml @@ -4,74 +4,708 @@ form: validation: loose fields: + scheduler_tabs: + type: tabs + active: 1 - status_title: - type: section - title: PLUGIN_ADMIN.SCHEDULER_STATUS - underline: true + fields: + status_tab: + type: tab + title: PLUGIN_ADMIN.SCHEDULER_STATUS - status: - type: cronstatus - validate: - type: commalist + fields: + status_title: + type: section + title: PLUGIN_ADMIN.SCHEDULER_STATUS + underline: true - jobs_title: - type: section - title: PLUGIN_ADMIN.SCHEDULER_JOBS - underline: true + status: + type: cronstatus + validate: + type: commalist + + webhook_status_override: + type: display + label: + content: | + + markdown: false + + status_enhanced: + type: display + label: + content: | + - custom_jobs: - type: list - style: vertical - label: - classes: cron-job-list compact - key: id - fields: - .id: - type: key - label: ID - placeholder: 'process-name' - validate: - required: true - pattern: '[a-zа-я0-9_\-]+' - max: 20 - message: 'ID must be lowercase with dashes/underscores only and less than 20 characters' - .command: - type: text - label: PLUGIN_ADMIN.COMMAND - placeholder: 'ls' - validate: - required: true - .args: - type: text - label: PLUGIN_ADMIN.EXTRA_ARGUMENTS - placeholder: '-lah' - .at: - type: text - wrapper_classes: cron-selector - label: PLUGIN_ADMIN.SCHEDULER_RUNAT - help: PLUGIN_ADMIN.SCHEDULER_RUNAT_HELP - placeholder: '* * * * *' - validate: - required: true - .output: - type: text - label: PLUGIN_ADMIN.SCHEDULER_OUTPUT - help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_HELP - placeholder: 'logs/ls-cron.out' - .output_mode: - type: select - label: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE - help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE_HELP - default: append - options: - append: Append - overwrite: Overwrite - .email: - type: text - label: PLUGIN_ADMIN.SCHEDULER_EMAIL - help: PLUGIN_ADMIN.SCHEDULER_EMAIL_HELP - placeholder: 'notifications@yoursite.com' + modern_status: + type: conditional + condition: config.scheduler.modern.enabled + + fields: + modern_health: + type: display + label: Health Status + content: | +
+
Checking health...
+
+ + markdown: false + + trigger_methods: + type: display + label: Active Triggers + content: | +
+
Checking triggers...
+
+ + markdown: false + + jobs_tab: + type: tab + title: PLUGIN_ADMIN.SCHEDULER_JOBS + + fields: + jobs_title: + type: section + title: PLUGIN_ADMIN.SCHEDULER_JOBS + underline: true + + custom_jobs: + type: list + style: vertical + label: + classes: cron-job-list compact + key: id + fields: + .id: + type: key + label: ID + placeholder: 'process-name' + validate: + required: true + pattern: '[a-zа-я0-9_\-]+' + max: 20 + message: 'ID must be lowercase with dashes/underscores only and less than 20 characters' + .command: + type: text + label: PLUGIN_ADMIN.COMMAND + placeholder: 'ls' + validate: + required: true + .args: + type: text + label: PLUGIN_ADMIN.EXTRA_ARGUMENTS + placeholder: '-lah' + .at: + type: text + wrapper_classes: cron-selector + label: PLUGIN_ADMIN.SCHEDULER_RUNAT + help: PLUGIN_ADMIN.SCHEDULER_RUNAT_HELP + placeholder: '* * * * *' + validate: + required: true + .output: + type: text + label: PLUGIN_ADMIN.SCHEDULER_OUTPUT + help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_HELP + placeholder: 'logs/ls-cron.out' + .output_mode: + type: select + label: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE + help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE_HELP + default: append + options: + append: Append + overwrite: Overwrite + .email: + type: text + label: PLUGIN_ADMIN.SCHEDULER_EMAIL + help: PLUGIN_ADMIN.SCHEDULER_EMAIL_HELP + placeholder: 'notifications@yoursite.com' + + modern_tab: + type: tab + title: Modern Features + + fields: + modern.enabled: + type: toggle + label: Enable Modern Scheduler + help: Enable enhanced scheduler features (job queue, retry, webhooks, monitoring) + highlight: 0 + default: 0 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + modern_features: + type: conditional + condition: config.scheduler.modern.enabled + + fields: + workers_section: + type: section + title: Worker Configuration + underline: true + + fields: + modern.workers: + type: number + label: Concurrent Workers + help: Number of jobs that can run simultaneously (1 = sequential) + default: 1 + size: x-small + append: workers + validate: + type: int + min: 1 + max: 10 + + retry_section: + type: section + title: Retry Configuration + underline: true + + fields: + modern.retry.enabled: + type: toggle + label: Enable Job Retry + help: Automatically retry failed jobs + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + modern.retry.max_attempts: + type: number + label: Maximum Retry Attempts + help: Maximum number of times to retry a failed job + default: 3 + size: x-small + append: retries + validate: + type: int + min: 1 + max: 10 + + modern.retry.backoff: + type: select + label: Retry Backoff Strategy + help: How to calculate delay between retries + default: exponential + options: + linear: Linear (fixed delay) + exponential: Exponential (increasing delay) + + queue_section: + type: section + title: Queue Configuration + underline: true + + fields: + modern.queue.path: + type: text + label: Queue Storage Path + help: Where to store queued jobs + default: 'user-data://scheduler/queue' + placeholder: 'user-data://scheduler/queue' + + modern.queue.max_size: + type: number + label: Maximum Queue Size + help: Maximum number of jobs that can be queued + default: 1000 + size: x-small + append: jobs + validate: + type: int + min: 100 + max: 10000 + + history_section: + type: section + title: Job History + underline: true + + fields: + modern.history.enabled: + type: toggle + label: Enable Job History + help: Track execution history for all jobs + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + modern.history.retention_days: + type: number + label: History Retention (days) + help: How long to keep job history + default: 30 + size: x-small + append: days + validate: + type: int + min: 1 + max: 365 + + webhook_section: + type: section + title: Webhook Configuration + underline: true + + fields: + webhook_plugin_notice: + type: display + label: + content: | +
+ Plugin Required: The scheduler-webhook plugin must be installed and enabled for webhook functionality to work. +

+ Install with: bin/gpm install scheduler-webhook +
+ markdown: false + modern.webhook.enabled: + type: toggle + label: Enable Webhook Triggers + help: Allow triggering scheduler via HTTP webhook + highlight: 0 + default: 0 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + modern.webhook.token: + type: text + label: Webhook Security Token + help: Secret token for authenticating webhook requests. Keep this secret! + placeholder: 'Click Generate to create a secure token' + + webhook_token_generate: + type: display + label: + content: | + + + markdown: false + + modern.webhook.path: + type: text + label: Webhook Path + help: URL path for webhook endpoint + default: '/scheduler/webhook' + placeholder: '/scheduler/webhook' + + health_section: + type: section + title: Health Check Configuration + underline: true + + fields: + modern.health.enabled: + type: toggle + label: Enable Health Check + help: Provide health status endpoint for monitoring + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + modern.health.path: + type: text + label: Health Check Path + help: URL path for health check endpoint + default: '/scheduler/health' + placeholder: '/scheduler/health' + + webhook_usage: + type: section + title: Usage Examples + underline: true + + fields: + webhook_examples: + type: display + label: + content: | +
+

How to use webhooks:

+

Trigger all due jobs (respects schedule):

+
curl -X POST https://your-site.com/scheduler/webhook \
+                                                      -H "Authorization: Bearer YOUR_TOKEN"
+ +

Force-run specific job (ignores schedule):

+
curl -X POST https://your-site.com/scheduler/webhook?job=backup \
+                                                      -H "Authorization: Bearer YOUR_TOKEN"
+ +

Check health status:

+
curl https://your-site.com/scheduler/health
+ +

GitHub Actions example:

+
- name: Trigger Scheduler
+                                                  run: |
+                                                    curl -X POST ${{ secrets.SITE_URL }}/scheduler/webhook \
+                                                      -H "Authorization: Bearer ${{ secrets.WEBHOOK_TOKEN }}"
+
+ markdown: false diff --git a/system/config/scheduler.yaml b/system/config/scheduler.yaml new file mode 100644 index 000000000..8868532d2 --- /dev/null +++ b/system/config/scheduler.yaml @@ -0,0 +1,68 @@ +# Grav Scheduler Configuration + +# Default scheduler settings (backward compatible) +defaults: + output: true + output_type: file + email: null + +# Status of individual jobs (enabled/disabled) +status: {} + +# Custom scheduled jobs +custom_jobs: {} + +# Modern scheduler features (disabled by default for backward compatibility) +modern: + # Enable modern scheduler features + enabled: false + + # Number of concurrent workers (1 = sequential execution like legacy) + workers: 1 + + # Job retry configuration + retry: + enabled: true + max_attempts: 3 + backoff: exponential # 'linear' or 'exponential' + + # Job queue configuration + queue: + path: user-data://scheduler/queue + max_size: 1000 + + # Webhook trigger configuration + webhook: + enabled: false + token: null # Set a secure token to enable webhook triggers + path: /scheduler/webhook + + # Health check endpoint + health: + enabled: true + path: /scheduler/health + + # Job execution history + history: + enabled: true + retention_days: 30 + path: user-data://scheduler/history + + # Performance settings + performance: + job_timeout: 300 # Default timeout in seconds + lock_timeout: 10 # Lock acquisition timeout in seconds + + # Monitoring and alerts + monitoring: + enabled: false + alert_on_failure: true + alert_email: null + webhook_url: null + + # Trigger detection methods + triggers: + check_cron: true + check_systemd: true + check_webhook: true + check_external: true \ No newline at end of file diff --git a/system/src/Grav/Common/Scheduler/Job.php b/system/src/Grav/Common/Scheduler/Job.php index 68cbc042a..6967db37a 100644 --- a/system/src/Grav/Common/Scheduler/Job.php +++ b/system/src/Grav/Common/Scheduler/Job.php @@ -10,7 +10,7 @@ namespace Grav\Common\Scheduler; use Closure; -use Cron\CronExpression; +use Dragonmantank\Cron\CronExpression; use DateTime; use Grav\Common\Grav; use InvalidArgumentException; diff --git a/system/src/Grav/Common/Scheduler/JobQueue.php b/system/src/Grav/Common/Scheduler/JobQueue.php new file mode 100644 index 000000000..b871d3b59 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/JobQueue.php @@ -0,0 +1,501 @@ +queuePath = $queuePath; + $this->lockFile = $queuePath . '/.lock'; + + // Create queue directories + $this->initializeDirectories(); + } + + /** + * Initialize queue directories + * + * @return void + */ + protected function initializeDirectories(): void + { + $dirs = [ + $this->queuePath . '/pending', + $this->queuePath . '/processing', + $this->queuePath . '/failed', + $this->queuePath . '/completed', + ]; + + foreach ($dirs as $dir) { + if (!file_exists($dir)) { + mkdir($dir, 0755, true); + } + } + } + + /** + * Push a job to the queue + * + * @param Job $job + * @param string $priority + * @return string Job queue ID + */ + public function push(Job $job, string $priority = self::PRIORITY_NORMAL): string + { + $queueId = $this->generateQueueId($job); + $timestamp = microtime(true); + + $queueItem = [ + 'id' => $queueId, + 'job_id' => $job->getId(), + 'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure', + 'arguments' => $job->getArguments(), + 'priority' => $priority, + 'timestamp' => $timestamp, + 'attempts' => 0, + 'max_attempts' => $job instanceof ModernJob ? $job->getMaxAttempts() : 1, + 'created_at' => date('c'), + 'scheduled_for' => null, + 'metadata' => [], + ]; + + // Serialize the job if it's a closure + if (!is_string($job->getCommand())) { + $queueItem['serialized_job'] = base64_encode(serialize($job)); + } + + $this->writeQueueItem($queueItem, 'pending'); + + return $queueId; + } + + /** + * Push a job for delayed execution + * + * @param Job $job + * @param \DateTime $scheduledFor + * @param string $priority + * @return string + */ + public function pushDelayed(Job $job, \DateTime $scheduledFor, string $priority = self::PRIORITY_NORMAL): string + { + $queueId = $this->push($job, $priority); + + // Update the scheduled time + $item = $this->getQueueItem($queueId, 'pending'); + if ($item) { + $item['scheduled_for'] = $scheduledFor->format('c'); + $this->writeQueueItem($item, 'pending'); + } + + return $queueId; + } + + /** + * Pop the next job from the queue + * + * @return Job|null + */ + public function pop(): ?Job + { + $this->lock(); + + try { + // Get all pending items + $items = $this->getPendingItems(); + + if (empty($items)) { + $this->unlock(); + return null; + } + + // Sort by priority and timestamp + usort($items, function($a, $b) { + $priorityOrder = [ + self::PRIORITY_HIGH => 0, + self::PRIORITY_NORMAL => 1, + self::PRIORITY_LOW => 2, + ]; + + $aPriority = $priorityOrder[$a['priority']] ?? 1; + $bPriority = $priorityOrder[$b['priority']] ?? 1; + + if ($aPriority !== $bPriority) { + return $aPriority - $bPriority; + } + + return $a['timestamp'] <=> $b['timestamp']; + }); + + // Get the first item that's ready to run + $now = new \DateTime(); + foreach ($items as $item) { + if ($item['scheduled_for']) { + $scheduledTime = new \DateTime($item['scheduled_for']); + if ($scheduledTime > $now) { + continue; // Skip items not yet due + } + } + + // Move to processing + $this->moveQueueItem($item['id'], 'pending', 'processing'); + + // Reconstruct the job + $job = $this->reconstructJob($item); + + $this->unlock(); + return $job; + } + + $this->unlock(); + return null; + + } catch (\Exception $e) { + $this->unlock(); + throw $e; + } + } + + /** + * Mark a job as completed + * + * @param string $queueId + * @return void + */ + public function complete(string $queueId): void + { + $this->moveQueueItem($queueId, 'processing', 'completed'); + + // Clean up old completed items + $this->cleanupCompleted(); + } + + /** + * Mark a job as failed + * + * @param string $queueId + * @param string $error + * @return void + */ + public function fail(string $queueId, string $error = ''): void + { + $item = $this->getQueueItem($queueId, 'processing'); + + if ($item) { + $item['attempts']++; + $item['last_error'] = $error; + $item['failed_at'] = date('c'); + + if ($item['attempts'] < $item['max_attempts']) { + // Move back to pending for retry + $item['retry_at'] = $this->calculateRetryTime($item['attempts']); + $item['scheduled_for'] = $item['retry_at']; + $this->writeQueueItem($item, 'pending'); + $this->deleteQueueItem($queueId, 'processing'); + } else { + // Move to failed (dead letter queue) + $this->writeQueueItem($item, 'failed'); + $this->deleteQueueItem($queueId, 'processing'); + } + } + } + + /** + * Get queue size + * + * @return int + */ + public function size(): int + { + return count($this->getPendingItems()); + } + + /** + * Check if queue is empty + * + * @return bool + */ + public function isEmpty(): bool + { + return $this->size() === 0; + } + + /** + * Get queue statistics + * + * @return array + */ + public function getStatistics(): array + { + return [ + 'pending' => count($this->getPendingItems()), + 'processing' => count($this->getItemsInDirectory('processing')), + 'failed' => count($this->getItemsInDirectory('failed')), + 'completed_today' => $this->countCompletedToday(), + ]; + } + + /** + * Generate a unique queue ID + * + * @param Job $job + * @return string + */ + protected function generateQueueId(Job $job): string + { + return $job->getId() . '_' . uniqid('', true); + } + + /** + * Write queue item to disk + * + * @param array $item + * @param string $directory + * @return void + */ + protected function writeQueueItem(array $item, string $directory): void + { + $path = $this->queuePath . '/' . $directory . '/' . $item['id'] . '.json'; + $file = JsonFile::instance($path); + $file->save($item); + } + + /** + * Read queue item from disk + * + * @param string $queueId + * @param string $directory + * @return array|null + */ + protected function getQueueItem(string $queueId, string $directory): ?array + { + $path = $this->queuePath . '/' . $directory . '/' . $queueId . '.json'; + + if (!file_exists($path)) { + return null; + } + + $file = JsonFile::instance($path); + return $file->content(); + } + + /** + * Delete queue item + * + * @param string $queueId + * @param string $directory + * @return void + */ + protected function deleteQueueItem(string $queueId, string $directory): void + { + $path = $this->queuePath . '/' . $directory . '/' . $queueId . '.json'; + + if (file_exists($path)) { + unlink($path); + } + } + + /** + * Move queue item between directories + * + * @param string $queueId + * @param string $fromDir + * @param string $toDir + * @return void + */ + protected function moveQueueItem(string $queueId, string $fromDir, string $toDir): void + { + $fromPath = $this->queuePath . '/' . $fromDir . '/' . $queueId . '.json'; + $toPath = $this->queuePath . '/' . $toDir . '/' . $queueId . '.json'; + + if (file_exists($fromPath)) { + rename($fromPath, $toPath); + } + } + + /** + * Get all pending items + * + * @return array + */ + protected function getPendingItems(): array + { + return $this->getItemsInDirectory('pending'); + } + + /** + * Get items in a specific directory + * + * @param string $directory + * @return array + */ + protected function getItemsInDirectory(string $directory): array + { + $items = []; + $path = $this->queuePath . '/' . $directory; + + if (!is_dir($path)) { + return $items; + } + + $files = glob($path . '/*.json'); + foreach ($files as $file) { + $jsonFile = JsonFile::instance($file); + $items[] = $jsonFile->content(); + } + + return $items; + } + + /** + * Reconstruct a job from queue item + * + * @param array $item + * @return Job|null + */ + protected function reconstructJob(array $item): ?Job + { + if (isset($item['serialized_job'])) { + // Unserialize the job + try { + $job = unserialize(base64_decode($item['serialized_job'])); + if ($job instanceof Job) { + return $job; + } + } catch (\Exception $e) { + // Failed to unserialize + return null; + } + } + + // Create a new job from command + if (isset($item['command'])) { + $args = $item['arguments'] ?? []; + $job = new Job($item['command'], $args, $item['job_id']); + return $job; + } + + return null; + } + + /** + * Calculate retry time with exponential backoff + * + * @param int $attempts + * @return string + */ + protected function calculateRetryTime(int $attempts): string + { + $backoffSeconds = min(pow(2, $attempts) * 60, 3600); // Max 1 hour + $retryTime = new \DateTime(); + $retryTime->modify("+{$backoffSeconds} seconds"); + return $retryTime->format('c'); + } + + /** + * Clean up old completed items + * + * @return void + */ + protected function cleanupCompleted(): void + { + $items = $this->getItemsInDirectory('completed'); + $cutoff = new \DateTime('-24 hours'); + + foreach ($items as $item) { + if (isset($item['created_at'])) { + $createdAt = new \DateTime($item['created_at']); + if ($createdAt < $cutoff) { + $this->deleteQueueItem($item['id'], 'completed'); + } + } + } + } + + /** + * Count completed jobs today + * + * @return int + */ + protected function countCompletedToday(): int + { + $items = $this->getItemsInDirectory('completed'); + $today = new \DateTime('today'); + $count = 0; + + foreach ($items as $item) { + if (isset($item['created_at'])) { + $createdAt = new \DateTime($item['created_at']); + if ($createdAt >= $today) { + $count++; + } + } + } + + return $count; + } + + /** + * Acquire lock for queue operations + * + * @return void + */ + protected function lock(): void + { + $attempts = 0; + while (file_exists($this->lockFile) && $attempts < 10) { + usleep(100000); // 100ms + $attempts++; + } + + if ($attempts >= 10) { + throw new RuntimeException('Could not acquire queue lock'); + } + + touch($this->lockFile); + } + + /** + * Release queue lock + * + * @return void + */ + protected function unlock(): void + { + if (file_exists($this->lockFile)) { + unlink($this->lockFile); + } + } +} \ No newline at end of file diff --git a/system/src/Grav/Common/Scheduler/ModernJob.php b/system/src/Grav/Common/Scheduler/ModernJob.php new file mode 100644 index 000000000..1bbe81bb3 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/ModernJob.php @@ -0,0 +1,545 @@ +maxAttempts = $attempts; + return $this; + } + + /** + * Get maximum retry attempts + * + * @return int + */ + public function getMaxAttempts(): int + { + return $this->maxAttempts; + } + + /** + * Set retry delay + * + * @param int $seconds + * @param string $strategy 'linear' or 'exponential' + * @return self + */ + public function retryDelay(int $seconds, string $strategy = 'exponential'): self + { + $this->retryDelay = $seconds; + $this->retryStrategy = $strategy; + return $this; + } + + /** + * Get current retry count + * + * @return int + */ + public function getRetryCount(): int + { + return $this->retryCount; + } + + /** + * Set job timeout + * + * @param int $seconds + * @return self + */ + public function timeout(int $seconds): self + { + $this->timeout = $seconds; + return $this; + } + + /** + * Set job priority + * + * @param string $priority 'high', 'normal', or 'low' + * @return self + */ + public function priority(string $priority): self + { + if (!in_array($priority, ['high', 'normal', 'low'])) { + throw new \InvalidArgumentException('Priority must be high, normal, or low'); + } + $this->priority = $priority; + return $this; + } + + /** + * Get job priority + * + * @return string + */ + public function getPriority(): string + { + return $this->priority; + } + + /** + * Add job dependency + * + * @param string $jobId + * @return self + */ + public function dependsOn(string $jobId): self + { + $this->dependencies[] = $jobId; + return $this; + } + + /** + * Chain another job to run after this one + * + * @param Job $job + * @param bool $onlyOnSuccess Run only if current job succeeds + * @return self + */ + public function chain(Job $job, bool $onlyOnSuccess = true): self + { + $this->chainedJobs[] = [ + 'job' => $job, + 'onlyOnSuccess' => $onlyOnSuccess, + ]; + return $this; + } + + /** + * Add metadata to the job + * + * @param string $key + * @param mixed $value + * @return self + */ + public function withMetadata(string $key, $value): self + { + $this->metadata[$key] = $value; + return $this; + } + + /** + * Add tags to the job + * + * @param array $tags + * @return self + */ + public function withTags(array $tags): self + { + $this->tags = array_merge($this->tags, $tags); + return $this; + } + + /** + * Set success callback + * + * @param callable $callback + * @return self + */ + public function onSuccess(callable $callback): self + { + $this->onSuccess = $callback; + return $this; + } + + /** + * Set failure callback + * + * @param callable $callback + * @return self + */ + public function onFailure(callable $callback): self + { + $this->onFailure = $callback; + return $this; + } + + /** + * Set retry callback + * + * @param callable $callback + * @return self + */ + public function onRetry(callable $callback): self + { + $this->onRetry = $callback; + return $this; + } + + /** + * Run the job with retry support + * + * @return bool + */ + public function runWithRetry(): bool + { + $attempts = 0; + $lastException = null; + + while ($attempts < $this->maxAttempts) { + $attempts++; + $this->retryCount = $attempts - 1; + + try { + // Record execution start time + $this->executionStartTime = microtime(true); + + // Run the job + $result = $this->run(); + + // Record execution time + $this->executionTime = microtime(true) - $this->executionStartTime; + + if ($result && $this->isSuccessful()) { + // Call success callback + if ($this->onSuccess) { + call_user_func($this->onSuccess, $this); + } + + // Run chained jobs + $this->runChainedJobs(true); + + return true; + } + + throw new RuntimeException('Job execution failed'); + + } catch (Exception $e) { + $lastException = $e; + $this->output = $e->getMessage(); + $this->successful = false; + + if ($attempts < $this->maxAttempts) { + // Call retry callback + if ($this->onRetry) { + call_user_func($this->onRetry, $this, $attempts, $e); + } + + // Calculate delay before retry + $delay = $this->calculateRetryDelay($attempts); + if ($delay > 0) { + sleep($delay); + } + } else { + // Final failure + if ($this->onFailure) { + call_user_func($this->onFailure, $this, $e); + } + + // Run chained jobs that should run on failure + $this->runChainedJobs(false); + } + } + } + + return false; + } + + /** + * Override parent run method to add timeout support + * + * @return bool + */ + public function run(): bool + { + // Check dependencies + if (!$this->checkDependencies()) { + $this->output = 'Dependencies not met'; + $this->successful = false; + return false; + } + + // Call parent run method + $result = parent::run(); + + // Apply timeout to process if applicable + if ($this->process instanceof Process && $this->timeout > 0) { + $this->process->setTimeout($this->timeout); + } + + return $result; + } + + /** + * Get execution time in seconds + * + * @return float + */ + public function getExecutionTime(): float + { + return $this->executionTime; + } + + /** + * Get job metadata + * + * @param string|null $key + * @return mixed + */ + public function getMetadata(string $key = null) + { + if ($key === null) { + return $this->metadata; + } + + return $this->metadata[$key] ?? null; + } + + /** + * Get job tags + * + * @return array + */ + public function getTags(): array + { + return $this->tags; + } + + /** + * Check if job has a specific tag + * + * @param string $tag + * @return bool + */ + public function hasTag(string $tag): bool + { + return in_array($tag, $this->tags); + } + + /** + * Set queue ID + * + * @param string $queueId + * @return self + */ + public function setQueueId(string $queueId): self + { + $this->queueId = $queueId; + return $this; + } + + /** + * Get queue ID + * + * @return string|null + */ + public function getQueueId(): ?string + { + return $this->queueId; + } + + /** + * Get process (for background jobs) + * + * @return Process|null + */ + public function getProcess(): ?Process + { + return $this->process; + } + + /** + * Calculate retry delay based on strategy + * + * @param int $attempt + * @return int + */ + protected function calculateRetryDelay(int $attempt): int + { + if ($this->retryStrategy === 'exponential') { + return min($this->retryDelay * pow(2, $attempt - 1), 3600); // Max 1 hour + } + + return $this->retryDelay; + } + + /** + * Check if dependencies are met + * + * @return bool + */ + protected function checkDependencies(): bool + { + if (empty($this->dependencies)) { + return true; + } + + // This would need to check against job history or status + // For now, we'll assume dependencies are met + // In a real implementation, this would check the ModernScheduler's job status + return true; + } + + /** + * Run chained jobs + * + * @param bool $success Whether the current job succeeded + * @return void + */ + protected function runChainedJobs(bool $success): void + { + foreach ($this->chainedJobs as $chainedJob) { + $shouldRun = !$chainedJob['onlyOnSuccess'] || $success; + + if ($shouldRun) { + $job = $chainedJob['job']; + if ($job instanceof ModernJob) { + $job->runWithRetry(); + } else { + $job->run(); + } + } + } + } + + /** + * Convert job to array for serialization + * + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->getId(), + 'command' => is_string($this->command) ? $this->command : 'Closure', + 'at' => $this->getAt(), + 'enabled' => $this->getEnabled(), + 'priority' => $this->priority, + 'max_attempts' => $this->maxAttempts, + 'retry_count' => $this->retryCount, + 'retry_delay' => $this->retryDelay, + 'retry_strategy' => $this->retryStrategy, + 'timeout' => $this->timeout, + 'dependencies' => $this->dependencies, + 'metadata' => $this->metadata, + 'tags' => $this->tags, + 'execution_time' => $this->executionTime, + 'successful' => $this->successful, + 'output' => $this->output, + ]; + } + + /** + * Create job from array + * + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + $job = new self($data['command'] ?? '', [], $data['id'] ?? null); + + if (isset($data['at'])) { + $job->at($data['at']); + } + + if (isset($data['priority'])) { + $job->priority($data['priority']); + } + + if (isset($data['max_attempts'])) { + $job->maxAttempts($data['max_attempts']); + } + + if (isset($data['retry_delay']) && isset($data['retry_strategy'])) { + $job->retryDelay($data['retry_delay'], $data['retry_strategy']); + } + + if (isset($data['timeout'])) { + $job->timeout($data['timeout']); + } + + if (isset($data['dependencies'])) { + foreach ($data['dependencies'] as $dep) { + $job->dependsOn($dep); + } + } + + if (isset($data['metadata'])) { + foreach ($data['metadata'] as $key => $value) { + $job->withMetadata($key, $value); + } + } + + if (isset($data['tags'])) { + $job->withTags($data['tags']); + } + + return $job; + } +} \ No newline at end of file diff --git a/system/src/Grav/Common/Scheduler/ModernScheduler.php b/system/src/Grav/Common/Scheduler/ModernScheduler.php new file mode 100644 index 000000000..9905524f7 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/ModernScheduler.php @@ -0,0 +1,621 @@ +modernConfig = $grav['config']->get('scheduler.modern', []); + + // Set up modern features if enabled + if ($this->isModernEnabled()) { + $this->initializeModernFeatures(); + } + } + + /** + * Check if modern features are enabled + * + * @return bool + */ + public function isModernEnabled(): bool + { + return $this->modernConfig['enabled'] ?? false; + } + + /** + * Initialize modern scheduler features + * + * @return void + */ + protected function initializeModernFeatures(): void + { + $locator = Grav::instance()['locator']; + + // Set up paths + $this->queuePath = $this->modernConfig['queue']['path'] ?? 'user-data://scheduler/queue'; + $this->queuePath = $locator->findResource($this->queuePath, true, true); + + $this->historyPath = $this->modernConfig['history']['path'] ?? 'user-data://scheduler/history'; + $this->historyPath = $locator->findResource($this->historyPath, true, true); + + // Create directories if they don't exist + if (!file_exists($this->queuePath)) { + Folder::create($this->queuePath); + } + + if (!file_exists($this->historyPath)) { + Folder::create($this->historyPath); + } + + // Initialize job queue + $this->jobQueue = new JobQueue($this->queuePath); + + // Configure workers + $this->maxWorkers = $this->modernConfig['workers'] ?? 1; + + // Configure webhook + $this->webhookEnabled = $this->modernConfig['webhook']['enabled'] ?? false; + $this->webhookToken = $this->modernConfig['webhook']['token'] ?? null; + + // Configure health check + $this->healthEnabled = $this->modernConfig['health']['enabled'] ?? true; + } + + /** + * Enhanced run method with modern features + * + * @param DateTime|null $runTime + * @param bool $force + * @return void + */ + public function run(DateTime $runTime = null, $force = false): void + { + if (!$this->isModernEnabled()) { + // Fall back to parent implementation + parent::run($runTime, $force); + return; + } + + $this->loadSavedJobs(); + + if (null === $runTime) { + $runTime = new DateTime('now'); + } + + // Process queued jobs first + $this->processQueuedJobs(); + + // Get scheduled jobs + [$background, $foreground] = $this->getQueuedJobs(false); + $alljobs = array_merge($background, $foreground); + + // Check which jobs are due and add them to the queue + foreach ($alljobs as $job) { + if ($job->isDue($runTime) || $force) { + if ($job instanceof ModernJob) { + // Add to queue for processing + $this->jobQueue->push($job); + } else { + // Run legacy jobs directly + $job->run(); + $this->jobs_run[] = $job; + } + } + } + + // Process jobs with workers + $this->processJobsWithWorkers(); + + // Store states and history + $this->saveJobStates(); + $this->saveJobHistory(); + + // Update last run timestamp + $this->updateLastRun(); + } + + /** + * Process jobs from the queue + * + * @return void + */ + protected function processQueuedJobs(): void + { + $maxSize = $this->modernConfig['queue']['max_size'] ?? 1000; + + while (!$this->jobQueue->isEmpty() && count($this->workers) < $this->maxWorkers) { + $job = $this->jobQueue->pop(); + + if ($job) { + $this->executeJob($job); + } + } + } + + /** + * Process jobs using multiple workers + * + * @return void + */ + protected function processJobsWithWorkers(): void + { + // Wait for all workers to complete + foreach ($this->workers as $workerId => $process) { + if ($process instanceof Process) { + $process->wait(); + unset($this->workers[$workerId]); + } + } + } + + /** + * Execute a job with retry support + * + * @param Job $job + * @return void + */ + protected function executeJob(Job $job): void + { + if ($job instanceof ModernJob) { + // Use modern job execution with retry + $job->runWithRetry(); + } else { + // Use standard job execution + $job->run(); + } + + $this->jobs_run[] = $job; + + // Handle background jobs + if ($job->runInBackground() && $this->maxWorkers > 1) { + $process = $job->getProcess(); + if ($process) { + $this->workers[] = $process; + } + } + } + + /** + * Save job execution history + * + * @return void + */ + protected function saveJobHistory(): void + { + if (!$this->modernConfig['history']['enabled'] ?? true) { + return; + } + + $now = new DateTime('now'); + $historyFile = $this->historyPath . '/' . $now->format('Y-m-d') . '.json'; + + $history = []; + if (file_exists($historyFile)) { + $file = JsonFile::instance($historyFile); + $history = $file->content(); + } else { + $file = JsonFile::instance($historyFile); + } + + foreach ($this->jobs_run as $job) { + $entry = [ + 'job_id' => $job->getId(), + 'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure', + 'timestamp' => $now->format('c'), + 'success' => $job->isSuccessful(), + 'duration' => $job instanceof ModernJob ? $job->getExecutionTime() : null, + 'output' => substr($job->getOutput(), 0, 1000), // Limit output size + 'retry_count' => $job instanceof ModernJob ? $job->getRetryCount() : 0, + ]; + + $history[] = $entry; + } + + $file->save($history); + + // Clean up old history files + $this->cleanupHistory(); + } + + /** + * Clean up old history files + * + * @return void + */ + protected function cleanupHistory(): void + { + $retentionDays = $this->modernConfig['history']['retention_days'] ?? 30; + $cutoffDate = new DateTime("-{$retentionDays} days"); + + $files = glob($this->historyPath . '/*.json'); + foreach ($files as $file) { + $filename = basename($file, '.json'); + $fileDate = DateTime::createFromFormat('Y-m-d', $filename); + + if ($fileDate && $fileDate < $cutoffDate) { + unlink($file); + } + } + } + + /** + * Update last run timestamp + * + * @return void + */ + protected function updateLastRun(): void + { + $lastRunFile = $this->status_path . '/last_run.txt'; + file_put_contents($lastRunFile, (new DateTime('now'))->format('c'), LOCK_EX); + + // Also update the legacy location for backward compatibility + file_put_contents('logs/lastcron.run', (new DateTime('now'))->format('Y-m-d H:i:s'), LOCK_EX); + } + + /** + * Check scheduler health + * + * @return array + */ + public function getHealthStatus(): array + { + $lastRunFile = $this->status_path . '/last_run.txt'; + $lastRun = file_exists($lastRunFile) ? file_get_contents($lastRunFile) : null; + + $health = [ + 'status' => 'healthy', + 'last_run' => $lastRun, + 'last_run_age' => null, + 'queue_size' => 0, + 'failed_jobs_24h' => 0, + 'scheduled_jobs' => count($this->getAllJobs()), + 'modern_features' => $this->isModernEnabled(), + 'workers' => $this->maxWorkers, + 'trigger_methods' => $this->getActiveTriggers(), + ]; + + if ($lastRun) { + $lastRunTime = new DateTime($lastRun); + $now = new DateTime('now'); + $diff = $now->getTimestamp() - $lastRunTime->getTimestamp(); + $health['last_run_age'] = $diff; + + // Mark as unhealthy if no run in last 10 minutes + if ($diff > 600) { + $health['status'] = 'warning'; + } + + // Mark as critical if no run in last hour + if ($diff > 3600) { + $health['status'] = 'critical'; + } + } else { + $health['status'] = 'unknown'; + } + + // Get queue size if modern features enabled + if ($this->isModernEnabled() && $this->jobQueue) { + $health['queue_size'] = $this->jobQueue->size(); + } + + // Count failed jobs in last 24 hours + $health['failed_jobs_24h'] = $this->countRecentFailures(); + + return $health; + } + + /** + * Check if webhook is enabled + * + * @return bool + */ + public function isWebhookEnabled(): bool + { + return $this->webhookEnabled; + } + + /** + * Get active trigger methods + * + * @return array + */ + public function getActiveTriggers(): array + { + $triggers = []; + + // Check cron + $cronStatus = $this->isCrontabSetup(); + if ($cronStatus === 1) { + $triggers[] = 'cron'; + } + + // Check systemd timer + if ($this->isSystemdTimerActive()) { + $triggers[] = 'systemd'; + } + + // Check webhook + if ($this->webhookEnabled) { + $triggers[] = 'webhook'; + } + + // Check for external triggers + $lastRunFile = $this->status_path . '/last_run.txt'; + if (file_exists($lastRunFile)) { + $lastRun = file_get_contents($lastRunFile); + $lastRunTime = new DateTime($lastRun); + $now = new DateTime('now'); + $diff = $now->getTimestamp() - $lastRunTime->getTimestamp(); + + if ($diff < 120) { + $triggers[] = 'external'; + } + } + + return $triggers; + } + + /** + * Check if systemd timer is active + * + * @return bool + */ + protected function isSystemdTimerActive(): bool + { + if (Utils::isWindows()) { + return false; + } + + $process = new Process(['systemctl', 'is-active', 'grav-scheduler.timer']); + $process->run(); + + return $process->isSuccessful() && trim($process->getOutput()) === 'active'; + } + + /** + * Count recent job failures + * + * @return int + */ + protected function countRecentFailures(): int + { + $count = 0; + $cutoff = new DateTime('-24 hours'); + + // Check today's history + $todayFile = $this->historyPath . '/' . date('Y-m-d') . '.json'; + if (file_exists($todayFile)) { + $file = JsonFile::instance($todayFile); + $history = $file->content(); + + foreach ($history as $entry) { + $entryTime = new DateTime($entry['timestamp']); + if ($entryTime > $cutoff && !$entry['success']) { + $count++; + } + } + } + + // Check yesterday's history + $yesterdayFile = $this->historyPath . '/' . $cutoff->format('Y-m-d') . '.json'; + if (file_exists($yesterdayFile)) { + $file = JsonFile::instance($yesterdayFile); + $history = $file->content(); + + foreach ($history as $entry) { + $entryTime = new DateTime($entry['timestamp']); + if ($entryTime > $cutoff && !$entry['success']) { + $count++; + } + } + } + + return $count; + } + + /** + * Process webhook trigger + * + * @param string|null $token + * @param string|null $jobId + * @return array + */ + public function processWebhookTrigger($token = null, $jobId = null): array + { + if (!$this->webhookEnabled) { + return ['success' => false, 'message' => 'Webhook triggers are not enabled']; + } + + if ($this->webhookToken && $token !== $this->webhookToken) { + return ['success' => false, 'message' => 'Invalid webhook token']; + } + + if ($jobId) { + // Force run specific job (manual override - ignore schedule) + $job = $this->getJob($jobId); + if ($job) { + // Force run in foreground to get immediate result + $job->inForeground()->run(); + + // Track as manually executed + $this->jobs_run[] = $job; + $this->saveJobStates(); + $this->updateLastRun(); + + return [ + 'success' => $job->isSuccessful(), + 'message' => $job->isSuccessful() ? 'Job force-executed successfully' : 'Job execution failed', + 'job_id' => $jobId, + 'forced' => true, + 'output' => $job->getOutput(), + ]; + } else { + return ['success' => false, 'message' => 'Job not found: ' . $jobId]; + } + } else { + // Run all due jobs normally + $this->run(); + return [ + 'success' => true, + 'message' => 'Scheduler executed (due jobs only)', + 'jobs_run' => count($this->jobs_run), + 'timestamp' => date('c'), + ]; + } + } + + /** + * Get scheduler statistics + * + * @return array + */ + public function getStatistics(): array + { + $stats = [ + 'total_jobs' => count($this->getAllJobs()), + 'enabled_jobs' => 0, + 'disabled_jobs' => 0, + 'executions_today' => 0, + 'failures_today' => 0, + 'average_execution_time' => 0, + 'queue_size' => 0, + ]; + + // Count enabled/disabled jobs + foreach ($this->getAllJobs() as $job) { + if ($job->getEnabled()) { + $stats['enabled_jobs']++; + } else { + $stats['disabled_jobs']++; + } + } + + // Get today's statistics + $todayFile = $this->historyPath . '/' . date('Y-m-d') . '.json'; + if (file_exists($todayFile)) { + $file = JsonFile::instance($todayFile); + $history = $file->content(); + + $totalTime = 0; + $timeCount = 0; + + foreach ($history as $entry) { + $stats['executions_today']++; + + if (!$entry['success']) { + $stats['failures_today']++; + } + + if (isset($entry['duration']) && $entry['duration'] > 0) { + $totalTime += $entry['duration']; + $timeCount++; + } + } + + if ($timeCount > 0) { + $stats['average_execution_time'] = round($totalTime / $timeCount, 2); + } + } + + // Get queue size + if ($this->isModernEnabled() && $this->jobQueue) { + $stats['queue_size'] = $this->jobQueue->size(); + } + + return $stats; + } + + /** + * Run scheduler in daemon mode + * + * @param int $interval Check interval in seconds (default: 60) + * @return void + */ + public function runDaemon($interval = 60): void + { + if (!$this->isModernEnabled()) { + throw new RuntimeException('Daemon mode requires modern features to be enabled'); + } + + $lastRun = 0; + + while (true) { + $now = time(); + + // Run scheduler every minute + if ($now - $lastRun >= $interval) { + $this->run(); + $lastRun = $now; + } + + // Process any queued jobs + $this->processQueuedJobs(); + + // Sleep for a short interval + sleep(5); + + // Check for shutdown signal + if (file_exists($this->status_path . '/shutdown')) { + unlink($this->status_path . '/shutdown'); + break; + } + } + } +} \ No newline at end of file diff --git a/system/src/Grav/Common/Scheduler/Scheduler.php b/system/src/Grav/Common/Scheduler/Scheduler.php index 359f2d020..dce3c6a25 100644 --- a/system/src/Grav/Common/Scheduler/Scheduler.php +++ b/system/src/Grav/Common/Scheduler/Scheduler.php @@ -378,6 +378,40 @@ class Scheduler } + /** + * Check if webhook is enabled + * + * @return bool + */ + public function isWebhookEnabled(): bool + { + // Check config for webhook settings even in base scheduler + $config = Grav::instance()['config']; + return $config->get('scheduler.modern.webhook.enabled', false); + } + + /** + * Get active trigger methods + * + * @return array + */ + public function getActiveTriggers(): array + { + $triggers = []; + + $cronStatus = $this->isCrontabSetup(); + if ($cronStatus === 1) { + $triggers[] = 'cron'; + } + + // Check if webhook is enabled + if ($this->isWebhookEnabled()) { + $triggers[] = 'webhook'; + } + + return $triggers; + } + /** * Queue a job for execution in the correct queue. * diff --git a/system/src/Grav/Common/Scheduler/SchedulerController.php b/system/src/Grav/Common/Scheduler/SchedulerController.php new file mode 100644 index 000000000..d346ff4ff --- /dev/null +++ b/system/src/Grav/Common/Scheduler/SchedulerController.php @@ -0,0 +1,263 @@ +grav = $grav; + + // Get scheduler instance + $scheduler = $grav['scheduler']; + if ($scheduler instanceof ModernScheduler) { + $this->scheduler = $scheduler; + } else { + // Create ModernScheduler instance if not already + $this->scheduler = new ModernScheduler(); + } + } + + /** + * Handle health check endpoint + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function health(ServerRequestInterface $request): ResponseInterface + { + $config = $this->grav['config']->get('scheduler.modern', []); + + // Check if health endpoint is enabled + if (!($config['health']['enabled'] ?? true)) { + return $this->jsonResponse(['error' => 'Health check disabled'], 403); + } + + // Get health status + $health = $this->scheduler->getHealthStatus(); + + return $this->jsonResponse($health); + } + + /** + * Handle webhook trigger endpoint + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function webhook(ServerRequestInterface $request): ResponseInterface + { + $config = $this->grav['config']->get('scheduler.modern', []); + + // Check if webhook is enabled + if (!($config['webhook']['enabled'] ?? false)) { + return $this->jsonResponse(['error' => 'Webhook triggers disabled'], 403); + } + + // Get authorization header + $authHeader = $request->getHeaderLine('Authorization'); + $token = null; + + if (preg_match('/Bearer\s+(.+)$/i', $authHeader, $matches)) { + $token = $matches[1]; + } + + // Get query parameters + $params = $request->getQueryParams(); + $jobId = $params['job'] ?? null; + + // Process webhook + $result = $this->scheduler->processWebhookTrigger($token, $jobId); + + $statusCode = $result['success'] ? 200 : 400; + return $this->jsonResponse($result, $statusCode); + } + + /** + * Handle statistics endpoint + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function statistics(ServerRequestInterface $request): ResponseInterface + { + // Check if user is admin + $user = $this->grav['user'] ?? null; + if (!$user || !$user->authorize('admin.super')) { + return $this->jsonResponse(['error' => 'Unauthorized'], 401); + } + + $stats = $this->scheduler->getStatistics(); + + return $this->jsonResponse($stats); + } + + /** + * Handle admin AJAX requests for scheduler status + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function adminStatus(ServerRequestInterface $request): ResponseInterface + { + // Check if user is admin + $user = $this->grav['user'] ?? null; + if (!$user || !$user->authorize('admin.scheduler')) { + return $this->jsonResponse(['error' => 'Unauthorized'], 401); + } + + $health = $this->scheduler->getHealthStatus(); + + // Format for admin display + $response = [ + 'health' => $this->formatHealthStatus($health), + 'triggers' => $this->formatTriggers($health['trigger_methods'] ?? []) + ]; + + return $this->jsonResponse($response); + } + + /** + * Format health status for display + * + * @param array $health + * @return string + */ + protected function formatHealthStatus(array $health): string + { + $status = $health['status'] ?? 'unknown'; + $lastRun = $health['last_run'] ?? null; + $queueSize = $health['queue_size'] ?? 0; + $failedJobs = $health['failed_jobs_24h'] ?? 0; + + $statusBadge = match($status) { + 'healthy' => 'Healthy', + 'warning' => 'Warning', + 'critical' => 'Critical', + default => 'Unknown' + }; + + $html = '
'; + $html .= '

Status: ' . $statusBadge . '

'; + + if ($lastRun) { + $lastRunTime = new \DateTime($lastRun); + $now = new \DateTime(); + $diff = $now->diff($lastRunTime); + + $timeAgo = ''; + if ($diff->d > 0) { + $timeAgo = $diff->d . ' day' . ($diff->d > 1 ? 's' : '') . ' ago'; + } elseif ($diff->h > 0) { + $timeAgo = $diff->h . ' hour' . ($diff->h > 1 ? 's' : '') . ' ago'; + } elseif ($diff->i > 0) { + $timeAgo = $diff->i . ' minute' . ($diff->i > 1 ? 's' : '') . ' ago'; + } else { + $timeAgo = 'Less than a minute ago'; + } + + $html .= '

Last Run: ' . $timeAgo . '

'; + } else { + $html .= '

Last Run: Never

'; + } + + $html .= '

Queue Size: ' . $queueSize . '

'; + + if ($failedJobs > 0) { + $html .= '

Failed Jobs (24h): ' . $failedJobs . '

'; + } + + $html .= '
'; + + return $html; + } + + /** + * Format triggers for display + * + * @param array $triggers + * @return string + */ + protected function formatTriggers(array $triggers): string + { + if (empty($triggers)) { + return '
No active triggers detected. Please set up cron, systemd, or webhook triggers.
'; + } + + $html = '
'; + $html .= '
    '; + + foreach ($triggers as $trigger) { + $icon = match($trigger) { + 'cron' => '⏰', + 'systemd' => '⚙️', + 'webhook' => '🔗', + 'external' => '🌐', + default => '•' + }; + + $label = match($trigger) { + 'cron' => 'Cron Job', + 'systemd' => 'Systemd Timer', + 'webhook' => 'Webhook Triggers', + 'external' => 'External Triggers', + default => ucfirst($trigger) + }; + + $html .= '
  • ' . $icon . ' ' . $label . ' Active
  • '; + } + + $html .= '
'; + $html .= '
'; + + return $html; + } + + /** + * Create JSON response + * + * @param array $data + * @param int $statusCode + * @return ResponseInterface + */ + protected function jsonResponse(array $data, int $statusCode = 200): ResponseInterface + { + $response = $this->grav['response'] ?? new \Nyholm\Psr7\Response(); + + $response = $response->withStatus($statusCode) + ->withHeader('Content-Type', 'application/json'); + + $body = $response->getBody(); + $body->write(json_encode($data)); + + return $response; + } +} \ No newline at end of file diff --git a/system/src/Grav/Common/Service/SchedulerServiceProvider.php b/system/src/Grav/Common/Service/SchedulerServiceProvider.php index e87412eec..f854dbebf 100644 --- a/system/src/Grav/Common/Service/SchedulerServiceProvider.php +++ b/system/src/Grav/Common/Service/SchedulerServiceProvider.php @@ -10,6 +10,7 @@ namespace Grav\Common\Service; use Grav\Common\Scheduler\Scheduler; +use Grav\Common\Scheduler\ModernScheduler; use Pimple\Container; use Pimple\ServiceProviderInterface; @@ -25,7 +26,16 @@ class SchedulerServiceProvider implements ServiceProviderInterface */ public function register(Container $container) { - $container['scheduler'] = function () { + $container['scheduler'] = function ($c) { + $config = $c['config']; + + // Use ModernScheduler if modern features are enabled + $modernEnabled = $config->get('scheduler.modern.enabled', false); + + if ($modernEnabled) { + return new ModernScheduler(); + } + return new Scheduler(); }; } diff --git a/system/src/Grav/Console/Cli/SchedulerCommand.php b/system/src/Grav/Console/Cli/SchedulerCommand.php index 7e0cee360..a73266fc4 100644 --- a/system/src/Grav/Console/Cli/SchedulerCommand.php +++ b/system/src/Grav/Console/Cli/SchedulerCommand.php @@ -9,7 +9,7 @@ namespace Grav\Console\Cli; -use Cron\CronExpression; +use Dragonmantank\Cron\CronExpression; use Grav\Common\Grav; use Grav\Common\Utils; use Grav\Common\Scheduler\Scheduler; From e497a93da65443d8024cc0a9533b8e2969f4d5ac Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 24 Aug 2025 22:14:14 +0100 Subject: [PATCH 2/7] simplify extended jobs logic Signed-off-by: Andy Miller --- system/blueprints/config/scheduler.yaml | 116 ++-- system/src/Grav/Common/Scheduler/Job.php | 507 ++++++++++++++++++ .../src/Grav/Common/Scheduler/JobHistory.php | 462 ++++++++++++++++ system/src/Grav/Common/Scheduler/JobQueue.php | 119 +++- .../Grav/Common/Scheduler/ModernScheduler.php | 72 ++- .../src/Grav/Common/Scheduler/Scheduler.php | 482 ++++++++++++++++- .../Service/SchedulerServiceProvider.php | 36 +- 7 files changed, 1713 insertions(+), 81 deletions(-) create mode 100644 system/src/Grav/Common/Scheduler/JobHistory.php diff --git a/system/blueprints/config/scheduler.yaml b/system/blueprints/config/scheduler.yaml index ab9a2c016..6bccafbbd 100644 --- a/system/blueprints/config/scheduler.yaml +++ b/system/blueprints/config/scheduler.yaml @@ -250,47 +250,69 @@ form: var statusEl = document.getElementById('scheduler-health-status'); if (!statusEl) return; - var html = '
'; + // Modern card-based layout + var statusColor = '#6c757d'; + var statusLabel = data.status || 'unknown'; + if (data.status === 'healthy') statusColor = '#28a745'; + else if (data.status === 'warning') statusColor = '#ffc107'; + else if (data.status === 'critical') statusColor = '#dc3545'; - // Status badge - var badge = 'secondary'; - if (data.status === 'healthy') badge = 'success'; - else if (data.status === 'warning') badge = 'warning'; - else if (data.status === 'critical') badge = 'danger'; + var html = '
'; - html += '

Status: ' + - (data.status || 'Unknown').toUpperCase() + '

'; + // Status card + html += '
'; + html += 'Status:'; + html += '' + statusLabel + ''; + html += '
'; - // Last run + // Info grid + html += '
'; + + // Last run card + html += '
'; + html += '
Last Run
'; if (data.last_run) { var age = data.last_run_age; var ageText = 'just now'; - if (age > 3600) { + if (age > 86400) { + ageText = Math.floor(age / 86400) + ' day(s) ago'; + } else if (age > 3600) { ageText = Math.floor(age / 3600) + ' hour(s) ago'; } else if (age > 60) { ageText = Math.floor(age / 60) + ' minute(s) ago'; } else if (age > 0) { ageText = age + ' second(s) ago'; } - html += '

Last Run: ' + ageText + '

'; + html += '
' + ageText + '
'; } else { - html += '

Last Run: Never

'; + html += '
Never
'; } - - // Jobs count - html += '

Scheduled Jobs: ' + (data.scheduled_jobs || 0) + '

'; - - // Queue size (if modern features enabled) - if (data.modern_features && data.queue_size !== undefined) { - html += '

Queue Size: ' + data.queue_size + '

'; - } - - // Failed jobs - if (data.failed_jobs_24h > 0) { - html += '

Failed (24h): ' + data.failed_jobs_24h + '

'; - } - html += '
'; + + // Jobs count card + html += '
'; + html += '
Scheduled Jobs
'; + html += '
' + (data.scheduled_jobs || 0) + '
'; + html += '
'; + + html += '
'; // Close grid + + // Additional info if available + if (data.modern_features && data.queue_size !== undefined) { + html += '
'; + html += 'Queue Size: '; + html += '' + data.queue_size + ''; + html += '
'; + } + + // Failed jobs warning + if (data.failed_jobs_24h > 0) { + html += '
'; + html += '⚠️ Failed Jobs (24h): ' + data.failed_jobs_24h; + html += '
'; + } + + html += '
'; // Close main container statusEl.innerHTML = html; }) .catch(error => { @@ -339,26 +361,46 @@ form: var triggersEl = document.getElementById('scheduler-triggers'); if (!triggersEl) return; - var html = '
    '; + var html = '
    '; - // Cron status - if (cronReady) { - html += '
  • Cron: Active
  • '; - } else { - html += '
  • Cron: Not Configured
  • '; - } + // Cron trigger card + var cronBg = cronReady ? 'linear-gradient(135deg, #d4edda 0%, #fff 100%)' : 'linear-gradient(135deg, #f8f9fa 0%, #fff 100%)'; + var cronBorder = cronReady ? '#c3e6cb' : '#dee2e6'; + var cronIcon = cronReady ? '✅' : '❌'; + var cronStatus = cronReady ? 'Active' : 'Not Configured'; + var cronColor = cronReady ? '#155724' : '#6c757d'; - // Webhook status + html += '
    '; + html += '
    '; + html += '' + cronIcon + ''; + html += 'Cron:'; + html += '
    '; + html += '' + cronStatus + ''; + html += '
    '; + + // Webhook trigger card if (data.webhook_enabled) { - html += '
  • Webhook: Active
  • '; + html += '
    '; + html += '
    '; + html += ''; + html += 'Webhook:'; + html += '
    '; + html += 'Active'; + html += '
    '; } else { var modernEnabled = document.querySelector('[name="data[scheduler][modern][enabled]"]:checked'); if (modernEnabled && modernEnabled.value == '1') { - html += '
  • ⚠️ Webhook: Disabled
  • '; + html += '
    '; + html += '
    '; + html += '⚠️'; + html += 'Webhook:'; + html += '
    '; + html += 'Disabled'; + html += '
    '; } } - html += '
'; + html += '
'; // Add warning if no triggers active if (!cronReady && !data.webhook_enabled) { diff --git a/system/src/Grav/Common/Scheduler/Job.php b/system/src/Grav/Common/Scheduler/Job.php index 6967db37a..764885bc8 100644 --- a/system/src/Grav/Common/Scheduler/Job.php +++ b/system/src/Grav/Common/Scheduler/Job.php @@ -77,6 +77,40 @@ class Job private $successful = false; /** @var string|null */ private $backlink; + + // Modern Job features + /** @var int */ + protected $maxAttempts = 3; + /** @var int */ + protected $retryCount = 0; + /** @var int */ + protected $retryDelay = 60; // seconds + /** @var string */ + protected $retryStrategy = 'exponential'; // 'linear' or 'exponential' + /** @var float */ + protected $executionStartTime; + /** @var float */ + protected $executionDuration = 0; + /** @var int */ + protected $timeout = 300; // 5 minutes default + /** @var array */ + protected $dependencies = []; + /** @var array */ + protected $chainedJobs = []; + /** @var string|null */ + protected $queueId; + /** @var string */ + protected $priority = 'normal'; // 'high', 'normal', 'low' + /** @var array */ + protected $metadata = []; + /** @var array */ + protected $tags = []; + /** @var callable|null */ + protected $onSuccess; + /** @var callable|null */ + protected $onFailure; + /** @var callable|null */ + protected $onRetry; /** * Create a new Job instance. @@ -150,6 +184,16 @@ class Job return null; } + + /** + * Get raw arguments (array or string) + * + * @return array|string + */ + public function getRawArguments() + { + return $this->args; + } /** * @return CronExpression @@ -315,6 +359,13 @@ class Job */ public function run() { + // Check dependencies (modern feature) + if (!$this->checkDependencies()) { + $this->output = 'Dependencies not met'; + $this->successful = false; + return false; + } + // If the truthTest failed, don't run if ($this->truthTest !== true) { return false; @@ -340,6 +391,11 @@ class Job $args = is_string($this->args) ? explode(' ', $this->args) : $this->args; $command = array_merge([$this->command], $args); $process = new Process($command); + + // Apply timeout if set (modern feature) + if ($this->timeout > 0) { + $process->setTimeout($this->timeout); + } $this->process = $process; @@ -563,4 +619,455 @@ class Job return $this; } + + // Modern Job Methods + + /** + * Set maximum retry attempts + * + * @param int $attempts + * @return self + */ + public function maxAttempts(int $attempts): self + { + $this->maxAttempts = $attempts; + return $this; + } + + /** + * Get maximum retry attempts + * + * @return int + */ + public function getMaxAttempts(): int + { + return $this->maxAttempts; + } + + /** + * Set retry delay + * + * @param int $seconds + * @param string $strategy 'linear' or 'exponential' + * @return self + */ + public function retryDelay(int $seconds, string $strategy = 'exponential'): self + { + $this->retryDelay = $seconds; + $this->retryStrategy = $strategy; + return $this; + } + + /** + * Get current retry count + * + * @return int + */ + public function getRetryCount(): int + { + return $this->retryCount; + } + + /** + * Set job timeout + * + * @param int $seconds + * @return self + */ + public function timeout(int $seconds): self + { + $this->timeout = $seconds; + return $this; + } + + /** + * Set job priority + * + * @param string $priority 'high', 'normal', or 'low' + * @return self + */ + public function priority(string $priority): self + { + if (!in_array($priority, ['high', 'normal', 'low'])) { + throw new InvalidArgumentException('Priority must be high, normal, or low'); + } + $this->priority = $priority; + return $this; + } + + /** + * Get job priority + * + * @return string + */ + public function getPriority(): string + { + return $this->priority; + } + + /** + * Add job dependency + * + * @param string $jobId + * @return self + */ + public function dependsOn(string $jobId): self + { + $this->dependencies[] = $jobId; + return $this; + } + + /** + * Chain another job to run after this one + * + * @param Job $job + * @param bool $onlyOnSuccess Run only if current job succeeds + * @return self + */ + public function chain(Job $job, bool $onlyOnSuccess = true): self + { + $this->chainedJobs[] = [ + 'job' => $job, + 'onlyOnSuccess' => $onlyOnSuccess, + ]; + return $this; + } + + /** + * Add metadata to the job + * + * @param string $key + * @param mixed $value + * @return self + */ + public function withMetadata(string $key, $value): self + { + $this->metadata[$key] = $value; + return $this; + } + + /** + * Add tags to the job + * + * @param array $tags + * @return self + */ + public function withTags(array $tags): self + { + $this->tags = array_merge($this->tags, $tags); + return $this; + } + + /** + * Set success callback + * + * @param callable $callback + * @return self + */ + public function onSuccess(callable $callback): self + { + $this->onSuccess = $callback; + return $this; + } + + /** + * Set failure callback + * + * @param callable $callback + * @return self + */ + public function onFailure(callable $callback): self + { + $this->onFailure = $callback; + return $this; + } + + /** + * Set retry callback + * + * @param callable $callback + * @return self + */ + public function onRetry(callable $callback): self + { + $this->onRetry = $callback; + return $this; + } + + /** + * Run the job with retry support + * + * @return bool + */ + public function runWithRetry(): bool + { + $attempts = 0; + $lastException = null; + + while ($attempts < $this->maxAttempts) { + $attempts++; + $this->retryCount = $attempts - 1; + + try { + // Record execution start time + $this->executionStartTime = microtime(true); + + // Run the job + $result = $this->run(); + + // Record execution time + $this->executionDuration = microtime(true) - $this->executionStartTime; + + if ($result && $this->isSuccessful()) { + // Call success callback + if ($this->onSuccess) { + call_user_func($this->onSuccess, $this); + } + + // Run chained jobs + $this->runChainedJobs(true); + + return true; + } + + throw new RuntimeException('Job execution failed'); + + } catch (\Exception $e) { + $lastException = $e; + $this->output = $e->getMessage(); + $this->successful = false; + + if ($attempts < $this->maxAttempts) { + // Call retry callback + if ($this->onRetry) { + call_user_func($this->onRetry, $this, $attempts, $e); + } + + // Calculate delay before retry + $delay = $this->calculateRetryDelay($attempts); + if ($delay > 0) { + sleep($delay); + } + } else { + // Final failure + if ($this->onFailure) { + call_user_func($this->onFailure, $this, $e); + } + + // Run chained jobs that should run on failure + $this->runChainedJobs(false); + } + } + } + + return false; + } + + /** + * Get execution time in seconds + * + * @return float + */ + public function getExecutionTime(): float + { + return $this->executionDuration; + } + + /** + * Get job metadata + * + * @param string|null $key + * @return mixed + */ + public function getMetadata(string $key = null) + { + if ($key === null) { + return $this->metadata; + } + + return $this->metadata[$key] ?? null; + } + + /** + * Get job tags + * + * @return array + */ + public function getTags(): array + { + return $this->tags; + } + + /** + * Check if job has a specific tag + * + * @param string $tag + * @return bool + */ + public function hasTag(string $tag): bool + { + return in_array($tag, $this->tags); + } + + /** + * Set queue ID + * + * @param string $queueId + * @return self + */ + public function setQueueId(string $queueId): self + { + $this->queueId = $queueId; + return $this; + } + + /** + * Get queue ID + * + * @return string|null + */ + public function getQueueId(): ?string + { + return $this->queueId; + } + + /** + * Get process (for background jobs) + * + * @return Process|null + */ + public function getProcess(): ?Process + { + return $this->process; + } + + /** + * Calculate retry delay based on strategy + * + * @param int $attempt + * @return int + */ + protected function calculateRetryDelay(int $attempt): int + { + if ($this->retryStrategy === 'exponential') { + return min($this->retryDelay * pow(2, $attempt - 1), 3600); // Max 1 hour + } + + return $this->retryDelay; + } + + /** + * Check if dependencies are met + * + * @return bool + */ + protected function checkDependencies(): bool + { + if (empty($this->dependencies)) { + return true; + } + + // This would need to check against job history or status + // For now, we'll assume dependencies are met + // In a real implementation, this would check the Scheduler's job status + return true; + } + + /** + * Run chained jobs + * + * @param bool $success Whether the current job succeeded + * @return void + */ + protected function runChainedJobs(bool $success): void + { + foreach ($this->chainedJobs as $chainedJob) { + $shouldRun = !$chainedJob['onlyOnSuccess'] || $success; + + if ($shouldRun) { + $job = $chainedJob['job']; + if (method_exists($job, 'runWithRetry')) { + $job->runWithRetry(); + } else { + $job->run(); + } + } + } + } + + /** + * Convert job to array for serialization + * + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->getId(), + 'command' => is_string($this->command) ? $this->command : 'Closure', + 'at' => $this->getAt(), + 'enabled' => $this->getEnabled(), + 'priority' => $this->priority, + 'max_attempts' => $this->maxAttempts, + 'retry_count' => $this->retryCount, + 'retry_delay' => $this->retryDelay, + 'retry_strategy' => $this->retryStrategy, + 'timeout' => $this->timeout, + 'dependencies' => $this->dependencies, + 'metadata' => $this->metadata, + 'tags' => $this->tags, + 'execution_time' => $this->executionDuration, + 'successful' => $this->successful, + 'output' => $this->output, + ]; + } + + /** + * Create job from array + * + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + $job = new self($data['command'] ?? '', [], $data['id'] ?? null); + + if (isset($data['at'])) { + $job->at($data['at']); + } + + if (isset($data['priority'])) { + $job->priority($data['priority']); + } + + if (isset($data['max_attempts'])) { + $job->maxAttempts($data['max_attempts']); + } + + if (isset($data['retry_delay']) && isset($data['retry_strategy'])) { + $job->retryDelay($data['retry_delay'], $data['retry_strategy']); + } + + if (isset($data['timeout'])) { + $job->timeout($data['timeout']); + } + + if (isset($data['dependencies'])) { + foreach ($data['dependencies'] as $dep) { + $job->dependsOn($dep); + } + } + + if (isset($data['metadata'])) { + foreach ($data['metadata'] as $key => $value) { + $job->withMetadata($key, $value); + } + } + + if (isset($data['tags'])) { + $job->withTags($data['tags']); + } + + return $job; + } } diff --git a/system/src/Grav/Common/Scheduler/JobHistory.php b/system/src/Grav/Common/Scheduler/JobHistory.php new file mode 100644 index 000000000..8361d5c7f --- /dev/null +++ b/system/src/Grav/Common/Scheduler/JobHistory.php @@ -0,0 +1,462 @@ +historyPath = $historyPath; + $this->retentionDays = $retentionDays; + + // Ensure history directory exists + if (!is_dir($this->historyPath)) { + mkdir($this->historyPath, 0755, true); + } + } + + /** + * Log job execution + * + * @param Job $job + * @param array $metadata Additional metadata to store + * @return string Log entry ID + */ + public function logExecution(Job $job, array $metadata = []): string + { + $entryId = uniqid($job->getId() . '_', true); + $timestamp = new DateTime(); + + $entry = [ + 'id' => $entryId, + 'job_id' => $job->getId(), + 'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure', + 'arguments' => method_exists($job, 'getRawArguments') ? $job->getRawArguments() : $job->getArguments(), + 'executed_at' => $timestamp->format('c'), + 'timestamp' => $timestamp->getTimestamp(), + 'success' => $job->isSuccessful(), + 'output' => $this->captureOutput($job), + 'execution_time' => method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null, + 'retry_count' => method_exists($job, 'getRetryCount') ? $job->getRetryCount() : 0, + 'priority' => method_exists($job, 'getPriority') ? $job->getPriority() : 'normal', + 'tags' => method_exists($job, 'getTags') ? $job->getTags() : [], + 'metadata' => array_merge( + method_exists($job, 'getMetadata') ? $job->getMetadata() : [], + $metadata + ), + ]; + + // Store in daily file + $this->storeEntry($entry); + + // Also store in job-specific history + $this->storeJobHistory($job->getId(), $entry); + + return $entryId; + } + + /** + * Capture job output with length limit + * + * @param Job $job + * @return array + */ + protected function captureOutput(Job $job): array + { + $output = $job->getOutput(); + $truncated = false; + + if (strlen($output) > $this->maxOutputLength) { + $output = substr($output, 0, $this->maxOutputLength); + $truncated = true; + } + + return [ + 'content' => $output, + 'truncated' => $truncated, + 'length' => strlen($job->getOutput()), + ]; + } + + /** + * Store entry in daily log file + * + * @param array $entry + * @return void + */ + protected function storeEntry(array $entry): void + { + $date = date('Y-m-d'); + $filename = $this->historyPath . '/' . $date . '.json'; + + $jsonFile = JsonFile::instance($filename); + $entries = $jsonFile->content() ?: []; + $entries[] = $entry; + $jsonFile->save($entries); + } + + /** + * Store job-specific history + * + * @param string $jobId + * @param array $entry + * @return void + */ + protected function storeJobHistory(string $jobId, array $entry): void + { + $jobDir = $this->historyPath . '/jobs'; + if (!is_dir($jobDir)) { + mkdir($jobDir, 0755, true); + } + + $filename = $jobDir . '/' . $jobId . '.json'; + $jsonFile = JsonFile::instance($filename); + $history = $jsonFile->content() ?: []; + + // Keep only last 100 executions per job + $history[] = $entry; + if (count($history) > 100) { + $history = array_slice($history, -100); + } + + $jsonFile->save($history); + } + + /** + * Get job history + * + * @param string $jobId + * @param int $limit + * @return array + */ + public function getJobHistory(string $jobId, int $limit = 50): array + { + $filename = $this->historyPath . '/jobs/' . $jobId . '.json'; + if (!file_exists($filename)) { + return []; + } + + $jsonFile = JsonFile::instance($filename); + $history = $jsonFile->content() ?: []; + + // Return most recent first + $history = array_reverse($history); + + if ($limit > 0) { + $history = array_slice($history, 0, $limit); + } + + return $history; + } + + /** + * Get history for a date range + * + * @param DateTime $startDate + * @param DateTime $endDate + * @param string|null $jobId Filter by job ID + * @return array + */ + public function getHistoryRange(DateTime $startDate, DateTime $endDate, ?string $jobId = null): array + { + $history = []; + $current = clone $startDate; + + while ($current <= $endDate) { + $filename = $this->historyPath . '/' . $current->format('Y-m-d') . '.json'; + if (file_exists($filename)) { + $jsonFile = JsonFile::instance($filename); + $entries = $jsonFile->content() ?: []; + + foreach ($entries as $entry) { + if ($jobId === null || $entry['job_id'] === $jobId) { + $history[] = $entry; + } + } + } + + $current->modify('+1 day'); + } + + return $history; + } + + /** + * Get job statistics + * + * @param string $jobId + * @param int $days Number of days to analyze + * @return array + */ + public function getJobStatistics(string $jobId, int $days = 7): array + { + $startDate = new DateTime("-{$days} days"); + $endDate = new DateTime('now'); + + $history = $this->getHistoryRange($startDate, $endDate, $jobId); + + if (empty($history)) { + return [ + 'total_runs' => 0, + 'successful_runs' => 0, + 'failed_runs' => 0, + 'success_rate' => 0, + 'average_execution_time' => 0, + 'last_run' => null, + 'last_success' => null, + 'last_failure' => null, + ]; + } + + $totalRuns = count($history); + $successfulRuns = 0; + $executionTimes = []; + $lastRun = null; + $lastSuccess = null; + $lastFailure = null; + + foreach ($history as $entry) { + if ($entry['success']) { + $successfulRuns++; + if (!$lastSuccess || $entry['timestamp'] > $lastSuccess['timestamp']) { + $lastSuccess = $entry; + } + } else { + if (!$lastFailure || $entry['timestamp'] > $lastFailure['timestamp']) { + $lastFailure = $entry; + } + } + + if (!$lastRun || $entry['timestamp'] > $lastRun['timestamp']) { + $lastRun = $entry; + } + + if (isset($entry['execution_time']) && $entry['execution_time'] > 0) { + $executionTimes[] = $entry['execution_time']; + } + } + + return [ + 'total_runs' => $totalRuns, + 'successful_runs' => $successfulRuns, + 'failed_runs' => $totalRuns - $successfulRuns, + 'success_rate' => $totalRuns > 0 ? round(($successfulRuns / $totalRuns) * 100, 2) : 0, + 'average_execution_time' => !empty($executionTimes) ? round(array_sum($executionTimes) / count($executionTimes), 3) : 0, + 'last_run' => $lastRun, + 'last_success' => $lastSuccess, + 'last_failure' => $lastFailure, + ]; + } + + /** + * Get global statistics + * + * @param int $days + * @return array + */ + public function getGlobalStatistics(int $days = 7): array + { + $startDate = new DateTime("-{$days} days"); + $endDate = new DateTime('now'); + + $history = $this->getHistoryRange($startDate, $endDate); + + $jobStats = []; + foreach ($history as $entry) { + $jobId = $entry['job_id']; + if (!isset($jobStats[$jobId])) { + $jobStats[$jobId] = [ + 'runs' => 0, + 'success' => 0, + 'failed' => 0, + ]; + } + + $jobStats[$jobId]['runs']++; + if ($entry['success']) { + $jobStats[$jobId]['success']++; + } else { + $jobStats[$jobId]['failed']++; + } + } + + return [ + 'total_executions' => count($history), + 'unique_jobs' => count($jobStats), + 'job_statistics' => $jobStats, + 'period_days' => $days, + 'from_date' => $startDate->format('Y-m-d'), + 'to_date' => $endDate->format('Y-m-d'), + ]; + } + + /** + * Search history + * + * @param array $criteria + * @return array + */ + public function searchHistory(array $criteria): array + { + $results = []; + + // Determine date range + $startDate = isset($criteria['start_date']) ? new DateTime($criteria['start_date']) : new DateTime('-7 days'); + $endDate = isset($criteria['end_date']) ? new DateTime($criteria['end_date']) : new DateTime('now'); + + $history = $this->getHistoryRange($startDate, $endDate, $criteria['job_id'] ?? null); + + foreach ($history as $entry) { + $match = true; + + // Filter by success status + if (isset($criteria['success']) && $entry['success'] !== $criteria['success']) { + $match = false; + } + + // Filter by output content + if (isset($criteria['output_contains']) && + stripos($entry['output']['content'], $criteria['output_contains']) === false) { + $match = false; + } + + // Filter by tags + if (isset($criteria['tags']) && is_array($criteria['tags'])) { + $entryTags = $entry['tags'] ?? []; + if (empty(array_intersect($criteria['tags'], $entryTags))) { + $match = false; + } + } + + if ($match) { + $results[] = $entry; + } + } + + // Sort results + if (isset($criteria['sort_by'])) { + usort($results, function($a, $b) use ($criteria) { + $field = $criteria['sort_by']; + $order = $criteria['sort_order'] ?? 'desc'; + + $aVal = $a[$field] ?? 0; + $bVal = $b[$field] ?? 0; + + if ($order === 'asc') { + return $aVal <=> $bVal; + } else { + return $bVal <=> $aVal; + } + }); + } + + // Limit results + if (isset($criteria['limit'])) { + $results = array_slice($results, 0, $criteria['limit']); + } + + return $results; + } + + /** + * Clean old history files + * + * @return int Number of files deleted + */ + public function cleanOldHistory(): int + { + $deleted = 0; + $cutoffDate = new DateTime("-{$this->retentionDays} days"); + + $files = glob($this->historyPath . '/*.json'); + foreach ($files as $file) { + $filename = basename($file, '.json'); + // Check if filename is a date + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $filename)) { + $fileDate = new DateTime($filename); + if ($fileDate < $cutoffDate) { + unlink($file); + $deleted++; + } + } + } + + return $deleted; + } + + /** + * Export history to CSV + * + * @param array $history + * @param string $filename + * @return bool + */ + public function exportToCsv(array $history, string $filename): bool + { + $handle = fopen($filename, 'w'); + if (!$handle) { + return false; + } + + // Write headers + fputcsv($handle, [ + 'Job ID', + 'Executed At', + 'Success', + 'Execution Time', + 'Output Length', + 'Retry Count', + 'Priority', + 'Tags', + ]); + + // Write data + foreach ($history as $entry) { + fputcsv($handle, [ + $entry['job_id'], + $entry['executed_at'], + $entry['success'] ? 'Yes' : 'No', + $entry['execution_time'] ?? '', + $entry['output']['length'] ?? 0, + $entry['retry_count'] ?? 0, + $entry['priority'] ?? 'normal', + implode(', ', $entry['tags'] ?? []), + ]); + } + + fclose($handle); + return true; + } +} \ No newline at end of file diff --git a/system/src/Grav/Common/Scheduler/JobQueue.php b/system/src/Grav/Common/Scheduler/JobQueue.php index b871d3b59..bdf4596c5 100644 --- a/system/src/Grav/Common/Scheduler/JobQueue.php +++ b/system/src/Grav/Common/Scheduler/JobQueue.php @@ -81,20 +81,18 @@ class JobQueue 'id' => $queueId, 'job_id' => $job->getId(), 'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure', - 'arguments' => $job->getArguments(), + 'arguments' => method_exists($job, 'getRawArguments') ? $job->getRawArguments() : $job->getArguments(), 'priority' => $priority, 'timestamp' => $timestamp, 'attempts' => 0, - 'max_attempts' => $job instanceof ModernJob ? $job->getMaxAttempts() : 1, + 'max_attempts' => method_exists($job, 'getMaxAttempts') ? $job->getMaxAttempts() : 1, 'created_at' => date('c'), 'scheduled_for' => null, 'metadata' => [], ]; - // Serialize the job if it's a closure - if (!is_string($job->getCommand())) { - $queueItem['serialized_job'] = base64_encode(serialize($job)); - } + // Always serialize the job to preserve its full state + $queueItem['serialized_job'] = base64_encode(serialize($job)); $this->writeQueueItem($queueItem, 'pending'); @@ -130,7 +128,9 @@ class JobQueue */ public function pop(): ?Job { - $this->lock(); + if (!$this->lock()) { + return null; + } try { // Get all pending items @@ -188,6 +188,78 @@ class JobQueue } } + /** + * Pop a job from the queue with its queue ID + * + * @return array|null Array with 'job' and 'id' keys + */ + public function popWithId(): ?array + { + if (!$this->lock()) { + return null; + } + + try { + // Get all pending items + $items = $this->getPendingItems(); + + if (empty($items)) { + $this->unlock(); + return null; + } + + // Sort by priority and timestamp + usort($items, function($a, $b) { + $priorityOrder = [ + self::PRIORITY_HIGH => 0, + self::PRIORITY_NORMAL => 1, + self::PRIORITY_LOW => 2, + ]; + + $aPriority = $priorityOrder[$a['priority']] ?? 1; + $bPriority = $priorityOrder[$b['priority']] ?? 1; + + if ($aPriority !== $bPriority) { + return $aPriority - $bPriority; + } + + return $a['timestamp'] <=> $b['timestamp']; + }); + + // Get the first item that's ready to run + $now = new \DateTime(); + foreach ($items as $item) { + if ($item['scheduled_for']) { + $scheduledTime = new \DateTime($item['scheduled_for']); + if ($scheduledTime > $now) { + continue; // Skip items not yet due + } + } + + // Reconstruct the job first before moving it + $job = $this->reconstructJob($item); + + if (!$job) { + // Failed to reconstruct, skip this item + continue; + } + + // Move to processing only if we can reconstruct the job + $this->moveQueueItem($item['id'], 'pending', 'processing'); + + $this->unlock(); + return ['job' => $job, 'id' => $item['id']]; + } + + $this->unlock(); + return null; + + } catch (\Exception $e) { + $this->unlock(); + throw $e; + } + } + /** * Mark a job as completed * @@ -470,21 +542,36 @@ class JobQueue /** * Acquire lock for queue operations * - * @return void + * @return bool */ - protected function lock(): void + protected function lock(): bool { $attempts = 0; - while (file_exists($this->lockFile) && $attempts < 10) { - usleep(100000); // 100ms + $maxAttempts = 50; // 5 seconds total + + while ($attempts < $maxAttempts) { + // Check if lock file exists and is stale (older than 30 seconds) + if (file_exists($this->lockFile)) { + $lockAge = time() - filemtime($this->lockFile); + if ($lockAge > 30) { + // Stale lock, remove it + @unlink($this->lockFile); + } + } + + // Try to acquire lock atomically + $handle = @fopen($this->lockFile, 'x'); + if ($handle !== false) { + fclose($handle); + return true; + } + $attempts++; + usleep(100000); // 100ms } - if ($attempts >= 10) { - throw new RuntimeException('Could not acquire queue lock'); - } - - touch($this->lockFile); + // Could not acquire lock + return false; } /** diff --git a/system/src/Grav/Common/Scheduler/ModernScheduler.php b/system/src/Grav/Common/Scheduler/ModernScheduler.php index 9905524f7..4fa75b713 100644 --- a/system/src/Grav/Common/Scheduler/ModernScheduler.php +++ b/system/src/Grav/Common/Scheduler/ModernScheduler.php @@ -79,6 +79,51 @@ class ModernScheduler extends Scheduler return $this->modernConfig['enabled'] ?? false; } + /** + * Override to create ModernJob instances + * + * @param callable $fn + * @param array $args + * @param string|null $id + * @return ModernJob + */ + public function addFunction(callable $fn, $args = [], $id = null): Job + { + $job = new ModernJob($fn, $args, $id); + $this->queueJob($job->configure($this->config)); + + return $job; + } + + /** + * Override to create ModernJob instances + * + * @param string $command + * @param array $args + * @param string|null $id + * @return ModernJob + */ + public function addCommand($command, $args = [], $id = null): Job + { + $job = new ModernJob($command, $args, $id); + $this->queueJob($job->configure($this->config)); + + return $job; + } + + /** + * Override to create ModernJob instances + * + * @param string $command + * @param array $args + * @param string|null $id + * @return ModernJob + */ + public function raw($command, $args = [], $id = null): Job + { + return $this->addCommand($command, $args, $id); + } + /** * Initialize modern scheduler features * @@ -178,13 +223,13 @@ class ModernScheduler extends Scheduler */ protected function processQueuedJobs(): void { - $maxSize = $this->modernConfig['queue']['max_size'] ?? 1000; - + // Process existing queued jobs from previous runs while (!$this->jobQueue->isEmpty() && count($this->workers) < $this->maxWorkers) { $job = $this->jobQueue->pop(); if ($job) { $this->executeJob($job); + $this->jobs_run[] = $job; } } } @@ -196,7 +241,28 @@ class ModernScheduler extends Scheduler */ protected function processJobsWithWorkers(): void { - // Wait for all workers to complete + // Process all queued jobs + while (!$this->jobQueue->isEmpty()) { + // Wait if we've reached max workers + while (count($this->workers) >= $this->maxWorkers) { + foreach ($this->workers as $workerId => $process) { + if ($process instanceof Process && !$process->isRunning()) { + unset($this->workers[$workerId]); + } + } + if (count($this->workers) >= $this->maxWorkers) { + usleep(100000); // Wait 100ms + } + } + + // Get next job from queue + $job = $this->jobQueue->pop(); + if ($job) { + $this->executeJob($job); + } + } + + // Wait for all remaining workers to complete foreach ($this->workers as $workerId => $process) { if ($process instanceof Process) { $process->wait(); diff --git a/system/src/Grav/Common/Scheduler/Scheduler.php b/system/src/Grav/Common/Scheduler/Scheduler.php index dce3c6a25..abfb9def8 100644 --- a/system/src/Grav/Common/Scheduler/Scheduler.php +++ b/system/src/Grav/Common/Scheduler/Scheduler.php @@ -17,6 +17,7 @@ use InvalidArgumentException; use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; use RocketTheme\Toolbox\File\YamlFile; +use Symfony\Component\Yaml\Yaml; use function is_callable; use function is_string; @@ -49,19 +50,55 @@ class Scheduler /** @var string */ private $status_path; + + // Modern features (backward compatible - disabled by default) + /** @var JobQueue|null */ + protected $jobQueue = null; + + /** @var array */ + protected $workers = []; + + /** @var int */ + protected $maxWorkers = 1; + + /** @var bool */ + protected $webhookEnabled = false; + + /** @var string|null */ + protected $webhookToken = null; + + /** @var bool */ + protected $healthEnabled = true; + + /** @var string */ + protected $queuePath; + + /** @var string */ + protected $historyPath; + + /** @var array */ + protected $modernConfig = []; /** * Create new instance. */ public function __construct() { - $config = Grav::instance()['config']->get('scheduler.defaults', []); + $grav = Grav::instance(); + $config = $grav['config']->get('scheduler.defaults', []); $this->config = $config; - $this->status_path = Grav::instance()['locator']->findResource('user-data://scheduler', true, true); + $locator = $grav['locator']; + $this->status_path = $locator->findResource('user-data://scheduler', true, true); if (!file_exists($this->status_path)) { Folder::create($this->status_path); } + + // Initialize modern features if enabled + $this->modernConfig = $grav['config']->get('scheduler.modern', []); + if ($this->modernConfig['enabled'] ?? false) { + $this->initializeModernFeatures($locator); + } } /** @@ -121,6 +158,16 @@ class Scheduler return [$background, $foreground]; } + /** + * Get the job queue + * + * @return JobQueue|null + */ + public function getJobQueue(): ?JobQueue + { + return $this->jobQueue; + } + /** * Get all jobs if they are disabled or not as one array * @@ -199,22 +246,44 @@ class Scheduler $runTime = new DateTime('now'); } - // Star processing jobs - foreach ($alljobs as $job) { - if ($job->isDue($runTime) || $force) { - $job->run(); - $this->jobs_run[] = $job; + // Process jobs based on modern features + if ($this->jobQueue && ($this->modernConfig['queue']['enabled'] ?? false)) { + // Queue jobs for processing + foreach ($alljobs as $job) { + if ($job->isDue($runTime) || $force) { + // Add to queue for concurrent processing + $this->jobQueue->push($job); + } + } + + // Process queue with workers + $this->processJobsWithWorkers(); + + // When using queue, states are saved by executeJob when jobs complete + // Don't save states here as jobs may still be processing + } else { + // Legacy processing (one at a time) + foreach ($alljobs as $job) { + if ($job->isDue($runTime) || $force) { + $job->run(); + $this->jobs_run[] = $job; + } + } + + // Finish handling any background jobs + foreach ($background as $job) { + $job->finalize(); + } + + // Store states for legacy mode + $this->saveJobStates(); + + // Save history if enabled + if (($this->modernConfig['history']['enabled'] ?? false) && $this->historyPath) { + $this->saveJobHistory(); } } - // Finish handling any background jobs - foreach ($background as $job) { - $job->finalize(); - } - - // Store states - $this->saveJobStates(); - // Store run date file_put_contents("logs/lastcron.run", (new DateTime("now"))->format("Y-m-d H:i:s"), LOCK_EX); } @@ -378,6 +447,54 @@ class Scheduler } + /** + * Initialize modern features + * + * @param mixed $locator + * @return void + */ + protected function initializeModernFeatures($locator): void + { + // Set up paths + $this->queuePath = $this->modernConfig['queue']['path'] ?? 'user-data://scheduler/queue'; + $this->queuePath = $locator->findResource($this->queuePath, true, true); + + $this->historyPath = $this->modernConfig['history']['path'] ?? 'user-data://scheduler/history'; + $this->historyPath = $locator->findResource($this->historyPath, true, true); + + // Create directories if they don't exist + if (!file_exists($this->queuePath)) { + Folder::create($this->queuePath); + } + + if (!file_exists($this->historyPath)) { + Folder::create($this->historyPath); + } + + // Initialize job queue + $this->jobQueue = new JobQueue($this->queuePath); + + // Configure workers + $this->maxWorkers = $this->modernConfig['workers'] ?? 1; + + // Configure webhook + $this->webhookEnabled = $this->modernConfig['webhook']['enabled'] ?? false; + $this->webhookToken = $this->modernConfig['webhook']['token'] ?? null; + + // Configure health check + $this->healthEnabled = $this->modernConfig['health']['enabled'] ?? true; + } + + /** + * Get the job queue + * + * @return JobQueue|null + */ + public function getQueue(): ?JobQueue + { + return $this->jobQueue; + } + /** * Check if webhook is enabled * @@ -385,9 +502,7 @@ class Scheduler */ public function isWebhookEnabled(): bool { - // Check config for webhook settings even in base scheduler - $config = Grav::instance()['config']; - return $config->get('scheduler.modern.webhook.enabled', false); + return $this->webhookEnabled; } /** @@ -478,4 +593,335 @@ class Scheduler return $job; } + + /** + * Process jobs using multiple workers + * + * @return void + */ + protected function processJobsWithWorkers(): void + { + if (!$this->jobQueue) { + return; + } + + // Process all queued jobs + while (!$this->jobQueue->isEmpty()) { + // Wait if we've reached max workers + while (count($this->workers) >= $this->maxWorkers) { + foreach ($this->workers as $workerId => $worker) { + $process = null; + if (is_array($worker) && isset($worker['process'])) { + $process = $worker['process']; + } elseif ($worker instanceof Process) { + $process = $worker; + } + + if ($process instanceof Process && !$process->isRunning()) { + // Finalize job if needed + if (is_array($worker) && isset($worker['job'])) { + $worker['job']->finalize(); + + // Save job state + $this->saveJobState($worker['job']); + + // Update queue status + if (isset($worker['queueId']) && $this->jobQueue) { + if ($worker['job']->isSuccessful()) { + $this->jobQueue->complete($worker['queueId']); + } else { + $this->jobQueue->fail($worker['queueId'], $worker['job']->getOutput() ?: 'Job failed'); + } + } + } + unset($this->workers[$workerId]); + } + } + if (count($this->workers) >= $this->maxWorkers) { + usleep(100000); // Wait 100ms + } + } + + // Get next job from queue + $queueItem = $this->jobQueue->popWithId(); + if ($queueItem) { + $this->executeJob($queueItem['job'], $queueItem['id']); + } + } + + // Wait for all remaining workers to complete + foreach ($this->workers as $workerId => $worker) { + if (is_array($worker) && isset($worker['process'])) { + $process = $worker['process']; + if ($process instanceof Process) { + $process->wait(); + + // Finalize and save state for background jobs + if (isset($worker['job'])) { + $worker['job']->finalize(); + $this->saveJobState($worker['job']); + } + + // Update queue status for background jobs + if (isset($worker['queueId']) && $this->jobQueue) { + $job = $worker['job']; + if ($job->isSuccessful()) { + $this->jobQueue->complete($worker['queueId']); + } else { + $this->jobQueue->fail($worker['queueId'], $job->getOutput() ?: 'Job execution failed'); + } + } + + unset($this->workers[$workerId]); + } + } elseif ($worker instanceof Process) { + // Legacy format + $worker->wait(); + unset($this->workers[$workerId]); + } + } + } + + /** + * Process existing queued jobs + * + * @return void + */ + protected function processQueuedJobs(): void + { + if (!$this->jobQueue) { + return; + } + + // Process any existing queued jobs from previous runs + while (!$this->jobQueue->isEmpty() && count($this->workers) < $this->maxWorkers) { + $job = $this->jobQueue->pop(); + if ($job) { + $this->executeJob($job); + } + } + } + + /** + * Execute a job + * + * @param Job $job + * @param string|null $queueId Queue ID if job came from queue + * @return void + */ + protected function executeJob(Job $job, ?string $queueId = null): void + { + $job->run(); + $this->jobs_run[] = $job; + + // Save job state after execution + $this->saveJobState($job); + + // Check if job runs in background + if ($job->runInBackground()) { + // Background job - track it for later completion + $process = $job->getProcess(); + if ($process && $process->isStarted()) { + $this->workers[] = [ + 'process' => $process, + 'job' => $job, + 'queueId' => $queueId + ]; + // Don't update queue status yet - will be done when process completes + return; + } + } + + // Foreground job or background job that didn't start - update queue status immediately + if ($queueId && $this->jobQueue) { + // Job has already been finalized if it ran in foreground + if (!$job->runInBackground()) { + $job->finalize(); + } + + if ($job->isSuccessful()) { + // Move from processing to completed + $this->jobQueue->complete($queueId); + } else { + // Move from processing to failed + $this->jobQueue->fail($queueId, $job->getOutput() ?: 'Job execution failed'); + } + } + } + + /** + * Save state for a single job + * + * @param Job $job + * @return void + */ + protected function saveJobState(Job $job): void + { + $grav = Grav::instance(); + $locator = $grav['locator']; + $statusFile = $locator->findResource('user-data://scheduler/status.yaml', true, true); + + $status = []; + if (file_exists($statusFile)) { + $status = Yaml::parseFile($statusFile) ?: []; + } + + // Update job status + $status[$job->getId()] = [ + 'state' => $job->isSuccessful() ? 'success' : 'failure', + 'last-run' => time(), + ]; + + // Add error if job failed + if (!$job->isSuccessful()) { + $output = $job->getOutput(); + if ($output) { + $status[$job->getId()]['error'] = $output; + } else { + $status[$job->getId()]['error'] = null; + } + } + + file_put_contents($statusFile, Yaml::dump($status)); + } + + /** + * Save job execution history + * + * @return void + */ + protected function saveJobHistory(): void + { + if (!$this->historyPath) { + return; + } + + $history = []; + foreach ($this->jobs_run as $job) { + $history[] = [ + 'id' => $job->getId(), + 'executed_at' => date('c'), + 'success' => $job->isSuccessful(), + 'output' => substr($job->getOutput(), 0, 1000), + ]; + } + + if (!empty($history)) { + $filename = $this->historyPath . '/' . date('Y-m-d') . '.json'; + $existing = file_exists($filename) ? json_decode(file_get_contents($filename), true) : []; + $existing = array_merge($existing, $history); + file_put_contents($filename, json_encode($existing, JSON_PRETTY_PRINT)); + } + } + + /** + * Update last run timestamp + * + * @return void + */ + protected function updateLastRun(): void + { + $lastRunFile = $this->status_path . '/last_run.txt'; + file_put_contents($lastRunFile, date('Y-m-d H:i:s')); + } + + /** + * Get health status + * + * @return array + */ + public function getHealthStatus(): array + { + $lastRunFile = $this->status_path . '/last_run.txt'; + $lastRun = file_exists($lastRunFile) ? file_get_contents($lastRunFile) : null; + + $health = [ + 'status' => 'healthy', + 'last_run' => $lastRun, + 'last_run_age' => null, + 'queue_size' => 0, + 'failed_jobs_24h' => 0, + 'scheduled_jobs' => count($this->getAllJobs()), + 'modern_features' => $this->modernConfig['enabled'] ?? false, + 'webhook_enabled' => $this->webhookEnabled, + 'health_check_enabled' => $this->healthEnabled, + 'timestamp' => date('c'), + ]; + + // Calculate last run age + if ($lastRun) { + $lastRunTime = new DateTime($lastRun); + $now = new DateTime('now'); + $health['last_run_age'] = $now->getTimestamp() - $lastRunTime->getTimestamp(); + + // Determine status based on age + if ($health['last_run_age'] < 600) { // Less than 10 minutes + $health['status'] = 'healthy'; + } elseif ($health['last_run_age'] < 3600) { // Less than 1 hour + $health['status'] = 'warning'; + } else { + $health['status'] = 'critical'; + } + } else { + $health['status'] = 'unknown'; + } + + // Add queue stats if available + if ($this->jobQueue) { + $stats = $this->jobQueue->getStatistics(); + $health['queue_size'] = $stats['pending'] ?? 0; + $health['failed_jobs_24h'] = $stats['failed'] ?? 0; + } + + return $health; + } + + /** + * Process webhook trigger + * + * @param string|null $token + * @param string|null $jobId + * @return array + */ + public function processWebhookTrigger($token = null, $jobId = null): array + { + if (!$this->webhookEnabled) { + return ['success' => false, 'message' => 'Webhook triggers are not enabled']; + } + + if ($this->webhookToken && $token !== $this->webhookToken) { + return ['success' => false, 'message' => 'Invalid webhook token']; + } + + if ($jobId) { + // Force run specific job + $job = $this->getJob($jobId); + if ($job) { + $job->inForeground()->run(); + $this->jobs_run[] = $job; + $this->saveJobStates(); + $this->updateLastRun(); + + return [ + 'success' => $job->isSuccessful(), + 'message' => $job->isSuccessful() ? 'Job force-executed successfully' : 'Job execution failed', + 'job_id' => $jobId, + 'forced' => true, + 'output' => $job->getOutput(), + ]; + } else { + return ['success' => false, 'message' => 'Job not found: ' . $jobId]; + } + } else { + // Run all due jobs + $this->run(); + + return [ + 'success' => true, + 'message' => 'Scheduler executed (due jobs only)', + 'jobs_run' => count($this->jobs_run), + 'timestamp' => date('c'), + ]; + } + } } diff --git a/system/src/Grav/Common/Service/SchedulerServiceProvider.php b/system/src/Grav/Common/Service/SchedulerServiceProvider.php index f854dbebf..8b2b04bab 100644 --- a/system/src/Grav/Common/Service/SchedulerServiceProvider.php +++ b/system/src/Grav/Common/Service/SchedulerServiceProvider.php @@ -10,7 +10,8 @@ namespace Grav\Common\Service; use Grav\Common\Scheduler\Scheduler; -use Grav\Common\Scheduler\ModernScheduler; +use Grav\Common\Scheduler\JobQueue; +use Grav\Common\Scheduler\JobWorker; use Pimple\Container; use Pimple\ServiceProviderInterface; @@ -28,15 +29,36 @@ class SchedulerServiceProvider implements ServiceProviderInterface { $container['scheduler'] = function ($c) { $config = $c['config']; + $scheduler = new Scheduler(); - // Use ModernScheduler if modern features are enabled - $modernEnabled = $config->get('scheduler.modern.enabled', false); - - if ($modernEnabled) { - return new ModernScheduler(); + // Configure modern features if enabled + $modernConfig = $config->get('scheduler.modern', []); + if ($modernConfig['enabled'] ?? false) { + // Initialize components + $queuePath = $c['locator']->findResource('user-data://scheduler/queue', true, true); + $statusPath = $c['locator']->findResource('user-data://scheduler/status.yaml', true, true); + + // Set modern configuration on scheduler + $scheduler->setModernConfig($modernConfig); + + // Initialize job queue if enabled + if ($modernConfig['queue']['enabled'] ?? false) { + $jobQueue = new JobQueue($queuePath); + $scheduler->setJobQueue($jobQueue); + } + + // Initialize workers if enabled + if ($modernConfig['workers']['enabled'] ?? false) { + $workerCount = $modernConfig['workers']['count'] ?? 2; + $workers = []; + for ($i = 0; $i < $workerCount; $i++) { + $workers[] = new JobWorker("worker-{$i}"); + } + $scheduler->setWorkers($workers); + } } - return new Scheduler(); + return $scheduler; }; } } From a0679fc050bd786d89f9ff68d1bea10220a8b05c Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 24 Aug 2025 22:23:18 +0100 Subject: [PATCH 3/7] scheduler improvements Signed-off-by: Andy Miller --- .../src/Grav/Common/Scheduler/ModernJob.php | 545 -------------- .../Grav/Common/Scheduler/ModernScheduler.php | 687 ------------------ .../src/Grav/Common/Scheduler/Scheduler.php | 32 + 3 files changed, 32 insertions(+), 1232 deletions(-) delete mode 100644 system/src/Grav/Common/Scheduler/ModernJob.php delete mode 100644 system/src/Grav/Common/Scheduler/ModernScheduler.php diff --git a/system/src/Grav/Common/Scheduler/ModernJob.php b/system/src/Grav/Common/Scheduler/ModernJob.php deleted file mode 100644 index 1bbe81bb3..000000000 --- a/system/src/Grav/Common/Scheduler/ModernJob.php +++ /dev/null @@ -1,545 +0,0 @@ -maxAttempts = $attempts; - return $this; - } - - /** - * Get maximum retry attempts - * - * @return int - */ - public function getMaxAttempts(): int - { - return $this->maxAttempts; - } - - /** - * Set retry delay - * - * @param int $seconds - * @param string $strategy 'linear' or 'exponential' - * @return self - */ - public function retryDelay(int $seconds, string $strategy = 'exponential'): self - { - $this->retryDelay = $seconds; - $this->retryStrategy = $strategy; - return $this; - } - - /** - * Get current retry count - * - * @return int - */ - public function getRetryCount(): int - { - return $this->retryCount; - } - - /** - * Set job timeout - * - * @param int $seconds - * @return self - */ - public function timeout(int $seconds): self - { - $this->timeout = $seconds; - return $this; - } - - /** - * Set job priority - * - * @param string $priority 'high', 'normal', or 'low' - * @return self - */ - public function priority(string $priority): self - { - if (!in_array($priority, ['high', 'normal', 'low'])) { - throw new \InvalidArgumentException('Priority must be high, normal, or low'); - } - $this->priority = $priority; - return $this; - } - - /** - * Get job priority - * - * @return string - */ - public function getPriority(): string - { - return $this->priority; - } - - /** - * Add job dependency - * - * @param string $jobId - * @return self - */ - public function dependsOn(string $jobId): self - { - $this->dependencies[] = $jobId; - return $this; - } - - /** - * Chain another job to run after this one - * - * @param Job $job - * @param bool $onlyOnSuccess Run only if current job succeeds - * @return self - */ - public function chain(Job $job, bool $onlyOnSuccess = true): self - { - $this->chainedJobs[] = [ - 'job' => $job, - 'onlyOnSuccess' => $onlyOnSuccess, - ]; - return $this; - } - - /** - * Add metadata to the job - * - * @param string $key - * @param mixed $value - * @return self - */ - public function withMetadata(string $key, $value): self - { - $this->metadata[$key] = $value; - return $this; - } - - /** - * Add tags to the job - * - * @param array $tags - * @return self - */ - public function withTags(array $tags): self - { - $this->tags = array_merge($this->tags, $tags); - return $this; - } - - /** - * Set success callback - * - * @param callable $callback - * @return self - */ - public function onSuccess(callable $callback): self - { - $this->onSuccess = $callback; - return $this; - } - - /** - * Set failure callback - * - * @param callable $callback - * @return self - */ - public function onFailure(callable $callback): self - { - $this->onFailure = $callback; - return $this; - } - - /** - * Set retry callback - * - * @param callable $callback - * @return self - */ - public function onRetry(callable $callback): self - { - $this->onRetry = $callback; - return $this; - } - - /** - * Run the job with retry support - * - * @return bool - */ - public function runWithRetry(): bool - { - $attempts = 0; - $lastException = null; - - while ($attempts < $this->maxAttempts) { - $attempts++; - $this->retryCount = $attempts - 1; - - try { - // Record execution start time - $this->executionStartTime = microtime(true); - - // Run the job - $result = $this->run(); - - // Record execution time - $this->executionTime = microtime(true) - $this->executionStartTime; - - if ($result && $this->isSuccessful()) { - // Call success callback - if ($this->onSuccess) { - call_user_func($this->onSuccess, $this); - } - - // Run chained jobs - $this->runChainedJobs(true); - - return true; - } - - throw new RuntimeException('Job execution failed'); - - } catch (Exception $e) { - $lastException = $e; - $this->output = $e->getMessage(); - $this->successful = false; - - if ($attempts < $this->maxAttempts) { - // Call retry callback - if ($this->onRetry) { - call_user_func($this->onRetry, $this, $attempts, $e); - } - - // Calculate delay before retry - $delay = $this->calculateRetryDelay($attempts); - if ($delay > 0) { - sleep($delay); - } - } else { - // Final failure - if ($this->onFailure) { - call_user_func($this->onFailure, $this, $e); - } - - // Run chained jobs that should run on failure - $this->runChainedJobs(false); - } - } - } - - return false; - } - - /** - * Override parent run method to add timeout support - * - * @return bool - */ - public function run(): bool - { - // Check dependencies - if (!$this->checkDependencies()) { - $this->output = 'Dependencies not met'; - $this->successful = false; - return false; - } - - // Call parent run method - $result = parent::run(); - - // Apply timeout to process if applicable - if ($this->process instanceof Process && $this->timeout > 0) { - $this->process->setTimeout($this->timeout); - } - - return $result; - } - - /** - * Get execution time in seconds - * - * @return float - */ - public function getExecutionTime(): float - { - return $this->executionTime; - } - - /** - * Get job metadata - * - * @param string|null $key - * @return mixed - */ - public function getMetadata(string $key = null) - { - if ($key === null) { - return $this->metadata; - } - - return $this->metadata[$key] ?? null; - } - - /** - * Get job tags - * - * @return array - */ - public function getTags(): array - { - return $this->tags; - } - - /** - * Check if job has a specific tag - * - * @param string $tag - * @return bool - */ - public function hasTag(string $tag): bool - { - return in_array($tag, $this->tags); - } - - /** - * Set queue ID - * - * @param string $queueId - * @return self - */ - public function setQueueId(string $queueId): self - { - $this->queueId = $queueId; - return $this; - } - - /** - * Get queue ID - * - * @return string|null - */ - public function getQueueId(): ?string - { - return $this->queueId; - } - - /** - * Get process (for background jobs) - * - * @return Process|null - */ - public function getProcess(): ?Process - { - return $this->process; - } - - /** - * Calculate retry delay based on strategy - * - * @param int $attempt - * @return int - */ - protected function calculateRetryDelay(int $attempt): int - { - if ($this->retryStrategy === 'exponential') { - return min($this->retryDelay * pow(2, $attempt - 1), 3600); // Max 1 hour - } - - return $this->retryDelay; - } - - /** - * Check if dependencies are met - * - * @return bool - */ - protected function checkDependencies(): bool - { - if (empty($this->dependencies)) { - return true; - } - - // This would need to check against job history or status - // For now, we'll assume dependencies are met - // In a real implementation, this would check the ModernScheduler's job status - return true; - } - - /** - * Run chained jobs - * - * @param bool $success Whether the current job succeeded - * @return void - */ - protected function runChainedJobs(bool $success): void - { - foreach ($this->chainedJobs as $chainedJob) { - $shouldRun = !$chainedJob['onlyOnSuccess'] || $success; - - if ($shouldRun) { - $job = $chainedJob['job']; - if ($job instanceof ModernJob) { - $job->runWithRetry(); - } else { - $job->run(); - } - } - } - } - - /** - * Convert job to array for serialization - * - * @return array - */ - public function toArray(): array - { - return [ - 'id' => $this->getId(), - 'command' => is_string($this->command) ? $this->command : 'Closure', - 'at' => $this->getAt(), - 'enabled' => $this->getEnabled(), - 'priority' => $this->priority, - 'max_attempts' => $this->maxAttempts, - 'retry_count' => $this->retryCount, - 'retry_delay' => $this->retryDelay, - 'retry_strategy' => $this->retryStrategy, - 'timeout' => $this->timeout, - 'dependencies' => $this->dependencies, - 'metadata' => $this->metadata, - 'tags' => $this->tags, - 'execution_time' => $this->executionTime, - 'successful' => $this->successful, - 'output' => $this->output, - ]; - } - - /** - * Create job from array - * - * @param array $data - * @return self - */ - public static function fromArray(array $data): self - { - $job = new self($data['command'] ?? '', [], $data['id'] ?? null); - - if (isset($data['at'])) { - $job->at($data['at']); - } - - if (isset($data['priority'])) { - $job->priority($data['priority']); - } - - if (isset($data['max_attempts'])) { - $job->maxAttempts($data['max_attempts']); - } - - if (isset($data['retry_delay']) && isset($data['retry_strategy'])) { - $job->retryDelay($data['retry_delay'], $data['retry_strategy']); - } - - if (isset($data['timeout'])) { - $job->timeout($data['timeout']); - } - - if (isset($data['dependencies'])) { - foreach ($data['dependencies'] as $dep) { - $job->dependsOn($dep); - } - } - - if (isset($data['metadata'])) { - foreach ($data['metadata'] as $key => $value) { - $job->withMetadata($key, $value); - } - } - - if (isset($data['tags'])) { - $job->withTags($data['tags']); - } - - return $job; - } -} \ No newline at end of file diff --git a/system/src/Grav/Common/Scheduler/ModernScheduler.php b/system/src/Grav/Common/Scheduler/ModernScheduler.php deleted file mode 100644 index 4fa75b713..000000000 --- a/system/src/Grav/Common/Scheduler/ModernScheduler.php +++ /dev/null @@ -1,687 +0,0 @@ -modernConfig = $grav['config']->get('scheduler.modern', []); - - // Set up modern features if enabled - if ($this->isModernEnabled()) { - $this->initializeModernFeatures(); - } - } - - /** - * Check if modern features are enabled - * - * @return bool - */ - public function isModernEnabled(): bool - { - return $this->modernConfig['enabled'] ?? false; - } - - /** - * Override to create ModernJob instances - * - * @param callable $fn - * @param array $args - * @param string|null $id - * @return ModernJob - */ - public function addFunction(callable $fn, $args = [], $id = null): Job - { - $job = new ModernJob($fn, $args, $id); - $this->queueJob($job->configure($this->config)); - - return $job; - } - - /** - * Override to create ModernJob instances - * - * @param string $command - * @param array $args - * @param string|null $id - * @return ModernJob - */ - public function addCommand($command, $args = [], $id = null): Job - { - $job = new ModernJob($command, $args, $id); - $this->queueJob($job->configure($this->config)); - - return $job; - } - - /** - * Override to create ModernJob instances - * - * @param string $command - * @param array $args - * @param string|null $id - * @return ModernJob - */ - public function raw($command, $args = [], $id = null): Job - { - return $this->addCommand($command, $args, $id); - } - - /** - * Initialize modern scheduler features - * - * @return void - */ - protected function initializeModernFeatures(): void - { - $locator = Grav::instance()['locator']; - - // Set up paths - $this->queuePath = $this->modernConfig['queue']['path'] ?? 'user-data://scheduler/queue'; - $this->queuePath = $locator->findResource($this->queuePath, true, true); - - $this->historyPath = $this->modernConfig['history']['path'] ?? 'user-data://scheduler/history'; - $this->historyPath = $locator->findResource($this->historyPath, true, true); - - // Create directories if they don't exist - if (!file_exists($this->queuePath)) { - Folder::create($this->queuePath); - } - - if (!file_exists($this->historyPath)) { - Folder::create($this->historyPath); - } - - // Initialize job queue - $this->jobQueue = new JobQueue($this->queuePath); - - // Configure workers - $this->maxWorkers = $this->modernConfig['workers'] ?? 1; - - // Configure webhook - $this->webhookEnabled = $this->modernConfig['webhook']['enabled'] ?? false; - $this->webhookToken = $this->modernConfig['webhook']['token'] ?? null; - - // Configure health check - $this->healthEnabled = $this->modernConfig['health']['enabled'] ?? true; - } - - /** - * Enhanced run method with modern features - * - * @param DateTime|null $runTime - * @param bool $force - * @return void - */ - public function run(DateTime $runTime = null, $force = false): void - { - if (!$this->isModernEnabled()) { - // Fall back to parent implementation - parent::run($runTime, $force); - return; - } - - $this->loadSavedJobs(); - - if (null === $runTime) { - $runTime = new DateTime('now'); - } - - // Process queued jobs first - $this->processQueuedJobs(); - - // Get scheduled jobs - [$background, $foreground] = $this->getQueuedJobs(false); - $alljobs = array_merge($background, $foreground); - - // Check which jobs are due and add them to the queue - foreach ($alljobs as $job) { - if ($job->isDue($runTime) || $force) { - if ($job instanceof ModernJob) { - // Add to queue for processing - $this->jobQueue->push($job); - } else { - // Run legacy jobs directly - $job->run(); - $this->jobs_run[] = $job; - } - } - } - - // Process jobs with workers - $this->processJobsWithWorkers(); - - // Store states and history - $this->saveJobStates(); - $this->saveJobHistory(); - - // Update last run timestamp - $this->updateLastRun(); - } - - /** - * Process jobs from the queue - * - * @return void - */ - protected function processQueuedJobs(): void - { - // Process existing queued jobs from previous runs - while (!$this->jobQueue->isEmpty() && count($this->workers) < $this->maxWorkers) { - $job = $this->jobQueue->pop(); - - if ($job) { - $this->executeJob($job); - $this->jobs_run[] = $job; - } - } - } - - /** - * Process jobs using multiple workers - * - * @return void - */ - protected function processJobsWithWorkers(): void - { - // Process all queued jobs - while (!$this->jobQueue->isEmpty()) { - // Wait if we've reached max workers - while (count($this->workers) >= $this->maxWorkers) { - foreach ($this->workers as $workerId => $process) { - if ($process instanceof Process && !$process->isRunning()) { - unset($this->workers[$workerId]); - } - } - if (count($this->workers) >= $this->maxWorkers) { - usleep(100000); // Wait 100ms - } - } - - // Get next job from queue - $job = $this->jobQueue->pop(); - if ($job) { - $this->executeJob($job); - } - } - - // Wait for all remaining workers to complete - foreach ($this->workers as $workerId => $process) { - if ($process instanceof Process) { - $process->wait(); - unset($this->workers[$workerId]); - } - } - } - - /** - * Execute a job with retry support - * - * @param Job $job - * @return void - */ - protected function executeJob(Job $job): void - { - if ($job instanceof ModernJob) { - // Use modern job execution with retry - $job->runWithRetry(); - } else { - // Use standard job execution - $job->run(); - } - - $this->jobs_run[] = $job; - - // Handle background jobs - if ($job->runInBackground() && $this->maxWorkers > 1) { - $process = $job->getProcess(); - if ($process) { - $this->workers[] = $process; - } - } - } - - /** - * Save job execution history - * - * @return void - */ - protected function saveJobHistory(): void - { - if (!$this->modernConfig['history']['enabled'] ?? true) { - return; - } - - $now = new DateTime('now'); - $historyFile = $this->historyPath . '/' . $now->format('Y-m-d') . '.json'; - - $history = []; - if (file_exists($historyFile)) { - $file = JsonFile::instance($historyFile); - $history = $file->content(); - } else { - $file = JsonFile::instance($historyFile); - } - - foreach ($this->jobs_run as $job) { - $entry = [ - 'job_id' => $job->getId(), - 'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure', - 'timestamp' => $now->format('c'), - 'success' => $job->isSuccessful(), - 'duration' => $job instanceof ModernJob ? $job->getExecutionTime() : null, - 'output' => substr($job->getOutput(), 0, 1000), // Limit output size - 'retry_count' => $job instanceof ModernJob ? $job->getRetryCount() : 0, - ]; - - $history[] = $entry; - } - - $file->save($history); - - // Clean up old history files - $this->cleanupHistory(); - } - - /** - * Clean up old history files - * - * @return void - */ - protected function cleanupHistory(): void - { - $retentionDays = $this->modernConfig['history']['retention_days'] ?? 30; - $cutoffDate = new DateTime("-{$retentionDays} days"); - - $files = glob($this->historyPath . '/*.json'); - foreach ($files as $file) { - $filename = basename($file, '.json'); - $fileDate = DateTime::createFromFormat('Y-m-d', $filename); - - if ($fileDate && $fileDate < $cutoffDate) { - unlink($file); - } - } - } - - /** - * Update last run timestamp - * - * @return void - */ - protected function updateLastRun(): void - { - $lastRunFile = $this->status_path . '/last_run.txt'; - file_put_contents($lastRunFile, (new DateTime('now'))->format('c'), LOCK_EX); - - // Also update the legacy location for backward compatibility - file_put_contents('logs/lastcron.run', (new DateTime('now'))->format('Y-m-d H:i:s'), LOCK_EX); - } - - /** - * Check scheduler health - * - * @return array - */ - public function getHealthStatus(): array - { - $lastRunFile = $this->status_path . '/last_run.txt'; - $lastRun = file_exists($lastRunFile) ? file_get_contents($lastRunFile) : null; - - $health = [ - 'status' => 'healthy', - 'last_run' => $lastRun, - 'last_run_age' => null, - 'queue_size' => 0, - 'failed_jobs_24h' => 0, - 'scheduled_jobs' => count($this->getAllJobs()), - 'modern_features' => $this->isModernEnabled(), - 'workers' => $this->maxWorkers, - 'trigger_methods' => $this->getActiveTriggers(), - ]; - - if ($lastRun) { - $lastRunTime = new DateTime($lastRun); - $now = new DateTime('now'); - $diff = $now->getTimestamp() - $lastRunTime->getTimestamp(); - $health['last_run_age'] = $diff; - - // Mark as unhealthy if no run in last 10 minutes - if ($diff > 600) { - $health['status'] = 'warning'; - } - - // Mark as critical if no run in last hour - if ($diff > 3600) { - $health['status'] = 'critical'; - } - } else { - $health['status'] = 'unknown'; - } - - // Get queue size if modern features enabled - if ($this->isModernEnabled() && $this->jobQueue) { - $health['queue_size'] = $this->jobQueue->size(); - } - - // Count failed jobs in last 24 hours - $health['failed_jobs_24h'] = $this->countRecentFailures(); - - return $health; - } - - /** - * Check if webhook is enabled - * - * @return bool - */ - public function isWebhookEnabled(): bool - { - return $this->webhookEnabled; - } - - /** - * Get active trigger methods - * - * @return array - */ - public function getActiveTriggers(): array - { - $triggers = []; - - // Check cron - $cronStatus = $this->isCrontabSetup(); - if ($cronStatus === 1) { - $triggers[] = 'cron'; - } - - // Check systemd timer - if ($this->isSystemdTimerActive()) { - $triggers[] = 'systemd'; - } - - // Check webhook - if ($this->webhookEnabled) { - $triggers[] = 'webhook'; - } - - // Check for external triggers - $lastRunFile = $this->status_path . '/last_run.txt'; - if (file_exists($lastRunFile)) { - $lastRun = file_get_contents($lastRunFile); - $lastRunTime = new DateTime($lastRun); - $now = new DateTime('now'); - $diff = $now->getTimestamp() - $lastRunTime->getTimestamp(); - - if ($diff < 120) { - $triggers[] = 'external'; - } - } - - return $triggers; - } - - /** - * Check if systemd timer is active - * - * @return bool - */ - protected function isSystemdTimerActive(): bool - { - if (Utils::isWindows()) { - return false; - } - - $process = new Process(['systemctl', 'is-active', 'grav-scheduler.timer']); - $process->run(); - - return $process->isSuccessful() && trim($process->getOutput()) === 'active'; - } - - /** - * Count recent job failures - * - * @return int - */ - protected function countRecentFailures(): int - { - $count = 0; - $cutoff = new DateTime('-24 hours'); - - // Check today's history - $todayFile = $this->historyPath . '/' . date('Y-m-d') . '.json'; - if (file_exists($todayFile)) { - $file = JsonFile::instance($todayFile); - $history = $file->content(); - - foreach ($history as $entry) { - $entryTime = new DateTime($entry['timestamp']); - if ($entryTime > $cutoff && !$entry['success']) { - $count++; - } - } - } - - // Check yesterday's history - $yesterdayFile = $this->historyPath . '/' . $cutoff->format('Y-m-d') . '.json'; - if (file_exists($yesterdayFile)) { - $file = JsonFile::instance($yesterdayFile); - $history = $file->content(); - - foreach ($history as $entry) { - $entryTime = new DateTime($entry['timestamp']); - if ($entryTime > $cutoff && !$entry['success']) { - $count++; - } - } - } - - return $count; - } - - /** - * Process webhook trigger - * - * @param string|null $token - * @param string|null $jobId - * @return array - */ - public function processWebhookTrigger($token = null, $jobId = null): array - { - if (!$this->webhookEnabled) { - return ['success' => false, 'message' => 'Webhook triggers are not enabled']; - } - - if ($this->webhookToken && $token !== $this->webhookToken) { - return ['success' => false, 'message' => 'Invalid webhook token']; - } - - if ($jobId) { - // Force run specific job (manual override - ignore schedule) - $job = $this->getJob($jobId); - if ($job) { - // Force run in foreground to get immediate result - $job->inForeground()->run(); - - // Track as manually executed - $this->jobs_run[] = $job; - $this->saveJobStates(); - $this->updateLastRun(); - - return [ - 'success' => $job->isSuccessful(), - 'message' => $job->isSuccessful() ? 'Job force-executed successfully' : 'Job execution failed', - 'job_id' => $jobId, - 'forced' => true, - 'output' => $job->getOutput(), - ]; - } else { - return ['success' => false, 'message' => 'Job not found: ' . $jobId]; - } - } else { - // Run all due jobs normally - $this->run(); - return [ - 'success' => true, - 'message' => 'Scheduler executed (due jobs only)', - 'jobs_run' => count($this->jobs_run), - 'timestamp' => date('c'), - ]; - } - } - - /** - * Get scheduler statistics - * - * @return array - */ - public function getStatistics(): array - { - $stats = [ - 'total_jobs' => count($this->getAllJobs()), - 'enabled_jobs' => 0, - 'disabled_jobs' => 0, - 'executions_today' => 0, - 'failures_today' => 0, - 'average_execution_time' => 0, - 'queue_size' => 0, - ]; - - // Count enabled/disabled jobs - foreach ($this->getAllJobs() as $job) { - if ($job->getEnabled()) { - $stats['enabled_jobs']++; - } else { - $stats['disabled_jobs']++; - } - } - - // Get today's statistics - $todayFile = $this->historyPath . '/' . date('Y-m-d') . '.json'; - if (file_exists($todayFile)) { - $file = JsonFile::instance($todayFile); - $history = $file->content(); - - $totalTime = 0; - $timeCount = 0; - - foreach ($history as $entry) { - $stats['executions_today']++; - - if (!$entry['success']) { - $stats['failures_today']++; - } - - if (isset($entry['duration']) && $entry['duration'] > 0) { - $totalTime += $entry['duration']; - $timeCount++; - } - } - - if ($timeCount > 0) { - $stats['average_execution_time'] = round($totalTime / $timeCount, 2); - } - } - - // Get queue size - if ($this->isModernEnabled() && $this->jobQueue) { - $stats['queue_size'] = $this->jobQueue->size(); - } - - return $stats; - } - - /** - * Run scheduler in daemon mode - * - * @param int $interval Check interval in seconds (default: 60) - * @return void - */ - public function runDaemon($interval = 60): void - { - if (!$this->isModernEnabled()) { - throw new RuntimeException('Daemon mode requires modern features to be enabled'); - } - - $lastRun = 0; - - while (true) { - $now = time(); - - // Run scheduler every minute - if ($now - $lastRun >= $interval) { - $this->run(); - $lastRun = $now; - } - - // Process any queued jobs - $this->processQueuedJobs(); - - // Sleep for a short interval - sleep(5); - - // Check for shutdown signal - if (file_exists($this->status_path . '/shutdown')) { - unlink($this->status_path . '/shutdown'); - break; - } - } - } -} \ No newline at end of file diff --git a/system/src/Grav/Common/Scheduler/Scheduler.php b/system/src/Grav/Common/Scheduler/Scheduler.php index abfb9def8..410bfec1f 100644 --- a/system/src/Grav/Common/Scheduler/Scheduler.php +++ b/system/src/Grav/Common/Scheduler/Scheduler.php @@ -76,6 +76,9 @@ class Scheduler /** @var string */ protected $historyPath; + /** @var JobHistory|null */ + protected $jobHistory = null; + /** @var array */ protected $modernConfig = []; @@ -474,6 +477,10 @@ class Scheduler // Initialize job queue $this->jobQueue = new JobQueue($this->queuePath); + // Initialize job history + $retentionDays = $this->modernConfig['history']['retention_days'] ?? 30; + $this->jobHistory = new JobHistory($this->historyPath, $retentionDays); + // Configure workers $this->maxWorkers = $this->modernConfig['workers'] ?? 1; @@ -495,6 +502,16 @@ class Scheduler return $this->jobQueue; } + /** + * Get the job history + * + * @return JobHistory|null + */ + public function getHistory(): ?JobHistory + { + return $this->jobHistory; + } + /** * Check if webhook is enabled * @@ -660,6 +677,15 @@ class Scheduler if (isset($worker['job'])) { $worker['job']->finalize(); $this->saveJobState($worker['job']); + + // Log background job completion to history + if ($this->jobHistory) { + $metadata = [ + 'queue_id' => $worker['queueId'] ?? null, + 'background' => true + ]; + $this->jobHistory->logExecution($worker['job'], $metadata); + } } // Update queue status for background jobs @@ -747,6 +773,12 @@ class Scheduler $this->jobQueue->fail($queueId, $job->getOutput() ?: 'Job execution failed'); } } + + // Log foreground jobs immediately + if (!$job->runInBackground() && $this->jobHistory) { + $metadata = ['queue_id' => $queueId]; + $this->jobHistory->logExecution($job, $metadata); + } } /** From b851d9bf9dd3ede044aeaa78f12097c94386be0f Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 24 Aug 2025 23:58:28 +0100 Subject: [PATCH 4/7] added scheduler logging Signed-off-by: Andy Miller --- system/blueprints/config/scheduler.yaml | 611 ++++++++++-------- .../src/Grav/Common/Scheduler/Scheduler.php | 133 +++- 2 files changed, 434 insertions(+), 310 deletions(-) diff --git a/system/blueprints/config/scheduler.yaml b/system/blueprints/config/scheduler.yaml index 6bccafbbd..8b829947a 100644 --- a/system/blueprints/config/scheduler.yaml +++ b/system/blueprints/config/scheduler.yaml @@ -80,8 +80,7 @@ form: content: | - modern_status: - type: conditional - condition: config.scheduler.modern.enabled - - fields: - modern_health: - type: display - label: Health Status - content: | -
-
Checking health...
-
- - markdown: false + // Refresh every 30 seconds + setInterval(loadHealthStatus, 30000); + })(); + + markdown: false - trigger_methods: - type: display - label: Active Triggers - content: | -
-
Checking triggers...
-
- - markdown: false + } + })(); + + markdown: false jobs_tab: type: tab @@ -501,233 +486,291 @@ form: modern_tab: type: tab - title: Modern Features + title: Advanced Features fields: - modern.enabled: - type: toggle - label: Enable Modern Scheduler - help: Enable enhanced scheduler features (job queue, retry, webhooks, monitoring) - highlight: 0 - default: 0 - options: - 1: PLUGIN_ADMIN.ENABLED - 0: PLUGIN_ADMIN.DISABLED - validate: - type: bool - - modern_features: - type: conditional - condition: config.scheduler.modern.enabled + workers_section: + type: section + title: Worker Configuration + underline: true fields: - workers_section: - type: section - title: Worker Configuration - underline: true + modern.workers: + type: number + label: Concurrent Workers + help: Number of jobs that can run simultaneously (1 = sequential) + default: 4 + size: x-small + append: workers + validate: + type: int + min: 1 + max: 10 + retry_section: + type: section + title: Retry Configuration + underline: true + + fields: + modern.retry.enabled: + type: toggle + label: Enable Job Retry + help: Automatically retry failed jobs + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + modern.retry.max_attempts: + type: number + label: Maximum Retry Attempts + help: Maximum number of times to retry a failed job + default: 3 + size: x-small + append: retries + validate: + type: int + min: 1 + max: 10 + + modern.retry.backoff: + type: select + label: Retry Backoff Strategy + help: How to calculate delay between retries + default: exponential + options: + linear: Linear (fixed delay) + exponential: Exponential (increasing delay) + + queue_section: + type: section + title: Queue Configuration + underline: true + + fields: + modern.queue.path: + type: text + label: Queue Storage Path + help: Where to store queued jobs + default: 'user-data://scheduler/queue' + placeholder: 'user-data://scheduler/queue' + + modern.queue.max_size: + type: number + label: Maximum Queue Size + help: Maximum number of jobs that can be queued + default: 1000 + size: x-small + append: jobs + validate: + type: int + min: 100 + max: 10000 + + history_section: + type: section + title: Job History + underline: true + + fields: + modern.history.enabled: + type: toggle + label: Enable Job History + help: Track execution history for all jobs + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + modern.history.retention_days: + type: number + label: History Retention (days) + help: How long to keep job history + default: 30 + size: x-small + append: days + validate: + type: int + min: 1 + max: 365 + + webhook_section: + type: section + title: Webhook Configuration + underline: true + + fields: + webhook_plugin_notice: + type: conditional + condition: config.plugins.scheduler-webhook.enabled == false + fields: - modern.workers: - type: number - label: Concurrent Workers - help: Number of jobs that can run simultaneously (1 = sequential) - default: 1 - size: x-small - append: workers - validate: - type: int - min: 1 - max: 10 - - retry_section: - type: section - title: Retry Configuration - underline: true - - fields: - modern.retry.enabled: - type: toggle - label: Enable Job Retry - help: Automatically retry failed jobs - highlight: 1 - default: 1 - options: - 1: PLUGIN_ADMIN.ENABLED - 0: PLUGIN_ADMIN.DISABLED - validate: - type: bool - - modern.retry.max_attempts: - type: number - label: Maximum Retry Attempts - help: Maximum number of times to retry a failed job - default: 3 - size: x-small - append: retries - validate: - type: int - min: 1 - max: 10 - - modern.retry.backoff: - type: select - label: Retry Backoff Strategy - help: How to calculate delay between retries - default: exponential - options: - linear: Linear (fixed delay) - exponential: Exponential (increasing delay) - - queue_section: - type: section - title: Queue Configuration - underline: true - - fields: - modern.queue.path: - type: text - label: Queue Storage Path - help: Where to store queued jobs - default: 'user-data://scheduler/queue' - placeholder: 'user-data://scheduler/queue' - - modern.queue.max_size: - type: number - label: Maximum Queue Size - help: Maximum number of jobs that can be queued - default: 1000 - size: x-small - append: jobs - validate: - type: int - min: 100 - max: 10000 - - history_section: - type: section - title: Job History - underline: true - - fields: - modern.history.enabled: - type: toggle - label: Enable Job History - help: Track execution history for all jobs - highlight: 1 - default: 1 - options: - 1: PLUGIN_ADMIN.ENABLED - 0: PLUGIN_ADMIN.DISABLED - validate: - type: bool - - modern.history.retention_days: - type: number - label: History Retention (days) - help: How long to keep job history - default: 30 - size: x-small - append: days - validate: - type: int - min: 1 - max: 365 - - webhook_section: - type: section - title: Webhook Configuration - underline: true - - fields: - webhook_plugin_notice: + webhook_install: type: display label: content: |
- Plugin Required: The scheduler-webhook plugin must be installed and enabled for webhook functionality to work. -

- Install with: bin/gpm install scheduler-webhook + Webhook Plugin Required
+ The scheduler-webhook plugin is required for webhook functionality.

+ + Install Plugin Now + + or run: bin/gpm install scheduler-webhook
markdown: false - modern.webhook.enabled: - type: toggle - label: Enable Webhook Triggers - help: Allow triggering scheduler via HTTP webhook - highlight: 0 - default: 0 - options: - 1: PLUGIN_ADMIN.ENABLED - 0: PLUGIN_ADMIN.DISABLED - validate: - type: bool - - modern.webhook.token: - type: text - label: Webhook Security Token - help: Secret token for authenticating webhook requests. Keep this secret! - placeholder: 'Click Generate to create a secure token' - webhook_token_generate: + webhook_plugin_ready: + type: conditional + condition: config.plugins.scheduler-webhook.enabled == true + + fields: + webhook_ready: type: display label: content: | - +
+ Webhook Plugin Ready!
+ The scheduler-webhook plugin is installed and active. Configure your webhook settings below. +
+ markdown: false + modern.webhook.enabled: + type: toggle + label: Enable Webhook Triggers + help: Allow triggering scheduler via HTTP webhook + highlight: 0 + default: 0 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + modern.webhook.token: + type: text + label: Webhook Security Token + help: Secret token for authenticating webhook requests. Keep this secret! + placeholder: 'Click Generate to create a secure token' + autocomplete: 'off' + + webhook_token_generate: + type: display + label: + content: | +
+ +
- markdown: false + markdown: false - modern.webhook.path: - type: text - label: Webhook Path - help: URL path for webhook endpoint - default: '/scheduler/webhook' - placeholder: '/scheduler/webhook' + modern.webhook.path: + type: text + label: Webhook Path + help: URL path for webhook endpoint + default: '/scheduler/webhook' + placeholder: '/scheduler/webhook' - health_section: - type: section - title: Health Check Configuration - underline: true + health_section: + type: section + title: Health Check Configuration + underline: true - fields: - modern.health.enabled: - type: toggle - label: Enable Health Check - help: Provide health status endpoint for monitoring - highlight: 1 - default: 1 - options: - 1: PLUGIN_ADMIN.ENABLED - 0: PLUGIN_ADMIN.DISABLED - validate: - type: bool + fields: + modern.health.enabled: + type: toggle + label: Enable Health Check + help: Provide health status endpoint for monitoring + highlight: 1 + default: 1 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool - modern.health.path: - type: text - label: Health Check Path - help: URL path for health check endpoint - default: '/scheduler/health' - placeholder: '/scheduler/health' + modern.health.path: + type: text + label: Health Check Path + help: URL path for health check endpoint + default: '/scheduler/health' + placeholder: '/scheduler/health' - webhook_usage: - type: section - title: Usage Examples - underline: true - - fields: - webhook_examples: - type: display - label: - content: | + webhook_usage: + type: section + title: Usage Examples + underline: true + + fields: + webhook_examples: + type: display + label: + content: |

How to use webhooks:

Trigger all due jobs (respects schedule):

@@ -747,7 +790,7 @@ form: curl -X POST ${{ secrets.SITE_URL }}/scheduler/webhook \ -H "Authorization: Bearer ${{ secrets.WEBHOOK_TOKEN }}"
- markdown: false + markdown: false diff --git a/system/src/Grav/Common/Scheduler/Scheduler.php b/system/src/Grav/Common/Scheduler/Scheduler.php index 410bfec1f..559c6dd30 100644 --- a/system/src/Grav/Common/Scheduler/Scheduler.php +++ b/system/src/Grav/Common/Scheduler/Scheduler.php @@ -18,6 +18,8 @@ use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; use RocketTheme\Toolbox\File\YamlFile; use Symfony\Component\Yaml\Yaml; +use Monolog\Logger; +use Monolog\Handler\StreamHandler; use function is_callable; use function is_string; @@ -76,8 +78,8 @@ class Scheduler /** @var string */ protected $historyPath; - /** @var JobHistory|null */ - protected $jobHistory = null; + /** @var Logger|null */ + protected $logger = null; /** @var array */ protected $modernConfig = []; @@ -97,11 +99,10 @@ class Scheduler Folder::create($this->status_path); } - // Initialize modern features if enabled + // Initialize modern features (always enabled now) $this->modernConfig = $grav['config']->get('scheduler.modern', []); - if ($this->modernConfig['enabled'] ?? false) { - $this->initializeModernFeatures($locator); - } + // Always initialize modern features - they're now part of core + $this->initializeModernFeatures($locator); } /** @@ -249,16 +250,31 @@ class Scheduler $runTime = new DateTime('now'); } + // Log scheduler run + if ($this->logger) { + $jobCount = count($alljobs); + $forceStr = $force ? ' (forced)' : ''; + $this->logger->debug("Scheduler run started - {$jobCount} jobs available{$forceStr}", [ + 'time' => $runTime->format('Y-m-d H:i:s') + ]); + } + // Process jobs based on modern features if ($this->jobQueue && ($this->modernConfig['queue']['enabled'] ?? false)) { // Queue jobs for processing + $queuedCount = 0; foreach ($alljobs as $job) { if ($job->isDue($runTime) || $force) { // Add to queue for concurrent processing $this->jobQueue->push($job); + $queuedCount++; } } + if ($this->logger && $queuedCount > 0) { + $this->logger->debug("Queued {$queuedCount} job(s) for processing"); + } + // Process queue with workers $this->processJobsWithWorkers(); @@ -287,6 +303,31 @@ class Scheduler } } + // Log run summary + if ($this->logger) { + $successCount = 0; + $failureCount = 0; + $executedJobs = array_merge($this->executed_jobs, $this->jobs_run); + + foreach ($executedJobs as $job) { + if ($job->isSuccessful()) { + $successCount++; + } else { + $failureCount++; + } + } + + if (count($executedJobs) > 0) { + if ($failureCount > 0) { + $this->logger->warning("Scheduler completed: {$successCount} succeeded, {$failureCount} failed"); + } else { + $this->logger->info("Scheduler completed: {$successCount} job(s) succeeded"); + } + } else { + $this->logger->debug('Scheduler completed: no jobs were due'); + } + } + // Store run date file_put_contents("logs/lastcron.run", (new DateTime("now"))->format("Y-m-d H:i:s"), LOCK_EX); } @@ -474,15 +515,14 @@ class Scheduler Folder::create($this->historyPath); } - // Initialize job queue + // Initialize job queue (always enabled) $this->jobQueue = new JobQueue($this->queuePath); - // Initialize job history - $retentionDays = $this->modernConfig['history']['retention_days'] ?? 30; - $this->jobHistory = new JobHistory($this->historyPath, $retentionDays); + // Initialize scheduler logger + $this->initializeLogger($locator); - // Configure workers - $this->maxWorkers = $this->modernConfig['workers'] ?? 1; + // Configure workers (default to 4 for concurrent processing) + $this->maxWorkers = $this->modernConfig['workers'] ?? 4; // Configure webhook $this->webhookEnabled = $this->modernConfig['webhook']['enabled'] ?? false; @@ -503,13 +543,28 @@ class Scheduler } /** - * Get the job history + * Initialize the scheduler logger * - * @return JobHistory|null + * @param $locator + * @return void */ - public function getHistory(): ?JobHistory + protected function initializeLogger($locator): void { - return $this->jobHistory; + $this->logger = new Logger('scheduler'); + + // Single scheduler log file - all levels + $logFile = $locator->findResource('log://scheduler.log', true, true); + $this->logger->pushHandler(new StreamHandler($logFile, Logger::DEBUG)); + } + + /** + * Get the scheduler logger + * + * @return Logger|null + */ + public function getLogger(): ?Logger + { + return $this->logger; } /** @@ -678,13 +733,26 @@ class Scheduler $worker['job']->finalize(); $this->saveJobState($worker['job']); - // Log background job completion to history - if ($this->jobHistory) { - $metadata = [ - 'queue_id' => $worker['queueId'] ?? null, - 'background' => true - ]; - $this->jobHistory->logExecution($worker['job'], $metadata); + // Log background job completion + if ($this->logger) { + $job = $worker['job']; + $jobId = $job->getId(); + $command = is_string($job->getCommand()) ? $job->getCommand() : 'Closure'; + + if ($job->isSuccessful()) { + $execTime = method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null; + $timeStr = $execTime ? sprintf(' (%.2fs)', $execTime) : ''; + $this->logger->info("Job '{$jobId}' completed successfully{$timeStr}", [ + 'command' => $command, + 'background' => true + ]); + } else { + $error = trim($job->getOutput()) ?: 'Unknown error'; + $this->logger->error("Job '{$jobId}' failed: {$error}", [ + 'command' => $command, + 'background' => true + ]); + } } } @@ -775,9 +843,22 @@ class Scheduler } // Log foreground jobs immediately - if (!$job->runInBackground() && $this->jobHistory) { - $metadata = ['queue_id' => $queueId]; - $this->jobHistory->logExecution($job, $metadata); + if (!$job->runInBackground() && $this->logger) { + $jobId = $job->getId(); + $command = is_string($job->getCommand()) ? $job->getCommand() : 'Closure'; + + if ($job->isSuccessful()) { + $execTime = method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null; + $timeStr = $execTime ? sprintf(' (%.2fs)', $execTime) : ''; + $this->logger->info("Job '{$jobId}' completed successfully{$timeStr}", [ + 'command' => $command + ]); + } else { + $error = trim($job->getOutput()) ?: 'Unknown error'; + $this->logger->error("Job '{$jobId}' failed: {$error}", [ + 'command' => $command + ]); + } } } From 89764a51fb0b6393b580b71e3a23cd5ed3a409ca Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Mon, 25 Aug 2025 12:36:06 +0100 Subject: [PATCH 5/7] more scheduler fixes Signed-off-by: Andy Miller --- system/blueprints/config/scheduler.yaml | 157 ++++++++++++------ .../src/Grav/Common/Scheduler/Scheduler.php | 40 +++-- .../Common/Scheduler/SchedulerController.php | 9 +- 3 files changed, 143 insertions(+), 63 deletions(-) diff --git a/system/blueprints/config/scheduler.yaml b/system/blueprints/config/scheduler.yaml index 8b829947a..7b3f4e0bb 100644 --- a/system/blueprints/config/scheduler.yaml +++ b/system/blueprints/config/scheduler.yaml @@ -324,6 +324,68 @@ form: markdown: false + cron_setup: + type: display + label: Cron Setup Commands + content: | + +
+ + +
+ +
+ +
Copy
+
+
+ +
+ +
+ +
Copy
+
+
+ +
+ Note: These commands will run the scheduler every minute. Adjust the path if needed before copying. +
+
+ markdown: false + trigger_methods: type: display label: Active Triggers @@ -607,42 +669,9 @@ form: underline: true fields: - webhook_plugin_notice: - type: conditional - condition: config.plugins.scheduler-webhook.enabled == false - - fields: - webhook_install: - type: display - label: - content: | -
- Webhook Plugin Required
- The scheduler-webhook plugin is required for webhook functionality.

- - Install Plugin Now - - or run: bin/gpm install scheduler-webhook -
- markdown: false - - webhook_plugin_ready: - type: conditional - condition: config.plugins.scheduler-webhook.enabled == true - - fields: - webhook_ready: - type: display - label: - content: | -
- Webhook Plugin Ready!
- The scheduler-webhook plugin is installed and active. Configure your webhook settings below. -
- markdown: false + webhook_plugin_status: + type: webhook-status + label: modern.webhook.enabled: type: toggle label: Enable Webhook Triggers @@ -771,24 +800,52 @@ form: type: display label: content: | -
-

How to use webhooks:

-

Trigger all due jobs (respects schedule):

-
curl -X POST https://your-site.com/scheduler/webhook \
-                                                      -H "Authorization: Bearer YOUR_TOKEN"
+ +
+ -

Force-run specific job (ignores schedule):

-
curl -X POST https://your-site.com/scheduler/webhook?job=backup \
-                                                      -H "Authorization: Bearer YOUR_TOKEN"
- -

Check health status:

-
curl https://your-site.com/scheduler/health
- -

GitHub Actions example:

-
- name: Trigger Scheduler
+                                                    
+

How to use webhooks:

+ +
+ +
+ +
Copy
+
+
+ +
+ +
+ +
Copy
+
+
+ +
+ +
+ +
Copy
+
+
+ +
+

GitHub Actions example:

+
- name: Trigger Scheduler
                                                   run: |
                                                     curl -X POST ${{ secrets.SITE_URL }}/scheduler/webhook \
                                                       -H "Authorization: Bearer ${{ secrets.WEBHOOK_TOKEN }}"
+
+
markdown: false diff --git a/system/src/Grav/Common/Scheduler/Scheduler.php b/system/src/Grav/Common/Scheduler/Scheduler.php index 559c6dd30..f19fae9b2 100644 --- a/system/src/Grav/Common/Scheduler/Scheduler.php +++ b/system/src/Grav/Common/Scheduler/Scheduler.php @@ -307,6 +307,7 @@ class Scheduler if ($this->logger) { $successCount = 0; $failureCount = 0; + $failedJobNames = []; $executedJobs = array_merge($this->executed_jobs, $this->jobs_run); foreach ($executedJobs as $job) { @@ -314,12 +315,14 @@ class Scheduler $successCount++; } else { $failureCount++; + $failedJobNames[] = $job->getId(); } } if (count($executedJobs) > 0) { if ($failureCount > 0) { - $this->logger->warning("Scheduler completed: {$successCount} succeeded, {$failureCount} failed"); + $failedList = implode(', ', $failedJobNames); + $this->logger->warning("Scheduler completed: {$successCount} succeeded, {$failureCount} failed (failed: {$failedList})"); } else { $this->logger->info("Scheduler completed: {$successCount} job(s) succeeded"); } @@ -948,14 +951,26 @@ class Scheduler $lastRunFile = $this->status_path . '/last_run.txt'; $lastRun = file_exists($lastRunFile) ? file_get_contents($lastRunFile) : null; + // Load all jobs and check how many are currently due + $this->loadSavedJobs(); + $allJobs = $this->getAllJobs(); + $now = new DateTime('now'); + $dueJobs = 0; + + foreach ($allJobs as $job) { + if ($job->isDue($now)) { + $dueJobs++; + } + } + $health = [ 'status' => 'healthy', 'last_run' => $lastRun, 'last_run_age' => null, 'queue_size' => 0, 'failed_jobs_24h' => 0, - 'scheduled_jobs' => count($this->getAllJobs()), - 'modern_features' => $this->modernConfig['enabled'] ?? false, + 'scheduled_jobs' => count($allJobs), + 'jobs_due' => $dueJobs, 'webhook_enabled' => $this->webhookEnabled, 'health_check_enabled' => $this->healthEnabled, 'timestamp' => date('c'), @@ -964,19 +979,20 @@ class Scheduler // Calculate last run age if ($lastRun) { $lastRunTime = new DateTime($lastRun); - $now = new DateTime('now'); $health['last_run_age'] = $now->getTimestamp() - $lastRunTime->getTimestamp(); - - // Determine status based on age - if ($health['last_run_age'] < 600) { // Less than 10 minutes - $health['status'] = 'healthy'; - } elseif ($health['last_run_age'] < 3600) { // Less than 1 hour + } + + // Determine status based on whether jobs are due + if ($dueJobs > 0) { + // Jobs are due but haven't been run + if ($health['last_run_age'] === null || $health['last_run_age'] > 300) { // No run or older than 5 minutes $health['status'] = 'warning'; - } else { - $health['status'] = 'critical'; + $health['message'] = $dueJobs . ' job(s) are due to run'; } } else { - $health['status'] = 'unknown'; + // No jobs are due - this is healthy + $health['status'] = 'healthy'; + $health['message'] = 'No jobs currently due'; } // Add queue stats if available diff --git a/system/src/Grav/Common/Scheduler/SchedulerController.php b/system/src/Grav/Common/Scheduler/SchedulerController.php index d346ff4ff..6c9808df4 100644 --- a/system/src/Grav/Common/Scheduler/SchedulerController.php +++ b/system/src/Grav/Common/Scheduler/SchedulerController.php @@ -157,6 +157,8 @@ class SchedulerController $lastRun = $health['last_run'] ?? null; $queueSize = $health['queue_size'] ?? 0; $failedJobs = $health['failed_jobs_24h'] ?? 0; + $jobsDue = $health['jobs_due'] ?? 0; + $message = $health['message'] ?? ''; $statusBadge = match($status) { 'healthy' => 'Healthy', @@ -166,7 +168,11 @@ class SchedulerController }; $html = '
'; - $html .= '

Status: ' . $statusBadge . '

'; + $html .= '

Status: ' . $statusBadge; + if ($message) { + $html .= ' - ' . htmlspecialchars($message); + } + $html .= '

'; if ($lastRun) { $lastRunTime = new \DateTime($lastRun); @@ -189,6 +195,7 @@ class SchedulerController $html .= '

Last Run: Never

'; } + $html .= '

Jobs Due: ' . $jobsDue . '

'; $html .= '

Queue Size: ' . $queueSize . '

'; if ($failedJobs > 0) { From 9d71de8e54873fe6b317d8351d57a45b3f632e89 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Mon, 25 Aug 2025 12:53:47 +0100 Subject: [PATCH 6/7] more scheduler improvements Signed-off-by: Andy Miller --- .../src/Grav/Common/Scheduler/Scheduler.php | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/system/src/Grav/Common/Scheduler/Scheduler.php b/system/src/Grav/Common/Scheduler/Scheduler.php index f19fae9b2..4b9c06c25 100644 --- a/system/src/Grav/Common/Scheduler/Scheduler.php +++ b/system/src/Grav/Common/Scheduler/Scheduler.php @@ -241,6 +241,13 @@ class Scheduler */ public function run(DateTime $runTime = null, $force = false) { + // Initialize system jobs if not already done + $grav = Grav::instance(); + if (count($this->jobs) === 0) { + // Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.) + $grav->fireEvent('onSchedulerInitialized', new \RocketTheme\Toolbox\Event\Event(['scheduler' => $this])); + } + $this->loadSavedJobs(); [$background, $foreground] = $this->getQueuedJobs(false); @@ -333,6 +340,9 @@ class Scheduler // Store run date file_put_contents("logs/lastcron.run", (new DateTime("now"))->format("Y-m-d H:i:s"), LOCK_EX); + + // Update last run timestamp for health checks + $this->updateLastRun(); } /** @@ -951,13 +961,24 @@ class Scheduler $lastRunFile = $this->status_path . '/last_run.txt'; $lastRun = file_exists($lastRunFile) ? file_get_contents($lastRunFile) : null; - // Load all jobs and check how many are currently due + // Initialize system jobs if not already done + $grav = Grav::instance(); + if (count($this->jobs) === 0) { + // Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.) + $grav->fireEvent('onSchedulerInitialized', new \RocketTheme\Toolbox\Event\Event(['scheduler' => $this])); + } + + // Load custom jobs $this->loadSavedJobs(); - $allJobs = $this->getAllJobs(); + + // Get only enabled jobs for health status + [$background, $foreground] = $this->getQueuedJobs(false); + $enabledJobs = array_merge($background, $foreground); + $now = new DateTime('now'); $dueJobs = 0; - foreach ($allJobs as $job) { + foreach ($enabledJobs as $job) { if ($job->isDue($now)) { $dueJobs++; } @@ -969,7 +990,7 @@ class Scheduler 'last_run_age' => null, 'queue_size' => 0, 'failed_jobs_24h' => 0, - 'scheduled_jobs' => count($allJobs), + 'scheduled_jobs' => count($enabledJobs), 'jobs_due' => $dueJobs, 'webhook_enabled' => $this->webhookEnabled, 'health_check_enabled' => $this->healthEnabled, @@ -1022,6 +1043,16 @@ class Scheduler return ['success' => false, 'message' => 'Invalid webhook token']; } + // Initialize system jobs if not already done + $grav = Grav::instance(); + if (count($this->jobs) === 0) { + // Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.) + $grav->fireEvent('onSchedulerInitialized', new \RocketTheme\Toolbox\Event\Event(['scheduler' => $this])); + } + + // Load custom jobs + $this->loadSavedJobs(); + if ($jobId) { // Force run specific job $job = $this->getJob($jobId); From c608ed10cf5df1df0fcf87dfdc5ba624a4559f31 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Mon, 25 Aug 2025 13:27:23 +0100 Subject: [PATCH 7/7] implement a better purge strategy Signed-off-by: Andy Miller --- system/blueprints/config/system.yaml | 13 ++++++ system/config/system.yaml | 1 + system/src/Grav/Common/Cache.php | 69 ++++++++++++++++++++++++---- 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/system/blueprints/config/system.yaml b/system/blueprints/config/system.yaml index ac44f27b3..70ee08879 100644 --- a/system/blueprints/config/system.yaml +++ b/system/blueprints/config/system.yaml @@ -633,6 +633,19 @@ form: help: PLUGIN_ADMIN.CACHE_PREFIX_HELP placeholder: PLUGIN_ADMIN.CACHE_PREFIX_PLACEHOLDER + cache.purge_max_age_days: + type: text + size: x-small + append: GRAV.NICETIME.DAY_PLURAL + label: PLUGIN_ADMIN.CACHE_PURGE_AGE + help: PLUGIN_ADMIN.CACHE_PURGE_AGE_HELP + validate: + type: number + min: 1 + max: 365 + step: 1 + default: 30 + cache.purge_at: type: cron label: PLUGIN_ADMIN.CACHE_PURGE_JOB diff --git a/system/config/system.yaml b/system/config/system.yaml index 30eabbfbe..984f46771 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -101,6 +101,7 @@ cache: clear_images_by_default: false # By default grav does not include processed images in cache clear, this can be enabled cli_compatibility: false # Ensures only non-volatile drivers are used (file, redis, memcache, etc.) lifetime: 604800 # Lifetime of cached data in seconds (0 = infinite) + purge_max_age_days: 30 # Maximum age of cache items in days before they are purged gzip: false # GZip compress the page output allow_webserver_gzip: false # If true, `content-encoding: identity` but connection isn't closed before `onShutDown()` event redis: diff --git a/system/src/Grav/Common/Cache.php b/system/src/Grav/Common/Cache.php index 6927c401c..c7afcbe55 100644 --- a/system/src/Grav/Common/Cache.php +++ b/system/src/Grav/Common/Cache.php @@ -170,24 +170,75 @@ class Cache extends Getters } /** - * Deletes the old out of date file-based caches + * Deletes old cache files based on age * * @return int */ public function purgeOldCache() { + // Get the max age for cache files from config (default 30 days) + $max_age_days = $this->config->get('system.cache.purge_max_age_days', 30); + $max_age_seconds = $max_age_days * 86400; // Convert days to seconds + $now = time(); + $count = 0; + + // First, clean up old orphaned cache directories (not the current one) $cache_dir = dirname($this->cache_dir); $current = Utils::basename($this->cache_dir); - $count = 0; - + foreach (new DirectoryIterator($cache_dir) as $file) { $dir = $file->getBasename(); if ($dir === $current || $file->isDot() || $file->isFile()) { continue; } - - Folder::delete($file->getPathname()); - $count++; + + // Check if directory is old and empty or very old (90+ days) + $dir_age = $now - $file->getMTime(); + if ($dir_age > 7776000) { // 90 days + Folder::delete($file->getPathname()); + $count++; + } + } + + // Now clean up old cache files within the current cache directory + if (is_dir($this->cache_dir)) { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->cache_dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $file_age = $now - $file->getMTime(); + if ($file_age > $max_age_seconds) { + @unlink($file->getPathname()); + $count++; + } + } + } + } + + // Also clean up old files in compiled cache + $grav = Grav::instance(); + $compiled_dir = $this->config->get('system.cache.compiled_dir', 'cache://compiled'); + $compiled_path = $grav['locator']->findResource($compiled_dir, true); + + if ($compiled_path && is_dir($compiled_path)) { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($compiled_path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $file_age = $now - $file->getMTime(); + // Compiled files can be kept longer (60 days) + if ($file_age > ($max_age_seconds * 2)) { + @unlink($file->getPathname()); + $count++; + } + } + } } return $count; @@ -632,8 +683,10 @@ class Cache extends Getters { /** @var Cache $cache */ $cache = Grav::instance()['cache']; - $deleted_folders = $cache->purgeOldCache(); - $msg = 'Purged ' . $deleted_folders . ' old cache folders...'; + $deleted_items = $cache->purgeOldCache(); + + $max_age = $cache->config->get('system.cache.purge_max_age_days', 30); + $msg = 'Purged ' . $deleted_items . ' old cache items (files older than ' . $max_age . ' days)'; if ($echo) { echo $msg;