BackgroundTasksHandler   A
last analyzed

Complexity

Total Complexity 30

Size/Duplication

Total Lines 259
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 105
c 1
b 0
f 0
dl 0
loc 259
rs 10
wmc 30

12 Methods

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