Skip to content
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

Feature: task time limits (and other related stuff) #11

Merged
merged 61 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
64c13e7
Add time limit param to AsyncTask
Vectorial1024 Dec 22, 2024
21a253d
Adjust comments
Vectorial1024 Dec 22, 2024
f41c954
Allow setting task time limit
Vectorial1024 Dec 23, 2024
e096dcf
Add test cases
Vectorial1024 Dec 23, 2024
269b6fc
Implement Windows task time limit
Vectorial1024 Dec 23, 2024
c622362
Implement Unix task time limit
Vectorial1024 Dec 27, 2024
bbe19e3
Handle MacOS gtimeout vs timeout
Vectorial1024 Dec 27, 2024
c526ac3
Adjust test sleeping
Vectorial1024 Dec 27, 2024
ed8436d
Cleanup coreutils detection
Vectorial1024 Dec 27, 2024
c1f3664
MacOS runner: install GNU coreutils
Vectorial1024 Dec 27, 2024
38ec796
Fix MacOS Homebrew install
Vectorial1024 Dec 27, 2024
fa0c8e1
Adjust sleeping logic
Vectorial1024 Dec 27, 2024
faa54cf
Fix variable type error
Vectorial1024 Dec 27, 2024
bddb22e
Add strict type directive
Vectorial1024 Dec 27, 2024
41a1d5b
Sleep faster
Vectorial1024 Dec 27, 2024
ea92670
Add nohup assertions
Vectorial1024 Dec 27, 2024
dc73c02
Adjust sleeping
Vectorial1024 Dec 27, 2024
4171b86
Ensure the async runner is run silently
Vectorial1024 Dec 27, 2024
8a29c58
Clean up the README
Vectorial1024 Dec 27, 2024
476d539
Implement Windows runtime timeout
Vectorial1024 Dec 29, 2024
0367ab4
Correctly handle no-error case
Vectorial1024 Dec 29, 2024
665ef6a
Also handle PHP INI timeout case
Vectorial1024 Dec 30, 2024
795e789
Test the async task timeout handler
Vectorial1024 Dec 30, 2024
5683988
Fix wrong config of testing class
Vectorial1024 Dec 30, 2024
2d31c12
Implement Unix task timeout
Vectorial1024 Dec 30, 2024
f4513c5
Fix "no constants" during shutdown functions
Vectorial1024 Dec 30, 2024
ae53283
Handle missing LARAVEL_START during test cases
Vectorial1024 Dec 30, 2024
6758d66
Properly write down the constant
Vectorial1024 Dec 30, 2024
5ad1551
Adjust test sleeping
Vectorial1024 Dec 31, 2024
161388f
Make timeout check stricter
Vectorial1024 Dec 31, 2024
0ae8b92
Adjust task timeout checks
Vectorial1024 Dec 31, 2024
05defbc
Clean up and stabilize timeout detection
Vectorial1024 Dec 31, 2024
36a5099
Unset the var
Vectorial1024 Dec 31, 2024
3119eea
Tell GitHub runners to open up
Vectorial1024 Dec 31, 2024
e4a55c8
Open up again
Vectorial1024 Dec 31, 2024
23da68e
Open up again
Vectorial1024 Dec 31, 2024
24c43fe
Fix typo
Vectorial1024 Dec 31, 2024
5fe00e4
Use the correct Unix process to check time
Vectorial1024 Dec 31, 2024
f9e012f
Use workaround
Vectorial1024 Dec 31, 2024
9c3b3d4
Re-implement timeout workaround
Vectorial1024 Dec 31, 2024
2fe1a1a
Temporarily allow errors to come up
Vectorial1024 Dec 31, 2024
cf5f6fc
Fix inappropriate signal handler visibility
Vectorial1024 Dec 31, 2024
95290f5
Revert temp-test code
Vectorial1024 Dec 31, 2024
e46119b
Add back the epsilon time
Vectorial1024 Dec 31, 2024
7a22e5d
Add sanity check
Vectorial1024 Dec 31, 2024
13f74c4
Move san-check position
Vectorial1024 Dec 31, 2024
2074be8
Correctly detect PHP fatal errors
Vectorial1024 Jan 1, 2025
35db912
Remove sanity check
Vectorial1024 Jan 1, 2025
6837265
Stabilize timeout checking
Vectorial1024 Jan 1, 2025
6fe7c6c
Stabilize timeout checking
Vectorial1024 Jan 1, 2025
5c129d5
Cleanup the task runner
Vectorial1024 Jan 1, 2025
5294065
Reorganize testing tasks
Vectorial1024 Jan 1, 2025
f7cc039
Test async timeout handlers are not wrongly triggered
Vectorial1024 Jan 1, 2025
d2e9953
Use new way of sending Unix timeout info
Vectorial1024 Jan 2, 2025
1d0ab53
Do not check for time limit if the limit is not set
Vectorial1024 Jan 2, 2025
b1bf2d9
Test that no time limits do not trigger timeout handler
Vectorial1024 Jan 2, 2025
99b31cb
Force a clean test environment
Vectorial1024 Jan 2, 2025
383bc77
Force a clean testing environment
Vectorial1024 Jan 2, 2025
1580159
Be more lenient with test case timeouts
Vectorial1024 Jan 2, 2025
1ed6729
README: describe task time limits
Vectorial1024 Jan 2, 2025
8b7371c
Improve DocString
Vectorial1024 Jan 2, 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
3 changes: 3 additions & 0 deletions .github/workflows/macos_l11.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ jobs:
with:
php-version: '8.2'

- name: "Homebrew: Install GNU Core Utilities"
run: yes | brew install coreutils

- name: Validate composer.json and composer.lock
run: composer validate --strict

Expand Down
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ Utilize Laravel Processes to run PHP code asynchronously.
- Restrictions from `laravel/serializable-closure` apply (see [their README](https://github.com/laravel/serializable-closure))
- Hands-off execution: no built-in result-checking, check the results yourself (e.g. via database, file cache, etc)

This library internally uses an Artisan command to run the async code, which is similar to Laravel 11 [Concurrency](https://laravel.com/docs/11.x/concurrency).

## Why should I want this?
This library is very helpful for these cases:
- You want a minimal-setup async for easy vertical scaling
- You want a cross-platform minimal-setup async for easy vertical scaling
- You want to start quick-and-dirty async tasks right now (e.g. prefetching resources, pinging remote, etc.)
- Best is if your task only has very few lines of code
- Laravel 11 [Concurrency](https://laravel.com/docs/11.x/concurrency) is too limiting; e.g.:
- You want to do something else while waiting for results
- You want to conveniently limit the max (real) execution time of the concurrent tasks
- And perhaps more!

Of course, if you are considering extreme scaling (e.g. Redis queues in Laravel, multi-worker clusters, etc.) or guaranteed task execution, then this library is obviously not for you.

Expand All @@ -32,6 +36,12 @@ composer require vectorial1024/laravel-process-async

This library supports Unix and Windows; see the Testing section for more details.

### Extra requirements for Unix
If you are on Unix, check that you also have the following:
- GNU Core Utilities (`coreutils`)
- MacOS do `brew install coreutils`!
- Other Unix distros should check if `coreutils` is preinstalled

## Change log
Please see `CHANGELOG.md`.

Expand Down Expand Up @@ -59,6 +69,24 @@ $task->start();
// the task is now run in another PHP process, and will not report back to this PHP process.
```

### Task time limits
You can set task time limits before you start them, but you cannot change them after the tasks are started. When the time limit is reached, the async task is killed.

The default time limit is 30 real seconds. You can also choose to not set any time limit, in this case the (CLI) PHP `max_execution_time` directive will control the time limit.

Note: `AsyncTaskInterface` contains an implementable method `handleTimeout` for you to define timeout-related cleanups (e.g. write to some log that the task has timed out). This method is still called when the PHP `max_execution_time` directive is triggered.

```php
// start with the default time limit...
$task->start();

// start task with a different time limit...
$task->withTimeLimit(15)->start();

// ...or not have any limits at all (beware of orphaned processes!)
$task->withoutTimeLimit()->start();
```

## Testing
PHPUnit via Composer script:
```sh
Expand Down
235 changes: 234 additions & 1 deletion src/AsyncTask.php
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
<?php

declare(strict_types=1);

namespace Vectorial1024\LaravelProcessAsync;

use Closure;
use Illuminate\Process\InvokedProcess;
use Illuminate\Support\Facades\Process;
use Laravel\SerializableClosure\SerializableClosure;
use LogicException;
use loophp\phposinfo\OsInfo;
use RuntimeException;

/**
* The common handler of an AsyncTask; this can be a closure (will be wrapped inside AsyncTask) or an interface instance.
Expand All @@ -25,6 +29,65 @@ class AsyncTask
*/
private InvokedProcess|null $runnerProcess = null;

/**
* The maximum real time (in seconds) this task is allowed to run.
* @var int|null
*/
private int|null $timeLimit = 30;

/**
* The value of constant("LARAVEL_START") for future usage. Apparently, constants are not available during shutdown functions.
* @var float|null
*/
private float|null $laravelStartVal = null;

/**
* On Unix only. Indicates the process ID that can be used to track the "time elapsed" stat, which resolves to the following:
* - if the task was started under the `timeout` command, then the PID of said `timeout` command
* - else (i.e., started without time limit), the self PID
*
* If not yet initialized or on Windows, then will be 0, which indicates an invalid PID.
* @var int
*/
private int $timerProcID = 0;

/**
* On Unix only. Indicates whether a SIGINT has been received.
* @var bool
*/
private bool $hasSigInt = false;

/**
* The string constant name for constant('LARAVEL_START'). Mainly to keep the code clean.
* @var string
*/
private const LARAVEL_START = "LARAVEL_START";

/**
* The bitmask that can filter for fatal runtime errors.
*
* Fatal errors other than the specific "time limit exceeded" error must not trigger the timeout handlers.
*/
private const FATAL_ERROR_BITMASK = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR;

/**
* Indicates whether GNU coreutils is found in the system; in particular, we are looking for the timeout command inside coreutils.
*
* If null, indicates we haven't checked this yet.
*
* Always null in Windows since Windows-side does not require GNU coreutils.
* @var bool|null
*/
private static bool|null $hasGnuCoreUtils = null;

/**
* The name of the found timeout command inside GNU coreutils.
*
* It is known that older MacOS environments might have "gtimeout" instead of "timeout".
* @var string|null
*/
private static string|null $timeoutCmdName = null;

/**
* Creates an AsyncTask instance.
* @param Closure|AsyncTaskInterface $theTask The task to be executed in the background.
Expand All @@ -48,6 +111,35 @@ public function __construct(Closure|AsyncTaskInterface $theTask)
public function run(): void
{
// todo startup configs
// write down the LARAVEL_START constant value for future usage
$this->laravelStartVal = defined(self::LARAVEL_START) ? constant("LARAVEL_START") : null;

// install a timeout detector
// this single function checks all kinds of timeouts
register_shutdown_function([$this, 'shutdownCheckTaskTimeout']);
if (OsInfo::isWindows()) {
// windows can just use PHP's time limit
set_time_limit($this->timeLimit);
} else {
// assume anything not Windows to be Unix
// we already set it to kill this task after the timeout, so we just need to install a listener to catch the signal and exit gracefully
pcntl_async_signals(true);
pcntl_signal(SIGINT, function () {
// sicne we are already running with nohup, we can use SIGINT to indicate that a timeout has occurred.
// exit asap so that our error checking inside shutdown functions can take place outside of the usual max_execution_time limit
$this->hasSigInt = true;
exit();
});

// and we also need to see the command name of our parent, to correctly track time
$this->timerProcID = getmypid();
$parentPid = posix_getppid();
$parentCmd = exec("ps -p $parentPid -o comm=");
if ($parentCmd === "timeout" || $parentCmd === "gtimeout") {
// we should use the parent instead to time this task
$this->timerProcID = $parentPid;
}
}

// then, execute the task itself
if ($this->theTask instanceof SerializableClosure) {
Expand Down Expand Up @@ -77,12 +169,32 @@ public function start(): void
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 /c start /b $baseCommand");
return;
}
// assume anything not windows to be unix
// unix use nohup
$this->runnerProcess = Process::quietly()->start("nohup $baseCommand");
// check time limit settings
$timeoutClause = "";
if ($this->timeLimit > 0) {
// do we really have timeout here?
if (static::$hasGnuCoreUtils === null) {
// haven't checked before; check
$tmpOut = exec("command -v timeout || command -v gtimeout");
$cmdName = !empty($tmpOut) ? $tmpOut : null;
unset($tmpOut);
static::$hasGnuCoreUtils = $cmdName !== null;
static::$timeoutCmdName = $cmdName;
}
if (static::$hasGnuCoreUtils === false) {
// can't do anything without GNU coreutils!
throw new RuntimeException("AsyncTask time limit requires GNU coreutils, but GNU coreutils was not installed");
}
// 2 is INT signal
$timeoutClause = static::$timeoutCmdName . " -s 2 {$this->timeLimit}";
}
$this->runnerProcess = Process::quietly()->start("nohup $timeoutClause $baseCommand >/dev/null 2>&1");
}

/**
Expand Down Expand Up @@ -126,4 +238,125 @@ public static function fromBase64Serial(string $serial): ?static
return null;
}
}

/**
* Returns the maximum real time this task is allowed to run. This also includes time spent on sleeping and waiting!
*
* Null indicates unlimited time.
* @return int|null The time limit in seconds.
*/
public function getTimeLimit(): int|null
{
return $this->timeLimit;
}

/**
* Sets the maximum real time this task is allowed to run. Chainable.
*
* When the task reaches the time limit, the timeout handler (if exists) will be called.
* @param int $seconds The time limit in seconds.
* @return AsyncTask $this for chaining.
*/
public function withTimeLimit(int $seconds): static
{
if ($seconds == 0) {
throw new LogicException("AsyncTask time limit must be positive (hint: use withoutTimeLimit() for no time limits)");
}
if ($seconds < 0) {
throw new LogicException("AsyncTask time limit must be positive");
}
$this->timeLimit = $seconds;
return $this;
}

/**
* Sets this task to run with no time limit (PHP INI `max_execution_time` may apply). Chainable.
* @return AsyncTask $this for chaining.
*/
public function withoutTimeLimit(): static
{
$this->timeLimit = null;
return $this;
}

/**
* A shutdown function.
*
* Upon shutdown, checks whether this is due to the task timing out, and if so, triggers the timeout handler.
* @return void
*/
protected function shutdownCheckTaskTimeout(): void
{
if (!$this->hasTimedOut()) {
// shutdown due to other reasons; skip
return;
}

// timeout!
// trigger the timeout handler
if ($this->theTask instanceof AsyncTaskInterface) {
$this->theTask->handleTimeout();
}
}

/**
* During shutdown, checks whether this shutdown satisfies the "task timed out shutdown" condition.
* @return bool True if this task is timed out according to our specifications.
*/
private function hasTimedOut(): bool
{
// we perform a series of checks to see if this task has timed out

// dedicated SIGINT indicates a timeout
if ($this->hasSigInt) {
return true;
}

// runtime timeout triggers a PHP fatal error
// this can happen on Windows by our specification, or on Unix when the actual CLI PHP time limit is smaller than the time limit of this task
$lastError = error_get_last();
if ($lastError !== null && ($lastError['type'] & self::FATAL_ERROR_BITMASK)) {
// has fatal error; is it our timeout error?
return str_contains($lastError['message'], "Maximum execution time");
}
unset($lastError);

// the remaining checks use the time-limit variable, so if it is unset, then there is nothing to check
if ($this->timeLimit <= 0) {
return false;
}

// not a runtime timeout; one of the following:
// it ended within the time limit; or
// on Unix, it ran out of time so it is getting a SIGTERM from us; or
// it somehow ran out of time, and is being manually detected and killed
if ($this->laravelStartVal !== null) {
// this is very important; in some test cases, this is being run directly by PHPUnit, and so LARAVEL_START will be null
// in this case, we have no idea when this task has started running, so we cannot deduce any timeout statuses

// check LARAVEL_START with microtime
$timeElapsed = microtime(true) - $this->laravelStartVal;
if ($timeElapsed >= $this->timeLimit) {
// yes
return true;
}

// if we are on Unix, and when we have set a task time limit, then the LARAVEL_START value is inaccurate
// because there will always be a small but significant delay between `timeout` start time and PHP start time.
// in this case, we will look at the pre-determined timer PID to ask about the actual elapsed time through the kernel's proc data
// this method should be slower than the microtime method
if (OsInfo::isUnix()) {
// get time elapsed in seconds
$tempOut = exec("ps -p {$this->timerProcID} -o etimes=");
// this must exist (we are still running!), otherwise it indicates the kernel is broken and we can go grab a chicken dinner instead
$timeElapsed = (int) $tempOut;
unset($tempOut);
// it seems like etimes can get random off-by-1 inaccuracies (e.g. timeout supposed to be 7, but etimes sees 6.99999... and prints "6")
return $timeElapsed >= $this->timeLimit;
}
}

// didn't see anything; assume is no
return false;
}
}
10 changes: 10 additions & 0 deletions src/AsyncTaskInterface.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace Vectorial1024\LaravelProcessAsync;

/**
Expand All @@ -16,4 +18,12 @@ interface AsyncTaskInterface
* @return void
*/
public function execute(): void;

/**
* Cleans up the task when the task runner has run out of time specified by its time limit.
*
* Note: there is no need to call exit() again in this function.
* @return void
*/
public function handleTimeout(): void;
}
Loading
Loading