Passed
Pull Request — master (#410)
by Arman
03:14
created

CronLock::release()   A

Complexity

Conditions 6
Paths 7

Size

Total Lines 18
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 6
eloc 10
c 2
b 1
f 0
nc 7
nop 0
dl 0
loc 18
rs 9.2222
1
<?php
2
3
namespace Quantum\Libraries\Cron;
4
5
use Quantum\Libraries\Cron\Exceptions\CronException;
6
7
/**
8
 * CronLock - file based lock with flock()
9
 *
10
 * Changes vs original:
11
 * - taskName sanitized for safe filename
12
 * - release() removes lock file
13
 * - stale cleanup reads timestamp from locked handle (no extra fs()->get)
14
 * - isLocked() is non-destructive (does NOT delete files); cleanup handles stale deletion
15
 * - optional refresh() to update timestamp while running
16
 */
17
class CronLock
18
{
19
    private string $lockDirectory;
20
    private string $taskName;
21
    private string $lockFile;
22
23
    /** @var resource|null */
24
    private $lockHandle = null;
25
26
    private bool $ownsLock = false;
27
    private int $maxLockAge;
28
29
    private const DEFAULT_MAX_LOCK_AGE = 86400;
30
31
    public function __construct(string $taskName, ?string $lockDirectory = null, ?int $maxLockAge = null)
32
    {
33
        $this->taskName = $this->sanitizeTaskName($taskName);
34
        $this->lockDirectory = $this->resolveLockDirectory($lockDirectory);
35
        $this->lockFile = $this->lockDirectory . DS . $this->taskName . '.lock';
36
        $this->maxLockAge = $maxLockAge ?? (int) cron_config('max_lock_age', self::DEFAULT_MAX_LOCK_AGE);
37
38
        $this->ensureLockDirectoryExists();
39
        $this->cleanupStaleLocks();
40
    }
41
42
    public function acquire(): bool
43
    {
44
        $this->lockHandle = fopen($this->lockFile, 'c+');
45
        if ($this->lockHandle === false) {
46
            $this->lockHandle = null;
47
            $this->ownsLock = false;
48
            return false;
49
        }
50
51
        if (!flock($this->lockHandle, LOCK_EX | LOCK_NB)) {
52
            fclose($this->lockHandle);
53
            $this->lockHandle = null;
54
            $this->ownsLock = false;
55
            return false;
56
        }
57
58
        if (!$this->writeTimestampToHandle($this->lockHandle)) {
59
            flock($this->lockHandle, LOCK_UN);
60
            fclose($this->lockHandle);
61
            $this->lockHandle = null;
62
            $this->ownsLock = false;
63
            return false;
64
        }
65
        $this->ownsLock = true;
66
67
        return true;
68
    }
69
70
    /**
71
     * Update lock timestamp (useful for long-running jobs)
72
     */
73
    public function refresh(): bool
74
    {
75
        if (!$this->ownsLock || $this->lockHandle === null) {
76
            return false;
77
        }
78
79
        return $this->writeTimestampToHandle($this->lockHandle);
80
    }
81
82
    public function getTimestamp(): int
83
    {
84
        if ($this->lockHandle === null) {
85
            return 0;
86
        }
87
88
        $timestamp = $this->readTimestampFromHandle($this->lockHandle);
89
        return $timestamp ?? 0;
90
    }
91
92
    public function release(): bool
93
    {
94
        if (!$this->ownsLock || $this->lockHandle === null) {
95
            return true;
96
        }
97
98
        $unlocked = flock($this->lockHandle, LOCK_UN);
99
        $closed = fclose($this->lockHandle);
100
101
        $this->lockHandle = null;
102
        $this->ownsLock = false;
103
104
        $removed = true;
105
        if (fs()->exists($this->lockFile)) {
106
            $removed = fs()->remove($this->lockFile);
107
        }
108
109
        return $unlocked && $closed && $removed;
110
    }
111
112
    /**
113
     * Check if another process currently holds the lock.
114
     */
115
    public function isLocked(): bool
116
    {
117
        if (!fs()->exists($this->lockFile)) {
118
            return false;
119
        }
120
121
        $handle = fopen($this->lockFile, 'c+');
122
        if ($handle === false) {
123
            return true;
124
        }
125
126
        if (!flock($handle, LOCK_EX | LOCK_NB)) {
127
            fclose($handle);
128
            return true;
129
        }
130
131
        flock($handle, LOCK_UN);
132
        fclose($handle);
133
134
        return false;
135
    }
136
137
    private function sanitizeTaskName(string $taskName): string
138
    {
139
        $taskName = trim($taskName);
140
        if ($taskName === '') {
141
            return 'default';
142
        }
143
144
        // Keep safe filename chars only
145
        $taskName = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $taskName) ?? 'default';
146
        $taskName = trim($taskName, '._-');
147
148
        return $taskName !== '' ? $taskName : 'default';
149
    }
150
151
    private function resolveLockDirectory(?string $lockDirectory): string
152
    {
153
        $path = $lockDirectory ?? cron_config('lock_path');
154
        return $path === null ? $this->getDefaultLockDirectory() : $path;
155
    }
156
157
    private function getDefaultLockDirectory(): string
158
    {
159
        return base_dir() . DS . 'runtime' . DS . 'cron' . DS . 'locks';
160
    }
161
162
    private function ensureLockDirectoryExists(): void
163
    {
164
        if ($this->lockDirectory === '') {
165
            throw CronException::lockDirectoryNotWritable('');
166
        }
167
168
        $this->createDirectory($this->lockDirectory);
169
170
        if (!fs()->isWritable($this->lockDirectory)) {
171
            throw CronException::lockDirectoryNotWritable($this->lockDirectory);
172
        }
173
    }
174
175
    private function createDirectory(string $directory): void
176
    {
177
        if (fs()->isDirectory($directory)) {
178
            return;
179
        }
180
181
        $parent = dirname($directory);
182
        if ($parent && $parent !== $directory) {
183
            $this->createDirectory($parent);
184
        }
185
186
        // @phpstan-ignore-next-line
187
        if (!fs()->makeDirectory($directory) && !fs()->isDirectory($directory)) {
188
            throw CronException::lockDirectoryNotWritable($directory);
189
        }
190
    }
191
192
    /**
193
     * Removes stale lock files that are NOT currently locked by any process.
194
     * Safe because we take LOCK_EX before removing.
195
     */
196
    private function cleanupStaleLocks(): void
197
    {
198
        if (!fs()->isDirectory($this->lockDirectory)) {
199
            return;
200
        }
201
202
        $files = fs()->glob($this->lockDirectory . DS . '*.lock') ?: [];
203
        $now = time();
204
205
        foreach ($files as $file) {
206
            $handle = fopen($file, 'c+');
207
            if ($handle === false) {
208
                continue;
209
            }
210
211
            // If someone holds it, skip
212
            if (!flock($handle, LOCK_EX | LOCK_NB)) {
213
                fclose($handle);
214
                continue;
215
            }
216
217
            $timestamp = $this->readTimestampFromHandle($handle);
218
219
            if ($timestamp !== null && ($now - $timestamp) > $this->maxLockAge) {
220
                flock($handle, LOCK_UN);
221
                fclose($handle);
222
                fs()->remove($file);
223
                continue;
224
            }
225
226
            flock($handle, LOCK_UN);
227
            fclose($handle);
228
        }
229
    }
230
231
    private function writeTimestampToHandle($handle): bool
232
    {
233
        if (ftruncate($handle, 0) === false) {
234
            return false;
235
        }
236
        if (rewind($handle) === false) {
237
            return false;
238
        }
239
240
        $timestamp = (string) time();
241
        $bytes = fwrite($handle, $timestamp);
242
243
        if ($bytes === false) {
244
            return false;
245
        }
246
247
        if (fflush($handle) === false) {
248
            return false;
249
        }
250
251
        return true;
252
    }
253
254
    private function readTimestampFromHandle($handle): ?int
255
    {
256
        if (rewind($handle) === false) {
257
            return null;
258
        }
259
        $content = stream_get_contents($handle);
260
        if ($content === false) {
261
            return null;
262
        }
263
264
        $timestamp = (int) trim((string) $content);
265
        return $timestamp > 0 ? $timestamp : null;
266
    }
267
}
268