Lines 96.66% 29 / 30
Methods 85.71% 6 / 7
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 recordWorkerReady 100.00% 2 / 2 100.00% 1 / 1 1
 recordWorkerQuarantined 100.00% 2 / 2 100.00% 1 / 1 1
 scheduleOnceWorkersSettle 100.00% 7 / 7 100.00% 1 / 1 4
 reconcileIfDue 90.00% 9 / 10 0.00% 0 / 1 5.03
 haveAllWorkersSettled 100.00% 1 / 1 100.00% 1 / 1 1
 buildReconcile 100.00% 7 / 7 100.00% 1 / 1 4
21final class ExposureReconciler
22{
23    private const int GRACE_SECONDS_AFTER_WORKERS_SETTLE = 30;
24
25    /** @var array<int, AppIdCollection> */
26    private array $runningAppsByWorker = [];
27
28    private bool $graceStarted = false;
29
30    private bool $due = false;
31
32    public function __construct(
33        private readonly WorkerSlotCollection $workerSlots,
34        private readonly ExposureLink $exposures,
35        private readonly HaClient $client,
36        private readonly ComponentTracker $tracker,
37        private readonly ExposeConfig $expose,
38        private readonly Timers $timers,
39        private readonly LoggerInterface $logger,
40    ) {}
41
42    public function recordWorkerReady(WorkerId $workerId, AppIdCollection $runningApps): void
43    {
44        $this->runningAppsByWorker[$workerId->value] = $runningApps;
45        $this->scheduleOnceWorkersSettle();
46    }
47
48    public function recordWorkerQuarantined(WorkerId $workerId): void
49    {
50        $this->runningAppsByWorker[$workerId->value] = AppIdCollection::empty();
51        $this->scheduleOnceWorkersSettle();
52    }
53
54    public function scheduleOnceWorkersSettle(): void
55    {
56        if (!$this->expose->prune || $this->graceStarted || !$this->haveAllWorkersSettled()) {
57            return;
58        }
59
60        $this->graceStarted = true;
61        $this->timers->startTimer(Duration::seconds(self::GRACE_SECONDS_AFTER_WORKERS_SETTLE), function (): void {
62            $this->due = true;
63            $this->reconcileIfDue();
64        });
65    }
66
67    public function reconcileIfDue(): void
68    {
69        if (!$this->due || !$this->tracker->isActive()) {
70            return;
71        }
72
73        $this->due = false;
74
75        try {
76            $removed = $this->client->reconcileExposedEntities($this->buildReconcile());
77        } catch (HaClientException $e) {
78            $this->due = true;
79            $this->logger->debug('Pruning exposed entities waits for the next stewart integration session', ['exception' => $e]);
80
81            return;
82        }
83
84        if (!$removed->isEmpty()) {
85            $this->logger->info('Removed exposed entities that no app exposes any more', ['entities' => $removed->toStrings()]);
86        }
87    }
88
89    private function haveAllWorkersSettled(): bool
90    {
91        return !$this->workerSlots->containsWhere(fn(WorkerSlot $slot): bool => !isset($this->runningAppsByWorker[$slot->workerId->value]));
92    }
93
94    private function buildReconcile(): ExposedEntityReconcile
95    {
96        $keptApps = [];
97
98        foreach ($this->workerSlots as $slot) {
99            $runningApps = $this->runningAppsByWorker[$slot->workerId->value] ?? AppIdCollection::empty();
100
101            foreach ($slot->listAppIds() as $appId) {
102                if (!$runningApps->containsId($appId)) {
103                    $keptApps[] = $appId;
104                }
105            }
106        }
107
108        return new ExposedEntityReconcile($this->expose->instance, $this->exposures->listKeptAddresses(), AppIdCollection::fromList($keptApps));
109    }
110}