Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.10% covered (success)
88.10%
111 / 126
83.33% covered (success)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
TaskScheduler
88.10% covered (success)
88.10%
111 / 126
83.33% covered (success)
83.33%
5 / 6
22.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 run
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
5
 cleanupSessions
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
4
 optimizeSearchIndex
62.50% covered (warning)
62.50%
25 / 40
0.00% covered (danger)
0.00%
0 / 1
11.38
 aggregateStatistics
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 createBackup
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * Scheduled background tasks for phpMyFAQ.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-13
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Scheduler;
21
22use phpMyFAQ\Administration\Backup;
23use phpMyFAQ\Administration\Session as AdminSession;
24use phpMyFAQ\Configuration;
25use phpMyFAQ\Enums\BackupType;
26use phpMyFAQ\Faq\Statistics;
27use Throwable;
28
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}