Skip to content

Instantly share code, notes, and snippets.

@mikeshiyan
Last active November 21, 2018 15:12
Show Gist options
  • Save mikeshiyan/1dd25781f25f702290e068c42891c2a8 to your computer and use it in GitHub Desktop.
Save mikeshiyan/1dd25781f25f702290e068c42891c2a8 to your computer and use it in GitHub Desktop.
Renewable persistent database lock backend class for Drupal 7 (min PHP 7.1). Perfect for locking batch process.
<?php
/**
* Defines the renewable persistent database lock backend.
*/
class RenewablePersistentDbLockBe {
/**
* Lock token identifier.
*
* @var string
*/
protected static $lockId;
/**
* Existing locks for this page request.
*
* @var array
*/
protected static $locks = [];
/**
* Gets the page token for locks.
*
* @return string
* Unique ID for the request.
*/
public function getLockId(): string {
if (!isset(self::$lockId)) {
self::$lockId = uniqid(mt_rand(), TRUE);
}
return self::$lockId;
}
/**
* Acquires (or renews) a lock for the current page request.
*
* @param string $name
* Lock name. Limit of name's length is 255 characters.
* @param float $timeout
* (optional) Lock lifetime in seconds. Defaults to 30.0.
*
* @return bool
* TRUE if the lock was acquired, FALSE if it failed.
*/
public function acquire(string $name, float $timeout = 30.0): bool {
if (!empty(self::$locks[$name])) {
return $this->renew($name, $this->getLockId(), $timeout);
}
else {
// Optimistically try to acquire the lock, then retry once if it fails.
// The first time through the loop cannot be a retry.
$retry = FALSE;
// We always want to do this code at least once.
do {
try {
db_insert('semaphore')
->fields([
'name' => $name,
'value' => $this->getLockId(),
'expire' => $this->calcExpire($timeout),
])
->execute();
// We track all acquired locks in the global variable.
self::$locks[$name] = TRUE;
// We never need to try again.
$retry = FALSE;
}
catch (\PDOException $e) {
// Suppress the error. If this is our first pass through the loop,
// then $retry is FALSE. In this case, the insert failed because some
// other request acquired the lock but did not release it. We decide
// whether to retry by checking lockMayBeAvailable(). This will clear
// the offending row from the database table in case it has expired.
$retry = $retry ? FALSE : $this->lockMayBeAvailable($name);
}
// We only retry in case the first attempt failed, but we then broke
// an expired lock.
} while ($retry);
return !empty(self::$locks[$name]);
}
}
/**
* Renews a lock.
*
* @param string $name
* Lock name. Limit of name's length is 255 characters.
* @param string $lock_id
* The lock ID.
* @param float $timeout
* (optional) Lock lifetime in seconds. Defaults to 30.0.
*
* @return bool
* TRUE if the lock was renewed, FALSE if it failed.
*/
public function renew(string $name, string $lock_id, float $timeout = 30.0): bool {
// Try to extend the expiration of a lock we already acquired.
$success = (bool) db_update('semaphore')
->fields(['expire' => $this->calcExpire($timeout)])
->condition('name', $name)
->condition('value', $lock_id)
->execute();
if ($lock_id === $this->getLockId()) {
self::$locks[$name] = $success;
}
return $success;
}
/**
* Calculates the Expire value.
*
* @param float $timeout
* Lock lifetime in seconds.
*
* @return float
* The expiration timestamp.
*/
protected function calcExpire(float $timeout): float {
// Insure that the timeout is at least 1 ms.
return microtime(TRUE) + max($timeout, 0.001);
}
/**
* Checks if a lock is available for acquiring.
*
* @param string $name
* Lock to acquire.
*
* @return bool
* TRUE if there is no lock or it was removed, FALSE otherwise.
*/
public function lockMayBeAvailable(string $name): bool {
$lock = db_query('SELECT expire, value FROM {semaphore} WHERE name = :name', [':name' => $name])->fetchAssoc();
if (!$lock) {
return TRUE;
}
$expire = (float) $lock['expire'];
$now = microtime(TRUE);
if ($now > $expire) {
// We check two conditions to prevent a race condition where another
// request acquired the lock and set a new expire time. We add a small
// number to $expire to avoid errors with float to string conversion.
return (bool) db_delete('semaphore')
->condition('name', $name)
->condition('value', $lock['value'])
->condition('expire', 0.0001 + $expire, '<=')
->execute();
}
return FALSE;
}
/**
* Waits a short amount of time before a second lock acquire attempt.
*
* This function may be called in a request that fails to acquire a desired
* lock. This will block further execution until the lock is available or the
* specified delay in seconds is reached. This should not be used with locks
* that are acquired very frequently, since the lock is likely to be acquired
* again by a different request while waiting.
*
* @param string $name
* Lock name currently being locked.
* @param int $delay
* (optional) Seconds to wait for. Defaults to 30.
*
* @return bool
* TRUE if the lock holds, FALSE if it may be available. You still need to
* acquire the lock manually and it may fail again.
*/
public function wait(string $name, int $delay = 30): bool {
// Pause the process for short periods between calling
// lock_may_be_available(). This prevents hitting the database with constant
// database queries while waiting, which could lead to performance issues.
// However, if the wait period is too long, there is the potential for a
// large number of processes to be blocked waiting for a lock, especially
// if the item being rebuilt is commonly requested. To address both of these
// concerns, begin waiting for 25ms, then add 25ms to the wait period each
// time until it reaches 500ms. After this point polling will continue every
// 500ms until $delay is reached.
// $delay is passed in seconds, but we will be using usleep(), which takes
// microseconds as a parameter. Multiply it by 1 million so that all
// further numbers are equivalent.
$delay = (int) $delay * 1000000;
// Begin sleeping at 25ms.
$sleep = 25000;
while ($delay > 0) {
// This function should only be called by a request that failed to get a
// lock, so we sleep first to give the parallel request a chance to finish
// and release the lock.
usleep($sleep);
// After each sleep, increase the value of $sleep until it reaches
// 500ms, to reduce the potential for a lock stampede.
$delay = $delay - $sleep;
$sleep = min(500000, $sleep + 25000, $delay);
if ($this->lockMayBeAvailable($name)) {
// No longer need to wait.
return FALSE;
}
}
// The caller must still wait longer to get the lock.
return TRUE;
}
/**
* Releases the given lock.
*
* @param string $name
* The name of the lock.
* @param string $lock_id
* (optional) If none given, remove the lock from the current page.
*/
public function release(string $name, string $lock_id = NULL): void {
$lock_id = $lock_id ?? $this->getLockId();
if ($lock_id === $this->getLockId()) {
self::$locks[$name] = FALSE;
}
db_delete('semaphore')
->condition('name', $name)
->condition('value', $lock_id)
->execute();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment