Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 11c3b24

Browse filesBrowse files
committed
[Lock][WIP] Initial pass on DynamoDb lock store
1 parent 59d8c63 commit 11c3b24
Copy full SHA for 11c3b24

File tree

Expand file treeCollapse file tree

5 files changed

+296
-0
lines changed
Filter options
Expand file treeCollapse file tree

5 files changed

+296
-0
lines changed

‎composer.json

Copy file name to clipboardExpand all lines: composer.json
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"require-dev": {
128128
"amphp/http-client": "^4.2.1|^5.0",
129129
"amphp/http-tunnel": "^1.0|^2.0",
130+
"async-aws/dynamo-db": "^3.0",
130131
"async-aws/ses": "^1.0",
131132
"async-aws/sqs": "^1.0|^2.0",
132133
"async-aws/sns": "^1.0",
+269Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Symfony\Component\Lock\Store;
5+
6+
use AsyncAws\DynamoDb\DynamoDbClient;
7+
use AsyncAws\DynamoDb\Exception\ConditionalCheckFailedException;
8+
use AsyncAws\DynamoDb\Input\CreateTableInput;
9+
use AsyncAws\DynamoDb\Input\DeleteItemInput;
10+
use AsyncAws\DynamoDb\Input\DescribeTableInput;
11+
use AsyncAws\DynamoDb\Input\GetItemInput;
12+
use AsyncAws\DynamoDb\Input\PutItemInput;
13+
use AsyncAws\DynamoDb\ValueObject\AttributeDefinition;
14+
use AsyncAws\DynamoDb\ValueObject\AttributeValue;
15+
use AsyncAws\DynamoDb\ValueObject\KeySchemaElement;
16+
use AsyncAws\DynamoDb\ValueObject\ProvisionedThroughput;
17+
use Symfony\Component\Lock\Exception\InvalidArgumentException;
18+
use Symfony\Component\Lock\Exception\InvalidTtlException;
19+
use Symfony\Component\Lock\Exception\LockAcquiringException;
20+
use Symfony\Component\Lock\Exception\LockConflictedException;
21+
use Symfony\Component\Lock\Key;
22+
use Symfony\Component\Lock\PersistingStoreInterface;
23+
24+
class DynamoDbStore implements PersistingStoreInterface
25+
{
26+
use ExpiringStoreTrait;
27+
28+
private const DEFAULT_OPTIONS = [
29+
'access_key' => null,
30+
'secret_key' => null,
31+
'session_token' => null,
32+
'endpoint' => 'https://dynamodb.us-west-1.amazonaws.com',
33+
'region' => 'eu-west-1',
34+
'table_name' => 'lock_keys',
35+
'id_attr' => 'key_id',
36+
'token_attr' => 'key_token',
37+
'expiration_attr' => 'key_expiration',
38+
'read_capacity_units' => 10,
39+
'write_capacity_units' => 20,
40+
'debug' => null,
41+
];
42+
43+
private DynamoDbClient $client;
44+
private string $tableName;
45+
private string $idAttr;
46+
private string $tokenAttr;
47+
private string $expirationAttr;
48+
private int $readCapacityUnits;
49+
private int $writeCapacityUnits;
50+
51+
public function __construct(
52+
DynamoDbClient|string $clientOrUrl,
53+
array $options = [],
54+
private readonly int $initialTtl = 300
55+
) {
56+
if ($clientOrUrl instanceof DynamoDbClient) {
57+
$this->client = $clientOrUrl;
58+
} else {
59+
if (!class_exists(DynamoDbClient::class)) {
60+
throw new InvalidArgumentException(\sprintf('You cannot use the "%s" if the DynamoDbClient is not available. Try running "composer require async-aws/dynamo-db".', __CLASS__));
61+
}
62+
63+
if (!str_starts_with($clientOrUrl, 'dynamodb:')) {
64+
throw new InvalidArgumentException('Unsupported DSN for DynamoDb.');
65+
}
66+
67+
if (false === $params = parse_url($clientOrUrl)) {
68+
throw new InvalidArgumentException('Invalid DynamoDb DSN.');
69+
}
70+
71+
$query = [];
72+
if (isset($params['query'])) {
73+
parse_str($params['query'], $query);
74+
}
75+
76+
// check for extra keys in options
77+
$optionsExtraKeys = array_diff(array_keys($options), array_keys(self::DEFAULT_OPTIONS));
78+
if (0 < \count($optionsExtraKeys)) {
79+
throw new InvalidArgumentException(\sprintf('Unknown option found: [%s]. Allowed options are [%s].', implode(', ', $optionsExtraKeys), implode(', ', array_keys(self::DEFAULT_OPTIONS))));
80+
}
81+
82+
// check for extra keys in query
83+
$queryExtraKeys = array_diff(array_keys($query), array_keys(self::DEFAULT_OPTIONS));
84+
if (0 < \count($queryExtraKeys)) {
85+
throw new InvalidArgumentException(\sprintf('Unknown option found in DSN: [%s]. Allowed options are [%s].', implode(', ', $queryExtraKeys), implode(', ', array_keys(self::DEFAULT_OPTIONS))));
86+
}
87+
88+
$options = $query + $options + self::DEFAULT_OPTIONS;
89+
90+
$clientConfiguration = [
91+
'region' => $options['region'],
92+
'accessKeyId' => rawurldecode($params['user'] ?? '') ?: $options['access_key'] ?? self::DEFAULT_OPTIONS['access_key'],
93+
'accessKeySecret' => rawurldecode($params['pass'] ?? '') ?: $options['secret_key'] ?? self::DEFAULT_OPTIONS['secret_key'],
94+
];
95+
if (null !== $options['session_token']) {
96+
$clientConfiguration['sessionToken'] = $options['session_token'];
97+
}
98+
if (isset($options['debug'])) {
99+
$clientConfiguration['debug'] = $options['debug'];
100+
}
101+
unset($query['region']);
102+
103+
if ('default' !== ($params['host'] ?? 'default')) {
104+
$clientConfiguration['endpoint'] = \sprintf('%s://%s%s', ($options['sslmode'] ?? null) === 'disable' ? 'http' : 'https', $params['host'], ($params['port'] ?? null) ? ':'.$params['port'] : '');
105+
if (preg_match(';^dynamodb\.([^\.]++)\.amazonaws\.com$;', $params['host'], $matches)) {
106+
$clientConfiguration['region'] = $matches[1];
107+
}
108+
} elseif (self::DEFAULT_OPTIONS['endpoint'] !== $options['endpoint'] ?? self::DEFAULT_OPTIONS['endpoint']) {
109+
$clientConfiguration['endpoint'] = $options['endpoint'];
110+
}
111+
112+
$parsedPath = explode('/', ltrim($params['path'] ?? '/', '/'));
113+
if ($tableName = end($parsedPath)) {
114+
$options['table_name'] = $tableName;
115+
}
116+
117+
$this->client = new DynamoDbClient($clientConfiguration);
118+
}
119+
120+
$this->tableName = $options['table_name'];
121+
$this->idAttr = $options['id_attr'];
122+
$this->tokenAttr = $options['token_attr'];
123+
$this->expirationAttr = $options['expiration_attr'];
124+
$this->readCapacityUnits = $options['read_capacity_units'];
125+
$this->writeCapacityUnits = $options['write_capacity_units'];
126+
}
127+
128+
public function save(Key $key): void
129+
{
130+
$key->reduceLifetime($this->initialTtl);
131+
132+
try {
133+
$this->client->putItem(new PutItemInput([
134+
'TableName' => $this->tableName,
135+
'Item' => [
136+
$this->idAttr => new AttributeValue(['S' => $this->getHashedKey($key)]),
137+
$this->tokenAttr => new AttributeValue(['S' => $this->getUniqueToken($key)]),
138+
$this->expirationAttr => new AttributeValue(['N' => (string) (\microtime() + $this->initialTtl)]),
139+
],
140+
'ConditionExpression' => 'attribute_not_exists(#key) OR #expires_at < :now',
141+
'ExpressionAttributeNames' => [
142+
'#key' => $this->idAttr,
143+
'#expires_at' => $this->expirationAttr,
144+
],
145+
'ExpressionAttributeValues' => [
146+
':now' => new AttributeValue(['N' => (string) \microtime()]),
147+
],
148+
]));
149+
} catch (ConditionalCheckFailedException) {
150+
// the lock is already acquired. It could be us. Let's try to put off.
151+
$this->putOffExpiration($key, $this->initialTtl);
152+
} catch (\Throwable $throwable) {
153+
throw new LockAcquiringException('Failed to acquire lock', 0, $throwable);
154+
}
155+
156+
$this->checkNotExpired($key);
157+
}
158+
159+
public function delete(Key $key): void
160+
{
161+
$this->client->deleteItem(new DeleteItemInput([
162+
'TableName' => $this->tableName,
163+
'Key' => [
164+
$this->idAttr => new AttributeValue(['S' => $this->getHashedKey($key)]),
165+
],
166+
]));
167+
}
168+
169+
public function exists(Key $key): bool
170+
{
171+
$existingLock = $this->client->getItem(new GetItemInput([
172+
'TableName' => $this->tableName,
173+
'ConsistentRead' => true,
174+
'Key' => [
175+
$this->idAttr => new AttributeValue(['S' => $this->getHashedKey($key)]),
176+
],
177+
]));
178+
179+
$item = $existingLock->getItem();
180+
181+
// Item not found at all
182+
if ($item === []) {
183+
return false;
184+
}
185+
186+
// We are not the owner
187+
if (isset($item[$this->tokenAttr]) === false || $this->getUniqueToken($key) !== $item[$this->tokenAttr]->getS()) {
188+
return false;
189+
}
190+
191+
// If item is expired, consider it doesn't exist
192+
return isset($item[$this->expirationAttr]) && \microtime() >= $item[$this->expirationAttr]->getN();
193+
}
194+
195+
public function putOffExpiration(Key $key, float $ttl): void
196+
{
197+
if ($ttl < 1) {
198+
throw new InvalidTtlException(\sprintf('"%s()" expects a TTL greater or equals to 1 second. Got "%s".', __METHOD__, $ttl));
199+
}
200+
201+
$key->reduceLifetime($ttl);
202+
203+
$uniqueToken = $this->getUniqueToken($key);
204+
205+
try {
206+
$this->client->putItem(new PutItemInput([
207+
'TableName' => $this->tableName,
208+
'Item' => [
209+
$this->idAttr => new AttributeValue(['S' => $this->getHashedKey($key)]),
210+
$this->tokenAttr => new AttributeValue(['S' => $uniqueToken]),
211+
$this->expirationAttr => new AttributeValue(['N' => (string) (\microtime() + $ttl)]),
212+
],
213+
'ConditionExpression' => 'attribute_exists(#key) AND (#token = :token OR #expires_at <= :now)',
214+
'ExpressionAttributeNames' => [
215+
'#key' => $this->idAttr,
216+
'#expires_at' => $this->expirationAttr,
217+
'#token' => $this->tokenAttr,
218+
],
219+
'ExpressionAttributeValues' => [
220+
':now' => new AttributeValue(['N' => (string) \microtime()]),
221+
':token' => $uniqueToken,
222+
],
223+
]));
224+
} catch (ConditionalCheckFailedException) {
225+
// The item doesn't exist or was acquired by someone else
226+
throw new LockConflictedException();
227+
} catch (\Throwable $throwable) {
228+
throw new LockAcquiringException('Failed to acquire lock', 0, $throwable);
229+
}
230+
231+
$this->checkNotExpired($key);
232+
}
233+
234+
public function createTable(): void
235+
{
236+
$this->client->createTable(new CreateTableInput([
237+
'TableName' => $this->tableName,
238+
'AttributeDefinitions' => [
239+
new AttributeDefinition(['AttributeName' => $this->idAttr, 'AttributeType' => 'S']),
240+
new AttributeDefinition(['AttributeName' => $this->tokenAttr, 'AttributeType' => 'S']),
241+
new AttributeDefinition(['AttributeName' => $this->expirationAttr, 'AttributeType' => 'N']),
242+
],
243+
'KeySchema' => [
244+
new KeySchemaElement(['AttributeName' => $this->idAttr, 'KeyType' => 'HASH']),
245+
],
246+
'ProvisionedThroughput' => new ProvisionedThroughput([
247+
'ReadCapacityUnits' => $this->readCapacityUnits,
248+
'WriteCapacityUnits' => $this->writeCapacityUnits,
249+
]),
250+
]));
251+
252+
$this->client->tableExists(new DescribeTableInput(['TableName' => $this->tableName]))->wait();
253+
}
254+
255+
private function getHashedKey(Key $key): string
256+
{
257+
return hash('sha256', (string) $key);
258+
}
259+
260+
private function getUniqueToken(Key $key): string
261+
{
262+
if (!$key->hasState(__CLASS__)) {
263+
$token = base64_encode(random_bytes(32));
264+
$key->setState(__CLASS__, $token);
265+
}
266+
267+
return $key->getState(__CLASS__);
268+
}
269+
}

‎src/Symfony/Component/Lock/Store/StoreFactory.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Lock/Store/StoreFactory.php
+7Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Lock\Store;
1313

14+
use AsyncAws\DynamoDb\DynamoDbClient;
1415
use Doctrine\DBAL\Connection;
1516
use Relay\Relay;
1617
use Symfony\Component\Cache\Adapter\AbstractAdapter;
@@ -27,6 +28,9 @@ class StoreFactory
2728
public static function createStore(#[\SensitiveParameter] object|string $connection): PersistingStoreInterface
2829
{
2930
switch (true) {
31+
case $connection instanceof DynamoDbClient:
32+
return new DynamoDbStore($connection);
33+
3034
case $connection instanceof \Redis:
3135
case $connection instanceof Relay:
3236
case $connection instanceof \RedisArray:
@@ -60,6 +64,9 @@ public static function createStore(#[\SensitiveParameter] object|string $connect
6064
case 'semaphore' === $connection:
6165
return new SemaphoreStore();
6266

67+
case str_starts_with($connection, 'dynamodb://'):
68+
return new DynamoDbStore($connection);
69+
6370
case str_starts_with($connection, 'redis:'):
6471
case str_starts_with($connection, 'rediss:'):
6572
case str_starts_with($connection, 'valkey:'):
+14Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Symfony\Component\Lock\Tests\Store;
5+
6+
use Symfony\Component\Lock\PersistingStoreInterface;
7+
8+
class DynamoDbStoreTest extends AbstractStoreTestCase
9+
{
10+
protected function getStore(): PersistingStoreInterface
11+
{
12+
// What should we do here...? :)
13+
}
14+
}

‎src/Symfony/Component/Lock/Tests/Store/StoreFactoryTest.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Lock/Tests/Store/StoreFactoryTest.php
+5Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111

1212
namespace Symfony\Component\Lock\Tests\Store;
1313

14+
use AsyncAws\DynamoDb\DynamoDbClient;
1415
use Doctrine\DBAL\Connection;
1516
use PHPUnit\Framework\TestCase;
1617
use Symfony\Component\Cache\Adapter\AbstractAdapter;
1718
use Symfony\Component\Cache\Adapter\MemcachedAdapter;
1819
use Symfony\Component\Lock\Store\DoctrineDbalPostgreSqlStore;
1920
use Symfony\Component\Lock\Store\DoctrineDbalStore;
21+
use Symfony\Component\Lock\Store\DynamoDbStore;
2022
use Symfony\Component\Lock\Store\FlockStore;
2123
use Symfony\Component\Lock\Store\InMemoryStore;
2224
use Symfony\Component\Lock\Store\MemcachedStore;
@@ -88,6 +90,9 @@ public static function validConnections(): \Generator
8890
yield ['postgres+advisory://server.com/test', DoctrineDbalPostgreSqlStore::class];
8991
yield ['postgresql+advisory://server.com/test', DoctrineDbalPostgreSqlStore::class];
9092
}
93+
if (class_exists(DynamoDbClient::class)) {
94+
yield ['dynamodb://default', DynamoDbStore::class];
95+
}
9196

9297
yield ['in-memory', InMemoryStore::class];
9398

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.