From 2ef03b5d8eedc0c86b26912681f7bba3a9ab7bf3 Mon Sep 17 00:00:00 2001 From: Shinichi Urabe Date: Sat, 5 Sep 2015 03:47:35 +0900 Subject: [PATCH 2/2] (refs #3836) Upgrade opPDODatabaseSessionStorage.class.php. @see http://symfony.com/doc/current/cookbook/doctrine/pdo_session_storage.html --- lib/config/opProjectConfiguration.class.php | 8 +- lib/user/opPDODatabaseSessionStorage.class.php | 609 ++++++++++++++++++++++++- 2 files changed, 604 insertions(+), 13 deletions(-) diff --git a/lib/config/opProjectConfiguration.class.php b/lib/config/opProjectConfiguration.class.php index 0890bcd..b3bef93 100644 --- a/lib/config/opProjectConfiguration.class.php +++ b/lib/config/opProjectConfiguration.class.php @@ -81,13 +81,7 @@ class opProjectConfiguration extends sfProjectConfiguration elseif ('database' === $name) { sfConfig::set('sf_factory_storage', 'opPDODatabaseSessionStorage'); - sfConfig::set('sf_factory_storage_parameters', array_merge(array( - 'db_table' => 'session', - 'database' => 'doctrine', - 'db_id_col' => 'id', - 'db_data_col' => 'session_data', - 'db_time_col' => 'time', - ), (array)$options, $params)); + sfConfig::set('sf_factory_storage_parameters', array_merge(array('database' => 'doctrine'), (array)$options, $params)); } elseif ('redis' === $name) { diff --git a/lib/user/opPDODatabaseSessionStorage.class.php b/lib/user/opPDODatabaseSessionStorage.class.php index 1386389..4da8e2e 100644 --- a/lib/user/opPDODatabaseSessionStorage.class.php +++ b/lib/user/opPDODatabaseSessionStorage.class.php @@ -9,21 +9,618 @@ */ /** - * opPDODatabaseSessionStorage + * Session handler using a PDO connection to read and write data. * - * @package OpenPNE - * @subpackage user - * @author Kousuke Ebihara + * It works with MySQL, PostgreSQL, Oracle, SQL Server and SQLite and implements + * different locking strategies to handle concurrent access to the same session. + * Locking is necessary to prevent loss of data due to race conditions and to keep + * the session data consistent between read() and write(). With locking, requests + * for the same session will wait until the other one finished writing. For this + * reason it's best practice to close a session as early as possible to improve + * concurrency. PHPs internal files session handler also implements locking. + * + * Attention: Since SQLite does not support row level locks but locks the whole database, + * it means only one session can be accessed at a time. Even different sessions would wait + * for another to finish. So saving session in SQLite should only be considered for + * development or prototypes. + * + * Session data is a binary string that can contain non-printable characters like the null byte. + * For this reason it must be saved in a binary column in the database like BLOB in MySQL. + * Saving it in a character column could corrupt the data. + * + * @see http://php.net/sessionhandlerinterface + * + * @author Fabien Potencier + * @author Michael Williams + * @author Tobias Schultze + * @author Shinichi Urabe */ class opPDODatabaseSessionStorage extends sfPDOSessionStorage { - public function sessionOpen($path = null, $name = null) + /** + * No locking is done. This means sessions are prone to loss of data due to + * race conditions of concurrent requests to the same session. The last session + * write will win in this case. It might be useful when you implement your own + * logic to deal with this like an optimistic approach. + */ + const LOCK_NONE = 0; + + /** + * Creates an application-level lock on a session. The disadvantage is that the + * lock is not enforced by the database and thus other, unaware parts of the + * application could still concurrently modify the session. The advantage is it + * does not require a transaction. + * This mode is not available for SQLite and not yet implemented for oci and sqlsrv. + */ + const LOCK_ADVISORY = 1; + + /** + * Issues a real row lock. Since it uses a transaction between opening and + * closing a session, you have to be careful when you use same database connection + * that you also use for your application logic. This mode is the default because + * it's the only reliable solution across DBMSs. + */ + const LOCK_TRANSACTIONAL = 2; + + /** + * @var string Database driver + */ + private $driver; + + /** + * @var string Table name + */ + private $table = 'session'; + + /** + * @var string Column for session id + */ + private $idCol = 'id'; + + /** + * @var string Column for session data + */ + private $dataCol = 'session_data'; + + /** + * @var string Column for lifetime + */ + private $lifetimeCol = 'session_lifetime'; + + /** + * @var string Column for timestamp + */ + private $timeCol = 'time'; + + /** + * @var int The strategy for locking, see constants + */ + private $lockMode = self::LOCK_TRANSACTIONAL; + + /** + * It's an array to support multiple reads before closing which is manual, non-standard usage. + * + * @var PDOStatement[] An array of statements to release advisory locks + */ + private $unlockStatements = array(); + + /** + * @var bool True when the current session exists but expired according to session.gc_maxlifetime + */ + private $sessionExpired = false; + + /** + * @var bool Whether a transaction is active + */ + private $inTransaction = false; + + /** + * @var bool Whether gc() has been called + */ + private $gcCalled = false; + + /** + * Constructor. + * + * List of available options: + * * db_table: The name of the table [default: session] + * * db_id_col: The column where to store the session id [default: id] + * * db_data_col: The column where to store the session data [default: session_data] + * * db_time_col: The column where to store the timestamp [default: session_time] + * * db_lifetime_col: The column where to store the lifetime [default: session_lifetime] + * * lock_mode: The strategy for locking, see constants [default: LOCK_TRANSACTIONAL] + * + * @param array $options An associative array of options + * + * @throws InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION + */ + public function initialize($options = array()) + { + $this->table = isset($options['db_table']) ? $options['db_table'] : $this->table; + $this->idCol = isset($options['db_id_col']) ? $options['db_id_col'] : $this->idCol; + $this->dataCol = isset($options['db_data_col']) ? $options['db_data_col'] : $this->dataCol; + $this->timeCol = isset($options['db_time_col']) ? $options['db_time_col'] : $this->timeCol; + $this->lifetimeCol = isset($options['db_lifetime_col']) ? $options['db_lifetime_col'] : $this->lifetimeCol; + $this->lockMode = isset($options['lock_mode']) ? $options['lock_mode'] : $this->lockMode; + if (opDoctrineQuery::getMasterConnectionDirect()->getName() === $options['database']) + { + // Skip "lockmode' when session connection use master. + $this->lockMode = self::LOCK_NONE; + } + + parent::initialize($options); + } + + /** + * Returns true when the current session exists but expired according to session.gc_maxlifetime. + * + * Can be used to distinguish between a new session and one that expired due to inactivity. + * + * @return bool Whether current session expired + */ + public function isSessionExpired() + { + return $this->sessionExpired; + } + + /** + * {@inheritdoc} + */ + public function sessionOpen($savePath, $sessionName) { if (is_string($this->options['database'])) { $this->options['database'] = sfContext::getInstance()->getDatabaseManager()->getDatabase($this->options['database']); } - return parent::sessionOpen($path, $name); + $bool = parent::sessionOpen($savePath, $sessionName); + $this->driver = $this->db->getAttribute(PDO::ATTR_DRIVER_NAME); + + return $bool; + } + + /** + * {@inheritdoc} + */ + public function sessionRead($sessionId) + { + try + { + return $this->doRead($sessionId); + } + catch (PDOException $e) + { + $this->rollback(); + + throw $e; + } + } + + /** + * {@inheritdoc} + */ + public function sessionGc($maxlifetime) + { + // We delay gc() to close() so that it is executed outside the transactional and blocking read-write process. + // This way, pruning expired sessions does not block them from being started while the current session is used. + $this->gcCalled = true; + + return true; + } + + /** + * {@inheritdoc} + */ + public function sessionDestroy($sessionId) + { + // delete the record associated with this id + $sql = "DELETE FROM $this->table WHERE $this->idCol = :id"; + + try + { + $stmt = $this->db->prepare($sql); + $stmt->bindParam(':id', $sessionId, PDO::PARAM_STR); + $stmt->execute(); + } + catch (PDOException $e) + { + $this->rollback(); + + throw $e; + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function sessionWrite($sessionId, $data) + { + $maxlifetime = (int) ini_get('session.gc_maxlifetime'); + + try + { + // We use a single MERGE SQL query when supported by the database. + $mergeSql = $this->getMergeSql(); + + if (null !== $mergeSql) + { + $mergeStmt = $this->db->prepare($mergeSql); + $mergeStmt->bindParam(':id', $sessionId, PDO::PARAM_STR); + $mergeStmt->bindParam(':data', $data, PDO::PARAM_LOB); + $mergeStmt->bindParam(':lifetime', $maxlifetime, PDO::PARAM_INT); + $mergeStmt->bindValue(':time', time(), PDO::PARAM_INT); + $mergeStmt->execute(); + + return true; + } + + $updateStmt = $this->db->prepare( + "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id" + ); + $updateStmt->bindParam(':id', $sessionId, PDO::PARAM_STR); + $updateStmt->bindParam(':data', $data, PDO::PARAM_LOB); + $updateStmt->bindParam(':lifetime', $maxlifetime, PDO::PARAM_INT); + $updateStmt->bindValue(':time', time(), PDO::PARAM_INT); + $updateStmt->execute(); + + // When MERGE is not supported, like in Postgres, we have to use this approach that can result in + // duplicate key errors when the same session is written simultaneously (given the LOCK_NONE behavior). + // We can just catch such an error and re-execute the update. This is similar to a serializable + // transaction with retry logic on serialization failures but without the overhead and without possible + // false positives due to longer gap locking. + if (!$updateStmt->rowCount()) + { + try + { + $insertStmt = $this->db->prepare( + "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)" + ); + $insertStmt->bindParam(':id', $sessionId, PDO::PARAM_STR); + $insertStmt->bindParam(':data', $data, PDO::PARAM_LOB); + $insertStmt->bindParam(':lifetime', $maxlifetime, PDO::PARAM_INT); + $insertStmt->bindValue(':time', time(), PDO::PARAM_INT); + $insertStmt->execute(); + } + catch (PDOException $e) + { + // Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys + if (0 === strpos($e->getCode(), '23')) + { + $updateStmt->execute(); + } + else + { + throw $e; + } + } + } + } + catch (PDOException $e) + { + $this->rollback(); + + throw $e; + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function sessionClose() + { + $this->commit(); + + while ($unlockStmt = array_shift($this->unlockStatements)) + { + $unlockStmt->execute(); + } + + if ($this->gcCalled) + { + $this->gcCalled = false; + + // delete the session records that have expired + $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol < :time"; + + $stmt = $this->db->prepare($sql); + $stmt->bindValue(':time', time(), PDO::PARAM_INT); + $stmt->execute(); + } + + return true; + } + + /** + * Helper method to begin a transaction. + * + * Since SQLite does not support row level locks, we have to acquire a reserved lock + * on the database immediately. Because of https://bugs.php.net/42766 we have to create + * such a transaction manually which also means we cannot use PDO::commit or + * PDO::rollback or PDO::inTransaction for SQLite. + * + * Also MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions + * due to http://www.mysqlperformanceblog.com/2013/12/12/one-more-innodb-gap-lock-to-avoid/ . + * So we change it to READ COMMITTED. + */ + private function beginTransaction() + { + if (!$this->inTransaction) + { + if ('sqlite' === $this->driver) + { + $this->db->exec('BEGIN IMMEDIATE TRANSACTION'); + } + else + { + if ('mysql' === $this->driver) + { + $this->db->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); + } + $this->db->beginTransaction(); + } + $this->inTransaction = true; + } + } + + /** + * Helper method to commit a transaction. + */ + private function commit() + { + if ($this->inTransaction) + { + try + { + // commit read-write transaction which also releases the lock + if ('sqlite' === $this->driver) + { + $this->db->exec('COMMIT'); + } + else + { + $this->db->commit(); + } + $this->inTransaction = false; + } + catch (PDOException $e) + { + $this->rollback(); + + throw $e; + } + } + } + + /** + * Helper method to rollback a transaction. + */ + private function rollback() + { + // We only need to rollback if we are in a transaction. Otherwise the resulting + // error would hide the real problem why rollback was called. We might not be + // in a transaction when not using the transactional locking behavior or when + // two callbacks (e.g. destroy and write) are invoked that both fail. + if ($this->inTransaction) + { + if ('sqlite' === $this->driver) + { + $this->db->exec('ROLLBACK'); + } + else + { + $this->db->rollBack(); + } + $this->inTransaction = false; + } + } + + /** + * Reads the session data in respect to the different locking strategies. + * + * We need to make sure we do not return session data that is already considered garbage according + * to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes. + * + * @param string $sessionId Session ID + * + * @return string The session data + */ + private function doRead($sessionId) + { + $this->sessionExpired = false; + + if (self::LOCK_ADVISORY === $this->lockMode) + { + $this->unlockStatements[] = $this->doAdvisoryLock($sessionId); + } + + $selectSql = $this->getSelectSql(); + $selectStmt = $this->db->prepare($selectSql); + $selectStmt->bindParam(':id', $sessionId, PDO::PARAM_STR); + $selectStmt->execute(); + + $sessionRows = $selectStmt->fetchAll(PDO::FETCH_NUM); + + if ($sessionRows) + { + if ($sessionRows[0][1] + $sessionRows[0][2] < time()) + { + $this->sessionExpired = true; + + return ''; + } + + return is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0]; + } + + if (self::LOCK_TRANSACTIONAL === $this->lockMode && 'sqlite' !== $this->driver) + { + // Exclusive-reading of non-existent rows does not block, so we need to do an insert to block + // until other connections to the session are committed. + try + { + $insertStmt = $this->db->prepare( + "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)" + ); + $insertStmt->bindParam(':id', $sessionId, PDO::PARAM_STR); + $insertStmt->bindValue(':data', '', PDO::PARAM_LOB); + $insertStmt->bindValue(':lifetime', 0, PDO::PARAM_INT); + $insertStmt->bindValue(':time', time(), PDO::PARAM_INT); + $insertStmt->execute(); + } + catch (PDOException $e) + { + // Catch duplicate key error because other connection created the session already. + // It would only not be the case when the other connection destroyed the session. + if (0 === strpos($e->getCode(), '23')) + { + // Retrieve finished session data written by concurrent connection. SELECT + // FOR UPDATE is necessary to avoid deadlock of connection that starts reading + // before we write (transform intention to real lock). + $selectStmt->execute(); + $sessionRows = $selectStmt->fetchAll(PDO::FETCH_NUM); + + if ($sessionRows) + { + return is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0]; + } + + return ''; + } + + throw $e; + } + } + + return ''; + } + + /** + * Executes an application-level lock on the database. + * + * @param string $sessionId Session ID + * + * @return PDOStatement The statement that needs to be executed later to release the lock + * + * @throws DomainException When an unsupported PDO driver is used + * + * @todo implement missing advisory locks + * - for oci using DBMS_LOCK.REQUEST + * - for sqlsrv using sp_getapplock with LockOwner = Session + */ + private function doAdvisoryLock($sessionId) + { + switch ($this->driver) + { + case 'mysql': + // should we handle the return value? 0 on timeout, null on error + // we use a timeout of 50 seconds which is also the default for innodb_lock_wait_timeout + $stmt = $this->db->prepare('SELECT GET_LOCK(:key, 50)'); + $stmt->bindValue(':key', $sessionId, PDO::PARAM_STR); + $stmt->execute(); + + $releaseStmt = $this->db->prepare('DO RELEASE_LOCK(:key)'); + $releaseStmt->bindValue(':key', $sessionId, PDO::PARAM_STR); + + return $releaseStmt; + case 'pgsql': + // Obtaining an exclusive session level advisory lock requires an integer key. + // So we convert the HEX representation of the session id to an integer. + // Since integers are signed, we have to skip one hex char to fit in the range. + if (4 === PHP_INT_SIZE) + { + $sessionInt1 = hexdec(substr($sessionId, 0, 7)); + $sessionInt2 = hexdec(substr($sessionId, 7, 7)); + + $stmt = $this->db->prepare('SELECT pg_advisory_lock(:key1, :key2)'); + $stmt->bindValue(':key1', $sessionInt1, PDO::PARAM_INT); + $stmt->bindValue(':key2', $sessionInt2, PDO::PARAM_INT); + $stmt->execute(); + + $releaseStmt = $this->db->prepare('SELECT pg_advisory_unlock(:key1, :key2)'); + $releaseStmt->bindValue(':key1', $sessionInt1, PDO::PARAM_INT); + $releaseStmt->bindValue(':key2', $sessionInt2, PDO::PARAM_INT); + } + else + { + $sessionBigInt = hexdec(substr($sessionId, 0, 15)); + + $stmt = $this->db->prepare('SELECT pg_advisory_lock(:key)'); + $stmt->bindValue(':key', $sessionBigInt, PDO::PARAM_INT); + $stmt->execute(); + + $releaseStmt = $this->db->prepare('SELECT pg_advisory_unlock(:key)'); + $releaseStmt->bindValue(':key', $sessionBigInt, PDO::PARAM_INT); + } + + return $releaseStmt; + case 'sqlite': + throw new DomainException('SQLite does not support advisory locks.'); + default: + throw new DomainException(sprintf('Advisory locks are currently not implemented for PDO driver "%s".', $this->driver)); + } + } + + /** + * Return a locking or nonlocking SQL query to read session information. + * + * @return string The SQL string + * + * @throws DomainException When an unsupported PDO driver is used + */ + private function getSelectSql() + { + if (self::LOCK_TRANSACTIONAL === $this->lockMode) + { + $this->beginTransaction(); + + switch ($this->driver) + { + case 'mysql': + case 'oci': + case 'pgsql': + return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE"; + case 'sqlsrv': + return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id"; + case 'sqlite': + // we already locked when starting transaction + break; + default: + throw new DomainException(sprintf('Transactional locks are currently not implemented for PDO driver "%s".', $this->driver)); + } + } + + return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id"; + } + + /** + * Returns a merge/upsert (i.e. insert or update) SQL query when supported by the database for writing session data. + * + * @return string|null The SQL string or null when not supported + */ + private function getMergeSql() + { + switch ($this->driver) + { + case 'mysql': + return "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ". + "ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)"; + case 'oci': + // DUAL is Oracle specific dummy table + return "MERGE INTO $this->table USING DUAL ON ($this->idCol = :id) ". + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ". + "WHEN MATCHED THEN UPDATE SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time"; + case 'sqlsrv' === $this->driver && version_compare($this->db->getAttribute(PDO::ATTR_SERVER_VERSION), '10', '>='): + // MERGE is only available since SQL Server 2008 and must be terminated by semicolon + // It also requires HOLDLOCK according to http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx + return "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = :id) ". + "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ". + "WHEN MATCHED THEN UPDATE SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time;"; + case 'sqlite': + return "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"; + } } } -- 2.5.1