Skip to content

Commit d8b403b

Browse files
committed
feat(cache): add lock store support
Signed-off-by: memleakd <[email protected]>
1 parent 88fbdb6 commit d8b403b

3 files changed

Lines changed: 273 additions & 3 deletions

File tree

system/Cache/Handlers/FileHandler.php

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use CodeIgniter\Cache\Exceptions\CacheException;
1717
use CodeIgniter\I18n\Time;
18+
use CodeIgniter\Lock\LockStoreInterface;
1819
use Config\Cache;
1920
use Throwable;
2021

@@ -23,7 +24,7 @@
2324
*
2425
* @see \CodeIgniter\Cache\Handlers\FileHandlerTest
2526
*/
26-
class FileHandler extends BaseHandler
27+
class FileHandler extends BaseHandler implements LockStoreInterface
2728
{
2829
/**
2930
* Maximum key length.
@@ -155,6 +156,78 @@ public function decrement(string $key, int $offset = 1): bool|int
155156
return $this->increment($key, -$offset);
156157
}
157158

159+
public function acquireLock(string $key, string $owner, int $ttl): bool
160+
{
161+
return $this->withLockFile($key, static function ($handle) use ($owner, $ttl): bool {
162+
$data = self::readLockData($handle);
163+
$now = Time::now()->getTimestamp();
164+
165+
if ($data !== null && $data['expires'] > $now) {
166+
return false;
167+
}
168+
169+
return self::writeLockData($handle, $owner, $now + $ttl);
170+
});
171+
}
172+
173+
public function releaseLock(string $key, string $owner): bool
174+
{
175+
return $this->withLockFile($key, static function ($handle) use ($owner): bool {
176+
$data = self::readLockData($handle);
177+
178+
if ($data === null || $data['owner'] !== $owner) {
179+
return false;
180+
}
181+
182+
return self::clearLockFile($handle);
183+
});
184+
}
185+
186+
public function forceReleaseLock(string $key): bool
187+
{
188+
return ! is_file($this->path . static::validateKey($key, $this->prefix))
189+
|| $this->withLockFile($key, static fn ($handle): bool => self::clearLockFile($handle), false);
190+
}
191+
192+
public function refreshLock(string $key, string $owner, int $ttl): bool
193+
{
194+
return $this->withLockFile($key, static function ($handle) use ($owner, $ttl): bool {
195+
$data = self::readLockData($handle);
196+
$now = Time::now()->getTimestamp();
197+
198+
if ($data === null || $data['owner'] !== $owner || $data['expires'] <= $now) {
199+
return false;
200+
}
201+
202+
return self::writeLockData($handle, $owner, $now + $ttl);
203+
});
204+
}
205+
206+
public function getLockOwner(string $key): ?string
207+
{
208+
$owner = null;
209+
210+
$this->withLockFile($key, static function ($handle) use (&$owner): bool {
211+
$data = self::readLockData($handle);
212+
213+
if ($data === null) {
214+
return true;
215+
}
216+
217+
if ($data['expires'] <= Time::now()->getTimestamp()) {
218+
self::clearLockFile($handle);
219+
220+
return true;
221+
}
222+
223+
$owner = $data['owner'];
224+
225+
return true;
226+
}, false);
227+
228+
return $owner;
229+
}
230+
158231
public function clean(): bool
159232
{
160233
return delete_files($this->path, false, true);
@@ -229,4 +302,92 @@ protected function getItem(string $filename): array|false
229302

230303
return $data;
231304
}
305+
306+
/**
307+
* @param callable(resource): bool $callback
308+
*/
309+
private function withLockFile(string $key, callable $callback, bool $create = true): bool
310+
{
311+
$key = static::validateKey($key, $this->prefix);
312+
$handle = @fopen($this->path . $key, $create ? 'c+b' : 'r+b');
313+
314+
if ($handle === false) {
315+
return false;
316+
}
317+
318+
try {
319+
if (! flock($handle, LOCK_EX)) {
320+
return false;
321+
}
322+
323+
return $callback($handle);
324+
} finally {
325+
flock($handle, LOCK_UN);
326+
fclose($handle);
327+
328+
if (is_file($this->path . $key)) {
329+
try {
330+
chmod($this->path . $key, $this->mode);
331+
} catch (Throwable $e) {
332+
log_message('debug', 'Failed to set mode on cache lock file: ' . $e);
333+
}
334+
}
335+
}
336+
}
337+
338+
/**
339+
* @param resource $handle
340+
*
341+
* @return array{owner: string, expires: int}|null
342+
*/
343+
private static function readLockData($handle): ?array
344+
{
345+
rewind($handle);
346+
347+
$content = stream_get_contents($handle);
348+
349+
if ($content === false || $content === '') {
350+
return null;
351+
}
352+
353+
try {
354+
$data = unserialize($content);
355+
} catch (Throwable) {
356+
return null;
357+
}
358+
359+
if (! is_array($data) || ! isset($data['owner'], $data['expires']) || ! is_string($data['owner']) || ! is_int($data['expires'])) {
360+
return null;
361+
}
362+
363+
return $data;
364+
}
365+
366+
/**
367+
* @param resource $handle
368+
*/
369+
private static function writeLockData($handle, string $owner, int $expires): bool
370+
{
371+
rewind($handle);
372+
373+
if (! ftruncate($handle, 0)) {
374+
return false;
375+
}
376+
377+
if (fwrite($handle, serialize(['owner' => $owner, 'expires' => $expires])) === false) {
378+
return false;
379+
}
380+
381+
return fflush($handle);
382+
}
383+
384+
/**
385+
* @param resource $handle
386+
*/
387+
private static function clearLockFile($handle): bool
388+
{
389+
rewind($handle);
390+
391+
return ftruncate($handle, 0) && fflush($handle);
392+
}
232393
}

system/Cache/Handlers/PredisHandler.php

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use CodeIgniter\Exceptions\CriticalError;
1717
use CodeIgniter\I18n\Time;
18+
use CodeIgniter\Lock\LockStoreInterface;
1819
use Config\Cache;
1920
use Exception;
2021
use Predis\Client;
@@ -26,7 +27,7 @@
2627
*
2728
* @see \CodeIgniter\Cache\Handlers\PredisHandlerTest
2829
*/
29-
class PredisHandler extends BaseHandler
30+
class PredisHandler extends BaseHandler implements LockStoreInterface
3031
{
3132
/**
3233
* Default config
@@ -167,6 +168,60 @@ public function decrement(string $key, int $offset = 1): int
167168
return $this->redis->hincrby($key, 'data', -$offset);
168169
}
169170

171+
public function acquireLock(string $key, string $owner, int $ttl): bool
172+
{
173+
$key = static::validateKey($key);
174+
$result = $this->redis->set($key, $owner, 'EX', $ttl, 'NX');
175+
176+
return $result instanceof Status && $result->getPayload() === 'OK';
177+
}
178+
179+
public function releaseLock(string $key, string $owner): bool
180+
{
181+
$key = static::validateKey($key);
182+
183+
$script = <<<'LUA'
184+
if redis.call("get", KEYS[1]) == ARGV[1] then
185+
return redis.call("del", KEYS[1])
186+
end
187+
188+
return 0
189+
LUA;
190+
191+
return $this->redis->eval($script, 1, $key, $owner) === 1;
192+
}
193+
194+
public function forceReleaseLock(string $key): bool
195+
{
196+
$key = static::validateKey($key);
197+
$deleted = $this->redis->del($key);
198+
199+
return is_int($deleted) && $deleted >= 0;
200+
}
201+
202+
public function refreshLock(string $key, string $owner, int $ttl): bool
203+
{
204+
$key = static::validateKey($key);
205+
206+
$script = <<<'LUA'
207+
if redis.call("get", KEYS[1]) == ARGV[1] then
208+
return redis.call("expire", KEYS[1], ARGV[2])
209+
end
210+
211+
return 0
212+
LUA;
213+
214+
return $this->redis->eval($script, 1, $key, $owner, (string) $ttl) === 1;
215+
}
216+
217+
public function getLockOwner(string $key): ?string
218+
{
219+
$key = static::validateKey($key);
220+
$owner = $this->redis->get($key);
221+
222+
return is_string($owner) ? $owner : null;
223+
}
224+
170225
public function clean(): bool
171226
{
172227
return $this->redis->flushdb()->getPayload() === 'OK';

system/Cache/Handlers/RedisHandler.php

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use CodeIgniter\Exceptions\CriticalError;
1717
use CodeIgniter\I18n\Time;
18+
use CodeIgniter\Lock\LockStoreInterface;
1819
use Config\Cache;
1920
use Redis;
2021
use RedisException;
@@ -24,7 +25,7 @@
2425
*
2526
* @see \CodeIgniter\Cache\Handlers\RedisHandlerTest
2627
*/
27-
class RedisHandler extends BaseHandler
28+
class RedisHandler extends BaseHandler implements LockStoreInterface
2829
{
2930
/**
3031
* Default config
@@ -185,6 +186,59 @@ public function decrement(string $key, int $offset = 1): int
185186
return $this->increment($key, -$offset);
186187
}
187188

189+
public function acquireLock(string $key, string $owner, int $ttl): bool
190+
{
191+
$key = static::validateKey($key, $this->prefix);
192+
193+
return (bool) $this->redis->set($key, $owner, ['nx', 'ex' => $ttl]);
194+
}
195+
196+
public function releaseLock(string $key, string $owner): bool
197+
{
198+
$key = static::validateKey($key, $this->prefix);
199+
200+
$script = <<<'LUA'
201+
if redis.call("get", KEYS[1]) == ARGV[1] then
202+
return redis.call("del", KEYS[1])
203+
end
204+
205+
return 0
206+
LUA;
207+
208+
return (int) $this->redis->eval($script, [$key, $owner], 1) === 1;
209+
}
210+
211+
public function forceReleaseLock(string $key): bool
212+
{
213+
$key = static::validateKey($key, $this->prefix);
214+
$deleted = $this->redis->del($key);
215+
216+
return is_int($deleted) && $deleted >= 0;
217+
}
218+
219+
public function refreshLock(string $key, string $owner, int $ttl): bool
220+
{
221+
$key = static::validateKey($key, $this->prefix);
222+
223+
$script = <<<'LUA'
224+
if redis.call("get", KEYS[1]) == ARGV[1] then
225+
return redis.call("expire", KEYS[1], ARGV[2])
226+
end
227+
228+
return 0
229+
LUA;
230+
231+
return (int) $this->redis->eval($script, [$key, $owner, $ttl], 1) === 1;
232+
}
233+
234+
public function getLockOwner(string $key): ?string
235+
{
236+
$key = static::validateKey($key, $this->prefix);
237+
$owner = $this->redis->get($key);
238+
239+
return is_string($owner) ? $owner : null;
240+
}
241+
188242
public function clean(): bool
189243
{
190244
return $this->redis->flushDB();

0 commit comments

Comments
 (0)