Skip to content
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
The MIT License (MIT)

Copyright (c) 2024 Willem Stuursma-Ruwen
Copyright (c) 2024 Markus Malkusch, Willem Stuursma-Ruwen, Michael Voříšek and GitHub contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
54 changes: 22 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
[![Build Status](https://github.com/php-lock/lock/actions/workflows/test-unit.yml/badge.svg?branch=master)](https://github.com/php-lock/lock/actions?query=branch:master)
[![License](https://poser.pugx.org/malkusch/lock/license)](https://packagist.org/packages/malkusch/lock)

This library helps executing critical code in concurrent situations.
This library helps executing critical code in concurrent situations in serialized fashion.

php-lock/lock follows semantic versioning. Read more on [semver.org][1].
php-lock/lock follows [semantic versioning][1].

----

Expand Down Expand Up @@ -164,8 +164,7 @@ implementations or create/extend your own implementation.

- [`FlockMutex`](#flockmutex)
- [`MemcachedMutex`](#memcachedmutex)
- [`PHPRedisMutex`](#phpredismutex)
- [`PredisMutex`](#predismutex)
- [`RedisMutex`](#redismutex)
- [`SemaphoreMutex`](#semaphoremutex)
- [`TransactionalMutex`](#transactionalmutex)
- [`MySQLMutex`](#mysqlmutex)
Expand Down Expand Up @@ -195,7 +194,7 @@ extension if possible or busy waiting if not.
#### MemcachedMutex

The **MemcachedMutex** is a spinlock implementation which uses the
[`Memcached` API](http://php.net/manual/en/book.memcached.php).
[`Memcached` extension](http://php.net/manual/en/book.memcached.php).

Example:
```php
Expand All @@ -213,13 +212,14 @@ $mutex->synchronized(function () use ($bankAccount, $amount) {
});
```

#### PHPRedisMutex
#### RedisMutex

The **PHPRedisMutex** is the distributed lock implementation of
[RedLock](http://redis.io/topics/distlock) which uses the
[`phpredis` extension](https://github.com/phpredis/phpredis).
The **RedisMutex** is the distributed lock implementation of
[RedLock](http://redis.io/topics/distlock) which supports the
[`phpredis` extension](https://github.com/phpredis/phpredis)
or [`Predis` API](https://github.com/nrk/predis).

This implementation requires at least `phpredis-2.2.4`.
Both Redis and Valkey servers are supported.

If used with a cluster of Redis servers, acquiring and releasing locks will
continue to function as long as a majority of the servers still works.
Expand All @@ -228,29 +228,9 @@ Example:
```php
$redis = new \Redis();
$redis->connect('localhost');
// OR $redis = new \Predis\Client('redis://localhost');

$mutex = new PHPRedisMutex([$redis], 'balance');
$mutex->synchronized(function () use ($bankAccount, $amount) {
$balance = $bankAccount->getBalance();
$balance -= $amount;
if ($balance < 0) {
throw new \DomainException('You have no credit');
}
$bankAccount->setBalance($balance);
});
```

#### PredisMutex

The **PredisMutex** is the distributed lock implementation of
[RedLock](http://redis.io/topics/distlock) which uses the
[`Predis` API](https://github.com/nrk/predis).

Example:
```php
$redis = new \Predis\Client('redis://localhost');

$mutex = new PredisMutex([$redis], 'balance');
$mutex = new RedisMutex([$redis], 'balance');
$mutex->synchronized(function () use ($bankAccount, $amount) {
$balance = $bankAccount->getBalance();
$balance -= $amount;
Expand Down Expand Up @@ -316,6 +296,8 @@ The **MySQLMutex** uses MySQL's
[`GET_LOCK`](https://dev.mysql.com/doc/refman/9.0/en/locking-functions.html#function_get-lock)
function.

Both MySQL and MariaDB servers are supported.

It supports timeouts. If the connection to the database server is lost or
interrupted, the lock is automatically released.

Expand Down Expand Up @@ -366,6 +348,14 @@ $mutex->synchronized(function () use ($bankAccount, $amount) {
});
```

## Authors

Since year 2015 the development was led by Markus Malkusch, Willem Stuursma-Ruwen, Michael Voříšek and many GitHub contributors.

Currently this library is maintained by Michael Voříšek - [GitHub][https://github.com/mvorisek] and [LinkedIn][https://www.linkedin.com/mvorisek].

Commercial support is available.

## License

This project is free and is licensed under the MIT.
Expand Down
12 changes: 1 addition & 11 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ parameters:
ignoreErrors:
# TODO
-
path: 'src/Mutex/RedisMutex.php'
path: 'src/Mutex/AbstractRedlockMutex.php'
identifier: if.condNotBoolean
message: '~^Only booleans are allowed in an if condition, mixed given\.$~'
count: 1
Expand All @@ -21,16 +21,6 @@ parameters:
identifier: if.condNotBoolean
message: '~^Only booleans are allowed in an if condition, mixed given\.$~'
count: 1
-
message: '~^Parameter #1 \$(redisAPI|redis) \(Redis\|RedisCluster\) of method Malkusch\\Lock\\Mutex\\PHPRedisMutex::(add|evalScript)\(\) should be contravariant with parameter \$redisAPI \(mixed\) of method Malkusch\\Lock\\Mutex\\RedisMutex::(add|evalScript)\(\)$~'
identifier: method.childParameterType
path: 'src/Mutex/PHPRedisMutex.php'
count: 2
-
message: '~^Parameter #1 \$(redisAPI|client) \(Predis\\ClientInterface\) of method Malkusch\\Lock\\Mutex\\PredisMutex::(add|evalScript)\(\) should be contravariant with parameter \$redisAPI \(mixed\) of method Malkusch\\Lock\\Mutex\\RedisMutex::(add|evalScript)\(\)$~'
identifier: method.childParameterType
path: 'src/Mutex/PredisMutex.php'
count: 2
-
path: 'tests/Mutex/*Test.php'
identifier: empty.notAllowed
Expand Down
173 changes: 173 additions & 0 deletions src/Mutex/AbstractRedlockMutex.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php

declare(strict_types=1);

namespace Malkusch\Lock\Mutex;

use Malkusch\Lock\Exception\LockAcquireException;
use Malkusch\Lock\Exception\LockReleaseException;
use Malkusch\Lock\Util\LockUtil;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;

/**
* Distributed mutex based on the Redlock algorithm.
*
* @template TClient of object
*
* @see http://redis.io/topics/distlock
*/
abstract class AbstractRedlockMutex extends AbstractSpinlockMutex implements LoggerAwareInterface
{
use LoggerAwareTrait;

/** @var string The random value token for key identification */
private $token;

/** @var array<int, TClient> */
private $clients;

/**
* Sets the Redis APIs.
*
* The Redis APIs needs to be connected. I.e. Redis::connect() was
* called already.
*
* @param array<int, TClient> $clients
* @param float $timeout The timeout in seconds a lock expires
*
* @throws \LengthException The timeout must be greater than 0
*/
public function __construct(array $clients, string $name, float $timeout = 3)
{
parent::__construct($name, $timeout);

$this->clients = $clients;
$this->logger = new NullLogger();
}

#[\Override]
protected function acquire(string $key, float $expire): bool
{
// 1. This differs from the specification to avoid an overflow on 32-Bit systems.
$time = microtime(true);

// 2.
$acquired = 0;
$errored = 0;
$this->token = LockUtil::getInstance()->makeRandomToken();
$exception = null;
foreach ($this->clients as $index => $client) {
try {
if ($this->add($client, $key, $this->token, $expire)) {
++$acquired;
}
} catch (LockAcquireException $exception) {
// todo if there is only one redis server, throw immediately.
$context = [
'key' => $key,
'index' => $index,
'token' => $this->token,
'exception' => $exception,
];
$this->logger->warning('Could not set {key} = {token} at server #{index}', $context);

++$errored;
}
}

// 3.
$elapsedTime = microtime(true) - $time;
$isAcquired = $this->isMajority($acquired) && $elapsedTime <= $expire;

if ($isAcquired) {
// 4.
return true;
}

// 5.
$this->release($key);

// In addition to RedLock it's an exception if too many servers fail.
if (!$this->isMajority(count($this->clients) - $errored)) {
assert($exception !== null); // The last exception for some context.

throw new LockAcquireException(
'It\'s not possible to acquire a lock because at least half of the Redis server are not available',
LockAcquireException::REDIS_NOT_ENOUGH_SERVERS,
$exception
);
}

return false;
}

#[\Override]
protected function release(string $key): bool
{
/*
* All Redis commands must be analyzed before execution to determine which keys the command will operate on. In
* order for this to be true for EVAL, keys must be passed explicitly.
*
* @link https://redis.io/commands/set
*/
$script = 'if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
';
$released = 0;
foreach ($this->clients as $index => $client) {
try {
if ($this->evalScript($client, $script, 1, [$key, $this->token])) {
++$released;
}
} catch (LockReleaseException $e) {
// todo throw if there is only one redis server
$context = [
'key' => $key,
'index' => $index,
'token' => $this->token,
'exception' => $e,
];
$this->logger->warning('Could not unset {key} = {token} at server #{index}', $context);
}
}

return $this->isMajority($released);
}

/**
* Returns if a count is the majority of all servers.
*
* @return bool True if the count is the majority
*/
private function isMajority(int $count): bool
{
return $count > count($this->clients) / 2;
}

/**
* Sets the key only if such key doesn't exist at the server yet.
*
* @param TClient $client
* @param float $expire The TTL seconds
*
* @return bool True if the key was set
*/
abstract protected function add($client, string $key, string $value, float $expire): bool;

/**
* @param TClient $client
* @param string $script The Lua script
* @param int $numkeys The number of values in $arguments that represent Redis key names
* @param list<mixed> $arguments Keys and values
*
* @return mixed The script result, or false if executing failed
*
* @throws LockReleaseException An unexpected error happened
*/
abstract protected function evalScript($client, string $script, int $numkeys, array $arguments);
}
Loading
Loading