12
12
namespace Symfony \Component \HttpFoundation \Session \Storage \Handler ;
13
13
14
14
/**
15
- * PdoSessionHandler.
15
+ * Session handler using a PDO connection to read and write data.
16
+ *
17
+ * Session data is a binary string that can contain non-printable characters like the null byte.
18
+ * For this reason this handler base64 encodes the data to be able to save it in a character column.
19
+ *
20
+ * This version of the PdoSessionHandler does NOT implement locking. So concurrent requests to the
21
+ * same session can result in data loss due to race conditions.
16
22
*
17
23
* @author Fabien Potencier <fabien@symfony.com>
18
24
* @author Michael Williams <michael.williams@funsational.com>
@@ -164,13 +170,10 @@ public function read($sessionId)
164
170
*/
165
171
public function write ($ sessionId , $ data )
166
172
{
167
- // Session data can contain non binary safe characters so we need to encode it.
168
173
$ encoded = base64_encode ($ data );
169
174
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
-
173
175
try {
176
+ // We use a single MERGE SQL query when supported by the database.
174
177
$ mergeSql = $ this ->getMergeSql ();
175
178
176
179
if (null !== $ mergeSql ) {
@@ -183,28 +186,36 @@ public function write($sessionId, $data)
183
186
return true ;
184
187
}
185
188
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 ' , $ sessionId , \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) "
197
- );
198
- $ insertStmt ->bindParam (':id ' , $ sessionId , \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 ;
189
+ $ updateStmt = $ this ->pdo ->prepare (
190
+ "UPDATE $ this ->table SET $ this ->dataCol = :data, $ this ->timeCol = :time WHERE $ this ->idCol = :id "
191
+ );
192
+ $ updateStmt ->bindParam (':id ' , $ sessionId , \PDO ::PARAM_STR );
193
+ $ updateStmt ->bindParam (':data ' , $ encoded , \PDO ::PARAM_STR );
194
+ $ updateStmt ->bindValue (':time ' , time (), \PDO ::PARAM_INT );
195
+ $ updateStmt ->execute ();
196
+
197
+ // When MERGE is not supported, like in Postgres, we have to use this approach that can result in
198
+ // duplicate key errors when the same session is written simultaneously. We can just catch such an
199
+ // error and re-execute the update. This is similar to a serializable transaction with retry logic
200
+ // on serialization failures but without the overhead and without possible false positives due to
201
+ // longer gap locking.
202
+ if (!$ updateStmt ->rowCount ()) {
203
+ try {
204
+ $ insertStmt = $ this ->pdo ->prepare (
205
+ "INSERT INTO $ this ->table ( $ this ->idCol , $ this ->dataCol , $ this ->timeCol ) VALUES (:id, :data, :time) "
206
+ );
207
+ $ insertStmt ->bindParam (':id ' , $ sessionId , \PDO ::PARAM_STR );
208
+ $ insertStmt ->bindParam (':data ' , $ encoded , \PDO ::PARAM_STR );
209
+ $ insertStmt ->bindValue (':time ' , time (), \PDO ::PARAM_INT );
210
+ $ insertStmt ->execute ();
211
+ } catch (\PDOException $ e ) {
212
+ // Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys
213
+ if (0 === strpos ($ e ->getCode (), '23 ' )) {
214
+ $ updateStmt ->execute ();
215
+ } else {
216
+ throw $ e ;
217
+ }
218
+ }
208
219
}
209
220
} catch (\PDOException $ e ) {
210
221
throw new \RuntimeException (sprintf ('PDOException was thrown when trying to write the session data: %s ' , $ e ->getMessage ()), 0 , $ e );
@@ -230,12 +241,13 @@ private function getMergeSql()
230
241
// DUAL is Oracle specific dummy table
231
242
return "MERGE INTO $ this ->table USING DUAL ON ( $ this ->idCol = :id) " .
232
243
"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) " .
244
+ "WHEN MATCHED THEN UPDATE SET $ this ->dataCol = :data, $ this ->timeCol = :time " ;
245
+ case 'sqlsrv ' === $ driver && version_compare ($ this ->pdo ->getAttribute (\PDO ::ATTR_SERVER_VERSION ), '10 ' , '>= ' ):
246
+ // MERGE is only available since SQL Server 2008 and must be terminated by semicolon
247
+ // It also requires HOLDLOCK according to http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx
248
+ return "MERGE INTO $ this ->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ( $ this ->idCol = :id) " .
237
249
"WHEN NOT MATCHED THEN INSERT ( $ this ->idCol , $ this ->dataCol , $ this ->timeCol ) VALUES (:id, :data, :time) " .
238
- "WHEN MATCHED THEN UPDATE SET $ this ->dataCol = :data; " ;
250
+ "WHEN MATCHED THEN UPDATE SET $ this ->dataCol = :data, $ this -> timeCol = :time ; " ;
239
251
case 'sqlite ' :
240
252
return "INSERT OR REPLACE INTO $ this ->table ( $ this ->idCol , $ this ->dataCol , $ this ->timeCol ) VALUES (:id, :data, :time) " ;
241
253
}
0 commit comments