19
19
* This means requests for the same session will wait until the other one finished.
20
20
* PHPs internal files session handler also works this way.
21
21
*
22
+ * Session data is a binary string that can contain non-printable characters like the null byte.
23
+ + For this reason this handler base64 encodes the data to be able to save it in a character column.
24
+ *
22
25
* Attention: Since SQLite does not support row level locks but locks the whole database,
23
26
* it means only one session can be accessed at a time. Even different sessions would wait
24
27
* for another to finish. So saving session in SQLite should only be considered for
@@ -192,13 +195,12 @@ public function destroy($sessionId)
192
195
*/
193
196
public function write ($ sessionId , $ data )
194
197
{
195
- // Session data can contain non binary safe characters so we need to encode it.
196
198
$ encoded = base64_encode ($ data );
197
199
198
200
// The session ID can be different from the one previously received in read()
199
201
// when the session ID changed due to session_regenerate_id(). So we have to
200
202
// do an insert or update even if we created a row in read() for locking.
201
- // We use a MERGE SQL query when supported by the database.
203
+ // We use a single MERGE SQL query when supported by the database.
202
204
203
205
try {
204
206
$ mergeSql = $ this ->getMergeSql ();
@@ -221,17 +223,30 @@ public function write($sessionId, $data)
221
223
$ updateStmt ->bindValue (':time ' , time (), \PDO ::PARAM_INT );
222
224
$ updateStmt ->execute ();
223
225
224
- // Since we have a lock on the session, this is safe to do. Otherwise it would be prone to
225
- // race conditions in high concurrency. And if it's a regenerated session ID it should be
226
- // unique anyway.
226
+ // When MERGE is not supported, like in Postgres, we have to use this approach that can result in
227
+ // duplicate key errors when the same session is written simultaneously. We can just catch such an
228
+ // error and re-execute the update. This is similar to a serializable transaction with retry logic
229
+ // on serialization failures but without the overhead and without possible false positives due to
230
+ // longer gap locking.
231
+ // Since we have a lock on the session, the above case should not happen. And if it's a regenerated
232
+ // session ID it should be unique anyway.
227
233
if (!$ updateStmt ->rowCount ()) {
228
- $ insertStmt = $ this ->pdo ->prepare (
229
- "INSERT INTO $ this ->table ( $ this ->idCol , $ this ->dataCol , $ this ->timeCol ) VALUES (:id, :data, :time) "
230
- );
231
- $ insertStmt ->bindParam (':id ' , $ sessionId , \PDO ::PARAM_STR );
232
- $ insertStmt ->bindParam (':data ' , $ encoded , \PDO ::PARAM_STR );
233
- $ insertStmt ->bindValue (':time ' , time (), \PDO ::PARAM_INT );
234
- $ insertStmt ->execute ();
234
+ try {
235
+ $ insertStmt = $ this ->pdo ->prepare (
236
+ "INSERT INTO $ this ->table ( $ this ->idCol , $ this ->dataCol , $ this ->timeCol ) VALUES (:id, :data, :time) "
237
+ );
238
+ $ insertStmt ->bindParam (':id ' , $ sessionId , \PDO ::PARAM_STR );
239
+ $ insertStmt ->bindParam (':data ' , $ encoded , \PDO ::PARAM_STR );
240
+ $ insertStmt ->bindValue (':time ' , time (), \PDO ::PARAM_INT );
241
+ $ insertStmt ->execute ();
242
+ } catch (\PDOException $ e ) {
243
+ // Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys
244
+ if (0 === strpos ($ e ->getCode (), '23 ' )) {
245
+ $ updateStmt ->execute ();
246
+ } else {
247
+ throw $ e ;
248
+ }
249
+ }
235
250
}
236
251
} catch (\PDOException $ e ) {
237
252
$ this ->rollback ();
@@ -353,9 +368,10 @@ private function lockSession($sessionId)
353
368
"WHEN NOT MATCHED THEN INSERT ( $ this ->idCol , $ this ->dataCol , $ this ->timeCol ) VALUES (:id, :data, :time) " .
354
369
"WHEN MATCHED THEN UPDATE SET $ this ->idCol = $ this ->idCol " ;
355
370
break ;
356
- case 'sqlsrv ' :
371
+ // todo: implement locking for SQL Server < 2008
372
+ case 'sqlsrv ' === $ this ->driver && version_compare ($ this ->pdo ->getAttribute (\PDO ::ATTR_SERVER_VERSION ), '10 ' , '>= ' ):
357
373
// MS SQL Server requires MERGE be terminated by semicolon
358
- $ sql = "MERGE INTO $ this ->table USING (SELECT 'x' AS dummy) AS src ON ( $ this ->idCol = :id) " .
374
+ $ sql = "MERGE INTO $ this ->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ( $ this ->idCol = :id) " .
359
375
"WHEN NOT MATCHED THEN INSERT ( $ this ->idCol , $ this ->dataCol , $ this ->timeCol ) VALUES (:id, :data, :time) " .
360
376
"WHEN MATCHED THEN UPDATE SET $ this ->idCol = $ this ->idCol ; " ;
361
377
break ;
@@ -397,9 +413,10 @@ private function getMergeSql()
397
413
return "MERGE INTO $ this ->table USING DUAL ON ( $ this ->idCol = :id) " .
398
414
"WHEN NOT MATCHED THEN INSERT ( $ this ->idCol , $ this ->dataCol , $ this ->timeCol ) VALUES (:id, :data, :time) " .
399
415
"WHEN MATCHED THEN UPDATE SET $ this ->dataCol = :data, $ this ->timeCol = :time " ;
400
- case 'sqlsrv ' :
401
- // MS SQL Server requires MERGE be terminated by semicolon
402
- return "MERGE INTO $ this ->table USING (SELECT 'x' AS dummy) AS src ON ( $ this ->idCol = :id) " .
416
+ case 'sqlsrv ' === $ this ->driver && version_compare ($ this ->pdo ->getAttribute (\PDO ::ATTR_SERVER_VERSION ), '10 ' , '>= ' ):
417
+ // MERGE is only available since SQL Server 2008 and must be terminated by semicolon
418
+ // It also requires HOLDLOCK according to http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx
419
+ return "MERGE INTO $ this ->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ( $ this ->idCol = :id) " .
403
420
"WHEN NOT MATCHED THEN INSERT ( $ this ->idCol , $ this ->dataCol , $ this ->timeCol ) VALUES (:id, :data, :time) " .
404
421
"WHEN MATCHED THEN UPDATE SET $ this ->dataCol = :data, $ this ->timeCol = :time; " ;
405
422
case 'sqlite ' :
0 commit comments