Lines 89.13% 82 / 92
Functions and Methods 57.14% 8 / 14
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
AppLifecycle 89.13% 82 / 92 57.14% 8 / 14 45.37 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 constructApps 100.00% 16 / 16 100.00% 1 / 1 6
 initializeApps 92.30% 12 / 13 0.00% 0 / 1 7.02
 listSlots 0.00% 0 / 1 0.00% 0 / 1 2
 listAppIdsInState 100.00% 2 / 2 100.00% 1 / 1 1
 markStopping 100.00% 1 / 1 100.00% 1 / 1 1
 disposeAppsBefore 100.00% 5 / 5 100.00% 1 / 1 3
 abandonUnfinishedApps 100.00% 5 / 5 100.00% 1 / 1 2
 isStopRequested 100.00% 1 / 1 100.00% 1 / 1 1
 moveToPhase 66.66% 2 / 3 0.00% 0 / 1 2.15
 constructApp 50.00% 5 / 10 0.00% 0 / 1 6.00
 initializeApp 100.00% 22 / 22 100.00% 1 / 1 9
 disposeApp 85.71% 6 / 7 0.00% 0 / 1 3.03
 failApp 80.00% 4 / 5 0.00% 0 / 1 2.03
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Runtime\Worker;
6
7use Amp\Cancellation;
8use Amp\CancelledException;
9use LogicException;
10use Stewart\Contracts\App;
11use Stewart\Contracts\App\Collection\AppIdCollection;
12use Stewart\Contracts\Exception\StewartException;
13use Stewart\Runtime\Container\AppContainerBuilder;
14use Stewart\Runtime\Container\AppRuntimeServices;
15use Stewart\Runtime\Container\TypedContainer;
16use Stewart\Runtime\Exception\AppError;
17use Stewart\Runtime\Exception\AppException;
18use Stewart\Runtime\Ipc\Message\Bootstrap;
19use Stewart\Runtime\Ipc\WorkerApp;
20use Stewart\Runtime\Lifecycle\AppFailurePhase;
21use Stewart\Runtime\Lifecycle\AppLifecyclePhase;
22use Stewart\Runtime\Lifecycle\AppState;
23use Stewart\Runtime\Model\ResourceScope;
24use Throwable;
25
26use function Amp\async;
27use function Amp\Future\await;
28
29final class AppLifecycle
30{
31    /** @var list<AppSlot> */
32    private readonly array $slots;
33
34    private AppLifecyclePhase $phase = AppLifecyclePhase::Starting;
35
36    public function __construct(
37        private readonly Bootstrap $bootstrap,
38        private readonly AppRuntimeServices $runtime,
39        private readonly AppResources $resources,
40        private readonly AppFailureReporter $failures,
41        private readonly AppContainerBuilder $containers,
42    ) {
43        $this->slots = $this->bootstrap->apps->collection->mapToList(static fn(WorkerApp $definition): AppSlot => new AppSlot($definition));
44    }
45
46    public function constructApps(): void
47    {
48        try {
49            $build = $this->containers->buildAppContainer(
50                apps: $this->bootstrap->apps->collection,
51                runtime: $this->runtime,
52                servicesFile: $this->bootstrap->settings->servicesFile,
53                workerId: $this->bootstrap->workerId,
54            );
55        } catch (StewartException $e) {
56            foreach ($this->slots as $slot) {
57                $this->failApp($slot, AppFailurePhase::Construct, $e);
58            }
59
60            return;
61        }
62
63        foreach ($this->slots as $slot) {
64            $failure = $build->failures->find($slot->id());
65
66            if ($failure !== null) {
67                $this->failApp($slot, AppFailurePhase::Construct, $failure->error);
68            } elseif ($slot->isInState(AppState::Declared)) {
69                $this->constructApp($build->container, $slot);
70            }
71        }
72    }
73
74    public function initializeApps(): void
75    {
76        $attempts = [];
77
78        foreach ($this->slots as $slot) {
79            if ($slot->isInState(AppState::Declared) || $slot->isInState(AppState::Constructing)) {
80                throw new LogicException(\sprintf('App "%s" is still being constructed.', $slot->id()));
81            }
82
83            if (!$slot->isInState(AppState::Constructed)) {
84                continue;
85            }
86
87            if ($this->isStopRequested()) {
88                $slot->moveTo(AppState::Skipped);
89
90                continue;
91            }
92
93            $slot->moveTo(AppState::Initializing);
94            $attempts[] = async(fn() => $this->initializeApp($slot));
95        }
96
97        await($attempts);
98
99        if (!$this->isStopRequested()) {
100            $this->resources->activateScope(ResourceScope::shared());
101        }
102    }
103
104    /** @return list<AppSlot> */
105    public function listSlots(): array
106    {
107        return $this->slots;
108    }
109
110    public function listAppIdsInState(AppState $state): AppIdCollection
111    {
112        $inState = array_filter($this->slots, static fn(AppSlot $slot): bool => $slot->isInState($state));
113
114        return AppIdCollection::fromList(array_map(static fn(AppSlot $slot) => $slot->id(), $inState));
115    }
116
117    public function markStopping(): void
118    {
119        $this->moveToPhase(AppLifecyclePhase::Stopping);
120    }
121
122    /** @throws CancelledException */
123    public function disposeAppsBefore(Cancellation $deadline): void
124    {
125        $disposals = [];
126
127        foreach ($this->slots as $slot) {
128            if ($slot->needsDisposal()) {
129                $disposals[] = async(fn() => $this->disposeApp($slot));
130            }
131        }
132
133        await($disposals, $deadline);
134    }
135
136    public function abandonUnfinishedApps(): AppIdCollection
137    {
138        $this->moveToPhase(AppLifecyclePhase::Closed);
139
140        $abandoned = array_filter($this->slots, static fn(AppSlot $slot): bool => !$slot->state->isTerminal());
141
142        foreach ($abandoned as $slot) {
143            $slot->moveTo(AppState::Abandoned);
144        }
145
146        return AppIdCollection::fromList(array_map(static fn(AppSlot $slot) => $slot->id(), $abandoned));
147    }
148
149    private function isStopRequested(): bool
150    {
151        return $this->phase !== AppLifecyclePhase::Starting;
152    }
153
154    private function moveToPhase(AppLifecyclePhase $phase): void
155    {
156        if (!$this->phase->canEnter($phase)) {
157            throw new LogicException(\sprintf('App lifecycle cannot move from %s to %s.', $this->phase->name, $phase->name));
158        }
159
160        $this->phase = $phase;
161    }
162
163    private function constructApp(TypedContainer $services, AppSlot $slot): void
164    {
165        if ($this->isStopRequested()) {
166            $slot->moveTo(AppState::Skipped);
167
168            return;
169        }
170
171        $slot->moveTo(AppState::Constructing);
172
173        try {
174            $app = $services->resolveService(App::class, AppContainerBuilder::buildAppServiceId($slot->id()));
175        } catch (Throwable $e) {
176            $this->failApp($slot, AppFailurePhase::Construct, $e);
177
178            return;
179        }
180
181        if (!$slot->isInState(AppState::Abandoned)) {
182            $slot->markConstructed($app);
183        }
184    }
185
186    private function initializeApp(AppSlot $slot): void
187    {
188        $attempt = async(function () use ($slot): void {
189            $slot->app()->initialize();
190
191            if ($slot->isInState(AppState::Failed)) {
192                // initialize() finished after the timeout failed it; dispose so it releases what it opened.
193                $this->disposeApp($slot);
194            }
195        });
196
197        $timeout = $this->bootstrap->settings->initializeTimeout;
198        $deadline = $timeout === null ? null : $this->runtime->timers->timeout($timeout);
199
200        try {
201            $attempt->await($deadline);
202        } catch (Throwable $e) {
203            if ($e instanceof CancelledException && $timeout !== null && $deadline?->isRequested() === true) {
204                $attempt->ignore();
205                $e = AppException::createForReason(AppError::InitializeTimedOut, $e, appId: $slot->id()->value, timeout: (string) $timeout);
206            }
207
208            $this->failApp($slot, AppFailurePhase::Initialize, $e);
209
210            return;
211        }
212
213        if ($slot->isInState(AppState::Abandoned)) {
214            return;
215        }
216
217        if ($this->isStopRequested()) {
218            $slot->moveTo(AppState::Initialized);
219            $this->disposeApp($slot);
220
221            return;
222        }
223
224        $slot->moveTo(AppState::Running);
225        $this->resources->activateScope($slot->scope());
226    }
227
228    private function disposeApp(AppSlot $slot): void
229    {
230        if (!$slot->claimDisposal()) {
231            return;
232        }
233
234        $this->resources->releaseScope($slot->scope());
235
236        try {
237            $slot->app()->dispose();
238        } catch (Throwable $e) {
239            $this->failures->report($slot->scope(), AppFailurePhase::Dispose, $e);
240        }
241
242        $slot->markDisposed();
243    }
244
245    private function failApp(AppSlot $slot, AppFailurePhase $phase, Throwable $error): void
246    {
247        if ($slot->isInState(AppState::Abandoned)) {
248            return;
249        }
250
251        $slot->moveTo(AppState::Failed);
252        $this->resources->releaseScope($slot->scope());
253        $this->failures->report($slot->scope(), $phase, $error);
254    }
255}