Passed
Pull Request — master (#4676)
by Nils
05:33
created

BackgroundTasksHandler   A

Complexity

Total Complexity 28

Size/Duplication

Total Lines 244
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 95
c 1
b 0
f 0
dl 0
loc 244
rs 10
wmc 28

12 Methods

Rating   Name   Duplication   Size   Complexity  
A acquireProcessLock() 0 11 3
A performMaintenanceTasks() 0 4 1
A processTaskBatches() 0 25 5
A releaseProcessLock() 0 3 2
A cleanOldFinishedTasks() 0 35 3
A __construct() 0 7 2
A processIndividualTask() 0 26 2
A cleanMultipleItemsEdition() 0 6 1
A cleanupStaleTasks() 0 21 1
A processBackgroundTasks() 0 15 5
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');
1 ignored issue
show
introduced by
The condition LOG_TASKS === true is always false.
Loading history...
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');
1 ignored issue
show
introduced by
The condition LOG_TASKS === true is always false.
Loading history...
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');
1 ignored issue
show
introduced by
The condition LOG_TASKS === true is always false.
Loading history...
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('Processing task: ' . print_r($task, true), 'INFO');
1 ignored issue
show
introduced by
The condition LOG_TASKS === true is always false.
Loading history...
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
        $process->run();
188
    }
189
190
    /**
191
     * Count the number of currently running tasks.
192
     * @return int The number of running tasks.
193
     */
194
    private function countRunningTasks(): int {
195
        return DB::queryFirstField(
196
            'SELECT COUNT(*) 
197
            FROM ' . prefixTable('background_tasks') . ' 
198
            WHERE is_in_progress = 1'
199
        );
200
    }
201
202
    /**
203
     * Perform maintenance tasks.
204
     * This method cleans up old items, expired tokens, and finished tasks.
205
     */
206
    private function performMaintenanceTasks() {
207
        $this->cleanMultipleItemsEdition();
208
        $this->handleItemTokensExpiration();
209
        $this->cleanOldFinishedTasks();
210
    }
211
212
    /**
213
     * Clean up multiple items edition.
214
     * This method removes duplicate entries in the items_edition table.
215
     */
216
    private function cleanMultipleItemsEdition() {
217
        DB::query(
218
            'DELETE i1 FROM ' . prefixTable('items_edition') . ' i1
219
            JOIN (
220
                SELECT user_id, item_id, MIN(timestamp) AS oldest_timestamp
221
                FROM ' . prefixTable('items_edition') . '
222
                GROUP BY user_id, item_id
223
            ) i2 ON i1.user_id = i2.user_id AND i1.item_id = i2.item_id
224
            WHERE i1.timestamp > i2.oldest_timestamp'
225
        );
226
    }
227
228
    /**
229
     * Handle item tokens expiration.
230
     * This method removes expired tokens from the items_edition table.
231
     */
232
    private function handleItemTokensExpiration() {
233
        DB::query(
234
            'DELETE FROM ' . prefixTable('items_edition') . '
235
            WHERE timestamp < %i',
236
            time() - ($this->settings['delay_item_edition'] * 60 ?: EDITION_LOCK_PERIOD)
237
        );
238
    }
239
240
    /**
241
     * Clean up old finished tasks.
242
     * This method removes tasks that have been completed for too long.
243
     */
244
    private function cleanOldFinishedTasks() {
245
        // Timestamp cutoff for removal
246
        $cutoffTimestamp = time() - $this->maxTimeBeforeRemoval;
247
    
248
        // 1. Get all finished tasks older than the cutoff timestamp
249
        //    and that are not in progress
250
        $tasks = DB::query(
251
            'SELECT increment_id FROM ' . prefixTable('background_tasks') . '
252
            WHERE status = %s AND is_in_progress = %i AND finished_at < %s',
253
            'completed',
254
            -1,
255
            $cutoffTimestamp
256
        );
257
        
258
        if (empty($tasks)) {
259
            return;
260
        }
261
    
262
        $taskIds = array_column($tasks, 'increment_id');
263
    
264
        // 2. Delete all subtasks related to these tasks
265
        DB::query(
266
            'DELETE FROM ' . prefixTable('background_subtasks') . '
267
            WHERE task_id IN %ls',
268
            $taskIds
269
        );
270
    
271
        // 3. Delete the tasks themselves
272
        DB::query(
273
            'DELETE FROM ' . prefixTable('background_tasks') . '
274
            WHERE increment_id IN %ls',
275
            $taskIds
276
        );
277
    
278
        if (LOG_TASKS=== true) $this->logger->log('Old finished tasks cleaned: ' . count($taskIds), 'INFO');
1 ignored issue
show
introduced by
The condition LOG_TASKS === true is always false.
Loading history...
279
    }
280
}
281
282
283
284
// Main execution
285
try {
286
    $configManager = new ConfigManager();
287
    $settings = $configManager->getAllSettings();
288
    
289
    $tasksHandler = new BackgroundTasksHandler($settings);
290
    $tasksHandler->processBackgroundTasks();
291
} catch (Exception $e) {
292
    error_log('Teampass Background Tasks Error: ' . $e->getMessage());
293
}