docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace App\Framework\Scheduler\Commands;
use App\Framework\Attributes\ConsoleCommand as ConsoleCommandAttribute;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Scheduler\Services\SchedulerService;
/**
* Console commands for scheduler management
*/
final readonly class SchedulerCommands
{
public function __construct(
private SchedulerService $scheduler
) {}
#[ConsoleCommandAttribute(name: 'schedule:list', description: 'List all scheduled tasks')]
public function listTasks(): void
{
$tasks = $this->scheduler->getScheduledTasks();
if (empty($tasks)) {
echo "No scheduled tasks found\n";
return;
}
echo "📅 Scheduled Tasks\n";
echo "=================\n\n";
foreach ($tasks as $task) {
$nextExecution = $task->nextExecution?->toRfc3339() ?? 'Not scheduled';
$lastExecution = $task->lastExecution?->toRfc3339() ?? 'Never';
echo "🔄 {$task->id}\n";
echo " Type: {$task->schedule->getType()}\n";
echo " Description: {$task->schedule->getDescription()}\n";
echo " Next: {$nextExecution}\n";
echo " Last: {$lastExecution}\n";
echo " Executions: {$task->executionCount}\n\n";
}
}
#[ConsoleCommandAttribute(name: 'schedule:stats', description: 'Show scheduler statistics')]
public function showStats(): void
{
$stats = $this->scheduler->getStats();
echo "📊 Scheduler Statistics\n";
echo "======================\n\n";
echo "Total Tasks: {$stats['total_tasks']}\n";
echo "Due Tasks: {$stats['due_tasks']}\n";
echo "Next Execution: " . ($stats['next_execution'] ?? 'None') . "\n\n";
if (!empty($stats['schedule_types'])) {
echo "Schedule Types:\n";
foreach ($stats['schedule_types'] as $type => $count) {
echo " {$type}: {$count}\n";
}
}
}
#[ConsoleCommandAttribute(name: 'schedule:due', description: 'Show tasks due for execution')]
public function showDueTasks(): void
{
$dueTasks = $this->scheduler->getDueTasks();
if (empty($dueTasks)) {
echo "No tasks are currently due for execution\n";
return;
}
echo "⏰ Tasks Due for Execution\n";
echo "=========================\n\n";
foreach ($dueTasks as $task) {
echo "🔔 {$task->id}\n";
echo " Schedule: {$task->schedule->getDescription()}\n";
echo " Due: {$task->nextExecution?->toRfc3339()}\n\n";
}
}
#[ConsoleCommandAttribute(name: 'schedule:run', description: 'Execute all due tasks')]
public function runDueTasks(): void
{
$dueTasks = $this->scheduler->getDueTasks();
if (empty($dueTasks)) {
echo "No tasks are currently due for execution\n";
return;
}
echo "🚀 Executing " . count($dueTasks) . " due tasks...\n\n";
$results = $this->scheduler->executeDueTasks();
$successful = 0;
$failed = 0;
foreach ($results as $result) {
if ($result->isSuccess()) {
$successful++;
echo "{$result->taskId} - Completed in {$result->executionTimeSeconds}s\n";
if ($result->hasNextExecution()) {
echo " Next: {$result->nextExecution->toRfc3339()}\n";
}
} else {
$failed++;
echo "{$result->taskId} - Failed: {$result->getErrorMessage()}\n";
echo " Execution time: {$result->executionTimeSeconds}s\n";
if ($result->hasNextExecution()) {
echo " Next: {$result->nextExecution->toRfc3339()}\n";
}
}
echo "\n";
}
echo "Summary: {$successful} successful, {$failed} failed\n";
}
#[ConsoleCommandAttribute(name: 'schedule:next', description: 'Show next scheduled execution')]
public function showNext(): void
{
$nextExecution = $this->scheduler->getNextExecution();
if (!$nextExecution) {
echo "No tasks are scheduled for execution\n";
return;
}
$now = Timestamp::now();
$timeUntil = $nextExecution->getTimestamp() - $now->getTimestamp();
echo "🕐 Next Scheduled Execution\n";
echo "==========================\n\n";
echo "Time: {$nextExecution->toRfc3339()}\n";
echo "In: " . $this->formatDuration($timeUntil) . "\n\n";
// Show which tasks are scheduled for this time
$tasksAtTime = [];
foreach ($this->scheduler->getScheduledTasks() as $task) {
if ($task->nextExecution && $task->nextExecution->equals($nextExecution)) {
$tasksAtTime[] = $task;
}
}
if (!empty($tasksAtTime)) {
echo "Tasks:\n";
foreach ($tasksAtTime as $task) {
echo "{$task->id}\n";
}
}
}
#[ConsoleCommandAttribute(name: 'schedule:check', description: 'Check scheduler health')]
public function checkHealth(): void
{
$stats = $this->scheduler->getStats();
$now = Timestamp::now();
echo "🏥 Scheduler Health Check\n";
echo "========================\n\n";
// Basic stats
echo "✅ Scheduler is running\n";
echo "📊 {$stats['total_tasks']} tasks registered\n";
// Check for overdue tasks
$overdueTasks = [];
foreach ($this->scheduler->getScheduledTasks() as $task) {
if ($task->nextExecution && $task->nextExecution->isBefore($now)) {
$overdue = $now->getTimestamp() - $task->nextExecution->getTimestamp();
$overdueTasks[] = ['task' => $task, 'overdue' => $overdue];
}
}
if (!empty($overdueTasks)) {
echo "\n⚠️ Overdue Tasks:\n";
foreach ($overdueTasks as $overdueTask) {
$task = $overdueTask['task'];
$overdue = $this->formatDuration($overdueTask['overdue']);
echo "{$task->id} (overdue by {$overdue})\n";
}
} else {
echo "✅ No overdue tasks\n";
}
// Next execution
$nextExecution = $this->scheduler->getNextExecution();
if ($nextExecution) {
$timeUntil = $nextExecution->getTimestamp() - $now->getTimestamp();
echo "🕐 Next execution in " . $this->formatDuration($timeUntil) . "\n";
} else {
echo " No future executions scheduled\n";
}
}
private function formatDuration(int $seconds): string
{
if ($seconds < 60) {
return "{$seconds}s";
}
if ($seconds < 3600) {
$minutes = (int) ($seconds / 60);
$remainingSeconds = $seconds % 60;
return $remainingSeconds > 0 ? "{$minutes}m {$remainingSeconds}s" : "{$minutes}m";
}
if ($seconds < 86400) {
$hours = (int) ($seconds / 3600);
$remainingMinutes = (int) (($seconds % 3600) / 60);
return $remainingMinutes > 0 ? "{$hours}h {$remainingMinutes}m" : "{$hours}h";
}
$days = (int) ($seconds / 86400);
$remainingHours = (int) (($seconds % 86400) / 3600);
return $remainingHours > 0 ? "{$days}d {$remainingHours}h" : "{$days}d";
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Framework\Scheduler\Contracts;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Interface for recurring schedule definitions
*/
interface ScheduleInterface
{
/**
* Get the next execution time based on the current time
*/
public function getNextExecution(?Timestamp $from = null): ?Timestamp;
/**
* Check if this schedule should execute at the given time
*/
public function shouldExecuteAt(Timestamp $time): bool;
/**
* Get a human-readable description of this schedule
*/
public function getDescription(): string;
/**
* Get the schedule type identifier
*/
public function getType(): string;
/**
* Convert schedule to array representation
*/
public function toArray(): array;
}

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Framework\Scheduler\Schedules;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Scheduler\Contracts\ScheduleInterface;
use App\Framework\Scheduler\ValueObjects\CronExpression;
/**
* Schedule implementation for cron expressions
*/
final readonly class CronSchedule implements ScheduleInterface
{
public function __construct(
public CronExpression $cronExpression
) {}
public static function fromExpression(string $expression): self
{
return new self(CronExpression::fromString($expression));
}
public static function everyMinute(): self
{
return new self(CronExpression::everyMinute());
}
public static function hourly(): self
{
return new self(CronExpression::hourly());
}
public static function daily(): self
{
return new self(CronExpression::daily());
}
public static function weekly(): self
{
return new self(CronExpression::weekly());
}
public static function monthly(): self
{
return new self(CronExpression::monthly());
}
public static function at(int $hour, int $minute = 0): self
{
return new self(CronExpression::at($hour, $minute));
}
public static function weekdaysAt(int $hour, int $minute = 0): self
{
return new self(CronExpression::weekdaysAt($hour, $minute));
}
public function getNextExecution(?Timestamp $from = null): ?Timestamp
{
$from = $from ?? Timestamp::now();
// Start checking from the next minute
$checkTime = $from->add(\App\Framework\Core\ValueObjects\Duration::fromMinutes(1));
// Round down to the start of the minute
$checkTime = Timestamp::fromDateTime(
new \DateTimeImmutable($checkTime->format('Y-m-d H:i:00'))
);
// Check up to one year in the future to avoid infinite loops
$maxTime = $from->add(\App\Framework\Core\ValueObjects\Duration::fromDays(365));
while ($checkTime->isBefore($maxTime)) {
if ($this->shouldExecuteAt($checkTime)) {
return $checkTime;
}
$checkTime = $checkTime->add(\App\Framework\Core\ValueObjects\Duration::fromMinutes(1));
}
return null; // No execution found within the next year
}
public function shouldExecuteAt(Timestamp $time): bool
{
$dateTime = $time->toDateTime();
return $this->matchesMinute($dateTime->format('i')) &&
$this->matchesHour($dateTime->format('H')) &&
$this->matchesDay($dateTime->format('j')) &&
$this->matchesMonth($dateTime->format('n')) &&
$this->matchesWeekday($dateTime->format('w'));
}
public function getDescription(): string
{
return $this->cronExpression->getDescription();
}
public function getType(): string
{
return 'cron';
}
public function toArray(): array
{
return [
'type' => $this->getType(),
'expression' => $this->cronExpression->toString(),
'description' => $this->getDescription()
];
}
private function matchesMinute(string $minute): bool
{
return $this->matchesField($this->cronExpression->getMinute(), (int) $minute, 0, 59);
}
private function matchesHour(string $hour): bool
{
return $this->matchesField($this->cronExpression->getHour(), (int) $hour, 0, 23);
}
private function matchesDay(string $day): bool
{
return $this->matchesField($this->cronExpression->getDayOfMonth(), (int) $day, 1, 31);
}
private function matchesMonth(string $month): bool
{
return $this->matchesField($this->cronExpression->getMonth(), (int) $month, 1, 12);
}
private function matchesWeekday(string $weekday): bool
{
// Convert Sunday from 0 to 7 for consistency
$day = $weekday === '0' ? 7 : (int) $weekday;
return $this->matchesField($this->cronExpression->getDayOfWeek(), $day, 0, 7);
}
private function matchesField(string $field, int $value, int $min, int $max): bool
{
// Wildcard matches everything
if ($field === '*') {
return true;
}
// Handle ranges (e.g., 1-5)
if (str_contains($field, '-')) {
[$start, $end] = explode('-', $field);
return $value >= (int) $start && $value <= (int) $end;
}
// Handle step values (e.g., */5, 1-10/2)
if (str_contains($field, '/')) {
[$base, $step] = explode('/', $field);
$stepValue = (int) $step;
if ($base === '*') {
return ($value - $min) % $stepValue === 0;
}
// Handle ranges with steps (e.g., 1-10/2)
if (str_contains($base, '-')) {
[$start, $end] = explode('-', $base);
$startValue = (int) $start;
return $value >= $startValue &&
$value <= (int) $end &&
($value - $startValue) % $stepValue === 0;
}
// Single value with step
$baseValue = (int) $base;
return $value >= $baseValue && ($value - $baseValue) % $stepValue === 0;
}
// Handle comma-separated values (e.g., 1,3,5)
if (str_contains($field, ',')) {
$values = array_map('intval', explode(',', $field));
return in_array($value, $values, true);
}
// Single value
$fieldValue = (int) $field;
// Special handling for weekday: 0 and 7 both represent Sunday
if ($min === 0 && $max === 7) {
return $value === $fieldValue ||
($value === 7 && $fieldValue === 0) ||
($value === 0 && $fieldValue === 7);
}
return $value === $fieldValue;
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Framework\Scheduler\Schedules;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Scheduler\Contracts\ScheduleInterface;
use App\Framework\Worker\Every;
/**
* Schedule implementation for interval-based execution
* Compatible with the existing Worker Every system
*/
final readonly class IntervalSchedule implements ScheduleInterface
{
public function __construct(
public Duration $interval,
public ?Timestamp $startTime = null
) {}
public static function fromEvery(Every $every): self
{
return new self(
interval: Duration::fromSeconds($every->toSeconds())
);
}
public static function every(Duration $interval): self
{
return new self(interval: $interval);
}
public static function everySeconds(int $seconds): self
{
return new self(interval: Duration::fromSeconds($seconds));
}
public static function everyMinutes(int $minutes): self
{
return new self(interval: Duration::fromMinutes($minutes));
}
public static function everyHours(int $hours): self
{
return new self(interval: Duration::fromHours($hours));
}
public static function everyDays(int $days): self
{
return new self(interval: Duration::fromDays($days));
}
public static function startingAt(Duration $interval, Timestamp $startTime): self
{
return new self(
interval: $interval,
startTime: $startTime
);
}
public function getNextExecution(?Timestamp $from = null): ?Timestamp
{
$from = $from ?? Timestamp::now();
$start = $this->startTime ?? $from;
// If we haven't reached the start time yet
if ($from->isBefore($start)) {
return $start;
}
// Calculate how many intervals have passed since start
$elapsed = $from->toTimestamp() - $start->toTimestamp();
$intervalSeconds = $this->interval->toSeconds();
// Calculate the next interval
$nextIntervalCount = (int) ceil($elapsed / $intervalSeconds);
$nextExecutionTime = $start->toTimestamp() + ($nextIntervalCount * (int) $intervalSeconds);
return Timestamp::fromFloat($nextExecutionTime);
}
public function shouldExecuteAt(Timestamp $time): bool
{
$start = $this->startTime ?? Timestamp::fromFloat(0);
// Before start time
if ($time->isBefore($start)) {
return false;
}
$elapsed = $time->toTimestamp() - $start->toTimestamp();
$intervalSeconds = $this->interval->toSeconds();
// Check if this timestamp aligns with our interval
return $elapsed % $intervalSeconds === 0;
}
public function getDescription(): string
{
$intervalDesc = $this->formatInterval();
if ($this->startTime) {
return "Every {$intervalDesc} starting at {$this->startTime->toRfc3339()}";
}
return "Every {$intervalDesc}";
}
public function getType(): string
{
return 'interval';
}
public function toArray(): array
{
return [
'type' => $this->getType(),
'interval_seconds' => $this->interval->toSeconds(),
'start_time' => $this->startTime?->toRfc3339(),
'description' => $this->getDescription()
];
}
public function toEvery(): Every
{
$totalSeconds = $this->interval->toSeconds();
$days = (int) floor($totalSeconds / 86400);
$remainingSeconds = $totalSeconds % 86400;
$hours = (int) floor($remainingSeconds / 3600);
$remainingSeconds %= 3600;
$minutes = (int) floor($remainingSeconds / 60);
$seconds = $remainingSeconds % 60;
return new Every(
days: $days,
hours: $hours,
minutes: $minutes,
seconds: $seconds
);
}
private function formatInterval(): string
{
$seconds = $this->interval->toSeconds();
if ($seconds < 60) {
return "{$seconds} second" . ($seconds !== 1 ? 's' : '');
}
if ($seconds < 3600) {
$minutes = (int) ($seconds / 60);
return "{$minutes} minute" . ($minutes !== 1 ? 's' : '');
}
if ($seconds < 86400) {
$hours = (int) ($seconds / 3600);
return "{$hours} hour" . ($hours !== 1 ? 's' : '');
}
$days = (int) ($seconds / 86400);
return "{$days} day" . ($days !== 1 ? 's' : '');
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Framework\Scheduler\Schedules;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Scheduler\Contracts\ScheduleInterface;
/**
* Schedule implementation for one-time execution
*/
final readonly class OneTimeSchedule implements ScheduleInterface
{
public function __construct(
public Timestamp $executeAt
) {}
public static function at(Timestamp $timestamp): self
{
return new self(executeAt: $timestamp);
}
public static function in(int $seconds): self
{
$executeTime = Timestamp::fromFloat(time() + $seconds);
return new self(executeAt: $executeTime);
}
public function getNextExecution(?Timestamp $from = null): ?Timestamp
{
$from = $from ?? Timestamp::now();
// If the execution time has passed, return null (no more executions)
if ($this->executeAt->isBefore($from) || $this->executeAt->equals($from)) {
return null;
}
return $this->executeAt;
}
public function shouldExecuteAt(Timestamp $time): bool
{
return $this->executeAt->equals($time);
}
public function getDescription(): string
{
return "Execute once at {$this->executeAt->format('c')}";
}
public function getType(): string
{
return 'one-time';
}
public function toArray(): array
{
return [
'type' => $this->getType(),
'execute_at' => $this->executeAt->format('c'),
'description' => $this->getDescription()
];
}
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Framework\Scheduler\Services;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Scheduler\Contracts\ScheduleInterface;
use App\Framework\Scheduler\ValueObjects\ScheduledTask;
use App\Framework\Scheduler\ValueObjects\TaskExecutionResult;
/**
* Central scheduler service for managing scheduled tasks
*/
final class SchedulerService
{
/** @var array<string, ScheduledTask> */
private array $scheduledTasks = [];
public function __construct(
private readonly Logger $logger
) {}
/**
* Register a scheduled task
*/
public function schedule(string $taskId, ScheduleInterface $schedule, callable $task): void
{
$this->scheduledTasks[$taskId] = new ScheduledTask(
id: $taskId,
schedule: $schedule,
task: $task,
nextExecution: $schedule->getNextExecution()
);
$this->logger->info('Task scheduled', LogContext::withData([
'task_id' => $taskId,
'schedule_type' => $schedule->getType(),
'description' => $schedule->getDescription(),
'next_execution' => $this->scheduledTasks[$taskId]->nextExecution?->format('c')
]));
}
/**
* Unschedule a task
*/
public function unschedule(string $taskId): bool
{
if (!isset($this->scheduledTasks[$taskId])) {
return false;
}
unset($this->scheduledTasks[$taskId]);
$this->logger->info('Task unscheduled', LogContext::withData([
'task_id' => $taskId
]));
return true;
}
/**
* Get all scheduled tasks
*/
public function getScheduledTasks(): array
{
return $this->scheduledTasks;
}
/**
* Get a specific scheduled task
*/
public function getTask(string $taskId): ?ScheduledTask
{
return $this->scheduledTasks[$taskId] ?? null;
}
/**
* Get tasks that should execute now
*/
public function getDueTasks(?Timestamp $at = null): array
{
$at = $at ?? Timestamp::now();
$dueTasks = [];
foreach ($this->scheduledTasks as $task) {
if ($task->isDue($at)) {
$dueTasks[] = $task;
}
}
return $dueTasks;
}
/**
* Execute all due tasks
*/
public function executeDueTasks(?Timestamp $at = null): array
{
$dueTasks = $this->getDueTasks($at);
$results = [];
foreach ($dueTasks as $task) {
$results[] = $this->executeTask($task);
}
return $results;
}
/**
* Execute a specific task and update its next execution time
*/
public function executeTask(ScheduledTask $task): TaskExecutionResult
{
$startTime = Timestamp::now();
try {
$this->logger->info('Executing scheduled task', LogContext::withData([
'task_id' => $task->id,
'schedule_type' => $task->schedule->getType()
]));
// Execute the task
$result = ($task->task)();
// Update next execution time
$nextExecution = $task->schedule->getNextExecution();
$this->scheduledTasks[$task->id] = $task->withNextExecution($nextExecution);
$duration = Timestamp::now()->toTimestamp() - $startTime->toTimestamp();
$this->logger->info('Scheduled task completed successfully', LogContext::withData([
'task_id' => $task->id,
'execution_time_seconds' => $duration,
'next_execution' => $nextExecution?->format('c')
]));
return TaskExecutionResult::success(
taskId: $task->id,
executionTime: $duration,
result: $result,
nextExecution: $nextExecution
);
} catch (\Throwable $e) {
$duration = Timestamp::now()->toTimestamp() - $startTime->toTimestamp();
$this->logger->error('Scheduled task failed', LogContext::withData([
'task_id' => $task->id,
'error' => $e->getMessage(),
'execution_time_seconds' => $duration
]));
// Still update next execution time for recurring tasks
$nextExecution = $task->schedule->getNextExecution();
if ($nextExecution) {
$this->scheduledTasks[$task->id] = $task->withNextExecution($nextExecution);
}
return TaskExecutionResult::failure(
taskId: $task->id,
executionTime: $duration,
error: $e,
nextExecution: $nextExecution
);
}
}
/**
* Check if a task exists
*/
public function hasTask(string $taskId): bool
{
return isset($this->scheduledTasks[$taskId]);
}
/**
* Get the next scheduled execution time across all tasks
*/
public function getNextExecution(): ?Timestamp
{
$nextExecution = null;
foreach ($this->scheduledTasks as $task) {
if ($task->nextExecution && (!$nextExecution || $task->nextExecution->isBefore($nextExecution))) {
$nextExecution = $task->nextExecution;
}
}
return $nextExecution;
}
/**
* Get scheduler statistics
*/
public function getStats(): array
{
$stats = [
'total_tasks' => count($this->scheduledTasks),
'due_tasks' => count($this->getDueTasks()),
'next_execution' => $this->getNextExecution()?->format('c'),
'schedule_types' => []
];
foreach ($this->scheduledTasks as $task) {
$type = $task->schedule->getType();
$stats['schedule_types'][$type] = ($stats['schedule_types'][$type] ?? 0) + 1;
}
return $stats;
}
}

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Framework\Scheduler\ValueObjects;
/**
* Value Object for cron expressions with validation
*/
final readonly class CronExpression
{
public function __construct(
public string $expression
) {
$this->validate();
}
public static function fromString(string $expression): self
{
return new self(trim($expression));
}
public static function everyMinute(): self
{
return new self('* * * * *');
}
public static function hourly(): self
{
return new self('0 * * * *');
}
public static function daily(): self
{
return new self('0 0 * * *');
}
public static function weekly(): self
{
return new self('0 0 * * 0');
}
public static function monthly(): self
{
return new self('0 0 1 * *');
}
public static function yearly(): self
{
return new self('0 0 1 1 *');
}
public static function at(int $hour, int $minute = 0): self
{
if ($hour < 0 || $hour > 23) {
throw new \InvalidArgumentException('Hour must be between 0 and 23');
}
if ($minute < 0 || $minute > 59) {
throw new \InvalidArgumentException('Minute must be between 0 and 59');
}
return new self("{$minute} {$hour} * * *");
}
public static function weekdaysAt(int $hour, int $minute = 0): self
{
if ($hour < 0 || $hour > 23) {
throw new \InvalidArgumentException('Hour must be between 0 and 23');
}
if ($minute < 0 || $minute > 59) {
throw new \InvalidArgumentException('Minute must be between 0 and 59');
}
return new self("{$minute} {$hour} * * 1-5");
}
public function toString(): string
{
return $this->expression;
}
public function equals(self $other): bool
{
return $this->expression === $other->expression;
}
public function getParts(): array
{
return explode(' ', $this->expression);
}
public function getMinute(): string
{
return $this->getParts()[0];
}
public function getHour(): string
{
return $this->getParts()[1];
}
public function getDayOfMonth(): string
{
return $this->getParts()[2];
}
public function getMonth(): string
{
return $this->getParts()[3];
}
public function getDayOfWeek(): string
{
return $this->getParts()[4];
}
public function getDescription(): string
{
return match ($this->expression) {
'* * * * *' => 'Every minute',
'0 * * * *' => 'Every hour',
'0 0 * * *' => 'Daily at midnight',
'0 0 * * 0' => 'Weekly on Sunday at midnight',
'0 0 1 * *' => 'Monthly on the 1st at midnight',
'0 0 1 1 *' => 'Yearly on January 1st at midnight',
default => "Cron: {$this->expression}"
};
}
private function validate(): void
{
if (empty($this->expression)) {
throw new \InvalidArgumentException('Cron expression cannot be empty');
}
$parts = explode(' ', $this->expression);
if (count($parts) !== 5) {
throw new \InvalidArgumentException(
'Cron expression must have exactly 5 parts (minute hour day month weekday)'
);
}
[$minute, $hour, $day, $month, $weekday] = $parts;
$this->validateField($minute, 0, 59, 'minute');
$this->validateField($hour, 0, 23, 'hour');
$this->validateField($day, 1, 31, 'day');
$this->validateField($month, 1, 12, 'month');
$this->validateField($weekday, 0, 7, 'weekday'); // 0 and 7 both represent Sunday
}
private function validateField(string $field, int $min, int $max, string $fieldName): void
{
// Allow wildcards
if ($field === '*') {
return;
}
// Handle ranges (e.g., 1-5)
if (str_contains($field, '-')) {
$range = explode('-', $field);
if (count($range) !== 2) {
throw new \InvalidArgumentException("Invalid range in {$fieldName}: {$field}");
}
$start = (int) $range[0];
$end = (int) $range[1];
if ($start < $min || $start > $max || $end < $min || $end > $max || $start > $end) {
throw new \InvalidArgumentException("Invalid range in {$fieldName}: {$field}");
}
return;
}
// Handle step values (e.g., */5)
if (str_contains($field, '/')) {
$step = explode('/', $field);
if (count($step) !== 2) {
throw new \InvalidArgumentException("Invalid step in {$fieldName}: {$field}");
}
$stepValue = (int) $step[1];
if ($stepValue <= 0) {
throw new \InvalidArgumentException("Step value must be positive in {$fieldName}: {$field}");
}
// Validate base (e.g., the '*' in '*/5')
if ($step[0] !== '*') {
$this->validateField($step[0], $min, $max, $fieldName);
}
return;
}
// Handle comma-separated values (e.g., 1,3,5)
if (str_contains($field, ',')) {
$values = explode(',', $field);
foreach ($values as $value) {
$this->validateField(trim($value), $min, $max, $fieldName);
}
return;
}
// Single numeric value
if (!is_numeric($field)) {
throw new \InvalidArgumentException("Invalid {$fieldName} value: {$field}");
}
$value = (int) $field;
if ($value < $min || $value > $max) {
throw new \InvalidArgumentException(
"{$fieldName} must be between {$min} and {$max}, got {$value}"
);
}
// Special case for weekday: both 0 and 7 represent Sunday
if ($fieldName === 'weekday' && $value === 7) {
return; // 7 is valid for Sunday
}
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Framework\Scheduler\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Value Object for single job scheduling (non-recurring)
*/
final readonly class JobSchedule
{
public function __construct(
public ?Timestamp $scheduledAt = null,
public ?Timestamp $notBefore = null,
public ?Timestamp $notAfter = null
) {
$this->validate();
}
public static function immediate(): self
{
return new self();
}
public static function delayed(Duration $delay): self
{
return new self(
scheduledAt: Timestamp::now()->add($delay)
);
}
public static function at(Timestamp $scheduledAt): self
{
return new self(scheduledAt: $scheduledAt);
}
public static function between(Timestamp $notBefore, Timestamp $notAfter): self
{
return new self(
notBefore: $notBefore,
notAfter: $notAfter
);
}
public static function atWithWindow(
Timestamp $scheduledAt,
Timestamp $notBefore,
Timestamp $notAfter
): self {
return new self(
scheduledAt: $scheduledAt,
notBefore: $notBefore,
notAfter: $notAfter
);
}
public function isImmediate(): bool
{
return $this->scheduledAt === null;
}
public function isScheduled(): bool
{
return $this->scheduledAt !== null;
}
public function hasTimeWindow(): bool
{
return $this->notBefore !== null || $this->notAfter !== null;
}
public function shouldExecuteNow(): bool
{
$now = Timestamp::now();
// Check time window constraints
if ($this->notBefore && $now->isBefore($this->notBefore)) {
return false;
}
if ($this->notAfter && $now->isAfter($this->notAfter)) {
return false;
}
// Immediate execution
if ($this->isImmediate()) {
return true;
}
// Scheduled execution
return $this->scheduledAt && $now->isAfterOrEqual($this->scheduledAt);
}
public function getExecutionTime(): ?Timestamp
{
return $this->scheduledAt;
}
public function withScheduledAt(Timestamp $scheduledAt): self
{
return new self(
scheduledAt: $scheduledAt,
notBefore: $this->notBefore,
notAfter: $this->notAfter
);
}
public function withTimeWindow(Timestamp $notBefore, Timestamp $notAfter): self
{
return new self(
scheduledAt: $this->scheduledAt,
notBefore: $notBefore,
notAfter: $notAfter
);
}
public function toArray(): array
{
return [
'scheduled_at' => $this->scheduledAt?->toRfc3339(),
'not_before' => $this->notBefore?->toRfc3339(),
'not_after' => $this->notAfter?->toRfc3339()
];
}
public static function fromArray(array $data): self
{
return new self(
scheduledAt: isset($data['scheduled_at']) ? Timestamp::fromRfc3339($data['scheduled_at']) : null,
notBefore: isset($data['not_before']) ? Timestamp::fromRfc3339($data['not_before']) : null,
notAfter: isset($data['not_after']) ? Timestamp::fromRfc3339($data['not_after']) : null
);
}
private function validate(): void
{
if ($this->notBefore && $this->notAfter && $this->notAfter->isBefore($this->notBefore)) {
throw new \InvalidArgumentException('notAfter cannot be before notBefore');
}
if ($this->scheduledAt && $this->notBefore && $this->scheduledAt->isBefore($this->notBefore)) {
throw new \InvalidArgumentException('scheduledAt cannot be before notBefore');
}
if ($this->scheduledAt && $this->notAfter && $this->scheduledAt->isAfter($this->notAfter)) {
throw new \InvalidArgumentException('scheduledAt cannot be after notAfter');
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Framework\Scheduler\ValueObjects;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Scheduler\Contracts\ScheduleInterface;
/**
* Value Object representing a scheduled task
*/
final readonly class ScheduledTask
{
public function __construct(
public string $id,
public ScheduleInterface $schedule,
public mixed $task,
public ?Timestamp $nextExecution = null,
public ?Timestamp $lastExecution = null,
public int $executionCount = 0
) {
if (!is_callable($this->task)) {
throw new \InvalidArgumentException('Task must be callable');
}
}
public function isDue(?Timestamp $at = null): bool
{
$at = $at ?? Timestamp::now();
if (!$this->nextExecution) {
return false;
}
return $at->isAfter($this->nextExecution) || $at->equals($this->nextExecution);
}
public function withNextExecution(?Timestamp $nextExecution): self
{
return new self(
id: $this->id,
schedule: $this->schedule,
task: $this->task,
nextExecution: $nextExecution,
lastExecution: $this->lastExecution,
executionCount: $this->executionCount
);
}
public function withLastExecution(Timestamp $lastExecution): self
{
return new self(
id: $this->id,
schedule: $this->schedule,
task: $this->task,
nextExecution: $this->nextExecution,
lastExecution: $lastExecution,
executionCount: $this->executionCount + 1
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'schedule' => $this->schedule->toArray(),
'next_execution' => $this->nextExecution?->toRfc3339(),
'last_execution' => $this->lastExecution?->toRfc3339(),
'execution_count' => $this->executionCount
];
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Framework\Scheduler\ValueObjects;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Value Object representing the result of a task execution
*/
final readonly class TaskExecutionResult
{
public function __construct(
public string $taskId,
public bool $success,
public int $executionTimeSeconds,
public mixed $result = null,
public ?\Throwable $error = null,
public ?Timestamp $nextExecution = null,
public ?Timestamp $executedAt = null
) {}
public static function success(
string $taskId,
int $executionTime,
mixed $result = null,
?Timestamp $nextExecution = null
): self {
return new self(
taskId: $taskId,
success: true,
executionTimeSeconds: $executionTime,
result: $result,
nextExecution: $nextExecution,
executedAt: Timestamp::now()
);
}
public static function failure(
string $taskId,
int $executionTime,
\Throwable $error,
?Timestamp $nextExecution = null
): self {
return new self(
taskId: $taskId,
success: false,
executionTimeSeconds: $executionTime,
error: $error,
nextExecution: $nextExecution,
executedAt: Timestamp::now()
);
}
public function isSuccess(): bool
{
return $this->success;
}
public function isFailure(): bool
{
return !$this->success;
}
public function hasNextExecution(): bool
{
return $this->nextExecution !== null;
}
public function getErrorMessage(): ?string
{
return $this->error?->getMessage();
}
public function toArray(): array
{
return [
'task_id' => $this->taskId,
'success' => $this->success,
'execution_time_seconds' => $this->executionTimeSeconds,
'executed_at' => $this->executedAt->toRfc3339(),
'next_execution' => $this->nextExecution?->toRfc3339(),
'error_message' => $this->getErrorMessage(),
'has_result' => $this->result !== null
];
}
}