Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit e58d7cf

Browse files
committed
[HttpFoundation] fix PDO session handler under high concurrency
1 parent a18ee42 commit e58d7cf

File tree

1 file changed

+86
-92
lines changed

1 file changed

+86
-92
lines changed

src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php

Lines changed: 86 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,34 @@
1616
*
1717
* @author Fabien Potencier <[email protected]>
1818
* @author Michael Williams <[email protected]>
19+
* @author Tobias Schultze <http://tobion.de>
1920
*/
2021
class PdoSessionHandler implements \SessionHandlerInterface
2122
{
2223
/**
23-
* @var \PDO PDO instance.
24+
* @var \PDO PDO instance
2425
*/
2526
private $pdo;
2627

2728
/**
28-
* @var array Database options.
29+
* @var string Table name
2930
*/
30-
private $dbOptions;
31+
private $table;
32+
33+
/**
34+
* @var string Column for session id
35+
*/
36+
private $idCol;
37+
38+
/**
39+
* @var string Column for session data
40+
*/
41+
private $dataCol;
42+
43+
/**
44+
* @var string Column for timestamp
45+
*/
46+
private $timeCol;
3147

3248
/**
3349
* Constructor.
@@ -52,11 +68,16 @@ public function __construct(\PDO $pdo, array $dbOptions = array())
5268
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__));
5369
}
5470
$this->pdo = $pdo;
55-
$this->dbOptions = array_merge(array(
71+
$dbOptions = array_merge(array(
5672
'db_id_col' => 'sess_id',
5773
'db_data_col' => 'sess_data',
5874
'db_time_col' => 'sess_time',
5975
), $dbOptions);
76+
77+
$this->table = $dbOptions['db_table'];
78+
$this->idCol = $dbOptions['db_id_col'];
79+
$this->dataCol = $dbOptions['db_data_col'];
80+
$this->timeCol = $dbOptions['db_time_col'];
6081
}
6182

6283
/**
@@ -80,19 +101,15 @@ public function close()
80101
*/
81102
public function destroy($id)
82103
{
83-
// get table/column
84-
$dbTable = $this->dbOptions['db_table'];
85-
$dbIdCol = $this->dbOptions['db_id_col'];
86-
87104
// delete the record associated with this id
88-
$sql = "DELETE FROM $dbTable WHERE $dbIdCol = :id";
105+
$sql = "DELETE FROM $this->table WHERE $this->idCol = :id";
89106

90107
try {
91108
$stmt = $this->pdo->prepare($sql);
92109
$stmt->bindParam(':id', $id, \PDO::PARAM_STR);
93110
$stmt->execute();
94111
} catch (\PDOException $e) {
95-
throw new \RuntimeException(sprintf('PDOException was thrown when trying to manipulate session data: %s', $e->getMessage()), 0, $e);
112+
throw new \RuntimeException(sprintf('PDOException was thrown when trying to delete a session: %s', $e->getMessage()), 0, $e);
96113
}
97114

98115
return true;
@@ -103,19 +120,15 @@ public function destroy($id)
103120
*/
104121
public function gc($lifetime)
105122
{
106-
// get table/column
107-
$dbTable = $this->dbOptions['db_table'];
108-
$dbTimeCol = $this->dbOptions['db_time_col'];
109-
110123
// delete the session records that have expired
111-
$sql = "DELETE FROM $dbTable WHERE $dbTimeCol < :time";
124+
$sql = "DELETE FROM $this->table WHERE $this->timeCol < :time";
112125

113126
try {
114127
$stmt = $this->pdo->prepare($sql);
115128
$stmt->bindValue(':time', time() - $lifetime, \PDO::PARAM_INT);
116129
$stmt->execute();
117130
} catch (\PDOException $e) {
118-
throw new \RuntimeException(sprintf('PDOException was thrown when trying to manipulate session data: %s', $e->getMessage()), 0, $e);
131+
throw new \RuntimeException(sprintf('PDOException was thrown when trying to delete expired sessions: %s', $e->getMessage()), 0, $e);
119132
}
120133

121134
return true;
@@ -126,29 +139,20 @@ public function gc($lifetime)
126139
*/
127140
public function read($id)
128141
{
129-
// get table/columns
130-
$dbTable = $this->dbOptions['db_table'];
131-
$dbDataCol = $this->dbOptions['db_data_col'];
132-
$dbIdCol = $this->dbOptions['db_id_col'];
142+
$sql = "SELECT $this->dataCol FROM $this->table WHERE $this->idCol = :id";
133143

134144
try {
135-
$sql = "SELECT $dbDataCol FROM $dbTable WHERE $dbIdCol = :id";
136-
137145
$stmt = $this->pdo->prepare($sql);
138146
$stmt->bindParam(':id', $id, \PDO::PARAM_STR);
139-
140147
$stmt->execute();
141-
// it is recommended to use fetchAll so that PDO can close the DB cursor
142-
// we anyway expect either no rows, or one row with one column. fetchColumn, seems to be buggy #4777
148+
149+
// We use fetchAll instead of fetchColumn to make sure the DB cursor gets closed
143150
$sessionRows = $stmt->fetchAll(\PDO::FETCH_NUM);
144151

145-
if (count($sessionRows) == 1) {
152+
if ($sessionRows) {
146153
return base64_decode($sessionRows[0][0]);
147154
}
148155

149-
// session does not exist, create it
150-
$this->createNewSession($id);
151-
152156
return '';
153157
} catch (\PDOException $e) {
154158
throw new \RuntimeException(sprintf('PDOException was thrown when trying to read the session data: %s', $e->getMessage()), 0, $e);
@@ -160,84 +164,74 @@ public function read($id)
160164
*/
161165
public function write($id, $data)
162166
{
163-
// get table/column
164-
$dbTable = $this->dbOptions['db_table'];
165-
$dbDataCol = $this->dbOptions['db_data_col'];
166-
$dbIdCol = $this->dbOptions['db_id_col'];
167-
$dbTimeCol = $this->dbOptions['db_time_col'];
168-
169-
//session data can contain non binary safe characters so we need to encode it
167+
// Session data can contain non binary safe characters so we need to encode it.
170168
$encoded = base64_encode($data);
171169

170+
// We use a MERGE SQL query when supported by the database.
171+
// Otherwise we have to use a transactional DELETE followed by INSERT to prevent duplicate entries under high concurrency.
172+
172173
try {
173-
$driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
174-
175-
if ('mysql' === $driver) {
176-
// MySQL would report $stmt->rowCount() = 0 on UPDATE when the data is left unchanged
177-
// it could result in calling createNewSession() whereas the session already exists in
178-
// the DB which would fail as the id is unique
179-
$stmt = $this->pdo->prepare(
180-
"INSERT INTO $dbTable ($dbIdCol, $dbDataCol, $dbTimeCol) VALUES (:id, :data, :time) " .
181-
"ON DUPLICATE KEY UPDATE $dbDataCol = VALUES($dbDataCol), $dbTimeCol = VALUES($dbTimeCol)"
174+
$mergeSql = $this->getMergeSql();
175+
176+
if (null !== $mergeSql) {
177+
$mergeStmt = $this->pdo->prepare($mergeSql);
178+
$mergeStmt->bindParam(':id', $id, \PDO::PARAM_STR);
179+
$mergeStmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
180+
$mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT);
181+
$mergeStmt->execute();
182+
183+
return true;
184+
}
185+
186+
$this->pdo->beginTransaction();
187+
188+
try {
189+
$deleteStmt = $this->pdo->prepare(
190+
"DELETE FROM $this->table WHERE $this->idCol = :id"
191+
);
192+
$deleteStmt->bindParam(':id', $id, \PDO::PARAM_STR);
193+
$deleteStmt->execute();
194+
195+
$insertStmt = $this->pdo->prepare(
196+
"INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time)"
182197
);
183-
$stmt->bindParam(':id', $id, \PDO::PARAM_STR);
184-
$stmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
185-
$stmt->bindValue(':time', time(), \PDO::PARAM_INT);
186-
$stmt->execute();
187-
} elseif ('oci' === $driver) {
188-
$stmt = $this->pdo->prepare("MERGE INTO $dbTable USING DUAL ON($dbIdCol = :id) ".
189-
"WHEN NOT MATCHED THEN INSERT ($dbIdCol, $dbDataCol, $dbTimeCol) VALUES (:id, :data, sysdate) " .
190-
"WHEN MATCHED THEN UPDATE SET $dbDataCol = :data WHERE $dbIdCol = :id");
191-
192-
$stmt->bindParam(':id', $id, \PDO::PARAM_STR);
193-
$stmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
194-
$stmt->execute();
195-
} else {
196-
$stmt = $this->pdo->prepare("UPDATE $dbTable SET $dbDataCol = :data, $dbTimeCol = :time WHERE $dbIdCol = :id");
197-
$stmt->bindParam(':id', $id, \PDO::PARAM_STR);
198-
$stmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
199-
$stmt->bindValue(':time', time(), \PDO::PARAM_INT);
200-
$stmt->execute();
201-
202-
if (!$stmt->rowCount()) {
203-
// No session exists in the database to update. This happens when we have called
204-
// session_regenerate_id()
205-
$this->createNewSession($id, $data);
206-
}
198+
$insertStmt->bindParam(':id', $id, \PDO::PARAM_STR);
199+
$insertStmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
200+
$insertStmt->bindValue(':time', time(), \PDO::PARAM_INT);
201+
$insertStmt->execute();
202+
203+
$this->pdo->commit();
204+
} catch (\PDOException $e) {
205+
$this->pdo->rollback();
206+
207+
throw $e;
207208
}
208209
} catch (\PDOException $e) {
209-
throw new \RuntimeException(sprintf('PDOException was thrown when trying to write the session data: %s', $e->getMessage()), 0, $e);
210+
throw new \RuntimeException(sprintf('PDOException was thrown when trying to write the session data: %s', $e->getMessage()), 0, $e);
210211
}
211212

212213
return true;
213214
}
214215

215216
/**
216-
* Creates a new session with the given $id and $data
217+
* Returns a merge/upsert (i.e. insert or update) SQL query when supported by the database.
217218
*
218-
* @param string $id
219-
* @param string $data
220-
*
221-
* @return boolean True.
219+
* @return string|null The SQL string or null when not supported
222220
*/
223-
private function createNewSession($id, $data = '')
221+
private function getMergeSql()
224222
{
225-
// get table/column
226-
$dbTable = $this->dbOptions['db_table'];
227-
$dbDataCol = $this->dbOptions['db_data_col'];
228-
$dbIdCol = $this->dbOptions['db_id_col'];
229-
$dbTimeCol = $this->dbOptions['db_time_col'];
230-
231-
$sql = "INSERT INTO $dbTable ($dbIdCol, $dbDataCol, $dbTimeCol) VALUES (:id, :data, :time)";
232-
233-
//session data can contain non binary safe characters so we need to encode it
234-
$encoded = base64_encode($data);
235-
$stmt = $this->pdo->prepare($sql);
236-
$stmt->bindParam(':id', $id, \PDO::PARAM_STR);
237-
$stmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
238-
$stmt->bindValue(':time', time(), \PDO::PARAM_INT);
239-
$stmt->execute();
223+
$driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
224+
225+
switch ($driver) {
226+
case 'mysql':
227+
return "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " .
228+
"ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->timeCol = VALUES($this->timeCol)";
229+
case 'oci':
230+
return "MERGE INTO $this->table USING DUAL ON ($this->idCol = :id) " .
231+
"WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " .
232+
"WHEN MATCHED THEN UPDATE SET $this->dataCol = :data";
233+
}
240234

241-
return true;
235+
return null;
242236
}
243237
}

0 commit comments

Comments
 (0)