Skip to content

Commit 2c2efde

Browse files
authored
Merge pull request #311 from WebFiori/dev
feat(scheduler): add scheduler:daemon command with time-limited execution
2 parents 825233f + 854a64b commit 2c2efde

4 files changed

Lines changed: 197 additions & 1 deletion

File tree

WebFiori/Framework/App.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,8 @@ public static function getRunner() : Runner {
351351
'\\WebFiori\\Framework\\Cli\\Commands\\VersionCommand',
352352

353353
'\\WebFiori\\Framework\\Cli\\Commands\\SchedulerCommand',
354-
'\\WebFiori\\Framework\\Cli\\Commands\\SchedulerRunCommand',
354+
'\\WebFiori\\Framework\\Cli\\Commands\\SchedulerRunCommand',
355+
'\\WebFiori\\Framework\\Cli\\Commands\\SchedulerDaemonCommand',
355356
'\\WebFiori\\Framework\\Cli\\Commands\\AddDbConnectionCommand',
356357
'\\WebFiori\\Framework\\Cli\\Commands\\AddSmtpConnectionCommand',
357358
'\\WebFiori\\Framework\\Cli\\Commands\\AddLangCommand',
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
/**
3+
* This file is licensed under MIT License.
4+
*
5+
* Copyright (c) 2026-present WebFiori Framework
6+
*
7+
* For more information on the license, please visit:
8+
* https://github.com/WebFiori/.github/blob/main/LICENSE
9+
*
10+
*/
11+
namespace WebFiori\Framework\Cli\Commands;
12+
13+
use WebFiori\Cli\Argument;
14+
use WebFiori\Cli\Command;
15+
use WebFiori\Framework\Cli\CLIUtils;
16+
use WebFiori\Framework\Scheduler\TasksManager;
17+
18+
/**
19+
* A CLI command that runs the scheduler in a loop for a configurable duration.
20+
*
21+
* This command is intended for local development and testing. It calls
22+
* the scheduler check every 60 seconds and self-terminates after the
23+
* specified maximum number of minutes.
24+
*
25+
* Usage:
26+
* php webfiori scheduler:daemon # runs for 60 minutes (default)
27+
* php webfiori scheduler:daemon --max-minutes=5 # runs for 5 minutes
28+
*
29+
* For production environments, use `scheduler:run` with OS-level cron instead.
30+
*
31+
* @author Ibrahim
32+
*/
33+
class SchedulerDaemonCommand extends Command {
34+
/**
35+
* Creates a new instance of the command.
36+
*
37+
* Registers the `--max-minutes` argument with a default of 60 and
38+
* optionally the scheduler password argument if a password is configured.
39+
*/
40+
public function __construct() {
41+
parent::__construct('scheduler:daemon', [
42+
new Argument('--max-minutes', 'Maximum number of minutes to keep the daemon running. Default: 60.', true),
43+
new Argument('--show-log', 'If set, execution log will be shown after each run.', true),
44+
], 'Run the scheduler in a loop for a limited duration.');
45+
46+
if (TasksManager::getPassword() != 'NO_PASSWORD') {
47+
$this->addArg('p', [
48+
'optional' => false,
49+
'description' => 'Scheduler password.'
50+
]);
51+
}
52+
}
53+
54+
/**
55+
* Executes the daemon loop.
56+
*
57+
* The scheduler check is invoked every 60 seconds. The loop exits
58+
* when the elapsed time exceeds the value of `--max-minutes`.
59+
*
60+
* @return int 0 on success, -1 if the password is incorrect or no tasks exist.
61+
*/
62+
public function exec(): int {
63+
$count = count(TasksManager::getTasks());
64+
65+
if ($count == 0) {
66+
$this->info('There are no scheduled tasks.');
67+
return -1;
68+
}
69+
70+
$maxMinutes = $this->getArgValue('--max-minutes');
71+
$maxMinutes = $maxMinutes !== null ? intval($maxMinutes) : 60;
72+
73+
if ($maxMinutes <= 0) {
74+
$this->error('--max-minutes must be a positive integer.');
75+
return -1;
76+
}
77+
78+
$pass = CLIUtils::resolvePassword($this->getArgValue('p')) ?? '';
79+
$maxSeconds = $maxMinutes * 60;
80+
$startTime = time();
81+
82+
$this->println("Scheduler daemon started. Will run for $maxMinutes minute(s).");
83+
$this->println('Press Ctrl+C to stop.');
84+
$this->println('---');
85+
86+
while ((time() - $startTime) < $maxSeconds) {
87+
$this->println('[' . date('Y-m-d H:i:s') . '] Running scheduler check...');
88+
$result = TasksManager::run($pass, null, false, $this);
89+
90+
if ($result == 'INV_PASS') {
91+
$this->error('Provided password is incorrect.');
92+
return -1;
93+
}
94+
95+
$this->println('Executed: ' . $result['executed-count'] . '/' . $result['total-tasks'] . ' tasks.');
96+
97+
$remaining = $maxSeconds - (time() - $startTime);
98+
99+
if ($remaining <= 0) {
100+
break;
101+
}
102+
103+
$sleepTime = min(60, $remaining);
104+
$this->println("Next check in $sleepTime second(s)...");
105+
$this->println('---');
106+
sleep($sleepTime);
107+
}
108+
109+
$this->println('---');
110+
$this->success('Daemon stopped after ' . $maxMinutes . ' minute(s).');
111+
return 0;
112+
}
113+
}

tests/WebFiori/Framework/Tests/Cli/HelpCommandTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public function test00() {
2222

2323
" scheduler: Run tasks scheduler.\n",
2424
" scheduler:run: Run the tasks scheduler check.\n",
25+
" scheduler:daemon: Run the scheduler in a loop for a limited duration.\n",
2526
" add:db-connection: Add a database connection.\n",
2627
" add:smtp-connection: Add an SMTP account.\n",
2728
" add:lang: Add a website language.\n",
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
namespace WebFiori\Framework\Test\Cli;
3+
4+
use WebFiori\Framework\Cli\CLITestCase;
5+
use WebFiori\Framework\Cli\Commands\SchedulerDaemonCommand;
6+
use WebFiori\Framework\Scheduler\TasksManager;
7+
8+
class SchedulerDaemonCommandTest extends CLITestCase {
9+
10+
public function setUp(): void {
11+
parent::setUp();
12+
TasksManager::reset();
13+
TasksManager::setPassword('123456');
14+
TasksManager::registerTasks();
15+
}
16+
17+
/**
18+
* @test
19+
* Covers: exec() — no tasks registered, returns -1
20+
*/
21+
public function testNoTasks() {
22+
TasksManager::reset();
23+
24+
$output = $this->executeSingleCommand(new SchedulerDaemonCommand(), ['p' => '123456']);
25+
26+
$this->assertEquals(["Info: There are no scheduled tasks.\n"], $output);
27+
$this->assertEquals(-1, $this->getExitCode());
28+
}
29+
30+
/**
31+
* @test
32+
* Covers: exec() — --max-minutes=0 is rejected
33+
*/
34+
public function testInvalidMaxMinutesZero() {
35+
$output = $this->executeSingleCommand(new SchedulerDaemonCommand(), [
36+
'p' => '123456',
37+
'--max-minutes' => '0'
38+
]);
39+
40+
$this->assertEquals(["Error: --max-minutes must be a positive integer.\n"], $output);
41+
$this->assertEquals(-1, $this->getExitCode());
42+
}
43+
44+
/**
45+
* @test
46+
* Covers: exec() — negative --max-minutes is rejected
47+
*/
48+
public function testInvalidMaxMinutesNegative() {
49+
$output = $this->executeSingleCommand(new SchedulerDaemonCommand(), [
50+
'p' => '123456',
51+
'--max-minutes' => '-5'
52+
]);
53+
54+
$this->assertEquals(["Error: --max-minutes must be a positive integer.\n"], $output);
55+
$this->assertEquals(-1, $this->getExitCode());
56+
}
57+
58+
/**
59+
* @test
60+
* Covers: exec() — wrong password returns -1
61+
*/
62+
public function testWrongPassword() {
63+
$output = $this->executeSingleCommand(new SchedulerDaemonCommand(), [
64+
'p' => 'wrong',
65+
'--max-minutes' => '1'
66+
]);
67+
68+
$this->assertContains("Error: Provided password is incorrect.\n", $output);
69+
$this->assertEquals(-1, $this->getExitCode());
70+
}
71+
72+
/**
73+
* @test
74+
* Covers: __construct() — command metadata
75+
*/
76+
public function testCommandInfo() {
77+
$cmd = new SchedulerDaemonCommand();
78+
$this->assertEquals('scheduler:daemon', $cmd->getName());
79+
$this->assertEquals('Run the scheduler in a loop for a limited duration.', $cmd->getDescription());
80+
}
81+
}

0 commit comments

Comments
 (0)