Skip to content

Commit d341105

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

File tree

6 files changed

+511
-2
lines changed

6 files changed

+511
-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: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
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\Schema\Schema;
17+
use Symfony\Component\Lock\Exception\InvalidArgumentException;
18+
use Symfony\Component\Lock\Exception\LockConflictedException;
19+
use Symfony\Component\Lock\Exception\LockExpiredException;
20+
use Symfony\Component\Lock\Exception\NotSupportedException;
21+
use Symfony\Component\Lock\Key;
22+
use Symfony\Component\Lock\StoreInterface;
23+
24+
/**
25+
* PdoStore is a StoreInterface implementation using a PDO 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 ttl's 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 PdoStore implements StoreInterface
38+
{
39+
private $conn;
40+
private $dsn;
41+
private $driver;
42+
private $table = 'lock_keys';
43+
private $idCol = 'key_id';
44+
private $tokenCol = 'key_token';
45+
private $expirationCol = 'key_expiration';
46+
private $username = '';
47+
private $password = '';
48+
private $connectionOptions = array();
49+
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+
* * db_username: The username when lazy-connect [default: '']
64+
* * db_password: The password when lazy-connect [default: '']
65+
* * db_connection_options: An array of driver-specific connection options [default: array()]
66+
*
67+
* @param \PDO|Connection|string $connOrDsn A \PDO or Connection instance or DSN string or null
68+
* @param array $options An associative array of options
69+
* @param float $gcProbability Probability expressed as floating number between 0 and 1 to clean old locks
70+
* @param int $initialTtl The expiration delay of locks in seconds
71+
*
72+
* @throws InvalidArgumentException When first argument is not PDO nor Connection nor string
73+
* @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION
74+
* @throws InvalidArgumentException When namespace contains invalid characters
75+
*/
76+
public function __construct($connOrDsn, array $options = array(), float $gcProbability = 0.01, int $initialTtl = 300)
77+
{
78+
if ($gcProbability < 0 || $gcProbability > 1) {
79+
throw new InvalidArgumentException(sprintf('"%s" requires gcProbability between 0 and 1, "%f" given.', __CLASS__, $gcProbability));
80+
}
81+
if ($initialTtl < 1) {
82+
throw new InvalidArgumentException(sprintf('%s() expects a strictly positive TTL. Got %d.', __METHOD__, $initialTtl));
83+
}
84+
85+
if ($connOrDsn instanceof \PDO) {
86+
if (\PDO::ERRMODE_EXCEPTION !== $connOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) {
87+
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__));
88+
}
89+
90+
$this->conn = $connOrDsn;
91+
} elseif ($connOrDsn instanceof Connection) {
92+
$this->conn = $connOrDsn;
93+
} elseif (is_string($connOrDsn)) {
94+
$this->dsn = $connOrDsn;
95+
} else {
96+
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)));
97+
}
98+
99+
$this->table = $options['db_table'] ?? $this->table;
100+
$this->idCol = $options['db_id_col'] ?? $this->idCol;
101+
$this->tokenCol = $options['db_token_col'] ?? $this->tokenCol;
102+
$this->expirationCol = $options['db_expiration_col'] ?? $this->expirationCol;
103+
$this->username = $options['db_username'] ?? $this->username;
104+
$this->password = $options['db_password'] ?? $this->password;
105+
$this->connectionOptions = $options['db_connection_options'] ?? $this->connectionOptions;
106+
107+
$this->gcProbability = $gcProbability;
108+
$this->initialTtl = $initialTtl;
109+
}
110+
111+
/**
112+
* {@inheritdoc}
113+
*/
114+
public function save(Key $key)
115+
{
116+
$key->reduceLifetime($this->initialTtl);
117+
118+
$sql = "INSERT INTO $this->table ($this->idCol, $this->tokenCol, $this->expirationCol) VALUES (:id, :token, {$this->getCurrentTimestampStatment()} + $this->initialTtl)";
119+
$stmt = $this->getConnection()->prepare($sql);
120+
121+
$stmt->bindValue(':id', $this->getHashedKey($key));
122+
$stmt->bindValue(':token', $this->getToken($key));
123+
124+
try {
125+
$stmt->execute();
126+
if ($key->isExpired()) {
127+
throw new LockExpiredException(sprintf('Failed to put off the expiration of the "%s" lock within the specified time.', $key));
128+
}
129+
130+
return;
131+
} catch (DBALException $e) {
132+
// the lock is already acquired. It could be us. Let's try to put off.
133+
$this->putOffExpiration($key, $this->initialTtl);
134+
} catch (\PDOException $e) {
135+
// the lock is already acquired. It could be us. Let's try to put off.
136+
$this->putOffExpiration($key, $this->initialTtl);
137+
}
138+
139+
if ($key->isExpired()) {
140+
throw new LockExpiredException(sprintf('Failed to store the "%s" lock.', $key));
141+
}
142+
143+
if ($this->gcProbability > 0 && (1.0 === $this->gcProbability || (random_int(0, PHP_INT_MAX) / PHP_INT_MAX) <= $this->gcProbability)) {
144+
$this->prune();
145+
}
146+
}
147+
148+
/**
149+
* {@inheritdoc}
150+
*/
151+
public function waitAndSave(Key $key)
152+
{
153+
throw new NotSupportedException(sprintf('The store "%s" does not supports blocking locks.', __CLASS__));
154+
}
155+
156+
/**
157+
* {@inheritdoc}
158+
*/
159+
public function putOffExpiration(Key $key, $ttl)
160+
{
161+
if ($ttl < 1) {
162+
throw new InvalidArgumentException(sprintf('%s() expects a TTL greater or equals to 1. Got %s.', __METHOD__, $ttl));
163+
}
164+
165+
$key->reduceLifetime($ttl);
166+
167+
$sql = "UPDATE $this->table SET $this->expirationCol = {$this->getCurrentTimestampStatment()} + $ttl, $this->tokenCol = :token WHERE $this->idCol = :id AND ($this->tokenCol = :token OR $this->expirationCol <= {$this->getCurrentTimestampStatment()})";
168+
$stmt = $this->getConnection()->prepare($sql);
169+
170+
$stmt->bindValue(':id', $this->getHashedKey($key));
171+
$stmt->bindValue(':token', $this->getToken($key));
172+
$stmt->execute();
173+
174+
// 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
175+
if (!$stmt->rowCount() && !$this->exists($key)) {
176+
throw new LockConflictedException();
177+
}
178+
179+
if ($key->isExpired()) {
180+
throw new LockExpiredException(sprintf('Failed to put off the expiration of the "%s" lock within the specified time.', $key));
181+
}
182+
}
183+
184+
/**
185+
* {@inheritdoc}
186+
*/
187+
public function delete(Key $key)
188+
{
189+
$sql = "DELETE FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token";
190+
$stmt = $this->getConnection()->prepare($sql);
191+
192+
$stmt->bindValue(':id', $this->getHashedKey($key));
193+
$stmt->bindValue(':token', $this->getToken($key));
194+
$stmt->execute();
195+
}
196+
197+
/**
198+
* {@inheritdoc}
199+
*/
200+
public function exists(Key $key)
201+
{
202+
$sql = "SELECT 1 FROM $this->table WHERE $this->idCol = :id AND $this->tokenCol = :token AND $this->expirationCol > {$this->getCurrentTimestampStatment()}";
203+
$stmt = $this->getConnection()->prepare($sql);
204+
205+
$stmt->bindValue(':id', $this->getHashedKey($key));
206+
$stmt->bindValue(':token', $this->getToken($key));
207+
$stmt->execute();
208+
209+
return (bool) $stmt->fetchColumn();
210+
}
211+
212+
/**
213+
* Returns an hashed version of the key.
214+
*/
215+
private function getHashedKey(Key $key): string
216+
{
217+
return hash('sha256', $key);
218+
}
219+
220+
/**
221+
* Retrieve an unique token for the given key.
222+
*/
223+
private function getToken(Key $key): string
224+
{
225+
if (!$key->hasState(__CLASS__)) {
226+
$token = base64_encode(random_bytes(32));
227+
$key->setState(__CLASS__, $token);
228+
}
229+
230+
return $key->getState(__CLASS__);
231+
}
232+
233+
/**
234+
* @return \PDO|Connection
235+
*/
236+
private function getConnection()
237+
{
238+
if (null === $this->conn) {
239+
$this->conn = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions);
240+
$this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
241+
}
242+
243+
return $this->conn;
244+
}
245+
246+
/**
247+
* Creates the table to store lock keys which can be called once for setup.
248+
*
249+
* @throws \PDOException When the table already exists
250+
* @throws DBALException When the table already exists
251+
* @throws \DomainException When an unsupported PDO driver is used
252+
*/
253+
public function createTable(): void
254+
{
255+
// connect if we are not yet
256+
$conn = $this->getConnection();
257+
$driver = $this->getDriver();
258+
259+
if ($conn instanceof Connection) {
260+
$types = array(
261+
'mysql' => 'binary',
262+
'sqlite' => 'text',
263+
'pgsql' => 'string',
264+
'oci' => 'string',
265+
'sqlsrv' => 'string',
266+
);
267+
if (!isset($types[$driver])) {
268+
throw new \DomainException(sprintf('Creating the lock table is currently not implemented for PDO driver "%s".', $driver));
269+
}
270+
271+
$schema = new Schema();
272+
$table = $schema->createTable($this->table);
273+
$table->addColumn($this->idCol, 'string', array('length' => 64));
274+
$table->addColumn($this->tokenCol, 'string', array('length' => 44));
275+
$table->addColumn($this->expirationCol, 'integer', array('unsigned' => true));
276+
$table->setPrimaryKey(array($this->idCol));
277+
278+
foreach ($schema->toSql($conn->getDatabasePlatform()) as $sql) {
279+
$conn->exec($sql);
280+
}
281+
282+
return;
283+
}
284+
285+
switch ($driver) {
286+
case 'mysql':
287+
$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";
288+
break;
289+
case 'sqlite':
290+
$sql = "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->tokenCol TEXT NOT NULL, $this->expirationCol INTEGER)";
291+
break;
292+
case 'pgsql':
293+
$sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(64) NOT NULL, $this->expirationCol INTEGER)";
294+
break;
295+
case 'oci':
296+
$sql = "CREATE TABLE $this->table ($this->idCol VARCHAR2(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR2(64) NOT NULL, $this->expirationCol INTEGER)";
297+
break;
298+
case 'sqlsrv':
299+
$sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(64) NOT NULL PRIMARY KEY, $this->tokenCol VARCHAR(64) NOT NULL, $this->expirationCol INTEGER)";
300+
break;
301+
default:
302+
throw new \DomainException(sprintf('Creating the lock table is currently not implemented for PDO driver "%s".', $driver));
303+
}
304+
305+
$conn->exec($sql);
306+
}
307+
308+
/**
309+
* Cleanup the table by removing all expired locks.
310+
*/
311+
private function prune(): void
312+
{
313+
$sql = "DELETE FROM $this->table WHERE $this->expirationCol <= {$this->getCurrentTimestampStatment()}";
314+
315+
$stmt = $this->getConnection()->prepare($sql);
316+
317+
$stmt->execute();
318+
}
319+
320+
/**
321+
* Returns the current's connection's driver.
322+
*/
323+
private function getDriver(): string
324+
{
325+
if (null !== $this->driver) {
326+
return $this->driver;
327+
}
328+
329+
$con = $this->getConnection();
330+
if ($con instanceof \PDO) {
331+
$this->driver = $con->getAttribute(\PDO::ATTR_DRIVER_NAME);
332+
} else {
333+
switch ($this->driver = $con->getDriver()->getName()) {
334+
case 'mysqli':
335+
case 'pdo_mysql':
336+
case 'drizzle_pdo_mysql':
337+
$this->driver = 'mysql';
338+
break;
339+
case 'pdo_sqlite':
340+
$this->driver = 'sqlite';
341+
break;
342+
case 'pdo_pgsql':
343+
$this->driver = 'pgsql';
344+
break;
345+
case 'oci8':
346+
case 'pdo_oracle':
347+
$this->driver = 'oci';
348+
break;
349+
case 'pdo_sqlsrv':
350+
$this->driver = 'sqlsrv';
351+
break;
352+
}
353+
}
354+
355+
return $this->driver;
356+
}
357+
358+
/**
359+
* Provide a SQL function to get the current timestamp regarding the current connection's driver.
360+
*/
361+
private function getCurrentTimestampStatment(): string
362+
{
363+
switch ($this->getDriver()) {
364+
case 'mysql':
365+
return 'UNIX_TIMESTAMP()';
366+
case 'sqlite':
367+
return 'strftime(\'%s\',\'now\')';
368+
case 'pgsql':
369+
return 'CAST(EXTRACT(epoch FROM NOW()) AS INT)';
370+
case 'oci':
371+
return '(SYSDATE - TO_DATE(\'19700101\',\'yyyymmdd\'))*86400 - TO_NUMBER(SUBSTR(TZ_OFFSET(sessiontimezone), 1, 3))*3600';
372+
case 'sqlsrv':
373+
return 'DATEDIFF(s, \'1970-01-01\', GETUTCDATE())';
374+
default:
375+
return time();
376+
}
377+
}
378+
}

0 commit comments

Comments
 (0)