diff --git a/docs/basic-usage.md b/docs/basic-usage.md index d9d08bd..5bf7843 100644 --- a/docs/basic-usage.md +++ b/docs/basic-usage.md @@ -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: diff --git a/src/Task.php b/src/Task.php index 6ccd61f..a5659c6 100644 --- a/src/Task.php +++ b/src/Task.php @@ -66,6 +66,16 @@ class Task */ 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. * @@ -119,12 +129,23 @@ public function getAction() */ 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); } - return $this->{$method}(); + try { + $method = 'run' . ucfirst($this->type); + if (! method_exists($this, $method)) { + throw TasksException::forInvalidTaskType($this->type); + } + + return $this->{$method}(); + } finally { + if ($this->singleInstance) { + cache()->delete($lockKey); + } + } } /** @@ -145,9 +166,29 @@ public function shouldRun(?string $testTime = null): bool 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. @@ -296,6 +337,8 @@ protected function buildName() * Magic getter * * @return mixed + * + * @throws ReflectionException */ public function __get(string $key) { @@ -307,4 +350,16 @@ public function __get(string $key) 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); + } } diff --git a/tests/unit/TaskTest.php b/tests/unit/TaskTest.php index d08ad58..55862fe 100644 --- a/tests/unit/TaskTest.php +++ b/tests/unit/TaskTest.php @@ -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; @@ -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')); + } }