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

Skip to content

Commit 7611a62

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

File tree

4 files changed

+470
-1
lines changed

4 files changed

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

0 commit comments

Comments
 (0)
0