Skip to content

Commit fbdba3e

Browse files
committed
feat: add queue function runtime
1 parent 0547b3b commit fbdba3e

13 files changed

Lines changed: 496 additions & 0 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of Ymir PHP Runtime.
7+
*
8+
* (c) Carl Alexander <support@ymirapp.com>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Ymir\Runtime\Lambda\Handler\Sqs;
15+
16+
use Ymir\Runtime\Lambda\Handler\LambdaEventHandlerInterface;
17+
use Ymir\Runtime\Lambda\InvocationEvent\InvocationEventInterface;
18+
use Ymir\Runtime\Lambda\InvocationEvent\SqsEvent;
19+
use Ymir\Runtime\Lambda\InvocationEvent\SqsRecord;
20+
use Ymir\Runtime\Lambda\Response\ResponseInterface;
21+
use Ymir\Runtime\Lambda\Response\SqsResponse;
22+
23+
/**
24+
* Base handler for SQS events.
25+
*/
26+
abstract class AbstractSqsHandler implements LambdaEventHandlerInterface
27+
{
28+
/**
29+
* {@inheritdoc}
30+
*/
31+
public function canHandle(InvocationEventInterface $event): bool
32+
{
33+
return $event instanceof SqsEvent;
34+
}
35+
36+
/**
37+
* {@inheritdoc}
38+
*/
39+
public function handle(InvocationEventInterface $event): ResponseInterface
40+
{
41+
if (!$event instanceof SqsEvent || !$this->canHandle($event)) {
42+
throw new \InvalidArgumentException(sprintf('%s cannot handle the given invocation event object', (new \ReflectionClass(static::class))->getShortName()));
43+
}
44+
45+
$failedRecords = [];
46+
47+
$event->getRecords()->each(function (SqsRecord $record) use (&$failedRecords): void {
48+
try {
49+
$this->processRecord($record);
50+
} catch (\Throwable $exception) {
51+
$failedRecords[] = $record;
52+
}
53+
});
54+
55+
return new SqsResponse($failedRecords);
56+
}
57+
58+
abstract protected function processRecord(SqsRecord $record): void;
59+
}

src/Lambda/InvocationEvent/InvocationEventFactory.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ public static function createFromInvocationEvent(Context $context, array $event)
9191
$invocationEvent = new PingEvent($context);
9292
} elseif (isset($event['php'])) {
9393
$invocationEvent = new PhpConsoleCommandEvent($context, (string) $event['php']);
94+
} elseif (isset($event['Records'][0]['eventSource']) && 'aws:sqs' === $event['Records'][0]['eventSource']) {
95+
$invocationEvent = new SqsEvent($context, $event);
9496
} elseif (isset($event['warmup'])) {
9597
$invocationEvent = new WarmUpEvent($context, (int) $event['warmup']);
9698
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of Ymir PHP Runtime.
7+
*
8+
* (c) Carl Alexander <support@ymirapp.com>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Ymir\Runtime\Lambda\InvocationEvent;
15+
16+
use Tightenco\Collect\Support\Enumerable;
17+
18+
/**
19+
* Lambda invocation event for an SQS message.
20+
*
21+
* @see https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html
22+
*/
23+
class SqsEvent extends AbstractEvent
24+
{
25+
/**
26+
* The Lambda event details.
27+
*
28+
* @var array
29+
*/
30+
private $event;
31+
32+
/**
33+
* Constructor.
34+
*/
35+
public function __construct(Context $context, array $event = [])
36+
{
37+
parent::__construct($context);
38+
39+
$this->event = $event;
40+
}
41+
42+
/**
43+
* Get the records in the SQS event.
44+
*/
45+
public function getRecords(): Enumerable
46+
{
47+
return collect($this->event['Records'] ?? [])->map(function (array $record) {
48+
return new SqsRecord($record);
49+
});
50+
}
51+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of Ymir PHP Runtime.
7+
*
8+
* (c) Carl Alexander <support@ymirapp.com>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Ymir\Runtime\Lambda\InvocationEvent;
15+
16+
/**
17+
* An SQS record.
18+
*/
19+
class SqsRecord
20+
{
21+
/**
22+
* The record details.
23+
*
24+
* @var array
25+
*/
26+
private $record;
27+
28+
/**
29+
* Constructor.
30+
*/
31+
public function __construct(array $record)
32+
{
33+
$this->record = $record;
34+
}
35+
36+
/**
37+
* Get the body of the message.
38+
*/
39+
public function getBody(): string
40+
{
41+
return $this->record['body'];
42+
}
43+
44+
/**
45+
* Get the message attributes.
46+
*/
47+
public function getMessageAttributes(): array
48+
{
49+
return $this->record['messageAttributes'];
50+
}
51+
52+
/**
53+
* Get the event source ARN.
54+
*/
55+
public function getEventSourceArn(): string
56+
{
57+
return $this->record['eventSourceARN'];
58+
}
59+
60+
/**
61+
* Get the unique ID of the message.
62+
*/
63+
public function getMessageId(): string
64+
{
65+
return $this->record['messageId'];
66+
}
67+
68+
/**
69+
* Get the receipt handle.
70+
*/
71+
public function getReceiptHandle(): string
72+
{
73+
return $this->record['receiptHandle'];
74+
}
75+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of Ymir PHP Runtime.
7+
*
8+
* (c) Carl Alexander <support@ymirapp.com>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Ymir\Runtime\Lambda\Response;
15+
16+
use Ymir\Runtime\Lambda\InvocationEvent\SqsRecord;
17+
18+
/**
19+
* A lambda response for an SQS event.
20+
*/
21+
class SqsResponse implements ResponseInterface
22+
{
23+
/**
24+
* The SQS records that failed to be processed.
25+
*
26+
* @var SqsRecord[]
27+
*/
28+
private $failedRecords;
29+
30+
/**
31+
* Constructor.
32+
*/
33+
public function __construct(array $failedRecords = [])
34+
{
35+
$this->failedRecords = $failedRecords;
36+
}
37+
38+
/**
39+
* {@inheritdoc}
40+
*/
41+
public function getResponseData(): array
42+
{
43+
return [
44+
'batchItemFailures' => array_map(function (SqsRecord $record): array {
45+
return ['itemIdentifier' => $record->getMessageId()];
46+
}, $this->failedRecords),
47+
];
48+
}
49+
}

src/QueueRuntime.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of Ymir PHP Runtime.
7+
*
8+
* (c) Carl Alexander <support@ymirapp.com>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Ymir\Runtime;
15+
16+
/**
17+
* Runtime for "queue" functions.
18+
*/
19+
class QueueRuntime extends AbstractRuntime
20+
{
21+
/**
22+
* The function type that the runtime handles.
23+
*/
24+
public const TYPE = 'queue';
25+
}

src/Runtime.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ public static function create(): RuntimeInterface
6666
new ConsoleCommandLambdaEventHandler($logger),
6767
])), $logger);
6868

69+
break;
70+
case QueueRuntime::TYPE:
71+
$runtime = new QueueRuntime($runtimeApiClient, new LambdaEventHandlerCollection($logger, array_merge($handlers, [
72+
])), $logger);
73+
6974
break;
7075
case WebsiteRuntime::TYPE:
7176
$maxInvocations = ((int) getenv('YMIR_RUNTIME_MAX_INVOCATIONS')) ?: null;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of Ymir PHP Runtime.
7+
*
8+
* (c) Carl Alexander <support@ymirapp.com>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Ymir\Runtime\Tests\Unit\Lambda\Handler\Sqs;
15+
16+
use PHPUnit\Framework\TestCase;
17+
use Ymir\Runtime\Lambda\Handler\Sqs\AbstractSqsHandler;
18+
use Ymir\Runtime\Lambda\InvocationEvent\SqsEvent;
19+
use Ymir\Runtime\Lambda\InvocationEvent\SqsRecord;
20+
use Ymir\Runtime\Lambda\Response\SqsResponse;
21+
use Ymir\Runtime\Tests\Mock\ContextMockTrait;
22+
23+
class AbstractSqsHandlerTest extends TestCase
24+
{
25+
use ContextMockTrait;
26+
27+
public function testCanHandleReturnsFalse(): void
28+
{
29+
$handler = $this->getMockForAbstractClass(AbstractSqsHandler::class);
30+
31+
$this->assertFalse($handler->canHandle($this->getMockBuilder(\Ymir\Runtime\Lambda\InvocationEvent\InvocationEventInterface::class)->getMock()));
32+
}
33+
34+
public function testCanHandleReturnsTrue(): void
35+
{
36+
$handler = $this->getMockForAbstractClass(AbstractSqsHandler::class);
37+
38+
$this->assertTrue($handler->canHandle(new SqsEvent($this->getContextMock())));
39+
}
40+
41+
public function testHandleCollectsFailures(): void
42+
{
43+
$record1 = new SqsRecord(['messageId' => 'id1']);
44+
$record2 = new SqsRecord(['messageId' => 'id2']);
45+
$record3 = new SqsRecord(['messageId' => 'id3']);
46+
47+
$event = new SqsEvent($this->getContextMock(), [
48+
'Records' => [
49+
['messageId' => 'id1'],
50+
['messageId' => 'id2'],
51+
['messageId' => 'id3'],
52+
],
53+
]);
54+
55+
$handler = $this->getMockForAbstractClass(AbstractSqsHandler::class);
56+
$handler->expects($this->exactly(3))
57+
->method('processRecord')
58+
->willReturnCallback(function (SqsRecord $record): void {
59+
if ('id2' === $record->getMessageId()) {
60+
throw new \Exception('Failed');
61+
}
62+
});
63+
64+
$response = $handler->handle($event);
65+
66+
$this->assertInstanceOf(SqsResponse::class, $response);
67+
$this->assertSame([
68+
'batchItemFailures' => [
69+
['itemIdentifier' => 'id2'],
70+
],
71+
], $response->getResponseData());
72+
}
73+
}

tests/Unit/Lambda/InvocationEvent/InvocationEventFactoryTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Ymir\Runtime\Lambda\InvocationEvent\InvocationEventFactory;
2121
use Ymir\Runtime\Lambda\InvocationEvent\PhpConsoleCommandEvent;
2222
use Ymir\Runtime\Lambda\InvocationEvent\PingEvent;
23+
use Ymir\Runtime\Lambda\InvocationEvent\SqsEvent;
2324
use Ymir\Runtime\Lambda\InvocationEvent\WarmUpEvent;
2425
use Ymir\Runtime\Tests\Mock\ContextMockTrait;
2526

@@ -60,6 +61,11 @@ public function testCreatesPingEvent(): void
6061
$this->assertInstanceOf(PingEvent::class, InvocationEventFactory::createFromInvocationEvent($this->getContextMock(), ['ping' => true]));
6162
}
6263

64+
public function testCreatesSqsEvent(): void
65+
{
66+
$this->assertInstanceOf(SqsEvent::class, InvocationEventFactory::createFromInvocationEvent($this->getContextMock(), ['Records' => [['eventSource' => 'aws:sqs']]]));
67+
}
68+
6369
public function testCreatesWarmUpEvent(): void
6470
{
6571
$this->assertInstanceOf(WarmUpEvent::class, InvocationEventFactory::createFromInvocationEvent($this->getContextMock(), ['warmup' => '5']));

0 commit comments

Comments
 (0)