Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion src/cache/src/ArrayLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
namespace Hypervel\Cache;

use Carbon\Carbon;
use Hypervel\Cache\Contracts\RefreshableLock;
use InvalidArgumentException;

class ArrayLock extends Lock
class ArrayLock extends Lock implements RefreshableLock
{
/**
* The parent array cache store.
Expand Down Expand Up @@ -83,4 +85,59 @@ protected function getCurrentOwner(): string
{
return $this->store->locks[$this->name]['owner'];
}

/**
* Refresh the lock's TTL if still owned by this process.
*
* @throws InvalidArgumentException If an explicit non-positive TTL is provided
*/
public function refresh(?int $seconds = null): bool
{
// Permanent lock with no explicit TTL requested - nothing to refresh
if ($seconds === null && $this->seconds <= 0) {
return true;
}

$seconds ??= $this->seconds;

if ($seconds <= 0) {
throw new InvalidArgumentException(
'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.'
);
}

if (! $this->exists()) {
return false;
}

if (! $this->isOwnedByCurrentProcess()) {
return false;
}

$this->store->locks[$this->name]['expiresAt'] = Carbon::now()->addSeconds($seconds);

return true;
}

/**
* Get the number of seconds until the lock expires.
*/
public function getRemainingLifetime(): ?float
{
if (! $this->exists()) {
return null;
}

$expiresAt = $this->store->locks[$this->name]['expiresAt'];

if ($expiresAt === null) {
return null;
}

if ($expiresAt->isPast()) {
return null;
}

return (float) Carbon::now()->diffInSeconds($expiresAt);
}
}
41 changes: 41 additions & 0 deletions src/cache/src/Contracts/RefreshableLock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Hypervel\Cache\Contracts;

use InvalidArgumentException;

/**
* A lock that supports refreshing its TTL and inspecting remaining lifetime.
*
* Not all lock drivers can implement this interface atomically. Drivers that
* cannot guarantee atomic refresh operations (like CacheLock) should not
* implement this interface.
*/
interface RefreshableLock extends Lock
{
/**
* Refresh the lock's TTL if still owned by this process.
*
* This operation is atomic - if the lock has been released or acquired
* by another process, this will return false without modifying anything.
*
* When called without arguments on a permanent lock (one acquired with
* a TTL of 0), this is a no-op that returns true since there's no TTL
* to refresh.
*
* @param null|int $seconds Seconds to set the TTL to (null = use original TTL from construction)
* @return bool True if the lock was refreshed (or is permanent), false if not owned or expired
*
* @throws InvalidArgumentException If $seconds is explicitly provided and is not positive
*/
public function refresh(?int $seconds = null): bool;

/**
* Get the number of seconds until the lock expires.
*
* @return null|float Seconds remaining, or null if lock doesn't exist or has no expiry
*/
public function getRemainingLifetime(): ?float;
}
56 changes: 55 additions & 1 deletion src/cache/src/DatabaseLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
use Hyperf\Database\ConnectionInterface;
use Hyperf\Database\ConnectionResolverInterface;
use Hyperf\Database\Exception\QueryException;
use Hypervel\Cache\Contracts\RefreshableLock;
use InvalidArgumentException;

use function Hyperf\Support\optional;

class DatabaseLock extends Lock
class DatabaseLock extends Lock implements RefreshableLock
{
/**
* The database connection resolver.
Expand Down Expand Up @@ -146,4 +148,56 @@ protected function expiresAt(): int

return $this->currentTime() + $lockTimeout;
}

/**
* Refresh the lock's TTL if still owned by this process.
*
* @throws InvalidArgumentException If an explicit non-positive TTL is provided
*/
public function refresh(?int $seconds = null): bool
{
// Permanent lock with no explicit TTL requested - nothing to refresh
if ($seconds === null && $this->seconds <= 0) {
return true;
}

$seconds ??= $this->seconds;

if ($seconds <= 0) {
throw new InvalidArgumentException(
'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.'
);
}

$updated = $this->connection()->table($this->table)
->where('key', $this->name)
->where('owner', $this->owner)
->update([
'expiration' => $this->currentTime() + $seconds,
]);

return $updated >= 1;
}

/**
* Get the number of seconds until the lock expires.
*/
public function getRemainingLifetime(): ?float
{
$lock = $this->connection()->table($this->table)
->where('key', $this->name)
->first();

if ($lock === null) {
return null;
}

$remaining = $lock->expiration - $this->currentTime();

if ($remaining <= 0) {
return null;
}

return (float) $remaining;
}
}
22 changes: 19 additions & 3 deletions src/cache/src/LuaScripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,33 @@ class LuaScripts
*
* KEYS[1] - The name of the lock
* ARGV[1] - The owner key of the lock instance trying to release it
*
* @return string
*/
public static function releaseLock()
public static function releaseLock(): string
{
return <<<'LUA'
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
LUA;
}

/**
* Get the Lua script to atomically refresh a lock's TTL.
*
* KEYS[1] - The name of the lock
* ARGV[1] - The owner key of the lock instance trying to refresh it
* ARGV[2] - The new TTL in seconds
*/
public static function refreshLock(): string
{
return <<<'LUA'
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("expire",KEYS[1],ARGV[2])
else
return 0
end
LUA;
}
}
36 changes: 35 additions & 1 deletion src/cache/src/NoLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

namespace Hypervel\Cache;

class NoLock extends Lock
use Hypervel\Cache\Contracts\RefreshableLock;
use InvalidArgumentException;

class NoLock extends Lock implements RefreshableLock
{
/**
* Attempt to acquire the lock.
Expand Down Expand Up @@ -36,4 +39,35 @@ protected function getCurrentOwner(): string
{
return $this->owner;
}

/**
* Refresh the lock's TTL if still owned by this process.
*
* @throws InvalidArgumentException If an explicit non-positive TTL is provided
*/
public function refresh(?int $seconds = null): bool
{
// Permanent lock with no explicit TTL requested - nothing to refresh
if ($seconds === null && $this->seconds <= 0) {
return true;
}

$seconds ??= $this->seconds;

if ($seconds <= 0) {
throw new InvalidArgumentException(
'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.'
);
}

return true;
}

/**
* Get the number of seconds until the lock expires.
*/
public function getRemainingLifetime(): ?float
{
return null;
}
}
43 changes: 42 additions & 1 deletion src/cache/src/RedisLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
namespace Hypervel\Cache;

use Hyperf\Redis\Redis;
use Hypervel\Cache\Contracts\RefreshableLock;
use InvalidArgumentException;

class RedisLock extends Lock
class RedisLock extends Lock implements RefreshableLock
{
/**
* The Redis factory implementation.
Expand All @@ -31,6 +33,7 @@ public function acquire(): bool
if ($this->seconds > 0) {
return $this->redis->set($this->name, $this->owner, ['EX' => $this->seconds, 'NX']) == true;
}

return $this->redis->setnx($this->name, $this->owner) == true;
}

Expand All @@ -57,4 +60,42 @@ protected function getCurrentOwner(): string
{
return $this->redis->get($this->name);
}

/**
* Refresh the lock's TTL if still owned by this process.
*
* @throws InvalidArgumentException If an explicit non-positive TTL is provided
*/
public function refresh(?int $seconds = null): bool
{
// Permanent lock with no explicit TTL requested - nothing to refresh
if ($seconds === null && $this->seconds <= 0) {
return true;
}

$seconds ??= $this->seconds;

if ($seconds <= 0) {
throw new InvalidArgumentException(
'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.'
);
}

return (bool) $this->redis->eval(LuaScripts::refreshLock(), [$this->name, $this->owner, $seconds], 1);
}

/**
* Get the number of seconds until the lock expires.
*/
public function getRemainingLifetime(): ?float
{
$ttl = $this->redis->ttl($this->name);

// -2 = key doesn't exist, -1 = key has no expiry
if ($ttl < 0) {
return null;
}

return (float) $ttl;
}
}
Loading