Lines 90.90% 50 / 55
Methods 92.85% 13 / 14
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 arm 100.00% 1 / 1 100.00% 1 / 1 1
 armUnderTaskId 100.00% 21 / 21 100.00% 1 / 1 4
 claimTaskId 100.00% 1 / 1 100.00% 1 / 1 1
 startEntriesOf 100.00% 3 / 3 100.00% 1 / 1 3
 runHeldOccurrencesOf 100.00% 2 / 2 100.00% 1 / 1 2
 cancelEntriesOf 100.00% 2 / 2 100.00% 1 / 1 2
 cancelAll 100.00% 2 / 2 100.00% 1 / 1 2
 count 100.00% 1 / 1 100.00% 1 / 1 1
 countFor 100.00% 1 / 1 100.00% 1 / 1 1
 occursAtAll 100.00% 10 / 10 100.00% 1 / 1 3
 startEntry 28.57% 2 / 7 0.00% 0 / 1 6.28
 forgetEntry 100.00% 2 / 2 100.00% 1 / 1 2
 listEntriesOf 100.00% 1 / 1 100.00% 1 / 1 1
18final class ScheduleRegistry
19{
20    /** @var array<string, ScheduleEntry> */
21    private array $entries = [];
22
23    private int $counter = 0;
24
25    private readonly string $scheduleIdPrefix;
26
27    public function __construct(
28        WorkerId $workerId,
29        private readonly ScheduleContext $context,
30        private readonly TriggerFactory $triggers,
31        private readonly ScopeLifecycle $scopes,
32    ) {
33        $this->scheduleIdPrefix = $workerId->toProcessIdPrefix();
34    }
35
36    /** @param Closure(ScheduledRun): void $handler */
37    public function arm(ResourceScope $scope, WallClockSchedule|ElapsedSchedule $schedule, LoggerInterface $logger, Closure $handler): ScheduledTask
38    {
39        return $this->armUnderTaskId($this->claimTaskId(), $scope, $schedule, $logger, $handler);
40    }
41
42    /** @param Closure(ScheduledRun): void $handler */
43    public function armUnderTaskId(string $taskId, ResourceScope $scope, WallClockSchedule|ElapsedSchedule $schedule, LoggerInterface $logger, Closure $handler): ScheduledTask
44    {
45        $trigger = $this->triggers->createTriggerFor($schedule);
46        $scopeState = $this->scopes->findOrCreateStateOf($scope);
47        $entry = new ScheduleEntry(
48            new ScheduleOrigin($scope, $taskId, $trigger->describe()),
49            $trigger,
50            $logger,
51            $this->context,
52            $scopeState,
53            $handler,
54            $this->forgetEntry(...),
55        );
56
57        if ($scopeState->isClosed()) {
58            $logger->debug('Ignored a schedule armed after its app stopped', ['schedule' => $entry->origin->description]);
59            $entry->cancel();
60
61            return $entry->task;
62        }
63
64        if (!$this->occursAtAll($entry)) {
65            return $entry->task;
66        }
67
68        $this->entries[$entry->origin->taskId] = $entry;
69
70        if ($scopeState->isLive()) {
71            $this->startEntry($entry);
72        }
73
74        return $entry->task;
75    }
76
77    public function claimTaskId(): string
78    {
79        return $this->scheduleIdPrefix . ':' . $this->counter++;
80    }
81
82    public function startEntriesOf(ResourceScope $scope): void
83    {
84        foreach ($this->listEntriesOf($scope) as $entry) {
85            if (!$entry->started) {
86                $this->startEntry($entry);
87            }
88        }
89    }
90
91    public function runHeldOccurrencesOf(ResourceScope $scope): void
92    {
93        foreach ($this->listEntriesOf($scope) as $entry) {
94            $entry->runHeldOccurrence();
95        }
96    }
97
98    public function cancelEntriesOf(ResourceScope $scope): void
99    {
100        foreach ($this->listEntriesOf($scope) as $entry) {
101            $entry->cancel();
102        }
103    }
104
105    public function cancelAll(): void
106    {
107        foreach ($this->entries as $entry) {
108            $entry->cancel();
109        }
110    }
111
112    public function count(): int
113    {
114        return \count($this->entries);
115    }
116
117    public function countFor(ResourceScope $scope): int
118    {
119        return \count($this->listEntriesOf($scope));
120    }
121
122    private function occursAtAll(ScheduleEntry $entry): bool
123    {
124        try {
125            $occurs = $entry->occursAtAll();
126        } catch (Throwable $e) {
127            $this->context->listener->scheduleFailed($entry->origin, $e);
128            $entry->cancel();
129
130            return false;
131        }
132
133        if (!$occurs) {
134            $entry->warnNeverOccurs();
135            $entry->cancel();
136
137            return false;
138        }
139
140        return true;
141    }
142
143    private function startEntry(ScheduleEntry $entry): void
144    {
145        try {
146            $started = $entry->start();
147        } catch (Throwable $e) {
148            $this->context->listener->scheduleFailed($entry->origin, $e);
149            $entry->cancel();
150
151            return;
152        }
153
154        if (!$started) {
155            $entry->cancel();
156        }
157    }
158
159    private function forgetEntry(ScheduleEntry $entry): void
160    {
161        // A re-armed entity time reuses its task id, so only the entry still registered under it is removed.
162        if (($this->entries[$entry->origin->taskId] ?? null) === $entry) {
163            unset($this->entries[$entry->origin->taskId]);
164        }
165    }
166
167    /** @return array<string, ScheduleEntry> */
168    private function listEntriesOf(ResourceScope $scope): array
169    {
170        return array_filter($this->entries, static fn(ScheduleEntry $entry): bool => $entry->origin->scope->equals($scope));
171    }
172}