8000 Split Lock PdoStore into distinct DBAL class · symfony/symfony@70d9543 · GitHub
[go: up one dir, main page]

Skip to content

Commit 70d9543

Browse files
committed
Split Lock PdoStore into distinct DBAL class
1 parent 055b38f commit 70d9543

File tree

3 files changed

+363
-7
lines changed

3 files changed

+363
-7
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
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\Store;
13+
14+
use Doctrine\DBAL\Connection;
15+
use Doctrine\DBAL\Exception as DBALException;
16+
use Doctrine\DBAL\Schema\Schema;
17+
use Symfony\Component\Lock\Exception\InvalidArgumentException;
18+
use Symfony\Component\Lock\Exception\InvalidTtlException;
19+
use Symfony\Component\Lock\Exception\LockConflictedException;
20+
use Symfony\Component\Lock\Exception\NotSupportedException;
21+
use Symfony\Component\Lock\Key;
22+
use Symfony\Component\Lock\PersistingStoreInterface;
23+
24+
/**
25+
* DbalStore is a PersistingStoreInterface implementation using a Doctrine DBAL connection.
26+
*
27+
* Lock metadata are stored in a table. You can use createTable() to initialize
28+
* a correctly defined table.
29+
30+
* CAUTION: This store relies on all client and server nodes to have
31+
* synchronized clocks for lock expiry to occur at the correct time.
32+
* To ensure locks don't expire prematurely; the TTLs should be set with enough
33+
* extra time to account for any clock drift between nodes.
34+
*
35+
* @author Jérémy Derussé <jeremy@derusse.com>
36+
*/
37+
class DbalStore implements PersistingStoreInterface
38+
{
39+
use ExpiringStoreTrait;
40+
41+
private $conn;
42+
private $dsn;
43+
private $driver;
44+
private $table = 'lock_keys';
45+
private $idCol = 'key_id';
46+
private $tokenCol = 'key_token';
47+
private $expirationCol = 'key_expiration';
48+
private $username = '';
49+
private $password = '';
50+
private $gcProbability;
51+
private $initialTtl;
52+
53+
/**
54+
* You can either pass an existing database connection as PDO instance or
55+
* a Doctrine DBAL Connection or a DSN string that will be used to
56+
* lazy-connect to the database when the lock is actually used.
57+
*
58+
* List of available options:
59+
* * db_table: The name of the table [default: lock_keys]
60+
* * db_id_col: The column where to store the lock key [default: key_id]
61+
* * db_token_col: The column where to store the lock token [default: key_token]
62+
* * db_expiration_col: The column where to store the expiration [default: key_expiration]
63+
*
64+
* @param Connection $conn A DBAL Connection instance
65+
* @param array $options An associative array of options
66+
* @param float $gcProbability Probability expressed as floating number between 0 and 1 to clean old locks
67+
* @param int $initialTtl The expiration delay of locks in seconds
68+
*
69+
* @throws InvalidArgumentException When namespace contains invalid characters
70+
* @throws InvalidArgumentException When the initial ttl is not valid
71+
*/
72+
public function __construct(Connection $conn, array $options = [], float $gcProbability = 0.01, int $initialTtl = 300)
73+
{
74+
if ($gcProbability < 0 || $gcProbability > 1) {
75+
throw new InvalidArgumentException(sprintf('"%s" requires gcProbability between 0 and 1, "%f" given.', __METHOD__, $gcProbability));
76+
}
77+
if ($initialTtl < 1) {
78+
throw new InvalidTtlException(sprintf('"%s()" expects a strictly positive TTL, "%d" given.', __METHOD__, $initialTtl));
79+
}
80+
81+
$this->conn = $conn;
82+
83+
$this->table = $options['db_table'] ?? $this->table;
84+
$this->idCol = $options['db_id_col'] ?? $this->idCol;
85+
$this->tokenCol = $options['db_token_col'] ?? $this->tokenCol;
86+
$this->expirationCol = $options['db_expiration_col'] ?? $this->expirationCol;
87+
88+
$this->gcProbability = $gcProbability;
89+
$this->initialTtl = $initialTtl;
90+
}
91+
92+
/**
93+
* {@inheritdoc}
94+
*/
95+
public function save(Key $key)
96+
{
97+
$key->reduceLifetime($this->initialTtl);
98+
99+
$sql = "INSERT INTO $this->table ($this->idCol, $this->tokenCol, $this->expirationCol) VALUES (:id, :token, {$this->getCurrentTimestampStatement()} + $this->initialTtl)";
100+
$stmt = $this->getConnection()->prepare($sql);
101+
102+
$stmt->bindValue(':id', $this->getHashedKey($key));
103+
$stmt->bindValue(':token', $this->getUniqueToken($key));
104+
105+
try {
106+
$stmt->executeStatement();
107+
} catch (DBALException $e) {
108+
// the lock is already acquired. It could be us. Let's try to put off.
109+
$this->putOffExpiration($key, $this->initialTtl);
110+
}
111+
112+
if ($this->gcProbability > 0 && (1.0 === $this->gcProbability || (random_int(0, \PHP_INT_MAX) / \PHP_INT_MAX) <= $this->gcProbability)) {
113+
$this->prune();
114+
}
115+
116+
$this->checkNotExpired($key);
117+
}
118+
119+
/**
120+
* {@inheritdoc}
121+
*/
122+
public function putOffExpiration(Key $key, $ttl)
123+
{
124+
if ($ttl < 1) {
125+
throw new InvalidTtlException(sprintf('"%s()" expects a TTL greater or equals to 1 second. Got %s.', __METHOD__, $ttl));
126+
}
127+
128+
$key->reduceLifetime($ttl);
129+
130+
$sql = "UPDATE $this->table SET $this->expirationCol = {$this->getCurrentTimestampStatement()} + $ttl, $this->tokenCol = :token1 WHERE $this->idCol = :id AND ($this->tokenCol = :token2 OR $this->expirationCol <= {$this->getCurrentTimestampStatement()})";
131+
$stmt = $this->getConnection()->prepare($sql);
132+
133+
$uniqueToken = $this->getUniqueToken($key);
134+
$stmt->bindValue(':id', $this->getHashedKey($key));
135+
$stmt->bindValue(':token1', $uniqueToken);
136+
$stmt->bindValue(':token2', $uniqueToken);
137+
$result = $stmt->executeQuery();
138+
139+
// If this method is called twice in the same second, the row wouldn't be updated. We have to call exists to know if we are the owner
140+
if (!$result->rowCount() && !$this->exists($key)) {
141+
throw new LockConflictedException();
142+
}
143+
144+
$this->checkNotExpired($key);
145+
}
146+
147+
/**
148+
* {@inheritdoc}
149+
*/
150+
public function delete(Key $key)
151+
{
152+
$sql = "DELETE FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token";
153+
$stmt = $this->getConnection()->prepare($sql);
154+
155+
$stmt->bindValue(':id', $this->getHashedKey($key));
156+
$stmt->bindValue(':token', $this->getUniqueToken($key));
157+
$stmt->executeStatement();
158+
}
159+
160+
/**
161+
* {@inheritdoc}
162+
*/
163+
public function exists(Key $key)
164+
{
165+
$sql = "SELECT 1 FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token AND $this->expirationCol > {$this->getCurrentTimestampStatement()}";
166+
$stmt = $this->getConnection()->prepare($sql);
167+
168+
$stmt->bindValue(':id', $this->getHashedKey($key));
169+
$stmt->bindValue(':token', $this->getUniqueToken($key));
170+
$result = $stmt->executeQuery();
171+
172+
return (bool) $result->fetchOne();
173+
}
174+
175+
/**
176+
* Returns a hashed version of the key.
177+
*/
178+
private function getHashedKey(Key $key): string
179+
{
180+
return hash('sha256', (string) $key);
181+
}
182+
183+
private function getUniqueToken(Key $key): string
184+
{
185+
if (!$key->hasState(__CLASS__)) {
186+
$token = base64_encode(random_bytes(32));
187+
$key->setState(__CLASS__, $token);
188+
}
189+
190+
return $key->getState(__CLASS__);
191+
}
192+
193+
/**
194+
* @return Connection
195+
*/
196+
private function getConnection()
197+
{
198+
return $this->conn;
199+
}
200+
201+
/**
202+
* Creates the table to store lock keys which can be called once for setup.
203+
*
204+
* @throws DBALException When the table already exists
205+
*/
206+
public function createTable(): void
207+
{
208+
$conn = $this->getConnection();
209+
210+
$schema = new Schema();
211+
$table = $schema->createTable($this->table);
212+
$table->addColumn($this->idCol, 'string', ['length' => 64]);
213+
$table->addColumn($this->tokenCol, 'string', ['length' => 44]);
214+
$table->addColumn($this->expirationCol, 'integer', ['unsigned' => true]);
215+
$table->setPrimaryKey([$this->idCol]);
216+
217+
foreach ($schema->toSql($conn->getDatabasePlatform()) as $sql) {
218+
$conn->executeStatement($sql);
219+
}
220+
}
221+
222+
/**
223+
* Cleans up the table by removing all expired locks.
224+
*/
225+
private function prune(): void
226+
{
227+
$sql = "DELETE FROM $this->table WHERE $this->expirationCol <= {$this->getCurrentTimestampStatement()}";
228+
229+
$conn = $this->getConnection();
230+
$conn->executeStatement($sql);
231+
}
232+
233+
private function getDriver(): string
234+
{
235+
if (null !== $this->driver) {
236+
return $this->driver;
237+
}
238+
239+
$driver = $this->getConnection()->getDriver();
240+
241+
switch (true) {
242+
case $driver instanceof \Doctrine\DBAL\Driver\Mysqli\Driver:
243+
throw new \LogicException(sprintf('The adapter "%s" does not support the mysqli driver, use pdo_mysql instead.', static::class));
244+
case $driver instanceof \Doctrine\DBAL\Driver\AbstractMySQLDriver:
245+
$this->driver = 'mysql';
246+
break;
247+
case $driver instanceof \Doctrine\DBAL\Driver\PDOSqlite\Driver:
248+
case $driver instanceof \Doctrine\DBAL\Driver\PDO\SQLite\Driver:
249+
$this->driver = 'sqlite';
250+
break;
251+
case $driver instanceof \Doctrine\DBAL\Driver\PDOPgSql\Driver:
252+
case $driver instanceof \Doctrine\DBAL\Driver\PDO\PgSQL\Driver:
253+
$this->driver = 'pgsql';
254+
break;
255+
case $driver instanceof \Doctrine\DBAL\Driver\OCI8\Driver:
256+
case $driver instanceof \Doctrine\DBAL\Driver\PDOOracle\Driver:
257+
case $driver instanceof \Doctrine\DBAL\Driver\PDO\OCI\Driver:
258+
$this->driver = 'oci';
259+
break;
260+
case $driver instanceof \Doctrine\DBAL\Driver\SQLSrv\Driver:
261+
case $driver instanceof \Doctrine\DBAL\Driver\PDOSqlsrv\Driver:
262+
case $driver instanceof \Doctrine\DBAL\Driver\PDO\SQLSrv\Driver:
263+
$this->driver = 'sqlsrv';
264+
break;
265+
default:
266+
$this->driver = \get_class($driver);
267+
break;
268+
}
269+
270+
return $this->driver;
271+
}
272+
273+
/**
274+
* Provides an SQL function to get the current timestamp regarding the current connection's driver.
275+
*/
276+
private function getCurrentTimestampStatement(): string
277+
{
278+
switch ($this->getDriver()) {
279+
case 'mysql':
280+
return 'UNIX_TIMESTAMP()';
281+
case 'sqlite':
282+
return 'strftime(\'%s\',\'now\')';
283+
case 'pgsql':
284+
return 'CAST(EXTRACT(epoch FROM NOW()) AS INT)';
285+
case 'oci':
286+
return '(SYSDATE - TO_DATE(\'19700101\',\'yyyymmdd\'))*86400 - TO_NUMBER(SUBSTR(TZ_OFFSET(sessiontimezone), 1, 3))*3600';
287+
case 'sqlsrv':
288+
return 'DATEDIFF(s, \'1970-01-01\', GETUTCDATE())';
289+
default:
290+
return time();
291+
}
292+
}
293+
}

src/Symfony/Component/Lock/Store/PdoStore.php

+8-7
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ class PdoStore implements PersistingStoreInterface
5353
private $initialTtl;
5454

5555
/**
56-
* You can either pass an existing database connection as PDO instance or
57-
* a Doctrine DBAL Connection or a DSN string that will be used to
58-
* lazy-connect to the database when the lock is actually used.
56+
* You can either pass an existing database connection as PDO instance
57+
* or a DSN string that will be used to lazy-connect to the database
58+
* when the lock is actually used.
5959
*
6060
* List of available options:
6161
* * db_table: The name of the table [default: lock_keys]
@@ -66,10 +66,10 @@ class PdoStore implements PersistingStoreInterface
6666
* * db_password: The password when lazy-connect [default: '']
6767
* * db_connection_options: An array of driver-specific connection options [default: []]
6868
*
69-
* @param \PDO|Connection|string $connOrDsn A \PDO or Connection instance or DSN string or null
70-
* @param array $options An associative array of options
71-
* @param float $gcProbability Probability expressed as floating number between 0 and 1 to clean old locks
72-
* @param int $initialTtl The expiration delay of locks in seconds
69+
* @param \PDO|string $connOrDsn A \PDO or Connection instance or DSN string or null
70+
* @param array $options An associative array of options
71+
* @param float $gcProbability Probability expressed as floating number between 0 and 1 to clean old locks
72+
* @param int $initialTtl The expiration delay of locks in seconds
7373
*
7474
* @throws InvalidArgumentException When first argument is not PDO nor Connection nor string
7575
* @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION
@@ -91,6 +91,7 @@ public function __construct($connOrDsn, array $options = [], float $gcProbabilit
9191

9292
$this->conn = $connOrDsn;
9393
} elseif ($connOrDsn instanceof Connection) {
94+
trigger_deprecation('symfony/lock', '5.4', 'Usage of a DBAL Connection with "%s" is deprecated and will be removed in symfony 6.0. Use "%s" instead.', __CLASS__, DbalStore::class);
9495
$this->conn = $connOrDsn;
9596
} elseif (\is_string($connOrDsn)) {
9697
$this->dsn = $connOrDsn;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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\Tests\Store;
13+
14+
use Doctrine\DBAL\DriverManager;
15+
use Symfony\Component\Lock\PersistingStoreInterface;
16+
use Symfony\Component\Lock\Store\DbalStore;
17+
18+
/**
19+
* @author Jérémy Derussé <jeremy@derusse.com>
20+
*
21+
* @requires extension pdo_sqlite
22+
*/
23+
class PdoDbalStoreTest extends AbstractStoreTest
24+
{
25+
use ExpiringStoreTestTrait;
26+
27+
protected static $dbFile;
28+
29+
public static function setUpBeforeClass(): void
30+
{
31+
self::$dbFile = tempnam(sys_get_temp_dir(), 'sf_sqlite_lock');
32+
33+
$store = new DbalStore(DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile]));
34+
$store->createTable();
35+
}
36+
37+
public static function tearDownAfterClass(): void
38+
{
39+
@unlink(self::$dbFile);
40+
}
41+
42+
/**
43+
* {@inheritdoc}
44+
*/
45+
protected function getClockDelay()
46+
{
47+
return 1000000;
48+
}
49+
50+
/**
51+
* {@inheritdoc}
52+
*/
53+
public function getStore(): PersistingStoreInterface
54+
{
55+
return new DbalStore(DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => self::$dbFile]));
56+
}
57+
58+
public function testAbortAfterExpiration()
59+
{
60+
$this->markTestSkipped('Pdo expects a TTL greater than 1 sec. Simulating a slow network is too hard');
61+
}
62+
}

0 commit comments

Comments
 (0)
0