Lines
95.83%
92 / 96
Functions and Methods
69.23%
9 / 13
Classes and Traits
0.00%
0 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| ScheduleEntry | 95.83% | 92 / 96 | 69.23% | 9 / 13 | 38 | 0.00% | 0 / 1 | |||
| __construct | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | |||||
| warnNeverOccurs | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| occursAtAll | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| start | 80.00% | 4 / 5 | 0.00% | 0 / 1 | 2.03 | |||||
| getNextRunAt | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 2 | |||||
| cancel | 85.71% | 6 / 7 | 0.00% | 0 / 1 | 2.01 | |||||
| armTimerForNextOccurrence | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 4 | |||||
| onTimerFired | 88.88% | 8 / 9 | 0.00% | 0 / 1 | 4.02 | |||||
| runHeldOccurrence | 100.00% | 11 / 11 | 100.00% | 1 / 1 | 3 | |||||
| runDueOccurrence | 100.00% | 16 / 16 | 100.00% | 1 / 1 | 9 | |||||
| holdUntilResumed | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 1 | |||||
| startHandlerRun | 100.00% | 23 / 23 | 100.00% | 1 / 1 | 5 | |||||
| reportOverlap | 90.00% | 9 / 10 | 0.00% | 0 / 1 | 3.01 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace Stewart\Runtime\Schedule; | |
| 6 | ||
| 7 | use Closure; | |
| 8 | use Psr\Log\LoggerInterface; | |
| 9 | use Stewart\Contracts\Schedule\ScheduledRun; | |
| 10 | use Stewart\Contracts\Schedule\ScheduledTask; | |
| 11 | use Stewart\Contracts\Time\Duration; | |
| 12 | use Stewart\Contracts\Time\Instant; | |
| 13 | use Stewart\Contracts\Time\TimerHandle; | |
| 14 | use Stewart\Runtime\Scope\ScopeState; | |
| 15 | use Throwable; | |
| 16 | ||
| 17 | final class ScheduleEntry | |
| 18 | { | |
| 19 | public readonly ScheduledTask $task; | |
| 20 | ||
| 21 | public private(set) bool $active = true; | |
| 22 | ||
| 23 | public private(set) bool $started = false; | |
| 24 | ||
| 25 | private bool $running = false; | |
| 26 | ||
| 27 | /** @var (Closure(ScheduledRun): void)|null */ | |
| 28 | private ?Closure $handler; | |
| 29 | ||
| 30 | private ?TimerHandle $timer = null; | |
| 31 | ||
| 32 | private ?Instant $heldOccurrence = null; | |
| 33 | ||
| 34 | private int $overlapSkips = 0; | |
| 35 | ||
| 36 | /** | |
| 37 | * @param Closure(ScheduledRun): void $handler | |
| 38 | * @param Closure(self): void $onCancel | |
| 39 | */ | |
| 40 | public function __construct( | |
| 41 | public readonly ScheduleOrigin $origin, | |
| 42 | private readonly Trigger $trigger, | |
| 43 | private readonly LoggerInterface $logger, | |
| 44 | private readonly ScheduleContext $context, | |
| 45 | private readonly ScopeState $scopeState, | |
| 46 | Closure $handler, | |
| 47 | private readonly Closure $onCancel, | |
| 48 | ) { | |
| 49 | $this->handler = $handler; | |
| 50 | $this->task = new ScheduledTaskHandle($this); | |
| 51 | } | |
| 52 | ||
| 53 | public function warnNeverOccurs(): void | |
| 54 | { | |
| 55 | $this->logger->warning('A schedule never occurs; it was not armed', ['schedule' => $this->origin->description]); | |
| 56 | } | |
| 57 | ||
| 58 | /** @throws Throwable */ | |
| 59 | public function occursAtAll(): bool | |
| 60 | { | |
| 61 | return $this->trigger->occursAtAll(); | |
| 62 | } | |
| 63 | ||
| 64 | /** @throws Throwable */ | |
| 65 | public function start(): bool | |
| 66 | { | |
| 67 | if (!$this->trigger->start()) { | |
| 68 | return false; | |
| 69 | } | |
| 70 | ||
| 71 | $this->started = true; | |
| 72 | $this->armTimerForNextOccurrence(); | |
| 73 | ||
| 74 | return true; | |
| 75 | } | |
| 76 | ||
| 77 | public function getNextRunAt(): ?Instant | |
| 78 | { | |
| 79 | return $this->active ? $this->trigger->dueAt() ?? $this->heldOccurrence : null; | |
| 80 | } | |
| 81 | ||
| 82 | public function cancel(): void | |
| 83 | { | |
| 84 | if (!$this->active) { | |
| 85 | return; | |
| 86 | } | |
| 87 | ||
| 88 | $this->active = false; | |
| 89 | $this->handler = null; | |
| 90 | $this->timer?->cancel(); | |
| 91 | $this->timer = null; | |
| 92 | ||
| 93 | ($this->onCancel)($this); | |
| 94 | } | |
| 95 | ||
| 96 | private function armTimerForNextOccurrence(): void | |
| 97 | { | |
| 98 | $delay = $this->trigger->delayUntilDue(); | |
| 99 | ||
| 100 | if ($delay === null || !$this->active) { | |
| 101 | return; | |
| 102 | } | |
| 103 | ||
| 104 | $floor = Duration::milliseconds(1); | |
| 105 | $this->timer = $this->context->timers->startTimer($floor->isLongerThan($delay) ? $floor : $delay, $this->onTimerFired(...)); | |
| 106 | } | |
| 107 | ||
| 108 | private function onTimerFired(): void | |
| 109 | { | |
| 110 | $this->timer = null; | |
| 111 | ||
| 112 | if (!$this->active) { | |
| 113 | return; | |
| 114 | } | |
| 115 | ||
| 116 | try { | |
| 117 | if ($this->trigger->isDue()) { | |
| 118 | $this->runDueOccurrence(); | |
| 119 | } | |
| 120 | } catch (Throwable $e) { | |
| 121 | $this->context->listener->scheduleFailed($this->origin, $e); | |
| 122 | $this->cancel(); | |
| 123 | } | |
| 124 | ||
| 125 | $this->armTimerForNextOccurrence(); | |
| 126 | } | |
| 127 | ||
| 128 | public function runHeldOccurrence(): void | |
| 129 | { | |
| 130 | $handler = $this->handler; | |
| 131 | $scheduledFor = $this->heldOccurrence; | |
| 132 | ||
| 133 | if ($handler === null || $scheduledFor === null) { | |
| 134 | return; | |
| 135 | } | |
| 136 | ||
| 137 | $this->heldOccurrence = null; | |
| 138 | $this->logger->info('A one-shot schedule held by a pause ran late', [ | |
| 139 | 'schedule' => $this->origin->description, | |
| 140 | 'due' => $scheduledFor->toIso8601(), | |
| 141 | ]); | |
| 142 | $this->startHandlerRun($handler, $scheduledFor, 0); | |
| 143 | $this->cancel(); | |
| 144 | } | |
| 145 | ||
| 146 | // An advance() failure propagates to onTimerFired(), which reports it and cancels before any handler runs. | |
| 147 | private function runDueOccurrence(): void | |
| 148 | { | |
| 149 | // The next occurrence is fixed before the handler runs, so a slow handler cannot shift it. | |
| 150 | $handler = $this->handler; | |
| 151 | $scheduledFor = $this->trigger->dueAt(); | |
| 152 | $missed = $this->trigger->advance(); | |
| 153 | $occursAgain = $this->trigger->dueAt() !== null; | |
| 154 | ||
| 155 | if (!$occursAgain && !$this->trigger->isRecurring() && $this->scopeState->isPaused() && $scheduledFor !== null) { | |
| 156 | $this->holdUntilResumed($scheduledFor); | |
| 157 | ||
| 158 | return; | |
| 159 | } | |
| 160 | ||
| 161 | if ($handler !== null && $scheduledFor !== null) { | |
| 162 | $this->startHandlerRun($handler, $scheduledFor, $missed); | |
| 163 | } | |
| 164 | ||
| 165 | if ($occursAgain) { | |
| 166 | return; | |
| 167 | } | |
| 168 | ||
| 169 | if ($this->trigger->isRecurring()) { | |
| 170 | $this->logger->warning('A schedule has no further occurrences and has stopped', [ | |
| 171 | 'schedule' => $this->origin->description, | |
| 172 | ]); | |
| 173 | } | |
| 174 | ||
| 175 | $this->cancel(); | |
| 176 | } | |
| 177 | ||
| 178 | // A recurring schedule simply runs again later; a one-shot would be lost. | |
| 179 | private function holdUntilResumed(Instant $scheduledFor): void | |
| 180 | { | |
| 181 | $this->heldOccurrence = $scheduledFor; | |
| 182 | $this->logger->debug('A one-shot schedule came due while its app is paused; it runs on resume', [ | |
| 183 | 'schedule' => $this->origin->description, | |
| 184 | 'due' => $scheduledFor->toIso8601(), | |
| 185 | ]); | |
| 186 | } | |
| 187 | ||
| 188 | /** @param Closure(ScheduledRun): void $handler */ | |
| 189 | private function startHandlerRun(Closure $handler, Instant $scheduledFor, int $missed): void | |
| 190 | { | |
| 191 | $run = new ScheduledRun($this->task, $scheduledFor, $this->context->clock->getNow(), $missed); | |
| 192 | ||
| 193 | if ($this->scopeState->isPaused()) { | |
| 194 | $this->context->listener->scheduledRunSuppressed($this->origin, $run); | |
| 195 | ||
| 196 | return; | |
| 197 | } | |
| 198 | ||
| 199 | if ($this->running) { | |
| 200 | $this->reportOverlap($run); | |
| 201 | ||
| 202 | return; | |
| 203 | } | |
| 204 | ||
| 205 | if ($run->missedOccurrences > 0) { | |
| 206 | $this->logger->info('A scheduled run started late; the occurrences it overran were skipped', [ | |
| 207 | 'schedule' => $this->origin->description, | |
| 208 | 'due' => $run->scheduledFor->toIso8601(), | |
| 209 | 'late_ms' => $run->getLateness()->toMilliseconds(), | |
| 210 | 'missed' => $run->missedOccurrences, | |
| 211 | ]); | |
| 212 | } | |
| 213 | ||
| 214 | $this->running = true; | |
| 215 | $this->overlapSkips = 0; | |
| 216 | $this->context->listener->scheduledRunStarted($this->origin, $run); | |
| 217 | ||
| 218 | $this->context->runner->run(function () use ($handler, $run): void { | |
| 219 | try { | |
| 220 | $handler($run); | |
| 221 | } catch (Throwable $e) { | |
| 222 | $this->context->listener->scheduledRunFailed($this->origin, $e); | |
| 223 | } finally { | |
| 224 | $this->running = false; | |
| 225 | } | |
| 226 | }); | |
| 227 | } | |
| 228 | ||
| 229 | private function reportOverlap(ScheduledRun $run): void | |
| 230 | { | |
| 231 | $skips = ++$this->overlapSkips; | |
| 232 | $context = [ | |
| 233 | 'schedule' => $this->origin->description, | |
| 234 | 'due' => $run->scheduledFor->toIso8601(), | |
| 235 | 'skipped' => $skips, | |
| 236 | ]; | |
| 237 | ||
| 238 | if ($skips === 1) { | |
| 239 | $this->logger->warning('Skipped a scheduled run; the previous one has not finished', $context); | |
| 240 | } elseif ($this->context->overlapReports->includesOccurrence($skips)) { | |
| 241 | $this->logger->debug('Still skipping scheduled runs; the previous one has not finished', $context); | |
| 242 | } | |
| 243 | } | |
| 244 | } |