|
<?php |
|
|
|
class RedisLockFailException extends Exception {} |
|
|
|
class RedisLock |
|
{ |
|
const DEFAULT_TIMEOUT = 20; |
|
|
|
// what to do if we cannot get lock |
|
const FAIL_EXCEPT = 'fail_except'; // throw exception |
|
const FAIL_RETURN = 'fail_return'; // return false |
|
const FAIL_BLOCK = 'fail_block'; // try again a few times, then throw exception |
|
|
|
|
|
|
|
/** @var \Credis_Client */ |
|
protected static $redis; |
|
|
|
// track all our locks so we can release them if there's an exception or if we forget |
|
protected static $locks = []; |
|
|
|
// lock name. if two locks share a name, only one will ever be locked at a time |
|
protected $name; |
|
|
|
// value is a unique value set when locking the lock. |
|
// when we unlock, we send value to make sure the lock we're unlocking is this exact one |
|
protected $value; |
|
|
|
// what to do when a lock cannot be obtained |
|
protected $failBehavior; |
|
|
|
// how long before this lock expires. this is just in case we don't release the lock ourselves |
|
// this is a failsafe. it should be set longer than you ever expect to need the lock for, but |
|
// not so long that it freezes your app if the lock is never released |
|
protected $timeout; |
|
|
|
// does this lock need to be released. so we can release all locks without unneccesary redis queries |
|
protected $needsRelease = false; |
|
|
|
|
|
/** |
|
* Set redis instance to use for communication with Redis server. |
|
* You must call this before using any other function |
|
* |
|
* @param \Credis_Client $redis |
|
*/ |
|
public static function setRedis(\Credis_Client $redis) |
|
{ |
|
static::$redis = $redis; |
|
} |
|
|
|
protected static function ensureRedisIsSet() |
|
{ |
|
if (!static::$redis) |
|
{ |
|
throw new Exception('You must call setRedis() before using redis locks'); |
|
} |
|
} |
|
|
|
/** |
|
********************** THIS IS PROBABLY THE METHOD YOU WANT ******************************* |
|
* |
|
* Returns a lock object if the lock was successfully obtained. Store this object and call |
|
* release() on it when you're ready to release the lock. |
|
* |
|
* @return RedisLock|bool |
|
*/ |
|
public static function createAndLock($name, $timeout, $failBehavior = self::FAIL_EXCEPT) |
|
{ |
|
$lock = static::create($name, $timeout, $failBehavior); |
|
$return = $lock->lock(); |
|
return $return === true ? $lock : $return; |
|
} |
|
|
|
|
|
/** |
|
* @return RedisLock |
|
*/ |
|
public static function create($name, $timeout, $failBehavior = self::FAIL_EXCEPT) |
|
{ |
|
$lock = new static($name, $timeout, $failBehavior); |
|
static::$locks[] = $lock; |
|
return $lock; |
|
} |
|
|
|
|
|
public function __construct($name, $timeout, $failBehavior) |
|
{ |
|
if (!in_array($failBehavior, [static::FAIL_EXCEPT, static::FAIL_RETURN, static::FAIL_BLOCK])) |
|
{ |
|
throw new DomainException('Unknown fail behavior: ' . $failBehavior); |
|
} |
|
|
|
if (!is_int($timeout)) |
|
{ |
|
throw new InvalidArgumentException('$timeout must be an integer.'); |
|
} |
|
|
|
$this->name = $name; |
|
$this->value = function_exists('random_bytes') ? base64_encode(random_bytes(15)) : uniqid(); |
|
$this->failBehavior = $failBehavior; |
|
$this->timeout = (int) $timeout; |
|
} |
|
|
|
protected function key() |
|
{ |
|
return 'redislock:' . $this->name; |
|
} |
|
|
|
/** |
|
* Acquire lock |
|
* |
|
* @return bool True if lock acquired, false otherwise |
|
* @trhows RedisLockFailException |
|
*/ |
|
public function lock() |
|
{ |
|
$maxTries = 5; |
|
$numTries = 0; |
|
|
|
static::ensureRedisIsSet(); |
|
|
|
while ($numTries < $maxTries) |
|
{ |
|
$response = static::$redis->set($this->key(), $this->value, ['NX', 'EX' => $this->timeout]); |
|
|
|
if ($response === true) |
|
{ |
|
$this->needsRelease = true; |
|
return true; |
|
} |
|
|
|
switch($this->failBehavior) |
|
{ |
|
case static::FAIL_EXCEPT: throw new RedisLockFailException('Could not get lock "' . $this->name . '"'); |
|
case static::FAIL_RETURN: return false; |
|
case static::FAIL_BLOCK: break; // do nothing, it's handled below |
|
} |
|
|
|
$numTries++; |
|
if ($numTries < $maxTries) |
|
{ |
|
sleep(pow(2, $numTries-1)); |
|
} |
|
} |
|
|
|
throw new RedisLockFailException('Could not get lock "' . $this->name . '" after ' . $numTries .' tries.'); |
|
} |
|
|
|
public function release() |
|
{ |
|
static::ensureRedisIsSet(); |
|
if ($this->needsRelease) |
|
{ |
|
// script will unlock only if we're the ones who got the lock in the first place |
|
// see Patterns section at http://redis.io/commands/set |
|
$unlockScript = 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end'; |
|
static::$redis->eval($unlockScript, [$this->key()], [$this->value]); |
|
$this->needsRelease = false; |
|
} |
|
} |
|
|
|
|
|
/** |
|
* DO NOT USE THIS METHOD FOR NORMAL LOCKING. |
|
* |
|
* createAndLock() returns a lock object. You should store that object and call its release() |
|
* method when you're ready to release the lock. |
|
* |
|
* This should get called at the end of a request to clean up any leftover locks. |
|
*/ |
|
public static function releaseAllCurrentlyHeldLocks() |
|
{ |
|
static::ensureRedisIsSet(); |
|
foreach(static::$locks as $lock) |
|
{ |
|
$lock->release(); |
|
} |
|
} |
|
} |