From 0a60a80011c0f778910ba6aa56491041d5bac433 Mon Sep 17 00:00:00 2001 From: Dimitri Sitchet Tomkeu Date: Wed, 14 Oct 2026 19:06:37 +0100 Subject: [PATCH 1/3] chore: update de la version de blitz-php/framework --- composer.json | 2 +- spec/bootstrap.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index f75c7b5..0284f5c 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ }, "require-dev": { "blitz-php/coding-standard": "^1.4", - "blitz-php/framework": "^0.11.3", + "blitz-php/framework": "^0.12", "blitz-php/http-client": "^0.0.2", "kahlan/kahlan": "^6.0", "phpstan/phpstan": "^2.1" diff --git a/spec/bootstrap.php b/spec/bootstrap.php index f180b43..2b5687f 100644 --- a/spec/bootstrap.php +++ b/spec/bootstrap.php @@ -30,3 +30,4 @@ config()->load('parametres', VENDOR_PATH . 'blitz-php/parametres/src/Config/parametres.php'); config()->set('parametres.file.path', storage_path('.parametres.json')); config()->load('tasks', __DIR__ . '/../src/Config/tasks.php'); +config()->ghost('cache', ['handler' => 'file']); From 9b5c613205466013619beed0e327e6b457ab3454 Mon Sep 17 00:00:00 2001 From: Dimitri Sitchet Tomkeu Date: Wed, 14 Oct 2026 19:11:33 +0100 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20ajout=20d'un=20verrouillage=20d'ins?= =?UTF-8?q?tance=20unique=20pour=20les=20t=C3=A2ches=20prenant=20en=20char?= =?UTF-8?q?ge=20la=20dur=C3=A9e=20de=20vie=20(TTL)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/Task.spec.php | 132 ++++++++++++++++++++++++++++++++++++++++++++- src/Task.php | 59 ++++++++++++++++++-- 2 files changed, 186 insertions(+), 5 deletions(-) diff --git a/spec/Task.spec.php b/spec/Task.spec.php index 66a220a..8ac409d 100644 --- a/spec/Task.spec.php +++ b/spec/Task.spec.php @@ -11,6 +11,7 @@ use BlitzPHP\Spec\CliOutputHelper as COH; use BlitzPHP\Spec\ReflectionHelper; +use BlitzPHP\Tasks\Exceptions\TasksException; use BlitzPHP\Tasks\Task; use BlitzPHP\Utilities\Date; use BlitzPHP\Utilities\Helpers; @@ -137,7 +138,136 @@ expect($task->lastRun()->format('Y-m-d H:i:s'))->toBe($date); }); - it('Peut executer une commande shell', function () { + it('Execute une tache unique', function () { + $task = new Task('command', 'foo:bar'); + + expect($task->singleInstance)->toBeFalsy(); + + $result = $task->singleInstance(); + expect($task->singleInstance)->toBeTruthy(); + expect($task->singleInstanceTTL)->toBeNull(); + expect($result)->toBe($task); + + // Test avec un TTL personnalisé + $task->singleInstance(3600); + expect($task->singleInstance)->toBeTruthy(); + expect($task->singleInstanceTTL)->toBe(3600); + }); + + it('Recuperation de la cle de verouillage', function () { + $task = new Task('command', 'foo:bar'); + $task->named('test_task'); + + $method = ReflectionHelper::getPrivateMethodInvoker($task, 'getLockKey'); + $expected = 'task_lock_test_task'; + + expect($method())->toBe($expected); + + // Test avec une tache sans nom - doit utiliser le nom dynamique + $task = new Task('command', 'foo:bar'); + $method = ReflectionHelper::getPrivateMethodInvoker($task, 'getLockKey'); + + // Doit utiliser le nom de la tache depuis le getter magique + $expected = "task_lock_{$task->name}"; + expect($method())->toBe($expected); + }); + + it('Cohérence de la clé de verrouillage pour les tâches nommées', function () { + // Créer deux tâches closure différentes avec le même nom + $closure1 = static fn () => 'test1'; + + $closure2 = static function () { + return 'test2'; // Different functionality + }; + + $task1 = new Task('closure', $closure1); + $task2 = new Task('closure', $closure2); + + // Si elles ont le même nom, elles devraient avoir la même clé de verrouillage + $task1->named('same_name'); + $task2->named('same_name'); + + $getLockKey1 = ReflectionHelper::getPrivateMethodInvoker($task1, 'getLockKey'); + $getLockKey2 = ReflectionHelper::getPrivateMethodInvoker($task2, 'getLockKey'); + + expect($getLockKey1())->toBe($getLockKey2()); + + // Des noms différents devraient produire des clés différentes + $task3 = new Task('closure', $closure1); + $task3->named('different_name'); + + $getLockKey3 = ReflectionHelper::getPrivateMethodInvoker($task3, 'getLockKey'); + + expect($getLockKey1())->not->toBe($getLockKey3()); + }); + + it('shouldRun avec singleInstance', function () { + $task = (new Task('command', 'foo:bar')) + ->named('test_should_run') + ->hourly() + ->singleInstance(); + + // Devrait s'exécuter à l'heure correcte sans verrou existant + expect($task->shouldRun('12:00am'))->toBeTruthy(); + + // Créer un verrou + $lockKey = ReflectionHelper::getPrivateMethodInvoker($task, 'getLockKey')(); + cache()->set($lockKey, [], 3600); + + // Should not run if a lock exists + expect($task->shouldRun('12:00am'))->toBeFalsy(); + + cache()->delete($lockKey); + }); + + it('run avec singleInstance', function () { + $task = new Task('closure', static fn () => 'task executed'); + $task->named('test_run_single'); + $task->singleInstance(); + + $result = $task->run(); + expect($result)->toBe('task executed'); + $lockKey = ReflectionHelper::getPrivateMethodInvoker($task, 'getLockKey')(); + expect(cache()->get($lockKey))->toBeNull(); + }); + + it('verrou libéré après une exception', function () { + $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 = ReflectionHelper::getPrivateMethodInvoker($task, 'getLockKey')(); + + try { + $task->run(); + expect(false)->toBe(true); // Should not reach here + } catch (Exception $e) { + expect($e)->toBeAnInstanceOf(TasksException::class); + } + + expect(cache()->get($lockKey))->toBeNull(); + }); + + it('singleInstance avec TTL personnalisé', function () { + $task = new Task('closure', static fn () => 'done'); + $task->named('test_ttl'); + + $task->singleInstance(60); + + expect(ReflectionHelper::getPrivateProperty($task, 'singleInstanceTTL'))->toBe(60); + + $task2 = new Task('closure', static fn () => 'done'); + $task2->named('test_no_ttl'); + $task2->singleInstance(); + + expect(ReflectionHelper::getPrivateProperty($task2, 'singleInstanceTTL'))->toBeNull(); + }); + + it('Peut exécuter une commande shell', function () { expect(file_exists($path = __DIR__ . '/test.php'))->toBeFalsy(); $task = new Task('shell', 'cp ' . __FILE__ . ' ' . $path); diff --git a/src/Task.php b/src/Task.php index 6e9eb37..6021c5f 100644 --- a/src/Task.php +++ b/src/Task.php @@ -57,6 +57,16 @@ class Task */ protected array $environments = []; + /** + * Faut-il empêcher les exécutions simultanées de cette tâche ? + */ + protected bool $singleInstance = false; + + /** + * Durée maximale de verrouillage en secondes pour les tâches single instance. + */ + protected ?int $singleInstanceTTL = null; + /** * Timezone dans lequel la tâche doit être traitée. */ @@ -122,12 +132,23 @@ public function getAction(): mixed */ public function run() { - $method = 'run' . ucfirst($this->type); - if (! method_exists($this, $method)) { - throw TasksException::invalidTaskType($this->type); + if ($this->singleInstance) { + $lockKey = $this->getLockKey(); + cache()->set($lockKey, [], $this->singleInstanceTTL ?? 0); } - return $this->process($this->container, $method); + try { + $method = 'run' . ucfirst($this->type); + if (! method_exists($this, $method)) { + throw TasksException::invalidTaskType($this->type); + } + + return $this->process($this->container, $method); + } finally { + if ($this->singleInstance) { + cache()->delete($lockKey); + } + } } /** @@ -147,9 +168,27 @@ public function shouldRun(?string $testTime = null): bool return false; } + // S'il s'agit d'une tâche à instance unique et qu'un verrou est présent, ne pas l'exécuter + if ($this->singleInstance && cache()->get($this->getLockKey()) !== null) { + return false; + } + return $cron->shouldRun($this->getExpression()); } + /** + * Définit cette tâche comme une instance unique + * + * @param int|null $lockTTL Durée de vie du verrou de cache en secondes + */ + public function singleInstance(?int $lockTTL = null): static + { + $this->singleInstance = true; + $this->singleInstanceTTL = $lockTTL; + + return $this; + } + /** * Limite l'exécution de cette tâche uniquement dans des environnements spécifiés. */ @@ -322,4 +361,16 @@ public function __set(string $name, mixed $value): void { $this->attributes[$name] = $value; } + + /** + * Détermine la clé de verrouillage pour la tâche. + * + * @throws ReflectionException + */ + private function getLockKey(): string + { + $name = $this->attributes['name'] ?? $this->buildName(); + + return 'task_lock_' . $name; + } } From b88aff7b3f976907c38c7409e1d68e6b72162480 Mon Sep 17 00:00:00 2001 From: dimtrovich <37987162+dimtrovich@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:16:44 +0000 Subject: [PATCH 3/3] Fix styling --- .php-cs-fixer.dist.php | 2 +- spec/Task.spec.php | 2 +- src/Commands/Work.php | 2 +- src/Task.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 2095203..405a58e 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -42,5 +42,5 @@ 'BlitzPHP Tasks', 'Dimitri Sitchet Tomkeu', 'devcode.dst@gmail.com', - 2025 + 2025, ); diff --git a/spec/Task.spec.php b/spec/Task.spec.php index 8ac409d..f608c58 100644 --- a/spec/Task.spec.php +++ b/spec/Task.spec.php @@ -83,7 +83,7 @@ $task->run(); expect(COH::buffer())->toMatch( - static fn ($actual) => str_contains($actual, 'La commande peut produire du texte.') + static fn ($actual) => str_contains($actual, 'La commande peut produire du texte.'), ); }); diff --git a/src/Commands/Work.php b/src/Commands/Work.php index 8d5adcc..a5e883c 100644 --- a/src/Commands/Work.php +++ b/src/Commands/Work.php @@ -55,7 +55,7 @@ public function execute(array $params) '%s %s %s', escapeshellarg(PHP_BINARY), escapeshellarg(base_path('klinge')), - 'tasks:run' + 'tasks:run', ); if ($this->option('run-output-file')) { diff --git a/src/Task.php b/src/Task.php index 6021c5f..0286d9e 100644 --- a/src/Task.php +++ b/src/Task.php @@ -142,7 +142,7 @@ public function run() if (! method_exists($this, $method)) { throw TasksException::invalidTaskType($this->type); } - + return $this->process($this->container, $method); } finally { if ($this->singleInstance) {