Completed
Push — master ( 819272...f84b1b )
by Arman
13s
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 release(): bool
83
    {
84
        if (!$this->ownsLock || $this->lockHandle === null) {
85
            return true;
86
        }
87
88
        $unlocked = flock($this->lockHandle, LOCK_UN);
89
        $closed = fclose($this->lockHandle);
90
91
        $this->lockHandle = null;
92
        $this->ownsLock = false;
93
94
        $removed = true;
95
        if (fs()->exists($this->lockFile)) {
96
            $removed = fs()->remove($this->lockFile);
97
        }
98
99
        return $unlocked && $closed && $removed;
100
    }
101
102
    /**
103
     * Check if another process currently holds the lock.
104
     */
105
    public function isLocked(): bool
106
    {
107
        if (!fs()->exists($this->lockFile)) {
108
            return false;
109
        }
110
111
        $handle = fopen($this->lockFile, 'c+');
112
        if ($handle === false) {
113
            return true;
114
        }
115
116
        if (!flock($handle, LOCK_EX | LOCK_NB)) {
117
            fclose($handle);
118
            return true;
119
        }
120
121
        flock($handle, LOCK_UN);
122
        fclose($handle);
123
124
        return false;
125
    }
126
127
    private function sanitizeTaskName(string $taskName): string
128
    {
129
        $taskName = trim($taskName);
130
        if ($taskName === '') {
131
            return 'default';
132
        }
133
134
        // Keep safe filename chars only
135
        $taskName = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $taskName) ?? 'default';
136
        $taskName = trim($taskName, '._-');
137
138
        return $taskName !== '' ? $taskName : 'default';
139
    }
140
141
    private function resolveLockDirectory(?string $lockDirectory): string
142
    {
143
        $path = $lockDirectory ?? cron_config('lock_path');
144
        return $path === null ? $this->getDefaultLockDirectory() : $path;
145
    }
146
147
    private function getDefaultLockDirectory(): string
148
    {
149
        return base_dir() . DS . 'runtime' . DS . 'cron' . DS . 'locks';
150
    }
151
152
    private function ensureLockDirectoryExists(): void
153
    {
154
        if ($this->lockDirectory === '') {
155
            throw CronException::lockDirectoryNotWritable('');
156
        }
157
158
        $this->createDirectory($this->lockDirectory);
159
160
        if (!fs()->isWritable($this->lockDirectory)) {
161
            throw CronException::lockDirectoryNotWritable($this->lockDirectory);
162
        }
163
    }
164
165
    private function createDirectory(string $directory): void
166
    {
167
        if (fs()->isDirectory($directory)) {
168
            return;
169
        }
170
171
        $parent = dirname($directory);
172
        if ($parent && $parent !== $directory) {
173
            $this->createDirectory($parent);
174
        }
175
176
        // @phpstan-ignore-next-line
177
        if (!fs()->makeDirectory($directory) && !fs()->isDirectory($directory)) {
178
            throw CronException::lockDirectoryNotWritable($directory);
179
        }
180
    }
181
182
    /**
183
     * Removes stale lock files that are NOT currently locked by any process.
184
     * Safe because we take LOCK_EX before removing.
185
     */
186
    private function cleanupStaleLocks(): void
187
    {
188
        if (!fs()->isDirectory($this->lockDirectory)) {
189
            return;
190
        }
191
192
        $files = fs()->glob($this->lockDirectory . DS . '*.lock') ?: [];
193
        $now = time();
194
195
        foreach ($files as $file) {
196
            $handle = fopen($file, 'c+');
197
            if ($handle === false) {
198
                continue;
199
            }
200
201
            // If someone holds it, skip
202
            if (!flock($handle, LOCK_EX | LOCK_NB)) {
203
                fclose($handle);
204
                continue;
205
            }
206
207
            $timestamp = $this->readTimestampFromHandle($handle);
208
209
            if ($timestamp !== null && ($now - $timestamp) > $this->maxLockAge) {
210
                flock($handle, LOCK_UN);
211
                fclose($handle);
212
                fs()->remove($file);
213
                continue;
214
            }
215
216
            flock($handle, LOCK_UN);
217
            fclose($handle);
218
        }
219
    }
220
221
    private function writeTimestampToHandle($handle): bool
222
    {
223
        if (ftruncate($handle, 0) === false) {
224
            return false;
225
        }
226
        if (rewind($handle) === false) {
227
            return false;
228
        }
229
        if (fwrite($handle, (string) time()) === false) {
230
            return false;
231
        }
232
        if (fflush($handle) === false) {
233
            return false;
234
        }
235
236
        return true;
237
    }
238
239
    private function readTimestampFromHandle($handle): ?int
240
    {
241
        if (rewind($handle) === false) {
242
            return null;
243
        }
244
        $content = stream_get_contents($handle);
245
        if ($content === false) {
246
            return null;
247
        }
248
249
        $timestamp = (int) trim((string) $content);
250
        return $timestamp > 0 ? $timestamp : null;
251
    }
252
}
253