Skip to content

Commit f75ca76

Browse files
authored
feat: add single instance locking for tasks with TTL support (#185)
* feat: add single instance locking for tasks with TTL support * fix typo
1 parent 9916802 commit f75ca76

File tree

3 files changed

+255
-36
lines changed

3 files changed

+255
-36
lines changed

docs/basic-usage.md

+58-32
Original file line numberDiff line numberDiff line change
@@ -81,43 +81,69 @@ a simple URL string, you can use a closure or command instead.
8181
$schedule->url('https://my-status-cloud.com?site=foo.com')->everyFiveMinutes();
8282
```
8383

84+
## Single Instance Tasks
85+
86+
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:
87+
88+
```php
89+
$schedule->command('demo:heavy-task')->everyMinute()->singleInstance();
90+
```
91+
92+
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.
93+
94+
### Setting Lock Duration
95+
96+
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:
97+
98+
```php
99+
// Lock for a maximum of 30 minutes (1800 seconds)
100+
$schedule->command('demo:heavy-task')
101+
->everyFifteenMinutes()
102+
->singleInstance(30 * MINUTE);
103+
```
104+
105+
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.
106+
107+
If a task completes before the TTL expires, the lock is released immediately. The TTL only represents the maximum duration the lock can exist.
108+
84109
## Frequency Options
85110

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

88113

89-
| Method | Description |
90-
|:----------------------------------|:----------------------------------------------------------------------|
91-
| `->cron('* * * * *')` | Run on a custom cron schedule. |
92-
| `->daily('4:00 am')` | Runs daily at 12:00am, unless a time string is passed in. |
93-
| `->hourly() / ->hourly(15)` | Runs at the top of every hour or at specified minute. |
94-
| `->everyHour(3, 15)` | Runs every 3 hours at XX:15. |
95-
| `->betweenHours(6,12)` | Runs between hours 6 and 12. |
96-
| `->hours([0,10,16])` | Runs at hours 0, 10 and 16. |
97-
| `->everyMinute(20)` | Runs every 20 minutes. |
98-
| `->betweenMinutes(0,30)` | Runs between minutes 0 and 30. |
99-
| `->minutes([0,20,40])` | Runs at specific minutes 0,20 and 40. |
100-
| `->everyFiveMinutes()` | Runs every 5 minutes (12:00, 12:05, 12:10, etc) |
101-
| `->everyFifteenMinutes()` | Runs every 15 minutes (12:00, 12:15, etc) |
102-
| `->everyThirtyMinutes()` | Runs every 30 minutes (12:00, 12:30, etc) |
103-
| `->days([0,3])` | Runs only on Sunday and Wednesday ( 0 is Sunday , 6 is Saturday ) |
104-
| `->sundays('3:15am')` | Runs every Sunday at midnight, unless time passed in. |
105-
| `->mondays('3:15am')` | Runs every Monday at midnight, unless time passed in. |
106-
| `->tuesdays('3:15am')` | Runs every Tuesday at midnight, unless time passed in. |
107-
| `->wednesdays('3:15am')` | Runs every Wednesday at midnight, unless time passed in. |
108-
| `->thursdays('3:15am')` | Runs every Thursday at midnight, unless time passed in. |
109-
| `->fridays('3:15am')` | Runs every Friday at midnight, unless time passed in. |
110-
| `->saturdays('3:15am')` | Runs every Saturday at midnight, unless time passed in. |
111-
| `->monthly('12:21pm')` | Runs the first day of every month at 12:00am unless time passed in. |
112-
| `->daysOfMonth([1,15])` | Runs only on days 1 and 15. |
113-
| `->everyMonth(4)` | Runs every 4 months. |
114-
| `->betweenMonths(4,7)` | Runs between months 4 and 7. |
115-
| `->months([1,7])` | Runs only on January and July. |
116-
| `->quarterly('5:00am')` | Runs the first day of each quarter (Jan 1, Apr 1, July 1, Oct 1) |
117-
| `->yearly('12:34am')` | Runs the first day of the year. |
118-
| `->weekdays('1:23pm')` | Runs M-F at 12:00 am unless time passed in. |
119-
| `->weekends('2:34am')` | Runs Saturday and Sunday at 12:00 am unless time passed in. |
120-
| `->environments('local', 'prod')` | Restricts the task to run only in the specified environments |
114+
| Method | Description |
115+
|:----------------------------------------------|:--------------------------------------------------------------------|
116+
| `->cron('* * * * *')` | Run on a custom cron schedule. |
117+
| `->daily('4:00 am')` | Runs daily at 12:00am, unless a time string is passed in. |
118+
| `->hourly() / ->hourly(15)` | Runs at the top of every hour or at specified minute. |
119+
| `->everyHour(3, 15)` | Runs every 3 hours at XX:15. |
120+
| `->betweenHours(6,12)` | Runs between hours 6 and 12. |
121+
| `->hours([0,10,16])` | Runs at hours 0, 10 and 16. |
122+
| `->everyMinute(20)` | Runs every 20 minutes. |
123+
| `->betweenMinutes(0,30)` | Runs between minutes 0 and 30. |
124+
| `->minutes([0,20,40])` | Runs at specific minutes 0,20 and 40. |
125+
| `->everyFiveMinutes()` | Runs every 5 minutes (12:00, 12:05, 12:10, etc) |
126+
| `->everyFifteenMinutes()` | Runs every 15 minutes (12:00, 12:15, etc) |
127+
| `->everyThirtyMinutes()` | Runs every 30 minutes (12:00, 12:30, etc) |
128+
| `->days([0,3])` | Runs only on Sunday and Wednesday ( 0 is Sunday , 6 is Saturday ) |
129+
| `->sundays('3:15am')` | Runs every Sunday at midnight, unless time passed in. |
130+
| `->mondays('3:15am')` | Runs every Monday at midnight, unless time passed in. |
131+
| `->tuesdays('3:15am')` | Runs every Tuesday at midnight, unless time passed in. |
132+
| `->wednesdays('3:15am')` | Runs every Wednesday at midnight, unless time passed in. |
133+
| `->thursdays('3:15am')` | Runs every Thursday at midnight, unless time passed in. |
134+
| `->fridays('3:15am')` | Runs every Friday at midnight, unless time passed in. |
135+
| `->saturdays('3:15am')` | Runs every Saturday at midnight, unless time passed in. |
136+
| `->monthly('12:21pm')` | Runs the first day of every month at 12:00am unless time passed in. |
137+
| `->daysOfMonth([1,15])` | Runs only on days 1 and 15. |
138+
| `->everyMonth(4)` | Runs every 4 months. |
139+
| `->betweenMonths(4,7)` | Runs between months 4 and 7. |
140+
| `->months([1,7])` | Runs only on January and July. |
141+
| `->quarterly('5:00am')` | Runs the first day of each quarter (Jan 1, Apr 1, July 1, Oct 1) |
142+
| `->yearly('12:34am')` | Runs the first day of the year. |
143+
| `->weekdays('1:23pm')` | Runs M-F at 12:00 am unless time passed in. |
144+
| `->weekends('2:34am')` | Runs Saturday and Sunday at 12:00 am unless time passed in. |
145+
| `->environments('local', 'prod')` | Restricts the task to run only in the specified environments. |
146+
| `->singleInstance() / ->singleInstance(HOUR)` | Prevents concurrent executions of the same task. |
121147

122148

123149
These methods can be combined to create even more nuanced timings:

src/Task.php

+59-4
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ class Task
6666
*/
6767
protected string $name;
6868

69+
/**
70+
* Whether to prevent concurrent executions of this task.
71+
*/
72+
protected bool $singleInstance = false;
73+
74+
/**
75+
* Maximum lock duration in seconds for single instance tasks.
76+
*/
77+
protected ?int $singleInstanceTTL = null;
78+
6979
/**
7080
* @param $action mixed The actual content that should be run.
7181
*
@@ -119,12 +129,23 @@ public function getAction()
119129
*/
120130
public function run()
121131
{
122-
$method = 'run' . ucfirst($this->type);
123-
if (! method_exists($this, $method)) {
124-
throw TasksException::forInvalidTaskType($this->type);
132+
if ($this->singleInstance) {
133+
$lockKey = $this->getLockKey();
134+
cache()->save($lockKey, [], $this->singleInstanceTTL ?? 0);
125135
}
126136

127-
return $this->{$method}();
137+
try {
138+
$method = 'run' . ucfirst($this->type);
139+
if (! method_exists($this, $method)) {
140+
throw TasksException::forInvalidTaskType($this->type);
141+
}
142+
143+
return $this->{$method}();
144+
} finally {
145+
if ($this->singleInstance) {
146+
cache()->delete($lockKey);
147+
}
148+
}
128149
}
129150

130151
/**
@@ -145,9 +166,29 @@ public function shouldRun(?string $testTime = null): bool
145166
return false;
146167
}
147168

169+
// If this is a single instance task and a lock exists, don't run
170+
if ($this->singleInstance && cache()->get($this->getLockKey()) !== null) {
171+
return false;
172+
}
173+
148174
return $cron->shouldRun($this->getExpression());
149175
}
150176

177+
/**
178+
* Set this task to be a single instance
179+
*
180+
* @param int|null $lockTTL Time-to-live for the cache lock in seconds
181+
*
182+
* @return $this
183+
*/
184+
public function singleInstance(?int $lockTTL = null): static
185+
{
186+
$this->singleInstance = true;
187+
$this->singleInstanceTTL = $lockTTL;
188+
189+
return $this;
190+
}
191+
151192
/**
152193
* Restricts this task to run within only
153194
* specified environments.
@@ -296,6 +337,8 @@ protected function buildName()
296337
* Magic getter
297338
*
298339
* @return mixed
340+
*
341+
* @throws ReflectionException
299342
*/
300343
public function __get(string $key)
301344
{
@@ -307,4 +350,16 @@ public function __get(string $key)
307350
return $this->{$key};
308351
}
309352
}
353+
354+
/**
355+
* Determine the lock key for the task.
356+
*
357+
* @throws ReflectionException
358+
*/
359+
private function getLockKey(): string
360+
{
361+
$name = $this->name ?? $this->buildName();
362+
363+
return sprintf('task_lock_%s', $name);
364+
}
310365
}

tests/unit/TaskTest.php

+138
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
*/
1313

1414
use CodeIgniter\I18n\Time;
15+
use CodeIgniter\Tasks\Exceptions\TasksException;
1516
use CodeIgniter\Tasks\Task;
1617
use CodeIgniter\Test\DatabaseTestTrait;
1718
use CodeIgniter\Test\Filters\CITestStreamFilter;
@@ -159,4 +160,141 @@ public function testLastRun()
159160
$this->assertInstanceOf(Time::class, $task->lastRun()); // @phpstan-ignore-line
160161
$this->assertSame($date, $task->lastRun()->format('Y-m-d H:i:s'));
161162
}
163+
164+
public function testSingleInstanceMethod()
165+
{
166+
$task = new Task('command', 'foo:bar');
167+
168+
$this->assertFalse($this->getPrivateProperty($task, 'singleInstance'));
169+
170+
$result = $task->singleInstance();
171+
$this->assertTrue($this->getPrivateProperty($task, 'singleInstance'));
172+
$this->assertNull($this->getPrivateProperty($task, 'singleInstanceTTL'));
173+
$this->assertSame($task, $result);
174+
175+
// Test with custom TTL
176+
$task->singleInstance(3600);
177+
$this->assertTrue($this->getPrivateProperty($task, 'singleInstance'));
178+
$this->assertSame(3600, $this->getPrivateProperty($task, 'singleInstanceTTL'));
179+
}
180+
181+
public function testGetLockKey()
182+
{
183+
$task = new Task('command', 'foo:bar');
184+
$task->named('test_task');
185+
186+
$method = $this->getPrivateMethodInvoker($task, 'getLockKey');
187+
$expected = 'task_lock_test_task';
188+
189+
$this->assertSame($expected, $method());
190+
191+
// Test with unnamed task - should use dynamic name
192+
$task = new Task('command', 'foo:bar');
193+
$method = $this->getPrivateMethodInvoker($task, 'getLockKey');
194+
195+
// Should use task name from magic getter
196+
$expected = 'task_lock_' . $task->name;
197+
$this->assertSame($expected, $method());
198+
}
199+
200+
public function testNamedTaskLockConsistency()
201+
{
202+
// Create two different closure tasks with the same name
203+
$closure1 = static fn () => 'test1';
204+
205+
$closure2 = static function () {
206+
return 'test2'; // Different functionality
207+
};
208+
209+
$task1 = new Task('closure', $closure1);
210+
$task2 = new Task('closure', $closure2);
211+
212+
// If they have the same name, they should have the same lock key
213+
$task1->named('same_name');
214+
$task2->named('same_name');
215+
216+
$getLockKey1 = $this->getPrivateMethodInvoker($task1, 'getLockKey');
217+
$getLockKey2 = $this->getPrivateMethodInvoker($task2, 'getLockKey');
218+
219+
$this->assertSame($getLockKey1(), $getLockKey2());
220+
221+
// Different names should produce different keys
222+
$task3 = new Task('closure', $closure1);
223+
$task3->named('different_name');
224+
225+
$getLockKey3 = $this->getPrivateMethodInvoker($task3, 'getLockKey');
226+
227+
$this->assertNotSame($getLockKey1(), $getLockKey3());
228+
}
229+
230+
public function testShouldRunWithSingleInstance()
231+
{
232+
$task = (new Task('command', 'foo:bar'))
233+
->named('test_should_run')
234+
->hourly()
235+
->singleInstance();
236+
237+
// Should run at the right time with no existing lock
238+
$this->assertTrue($task->shouldRun('12:00am'));
239+
240+
// Create a lock
241+
$lockKey = $this->getPrivateMethodInvoker($task, 'getLockKey')();
242+
cache()->save($lockKey, [], 3600);
243+
244+
// Should not run if a lock exists
245+
$this->assertFalse($task->shouldRun('12:00am'));
246+
247+
cache()->delete($lockKey);
248+
}
249+
250+
public function testRunWithSingleInstance()
251+
{
252+
$task = new Task('closure', static fn () => 'task executed');
253+
$task->named('test_run_single');
254+
$task->singleInstance();
255+
256+
$result = $task->run();
257+
$this->assertSame('task executed', $result);
258+
259+
$lockKey = $this->getPrivateMethodInvoker($task, 'getLockKey')();
260+
$this->assertNull(cache()->get($lockKey));
261+
}
262+
263+
public function testLockReleasedAfterException()
264+
{
265+
$task = new Task('command', 'invalid:command');
266+
$task->named('test_exception');
267+
$task->singleInstance();
268+
269+
$reflection = new ReflectionClass($task);
270+
$property = $reflection->getProperty('type');
271+
$property->setValue($task, 'invalid_type');
272+
273+
$lockKey = $this->getPrivateMethodInvoker($task, 'getLockKey')();
274+
275+
try {
276+
$task->run();
277+
$this->fail('Expected exception was not thrown');
278+
} catch (Exception $e) {
279+
$this->assertInstanceOf(TasksException::class, $e);
280+
}
281+
282+
$this->assertNull(cache()->get($lockKey));
283+
}
284+
285+
public function testSingleInstanceWithCustomTTL()
286+
{
287+
$task = new Task('closure', static fn () => 'done');
288+
$task->named('test_ttl');
289+
290+
$task->singleInstance(60);
291+
292+
$this->assertSame(60, $this->getPrivateProperty($task, 'singleInstanceTTL'));
293+
294+
$task2 = new Task('closure', static fn () => 'done');
295+
$task2->named('test_no_ttl');
296+
$task2->singleInstance();
297+
298+
$this->assertNull($this->getPrivateProperty($task2, 'singleInstanceTTL'));
299+
}
162300
}

0 commit comments

Comments
 (0)