8000 Add a PdoStore in lock · symfony/symfony@5460018 · GitHub
[go: up one dir, main page]

Skip to content

Commit 5460018

Browse files
committed
Add a PdoStore in lock
1 parent c81f88f commit 5460018

File tree

6 files changed

+489
-2
lines changed

6 files changed

+489
-2
lines changed

src/Symfony/Component/Lock/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
4.2.0
5+
-----
6+
7+
* added the Pdo Store
8+
49
3.4.0
510
-----
611

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
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\DBALException;
16+
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
17+
use Doctrine\DBAL\Schema\Schema;
18+
use Symfony\Component\Lock\Exception\InvalidArgumentException;
19+
use Symfony\Component\Lock\Exception\LockConflictedException;
20+
use Symfony\Component\Lock\Exception\LockExpiredException;
21+
use Symfony\Component\Lock\Exception\NotSupportedException;
22+
use Symfony\Component\Lock\Key;
23+
use Symfony\Component\Lock\StoreInterface;
24+
25+
/**
26+
* PdoStore is a StoreInterface implementation using a PDO connection.
27+
*
28+
* Lock metadata are stored in a table. You can use createTable() to initialize
29+
* a correctly defined table.
30+
31+
* CAUTION: This store relies on all client and server nodes to have
32+
* synchronized clocks for lock expiry to occur at the correct time.
33+
* To ensure locks don't expire prematurely; the ttl's should be set with enough
34+
* extra time to account for any clock drift between nodes.
35+
*
36+
* @author Jérémy Derussé <jeremy@derusse.com>
37+
*/
38+
class PdoStore implements StoreInterface
39+
{
40+
private $conn;
41+
private $dsn;
42+
private $driver;
43+
private $table = 'lock_keys';
44+
private $idCol = 'key_id';
45+
private $tokenCol = 'key_token';
46+
private $expirationCol = 'key_expiration';
47+
private $username = '';
48+
private $password = '';
49+
private $connectionOptions = array();
50+
51+
private $gcProbability;
52+
private $initialTtl;
53+
private $clockDrift;
54+
55+
/**
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.
59+
*
60+
* List of available options:
61+
* * db_table: The name of the table [default: lock_keys]
62+
* * db_id_col: The column where to store the lock key [default: key_id]
63+
* * db_token_col: The column where to store the lock token [default: key_token]
64+
* * db_expiration_col: The column where to store the expiration [default: key_expiration]
65+
* * db_username: The username when lazy-connect [default: '']
66+
* * db_password: The password when lazy-connect [default: '']
67+
* * db_connection_options: An array of driver-specific connection options [default: array()]
68+
*
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 $clockDrift Extra time (in seconds) to account for any clock drift between nodes
72+
* @param float $gcProbability Probability expressed as floating number between 0 and 1 to clean old locks
73+
* @param int $initialTtl The expiration delay of locks in seconds
74+
*
75+
* @throws InvalidArgumentException When first argument is not PDO nor Connection nor string
76+
* @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION
77+
* @throws InvalidArgumentException When namespace contains invalid characters
78+
*/
79+
public function __construct($connOrDsn, array $options = array(), float $clockDrift = 0, float $gcProbability = 0.01, int $initialTtl = 300)
80+
{
81+
if ($clockDrift < 0) {
82+
throw new InvalidArgumentException(sprintf('"%s" expects a positive clockDrift, "%f" given.', __CLASS__, $clockDrift));
83+
}
84+
if ($gcProbability < 0 || $gcProbability > 1) {
85+
throw new InvalidArgumentException(sprintf('"%s" requires gcProbability between 0 and 1, "%f" given.', __CLASS__, $gcProbability));
86+
}
87+
if ($initialTtl < 1) {
88+
throw new InvalidArgumentException(sprintf('%s() expects a strictly positive TTL. Got %d.', __METHOD__, $initialTtl));
89+
}
90+
91+
if ($connOrDsn instanceof \PDO) {
92+
if (\PDO::ERRMODE_EXCEPTION !== $connOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) {
93+
throw new InvalidArgumentException(sprintf('"%s" requires PDO error mode attribute be set to throw Exceptions (i.e. $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION))', __CLASS__));
94+
}
95+
96+
$this->conn = $connOrDsn;
97+
} elseif ($connOrDsn instanceof Connection) {
98+
$this->conn = $connOrDsn;
99+
} elseif (is_string($connOrDsn)) {
100+
$this->dsn = $connOrDsn;
101+
} else {
102+
throw new InvalidArgumentException(sprintf('"%s" requires PDO or Doctrine\DBAL\Connection instance or DSN string as first argument, "%s" given.', __CLASS__, is_object($connOrDsn) ? get_class($connOrDsn) : gettype($connOrDsn)));
103+
}
104+
105+
$this->table = $options['db_table'] ?? $this->table;
106+
$this->idCol = $options['db_id_col'] ?? $this->idCol;
107+
$this->tokenCol = $options['db_token_col'] ?? $this->tokenCol;
108+
$this->expirationCol = $options['db_expiration_col'] ?? $this->expirationCol;
109+
$this->username = $options['db_username'] ?? $this->username;
110+
$this->password = $options['db_password'] ?? $this->password;
111+
$this->connectionOptions = $options['db_connection_options'] ?? $this->connectionOptions;
112+
113+
$this->clockDrift = $clockDrift;
114+
$this->gcProbability = $gcProbability;
115+
$this->initialTtl = $initialTtl;
116+
}
117+
118+
/**
119+
* {@inheritdoc}
120+
*/
121+
public function save(Key $key)
122+
{
123+
$key->reduceLifetime($this->initialTtl);
124+
125+
$sql = "INSERT INTO $this->table ($this->idCol, $this->tokenCol, $this->expirationCol) VALUES (:id, :token, :expiration)";
126+
$stmt = $this->getConnection()->prepare($sql);
127+
128+
$stmt->bindValue(':id', $this->getHashedKey($key));
129+
$stmt->bindValue(':token', $this->getToken($key));
130+
$stmt->bindValue(':expiration', time() + $this->clockDrift + $this->initialTtl, \PDO::PARAM_INT);
131+
132+
try {
133+
$stmt->execute();
134+
if ($key->isExpired()) {
135+
throw new LockExpiredException(sprintf('Failed to put off the expiration of the "%s" lock within the specified time.', $key));
136+
}
137+
138+
return;
139+
} c FEE1 atch (UniqueConstraintViolationException $e) {
140+
// the lock is already acquired. It could be us. Let's try to put off.
141+
$this->putOffExpiration($key, $this->initialTtl);
142+
} catch (\PDOException $e) {
143+
// the lock is already acquired. It could be us. Let's try to put off.
144+
$this->putOffExpiration($key, $this->initialTtl);
145+
}
146+
147+
if ($key->isExpired()) {
148+
throw new LockExpiredException(sprintf('Failed to store the "%s" lock.', $key));
149+
}
150+
151+
if ($this->gcProbability > 0 && (1.0 === $this->gcProbability || (random_int(0, PHP_INT_MAX) / PHP_INT_MAX) <= $this->gcProbability)) {
152+
$this->prune();
153+
}
154+
}
155+
156+
/**
157+
* {@inheritdoc}
158+
*/
159+
public function waitAndSave(Key $key)
160+
{
161+
throw new NotSupportedException(sprintf('The store "%s" does not supports blocking locks.', __CLASS__));
162+
}
163+
164+
/**
165+
* {@inheritdoc}
166+
*/
167+
public function putOffExpiration(Key $key, $ttl)
168+
{
169+
if ($ttl < 1) {
170+
throw new InvalidArgumentException(sprintf('%s() expects a TTL greater or equals to 1. Got %s.', __METHOD__, $ttl));
171+
}
172+
173+
$key->reduceLifetime($ttl);
174+
175+
$sql = "UPDATE $this->table SET $this->expirationCol = :expiration, $this->tokenCol = :token WHERE $this->idCol = :id AND ($this->tokenCol = :token OR $this->expirationCol <= :now)";
176+
$stmt = $this->getConnection()->prepare($sql);
177+
178+
$stmt->bindValue(':id', $this->getHashedKey($key));
179+
$stmt->bindValue(':token', $this->getToken($key));
180+
$stmt->bindValue(':expiration', time() + $this->clockDrift + $ttl, \PDO::PARAM_INT);
181+
$stmt->bindValue(':now', time(), \PDO::PARAM_INT);
182+
$stmt->execute();
183+
184+
// If this method is called twice in the same second, the row wouldnt' be updated. We have to call exists to know if the we are the owner
185+
if (!$stmt->rowCount() && !$this->exists($key)) {
186+
throw new LockConflictedException();
187+
}
188+
189+
if ($key->isExpired()) {
190+
throw new LockExpiredException(sprintf('Failed to put off the expiration of the "%s" lock within the specified time.', $key));
191+
}
192+
}
193+
194+
/**
195+
* {@inheritdoc}
196+
*/
197+
public function delete(Key $key)
198+
{
199+
$sql = "DELETE FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token";
200+
$stmt = $this->getConnection()->prepare($sql);
201+
202+
$stmt->bindValue(':id', $this->getHashedKey($key));
203+
$stmt->bindValue(':token', $this->getToken($key));
204+
$stmt->execute();
205+
}
206+
207+
/**
208+
* {@inheritdoc}
209+
*/
210+
public function exists(Key $key)
211+
{
212+
$sql = "SELECT 1 FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token AND $this->expirationCol > :now";
213+
$stmt = $this->getConnection()->prepare($sql);
214+
215+
$stmt->bindValue(':id', $this->getHashedKey($key));
216+
$stmt->bindValue(':token', $this->getToken($key));
217+
$stmt->bindValue(':now', time(), \PDO::PARAM_INT);
218+
$stmt->execute();
219+
220+
return (bool) $stmt->fetchColumn();
221+
}
222+
223+
/**
224+
* Returns an hashed version of the key.
225+
*/
226+
private function getHashedKey(Key $key): string
227+
{
228+
return hash('sha256', $key);
229+
}
230+
231+
/**
232+
* Retrieve an unique token for the given key.
233+
*/
234+
private function getToken(Key $key): string
235+
{
236+
if (!$key->hasState(__CLASS__)) {
237+
$token = base64_encode(random_bytes(32));
238+
$key->setState(__CLASS__, $token);
239+
}
240+
241+
return $key->getState(__CLASS__);
242+
}
243+
244+
/**
245+
* @return \PDO|Connection
246+
*/
247+
private function getConnection()
248+
{
249+
if (null === $this->conn) {
250+
$this->conn = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions);
251+
$this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
252+
}
253+
if (null === $this->driver) {
254+
if ($this->conn instanceof \PDO) {
255+
$this->driver = $this->conn->getAttribute(\PDO::ATTR_DRIVER_NAME);
256+
} else {
257+
switch ($this->driver = $this->conn->getDriver()->getName()) {
258+
case 'mysqli':
259+
case 'pdo_mysql':
260+
case 'drizzle_pdo_mysql':
261+
$this->driver = 'mysql';
262+
break;
263+
case 'pdo_sqlite':
264+
$this->driver = 'sqlite';
265+
break;
266+
case 'pdo_pgsql':
267+
$this->driver = 'pgsql';
268+
break;
269+
case 'oci8':
270+
case 'pdo_oracle':
271+
$this->driver = 'oci';
272+
break;
273+
case 'pdo_sqlsrv':
274+
$this->driver = 'sqlsrv';
275+
break;
276+
}
277+
}
278+
}
279+
280+
return $this->conn;
281+
}
282+
283+
/**
284+
* Creates the table to store lock keys which can be called once for setup.
285+
*
286+
* @throws \PDOException When the table already exists
287+
* @throws DBALException When the table already exists
288+
* @throws \DomainException When an unsupported PDO driver is used
289+
*/
290+
public function createTable()
291+
{
292+
// connect if we are not yet
293+
$conn = $this->getConnection();
294+
295+
if ($conn instanceof Connection) {
296+
$types = array(
297+
'mysql' => 'binary',
298+
'sqlite' => 'text',
299+
'pgsql' => 'string',
300+
'oci' => 'string',
301+
'sqlsrv' => 'string',
302+
);
303+
if (!isset($types[$this->driver])) {
304+
throw new \DomainException(sprintf('Creating the lock table is currently not implemented for PDO driver "%s".', $this->driver));
305+
}
306+
307+
$schema = new Schema();
308+
$table = $schema->createTable($this->table);
309+
$table->addColumn($this->idCol, 'string', array('length' => 64));
310+
$table->addColumn($this->tokenCol, 'string', array('length' => 44));
311+
$table->addColumn($this->expirationCol, 'integer', array('unsigned' => true));
312+
$table->setPrimaryKey(array($this->idCol));
313+
314+
foreach ($schema->toSql($conn->getDatabasePlatform()) as $sql) {
315+
$conn->exec($sql);
316+
}
317+
318+
return;
319+
}
320+
321+
switch ($this->driver) {
322+
case 'mysql':
323+
$sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(44) NOT NULL, $this->expirationCol INTEGER UNSIGNED NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB";
324+
break;
325+
case 'sqlite':
326+
$sql = "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->tokenCol TEXT NOT NULL, $this->expirationCol INTEGER)";
327+
break;
328+
case 'pgsql':
329+
$sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(64) NOT NULL, $this->expirationCol INTEGER)";
330+
break;
331+
case 'oci':
332+
$sql = "CREATE TABLE $this->table ($this->idCol VARCHAR2(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR2(64) NOT NULL, $this->expirationCol INTEGER)";
333+
break;
334+
case 'sqlsrv':
335+
$sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(64) NOT NULL, $this->expirationCol INTEGER)";
336+
break;
337+
default:
338+
throw new \DomainException(sprintf('Creating the lock table is currently not implemented for PDO driver "%s".', $this->driver));
339+
}
340+
341+
$conn->exec($sql);
342+
}
343+
344+
/**
345+
* {@inheritdoc}
346+
*/
347+
private function prune()
348+
{
349+
$sql = "DELETE FROM $this->table WHERE $this->expirationCol <= :now";
350+
351+
$stmt = $this->getConnection()->prepare($sql);
352+
$stmt->bindValue(':now', time(), \PDO::PARAM_INT);
353+
354+
$stmt->execute();
355+
}
356+
}

0 commit comments

Comments
 (0)
0