Skip to content

Commit e8f3bae

Browse files
committed
[Bref] Added support for handle timeouts
1 parent b84373a commit e8f3bae

File tree

6 files changed

+215
-0
lines changed

6 files changed

+215
-0
lines changed

src/bref/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ you will get an exception. Run `composer require bref/bref` to solve the issue.
1414
- We do not use internal classes in Bref any more (https://github.com/php-runtime/runtime/pull/88)
1515
- Some handlers do not require the `bref/bref` package (https://github.com/php-runtime/runtime/pull/89)
1616
- We include a runtime specific Bref layer (https://github.com/php-runtime/bref-layer)
17+
- Support for handle timeouts and lets your application shut down. This will shorten
18+
the actual execution time with 2 seconds.
1719

1820
## 0.2.2
1921

src/bref/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ We support all kinds of applications. See the following sections for details.
1414
1. [Simplify serverless.yml](#simplify-serverlessyml)
1515
1. [Typed handlers](#typed-handlers)
1616
1. [Symfony Messenger integration](#symfony-messenger-integration)
17+
1. [Handle timeouts](#handle-timeouts)
1718

1819
If you are new to the Symfony Runtime component, read more in the
1920
[main readme](https://github.com/php-runtime/runtime).
@@ -399,3 +400,43 @@ resources:
399400
QueueName: ${self:service}-workqueue
400401
VisibilityTimeout: 600
401402
```
403+
404+
## Handle timeouts
405+
406+
When a Lambda function times out, it is like the power to the computer is suddenly
407+
just turned off. This does not give the application a chance to shut down properly.
408+
This leaves you without any logs and the problem could be hard to fix.
409+
410+
To allow your application to shut down properly and write logs, we will throw an
411+
exception just before the Lambda times out.
412+
413+
Whenever a timeout happens, a full stack trace will be logged, including the line
414+
that was executing. In most cases, it is an external call to a database, cache or
415+
API that is stuck waiting.
416+
417+
### Catching the exception
418+
419+
You can catch the timeout exception to perform some cleanup, logs or even display
420+
a proper error page. If you are using a framework, this is likely done for you.
421+
Here is an example of a simple handler catching the timeout exception
422+
423+
```php
424+
use Bref\Context\Context;
425+
use Bref\Event\Handler;
426+
use Runtime\Bref\Timeout\LambdaTimeout;
427+
428+
class Handler implements Handler
429+
{
430+
public function handle($event, Context $context)
431+
{
432+
try {
433+
// your code here
434+
// ...
435+
} catch (LambdaTimeoutException $e) {
436+
echo 'Oops, sorry. We spent too much time on this.';
437+
} catch (\Throwable $e) {
438+
echo 'Some other unexpected error happened.';
439+
}
440+
}
441+
}
442+
```

src/bref/src/Lambda/LambdaClient.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Bref\Context\Context;
66
use Bref\Event\Handler;
77
use Exception;
8+
use Runtime\Bref\Timeout\Timeout;
89

910
/**
1011
* A port of LambdaRuntime from bref/bref package. That class is internal so
@@ -100,6 +101,12 @@ public function processNextEvent(Handler $handler): bool
100101
[$event, $context] = $this->waitNextInvocation();
101102
\assert($context instanceof Context);
102103

104+
$remainingTimeInMillis = $context->getRemainingTimeInMillis();
105+
if (0 < $remainingTimeInMillis) {
106+
// Throw exception before Lambda pulls the plug.
107+
Timeout::enable($remainingTimeInMillis);
108+
}
109+
103110
$this->ping();
104111

105112
try {
@@ -110,6 +117,8 @@ public function processNextEvent(Handler $handler): bool
110117
$this->signalFailure($context->getAwsRequestId(), $e);
111118

112119
return false;
120+
} finally {
121+
Timeout::reset();
113122
}
114123

115124
return true;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace Runtime\Bref\Timeout;
4+
5+
/**
6+
* The application took too long to produce a response. This exception is thrown
7+
* to give the application a chance to flush logs and shut itself down before
8+
* the power to AWS Lambda is disconnected.
9+
*
10+
* @author Tobias Nyholm <[email protected]>
11+
*/
12+
class LambdaTimeoutException extends \RuntimeException
13+
{
14+
}

src/bref/src/Timeout/Timeout.php

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
namespace Runtime\Bref\Timeout;
4+
5+
/**
6+
* Helper class to trigger an exception just before the Lambda times out. This
7+
* will give the application a chance to shut down.
8+
*
9+
* @author Tobias Nyholm <[email protected]>
10+
*/
11+
final class Timeout
12+
{
13+
/** @var bool */
14+
private static $initialized = false;
15+
16+
/** @var string|null */
17+
private static $stackTrace = null;
18+
19+
/**
20+
* @internal
21+
*/
22+
public static function enable(int $remainingTimeInMillis): void
23+
{
24+
self::init();
25+
26+
if (!self::$initialized) {
27+
return;
28+
}
29+
30+
$remainingTimeInSeconds = (int) floor($remainingTimeInMillis / 1000);
31+
32+
// The script will timeout 2 seconds before the remaining time
33+
// to allow some time for Bref/our app to recover and cleanup
34+
$margin = 2;
35+
36+
$timeoutDelayInSeconds = max(1, $remainingTimeInSeconds - $margin);
37+
38+
// Trigger SIGALRM in X seconds
39+
pcntl_alarm($timeoutDelayInSeconds);
40+
}
41+
42+
/**
43+
* Setup custom handler for SIGALRM.
44+
*/
45+
private static function init(): void
46+
{
47+
self::$stackTrace = null;
48+
49+
if (self::$initialized) {
50+
return;
51+
}
52+
53+
if (! function_exists('pcntl_async_signals')) {
54+
trigger_error('Could not enable timeout exceptions because pcntl extension is not enabled.');
55+
return;
56+
}
57+
58+
pcntl_async_signals(true);
59+
// Setup a handler for SIGALRM that throws an exception
60+
// This will interrupt any running PHP code, including `sleep()` or code stuck waiting for I/O.
61+
pcntl_signal(SIGALRM, function (): void {
62+
if (Timeout::$stackTrace !== null) {
63+
// we have already thrown an exception, do a harder exit.
64+
error_log('Lambda timed out');
65+
error_log((new LambdaTimeoutException)->getTraceAsString());
66+
error_log('Original stack trace');
67+
error_log(Timeout::$stackTrace);
68+
69+
exit(1);
70+
}
71+
72+
$exception = new LambdaTimeoutException('Maximum AWS Lambda execution time reached');
73+
Timeout::$stackTrace = $exception->getTraceAsString();
74+
75+
// Trigger another alarm after 1 second to do a hard exit.
76+
pcntl_alarm(1);
77+
78+
throw $exception;
79+
});
80+
81+
self::$initialized = true;
82+
}
83+
84+
/**
85+
* Cancel all current timeouts.
86+
*
87+
* @internal
88+
*/
89+
public static function reset(): void
90+
{
91+
if (self::$initialized) {
92+
pcntl_alarm(0);
93+
self::$stackTrace = null;
94+
}
95+
}
96+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace Runtime\Bref\Tests;
4+
5+
use Runtime\Bref\Timeout\LambdaTimeoutException;
6+
use Runtime\Bref\Timeout\Timeout;
7+
use PHPUnit\Framework\TestCase;
8+
9+
class TimeoutTest extends TestCase
10+
{
11+
public static function setUpBeforeClass(): void
12+
{
13+
if (! function_exists('pcntl_async_signals')) {
14+
self::markTestSkipped('PCNTL extension is not enabled.');
15+
}
16+
}
17+
18+
protected function setUp(): void
19+
{
20+
parent::setUp();
21+
unset($_SERVER['LAMBDA_INVOCATION_CONTEXT']);
22+
}
23+
24+
protected function tearDown(): void
25+
{
26+
Timeout::reset();
27+
parent::tearDown();
28+
}
29+
30+
public function testEnable()
31+
{
32+
Timeout::enable(3000);
33+
$timeout = pcntl_alarm(0);
34+
// 1 second (2 seconds shorter than the 3s remaining time)
35+
$this->assertSame(1, $timeout);
36+
}
37+
38+
public function testTimeoutsAreInterruptedInTime()
39+
{
40+
$start = microtime(true);
41+
Timeout::enable(3000);
42+
try {
43+
sleep(4);
44+
$this->fail('We expect a LambdaTimeout before we reach this line');
45+
} catch (LambdaTimeoutException $e) {
46+
$time = 1000 * (microtime(true) - $start);
47+
$this->assertEqualsWithDelta(1000, $time, 200, 'We must wait about 1 second');
48+
Timeout::reset();
49+
} catch (\Throwable $e) {
50+
$this->fail('It must throw a LambdaTimeout.');
51+
}
52+
}
53+
}

0 commit comments

Comments
 (0)