Skip to content

Feature: task IDs (and other related stuff) #14

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Jan 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
53be8cb
Create AsyncTaskStatus.php
Vectorial1024 Jan 5, 2025
7dfda17
Starting AsyncTask now returns a status object
Vectorial1024 Jan 5, 2025
5d09e50
Allow specifying task IDs
Vectorial1024 Jan 5, 2025
304307b
Pass the encoded task ID into the runner
Vectorial1024 Jan 5, 2025
1d4fb2a
More checks on AsyncTask IDs
Vectorial1024 Jan 5, 2025
fc5e96f
Add basic test cases
Vectorial1024 Jan 5, 2025
c712812
Implement skeleton for isRunning()
Vectorial1024 Jan 6, 2025
2fe2494
More skeleton for isRunning()
Vectorial1024 Jan 6, 2025
466e447
Implement Unix isRunning()
Vectorial1024 Jan 6, 2025
925dfb5
Stabilize test case timings
Vectorial1024 Jan 6, 2025
e562671
Stabilize timeout test case timing
Vectorial1024 Jan 7, 2025
2a00243
Implement Windows finding the PID
Vectorial1024 Jan 20, 2025
0c22a07
Implement Windows checking the task status
Vectorial1024 Jan 20, 2025
6e6e1db
Clean up the exception message
Vectorial1024 Jan 21, 2025
9d32cf7
Provide test cases for task status checking
Vectorial1024 Jan 21, 2025
b51c467
Simplify expressions
Vectorial1024 Jan 22, 2025
d17ed65
Adjust test case details
Vectorial1024 Jan 22, 2025
81f41c9
Make the dummy task sleep longer
Vectorial1024 Jan 22, 2025
0bc7e33
Try to stabilize the test case
Vectorial1024 Jan 22, 2025
815e7b9
Stabilize the test case
Vectorial1024 Jan 23, 2025
d73535d
Simplify expression
Vectorial1024 Jan 23, 2025
6ce5d30
Fix typo
Vectorial1024 Jan 23, 2025
a2f9ce9
Adjust wordings
Vectorial1024 Jan 23, 2025
66095b9
Provide README on task IDs
Vectorial1024 Jan 23, 2025
a2cb621
Fix README
Vectorial1024 Jan 23, 2025
15b3bc7
Add more to test case
Vectorial1024 Jan 25, 2025
cbcc650
Amend comments
Vectorial1024 Jan 25, 2025
2e0cfa8
Add test case for task status (time limit)
Vectorial1024 Jan 25, 2025
aa8e397
Update CHANGELOG.md
Vectorial1024 Jan 25, 2025
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Note: you may refer to `README.md` for description of features.

## Dev (WIP)
- Task IDs can be given to tasks (generated or not) (https://github.com/Vectorial1024/laravel-process-async/issues/5)

## 0.2.0 (2025-01-04)
- Task runners are now detached from the task giver (https://github.com/Vectorial1024/laravel-process-async/issues/7)
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,33 @@ Some tips:
- Use short but frequent sleeps instead.
- Avoid using `SIGINT`! On Unix, this signal is reserved for timeout detection.

### Task IDs
You can assign task IDs to tasks before they are run, but you cannot change them after the tasks are started. This allows you to track the statuses of long-running tasks across web requests.

By default, if a task does not has its user-specified task ID when starting, a ULID will be generated as its task ID.

```php
// create a task with a specified task ID...
$task = new AsyncTask(function () {}, "customTaskID");

// will return a status object for immediate checking...
$status = $task->start();

// in case the task ID was not given, what is the generated task ID?
$taskID = $status->taskID;

// is that task still running?
$status->isRunning();

// when task IDs are known, task status objects can be recreated on-the-fly
$anotherStatus = new AsyncTaskStatus("customTaskID");
```

Some tips:
- Task IDs can be optional (i.e. `null`) but CANNOT be blank (i.e. `""`)!
- If multiple tasks are started with the same task ID, then the task status object will only track the first task that was started
- Known issue: on Windows, checking task statuses can be slow (about 0.5 - 1 seconds) due to underlying bottlenecks

## Testing
PHPUnit via Composer script:
```sh
Expand Down
31 changes: 26 additions & 5 deletions src/AsyncTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Closure;
use Illuminate\Process\InvokedProcess;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Laravel\SerializableClosure\SerializableClosure;
use LogicException;
use loophp\phposinfo\OsInfo;
Expand All @@ -23,6 +25,14 @@ class AsyncTask
*/
private SerializableClosure|AsyncTaskInterface $theTask;

/**
* The user-specified ID of the current task. (Null means user did not specify any ID).
*
* If null, the task will generate an unsaved random ID when it is started.
* @var string|null
*/
private string|null $taskID;

/**
* The process that is actually running this task. Tasks that are not started will have null here.
* @var InvokedProcess|null
Expand Down Expand Up @@ -91,14 +101,19 @@ class AsyncTask
/**
* Creates an AsyncTask instance.
* @param Closure|AsyncTaskInterface $theTask The task to be executed in the background.
* @param string|null $taskID (optional) The user-specified task ID of this AsyncTask. Should be unique.
*/
public function __construct(Closure|AsyncTaskInterface $theTask)
public function __construct(Closure|AsyncTaskInterface $theTask, string|null $taskID = null)
{
if ($theTask instanceof Closure) {
// convert to serializable closure first
$theTask = new SerializableClosure($theTask);
}
$this->theTask = $theTask;
if ($taskID === "") {
throw new InvalidArgumentException("AsyncTask ID cannot be empty.");
}
$this->taskID = $taskID;
}

/**
Expand Down Expand Up @@ -159,21 +174,26 @@ public function run(): void

/**
* Starts this AsyncTask immediately in the background. A runner will then run this AsyncTask.
* @return void
* @return AsyncTaskStatus The status object for the started AsyncTask.
*/
public function start(): void
public function start(): AsyncTaskStatus
{
// prepare the task details
$taskID = $this->taskID ?? Str::ulid()->toString();
$taskStatus = new AsyncTaskStatus($taskID);

// prepare the runner command
$serializedTask = $this->toBase64Serial();
$baseCommand = "php artisan async:run $serializedTask";
$encodedTaskID = $taskStatus->getEncodedTaskID();
$baseCommand = "php artisan async:run $serializedTask --id='$encodedTaskID'";

// then, specific actions depending on the runtime OS
if (OsInfo::isWindows()) {
// basically, in windows, it is too tedioous to check whether we are in cmd or ps,
// but we require cmd (ps won't work here), so might as well force cmd like this
// windows has real max time limit
$this->runnerProcess = Process::quietly()->start("cmd >nul 2>nul /c start /b $baseCommand");
return;
return $taskStatus;
}
// assume anything not windows to be unix
// unix use nohup
Expand All @@ -197,6 +217,7 @@ public function start(): void
$timeoutClause = static::$timeoutCmdName . " -s 2 {$this->timeLimit}";
}
$this->runnerProcess = Process::quietly()->start("nohup $timeoutClause $baseCommand >/dev/null 2>&1");
return $taskStatus;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/AsyncTaskRunnerCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class AsyncTaskRunnerCommand extends Command
*
* @var string
*/
protected $signature = 'async:run {task}';
protected $signature = 'async:run {task} {--id=}';

/**
* The console command description.
Expand Down
212 changes: 212 additions & 0 deletions src/AsyncTaskStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<?php

declare(strict_types=1);

namespace Vectorial1024\LaravelProcessAsync;

use InvalidArgumentException;
use loophp\phposinfo\OsInfo;
use RuntimeException;

/**
* Represents the status of an async task: "running" or "stopped".
*
* This does not tell you whether it was a success/failure, since it depends on the user's custom result checking.
*/
class AsyncTaskStatus
{
private const MSG_CANNOT_CHECK_STATUS = "Could not check the status of the AsyncTask.";

/**
* The cached task ID for quick ID reusing. We will most probably reuse this ID many times.
* @var string|null
*/
private string|null $encodedTaskID = null;

/**
* Indicates whether the task is stopped.
*
* Note: the criteria is "pretty sure it is stopped"; once the task is stopped, it stays stopped.
* @var bool
*/
private bool $isStopped = false;

/**
* The last known PID of the task runner.
* @var int|null If null, it means the PID is unknown or expired.
*/
private int|null $lastKnownPID = null;

/**
* Constructs a status object.
* @param string $taskID The task ID of the async task so to check its status.
*/
public function __construct(
public readonly string $taskID
) {
if ($taskID === "") {
// why no blank IDs? because this will produce blank output via base64 encode.
throw new InvalidArgumentException("AsyncTask IDs cannot be blank");
}
}

/**
* Returns the task ID encoded in base64, mainly for result checking.
* @return string The encoded task ID.
*/
public function getEncodedTaskID(): string
{
if ($this->encodedTaskID === null) {
$this->encodedTaskID = base64_encode($this->taskID);
}
return $this->encodedTaskID;
}

/**
* Checks and returns whether the AsyncTask is still running.
*
* On Windows, this may take some time due to underlying bottlenecks.
*
* Note: when this method detects that the task has stopped running, it will not recheck whether the task has restarted.
* Use a fresh status object to track the (restarted) task.
* @return bool If true, indicates the task is still running.
*/
public function isRunning(): bool
{
if ($this->isStopped) {
return false;
}
// prove it is running
$isRunning = $this->proveTaskIsRunning();
if (!$isRunning) {
$this->isStopped = true;
}
return $isRunning;
}

/**
* Attempts to prove whether the AsyncTask is still running
* @return bool If false, then the task is shown to have been stopped.
*/
private function proveTaskIsRunning(): bool
{
if ($this->lastKnownPID === null) {
// we don't know where the task runner is at; find it!
return $this->findTaskRunnerProcess();
}
// we know the task runner; is it still running?
return $this->observeTaskRunnerProcess();
}

/**
* Attempts to find the task runner process (if exists), and writes down its PID.
* @return bool If true, then the task runner is successfully found.
*/
private function findTaskRunnerProcess(): bool
{
// find the runner in the system
// we might have multiple PIDs; in this case, pick the first one that appears
/*
* note: while the OS may allow reading multiple properties at the same time,
* we won't risk it because localizations might produce unexpected strings or unusual separators
* an example would be CJK potentially having an alternate character to replace ":"
*/
if (OsInfo::isWindows()) {
// Windows uses GCIM to discover processes
$results = [];
$encodedTaskID = $this->getEncodedTaskID();
$expectedCmdName = "artisan async:run";
// we can assume we are in cmd, but wcim in cmd is deprecated, and the replacement gcim requires powershell
$results = [];
$fullCmd = "powershell echo \"\"(gcim Win32_Process -Filter \\\"CommandLine LIKE '%id=\'$encodedTaskID\'%'\\\").ProcessId\"\"";
\Illuminate\Support\Facades\Log::info($fullCmd);
exec("powershell echo \"\"(gcim Win32_Process -Filter \\\"CommandLine LIKE '%id=\'$encodedTaskID\'%'\\\").ProcessId\"\"", $results);
// will output many lines, each line being a PID
foreach ($results as $candidatePID) {
$candidatePID = (int) $candidatePID;
// then use gcim again to see the cmd args
$cmdArgs = exec("powershell echo \"\"(gcim Win32_Process -Filter \\\"ProcessId = $candidatePID\\\").CommandLine\"\"");
if ($cmdArgs === false) {
throw new RuntimeException(self::MSG_CANNOT_CHECK_STATUS);
}
if (!str_contains($cmdArgs, $expectedCmdName)) {
// not really
continue;
}
$executable = exec("powershell echo \"\"(gcim Win32_Process -Filter \\\"ProcessId = $candidatePID\\\").Name\"\"");
if ($executable === false) {
throw new RuntimeException(self::MSG_CANNOT_CHECK_STATUS);
}
if ($executable !== "php.exe") {
// not really
// note: we currently hard-code "php" as the executable name
continue;
}
// all checks passed; it is this one
$this->lastKnownPID = $candidatePID;
return true;
}
return false;
}
// assume anything not Windows to be Unix
// find the runner on Unix systems via pgrep
$results = [];
$encodedTaskID = $this->getEncodedTaskID();
exec("pgrep -f id='$encodedTaskID'", $results);
// we may find multiple records here if we are using timeouts
// this is because there will be one parent timeout process and another actual child artisan process
// we want the child artisan process
$expectedCmdName = "artisan async:run";
foreach ($results as $candidatePID) {
$candidatePID = (int) $candidatePID;
// then use ps to see what really is it
$fullCmd = exec("ps -p $candidatePID -o args=");
if ($fullCmd === false) {
throw new RuntimeException(self::MSG_CANNOT_CHECK_STATUS);
}
if (!str_contains($fullCmd, $expectedCmdName)) {
// not really
continue;
}
$executable = exec("ps -p $candidatePID -o comm=");
if ($executable === false) {
throw new RuntimeException(self::MSG_CANNOT_CHECK_STATUS);
}
if ($executable !== "php") {
// not really
// note: we currently hard-code "php" as the executable name
continue;
}
// this is it!
$this->lastKnownPID = $candidatePID;
return true;
}
return false;
}

/**
* Given a previously-noted PID of the task runner, see if the task runner is still alive.
* @return bool If true, then the task runner is still running.
*/
private function observeTaskRunnerProcess(): bool
{
// since we should have remembered the PID, we can just query whether it still exists
// supposedly, the PID has not rolled over yet, right...?
if (OsInfo::isWindows()) {
// Windows can also use Get-Process to probe processes
$echoedPid = exec("powershell (Get-Process -id {$this->lastKnownPID}).Id");
if ($echoedPid === false) {
throw new RuntimeException(self::MSG_CANNOT_CHECK_STATUS);
}
$echoedPid = (int) $echoedPid;
return $this->lastKnownPID === $echoedPid;
}
// assume anything not Windows to be Unix
$echoedPid = exec("ps -p {$this->lastKnownPID} -o pid=");
if ($echoedPid === false) {
throw new RuntimeException(self::MSG_CANNOT_CHECK_STATUS);
}
$echoedPid = (int) $echoedPid;
return $this->lastKnownPID === $echoedPid;
}
}
Loading
Loading