Lines 95.83% 92 / 96
Methods 69.23% 9 / 13
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __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
17final 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}