Skip to content

Commit e81c872

Browse files
committed
bug symfony#10652 [HttpFoundation] fix PDO session handler under high concurrency (Tobion)
This PR was merged into the 2.3 branch. Discussion ---------- [HttpFoundation] fix PDO session handler under high concurrency | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | symfony#8448 and http://trac.symfony-project.org/ticket/4777 (which was never really fixed as you can see here) | License | MIT - The first commit fixes PDO session handler under high concurrency. - The second commit uses MERGE SQL for MS SQL Server. Tested with http://sqlfiddle.com/#!6/66b6d/14 - The third commit uses INSERT OR REPLACE for sqlite session handler http://sqlfiddle.com/#!7/e6707/3 What I find rather bad with the class design is that it depends on the table definition, but it's not part of the class. Also it doesn't make use of open() and close() which could be used to make the database connection lazy instead of having is open all the time when not needed. Doctrine also only lazy connects, but we use PDO directly here. Furthermore, the session handlers should not throw exceptions, from what I read, but return false when an error occurs. This is not followed in this class. Maybe @Drak knows how php session management behaves when the session handlers return false? Commits ------- 5c08e29 [HttpFoundation] use insert or replace for sqlite session handler 05ea19a [HttpFoundation] use MERGE SQL for MS SQL Server session storage e58d7cf [HttpFoundation] fix PDO session handler under high concurrency
2 parents 241dc10 + 5c08e29 commit e81c872

File tree

1 file changed

+94
-92
lines changed

1 file changed

+94
-92
lines changed

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

Lines changed: 94 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,82 @@ 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+
// DUAL is Oracle specific dummy table
231+
return "MERGE INTO $this->table USING DUAL ON ($this->idCol = :id) " .
232+
"WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " .
233+
"WHEN MATCHED THEN UPDATE SET $this->dataCol = :data";
234+
case 'sqlsrv':
235+
// MS SQL Server requires MERGE be terminated by semicolon
236+
return "MERGE INTO $this->table USING (SELECT 'x' AS dummy) AS src ON ($this->idCol = :id) " .
237+
"WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " .
238+
"WHEN MATCHED THEN UPDATE SET $this->dataCol = :data;";
239+
case 'sqlite':
240+
return "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time)";
241+
}
240242

241-
return true;
243+
return null;
242244
}
243245
}

0 commit comments

Comments
 (0)