Skip to content

Commit c89946f

Browse files
Merge pull request #14 from Vectorial1024/surveillance
Feature: task IDs (and other related stuff)
2 parents f0de38d + aa8e397 commit c89946f

File tree

7 files changed

+373
-9
lines changed

7 files changed

+373
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Note: you may refer to `README.md` for description of features.
33

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

67
## 0.2.0 (2025-01-04)
78
- Task runners are now detached from the task giver (https://github.com/Vectorial1024/laravel-process-async/issues/7)

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,33 @@ Some tips:
8989
- Use short but frequent sleeps instead.
9090
- Avoid using `SIGINT`! On Unix, this signal is reserved for timeout detection.
9191

92+
### Task IDs
93+
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.
94+
95+
By default, if a task does not has its user-specified task ID when starting, a ULID will be generated as its task ID.
96+
97+
```php
98+
// create a task with a specified task ID...
99+
$task = new AsyncTask(function () {}, "customTaskID");
100+
101+
// will return a status object for immediate checking...
102+
$status = $task->start();
103+
104+
// in case the task ID was not given, what is the generated task ID?
105+
$taskID = $status->taskID;
106+
107+
// is that task still running?
108+
$status->isRunning();
109+
110+
// when task IDs are known, task status objects can be recreated on-the-fly
111+
$anotherStatus = new AsyncTaskStatus("customTaskID");
112+
```
113+
114+
Some tips:
115+
- Task IDs can be optional (i.e. `null`) but CANNOT be blank (i.e. `""`)!
116+
- If multiple tasks are started with the same task ID, then the task status object will only track the first task that was started
117+
- Known issue: on Windows, checking task statuses can be slow (about 0.5 - 1 seconds) due to underlying bottlenecks
118+
92119
## Testing
93120
PHPUnit via Composer script:
94121
```sh

src/AsyncTask.php

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use Closure;
88
use Illuminate\Process\InvokedProcess;
99
use Illuminate\Support\Facades\Process;
10+
use Illuminate\Support\Str;
11+
use InvalidArgumentException;
1012
use Laravel\SerializableClosure\SerializableClosure;
1113
use LogicException;
1214
use loophp\phposinfo\OsInfo;
@@ -23,6 +25,14 @@ class AsyncTask
2325
*/
2426
private SerializableClosure|AsyncTaskInterface $theTask;
2527

28+
/**
29+
* The user-specified ID of the current task. (Null means user did not specify any ID).
30+
*
31+
* If null, the task will generate an unsaved random ID when it is started.
32+
* @var string|null
33+
*/
34+
private string|null $taskID;
35+
2636
/**
2737
* The process that is actually running this task. Tasks that are not started will have null here.
2838
* @var InvokedProcess|null
@@ -91,14 +101,19 @@ class AsyncTask
91101
/**
92102
* Creates an AsyncTask instance.
93103
* @param Closure|AsyncTaskInterface $theTask The task to be executed in the background.
104+
* @param string|null $taskID (optional) The user-specified task ID of this AsyncTask. Should be unique.
94105
*/
95-
public function __construct(Closure|AsyncTaskInterface $theTask)
106+
public function __construct(Closure|AsyncTaskInterface $theTask, string|null $taskID = null)
96107
{
97108
if ($theTask instanceof Closure) {
98109
// convert to serializable closure first
99110
$theTask = new SerializableClosure($theTask);
100111
}
101112
$this->theTask = $theTask;
113+
if ($taskID === "") {
114+
throw new InvalidArgumentException("AsyncTask ID cannot be empty.");
115+
}
116+
$this->taskID = $taskID;
102117
}
103118

104119
/**
@@ -159,21 +174,26 @@ public function run(): void
159174

160175
/**
161176
* Starts this AsyncTask immediately in the background. A runner will then run this AsyncTask.
162-
* @return void
177+
* @return AsyncTaskStatus The status object for the started AsyncTask.
163178
*/
164-
public function start(): void
179+
public function start(): AsyncTaskStatus
165180
{
181+
// prepare the task details
182+
$taskID = $this->taskID ?? Str::ulid()->toString();
183+
$taskStatus = new AsyncTaskStatus($taskID);
184+
166185
// prepare the runner command
167186
$serializedTask = $this->toBase64Serial();
168-
$baseCommand = "php artisan async:run $serializedTask";
187+
$encodedTaskID = $taskStatus->getEncodedTaskID();
188+
$baseCommand = "php artisan async:run $serializedTask --id='$encodedTaskID'";
169189

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

202223
/**

src/AsyncTaskRunnerCommand.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class AsyncTaskRunnerCommand extends Command
1414
*
1515
* @var string
1616
*/
17-
protected $signature = 'async:run {task}';
17+
protected $signature = 'async:run {task} {--id=}';
1818

1919
/**
2020
* The console command description.

src/AsyncTaskStatus.php

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Vectorial1024\LaravelProcessAsync;
6+
7+
use InvalidArgumentException;
8+
use loophp\phposinfo\OsInfo;
9+
use RuntimeException;
10+
11+
/**
12+
* Represents the status of an async task: "running" or "stopped".
13+
*
14+
* This does not tell you whether it was a success/failure, since it depends on the user's custom result checking.
15+
*/
16+
class AsyncTaskStatus
17+
{
18+
private const MSG_CANNOT_CHECK_STATUS = "Could not check the status of the AsyncTask.";
19+
20+
/**
21+
* The cached task ID for quick ID reusing. We will most probably reuse this ID many times.
22+
* @var string|null
23+
*/
24+
private string|null $encodedTaskID = null;
25+
26+
/**
27+
* Indicates whether the task is stopped.
28+
*
29+
* Note: the criteria is "pretty sure it is stopped"; once the task is stopped, it stays stopped.
30+
* @var bool
31+
*/
32+
private bool $isStopped = false;
33+
34+
/**
35+
* The last known PID of the task runner.
36+
* @var int|null If null, it means the PID is unknown or expired.
37+
*/
38+
private int|null $lastKnownPID = null;
39+
40+
/**
41+
* Constructs a status object.
42+
* @param string $taskID The task ID of the async task so to check its status.
43+
*/
44+
public function __construct(
45+
public readonly string $taskID
46+
) {
47+
if ($taskID === "") {
48+
// why no blank IDs? because this will produce blank output via base64 encode.
49+
throw new InvalidArgumentException("AsyncTask IDs cannot be blank");
50+
}
51+
}
52+
53+
/**
54+
* Returns the task ID encoded in base64, mainly for result checking.
55+
* @return string The encoded task ID.
56+
*/
57+
public function getEncodedTaskID(): string
58+
{
59+
if ($this->encodedTaskID === null) {
60+
$this->encodedTaskID = base64_encode($this->taskID);
61+
}
62+
return $this->encodedTaskID;
63+
}
64+
65+
/**
66+
* Checks and returns whether the AsyncTask is still running.
67+
*
68+
* On Windows, this may take some time due to underlying bottlenecks.
69+
*
70+
* Note: when this method detects that the task has stopped running, it will not recheck whether the task has restarted.
71+
* Use a fresh status object to track the (restarted) task.
72+
* @return bool If true, indicates the task is still running.
73+
*/
74+
public function isRunning(): bool
75+
{
76+
if ($this->isStopped) {
77+
return false;
78+
}
79+
// prove it is running
80+
$isRunning = $this->proveTaskIsRunning();
81+
if (!$isRunning) {
82+
$this->isStopped = true;
83+
}
84+
return $isRunning;
85+
}
86+
87+
/**
88+
* Attempts to prove whether the AsyncTask is still running
89+
* @return bool If false, then the task is shown to have been stopped.
90+
*/
91+
private function proveTaskIsRunning(): bool
92+
{
93+
if ($this->lastKnownPID === null) {
94+
// we don't know where the task runner is at; find it!
95+
return $this->findTaskRunnerProcess();
96+
}
97+
// we know the task runner; is it still running?
98+
return $this->observeTaskRunnerProcess();
99+
}
100+
101+
/**
102+
* Attempts to find the task runner process (if exists), and writes down its PID.
103+
* @return bool If true, then the task runner is successfully found.
104+
*/
105+
private function findTaskRunnerProcess(): bool
106+
{
107+
// find the runner in the system
108+
// we might have multiple PIDs; in this case, pick the first one that appears
109+
/*
110+
* note: while the OS may allow reading multiple properties at the same time,
111+
* we won't risk it because localizations might produce unexpected strings or unusual separators
112+
* an example would be CJK potentially having an alternate character to replace ":"
113+
*/
114+
if (OsInfo::isWindows()) {
115+
// Windows uses GCIM to discover processes
116+
$results = [];
117+
$encodedTaskID = $this->getEncodedTaskID();
118+
$expectedCmdName = "artisan async:run";
119+
// we can assume we are in cmd, but wcim in cmd is deprecated, and the replacement gcim requires powershell
120+
$results = [];
121+
$fullCmd = "powershell echo \"\"(gcim Win32_Process -Filter \\\"CommandLine LIKE '%id=\'$encodedTaskID\'%'\\\").ProcessId\"\"";
122+
\Illuminate\Support\Facades\Log::info($fullCmd);
123+
exec("powershell echo \"\"(gcim Win32_Process -Filter \\\"CommandLine LIKE '%id=\'$encodedTaskID\'%'\\\").ProcessId\"\"", $results);
124+
// will output many lines, each line being a PID
125+
foreach ($results as $candidatePID) {
126+
$candidatePID = (int) $candidatePID;
127+
// then use gcim again to see the cmd args
128+
$cmdArgs = exec("powershell echo \"\"(gcim Win32_Process -Filter \\\"ProcessId = $candidatePID\\\").CommandLine\"\"");
129+
if ($cmdArgs === false) {
130+
throw new RuntimeException(self::MSG_CANNOT_CHECK_STATUS);
131+
}
132+
if (!str_contains($cmdArgs, $expectedCmdName)) {
133+
// not really
134+
continue;
135+
}
136+
$executable = exec("powershell echo \"\"(gcim Win32_Process -Filter \\\"ProcessId = $candidatePID\\\").Name\"\"");
137+
if ($executable === false) {
138+
throw new RuntimeException(self::MSG_CANNOT_CHECK_STATUS);
139+
}
140+
if ($executable !== "php.exe") {
141+
// not really
142+
// note: we currently hard-code "php" as the executable name
143+
continue;
144+
}
145+
// all checks passed; it is this one
146+
$this->lastKnownPID = $candidatePID;
147+
return true;
148+
}
149+
return false;
150+
}
151+
// assume anything not Windows to be Unix
152+
// find the runner on Unix systems via pgrep
153+
$results = [];
154+
$encodedTaskID = $this->getEncodedTaskID();
155+
exec("pgrep -f id='$encodedTaskID'", $results);
156+
// we may find multiple records here if we are using timeouts
157+
// this is because there will be one parent timeout process and another actual child artisan process
158+
// we want the child artisan process
159+
$expectedCmdName = "artisan async:run";
160+
foreach ($results as $candidatePID) {
161+
$candidatePID = (int) $candidatePID;
162+
// then use ps to see what really is it
163+
$fullCmd = exec("ps -p $candidatePID -o args=");
164+
if ($fullCmd === false) {
165+
throw new RuntimeException(self::MSG_CANNOT_CHECK_STATUS);
166+
}
167+
if (!str_contains($fullCmd, $expectedCmdName)) {
168+
// not really
169+
continue;
170+
}
171+
$executable = exec("ps -p $candidatePID -o comm=");
172+
if ($executable === false) {
173+
throw new RuntimeException(self::MSG_CANNOT_CHECK_STATUS);
174+
}
175+
if ($executable !== "php") {
176+
// not really
177+
// note: we currently hard-code "php" as the executable name
178+
continue;
179+
}
180+
// this is it!
181+
$this->lastKnownPID = $candidatePID;
182+
return true;
183+
}
184+
return false;
185+
}
186+
187+
/**
188+
* Given a previously-noted PID of the task runner, see if the task runner is still alive.
189+
* @return bool If true, then the task runner is still running.
190+
*/
191+
private function observeTaskRunnerProcess(): bool
192+
{
193+
// since we should have remembered the PID, we can just query whether it still exists
194+
// supposedly, the PID has not rolled over yet, right...?
195+
if (OsInfo::isWindows()) {
196+
// Windows can also use Get-Process to probe processes
197+
$echoedPid = exec("powershell (Get-Process -id {$this->lastKnownPID}).Id");
198+
if ($echoedPid === false) {
199+
throw new RuntimeException(self::MSG_CANNOT_CHECK_STATUS);
200+
}
201+
$echoedPid = (int) $echoedPid;
202+
return $this->lastKnownPID === $echoedPid;
203+
}
204+
// assume anything not Windows to be Unix
205+
$echoedPid = exec("ps -p {$this->lastKnownPID} -o pid=");
206+
if ($echoedPid === false) {
207+
throw new RuntimeException(self::MSG_CANNOT_CHECK_STATUS);
208+
}
209+
$echoedPid = (int) $echoedPid;
210+
return $this->lastKnownPID === $echoedPid;
211+
}
212+
}

0 commit comments

Comments
 (0)