Skip to content
Open
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
29 changes: 29 additions & 0 deletions example/01-run-due-jobs.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php
use Gt\Cron\CrontabParser;
use Gt\Cron\ExpressionFactory;
use Gt\Cron\JobRepository;
use Gt\Cron\Queue;
use Gt\Cron\ScriptOutputMode;

chdir(dirname(__DIR__));
require "vendor/autoload.php";

$now = new DateTime("2026-03-11 12:25:00");
$crontab = <<<'CRON'
10 * * * * printf 'This is not due at 12:25.\n'
25 * * * * printf 'This runs at 25 past the hour.\n'
*/5 * * * * printf 'This runs every five minutes.\n'
CRON;

echo "Current time: " . $now->format("Y-m-d H:i:s") . PHP_EOL;
echo "Crontab:" . PHP_EOL;
echo $crontab . PHP_EOL . PHP_EOL;

$queue = new Queue($now);
$crontabParser = new CrontabParser(new ExpressionFactory());
$jobRepository = new JobRepository(ScriptOutputMode::INHERIT);
$crontabParser->parseIntoQueue($crontab, $queue, $jobRepository);

echo "Command output:" . PHP_EOL;
$runCommandList = $queue->runDueJobsAndGetCommands();
echo "Jobs ran: " . count($runCommandList) . PHP_EOL;
31 changes: 31 additions & 0 deletions example/02-next-job.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php
use Gt\Cron\CrontabParser;
use Gt\Cron\ExpressionFactory;
use Gt\Cron\JobRepository;
use Gt\Cron\Queue;
use Gt\Cron\ScriptOutputMode;

chdir(dirname(__DIR__));
require "vendor/autoload.php";

$now = new DateTime("2026-03-11 12:25:00");
$crontab = <<<'CRON'
25 * * * * printf 'Generate report.\n'
30 * * * * printf 'Warm cache.\n'
0 13 * * * printf 'Send lunchtime digest.\n'
CRON;

$queue = new Queue($now);
$crontabParser = new CrontabParser(new ExpressionFactory());
$jobRepository = new JobRepository(ScriptOutputMode::INHERIT);
$crontabParser->parseIntoQueue($crontab, $queue, $jobRepository);

echo "Current time: " . $now->format("Y-m-d H:i:s") . PHP_EOL;
echo "Crontab:" . PHP_EOL;
echo $crontab . PHP_EOL . PHP_EOL;

echo "Command output:" . PHP_EOL;
$queue->runDueJobsAndGetCommands();

echo "Next job: " . $queue->timeOfNextJob()?->format("Y-m-d H:i:s") . PHP_EOL;
echo "Next command: " . $queue->commandOfNextJob() . PHP_EOL;
31 changes: 31 additions & 0 deletions example/03-nickname-expressions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php
use Gt\Cron\CrontabParser;
use Gt\Cron\ExpressionFactory;
use Gt\Cron\JobRepository;
use Gt\Cron\Queue;
use Gt\Cron\ScriptOutputMode;

chdir(dirname(__DIR__));
require "vendor/autoload.php";

$now = new DateTime("2026-03-11 13:00:00");
$crontab = <<<'CRON'
# Nicknames are accepted as a single schedule token.
@hourly printf 'Hourly job fired.\n'
@daily printf 'Daily job is not due yet.\n'
CRON;

$queue = new Queue($now);
$crontabParser = new CrontabParser(new ExpressionFactory());
$jobRepository = new JobRepository(ScriptOutputMode::INHERIT);
$crontabParser->parseIntoQueue($crontab, $queue, $jobRepository);

echo "Current time: " . $now->format("Y-m-d H:i:s") . PHP_EOL;
echo "Crontab:" . PHP_EOL;
echo $crontab . PHP_EOL . PHP_EOL;

echo "Command output:" . PHP_EOL;
$runCommandList = $queue->runDueJobsAndGetCommands();
echo "Commands due right now: " . implode(", ", $runCommandList) . PHP_EOL;
echo "Next job: " . $queue->timeOfNextJob()?->format("Y-m-d H:i:s") . PHP_EOL;
echo "Next command: " . $queue->commandOfNextJob() . PHP_EOL;
49 changes: 49 additions & 0 deletions example/04-custom-expression-factory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
use Gt\Cron\CrontabParser;
use Gt\Cron\CronExpression;
use Gt\Cron\Expression;
use Gt\Cron\ExpressionFactory;
use Gt\Cron\JobRepository;
use Gt\Cron\Queue;
use Gt\Cron\ScriptOutputMode;

chdir(dirname(__DIR__));
require "vendor/autoload.php";

$customExpressionFactory = new class extends ExpressionFactory {
public function create(string $expression):Expression {
if($expression === "@start") {
return new class implements Expression {
public function isDue(DateTime $now):bool {
return true;
}

public function getNextRunDate(?DateTime $now = null):DateTime {
return clone ($now ?? new DateTime());
}
};
}

return new CronExpression($expression);
}
};

$now = new DateTime("2026-03-11 12:25:00");
$crontab = <<<'CRON'
@start printf 'This runs once when the runner starts.\n'
30 * * * * printf 'This is a normal cron schedule.\n'
CRON;

echo "Current time: " . $now->format("Y-m-d H:i:s") . PHP_EOL;
echo "Crontab:" . PHP_EOL;
echo $crontab . PHP_EOL . PHP_EOL;
echo "This example injects a custom ExpressionFactory to handle @start." . PHP_EOL;
echo "Command output:" . PHP_EOL;

$queue = new Queue($now);
$jobRepository = new JobRepository(ScriptOutputMode::INHERIT);
(new CrontabParser($customExpressionFactory))
->parseIntoQueue($crontab, $queue, $jobRepository);

$runCommandList = $queue->runDueJobsAndGetCommands();
echo "Jobs ran: " . count($runCommandList) . PHP_EOL;
12 changes: 12 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Examples

Run each example from the project root with `php`:

```bash
php example/01-run-due-jobs.php
php example/02-next-job.php
php example/03-nickname-expressions.php
php example/04-custom-expression-factory.php
```

Each script embeds its own crontab string so you can read the schedule and the code together.
40 changes: 40 additions & 0 deletions src/FunctionCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php
namespace Gt\Cron;

class FunctionCommand {
public function isCallable(string $command):bool {
$callable = $this->callableName($command);
return str_contains($callable, "::") || is_callable($callable);
}

public function execute(string $command):void {
$callableName = $this->callableName($command);
$callable = explode("::", $callableName);
if(!is_callable($callable)) {
throw new FunctionExecutionException($callableName);
}

call_user_func_array($callable, $this->arguments($command));
}

protected function callableName(string $command):string {
$bracketPos = strpos($command, "(");
if($bracketPos === false) {
return trim($command);
}

return trim(substr($command, 0, $bracketPos));
}

/** @return array<int, string> */
protected function arguments(string $command):array {
$bracketPos = strpos($command, "(");
if($bracketPos === false) {
return [];
}

$argsString = substr($command, $bracketPos);
$argsString = trim($argsString, " ();");
return str_getcsv($argsString);
}
}
160 changes: 15 additions & 145 deletions src/Job.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,28 @@
use DateTime;

class Job {
protected ScriptCommandResolver $scriptCommandResolver;
protected Expression $expression;
protected string $command;
protected bool $hasRun;
protected ScriptOutputMode $scriptOutputMode;
protected string $stdout;
protected string $stderr;
protected FunctionCommand $functionCommand;
protected ScriptRunner $scriptRunner;

public function __construct(
Expression $expression,
string $command,
ScriptOutputMode $scriptOutputMode = ScriptOutputMode::DISCARD
ScriptOutputMode $scriptOutputMode = ScriptOutputMode::DISCARD,
?FunctionCommand $functionCommand = null,
?ScriptRunner $scriptRunner = null,
) {
$this->scriptCommandResolver = new ScriptCommandResolver();
$this->expression = $expression;
$this->command = $command;
$this->hasRun = false;
$this->scriptOutputMode = $scriptOutputMode;
$this->stdout = "";
$this->stderr = "";
$this->functionCommand = $functionCommand ?? new FunctionCommand();
$this->scriptRunner = $scriptRunner ?? new ScriptRunner($scriptOutputMode);
}

public function isDue(?DateTime $now = null):bool {
Expand Down Expand Up @@ -58,13 +60,14 @@ public function run():void {
$this->stdout = "";
$this->stderr = "";

if($this->isFunction()) {
$this->executeFunction();
}
else {
// Assume the command is a shell command.
$this->executeScript();
if($this->functionCommand->isCallable($this->command)) {
$this->functionCommand->execute($this->command);
return;
}

$scriptResult = $this->scriptRunner->run($this->command);
$this->stdout = $scriptResult->stdout;
$this->stderr = $scriptResult->stderr;
}

public function hasRun():bool {
Expand All @@ -76,139 +79,6 @@ public function resetRunFlag():void {
}

public function isFunction():bool {
$command = $this->command;
$bracketPos = strpos(
$command,
"("
);
if($bracketPos !== false) {
$command = substr($command, 0, $bracketPos);
$command = trim($command);
}

return strstr($command, "::")
|| is_callable($command);
}

protected function executeFunction():void {
$command = $this->command;
$args = [];
$bracketPos = strpos($command, "(");
if($bracketPos !== false) {
$argsString = substr(
$command,
$bracketPos
);
$argsString = trim($argsString, " ();");
$args = str_getcsv($argsString, ",", "\"", "\\");

$command = substr(
$command,
0,
$bracketPos
);
$command = trim($command);
}

$callable = explode("::", $command);

if(!is_callable($callable)) {
throw new FunctionExecutionException($command);
}
call_user_func_array($callable, $args);
}

protected function executeScript():void {
$command = $this->scriptCommandResolver->resolve($this->command);
$descriptor = $this->createScriptDescriptor();
$pipes = [];

$proc = proc_open(
$command,
$descriptor,
$pipes
);

do {
if($proc) {
$status = proc_get_status($proc);
}
else {
$status = [
"running" => false,
"exitcode" => -1,
];
}
}while($status["running"]);

if($proc) {
$this->captureProcessOutput($pipes);
}

if($status["exitcode"] > 0) {
throw new ScriptExecutionException(
$this->command
);
}

if($proc) {
$this->closePipes($pipes);
proc_close($proc);
}
}

/** @return array<int, mixed> */
protected function createScriptDescriptor():array {
$stdin = ["pipe", "r"];

return match($this->scriptOutputMode) {
ScriptOutputMode::INHERIT => [
0 => $stdin,
1 => ["file", "php://stdout", "w"],
2 => ["file", "php://stderr", "w"],
],
ScriptOutputMode::CAPTURE => [
0 => $stdin,
1 => ["pipe", "w"],
2 => ["pipe", "w"],
],
default => [
0 => $stdin,
1 => ["file", $this->nullDevice(), "w"],
2 => ["file", $this->nullDevice(), "w"],
],
};
}

/** @param array<int,mixed> $pipes */
protected function captureProcessOutput(array $pipes):void {
if($this->scriptOutputMode !== ScriptOutputMode::CAPTURE) {
return;
}

if(isset($pipes[1]) && is_resource($pipes[1])) {
$this->stdout = stream_get_contents($pipes[1]) ?: "";
}

if(isset($pipes[2]) && is_resource($pipes[2])) {
$this->stderr = stream_get_contents($pipes[2]) ?: "";
}
}

/** @param array<int,mixed> $pipes */
protected function closePipes(array $pipes):void {
foreach($pipes as $pipe) {
if(is_resource($pipe)) {
fclose($pipe);
}
}
}

protected function nullDevice():string {
if(PHP_OS_FAMILY === "Windows") {
return "NUL";
}

return "/dev/null";
return $this->functionCommand->isCallable($this->command);
}
}
Loading
Loading