BackgroundTasksHandler   A
last analyzed

Complexity

Total Complexity 32

Size/Duplication

Total Lines 265
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 110
c 2
b 0
f 0
dl 0
loc 265
rs 9.84
wmc 32

12 Methods

Rating   Name   Duplication   Size   Complexity  
A acquireProcessLock() 0 11 3
A __construct() 0 7 2
A processBackgroundTasks() 0 15 5
A performMaintenanceTasks() 0 4 1
A processTaskBatches() 0 25 5
A releaseProcessLock() 0 4 3
A cleanOldFinishedTasks() 0 35 3
A processIndividualTask() 0 42 5
A cleanMultipleItemsEdition() 0 6 1
A cleanupStaleTasks() 0 22 1
A handleItemTokensExpiration() 0 5 2
A countRunningTasks() 0 4 1
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
        $this->maxTimeBeforeRemoval = isset($settings['history_duration']) ? ($settings['history_duration'] * 24 * 3600) : (15 * 24 * 3600);
50
    }
51
52
    /**
53
     * Main function to process background tasks
54
     */
55
    public function processBackgroundTasks() {
56
        // Prevent multiple concurrent executions
57
        if (!$this->acquireProcessLock()) {
58
            if (LOG_TASKS=== true) $this->logger->log('Process already running', 'INFO');
59
            return false;
60
        }
61
62
        try {
63
            $this->cleanupStaleTasks();
64
            $this->processTaskBatches();
65
            $this->performMaintenanceTasks();
66
        } catch (Exception $e) {
67
            if (LOG_TASKS=== true) $this->logger->log('Task processing error: ' . $e->getMessage(), 'ERROR');
68
        } finally {
69
            $this->releaseProcessLock();
70
        }
71
    }
72
73
    /**
74
     * Acquire a lock to prevent multiple instances of this script from running simultaneously.
75
     * @return bool
76
     */
77
    private function acquireProcessLock(): bool {
78
        $lockFile = empty(TASKS_LOCK_FILE) ? __DIR__.'/../files/teampass_background_tasks.lock' : TASKS_LOCK_FILE;
79
        
80
        $fp = fopen($lockFile, 'w');
81
        
82
        if (!flock($fp, LOCK_EX | LOCK_NB)) {
83
            return false;
84
        }
85
        
86
        fwrite($fp, (string)getmypid());
87
        return true;
88
    }
89
90
    /**
91
     * Release the lock file.
92
     */
93
    private function releaseProcessLock() {
94
        $lockFile = empty(TASKS_LOCK_FILE) ? __DIR__.'/../files/teampass_background_tasks.lock' : TASKS_LOCK_FILE;
95
        if (file_exists($lockFile)) {
96
            unlink($lockFile);
97
        }
98
    }
99
100
    /**
101
     * Cleanup stale tasks that have been running for too long or are marked as failed.
102
     */
103
    private function cleanupStaleTasks() {
104
        // Mark tasks as failed if they've been running too long
105
        DB::query(
106
            'UPDATE ' . prefixTable('background_tasks') . ' 
107
            SET is_in_progress = -1, 
108
                finished_at = %i, 
109
                status = "failed",
110
                error_message = "Task exceeded maximum execution time of '.$this->maxExecutionTime.' seconds"
111
            WHERE is_in_progress = 1 
112
            AND updated_at < %i',
113
            time(),
114
            time() - $this->maxExecutionTime
115
        );
116
117
        // Remove very old failed tasks
118
        DB::query(
119
            'DELETE t, st FROM ' . prefixTable('background_tasks') . ' t
120
            INNER JOIN ' . prefixTable('background_subtasks') . ' st ON (t.increment_id = st.task_id)
121
            WHERE t.finished_at < %i 
122
            AND t.status = %s',
123
            time() - $this->maxTimeBeforeRemoval,
124
            "failed"
125
        );
126
    }
127
128
    /**
129
     * Process batches of tasks.
130
     * This method fetches tasks from the database and processes them in parallel.
131
     */
132
    private function processTaskBatches() {
133
        $runningTasks = $this->countRunningTasks();
134
        
135
        // Check if the maximum number of parallel tasks is reached
136
        if ($runningTasks >= $this->maxParallelTasks) {
137
            if (LOG_TASKS=== true) $this->logger->log('Wait ... '.$runningTasks.' out of '.$this->maxParallelTasks.' are already running ', 'INFO');
138
            return;
139
        }
140
141
        $availableSlotsCount = $this->maxParallelTasks - $runningTasks;
142
143
        // Fetch next batch of tasks
144
        $tasks = DB::query(
145
            'SELECT increment_id, process_type, arguments 
146
            FROM ' . prefixTable('background_tasks') . '
147
            WHERE is_in_progress = 0 
148
            AND (finished_at IS NULL OR finished_at = "")
149
            ORDER BY increment_id ASC
150
            LIMIT %i',
151
            min($this->batchSize, $availableSlotsCount)
152
        );
153
154
        foreach ($tasks as $task) {
155
            if (LOG_TASKS=== true) $this->logger->log('Launching '.$task['increment_id'], 'INFO');
156
            $this->processIndividualTask($task);
157
        }
158
    }
159
160
    /**
161
     * Process an individual task.
162
     * This method updates the task status in the database and starts a new process for the task.
163
     * @param array $task The task to process.
164
     */
165
    private function processIndividualTask(array $task) {
166
        if (LOG_TASKS=== true)  $this->logger->log('Starting task: ' . print_r($task, true), 'INFO');
167
168
        // Store progress in the database        
169
        DB::update(
170
            prefixTable('background_tasks'),
171
            [
172
                'is_in_progress' => 1,
173
                'started_at' => time(),
174
                'updated_at' => time(),
175
                'status' => 'in_progress'
176
            ],
177
            'increment_id = %i',
178
            $task['increment_id']
179
        );
180
181
        // Prepare process
182
        $process = new Process([
183
            PHP_BINARY,
184
            __DIR__ . '/background_tasks___worker.php',
185
            $task['increment_id'],
186
            $task['process_type'],
187
            $task['arguments']
188
        ]);
189
190
        // Launch process
191
        try{
192
            $process->setTimeout($this->maxExecutionTime);
193
            $process->mustRun();
194
195
        } catch (Exception $e) {
196
            if (LOG_TASKS=== true) $this->logger->log('Error launching task: ' . $e->getMessage(), 'ERROR');
197
            DB::update(
198
                prefixTable('background_tasks'),
199
                [
200
                    'is_in_progress' => -1,
201
                    'finished_at' => time(),
202
                    'status' => 'failed',
203
                    '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...
204
                ],
205
                'increment_id = %i',
206
                $task['increment_id']
207
            );
208
        }
209
    }
210
211
    /**
212
     * Count the number of currently running tasks.
213
     * @return int The number of running tasks.
214
     */
215
    private function countRunningTasks(): int {
216
        return DB::queryFirstField(
217
            'SELECT COUNT(*) 
218
            FROM ' . prefixTable('background_tasks') . ' 
219
            WHERE is_in_progress = 1'
220
        );
221
    }
222
223
    /**
224
     * Perform maintenance tasks.
225
     * This method cleans up old items, expired tokens, and finished tasks.
226
     */
227
    private function performMaintenanceTasks() {
228
        $this->cleanMultipleItemsEdition();
229
        $this->handleItemTokensExpiration();
230
        $this->cleanOldFinishedTasks();
231
    }
232
233
    /**
234
     * Clean up multiple items edition.
235
     * This method removes duplicate entries in the items_edition table.
236
     */
237
    private function cleanMultipleItemsEdition() {
238
        DB::query(
239
            'DELETE i1 FROM ' . prefixTable('items_edition') . ' i1
240
            JOIN (
241
                SELECT user_id, item_id, MIN(timestamp) AS oldest_timestamp
242
                FROM ' . prefixTable('items_edition') . '
243
                GROUP BY user_id, item_id
244
            ) i2 ON i1.user_id = i2.user_id AND i1.item_id = i2.item_id
245
            WHERE i1.timestamp > i2.oldest_timestamp'
246
        );
247
    }
248
249
    /**
250
     * Handle item tokens expiration.
251
     * This method removes expired tokens from the items_edition table.
252
     */
253
    private function handleItemTokensExpiration() {
254
        DB::query(
255
            'DELETE FROM ' . prefixTable('items_edition') . '
256
            WHERE timestamp < %i',
257
            time() - ($this->settings['delay_item_edition'] * 60 ?: EDITION_LOCK_PERIOD)
258
        );
259
    }
260
261
    /**
262
     * Clean up old finished tasks.
263
     * This method removes tasks that have been completed for too long.
264
     */
265
    private function cleanOldFinishedTasks() {
266
        // Timestamp cutoff for removal
267
        $cutoffTimestamp = time() - $this->maxTimeBeforeRemoval;
268
    
269
        // 1. Get all finished tasks older than the cutoff timestamp
270
        //    and that are not in progress
271
        $tasks = DB::query(
272
            'SELECT increment_id FROM ' . prefixTable('background_tasks') . '
273
            WHERE status = %s AND is_in_progress = %i AND finished_at < %s',
274
            'completed',
275
            -1,
276
            $cutoffTimestamp
277
        );
278
        
279
        if (empty($tasks)) {
280
            return;
281
        }
282
    
283
        $taskIds = array_column($tasks, 'increment_id');
284
    
285
        // 2. Delete all subtasks related to these tasks
286
        DB::query(
287
            'DELETE FROM ' . prefixTable('background_subtasks') . '
288
            WHERE task_id IN %ls',
289
            $taskIds
290
        );
291
    
292
        // 3. Delete the tasks themselves
293
        DB::query(
294
            'DELETE FROM ' . prefixTable('background_tasks') . '
295
            WHERE increment_id IN %ls',
296
            $taskIds
297
        );
298
    
299
        if (LOG_TASKS=== true) $this->logger->log('Old finished tasks cleaned: ' . count($taskIds), 'INFO');
300
    }
301
}
302
303
304
305
// Main execution
306
try {
307
    $configManager = new ConfigManager();
308
    $settings = $configManager->getAllSettings();
309
    
310
    $tasksHandler = new BackgroundTasksHandler($settings);
311
    $tasksHandler->processBackgroundTasks();
312
} catch (Exception $e) {
313
    error_log('Teampass Background Tasks Error: ' . $e->getMessage());
314
}