Skip to content

Commit bafbb9e

Browse files
committed
feature #60138 [Lock] DynamoDB store (natepage)
This PR was squashed before being merged into the 7.4 branch. Discussion ---------- [Lock] DynamoDB store | Q | A | ------------- | --- | Branch? | 7.3 | Bug fix? | no | New feature? | yes <!-- please update src/**/CHANGELOG.md files --> | Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files --> | Issues | Fix #59996 <!-- prefix each issue number with "Fix #", no need to create an issue if none exists, explain below instead --> | License | MIT This PR introduces a new Lock store using AWS DynamoDb. The idea is coming from this [article](https://aws.amazon.com/blogs/database/building-distributed-locks-with-the-dynamodb-lock-client/). The implementation is heavily based on: - [DoctrineDbalStore](https://github.com/symfony/symfony/blob/7.3/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php) for the overall logic - [AmazonSqsMessenger Connection](https://github.com/symfony/amazon-sqs-messenger/blob/7.3/Transport/Connection.php) for the DSN/options handling Commits ------- 12566281f49 [Lock] DynamoDB store
2 parents 0c5f670 + 3f678da commit bafbb9e

31 files changed

+691
-1
lines changed

Bridge/DynamoDb/.gitattributes

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/Tests export-ignore
2+
/phpunit.xml.dist export-ignore
3+
/.git* export-ignore
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Please do not submit any Pull Requests here. They will be closed.
2+
---
3+
4+
Please submit your PR here instead:
5+
https://github.com/symfony/symfony
6+
7+
This repository is what we call a "subtree split": a read-only subset of that main repository.
8+
We're looking forward to your PR there!
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: Close Pull Request
2+
3+
on:
4+
pull_request_target:
5+
types: [opened]
6+
7+
jobs:
8+
run:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: superbrothers/close-pull-request@v3
12+
with:
13+
comment: |
14+
Thanks for your Pull Request! We love contributions.
15+
16+
However, you should instead open your PR on the main repository:
17+
https://github.com/symfony/symfony
18+
19+
This repository is what we call a "subtree split": a read-only subset of that main repository.
20+
We're looking forward to your PR there!

Bridge/DynamoDb/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
vendor/
2+
composer.lock
3+
phpunit.xml

Bridge/DynamoDb/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CHANGELOG
2+
=========
3+
4+
7.4
5+
---
6+
7+
* Add the bridge

Bridge/DynamoDb/LICENSE

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Copyright (c) 2025-present Fabien Potencier
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a copy
4+
of this software and associated documentation files (the "Software"), to deal
5+
in the Software without restriction, including without limitation the rights
6+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
copies of the Software, and to permit persons to whom the Software is furnished
8+
to do so, subject to the following conditions:
9+
10+
The above copyright notice and this permission notice shall be included in all
11+
copies or substantial portions of the Software.
12+
13+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19+
THE SOFTWARE.

Bridge/DynamoDb/README.md

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

0 commit comments

Comments
 (0)