Skip to content

Commit

Permalink
Merge pull request #14 from Vectorial1024/surveillance
Browse files Browse the repository at this point in the history
Feature: task IDs (and other related stuff)
  • Loading branch information
Vectorial1024 authored Jan 26, 2025
2 parents f0de38d + aa8e397 commit c89946f
Show file tree
Hide file tree
Showing 7 changed files with 373 additions and 9 deletions.
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

0 comments on commit c89946f

Please sign in to comment.