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

Skip to content

Commit 9474de0

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

File tree

6 files changed

+483
-2
lines changed

6 files changed

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

src/Symfony/Component/Lock/Tests/Store/BlockingStoreTestTrait.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ trait BlockingStoreTestTrait
2222
{
2323
/**
2424
* @see AbstractStoreTest::getStore()
25+
*
26+
* @return StoreInterface
2527
*/
2628
abstract protected function getStore();
2729

@@ -40,7 +42,6 @@ public function testBlockingLocks()
4042
$clockDelay = 50000;
4143

4244
/** @var StoreInterface $store */
43-
$store = $this->getStore();
4445
$key = new Key(uniqid(__METHOD__, true));
4546
$parentPID = posix_getpid();
4647

@@ -51,6 +52,7 @@ public function testBlockingLocks()
5152
// Wait the start of the child
5253
pcntl_sigwaitinfo(array(SIGHUP), $info);
5354

55+
$store = $this->getStore();
5456
try {
5557
// This call should failed given the lock should already by acquired by the child
5658
$store->save($key);
@@ -72,6 +74,8 @@ public function testBlockingLocks()
7274
} else {
7375
// Block SIGHUP signal
7476
pcntl_sigprocmask(SIG_BLOCK, array(SIGHUP));
77+
78+
$store = $this->getStore();
7579
try {
7680
$store->save($key);
7781
// send the ready signal to the parent

0 commit comments

Comments
 (0)
0