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

[Bref] Added support for handle timeouts #95

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions src/bref/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ you will get an exception. Run `composer require bref/bref` to solve the issue.
- We do not use internal classes in Bref any more (https://github.com/php-runtime/runtime/pull/88)
- Some handlers do not require the `bref/bref` package (https://github.com/php-runtime/runtime/pull/89)
- We include a runtime specific Bref layer (https://github.com/php-runtime/bref-layer)
- Support for handle timeouts and lets your application shut down. This will shorten
the actual execution time with 2 seconds.

### Fixed

Expand Down
41 changes: 41 additions & 0 deletions src/bref/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ We support all kinds of applications. See the following sections for details.
1. [Simplify serverless.yml](#simplify-serverlessyml)
1. [Typed handlers](#typed-handlers)
1. [Symfony Messenger integration](#symfony-messenger-integration)
1. [Handle timeouts](#handle-timeouts)

If you are new to the Symfony Runtime component, read more in the
[main readme](https://github.com/php-runtime/runtime).
Expand Down Expand Up @@ -399,3 +400,43 @@ resources:
QueueName: ${self:service}-workqueue
VisibilityTimeout: 600
```

## Handle timeouts

When a Lambda function times out, it is like the power to the computer is suddenly
just turned off. This does not give the application a chance to shut down properly.
This leaves you without any logs and the problem could be hard to fix.

To allow your application to shut down properly and write logs, we will throw an
exception just before the Lambda times out.

Whenever a timeout happens, a full stack trace will be logged, including the line
that was executing. In most cases, it is an external call to a database, cache or
API that is stuck waiting.

### Catching the exception

You can catch the timeout exception to perform some cleanup, logs or even display
a proper error page. If you are using a framework, this is likely done for you.
Here is an example of a simple handler catching the timeout exception

```php
use Bref\Context\Context;
use Bref\Event\Handler;
use Runtime\Bref\Timeout\LambdaTimeoutException;

class MySlowHandler implements Handler
{
public function handle($event, Context $context)
{
try {
// your code here
// ...
} catch (LambdaTimeoutException $e) {
return 'Oops, sorry. We spent too much time on this.';
} catch (\Throwable $e) {
return 'Some other unexpected error happened.';
}
}
}
```
5 changes: 4 additions & 1 deletion src/bref/src/BrefRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Bref\Event\Handler;
use Runtime\Bref\Lambda\LambdaClient;
use Runtime\Bref\Timeout\Timeout;
use Symfony\Component\Runtime\RunnerInterface;

/**
Expand All @@ -27,7 +28,7 @@ public function run(): int
$lambda = LambdaClient::fromEnvironmentVariable('symfony-runtime');

$loops = 0;
while (true) {
while (!Timeout::$_tiggered) {
if (++$loops > $this->loopMax) {
return 0;
}
Expand All @@ -41,5 +42,7 @@ public function run(): int
return 0;
}
}

return 0;
}
}
5 changes: 4 additions & 1 deletion src/bref/src/ConsoleApplicationRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Runtime\Bref;

use Runtime\Bref\Lambda\LambdaClient;
use Runtime\Bref\Timeout\Timeout;
use Symfony\Component\Console\Application;
use Symfony\Component\Runtime\RunnerInterface;

Expand All @@ -24,7 +25,7 @@ public function run(): int
{
$lambda = LambdaClient::fromEnvironmentVariable('symfony-runtime-console');

while (true) {
while (!Timeout::$_tiggered) {
/*
* In case the execution failed, we force starting a new process. This
* is because an uncaught exception could have left the application
Expand All @@ -34,5 +35,7 @@ public function run(): int
return 0;
}
}

return 0;
}
}
9 changes: 9 additions & 0 deletions src/bref/src/Lambda/LambdaClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Bref\Context\Context;
use Bref\Event\Handler;
use Exception;
use Runtime\Bref\Timeout\Timeout;

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

$remainingTimeInMillis = $context->getRemainingTimeInMillis();
if (0 < $remainingTimeInMillis) {
// Throw exception before Lambda pulls the plug.
Timeout::enable($remainingTimeInMillis);
}

$this->ping();

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

return false;
} finally {
Timeout::reset();
}

return true;
Expand Down
14 changes: 14 additions & 0 deletions src/bref/src/Timeout/LambdaTimeoutException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Runtime\Bref\Timeout;

/**
* The application took too long to produce a response. This exception is thrown
* to give the application a chance to flush logs and shut itself down before
* the power to AWS Lambda is disconnected.
*
* @author Tobias Nyholm <[email protected]>
*/
class LambdaTimeoutException extends \RuntimeException
{
}
88 changes: 88 additions & 0 deletions src/bref/src/Timeout/Timeout.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

namespace Runtime\Bref\Timeout;

/**
* Helper class to trigger an exception just before the Lambda times out. This
* will give the application a chance to shut down.
*
* @author Tobias Nyholm <[email protected]>
*/
final class Timeout
{
/** @var bool */
private static $initialized = false;

/**
* @var bool
* @internal
*/
public static $_tiggered = false;

/**
* @internal
*/
public static function enable(int $remainingTimeInMillis): void
{
self::init();

if (!self::$initialized) {
return;
}

$remainingTimeInSeconds = (int) floor($remainingTimeInMillis / 1000);

// The script will timeout 2 seconds before the remaining time
// to allow some time for Bref/our app to recover and cleanup
$margin = 2;

$timeoutDelayInSeconds = max(1, $remainingTimeInSeconds - $margin);

// Trigger SIGALRM in X seconds
pcntl_alarm($timeoutDelayInSeconds);
}

/**
* Setup custom handler for SIGALRM.
*/
private static function init(): void
{
if (self::$initialized) {
return;
}

if (!function_exists('pcntl_async_signals')) {
trigger_error('Could not enable timeout exceptions because pcntl extension is not enabled.');

return;
}

pcntl_async_signals(true);
// Setup a handler for SIGALRM that throws an exception
// This will interrupt any running PHP code, including `sleep()` or code stuck waiting for I/O.
pcntl_signal(SIGALRM, function (): void {
Timeout::$_tiggered = true;
$exception = new LambdaTimeoutException('Maximum AWS Lambda execution time reached');
// we have already thrown an exception, do a harder exit.
error_log($exception->getMessage());
error_log($exception->getTraceAsString());

exit(1);
});

self::$_tiggered = false;
self::$initialized = true;
}

/**
* Cancel all current timeouts.
*
* @internal
*/
public static function reset(): void
{
if (self::$initialized) {
pcntl_alarm(0);
}
}
}
32 changes: 29 additions & 3 deletions src/bref/tests/Lambda/LambdaClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use GuzzleHttp\Server\Server;
use PHPUnit\Framework\TestCase;
use Runtime\Bref\Lambda\LambdaClient;
use Runtime\Bref\Timeout\LambdaTimeoutException;
use Runtime\Bref\Timeout\Timeout;

/**
* Tests the communication between `LambdaClient` and the Lambda Runtime HTTP API.
Expand Down Expand Up @@ -189,9 +191,9 @@ public function handle($event, Context $context)

// Check the lambda result contains the error message
$error = json_decode((string) $eventFailureLog->getBody(), true);
$this->assertSame('Error while calling the Lambda runtime API: The requested URL returned error: 400 Bad Request', $error['errorMessage']);
$this->assertStringContainsString('Error while calling the Lambda runtime API: The requested URL returned error: 400', $error['errorMessage']);

$this->assertErrorInLogs('Exception', 'Error while calling the Lambda runtime API: The requested URL returned error: 400 Bad Request');
$this->assertErrorInLogs('Exception', 'Error while calling the Lambda runtime API: The requested URL returned error: 400');
}

public function test function results that cannot be encoded are reported as invocation errors()
Expand Down Expand Up @@ -235,6 +237,28 @@ public function handle($event, Context $context)
$this->assertInvocationResult(['foo' => 'bar']);
}

public function testLambdaTimeoutsCanBeAnticipated()
{
$this->givenAnEvent([]);

$start = microtime(true);
$this->lambda->processNextEvent(new class() implements Handler {
public function handle($event, Context $context)
{
// This 10s sleep should be interrupted
sleep(10);
}
});

$elapsedTime = microtime(true) - $start;
// The Lambda timeout was 2 seconds, we expect the Bref timeout to trigger 1 second before that: 1 second
$this->assertEqualsWithDelta(1, $elapsedTime, 0.2);
Timeout::reset();

$this->assertInvocationErrorResult(LambdaTimeoutException::class, 'Maximum AWS Lambda execution time reached');
$this->assertErrorInLogs(LambdaTimeoutException::class, 'Maximum AWS Lambda execution time reached');
}

/**
* @param mixed $event
*/
Expand All @@ -246,6 +270,8 @@ private function givenAnEvent($event): void
[
'lambda-runtime-aws-request-id' => '1',
'lambda-runtime-invoked-function-arn' => 'test-function-name',
// now + 2 seconds
'lambda-runtime-deadline-ms' => intval((microtime(true) + 2) * 1000),
],
json_encode($event)
),
Expand Down Expand Up @@ -312,7 +338,7 @@ private function assertErrorInLogs(string $errorClass, string $errorMessage): vo
'stack',
], array_keys($invocationResult));
$this->assertEquals($errorClass, $invocationResult['errorType']);
$this->assertEquals($errorMessage, $invocationResult['errorMessage']);
$this->assertStringContainsString($errorMessage, $invocationResult['errorMessage']);
$this->assertIsArray($invocationResult['stack']);
}

Expand Down
53 changes: 53 additions & 0 deletions src/bref/tests/Timeout/TimeoutTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace Runtime\Bref\Tests;

use PHPUnit\Framework\TestCase;
use Runtime\Bref\Timeout\LambdaTimeoutException;
use Runtime\Bref\Timeout\Timeout;

class TimeoutTest extends TestCase
{
public static function setUpBeforeClass(): void
{
if (!function_exists('pcntl_async_signals')) {
self::markTestSkipped('PCNTL extension is not enabled.');
}
}

protected function setUp(): void
{
parent::setUp();
unset($_SERVER['LAMBDA_INVOCATION_CONTEXT']);
}

protected function tearDown(): void
{
Timeout::reset();
parent::tearDown();
}

public function testEnable()
{
Timeout::enable(3000);
$timeout = pcntl_alarm(0);
// 1 second (2 seconds shorter than the 3s remaining time)
$this->assertSame(1, $timeout);
}

public function testTimeoutsAreInterruptedInTime()
{
$start = microtime(true);
Timeout::enable(3000);
try {
sleep(4);
$this->fail('We expect a LambdaTimeout before we reach this line');
} catch (LambdaTimeoutException $e) {
$time = 1000 * (microtime(true) - $start);
$this->assertEqualsWithDelta(1000, $time, 200, 'We must wait about 1 second');
Timeout::reset();
} catch (\Throwable $e) {
$this->fail('It must throw a LambdaTimeout.');
}
}
}