Skip to content
Open
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
14 changes: 14 additions & 0 deletions src/Illuminate/Bus/UniqueLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ public function release($job)
$cache->lock($this->getKey($job))->forceRelease();
}

/**
* Refresh the lock for the given job.
*/
public function refresh(mixed $job, ?int $seconds = null): bool
{
$cache = method_exists($job, 'uniqueVia')
? $job->uniqueVia()
: $this->cache;

$lock = $cache->lock($this->getKey($job));

return method_exists($lock, 'refresh') && $lock->refresh($seconds);
}

/**
* Generate the lock key for the given job.
*
Expand Down
19 changes: 19 additions & 0 deletions src/Illuminate/Cache/ArrayLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,23 @@ public function forceRelease()
{
unset($this->store->locks[$this->name]);
}

/**
* Attempt to refresh the lock for the given number of seconds.
*
* @param int|null $seconds
* @return bool
*/
public function refresh($seconds = null)
{
if (! $this->isOwnedByCurrentProcess()) {
return false;
}

$seconds ??= $this->seconds;

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

return true;
}
Comment on lines +112 to +123
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ArrayLock refresh functionality lacks test coverage. While CacheArrayStoreTest has comprehensive tests for lock acquisition and release, there are no tests for the newly added refresh method. Consider adding test cases that verify: (1) successful refresh, (2) refresh preventing acquisition by another process, (3) refresh failure when attempted by another owner, (4) refresh with default seconds parameter, and (5) refresh behavior with expired locks.

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +123
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refresh operation should verify that the lock hasn't expired before allowing the refresh. Currently, isOwnedByCurrentProcess checks ownership but doesn't validate that the lock's expiration hasn't passed. This means an expired lock could be refreshed by its previous owner. Consider adding expiration validation similar to how the acquire method checks if expiration.isFuture() before preventing acquisition.

Copilot uses AI. Check for mistakes.
}
18 changes: 18 additions & 0 deletions src/Illuminate/Cache/DatabaseLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,24 @@ protected function getCurrentOwner()
return $this->connection->table($this->table)->where('key', $this->name)->first()?->owner;
}

/**
* Attempt to refresh the lock for the given number of seconds.
*
* @param int|null $seconds
* @return bool
*/
public function refresh($seconds = null)
{
$seconds ??= $this->seconds;

$this->seconds = $seconds;

return $this->connection->table($this->table)
->where('key', $this->name)
->where('owner', $this->owner)
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refresh operation should verify that the lock hasn't expired before allowing the refresh. Currently, the update query only checks if the key and owner match, but doesn't validate that the current expiration hasn't passed. This means an expired lock could be refreshed by its previous owner, potentially extending a lock that should have been released. Consider adding a condition to check that the current expiration is greater than the current time, similar to how the acquire method handles expired locks.

Suggested change
->where('owner', $this->owner)
->where('owner', $this->owner)
->where('expiration', '>', $this->currentTime())

Copilot uses AI. Check for mistakes.
->update(['expiration' => $this->expiresAt()]) >= 1;
Comment on lines +183 to +188
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refresh operation modifies the instance's $seconds property as a side effect. This state mutation is unexpected for a method that's meant to atomically refresh a lock's expiration. If refresh is called multiple times with different durations, the instance state will change each time. Consider removing this line to avoid side effects, as the refresh operation should only update the lock's expiration in the storage backend without modifying the lock instance's state.

Suggested change
$this->seconds = $seconds;
return $this->connection->table($this->table)
->where('key', $this->name)
->where('owner', $this->owner)
->update(['expiration' => $this->expiresAt()]) >= 1;
$expiration = $this->currentTime() + $seconds;
return $this->connection->table($this->table)
->where('key', $this->name)
->where('owner', $this->owner)
->update(['expiration' => $expiration]) >= 1;

Copilot uses AI. Check for mistakes.
}

/**
* Get the name of the database connection being used to manage the lock.
*
Expand Down
17 changes: 17 additions & 0 deletions src/Illuminate/Cache/DynamoDbLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,21 @@ protected function getCurrentOwner()
{
return $this->dynamo->get($this->name);
}

/**
* Attempt to refresh the lock for the given number of seconds.
*
* @param int|null $seconds
* @return bool
*/
public function refresh($seconds = null)
{
$seconds ??= $this->seconds;

if ($seconds <= 0) {
$seconds = 86400;
}

return $this->dynamo->refreshIfOwned($this->name, $this->owner, $seconds);
}
Comment on lines +83 to +92
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DynamoDbLock refresh functionality lacks test coverage. There are no integration tests for DynamoDbLock at all. Consider adding test cases similar to those in FileCacheLockTest, RedisCacheLockTest, and DatabaseLockTest that verify: (1) successful refresh, (2) refresh preventing acquisition by another process, (3) refresh failure when attempted by another owner, and (4) refresh with default seconds parameter. Also verify the special handling of seconds <= 0 defaulting to 86400.

Copilot uses AI. Check for mistakes.
}
48 changes: 48 additions & 0 deletions src/Illuminate/Cache/DynamoDbStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -506,4 +506,52 @@ public function getClient()
{
return $this->dynamo;
}

/**
* Atomically refresh the expiration of a key if it matches the expected owner.
*
* @param string $key
* @param mixed $expectedOwner
* @param int $seconds
* @return bool
*/
public function refreshIfOwned($key, $expectedOwner, $seconds)
{
try {
$this->dynamo->updateItem([
'TableName' => $this->table,
'Key' => [
$this->keyAttribute => [
'S' => $this->prefix.$key,
],
],
'ConditionExpression' => 'attribute_exists(#key) AND #value = :owner AND #expires_at > :now',
'UpdateExpression' => 'SET #expires_at = :expires_at',
'ExpressionAttributeNames' => [
'#key' => $this->keyAttribute,
'#value' => $this->valueAttribute,
'#expires_at' => $this->expirationAttribute,
],
'ExpressionAttributeValues' => [
':owner' => [
$this->type($expectedOwner) => $this->serialize($expectedOwner),
],
':now' => [
'N' => (string) $this->currentTime(),
],
':expires_at' => [
'N' => (string) $this->toTimestamp($seconds),
],
],
]);

return true;
} catch (DynamoDbException $e) {
if (str_contains($e->getMessage(), 'ConditionalCheckFailed')) {
return false;
}

throw $e;
}
}
}
15 changes: 15 additions & 0 deletions src/Illuminate/Cache/FileLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,19 @@ public function acquire()
{
return $this->store->add($this->name, $this->owner, $this->seconds);
}

/**
* Attempt to refresh the lock for the given number of seconds.
*
* @param int|null $seconds
* @return bool
*/
public function refresh($seconds = null)
{
return $this->store->refreshIfOwned(
$this->name,
$this->owner,
$seconds ?? $this->seconds
);
}
}
48 changes: 48 additions & 0 deletions src/Illuminate/Cache/FileStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,54 @@ public function setLockDirectory($lockDirectory)
return $this;
}

/**
* Atomically refresh the expiration of a cache key if it matches the expected owner.
*
* @param string $key
* @param mixed $expectedOwner
* @param int $seconds
* @return bool
*/
public function refreshIfOwned($key, $expectedOwner, $seconds)
{
$this->ensureCacheDirectoryExists($path = $this->path($key));

$file = new LockableFile($path, 'c+');

try {
$file->getExclusiveLock();
} catch (LockTimeoutException) {
$file->close();

return false;
}

$contents = $file->read();

if (strlen($contents) < 10) {
$file->close();

return false;
}

$expire = substr($contents, 0, 10);
$currentOwner = unserialize(substr($contents, 10));

if ($currentOwner !== $expectedOwner || $this->currentTime() >= $expire) {
$file->close();

return false;
}

$file->truncate()
->write($this->expiration($seconds).serialize($expectedOwner))
->close();

$this->ensurePermissionsAreCorrect($path);

return true;
}

/**
* Get the cache key prefix.
*
Expand Down
11 changes: 11 additions & 0 deletions src/Illuminate/Cache/Lock.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,15 @@ public function betweenBlockedAttemptsSleepFor($milliseconds)

return $this;
}

/**
* Attempt to refresh the lock for the given number of seconds.
*
* @param int|null $seconds
* @return bool
*/
public function refresh($seconds = null)
{
throw new \RuntimeException('This lock driver does not support refreshing locks.');
}
}
20 changes: 20 additions & 0 deletions src/Illuminate/Cache/LuaScripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,26 @@ public static function releaseLock()
else
return 0
end
LUA;
}

/**
* Get the Lua script to atomically refresh a lock's expiration.
*
* KEYS[1] - The name of the lock
* ARGV[1] - The owner key of the lock instance trying to refresh it
* ARGV[2] - The number of seconds the lock should be valid
*
* @return string
*/
public static function refreshLock()
{
return <<<'LUA'
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("expire",KEYS[1],ARGV[2])
else
return 0
end
LUA;
}
}
19 changes: 19 additions & 0 deletions src/Illuminate/Cache/MemcachedLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,23 @@ protected function getCurrentOwner()
{
return $this->memcached->get($this->name);
}

/**
* Attempt to refresh the lock for the given number of seconds.
*
* @param int|null $seconds
* @return bool
*/
public function refresh($seconds = null)
{
$seconds ??= $this->seconds;

$value = $this->memcached->get($this->name, null, \Memcached::GET_EXTENDED);

if ($value === false || ($value['value'] ?? null) !== $this->owner) {
return false;
}

return $this->memcached->cas($value['cas'], $this->name, $this->owner, $seconds);
}
Comment on lines +81 to +92
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MemcachedLock refresh functionality lacks test coverage. While MemcachedCacheLockTestCase has comprehensive tests for lock acquisition and release, there are no tests for the newly added refresh method. Consider adding test cases similar to those in FileCacheLockTest and RedisCacheLockTest that verify: (1) successful refresh, (2) refresh preventing acquisition by another process, (3) refresh failure when attempted by another owner, and (4) refresh with default seconds parameter.

Copilot uses AI. Check for mistakes.
}
11 changes: 11 additions & 0 deletions src/Illuminate/Cache/NoLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,15 @@ protected function getCurrentOwner()
{
return $this->owner;
}

/**
* Attempt to refresh the lock for the given number of seconds.
*
* @param int|null $seconds
* @return bool
*/
public function refresh($seconds = null)
{
return true;
}
Comment on lines +53 to +56
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NoLock refresh functionality lacks test coverage. While NoLockTest has tests for lock acquisition and release, there are no tests for the newly added refresh method. Since NoLock always succeeds, add a test case to verify that refresh always returns true, maintaining the no-op behavior of this lock implementation.

Copilot uses AI. Check for mistakes.
}
15 changes: 15 additions & 0 deletions src/Illuminate/Cache/PhpRedisLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,19 @@ public function release()
...$this->redis->pack([$this->owner])
);
}

/**
* {@inheritDoc}
*/
public function refresh($seconds = null)
{
$seconds ??= $this->seconds;

return (bool) $this->redis->eval(
LuaScripts::refreshLock(),
1,
$this->name,
...$this->redis->pack([$this->owner, $seconds])
);
}
}
15 changes: 15 additions & 0 deletions src/Illuminate/Cache/RedisLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ protected function getCurrentOwner()
return $this->redis->get($this->name);
}

/**
* Attempt to refresh the lock for the given number of seconds.
*
* @param int|null $seconds
* @return bool
*/
public function refresh($seconds = null)
{
$seconds ??= $this->seconds;

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

/**
* Get the name of the Redis connection being used to manage the lock.
*
Expand Down
45 changes: 45 additions & 0 deletions tests/Integration/Cache/FileCacheLockTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,51 @@ public function testExceptionIfBlockCanNotAcquireLock()
Cache::lock('foo', 10)->block(5);
}

public function testLockCanBeRefreshed()
{
$lock = Cache::lock('foo', 10);
$this->assertTrue($lock->get());

// Refresh the lock for another 20 seconds
$this->assertTrue($lock->refresh(20));

// Lock should still be held
$this->assertFalse(Cache::lock('foo', 10)->get());

$lock->release();
}

public function testLockCannotBeRefreshedByAnotherOwner()
{
$firstLock = Cache::lock('foo', 10);
$this->assertTrue($firstLock->get());

// Create a new lock with a different owner
$secondLock = Cache::store('file')->restoreLock('foo', 'other_owner');

// Second lock should not be able to refresh
$this->assertFalse($secondLock->refresh(20));

// Original lock should still be able to refresh
$this->assertTrue($firstLock->refresh(20));

$firstLock->release();
}

public function testLockRefreshWithDefaultSeconds()
{
$lock = Cache::lock('foo', 10);
$this->assertTrue($lock->get());

// Refresh without specifying seconds should use the original duration
$this->assertTrue($lock->refresh());

// Lock should still be held
$this->assertFalse(Cache::lock('foo', 10)->get());

$lock->release();
}

protected function tearDown(): void
{
try {
Expand Down
Loading
Loading