Passed
Pull Request — master (#408)
by
unknown
03:52
created

CronLock::cleanupStaleLocks()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 6
c 1
b 0
f 0
nc 4
nop 0
dl 0
loc 11
rs 10
1
<?php
2
3
/**
4
 * Quantum PHP Framework
5
 *
6
 * An open source software development framework for PHP
7
 *
8
 * @package Quantum
9
 * @author Arman Ag. <[email protected]>
10
 * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org)
11
 * @link http://quantum.softberg.org/
12
 * @since 3.0.0
13
 */
14
15
namespace Quantum\Libraries\Cron;
16
17
use Quantum\Libraries\Cron\Exceptions\CronException;
18
19
/**
20
 * Class CronLock
21
 * @package Quantum\Libraries\Cron
22
 */
23
class CronLock
24
{
25
    /**
26
     * Lock directory path
27
     * @var string
28
     */
29
    private $lockDirectory;
30
31
    /**
32
     * Task name
33
     * @var string
34
     */
35
    private $taskName;
36
37
    /**
38
     * Lock file path
39
     * @var string|null
40
     */
41
    private $lockFile = null;
42
43
    /**
44
     * Lock file handle
45
     * @var resource|null
46
     */
47
    private $lockHandle = null;
48
49
    /**
50
     * Maximum lock age in seconds (24 hours)
51
     */
52
    private const MAX_LOCK_AGE = 86400;
53
54
    /**
55
     * CronLock constructor
56
     * @param string $taskName
57
     * @param string|null $lockDirectory
58
     * @throws CronException
59
     */
60
    public function __construct(string $taskName, ?string $lockDirectory = null)
61
    {
62
        $this->taskName = $taskName;
63
        $this->lockDirectory = $lockDirectory ?? $this->getDefaultLockDirectory();
64
        $this->lockFile = $this->lockDirectory . DIRECTORY_SEPARATOR . $this->taskName . '.lock';
65
66
        $this->ensureLockDirectoryExists();
67
        $this->cleanupStaleLocks();
68
    }
69
70
    /**
71
     * Acquire lock for the task
72
     * @return bool
73
     */
74
    public function acquire(): bool
75
    {
76
        if ($this->isLocked()) {
77
            return false;
78
        }
79
80
        $this->lockHandle = fopen($this->lockFile, 'w');
0 ignored issues
show
Bug introduced by
It seems like $this->lockFile can also be of type null; however, parameter $filename of fopen() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

80
        $this->lockHandle = fopen(/** @scrutinizer ignore-type */ $this->lockFile, 'w');
Loading history...
81
82
        if ($this->lockHandle === false) {
83
            return false;
84
        }
85
86
        if (!flock($this->lockHandle, LOCK_EX | LOCK_NB)) {
87
            fclose($this->lockHandle);
88
            $this->lockHandle = null;
89
            return false;
90
        }
91
92
        fwrite($this->lockHandle, json_encode([
93
            'task' => $this->taskName,
94
            'started_at' => time(),
95
            'pid' => getmypid(),
96
        ]));
97
98
        fflush($this->lockHandle);
99
100
        return true;
101
    }
102
103
    /**
104
     * Release the lock
105
     * @return bool
106
     */
107
    public function release(): bool
108
    {
109
        if ($this->lockHandle !== null) {
110
            flock($this->lockHandle, LOCK_UN);
111
            fclose($this->lockHandle);
112
            $this->lockHandle = null;
113
        }
114
115
        if (file_exists($this->lockFile)) {
0 ignored issues
show
Bug introduced by
It seems like $this->lockFile can also be of type null; however, parameter $filename of file_exists() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

115
        if (file_exists(/** @scrutinizer ignore-type */ $this->lockFile)) {
Loading history...
116
            return unlink($this->lockFile);
0 ignored issues
show
Bug introduced by
It seems like $this->lockFile can also be of type null; however, parameter $filename of unlink() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

116
            return unlink(/** @scrutinizer ignore-type */ $this->lockFile);
Loading history...
117
        }
118
119
        return true;
120
    }
121
122
    /**
123
     * Check if task is locked
124
     * @return bool
125
     */
126
    public function isLocked(): bool
127
    {
128
        if (!file_exists($this->lockFile)) {
0 ignored issues
show
Bug introduced by
It seems like $this->lockFile can also be of type null; however, parameter $filename of file_exists() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

128
        if (!file_exists(/** @scrutinizer ignore-type */ $this->lockFile)) {
Loading history...
129
            return false;
130
        }
131
132
        // Check if lock is stale
133
        if (time() - filemtime($this->lockFile) > self::MAX_LOCK_AGE) {
0 ignored issues
show
Bug introduced by
It seems like $this->lockFile can also be of type null; however, parameter $filename of filemtime() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

133
        if (time() - filemtime(/** @scrutinizer ignore-type */ $this->lockFile) > self::MAX_LOCK_AGE) {
Loading history...
134
            unlink($this->lockFile);
0 ignored issues
show
Bug introduced by
It seems like $this->lockFile can also be of type null; however, parameter $filename of unlink() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

134
            unlink(/** @scrutinizer ignore-type */ $this->lockFile);
Loading history...
135
            return false;
136
        }
137
138
        // Try to open the file to check if it's actually locked
139
        $handle = @fopen($this->lockFile, 'r');
0 ignored issues
show
Bug introduced by
It seems like $this->lockFile can also be of type null; however, parameter $filename of fopen() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

139
        $handle = @fopen(/** @scrutinizer ignore-type */ $this->lockFile, 'r');
Loading history...
140
        if ($handle === false) {
141
            return true;
142
        }
143
144
        $locked = !flock($handle, LOCK_EX | LOCK_NB);
145
146
        if (!$locked) {
147
            flock($handle, LOCK_UN);
148
        }
149
150
        fclose($handle);
151
152
        return $locked;
153
    }
154
155
    /**
156
     * Get default lock directory
157
     * @return string
158
     */
159
    private function getDefaultLockDirectory(): string
160
    {
161
        $baseDir = base_dir() . DIRECTORY_SEPARATOR . 'runtime';
162
        return $baseDir . DIRECTORY_SEPARATOR . 'cron' . DIRECTORY_SEPARATOR . 'locks';
163
    }
164
165
    /**
166
     * Ensure lock directory exists
167
     * @throws CronException
168
     */
169
    private function ensureLockDirectoryExists(): void
170
    {
171
        if (!is_dir($this->lockDirectory)) {
172
            if (!mkdir($this->lockDirectory, 0755, true)) {
173
                throw CronException::lockDirectoryNotWritable($this->lockDirectory);
174
            }
175
        }
176
177
        if (!is_writable($this->lockDirectory)) {
178
            throw CronException::lockDirectoryNotWritable($this->lockDirectory);
179
        }
180
    }
181
182
    /**
183
     * Cleanup stale locks
184
     */
185
    private function cleanupStaleLocks(): void
186
    {
187
        if (!is_dir($this->lockDirectory)) {
188
            return;
189
        }
190
191
        $files = glob($this->lockDirectory . DIRECTORY_SEPARATOR . '*.lock');
192
193
        foreach ($files as $file) {
194
            if (time() - filemtime($file) > self::MAX_LOCK_AGE) {
195
                @unlink($file);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

195
                /** @scrutinizer ignore-unhandled */ @unlink($file);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
196
            }
197
        }
198
    }
199
200
    /**
201
     * Destructor - ensure lock is released
202
     */
203
    public function __destruct()
204
    {
205
        $this->release();
206
    }
207
}
208