Skip to content

feat: add single instance locking for tasks with TTL support #185

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 23, 2025
Merged
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
90 changes: 58 additions & 32 deletions docs/basic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,43 +81,69 @@ a simple URL string, you can use a closure or command instead.
$schedule->url('https://my-status-cloud.com?site=foo.com')->everyFiveMinutes();
```

## Single Instance Tasks

Some tasks can run longer than their scheduled interval. To prevent multiple instances of the same task running simultaneously, you can use the `singleInstance()` method:

```php
$schedule->command('demo:heavy-task')->everyMinute()->singleInstance();
```

With this setup, even if the task takes more than one minute to complete, a new instance won't start until the running one finishes.

### Setting Lock Duration

By default, the lock will remain active until the task completes execution. However, you can specify a maximum lock duration by passing a TTL (time-to-live) value in seconds to the `singleInstance()` method:

```php
// Lock for a maximum of 30 minutes (1800 seconds)
$schedule->command('demo:heavy-task')
->everyFifteenMinutes()
->singleInstance(30 * MINUTE);
```

This is useful in preventing "stuck" locks. If a task crashes unexpectedly, the lock might remain indefinitely. Setting a TTL ensures the lock eventually expires.

If a task completes before the TTL expires, the lock is released immediately. The TTL only represents the maximum duration the lock can exist.

## Frequency Options

There are a number of ways available to specify how often the task is called.


| Method | Description |
|:----------------------------------|:----------------------------------------------------------------------|
| `->cron('* * * * *')` | Run on a custom cron schedule. |
| `->daily('4:00 am')` | Runs daily at 12:00am, unless a time string is passed in. |
| `->hourly() / ->hourly(15)` | Runs at the top of every hour or at specified minute. |
| `->everyHour(3, 15)` | Runs every 3 hours at XX:15. |
| `->betweenHours(6,12)` | Runs between hours 6 and 12. |
| `->hours([0,10,16])` | Runs at hours 0, 10 and 16. |
| `->everyMinute(20)` | Runs every 20 minutes. |
| `->betweenMinutes(0,30)` | Runs between minutes 0 and 30. |
| `->minutes([0,20,40])` | Runs at specific minutes 0,20 and 40. |
| `->everyFiveMinutes()` | Runs every 5 minutes (12:00, 12:05, 12:10, etc) |
| `->everyFifteenMinutes()` | Runs every 15 minutes (12:00, 12:15, etc) |
| `->everyThirtyMinutes()` | Runs every 30 minutes (12:00, 12:30, etc) |
| `->days([0,3])` | Runs only on Sunday and Wednesday ( 0 is Sunday , 6 is Saturday ) |
| `->sundays('3:15am')` | Runs every Sunday at midnight, unless time passed in. |
| `->mondays('3:15am')` | Runs every Monday at midnight, unless time passed in. |
| `->tuesdays('3:15am')` | Runs every Tuesday at midnight, unless time passed in. |
| `->wednesdays('3:15am')` | Runs every Wednesday at midnight, unless time passed in. |
| `->thursdays('3:15am')` | Runs every Thursday at midnight, unless time passed in. |
| `->fridays('3:15am')` | Runs every Friday at midnight, unless time passed in. |
| `->saturdays('3:15am')` | Runs every Saturday at midnight, unless time passed in. |
| `->monthly('12:21pm')` | Runs the first day of every month at 12:00am unless time passed in. |
| `->daysOfMonth([1,15])` | Runs only on days 1 and 15. |
| `->everyMonth(4)` | Runs every 4 months. |
| `->betweenMonths(4,7)` | Runs between months 4 and 7. |
| `->months([1,7])` | Runs only on January and July. |
| `->quarterly('5:00am')` | Runs the first day of each quarter (Jan 1, Apr 1, July 1, Oct 1) |
| `->yearly('12:34am')` | Runs the first day of the year. |
| `->weekdays('1:23pm')` | Runs M-F at 12:00 am unless time passed in. |
| `->weekends('2:34am')` | Runs Saturday and Sunday at 12:00 am unless time passed in. |
| `->environments('local', 'prod')` | Restricts the task to run only in the specified environments |
| Method | Description |
|:----------------------------------------------|:--------------------------------------------------------------------|
| `->cron('* * * * *')` | Run on a custom cron schedule. |
| `->daily('4:00 am')` | Runs daily at 12:00am, unless a time string is passed in. |
| `->hourly() / ->hourly(15)` | Runs at the top of every hour or at specified minute. |
| `->everyHour(3, 15)` | Runs every 3 hours at XX:15. |
| `->betweenHours(6,12)` | Runs between hours 6 and 12. |
| `->hours([0,10,16])` | Runs at hours 0, 10 and 16. |
| `->everyMinute(20)` | Runs every 20 minutes. |
| `->betweenMinutes(0,30)` | Runs between minutes 0 and 30. |
| `->minutes([0,20,40])` | Runs at specific minutes 0,20 and 40. |
| `->everyFiveMinutes()` | Runs every 5 minutes (12:00, 12:05, 12:10, etc) |
| `->everyFifteenMinutes()` | Runs every 15 minutes (12:00, 12:15, etc) |
| `->everyThirtyMinutes()` | Runs every 30 minutes (12:00, 12:30, etc) |
| `->days([0,3])` | Runs only on Sunday and Wednesday ( 0 is Sunday , 6 is Saturday ) |
| `->sundays('3:15am')` | Runs every Sunday at midnight, unless time passed in. |
| `->mondays('3:15am')` | Runs every Monday at midnight, unless time passed in. |
| `->tuesdays('3:15am')` | Runs every Tuesday at midnight, unless time passed in. |
| `->wednesdays('3:15am')` | Runs every Wednesday at midnight, unless time passed in. |
| `->thursdays('3:15am')` | Runs every Thursday at midnight, unless time passed in. |
| `->fridays('3:15am')` | Runs every Friday at midnight, unless time passed in. |
| `->saturdays('3:15am')` | Runs every Saturday at midnight, unless time passed in. |
| `->monthly('12:21pm')` | Runs the first day of every month at 12:00am unless time passed in. |
| `->daysOfMonth([1,15])` | Runs only on days 1 and 15. |
| `->everyMonth(4)` | Runs every 4 months. |
| `->betweenMonths(4,7)` | Runs between months 4 and 7. |
| `->months([1,7])` | Runs only on January and July. |
| `->quarterly('5:00am')` | Runs the first day of each quarter (Jan 1, Apr 1, July 1, Oct 1) |
| `->yearly('12:34am')` | Runs the first day of the year. |
| `->weekdays('1:23pm')` | Runs M-F at 12:00 am unless time passed in. |
| `->weekends('2:34am')` | Runs Saturday and Sunday at 12:00 am unless time passed in. |
| `->environments('local', 'prod')` | Restricts the task to run only in the specified environments. |
| `->singleInstance() / ->singleInstance(HOUR)` | Prevents concurrent executions of the same task. |


These methods can be combined to create even more nuanced timings:
Expand Down
63 changes: 59 additions & 4 deletions src/Task.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@
*/
protected string $name;

/**
* Whether to prevent concurrent executions of this task.
*/
protected bool $singleInstance = false;

/**
* Maximum lock duration in seconds for single instance tasks.
*/
protected ?int $singleInstanceTTL = null;

/**
* @param $action mixed The actual content that should be run.
*
Expand Down Expand Up @@ -119,12 +129,23 @@
*/
public function run()
{
$method = 'run' . ucfirst($this->type);
if (! method_exists($this, $method)) {
throw TasksException::forInvalidTaskType($this->type);
if ($this->singleInstance) {
$lockKey = $this->getLockKey();
cache()->save($lockKey, [], $this->singleInstanceTTL ?? 0);

Check warning on line 134 in src/Task.php

View workflow job for this annotation

GitHub Actions / infection / Mutation Testing

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ { if ($this->singleInstance) { $lockKey = $this->getLockKey(); - cache()->save($lockKey, [], $this->singleInstanceTTL ?? 0); + } try { $method = 'run' . ucfirst($this->type);

Check warning on line 134 in src/Task.php

View workflow job for this annotation

GitHub Actions / infection / Mutation Testing

Escaped Mutant for Mutator "Coalesce": @@ @@ { if ($this->singleInstance) { $lockKey = $this->getLockKey(); - cache()->save($lockKey, [], $this->singleInstanceTTL ?? 0); + cache()->save($lockKey, [], 0 ?? $this->singleInstanceTTL); } try { $method = 'run' . ucfirst($this->type);

Check warning on line 134 in src/Task.php

View workflow job for this annotation

GitHub Actions / infection / Mutation Testing

Escaped Mutant for Mutator "DecrementInteger": @@ @@ { if ($this->singleInstance) { $lockKey = $this->getLockKey(); - cache()->save($lockKey, [], $this->singleInstanceTTL ?? 0); + cache()->save($lockKey, [], $this->singleInstanceTTL ?? -1); } try { $method = 'run' . ucfirst($this->type);

Check warning on line 134 in src/Task.php

View workflow job for this annotation

GitHub Actions / infection / Mutation Testing

Escaped Mutant for Mutator "IncrementInteger": @@ @@ { if ($this->singleInstance) { $lockKey = $this->getLockKey(); - cache()->save($lockKey, [], $this->singleInstanceTTL ?? 0); + cache()->save($lockKey, [], $this->singleInstanceTTL ?? 1); } try { $method = 'run' . ucfirst($this->type);
}

return $this->{$method}();
try {
$method = 'run' . ucfirst($this->type);

Check warning on line 138 in src/Task.php

View workflow job for this annotation

GitHub Actions / infection / Mutation Testing

Escaped Mutant for Mutator "UnwrapUcFirst": @@ @@ cache()->save($lockKey, [], $this->singleInstanceTTL ?? 0); } try { - $method = 'run' . ucfirst($this->type); + $method = 'run' . $this->type; if (!method_exists($this, $method)) { throw TasksException::forInvalidTaskType($this->type); }
if (! method_exists($this, $method)) {
throw TasksException::forInvalidTaskType($this->type);
}

return $this->{$method}();
} finally {
if ($this->singleInstance) {
cache()->delete($lockKey);
}
}
}

/**
Expand All @@ -145,9 +166,29 @@
return false;
}

// If this is a single instance task and a lock exists, don't run
if ($this->singleInstance && cache()->get($this->getLockKey()) !== null) {
return false;
}

return $cron->shouldRun($this->getExpression());
}

/**
* Set this task to be a single instance
*
* @param int|null $lockTTL Time-to-live for the cache lock in seconds
*
* @return $this
*/
public function singleInstance(?int $lockTTL = null): static
{
$this->singleInstance = true;
$this->singleInstanceTTL = $lockTTL;

return $this;
}

/**
* Restricts this task to run within only
* specified environments.
Expand Down Expand Up @@ -296,6 +337,8 @@
* Magic getter
*
* @return mixed
*
* @throws ReflectionException
*/
public function __get(string $key)
{
Expand All @@ -307,4 +350,16 @@
return $this->{$key};
}
}

/**
* Determine the lock key for the task.
*
* @throws ReflectionException
*/
private function getLockKey(): string
{
$name = $this->name ?? $this->buildName();

return sprintf('task_lock_%s', $name);
}
}
138 changes: 138 additions & 0 deletions tests/unit/TaskTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/

use CodeIgniter\I18n\Time;
use CodeIgniter\Tasks\Exceptions\TasksException;
use CodeIgniter\Tasks\Task;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\Filters\CITestStreamFilter;
Expand Down Expand Up @@ -159,4 +160,141 @@ public function testLastRun()
$this->assertInstanceOf(Time::class, $task->lastRun()); // @phpstan-ignore-line
$this->assertSame($date, $task->lastRun()->format('Y-m-d H:i:s'));
}

public function testSingleInstanceMethod()
{
$task = new Task('command', 'foo:bar');

$this->assertFalse($this->getPrivateProperty($task, 'singleInstance'));

$result = $task->singleInstance();
$this->assertTrue($this->getPrivateProperty($task, 'singleInstance'));
$this->assertNull($this->getPrivateProperty($task, 'singleInstanceTTL'));
$this->assertSame($task, $result);

// Test with custom TTL
$task->singleInstance(3600);
$this->assertTrue($this->getPrivateProperty($task, 'singleInstance'));
$this->assertSame(3600, $this->getPrivateProperty($task, 'singleInstanceTTL'));
}

public function testGetLockKey()
{
$task = new Task('command', 'foo:bar');
$task->named('test_task');

$method = $this->getPrivateMethodInvoker($task, 'getLockKey');
$expected = 'task_lock_test_task';

$this->assertSame($expected, $method());

// Test with unnamed task - should use dynamic name
$task = new Task('command', 'foo:bar');
$method = $this->getPrivateMethodInvoker($task, 'getLockKey');

// Should use task name from magic getter
$expected = 'task_lock_' . $task->name;
$this->assertSame($expected, $method());
}

public function testNamedTaskLockConsistency()
{
// Create two different closure tasks with the same name
$closure1 = static fn () => 'test1';

$closure2 = static function () {
return 'test2'; // Different functionality
};

$task1 = new Task('closure', $closure1);
$task2 = new Task('closure', $closure2);

// If they have the same name, they should have the same lock key
$task1->named('same_name');
$task2->named('same_name');

$getLockKey1 = $this->getPrivateMethodInvoker($task1, 'getLockKey');
$getLockKey2 = $this->getPrivateMethodInvoker($task2, 'getLockKey');

$this->assertSame($getLockKey1(), $getLockKey2());

// Different names should produce different keys
$task3 = new Task('closure', $closure1);
$task3->named('different_name');

$getLockKey3 = $this->getPrivateMethodInvoker($task3, 'getLockKey');

$this->assertNotSame($getLockKey1(), $getLockKey3());
}

public function testShouldRunWithSingleInstance()
{
$task = (new Task('command', 'foo:bar'))
->named('test_should_run')
->hourly()
->singleInstance();

// Should run at the right time with no existing lock
$this->assertTrue($task->shouldRun('12:00am'));

// Create a lock
$lockKey = $this->getPrivateMethodInvoker($task, 'getLockKey')();
cache()->save($lockKey, [], 3600);

// Should not run if a lock exists
$this->assertFalse($task->shouldRun('12:00am'));

cache()->delete($lockKey);
}

public function testRunWithSingleInstance()
{
$task = new Task('closure', static fn () => 'task executed');
$task->named('test_run_single');
$task->singleInstance();

$result = $task->run();
$this->assertSame('task executed', $result);

$lockKey = $this->getPrivateMethodInvoker($task, 'getLockKey')();
$this->assertNull(cache()->get($lockKey));
}

public function testLockReleasedAfterException()
{
$task = new Task('command', 'invalid:command');
$task->named('test_exception');
$task->singleInstance();

$reflection = new ReflectionClass($task);
$property = $reflection->getProperty('type');
$property->setValue($task, 'invalid_type');

$lockKey = $this->getPrivateMethodInvoker($task, 'getLockKey')();

try {
$task->run();
$this->fail('Expected exception was not thrown');
} catch (Exception $e) {
$this->assertInstanceOf(TasksException::class, $e);
}

$this->assertNull(cache()->get($lockKey));
}

public function testSingleInstanceWithCustomTTL()
{
$task = new Task('closure', static fn () => 'done');
$task->named('test_ttl');

$task->singleInstance(60);

$this->assertSame(60, $this->getPrivateProperty($task, 'singleInstanceTTL'));

$task2 = new Task('closure', static fn () => 'done');
$task2->named('test_no_ttl');
$task2->singleInstance();

$this->assertNull($this->getPrivateProperty($task2, 'singleInstanceTTL'));
}
}
Loading