diff options
Diffstat (limited to 'vendor/ipl/scheduler/src')
-rw-r--r-- | vendor/ipl/scheduler/src/Common/Promises.php | 108 | ||||
-rw-r--r-- | vendor/ipl/scheduler/src/Common/TaskProperties.php | 83 | ||||
-rw-r--r-- | vendor/ipl/scheduler/src/Common/Timers.php | 60 | ||||
-rw-r--r-- | vendor/ipl/scheduler/src/Contract/Frequency.php | 62 | ||||
-rw-r--r-- | vendor/ipl/scheduler/src/Contract/Task.php | 39 | ||||
-rw-r--r-- | vendor/ipl/scheduler/src/Cron.php | 203 | ||||
-rw-r--r-- | vendor/ipl/scheduler/src/OneOff.php | 69 | ||||
-rw-r--r-- | vendor/ipl/scheduler/src/RRule.php | 328 | ||||
-rw-r--r-- | vendor/ipl/scheduler/src/Scheduler.php | 323 | ||||
-rw-r--r-- | vendor/ipl/scheduler/src/register_cron_aliases.php | 11 |
10 files changed, 1286 insertions, 0 deletions
diff --git a/vendor/ipl/scheduler/src/Common/Promises.php b/vendor/ipl/scheduler/src/Common/Promises.php new file mode 100644 index 0000000..b896627 --- /dev/null +++ b/vendor/ipl/scheduler/src/Common/Promises.php @@ -0,0 +1,108 @@ +<?php + +namespace ipl\Scheduler\Common; + +use ArrayObject; +use InvalidArgumentException; +use Ramsey\Uuid\UuidInterface; +use React\Promise\PromiseInterface; +use SplObjectStorage; + +trait Promises +{ + /** @var SplObjectStorage<UuidInterface, ArrayObject<int, PromiseInterface>> */ + protected $promises; + + /** + * Add the given promise for the specified UUID + * + * **Example Usage:** + * + * ```php + * $promise = work(); + * $promises->addPromise($uuid, $promise); + * ``` + * + * @param UuidInterface $uuid + * @param PromiseInterface $promise + * + * @return $this + */ + protected function addPromise(UuidInterface $uuid, PromiseInterface $promise): self + { + if (! $this->promises->contains($uuid)) { + $this->promises->attach($uuid, new ArrayObject()); + } + + $this->promises[$uuid][] = $promise; + + return $this; + } + + /** + * Remove the given promise for the specified UUID + * + * **Example Usage:** + * + * ```php + * $promise->always(function () use ($uuid, $promise) { + * $promises->removePromise($uuid, $promise); + * }) + * ``` + * + * @param UuidInterface $uuid + * @param PromiseInterface $promise + * + * @return $this + * + * @throws InvalidArgumentException If the given UUID doesn't have any registered promises or when the specified + * UUID promises doesn't contain the provided promise + */ + protected function removePromise(UuidInterface $uuid, PromiseInterface $promise): self + { + if (! $this->promises->contains($uuid)) { + throw new InvalidArgumentException( + sprintf('There are no registered promises for UUID %s', $uuid->toString()) + ); + } + + foreach ($this->promises[$uuid] as $k => $v) { + if ($v === $promise) { + unset($this->promises[$uuid][$k]); + + return $this; + } + } + + throw new InvalidArgumentException( + sprintf('There is no such promise for UUID %s', $uuid->toString()) + ); + } + + /** + * Detach and return promises for the given UUID, if any + * + * **Example Usage:** + * + * ```php + * foreach ($promises->detachPromises($uuid) as $promise) { + * $promise->cancel(); + * } + * ``` + * + * @param UuidInterface $uuid + * + * @return PromiseInterface[] + */ + protected function detachPromises(UuidInterface $uuid): array + { + if (! $this->promises->contains($uuid)) { + return []; + } + + $promises = $this->promises[$uuid]; + $this->promises->detach($uuid); + + return $promises->getArrayCopy(); + } +} diff --git a/vendor/ipl/scheduler/src/Common/TaskProperties.php b/vendor/ipl/scheduler/src/Common/TaskProperties.php new file mode 100644 index 0000000..4ab65e2 --- /dev/null +++ b/vendor/ipl/scheduler/src/Common/TaskProperties.php @@ -0,0 +1,83 @@ +<?php + +namespace ipl\Scheduler\Common; + +use LogicException; +use Ramsey\Uuid\UuidInterface; + +trait TaskProperties +{ + /** @var string */ + protected $description; + + /** @var string Name of this task */ + protected $name; + + /** @var UuidInterface Unique identifier of this task */ + protected $uuid; + + /** + * Set the description of this task + * + * @param ?string $desc + * + * @return $this + */ + public function setDescription(?string $desc): self + { + $this->description = $desc; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function getName(): string + { + if (! $this->name) { + throw new LogicException('Task name must not be null'); + } + + return $this->name; + } + + /** + * Set the name of this Task + * + * @param string $name + * + * @return $this + */ + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getUuid(): UuidInterface + { + if (! $this->uuid) { + throw new LogicException('Task UUID must not be null'); + } + + return $this->uuid; + } + + /** + * Set the UUID of this task + * + * @param UuidInterface $uuid + * + * @return $this + */ + public function setUuid(UuidInterface $uuid): self + { + $this->uuid = $uuid; + + return $this; + } +} diff --git a/vendor/ipl/scheduler/src/Common/Timers.php b/vendor/ipl/scheduler/src/Common/Timers.php new file mode 100644 index 0000000..2d0641f --- /dev/null +++ b/vendor/ipl/scheduler/src/Common/Timers.php @@ -0,0 +1,60 @@ +<?php + +namespace ipl\Scheduler\Common; + +use Ramsey\Uuid\UuidInterface; +use React\EventLoop\TimerInterface; +use SplObjectStorage; + +trait Timers +{ + /** @var SplObjectStorage<UuidInterface, TimerInterface> */ + protected $timers; + + /** + * Set a timer for the given UUID + * + * **Example Usage:** + * + * ```php + * $timers->attachTimer($uuid, Loop::addTimer($interval, $callback)); + * ``` + * + * @param UuidInterface $uuid + * @param TimerInterface $timer + * + * @return $this + */ + protected function attachTimer(UuidInterface $uuid, TimerInterface $timer): self + { + $this->timers->attach($uuid, $timer); + + return $this; + } + + /** + * Detach and return the timer for the given UUID, if any + * + * **Example Usage:** + * + * ```php + * Loop::cancelTimer($timers->detachTimer($uuid)); + * ``` + * + * @param UuidInterface $uuid + * + * @return ?TimerInterface + */ + protected function detachTimer(UuidInterface $uuid): ?TimerInterface + { + if (! $this->timers->contains($uuid)) { + return null; + } + + $timer = $this->timers->offsetGet($uuid); + + $this->timers->detach($uuid); + + return $timer; + } +} diff --git a/vendor/ipl/scheduler/src/Contract/Frequency.php b/vendor/ipl/scheduler/src/Contract/Frequency.php new file mode 100644 index 0000000..2235787 --- /dev/null +++ b/vendor/ipl/scheduler/src/Contract/Frequency.php @@ -0,0 +1,62 @@ +<?php + +namespace ipl\Scheduler\Contract; + +use DateTimeInterface; +use JsonSerializable; + +interface Frequency extends JsonSerializable +{ + /** @var string Format for representing datetimes when serializing the frequency to JSON */ + public const SERIALIZED_DATETIME_FORMAT = 'Y-m-d\TH:i:s.ue'; + + /** + * Get whether the frequency is due at the specified time + * + * @param DateTimeInterface $dateTime + * + * @return bool + */ + public function isDue(DateTimeInterface $dateTime): bool; + + /** + * Get the next due date relative to the given time + * + * @param DateTimeInterface $dateTime + * + * @return DateTimeInterface + */ + public function getNextDue(DateTimeInterface $dateTime): DateTimeInterface; + + /** + * Get whether the specified time is beyond the frequency's expiry time + * + * @param DateTimeInterface $dateTime + * + * @return bool + */ + public function isExpired(DateTimeInterface $dateTime): bool; + + /** + * Get the start time of this frequency + * + * @return ?DateTimeInterface + */ + public function getStart(): ?DateTimeInterface; + + /** + * Get the end time of this frequency + * + * @return ?DateTimeInterface + */ + public function getEnd(): ?DateTimeInterface; + + /** + * Create frequency from its stored JSON representation previously encoded with {@see json_encode()} + * + * @param string $json + * + * @return $this + */ + public static function fromJson(string $json): self; +} diff --git a/vendor/ipl/scheduler/src/Contract/Task.php b/vendor/ipl/scheduler/src/Contract/Task.php new file mode 100644 index 0000000..db09ddc --- /dev/null +++ b/vendor/ipl/scheduler/src/Contract/Task.php @@ -0,0 +1,39 @@ +<?php + +namespace ipl\Scheduler\Contract; + +use Ramsey\Uuid\UuidInterface; +use React\Promise\ExtendedPromiseInterface; + +interface Task +{ + /** + * Get the name of this task + * + * @return string + */ + public function getName(): string; + + /** + * Get unique identifier of this task + * + * @return UuidInterface + */ + public function getUuid(): UuidInterface; + + /** + * Get the description of this task + * + * @return ?string + */ + public function getDescription(): ?string; + + /** + * Run this tasks operations + * + * This commits the actions in a non-blocking fashion to the event loop and yields a deferred promise + * + * @return ExtendedPromiseInterface + */ + public function run(): ExtendedPromiseInterface; +} diff --git a/vendor/ipl/scheduler/src/Cron.php b/vendor/ipl/scheduler/src/Cron.php new file mode 100644 index 0000000..639957b --- /dev/null +++ b/vendor/ipl/scheduler/src/Cron.php @@ -0,0 +1,203 @@ +<?php + +namespace ipl\Scheduler; + +use Cron\CronExpression; +use DateTime; +use DateTimeInterface; +use DateTimeZone; +use InvalidArgumentException; +use ipl\Scheduler\Contract\Frequency; + +use function ipl\Stdlib\get_php_type; + +class Cron implements Frequency +{ + public const PART_MINUTE = 0; + public const PART_HOUR = 1; + public const PART_DAY = 2; + public const PART_MONTH = 3; + public const PART_WEEKDAY = 4; + + /** @var CronExpression */ + protected $cron; + + /** @var ?DateTimeInterface Start time of this frequency */ + protected $start; + + /** @var ?DateTimeInterface End time of this frequency */ + protected $end; + + /** @var string String representation of the cron expression */ + protected $expression; + + /** + * Create frequency from the specified cron expression + * + * @param string $expression + * + * @throws InvalidArgumentException If expression is not a valid cron expression + */ + public function __construct(string $expression) + { + $this->cron = new CronExpression($expression); + $this->expression = $expression; + } + + public function isDue(DateTimeInterface $dateTime): bool + { + if ($this->isExpired($dateTime) || $dateTime < $this->start) { + return false; + } + + return $this->cron->isDue($dateTime); + } + + public function getNextDue(DateTimeInterface $dateTime): DateTimeInterface + { + if ($this->isExpired($dateTime)) { + return $this->end; + } + + if ($dateTime < $this->start) { + return $this->start; + } + + return $this->cron->getNextRunDate($dateTime); + } + + public function isExpired(DateTimeInterface $dateTime): bool + { + return $this->end !== null && $this->end < $dateTime; + } + + public function getStart(): ?DateTimeInterface + { + return $this->start; + } + + public function getEnd(): ?DateTimeInterface + { + return $this->end; + } + + /** + * Get the configured cron expression + * + * @return string + */ + public function getExpression(): string + { + return $this->expression; + } + + /** + * Set the start time of this frequency + * + * @param DateTimeInterface $start + * + * @return $this + */ + public function startAt(DateTimeInterface $start): self + { + $this->start = clone $start; + $this->start->setTimezone(new DateTimeZone(date_default_timezone_get())); + + return $this; + } + + /** + * Set the end time of this frequency + * + * @param DateTimeInterface $end + * + * @return $this + */ + public function endAt(DateTimeInterface $end): Frequency + { + $this->end = clone $end; + $this->end->setTimezone(new DateTimeZone(date_default_timezone_get())); + + return $this; + } + + /** + * Get the given part of the underlying cron expression + * + * @param int $part One of the classes `PART_*` constants + * + * @return string + * + * @throws InvalidArgumentException If the given part is invalid + */ + public function getPart(int $part): string + { + $value = $this->cron->getExpression($part); + if ($value === null) { + throw new InvalidArgumentException(sprintf('Invalid expression part specified: %d', $part)); + } + + return $value; + } + + /** + * Get the parts of the underlying cron expression as an array + * + * @return string[] + */ + public function getParts(): array + { + return $this->cron->getParts(); + } + + /** + * Get whether the given cron expression is valid + * + * @param string $expression + * + * @return bool + */ + public static function isValid(string $expression): bool + { + return CronExpression::isValidExpression($expression); + } + + public static function fromJson(string $json): Frequency + { + $data = json_decode($json, true); + if (! is_array($data)) { + throw new InvalidArgumentException( + sprintf( + '%s expects json decoded value to be an array, got %s instead', + __METHOD__, + get_php_type($data) + ) + ); + } + + $self = new static($data['expression']); + if (isset($data['start'])) { + $self->startAt(new DateTime($data['start'])); + } + + if (isset($data['end'])) { + $self->endAt(new DateTime($data['end'])); + } + + return $self; + } + + public function jsonSerialize(): array + { + $data = ['expression' => $this->getExpression()]; + if ($this->start) { + $data['start'] = $this->start->format(static::SERIALIZED_DATETIME_FORMAT); + } + + if ($this->end) { + $data['end'] = $this->end->format(static::SERIALIZED_DATETIME_FORMAT); + } + + return $data; + } +} diff --git a/vendor/ipl/scheduler/src/OneOff.php b/vendor/ipl/scheduler/src/OneOff.php new file mode 100644 index 0000000..ebe945d --- /dev/null +++ b/vendor/ipl/scheduler/src/OneOff.php @@ -0,0 +1,69 @@ +<?php + +namespace ipl\Scheduler; + +use DateTime; +use DateTimeInterface; +use DateTimeZone; +use InvalidArgumentException; +use ipl\Scheduler\Contract\Frequency; + +use function ipl\Stdlib\get_php_type; + +class OneOff implements Frequency +{ + /** @var DateTimeInterface Start time of this frequency */ + protected $dateTime; + + public function __construct(DateTimeInterface $dateTime) + { + $this->dateTime = clone $dateTime; + $this->dateTime->setTimezone(new DateTimeZone(date_default_timezone_get())); + } + + public function isDue(DateTimeInterface $dateTime): bool + { + return ! $this->isExpired($dateTime) && $this->dateTime == $dateTime; + } + + public function getNextDue(DateTimeInterface $dateTime): DateTimeInterface + { + return $this->dateTime; + } + + public function isExpired(DateTimeInterface $dateTime): bool + { + return $this->dateTime < $dateTime; + } + + public function getStart(): ?DateTimeInterface + { + return $this->dateTime; + } + + public function getEnd(): ?DateTimeInterface + { + return $this->getStart(); + } + + public static function fromJson(string $json): Frequency + { + $data = json_decode($json, true); + if (! is_string($data)) { + throw new InvalidArgumentException( + sprintf( + '%s expects json decoded value to be string, got %s instead', + __METHOD__, + get_php_type($data) + ) + ); + } + + return new static(new DateTime($data)); + } + + public function jsonSerialize(): string + { + return $this->dateTime->format(static::SERIALIZED_DATETIME_FORMAT); + } +} diff --git a/vendor/ipl/scheduler/src/RRule.php b/vendor/ipl/scheduler/src/RRule.php new file mode 100644 index 0000000..bfad0e5 --- /dev/null +++ b/vendor/ipl/scheduler/src/RRule.php @@ -0,0 +1,328 @@ +<?php + +namespace ipl\Scheduler; + +use BadMethodCallException; +use DateTime; +use DateTimeInterface; +use DateTimeZone; +use Generator; +use InvalidArgumentException; +use ipl\Scheduler\Contract\Frequency; +use Recurr\Exception\InvalidRRule; +use Recurr\Rule as RecurrRule; +use Recurr\Transformer\ArrayTransformer; +use Recurr\Transformer\ArrayTransformerConfig; +use Recurr\Transformer\Constraint\AfterConstraint; +use Recurr\Transformer\Constraint\BetweenConstraint; +use stdClass; + +use function ipl\Stdlib\get_php_type; + +/** + * Support scheduling a task based on expressions in iCalendar format + */ +class RRule implements Frequency +{ + /** @var string Run once a year */ + public const YEARLY = 'YEARLY'; + + /** @var string Run every 3 month starting from the given start time */ + public const QUARTERLY = 'QUARTERLY'; + + /** @var string Run once a month */ + public const MONTHLY = 'MONTHLY'; + + /** @var string Run once a week based on the specified start time */ + public const WEEKLY = 'WEEKLY'; + + /** @var string Run once a day at the specified start time */ + public const DAILY = 'DAILY'; + + /** @var string Run once an hour */ + public const HOURLY = 'HOURLY'; + + /** @var string Run once a minute */ + public const MINUTELY = 'MINUTELY'; + + /** @var int Default limit of the recurrences to be generated by the transformer */ + private const DEFAULT_LIMIT = 1; + + /** @var RecurrRule */ + protected $rrule; + + /** @var ArrayTransformer */ + protected $transformer; + + /** @var ArrayTransformerConfig */ + protected $transformerConfig; + + /** @var string */ + protected $frequency; + + /** + * Construct a new rrule instance + * + * @param string|array<string, mixed> $rule + * + * @throws InvalidRRule + */ + public function __construct($rule) + { + $this->rrule = new RecurrRule($rule); + $this->frequency = $this->rrule->getFreqAsText(); + $this->transformerConfig = new ArrayTransformerConfig(); + $this->transformerConfig->setVirtualLimit(self::DEFAULT_LIMIT); + + // If the run day isn't set explicitly, we can enable the last day of month + // fix, so that it doesn't skip some months which doesn't have e.g. 29,30,31 days. + if ( + $this->getFrequency() === static::MONTHLY + && ! $this->rrule->getByDay() + && ! $this->rrule->getByMonthDay() + ) { + $this->transformerConfig->enableLastDayOfMonthFix(); + } + + $this->transformer = new ArrayTransformer($this->transformerConfig); + } + + /** + * Get an RRule instance from the provided frequency + * + * @param string $frequency + * + * @return $this + */ + public static function fromFrequency(string $frequency): self + { + $frequencies = array_flip([ + static::MINUTELY, + static::HOURLY, + static::DAILY, + static::WEEKLY, + static::MONTHLY, + static::QUARTERLY, + static::YEARLY + ]); + + if (! isset($frequencies[$frequency])) { + throw new InvalidArgumentException(sprintf('Unknown frequency provided: %s', $frequency)); + } + + if ($frequency === static::QUARTERLY) { + $repeat = static::MONTHLY; + $rule = "FREQ=$repeat;INTERVAL=3"; + } else { + $rule = "FREQ=$frequency"; + } + + $self = new static($rule); + $self->frequency = $frequency; + + return $self; + } + + public static function fromJson(string $json): Frequency + { + /** @var stdClass $data */ + $data = json_decode($json); + $self = new static($data->rrule); + $self->frequency = $data->frequency; + if (isset($data->start)) { + $start = DateTime::createFromFormat(static::SERIALIZED_DATETIME_FORMAT, $data->start); + if (! $start) { + throw new InvalidArgumentException(sprintf('Cannot deserialize start time: %s', $data->start)); + } + + $self->startAt($start); + } + + return $self; + } + + public function isDue(DateTimeInterface $dateTime): bool + { + if ($dateTime < $this->rrule->getStartDate() || $this->isExpired($dateTime)) { + return false; + } + + $nextDue = $this->getNextRecurrences($dateTime); + if (! $nextDue->valid()) { + return false; + } + + return $nextDue->current() == $dateTime; + } + + public function getNextDue(DateTimeInterface $dateTime): DateTimeInterface + { + if ($this->isExpired($dateTime)) { + return $this->getEnd(); + } + + $nextDue = $this->getNextRecurrences($dateTime, 1, false); + if (! $nextDue->valid()) { + return $dateTime; + } + + return $nextDue->current(); + } + + public function isExpired(DateTimeInterface $dateTime): bool + { + if ($this->rrule->repeatsIndefinitely()) { + return false; + } + + return $this->getEnd() !== null && $this->getEnd() < $dateTime; + } + + /** + * Set the start time of this frequency + * + * The given datetime will be cloned and microseconds removed since iCalendar datetimes only work to the second. + * + * @param DateTimeInterface $start + * + * @return $this + */ + public function startAt(DateTimeInterface $start): self + { + $startDate = clone $start; + // When the start time contains microseconds, the first recurrence will always be skipped, as + // the transformer operates only up to seconds level. See also the upstream issue #155 + $startDate->setTime($start->format('H'), $start->format('i'), $start->format('s')); + // In case start time uses a different tz than what the rrule internally does, we force it to use the same + $startDate->setTimezone(new DateTimeZone($this->rrule->getTimezone())); + + $this->rrule->setStartDate($startDate); + + return $this; + } + + public function getStart(): ?DateTimeInterface + { + return $this->rrule->getStartDate(); + } + + /** + * Set the time until this frequency lasts + * + * The given datetime will be cloned and microseconds removed since iCalendar datetimes only work to the second. + * + * @param DateTimeInterface $end + * + * @return $this + */ + public function endAt(DateTimeInterface $end): self + { + $end = clone $end; + $end->setTime($end->format('H'), $end->format('i'), $end->format('s')); + + $this->rrule->setUntil($end); + + return $this; + } + + public function getEnd(): ?DateTimeInterface + { + return $this->rrule->getEndDate() ?? $this->rrule->getUntil(); + } + + /** + * Get the frequency of this rule + * + * @return string + */ + public function getFrequency(): string + { + return $this->frequency; + } + + /** + * Get a set of recurrences relative to the given time + * + * @param DateTimeInterface $dateTime + * @param int $limit Limit the recurrences to be generated to the given value + * @param bool $include Whether to include the passed time in the result set + * + * @return Generator<DateTimeInterface> + */ + public function getNextRecurrences( + DateTimeInterface $dateTime, + int $limit = self::DEFAULT_LIMIT, + bool $include = true + ): Generator { + $resetTransformerConfig = function (int $limit = self::DEFAULT_LIMIT): void { + $this->transformerConfig->setVirtualLimit($limit); + $this->transformer->setConfig($this->transformerConfig); + }; + + if ($limit > self::DEFAULT_LIMIT) { + $resetTransformerConfig($limit); + } + + $constraint = new AfterConstraint($dateTime, $include); + if (! $this->rrule->repeatsIndefinitely()) { + // When accessing this method externally (not by using `getNextDue()`), the transformer may + // generate recurrences beyond the configured end time. + $constraint = new BetweenConstraint($dateTime, $this->getEnd(), $include); + } + + // Setting the start date to a date time smaller than now causes the underlying library + // not to generate any recurrences when using the regular frequencies such as `MINUTELY` etc. + // and the `$countConstraintFailures` is set to true. We need also to tell the transformer + // not to count the recurrences that fail the constraint's test! + $recurrences = $this->transformer->transform($this->rrule, $constraint, false); + foreach ($recurrences as $recurrence) { + yield $recurrence->getStart(); + } + + if ($limit > self::DEFAULT_LIMIT) { + $resetTransformerConfig(); + } + } + + public function jsonSerialize(): array + { + $data = [ + 'rrule' => $this->rrule->getString(RecurrRule::TZ_FIXED), + 'frequency' => $this->frequency + ]; + + $start = $this->getStart(); + if ($start) { + $data['start'] = $start->format(static::SERIALIZED_DATETIME_FORMAT); + } + + return $data; + } + + /** + * Redirect all public method calls to the underlying rrule object + * + * @param string $methodName + * @param array<mixed> $args + * + * @return mixed + * + * @throws BadMethodCallException If the given method doesn't exist or when setter method is called + */ + public function __call(string $methodName, array $args) + { + if (! method_exists($this->rrule, $methodName)) { + throw new BadMethodCallException( + sprintf('Call to undefined method %s::%s()', get_php_type($this->rrule), $methodName) + ); + } + + if (strtolower(substr($methodName, 0, 3)) !== 'get') { + throw new BadMethodCallException( + sprintf('Dynamic method %s is not supported. Only getters (get*) are', $methodName) + ); + } + + return call_user_func_array([$this->rrule, $methodName], $args); + } +} diff --git a/vendor/ipl/scheduler/src/Scheduler.php b/vendor/ipl/scheduler/src/Scheduler.php new file mode 100644 index 0000000..25ad3a1 --- /dev/null +++ b/vendor/ipl/scheduler/src/Scheduler.php @@ -0,0 +1,323 @@ +<?php + +namespace ipl\Scheduler; + +use DateTime; +use InvalidArgumentException; +use ipl\Scheduler\Common\Promises; +use ipl\Scheduler\Common\Timers; +use ipl\Scheduler\Contract\Frequency; +use ipl\Scheduler\Contract\Task; +use ipl\Stdlib\Events; +use React\EventLoop\Loop; +use React\Promise; +use React\Promise\ExtendedPromiseInterface; +use SplObjectStorage; +use Throwable; + +class Scheduler +{ + use Events; + use Timers; + use Promises; + + /** + * Event raised when a {@link Task task} is canceled + * + * The task and its pending operations as an array of canceled {@link ExtendedPromiseInterface promise}s + * are passed as parameters to the event callbacks. + * + * **Example usage:** + * + * ```php + * $scheduler->on($scheduler::ON_TASK_CANCEL, function (Task $task, array $_) use ($logger) { + * $logger->info(sprintf('Task %s cancelled', $task->getName())); + * }); + * ``` + */ + public const ON_TASK_CANCEL = 'task-cancel'; + + /** + * Event raised when an operation of a {@link Task task} is done + * + * The task and the operation result are passed as parameters to the event callbacks. + * + * **Example usage:** + * + * ```php + * $scheduler->on($scheduler::ON_TASK_DONE, function (Task $task, $result) use ($logger) { + * $logger->info(sprintf('Operation of task %s done: %s', $task->getName(), $result)); + * }); + * ``` + */ + public const ON_TASK_DONE = 'task-done'; + + /** + * Event raised when an operation of a {@link Task task} failed + * + * The task and the {@link Throwable reason} why the operation failed + * are passed as parameters to the event callbacks. + * + * **Example usage:** + * + * ```php + * $scheduler->on($scheduler::ON_TASK_FAILED, function (Task $task, Throwable $e) use ($logger) { + * $logger->error( + * sprintf('Operation of task %s failed: %s', $task->getName(), $e), + * ['exception' => $e] + * ); + * }); + * ``` + */ + public const ON_TASK_FAILED = 'task-failed'; + + /** + * Event raised when a {@link Task task} operation is scheduled + * + * The task and the {@link DateTime time} when it should run + * are passed as parameters to the event callbacks. + * + * **Example usage:** + * + * ```php + * $scheduler->on($scheduler::ON_TASK_SCHEDULED, function (Task $task, DateTime $dateTime) use ($logger) { + * $logger->info(sprintf( + * 'Scheduling task %s to run at %s', + * $task->getName(), + * IntlDateFormatter::formatObject($dateTime) + * )); + * }); + * ``` + */ + public const ON_TASK_SCHEDULED = 'task-scheduled'; + + /** + * Event raised upon operation of a {@link Task task} + * + * The task and the possibly not yet completed result of the operation as a {@link ExtendedPromiseInterface promise} + * are passed as parameters to the event callbacks. + * + * **Example usage:** + * + * ```php + * $scheduler->on($scheduler::ON_TASK_OPERATION, function (Task $task, ExtendedPromiseInterface $_) use ($logger) { + * $logger->info(sprintf('Task %s operating', $task->getName())); + * }); + * ``` + */ + public const ON_TASK_RUN = 'task-run'; + + /** + * Event raised when a {@see Task task} is expired + * + * The task and the {@see DateTime expire time} are passed as parameters to the event callbacks. + * Note that the expiration time is the first time that is considered expired based on the frequency + * of the task and can be later than the specified end time. + * + * **Example usage:** + * + * ```php + * $scheduler->on(Scheduler::ON_TASK_EXPIRED, function (Task $task, DateTime $dateTime) use ($logger) { + * $logger->info(sprintf('Removing expired task %s at %s', $task->getName(), $dateTime->format('Y-m-d H:i:s'))); + * }); + * ``` + */ + public const ON_TASK_EXPIRED = 'task-expired'; + + /** @var SplObjectStorage<Task, null> The scheduled tasks of this scheduler */ + protected $tasks; + + public function __construct() + { + $this->tasks = new SplObjectStorage(); + + $this->promises = new SplObjectStorage(); + $this->timers = new SplObjectStorage(); + + $this->init(); + } + + /** + * Initialize this scheduler + */ + protected function init(): void + { + } + + /** + * Remove and cancel the given task + * + * @param Task $task + * + * @return $this + * + * @throws InvalidArgumentException If the given task isn't scheduled + */ + public function remove(Task $task): self + { + if (! $this->hasTask($task)) { + throw new InvalidArgumentException(sprintf('Task %s not scheduled', $task->getName())); + } + + $this->cancelTask($task); + + $this->tasks->detach($task); + + return $this; + } + + /** + * Remove and cancel all tasks + * + * @return $this + */ + public function removeTasks(): self + { + foreach ($this->tasks as $task) { + $this->cancelTask($task); + } + + $this->tasks = new SplObjectStorage(); + + return $this; + } + + /** + * Get whether the specified task is scheduled + * + * @param Task $task + * + * @return bool + */ + public function hasTask(Task $task): bool + { + return $this->tasks->contains($task); + } + + /** + * Schedule the given task based on the specified frequency + * + * @param Task $task + * @param Frequency $frequency + * + * @return $this + */ + public function schedule(Task $task, Frequency $frequency): self + { + $now = new DateTime(); + if ($frequency->isExpired($now)) { + return $this; + } + + if ($frequency->isDue($now)) { + Loop::futureTick(function () use ($task): void { + $promise = $this->runTask($task); + $this->emit(static::ON_TASK_RUN, [$task, $promise]); + }); + $this->emit(static::ON_TASK_SCHEDULED, [$task, $now]); + + if ($frequency instanceof OneOff) { + return $this; + } + } + + $loop = function () use (&$loop, $task, $frequency): void { + $promise = $this->runTask($task); + $this->emit(static::ON_TASK_RUN, [$task, $promise]); + + $now = new DateTime(); + $nextDue = $frequency->getNextDue($now); + if ($frequency instanceof OneOff || $frequency->isExpired($nextDue)) { + $removeTask = function () use ($task, $nextDue): void { + $this->remove($task); + $this->emit(static::ON_TASK_EXPIRED, [$task, $nextDue]); + }; + + if ($this->promises->contains($task->getUuid())) { + $pendingPromises = (array) $this->promises->offsetGet($task->getUuid()); + Promise\all($pendingPromises)->always($removeTask); + } else { + $removeTask(); + } + + return; + } + + $this->attachTimer( + $task->getUuid(), + Loop::addTimer($nextDue->getTimestamp() - $now->getTimestamp(), $loop) + ); + $this->emit(static::ON_TASK_SCHEDULED, [$task, $nextDue]); + }; + + $nextDue = $frequency->getNextDue($now); + $this->attachTimer( + $task->getUuid(), + Loop::addTimer($nextDue->getTimestamp() - $now->getTimestamp(), $loop) + ); + $this->emit(static::ON_TASK_SCHEDULED, [$task, $nextDue]); + + $this->tasks->attach($task); + + return $this; + } + + public function isValidEvent(string $event): bool + { + $events = array_flip([ + static::ON_TASK_CANCEL, + static::ON_TASK_DONE, + static::ON_TASK_EXPIRED, + static::ON_TASK_FAILED, + static::ON_TASK_RUN, + static::ON_TASK_SCHEDULED + ]); + + return isset($events[$event]); + } + + /** + * Cancel the timer of the task and all pending operations + * + * @param Task $task + */ + protected function cancelTask(Task $task): void + { + Loop::cancelTimer($this->detachTimer($task->getUuid())); + + /** @var ExtendedPromiseInterface[] $promises */ + $promises = $this->detachPromises($task->getUuid()); + if (! empty($promises)) { + /** @var Promise\CancellablePromiseInterface $promise */ + foreach ($promises as $promise) { + $promise->cancel(); + } + $this->emit(self::ON_TASK_CANCEL, [$task, $promises]); + } + } + + /** + * Runs the given task immediately and registers handlers for the returned promise + * + * @param Task $task + * + * @return ExtendedPromiseInterface + */ + protected function runTask(Task $task): ExtendedPromiseInterface + { + $promise = $task->run(); + $this->addPromise($task->getUuid(), $promise); + + return $promise->then( + function ($result) use ($task): void { + $this->emit(self::ON_TASK_DONE, [$task, $result]); + }, + function (Throwable $reason) use ($task): void { + $this->emit(self::ON_TASK_FAILED, [$task, $reason]); + } + )->always(function () use ($task, $promise): void { + // Unregister the promise without canceling it as it's already resolved + $this->removePromise($task->getUuid(), $promise); + }); + } +} diff --git a/vendor/ipl/scheduler/src/register_cron_aliases.php b/vendor/ipl/scheduler/src/register_cron_aliases.php new file mode 100644 index 0000000..2987248 --- /dev/null +++ b/vendor/ipl/scheduler/src/register_cron_aliases.php @@ -0,0 +1,11 @@ +<?php + +use Cron\CronExpression; + +if (! CronExpression::supportsAlias('@minutely')) { + CronExpression::registerAlias('@minutely', '* * * * *'); +} + +if (! CronExpression::supportsAlias('@quarterly')) { + CronExpression::registerAlias('@quarterly', '0 0 1 */3 *'); +} |