commit
7dc25fa6d7
4 changed files with 225 additions and 0 deletions
-
11composer.json
-
90lib/JobLock/JobLock.php
-
115lib/JobLock/JobLockService.php
-
9lib/JobLock/JobLockedException.php
@ -0,0 +1,11 @@ |
|||
{ |
|||
"name": "pavlicek.dev/job-lock", |
|||
"description": "Simple library providing service and entity to manage locking of background jobs", |
|||
"autoload": { |
|||
"psr-0": {"": "lib/"} |
|||
}, |
|||
"require": { |
|||
"php": ">= 8.2.0", |
|||
"doctrine/orm": "*" |
|||
} |
|||
} |
|||
@ -0,0 +1,90 @@ |
|||
<?php |
|||
|
|||
namespace JobLock; |
|||
|
|||
use DateTime; |
|||
|
|||
use Doctrine\ORM\Mapping as ORM; |
|||
|
|||
#[ORM\Entity, ORM\Table(name: 'job_locks')]
|
|||
class JobLock |
|||
{ |
|||
#[ORM\Id, ORM\GeneratedValue(strategy: 'IDENTITY'), ORM\Column(type: 'bigint')]
|
|||
protected ?int $id = null; |
|||
|
|||
|
|||
#[ORM\Column(type: 'string')]
|
|||
protected string $identifier; |
|||
|
|||
|
|||
#[ORM\Column(type: 'datetime')]
|
|||
protected DateTime $acquired; |
|||
|
|||
|
|||
#[ORM\Column(type: 'datetime', nullable: true)]
|
|||
protected ?DateTime $expires = null; |
|||
|
|||
|
|||
#[ORM\Column(type: 'datetime', nullable: true)]
|
|||
protected ?DateTime $released = null; |
|||
|
|||
|
|||
public function getId() : ?int |
|||
{ |
|||
return $this->id; |
|||
} |
|||
|
|||
|
|||
public function setId(?int $id) : void |
|||
{ |
|||
$this->id = $id; |
|||
} |
|||
|
|||
|
|||
public function getIdentifier() : string |
|||
{ |
|||
return $this->identifier; |
|||
} |
|||
|
|||
|
|||
public function setIdentifier(string $identifier) : void |
|||
{ |
|||
$this->identifier = $identifier; |
|||
} |
|||
|
|||
|
|||
public function getAcquired() : DateTime |
|||
{ |
|||
return $this->acquired; |
|||
} |
|||
|
|||
|
|||
public function setAcquired(DateTime $acquired) : void |
|||
{ |
|||
$this->acquired = $acquired; |
|||
} |
|||
|
|||
|
|||
public function getExpires() : ?DateTime |
|||
{ |
|||
return $this->expires; |
|||
} |
|||
|
|||
|
|||
public function setExpires(?DateTime $expires) : void |
|||
{ |
|||
$this->expires = $expires; |
|||
} |
|||
|
|||
|
|||
public function getReleased() : ?DateTime |
|||
{ |
|||
return $this->released; |
|||
} |
|||
|
|||
|
|||
public function setReleased(?DateTime $released) : void |
|||
{ |
|||
$this->released = $released; |
|||
} |
|||
} |
|||
@ -0,0 +1,115 @@ |
|||
<?php |
|||
|
|||
namespace JobLock; |
|||
|
|||
use DateTime; |
|||
|
|||
use Doctrine\ORM\EntityManager; |
|||
|
|||
/** |
|||
* Simple service that persist lock information into database. Used when application with multiple |
|||
* instances needs to run a job or some other procedure only once and avoid other instances spawning |
|||
* concurrent processes. |
|||
* |
|||
* @author Jan Pavlíček <jan@pavlicek.dev> |
|||
*/ |
|||
class JobLockService |
|||
{ |
|||
// determines default expiration date for locks - after this date, locks are not considered valid even if not released
|
|||
const MAXIMUM_LOCK_DURATION = '23 hours 50 minutes'; |
|||
|
|||
// determines how long are expired locks left in the database
|
|||
const MAXIMUM_LOCK_LIFETIME = '3 days'; |
|||
|
|||
// determines minimum sleep duration when the job is invoked - used to avoid running the job multiple times
|
|||
// from different containers, along with WAIT_MS_MAX - a value is assigned randomly between those two boundaries
|
|||
// to create artifical inacurracy and give enough time for locks to be created in other processes
|
|||
const WAIT_MS_MIN = 0; |
|||
|
|||
// upper boundry, same as WAIT_MS_MIN but it is also used as a timeout between job finish and lock release
|
|||
const WAIT_MS_MAX = 20000; |
|||
|
|||
|
|||
protected EntityManager $em; |
|||
|
|||
|
|||
public function __construct(EntityManager $em) |
|||
{ |
|||
$this->em = $em; |
|||
} |
|||
|
|||
|
|||
|
|||
/** |
|||
* Create a lock for a job with identifier. If the job is already locked, throw an exception. |
|||
* Uses a random short sleep duration to avoid concurrent runs not seeing each others locks. |
|||
* |
|||
* @throws Common\JobLock\JobLockedException |
|||
*/ |
|||
public function acquireLock(string $identifier) : void |
|||
{ |
|||
// random sleep is used to avoid too much accuracy which can lead to other instance starting
|
|||
// the job while the lock is being created
|
|||
usleep(rand(self::WAIT_MS_MIN * 1000, self::WAIT_MS_MAX * 1000)); |
|||
|
|||
// check for current locks that have not expired yet
|
|||
$current_lock = $this->em->createQuery('SELECT l FROM ' . JobLock::class . ' l |
|||
WHERE l.identifier = :identifier |
|||
AND l.expires > CURRENT_DATE() |
|||
AND l.released IS NULL') |
|||
->setParameter('identifier', $identifier) |
|||
->getResult(); |
|||
|
|||
if ($current_lock) { |
|||
throw new JobLockedException; |
|||
} |
|||
|
|||
// persist the new lock
|
|||
$lock = new JobLock; |
|||
$lock->setIdentifier($identifier); |
|||
$lock->setExpires(new DateTime('+' . self::MAXIMUM_LOCK_DURATION)); |
|||
$lock->setAcquired(new DateTime); |
|||
|
|||
$this->em->persist($lock); |
|||
$this->em->flush(); |
|||
} |
|||
|
|||
|
|||
|
|||
/** |
|||
* find all unreleased locks for given identifier and release them |
|||
*/ |
|||
public function releaseLock(string $identifier) : void |
|||
{ |
|||
// sleep for maximum amount before releasing the lock to avoid another process with higher
|
|||
// wait time to start after the lock is released
|
|||
usleep(self::WAIT_MS_MAX * 1000); |
|||
|
|||
$locks = $this->em->getRepository(JobLock::class)->findBy(['identifier' => $identifier, 'released' => null]); |
|||
|
|||
$now = new DateTime; |
|||
foreach ($locks as $lock) { |
|||
$lock->setReleased($now); |
|||
} |
|||
|
|||
$this->em->flush(); |
|||
$this->cleanupExpiredLocks(); |
|||
} |
|||
|
|||
|
|||
|
|||
/** |
|||
* Removes all locks that have expiration date further in the past than the maximum lock lifetime |
|||
* config constant from the database |
|||
*/ |
|||
public function cleanupExpiredLocks() : void |
|||
{ |
|||
$threshold = new DateTime('-' . self::MAXIMUM_LOCK_LIFETIME); |
|||
$this->em->createQueryBuilder() |
|||
->delete(JobLock::class, 'l') |
|||
->where('l.expires <= :threshold') |
|||
->setParameter('threshold', $threshold) |
|||
->getQuery() |
|||
->execute(); |
|||
} |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
<?php |
|||
|
|||
namespace JobLock; |
|||
|
|||
use Exception; |
|||
|
|||
class JobLockedException extends Exception |
|||
{ |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue