Lines 88.09% 111 / 126
Methods 83.33% 5 / 6
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 run 100.00% 30 / 30 100.00% 1 / 1 5
 cleanupSessions 100.00% 22 / 22 100.00% 1 / 1 4
 optimizeSearchIndex 62.50% 25 / 40 0.00% 0 / 1 11.38
 aggregateStatistics 100.00% 19 / 19 100.00% 1 / 1 2
 createBackup 100.00% 14 / 14 100.00% 1 / 1 2
29class TaskScheduler
30{
31    private const int DEFAULT_SESSION_RETENTION_SECONDS = 86_400;
32
33    public function __construct(
34        private readonly Configuration $configuration,
35        private readonly AdminSession $adminSession,
36        private readonly Backup $backup,
37        private readonly Statistics $statistics,
38    ) {
39    }
40
41    /**
42     * Runs all configured scheduler tasks.
43     *
44     * @return array<string, mixed>
45     */
46    public function run(): array
47    {
48        $results = [];
49
50        try {
51            $results['sessionCleanup'] = $this->cleanupSessions();
52        } catch (Throwable $throwable) {
53            $this->configuration->getLogger()->error('Scheduled session cleanup threw an exception.', [
54                'message' => $throwable->getMessage(),
55                'trace' => $throwable->getTraceAsString(),
56            ]);
57            $results['sessionCleanup'] = null;
58        }
59
60        try {
61            $results['searchOptimization'] = $this->optimizeSearchIndex();
62        } catch (Throwable $throwable) {
63            $this->configuration->getLogger()->error('Scheduled search optimization threw an exception.', [
64                'message' => $throwable->getMessage(),
65                'trace' => $throwable->getTraceAsString(),
66            ]);
67            $results['searchOptimization'] = null;
68        }
69
70        try {
71            $results['statisticsAggregation'] = $this->aggregateStatistics();
72        } catch (Throwable $throwable) {
73            $this->configuration->getLogger()->error('Scheduled statistics aggregation threw an exception.', [
74                'message' => $throwable->getMessage(),
75                'trace' => $throwable->getTraceAsString(),
76            ]);
77            $results['statisticsAggregation'] = null;
78        }
79
80        try {
81            $results['backupCreation'] = $this->createBackup();
82        } catch (Throwable $throwable) {
83            $this->configuration->getLogger()->error('Scheduled backup creation threw an exception.', [
84                'message' => $throwable->getMessage(),
85                'trace' => $throwable->getTraceAsString(),
86            ]);
87            $results['backupCreation'] = null;
88        }
89
90        return $results;
91    }
92
93    /**
94     * @return array{success: bool, cutoffTimestamp: int, retentionSeconds: int}
95     */
96    public function cleanupSessions(): array
97    {
98        $configuredRetention = (int) ($this->configuration->get('session.scheduler.retentionSeconds') ?? 0);
99        $retentionSeconds = $configuredRetention > 0 ? $configuredRetention : self::DEFAULT_SESSION_RETENTION_SECONDS;
100        $cutoffTimestamp = time() - $retentionSeconds;
101
102        try {
103            $success = $this->adminSession->deleteSessions(0, $cutoffTimestamp);
104        } catch (Throwable $throwable) {
105            $this->configuration->getLogger()->error('Scheduled session cleanup threw an exception.', [
106                'message' => $throwable->getMessage(),
107                'trace' => $throwable->getTraceAsString(),
108                'cutoffTimestamp' => $cutoffTimestamp,
109                'retentionSeconds' => $retentionSeconds,
110            ]);
111            $success = false;
112        }
113
114        if (!$success) {
115            $this->configuration->getLogger()->warning('Scheduled session cleanup failed.', [
116                'cutoffTimestamp' => $cutoffTimestamp,
117                'retentionSeconds' => $retentionSeconds,
118            ]);
119        }
120
121        return [
122            'success' => $success,
123            'cutoffTimestamp' => $cutoffTimestamp,
124            'retentionSeconds' => $retentionSeconds,
125        ];
126    }
127
128    /**
129     * @return array{success: bool, skipped: bool, elasticsearch: bool|null, opensearch: bool|null}
130     */
131    public function optimizeSearchIndex(): array
132    {
133        $elasticsearchResult = null;
134        $openSearchResult = null;
135
136        if ($this->configuration->get('search.enableElasticsearch')) {
137            try {
138                $this->configuration
139                    ->getElasticsearch()
140                    ->indices()
141                    ->forcemerge([
142                        'index' => $this->configuration->getElasticsearchConfig()->getIndex(),
143                        'max_num_segments' => 1,
144                    ]);
145                $elasticsearchResult = true;
146            } catch (Throwable $throwable) {
147                $elasticsearchResult = false;
148                $this->configuration->getLogger()->error('Scheduled Elasticsearch optimization failed.', [
149                    'message' => $throwable->getMessage(),
150                    'trace' => $throwable->getTraceAsString(),
151                ]);
152            }
153        }
154
155        if ($this->configuration->get('search.enableOpenSearch')) {
156            try {
157                $this->configuration
158                    ->getOpenSearch()
159                    ->indices()
160                    ->forcemerge([
161                        'index' => $this->configuration->getOpenSearchConfig()->getIndex(),
162                        'max_num_segments' => 1,
163                    ]);
164                $openSearchResult = true;
165            } catch (Throwable $throwable) {
166                $openSearchResult = false;
167                $this->configuration->getLogger()->error('Scheduled OpenSearch optimization failed.', [
168                    'message' => $throwable->getMessage(),
169                    'trace' => $throwable->getTraceAsString(),
170                ]);
171            }
172        }
173
174        $skipped = $elasticsearchResult === null && $openSearchResult === null;
175        $success = !$skipped && $elasticsearchResult !== false && $openSearchResult !== false;
176
177        return [
178            'success' => $success,
179            'skipped' => $skipped,
180            'elasticsearch' => $elasticsearchResult,
181            'opensearch' => $openSearchResult,
182        ];
183    }
184
185    /**
186     * @return array{success: bool, generatedAt: int, totalFaqs: int|null, totalSessions: int|null, error: string|null}
187     */
188    public function aggregateStatistics(): array
189    {
190        try {
191            return [
192                'success' => true,
193                'generatedAt' => time(),
194                'totalFaqs' => $this->statistics->totalFaqs(),
195                'totalSessions' => $this->adminSession->getNumberOfSessions(),
196                'error' => null,
197            ];
198        } catch (Throwable $throwable) {
199            $this->configuration->getLogger()->error('Scheduled statistics aggregation failed.', [
200                'message' => $throwable->getMessage(),
201                'trace' => $throwable->getTraceAsString(),
202            ]);
203
204            return [
205                'success' => false,
206                'generatedAt' => time(),
207                'totalFaqs' => null,
208                'totalSessions' => null,
209                'error' => $throwable->getMessage(),
210            ];
211        }
212    }
213
214    /**
215     * @return array{success: bool, fileName: string|null}
216     */
217    public function createBackup(): array
218    {
219        try {
220            $backupResult = $this->backup->export(BackupType::BACKUP_TYPE_DATA);
221
222            return [
223                'success' => true,
224                'fileName' => $backupResult->fileName,
225            ];
226        } catch (Throwable $throwable) {
227            $this->configuration->getLogger()->error('Scheduled backup creation failed.', [
228                'message' => $throwable->getMessage(),
229                'trace' => $throwable->getTraceAsString(),
230            ]);
231
232            return [
233                'success' => false,
234                'fileName' => null,
235            ];
236        }
237    }
238}