BackgroundTasksHandler   A
last analyzed

Complexity

Total Complexity 31

Size/Duplication

Total Lines 263
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 109
dl 0
loc 263
rs 9.92
c 2
b 0
f 0
wmc 31

12 Methods

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