cleanMultipleItemsEdition()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 0
dl 0
loc 6
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * Teampass - a collaborative passwords manager.
4
 * ---
5
 * This file is part of the TeamPass project.
6
 * 
7
 * TeamPass is free software: you can redistribute it and/or modify it
8
 * under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation, version 3 of the License.
10
 * 
11
 * TeamPass is distributed in the hope that it will be useful,
12
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
 * GNU General Public License for more details.
15
 * 
16
 * You should have received a copy of the GNU General Public License
17
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18
 * 
19
 * Certain components of this file may be under different licenses. For
20
 * details, see the `licenses` directory or individual file headers.
21
 * ---
22
 * @file      background_tasks___handler.php
23
 * @author    Nils Laumaillé ([email protected])
24
 * @copyright 2009-2025 Teampass.net
25
 * @license   GPL-3.0
26
 * @see       https://www.teampass.net
27
 */
28
29
use Symfony\Component\Process\Process;
30
use TeampassClasses\ConfigManager\ConfigManager;
31
32
require_once __DIR__.'/../sources/main.functions.php';
33
require_once __DIR__ . '/taskLogger.php';
34
35
class BackgroundTasksHandler {
36
    private $settings;
37
    private $logger;
38
    private $maxParallelTasks;
39
    private $maxExecutionTime;
40
    private $batchSize;
41
    private $maxTimeBeforeRemoval;
42
43
    public function __construct(array $settings) {
44
        $this->settings = $settings;
45
        $this->logger = new TaskLogger($settings, LOG_TASKS_FILE);
46
        $this->maxParallelTasks = $settings['max_parallel_tasks'] ?? 2;
47
        $this->maxExecutionTime = $settings['task_maximum_run_time'] ?? 600;
48
        $this->batchSize = $settings['task_batch_size'] ?? 50;
49
        // Tasks history retention (seconds)
50
        // Prefer new setting `tasks_history_delay` (stored in seconds in DB),
51
        // fallback to legacy `history_duration` (days) if present, otherwise 15 days.
52
        $historyDelay = 0;
53
54
        if (isset($settings['tasks_history_delay']) === true) {
55
            $historyDelay = (int) $settings['tasks_history_delay'];
56
        } elseif (isset($settings['history_duration']) === true) {
57
            $historyDelay = (int) $settings['history_duration'] * 86400;
58
        }
59
60
        // Safety: if for any reason it is stored in days, convert to seconds.
61
        if ($historyDelay > 0 && $historyDelay < 86400) {
62
            $historyDelay = $historyDelay * 86400;
63
        }
64
65
$this->maxTimeBeforeRemoval = $historyDelay > 0 ? $historyDelay : (15 * 86400);
66
67
    }
68
69
    /**
70
     * Main function to process background tasks
71
     */
72
    public function processBackgroundTasks() {
73
        // Prevent multiple concurrent executions
74
        if (!$this->acquireProcessLock()) {
75
            if (LOG_TASKS=== true) $this->logger->log('Process already running', 'INFO');
76
            return false;
77
        }
78
79
        try {
80
            $this->cleanupStaleTasks();
81
            $this->processTaskBatches();
82
            $this->performMaintenanceTasks();
83
        } catch (Exception $e) {
84
            if (LOG_TASKS=== true) $this->logger->log('Task processing error: ' . $e->getMessage(), 'ERROR');
85
        } finally {
86
            $this->releaseProcessLock();
87
        }
88
    }
89
90
    /**
91
     * Acquire a lock to prevent multiple instances of this script from running simultaneously.
92
     * @return bool
93
     */
94
    private function acquireProcessLock(): bool {
95
        $lockFile = empty(TASKS_LOCK_FILE) ? __DIR__.'/../files/teampass_background_tasks.lock' : TASKS_LOCK_FILE;
96
        
97
        $fp = fopen($lockFile, 'w');
98
        
99
        if (!flock($fp, LOCK_EX | LOCK_NB)) {
100
            return false;
101
        }
102
        
103
        fwrite($fp, (string)getmypid());
104
        return true;
105
    }
106
107
    /**
108
     * Release the lock file.
109
     */
110
    private function releaseProcessLock() {
111
        $lockFile = empty(TASKS_LOCK_FILE) ? __DIR__.'/../files/teampass_background_tasks.lock' : TASKS_LOCK_FILE;
112
        if (file_exists($lockFile)) {
113
            unlink($lockFile);
114
        }
115
    }
116
117
    /**
118
     * Cleanup stale tasks that have been running for too long or are marked as failed.
119
     */
120
    private function cleanupStaleTasks() {
121
        // Mark tasks as failed if they've been running too long
122
        DB::query(
123
            'UPDATE ' . prefixTable('background_tasks') . ' 
124
            SET is_in_progress = -1, 
125
                finished_at = %i, 
126
                status = "failed",
127
                error_message = "Task exceeded maximum execution time of '.$this->maxExecutionTime.' seconds"
128
            WHERE is_in_progress = 1 
129
            AND updated_at < %i',
130
            time(),
131
            time() - $this->maxExecutionTime
132
        );
133
134
        // Remove very old failed tasks
135
        DB::query(
136
            'DELETE t, st FROM ' . prefixTable('background_tasks') . ' t
137
            INNER JOIN ' . prefixTable('background_subtasks') . ' st ON (t.increment_id = st.task_id)
138
            WHERE t.finished_at > 0
139
              AND t.finished_at < %i
140
              AND t.status = %s',
141
            time() - $this->maxTimeBeforeRemoval,
142
            'failed'
143
        );
144
    }
145
146
    /**
147
     * Process batches of tasks.
148
     * This method fetches tasks from the database and processes them in parallel.
149
     */
150
    private function processTaskBatches() {
151
        $runningTasks = $this->countRunningTasks();
152
        
153
        // Check if the maximum number of parallel tasks is reached
154
        if ($runningTasks >= $this->maxParallelTasks) {
155
            if (LOG_TASKS=== true) $this->logger->log('Wait ... '.$runningTasks.' out of '.$this->maxParallelTasks.' are already running ', 'INFO');
156
            return;
157
        }
158
159
        $availableSlotsCount = $this->maxParallelTasks - $runningTasks;
160
161
        // Fetch next batch of tasks
162
        $tasks = DB::query(
163
            'SELECT increment_id, process_type, arguments 
164
            FROM ' . prefixTable('background_tasks') . '
165
            WHERE is_in_progress = 0 
166
            AND (finished_at IS NULL OR finished_at = "")
167
            ORDER BY increment_id ASC
168
            LIMIT %i',
169
            min($this->batchSize, $availableSlotsCount)
170
        );
171
172
        foreach ($tasks as $task) {
173
            if (LOG_TASKS=== true) $this->logger->log('Launching '.$task['increment_id'], 'INFO');
174
            $this->processIndividualTask($task);
175
        }
176
    }
177
178
    /**
179
     * Process an individual task.
180
     * This method updates the task status in the database and starts a new process for the task.
181
     * @param array $task The task to process.
182
     */
183
    private function processIndividualTask(array $task) {
184
        if (LOG_TASKS=== true)  $this->logger->log('Starting task: ' . print_r($task, true), 'INFO');
185
186
        // Store progress in the database        
187
        DB::update(
188
            prefixTable('background_tasks'),
189
            [
190
                'is_in_progress' => 1,
191
                'started_at' => time(),
192
                'updated_at' => time(),
193
                'status' => 'in_progress'
194
            ],
195
            'increment_id = %i',
196
            $task['increment_id']
197
        );
198
199
        // Prepare process
200
        $process = new Process([
201
            PHP_BINARY,
202
            __DIR__ . '/background_tasks___worker.php',
203
            $task['increment_id'],
204
            $task['process_type'],
205
            $task['arguments']
206
        ]);
207
208
        // Launch process
209
        try{
210
            $process->setTimeout($this->maxExecutionTime);
211
            $process->mustRun();
212
213
        } catch (Exception $e) {
214
            if (LOG_TASKS=== true) $this->logger->log('Error launching task: ' . $e->getMessage(), 'ERROR');
215
            DB::update(
216
                prefixTable('background_tasks'),
217
                [
218
                    'is_in_progress' => -1,
219
                    'finished_at' => time(),
220
                    'status' => 'failed',
221
                    'error_message' => is_null($e->getMessage()) ? 'Unknown error' : $e->getMessage()
0 ignored issues
show
introduced by
The condition is_null($e->getMessage()) is always false.
Loading history...
222
                ],
223
                'increment_id = %i',
224
                $task['increment_id']
225
            );
226
        }
227
    }
228
229
    /**
230
     * Count the number of currently running tasks.
231
     * @return int The number of running tasks.
232
     */
233
    private function countRunningTasks(): int {
234
        return DB::queryFirstField(
235
            'SELECT COUNT(*) 
236
            FROM ' . prefixTable('background_tasks') . ' 
237
            WHERE is_in_progress = 1'
238
        );
239
    }
240
241
    /**
242
     * Perform maintenance tasks.
243
     * This method cleans up old items, expired tokens, and finished tasks.
244
     */
245
    private function performMaintenanceTasks() {
246
        $this->cleanMultipleItemsEdition();
247
        $this->handleItemTokensExpiration();
248
        $this->cleanOldFinishedTasks();
249
    }
250
251
    /**
252
     * Clean up multiple items edition.
253
     * This method removes duplicate entries in the items_edition table.
254
     */
255
    private function cleanMultipleItemsEdition() {
256
        DB::query(
257
            'DELETE i1 FROM ' . prefixTable('items_edition') . ' i1
258
            JOIN (
259
                SELECT user_id, item_id, MIN(timestamp) AS oldest_timestamp
260
                FROM ' . prefixTable('items_edition') . '
261
                GROUP BY user_id, item_id
262
            ) i2 ON i1.user_id = i2.user_id AND i1.item_id = i2.item_id
263
            WHERE i1.timestamp > i2.oldest_timestamp'
264
        );
265
    }
266
267
    /**
268
     * Handle item tokens expiration.
269
     * This method removes expired tokens from the items_edition table.
270
     */
271
    private function handleItemTokensExpiration() {
272
        DB::query(
273
            'DELETE FROM ' . prefixTable('items_edition') . '
274
            WHERE timestamp < %i',
275
            time() - ($this->settings['delay_item_edition'] * 60 ?: EDITION_LOCK_PERIOD)
276
        );
277
    }
278
279
    /**
280
     * Clean up old finished tasks.
281
     * This method removes tasks that have been completed for too long.
282
     */
283
    private function cleanOldFinishedTasks() {
284
        // Timestamp cutoff for removal
285
        $cutoffTimestamp = time() - $this->maxTimeBeforeRemoval;
286
    
287
        // 1. Get all finished tasks older than the cutoff timestamp
288
        //    and that are not in progress
289
        $tasks = DB::query(
290
            'SELECT increment_id FROM ' . prefixTable('background_tasks') . '
291
            WHERE status = %s AND is_in_progress = %i AND finished_at < %i',
292
            'completed',
293
            -1,
294
            $cutoffTimestamp
295
        );
296
        
297
        if (empty($tasks)) {
298
            return;
299
        }
300
    
301
        $taskIds = array_column($tasks, 'increment_id');
302
    
303
        // 2. Delete all subtasks related to these tasks
304
        DB::query(
305
            'DELETE FROM ' . prefixTable('background_subtasks') . '
306
            WHERE task_id IN %ls',
307
            $taskIds
308
        );
309
    
310
        // 3. Delete the tasks themselves
311
        DB::query(
312
            'DELETE FROM ' . prefixTable('background_tasks') . '
313
            WHERE increment_id IN %ls',
314
            $taskIds
315
        );
316
    
317
        if (LOG_TASKS=== true) $this->logger->log('Old finished tasks cleaned: ' . count($taskIds), 'INFO');
318
    }
319
}
320
321
322
323
// Main execution
324
try {
325
    $configManager = new ConfigManager();
326
    $settings = $configManager->getAllSettings();
327
    
328
    $tasksHandler = new BackgroundTasksHandler($settings);
329
    $tasksHandler->processBackgroundTasks();
330
} catch (Exception $e) {
331
    error_log('Teampass Background Tasks Error: ' . $e->getMessage());
332
}
333