プロジェクト

全般

プロフィール

Enhancement(機能追加・改善) #3836 » 0002-refs-3836-Upgrade-opPDODatabaseSessionStorage.class..patch

Shinichi Urabe, 2015-09-05 03:50

差分を表示:

lib/config/opProjectConfiguration.class.php
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)
{
lib/user/opPDODatabaseSessionStorage.class.php
*/
/**
* opPDODatabaseSessionStorage
* Session handler using a PDO connection to read and write data.
*
* @package OpenPNE
* @subpackage user
* @author Kousuke Ebihara <ebihara@tejimaya.com>
* 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 <fabien@symfony.com>
* @author Michael Williams <michael.williams@funsational.com>
* @author Tobias Schultze <http://tobion.de>
* @author Shinichi Urabe <urabe@tejimaya.com>
*/
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-2/2)