From 9843e5ece5967f5b501f93e6ba60ac3cf2c167aa Mon Sep 17 00:00:00 2001 From: Damyon Wiese Date: Tue, 17 Dec 2013 16:00:29 +0800 Subject: [PATCH] MDL-25500 lock: New locking framework. This locking system is designed to be used everywhere requiring locking in Moodle. Each use of the locking system can be configured to use a different type of locking (or the same type with a different configuration). The first supported lock types are memcache, memcached, file (flock), db (specific handlers for pg, mysql and mariadb). --- config-dist.php | 27 +++ lib/classes/lock/db_record_lock_factory.php | 254 ++++++++++++++++++++ lib/classes/lock/file_lock_factory.php | 196 +++++++++++++++ lib/classes/lock/lock.php | 112 +++++++++ lib/classes/lock/lock_config.php | 79 ++++++ lib/classes/lock/lock_factory.php | 106 ++++++++ lib/classes/lock/mysql_lock_factory.php | 157 ++++++++++++ lib/classes/lock/postgres_lock_factory.php | 244 +++++++++++++++++++ lib/db/install.xml | 18 +- lib/db/upgrade.php | 27 +++ lib/setuplib.php | 2 +- lib/tests/lock_config_test.php | 76 ++++++ lib/tests/lock_test.php | 111 +++++++++ lib/upgrade.txt | 2 + version.php | 2 +- 15 files changed, 1410 insertions(+), 3 deletions(-) create mode 100644 lib/classes/lock/db_record_lock_factory.php create mode 100644 lib/classes/lock/file_lock_factory.php create mode 100644 lib/classes/lock/lock.php create mode 100644 lib/classes/lock/lock_config.php create mode 100644 lib/classes/lock/lock_factory.php create mode 100644 lib/classes/lock/mysql_lock_factory.php create mode 100644 lib/classes/lock/postgres_lock_factory.php create mode 100644 lib/tests/lock_config_test.php create mode 100644 lib/tests/lock_test.php diff --git a/config-dist.php b/config-dist.php index 6733547e12c..b125d6ae92a 100644 --- a/config-dist.php +++ b/config-dist.php @@ -470,6 +470,33 @@ $CFG->admin = 'admin'; // will be sent to supportemail. // $CFG->supportuserid = -20; // +// Moodle 2.7 introduces a locking api for critical tasks (e.g. cron). +// The default locking system to use is DB locking for MySQL and Postgres, and File +// locking for Oracle and SQLServer. If $CFG->preventfilelocking is set, then the default +// will always be DB locking. It can be manually set to one of the lock +// factory classes listed below, or one of your own custom classes implementing the +// \core\lock\lock_factory interface. +// +// $CFG->lock_factory = "auto"; +// +// The list of available lock factories is: +// +// "\\core\\lock\\file_lock_factory" - File locking +// Uses lock files stored by default in the dataroot. Whether this +// works on clusters depends on the file system used for the dataroot. +// +// "\\core\\lock\\db_row_lock_factory" - DB locking based on table rows. +// +// "\\core\\lock\\postgres_lock_factory" - DB locking based on postgres advisory locks. +// +// "\\core\\lock\\mysql_lock_factory" - DB locking based on mysql lock functions. +// +// Settings used by the lock factories +// +// Location for lock files used by the File locking factory. This must exist +// on a shared file system that supports locking. +// $CFG->lock_file_root = $CFG->dataroot . '/lock'; +// //========================================================================= // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!! //========================================================================= diff --git a/lib/classes/lock/db_record_lock_factory.php b/lib/classes/lock/db_record_lock_factory.php new file mode 100644 index 00000000000..9b7ba53e003 --- /dev/null +++ b/lib/classes/lock/db_record_lock_factory.php @@ -0,0 +1,254 @@ +. + +/** + * This is a db record locking factory. + * + * @package core + * @category lock + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\lock; + +defined('MOODLE_INTERNAL') || die(); + +/** + * This is a db record locking factory. + * + * This lock factory uses record locks relying on sql of the form "SET XXX where YYY" and checking if the + * value was set. It supports timeouts, autorelease and can work on any DB. The downside - is this + * will always be slower than some shared memory type locking function. + * + * @package core + * @category lock + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class db_record_lock_factory implements lock_factory { + + /** @var moodle_database $db Hold a reference to the global $DB */ + protected $db; + + /** @var string $type Used to prefix lock keys */ + protected $type; + + /** @var array $openlocks - List of held locks - used by auto-release */ + protected $openlocks = array(); + + /** + * Is available. + * @return boolean - True if this lock type is available in this environment. + */ + public function is_available() { + return true; + } + + /** + * Almighty constructor. + * @param string $type - Used to prefix lock keys. + */ + public function __construct($type) { + global $DB; + + $this->type = $type; + // Save a reference to the global $DB so it will not be released while we still have open locks. + $this->db = $DB; + + \core_shutdown_manager::register_function(array($this, 'auto_release')); + } + + /** + * Return information about the blocking behaviour of the lock type on this platform. + * @return boolean - True + */ + public function supports_timeout() { + return true; + } + + /** + * Will this lock type will be automatically released when a process ends. + * + * @return boolean - True (shutdown handler) + */ + public function supports_auto_release() { + return true; + } + + /** + * Multiple locks for the same resource can be held by a single process. + * @return boolean - False - not process specific. + */ + public function supports_recursion() { + return false; + } + + /** + * This function generates a unique token for the lock to use. + * It is important that this token is not soley based on time as this could lead + * to duplicates in a clustered environment (especially on VMs due to poor time precision). + */ + protected function generate_unique_token() { + $uuid = ''; + + if (function_exists("uuid_create")) { + $context = null; + uuid_create($context); + + uuid_make($context, UUID_MAKE_V4); + uuid_export($context, UUID_FMT_STR, $uuid); + } else { + // Fallback uuid generation based on: + // "http://www.php.net/manual/en/function.uniqid.php#94959". + $uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + + // 32 bits for "time_low". + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + + // 16 bits for "time_mid". + mt_rand(0, 0xffff), + + // 16 bits for "time_hi_and_version", + // four most significant bits holds version number 4. + mt_rand(0, 0x0fff) | 0x4000, + + // 16 bits, 8 bits for "clk_seq_hi_res", + // 8 bits for "clk_seq_low", + // two most significant bits holds zero and one for variant DCE1.1. + mt_rand(0, 0x3fff) | 0x8000, + + // 48 bits for "node". + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)); + } + return trim($uuid); + } + + + /** + * Create and get a lock + * @param string $resource - The identifier for the lock. Should use frankenstyle prefix. + * @param int $timeout - The number of seconds to wait for a lock before giving up. + * @param int $maxlifetime - Unused by this lock type. + * @return boolean - true if a lock was obtained. + */ + public function get_lock($resource, $timeout, $maxlifetime = 86400) { + + $token = $this->generate_unique_token(); + $now = time(); + $giveuptime = $now + $timeout; + $expires = $now + $maxlifetime; + + if (!$this->db->record_exists('lock_db', array('resourcekey' => $resource))) { + $record = new \stdClass(); + $record->resourcekey = $resource; + $result = $this->db->insert_record('lock_db', $record); + } + + $params = array('expires' => $expires, + 'token' => $token, + 'resourcekey' => $resource, + 'now' => $now); + $sql = 'UPDATE {lock_db} + SET + expires = :expires, + owner = :token + WHERE + resourcekey = :resourcekey AND + (owner IS NULL OR expires < :now)'; + + do { + $now = time(); + $params['now'] = $now; + $this->db->execute($sql, $params); + + $countparams = array('owner' => $token, 'resourcekey' => $resource); + $result = $this->db->count_records('lock_db', $countparams); + $locked = $result === 1; + if (!$locked) { + usleep(rand(10000, 250000)); // Sleep between 10 and 250 milliseconds. + } + // Try until the giveup time. + } while (!$locked && $now < $giveuptime); + + if ($locked) { + $this->openlocks[$token] = 1; + return new lock($token, $this); + } + + return false; + } + + /** + * Release a lock that was previously obtained with @lock. + * @param lock $lock - a lock obtained from this factory. + * @return boolean - true if the lock is no longer held (including if it was never held). + */ + public function release_lock(lock $lock) { + $params = array('noexpires' => null, + 'token' => $lock->get_key(), + 'noowner' => null); + + $sql = 'UPDATE {lock_db} + SET + expires = :noexpires, + owner = :noowner + WHERE + owner = :token'; + $result = $this->db->execute($sql, $params); + if ($result) { + unset($this->openlocks[$lock->get_key()]); + } + return $result; + } + + /** + * Extend a lock that was previously obtained with @lock. + * @param lock $lock - a lock obtained from this factory. + * @param int $maxlifetime - the new lifetime for the lock (in seconds). + * @return boolean - true if the lock was extended. + */ + public function extend_lock(lock $lock, $maxlifetime = 86400) { + $now = time(); + $expires = $now + $maxlifetime; + $params = array('expires' => $expires, + 'token' => $lock->get_key()); + + $sql = 'UPDATE {lock_db} + SET + expires = :expires, + WHERE + owner = :token'; + + $this->db->execute($sql, $params); + $countparams = array('owner' => $lock->get_key()); + $result = $this->count_records('lock_db', $countparams); + + return $result === 0; + } + + /** + * Auto release any open locks on shutdown. + * This is required, because we may be using persistent DB connections. + */ + public function auto_release() { + // Called from the shutdown handler. Must release all open locks. + foreach ($this->openlocks as $key => $unused) { + $lock = new lock($key, $this); + $this->release_lock($lock); + } + } +} diff --git a/lib/classes/lock/file_lock_factory.php b/lib/classes/lock/file_lock_factory.php new file mode 100644 index 00000000000..17c084a641b --- /dev/null +++ b/lib/classes/lock/file_lock_factory.php @@ -0,0 +1,196 @@ +. + +/** + * Flock based file locking factory. + * + * The file lock factory returns file locks locked with the flock function. Works OK, except on some + * NFS, exotic shared storage and exotic server OSes (like windows). On windows, a second attempt to get a + * lock will block indefinitely instead of timing out. + * + * @package core + * @category lock + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\lock; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Flock based file locking factory. + * + * The file lock factory returns file locks locked with the flock function. Works OK, except on some + * NFS, exotic shared storage and exotic server OSes (like windows). On windows, a second attempt to get a + * lock will block indefinitely instead of timing out. + * + * @package core + * @category lock + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class file_lock_factory implements lock_factory { + + /** @var string $type - The type of lock, e.g. cache, cron, session. */ + protected $type; + + /** @var string $lockdirectory - Full system path to the directory used to store file locks. */ + protected $lockdirectory; + + /** @var boolean $verbose - If true, debugging info about the owner of the lock will be written to the lock file. */ + protected $verbose; + + /** + * Create this lock factory. + * + * @param string $type - The type, e.g. cron, cache, session + */ + public function __construct($type) { + global $CFG; + + $this->type = $type; + if (!isset($CFG->file_lock_root)) { + $this->lockdirectory = $CFG->dataroot . '/lock'; + } else { + $this->lockdirectory = $CFG->file_lock_root; + } + $this->verbose = false; + if ($CFG->debugdeveloper) { + $this->verbose = true; + } + } + + /** + * Return information about the blocking behaviour of the lock type on this platform. + * @return boolean - False if attempting to get a lock will block indefinitely. + */ + public function supports_timeout() { + global $CFG; + + return $CFG->ostype !== 'WINDOWS'; + } + + /** + * This lock type will be automatically released when a process ends. + * @return boolean - True + */ + public function supports_auto_release() { + return true; + } + + /** + * Is available. + * @return boolean - True if this lock type is available in this environment. + */ + public function is_available() { + return true; + } + + /** + * Multiple locks for the same resource cannot be held from a single process. + * @return boolean - False + */ + public function supports_recursion() { + return false; + } + + /** + * Get some info that might be useful for debugging. + * @return boolean - string + */ + protected function get_debug_info() { + return 'host:' . php_uname('n') . ', pid:' . getmypid() . ', time:' . time(); + } + + /** + * Get a lock within the specified timeout or return false. + * @param string $resource - The identifier for the lock. Should use frankenstyle prefix. + * @param int $timeout - The number of seconds to wait for a lock before giving up. + * @param int $maxlifetime - Unused by this lock type. + * @return boolean - true if a lock was obtained. + */ + public function get_lock($resource, $timeout, $maxlifetime = 86400) { + global $CFG; + + $giveuptime = time() + $timeout; + + $hash = md5($this->type . '_' . $resource); + $lockdir = $this->lockdirectory . '/' . substr($hash, 0, 2); + + if (!check_dir_exists($lockdir, true, true)) { + return false; + } + + $lockfilename = $lockdir . '/' . $hash; + + $filehandle = fopen($lockfilename, "wb"); + + // Could not open the lock file. + if (!$filehandle) { + return false; + } + + do { + // Will block on windows. So sad. + $wouldblock = false; + $locked = flock($filehandle, LOCK_EX | LOCK_NB, $wouldblock); + if (!$locked && $wouldblock) { + usleep(rand(10000, 250000)); // Sleep between 10 and 250 milliseconds. + } + // Try until the giveup time. + } while (!$locked && $wouldblock && time() < $giveuptime); + + if (!$locked) { + fclose($filehandle); + return false; + } + if ($this->verbose) { + fwrite($filehandle, $this->get_debug_info()); + } + return new lock($filehandle, $this); + } + + /** + * Release a lock that was previously obtained with @lock. + * @param lock $lock - A lock obtained from this factory. + * @return boolean - true if the lock is no longer held (including if it was never held). + */ + public function release_lock(lock $lock) { + $handle = $lock->get_key(); + + if (!$handle) { + // We didn't have a lock. + return false; + } + + $result = flock($handle, LOCK_UN); + fclose($handle); + return $result; + } + + /** + * Extend a lock that was previously obtained with @lock. + * @param lock $lock - not used + * @param int $maxlifetime - not used + * @return boolean - true if the lock was extended. + */ + public function extend_lock(lock $lock, $maxlifetime = 86400) { + // Not supported by this factory. + return false; + } + +} diff --git a/lib/classes/lock/lock.php b/lib/classes/lock/lock.php new file mode 100644 index 00000000000..e944b59adc8 --- /dev/null +++ b/lib/classes/lock/lock.php @@ -0,0 +1,112 @@ +. + +/** + * Class representing a lock + * + * The methods available for a specific lock type are only known by it's factory. + * + * @package core + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\lock; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class representing a lock + * + * The methods available for a specific lock type are only known by it's factory. + * + * @package core + * @category lock + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class lock { + + /** @var string|int $key A uniq key representing a held lock */ + protected $key = ''; + + /** @var lock_factory $factory The factory that generated this lock */ + protected $factory; + + /** @var bool $released Has this lock been released? If a lock falls out of scope without being released - show a warning. */ + protected $released; + + /** + * Construct a lock containing the unique key required to release it. + * @param string $key - The lock key. + * @param lock_factory $factory - The factory that generated this lock. + */ + public function __construct($key, $factory) { + $this->factory = $factory; + $this->key = $key; + $this->released = false; + } + + /** + * Return the unique key representing this lock. + * @return string|int lock key. + */ + public function get_key() { + return $this->key; + } + + /** + * Extend the lifetime of this lock. Not supported by all factories. + * @param int $maxlifetime - the new lifetime for the lock (in seconds). + * @return bool + */ + public function extend($maxlifetime = 86400) { + if ($this->factory) { + return $this->factory->extend_lock($this, $maxlifetime); + } + return false; + } + + /** + * Release this lock + * @return bool + */ + public function release() { + $this->released = true; + if (empty($this->factory)) { + return false; + } + $result = $this->factory->release_lock($this); + // Release any held references to the factory. + unset($this->factory); + $this->factory = null; + $this->key = ''; + return $result; + } + + /** + * Print debugging if this lock falls out of scope before being released. + */ + public function __destruct() { + if (!$this->released && defined('PHPUNIT_TEST')) { + $this->release(); + throw new \coding_exception('\core\lock\lock(' . $this->key . ') has fallen out of scope ' . + 'without being released.' . "\n" . + 'Locks must ALWAYS be released by calling $mylock->release().'); + } + } + +} diff --git a/lib/classes/lock/lock_config.php b/lib/classes/lock/lock_config.php new file mode 100644 index 00000000000..50d2e1899a8 --- /dev/null +++ b/lib/classes/lock/lock_config.php @@ -0,0 +1,79 @@ +. + +/** + * Lock configuration class, used to get an instance of the currently configured lock factory. + * + * @package core + * @category lock + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\lock; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Lock configuration class, used to get an instance of the currently configured lock factory. + * + * @package core + * @category lock + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class lock_config { + + /** + * Get an instance of the currently configured locking subclass. + * + * @param string $type - Unique namespace for the locks generated by this factory. e.g. core_cron + * @return \core\lock\lock_factory + */ + public static function get_lock_factory($type) { + global $CFG, $DB; + $lockfactory = null; + + if (isset($CFG->lock_factory) && $CFG->lock_factory != 'auto') { + if (!class_exists($CFG->lock_factory)) { + // In this case I guess it is not safe to continue. Different cluster nodes could end up using different locking + // types because of an installation error. + throw new \coding_exception('Lock factory set in $CFG does not exist: ' . $CFG->lock_factory); + } + $lockfactoryclass = $CFG->lock_factory; + $lockfactory = new $lockfactoryclass($type); + } else { + $dbtype = clean_param($DB->get_dbfamily(), PARAM_ALPHA); + + // DB Specific lock factory is prefered - should support auto-release. + $lockfactoryclass = "\\core\\lock\\${dbtype}_lock_factory"; + if (!class_exists($lockfactoryclass)) { + if (empty($CFG->preventfilelocking)) { + // File locking is second option - if $CFG->preventfilelocking allows it. + $lockfactoryclass = '\core\lock\file_lock_factory'; + } else { + // Final fallback - DB row locking. Does not support auto-release - so on failures + // we will have to wait for a timeout. + $lockfactoryclass = '\core\lock\db_record_lock_factory'; + } + } + $lockfactory = new $lockfactoryclass($type); + } + + return $lockfactory; + } + +} diff --git a/lib/classes/lock/lock_factory.php b/lib/classes/lock/lock_factory.php new file mode 100644 index 00000000000..8dfcf111693 --- /dev/null +++ b/lib/classes/lock/lock_factory.php @@ -0,0 +1,106 @@ +. + +/** + * Defines abstract factory class for generating locks. + * + * @package core + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\lock; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Defines abstract factory class for generating locks. + * + * @package core + * @category lock + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface lock_factory { + + /** + * Define the constructor signature required by the lock_config class. + * + * @param string $type - The type this lock is used for (e.g. cron, cache) + */ + public function __construct($type); + + /** + * Return information about the blocking behaviour of the locks on this platform. + * + * @return boolean - False if attempting to get a lock will block indefinitely. + */ + public function supports_timeout(); + + /** + * Will this lock be automatically released when the process ends. + * This should never be relied upon in code - but is useful in the case of + * fatal errors. If a lock type does not support this auto release, + * the max lock time parameter must be obeyed to eventually clean up a lock. + * + * @return boolean - True if this lock type will be automatically released when the current process ends. + */ + public function supports_auto_release(); + + /** + * supports_recursion + * + * @return boolean - True if attempting to get 2 locks on the same resource will "stack" + */ + public function supports_recursion(); + + /** + * Is available. + * + * @return boolean - True if this lock type is available in this environment. + */ + public function is_available(); + + /** + * Get a lock within the specified timeout or return false. + * + * @param string $resource - The identifier for the lock. Should use frankenstyle prefix. + * @param int $timeout - The number of seconds to wait for a lock before giving up. + * Not all lock types will support this. + * @param int $maxlifetime - The number of seconds to wait before reclaiming a stale lock. + * Not all lock types will use this - e.g. if they support auto releasing + * a lock when a process ends. + * @return \core\lock\lock|boolean - An instance of \core\lock\lock if the lock was obtained, or false. + */ + public function get_lock($resource, $timeout, $maxlifetime = 86400); + + /** + * Release a lock that was previously obtained with @lock. + * + * @param lock $lock - The lock to release. + * @return boolean - True if the lock is no longer held (including if it was never held). + */ + public function release_lock(lock $lock); + + /** + * Extend the timeout on a held lock. + * + * @param lock $lock - lock obtained from this factory + * @param int $maxlifetime - new max time to hold the lock + * @return boolean - True if the lock was extended. + */ + public function extend_lock(lock $lock, $maxlifetime = 86400); +} diff --git a/lib/classes/lock/mysql_lock_factory.php b/lib/classes/lock/mysql_lock_factory.php new file mode 100644 index 00000000000..bcb8626cc0d --- /dev/null +++ b/lib/classes/lock/mysql_lock_factory.php @@ -0,0 +1,157 @@ +. + +/** + * MySQL GET_LOCK locking factory. + * + * @package core + * @category lock + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\lock; + +defined('MOODLE_INTERNAL') || die(); + +/** + * MySQL GET_LOCK locking factory. + * + * Use MySQL GET_LOCK functions to support locking. Supports auto-release and timeouts and should be fairly quick. + * + * @package core + * @category lock + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mysql_lock_factory implements lock_factory { + + /** @var moodle_database $db Hold a reference to the global $DB */ + protected $db; + + /** @var string $type Used to prefix lock keys */ + protected $type; + + /** @var array $openlocks - List of held locks - used by auto-release */ + protected $openlocks = array(); + + /** + * Is available. + * @return boolean - True if this lock type is available in this environment. + */ + public function is_available() { + return $this->db->get_dbfamily() === 'mysql'; + } + + /** + * Almighty constructor. + * @param string $type - Used to prefix lock keys. + */ + public function __construct($type) { + global $DB; + + $this->type = $type; + // Save a reference to the global $DB so it will not be released while we still have open locks. + $this->db = $DB; + + \core_shutdown_manager::register_function(array($this, 'auto_release')); + } + + /** + * Return information about the blocking behaviour of the lock type on this platform. + * @return boolean - Defer to the DB driver. + */ + public function supports_timeout() { + return true; + } + + /** + * Will this lock type will be automatically released when a process ends. + * + * @return boolean - Defer to the DB driver. + */ + public function supports_auto_release() { + return true; + } + + /** + * Multiple locks for the same resource can be held by a single process. + * @return boolean - Defer to the DB driver. + */ + public function supports_recursion() { + return true; + } + + /** + * Create and get a lock + * @param string $resource - The identifier for the lock. Should use frankenstyle prefix. + * @param int $timeout - The number of seconds to wait for a lock before giving up. + * @param int $maxlifetime - Unused by this lock type. + * @return boolean - true if a lock was obtained. + */ + public function get_lock($resource, $timeout, $maxlifetime = 86400) { + $params = array( + 'key' => $resource, + 'timeout' => $timeout + ); + $result = $this->db->get_record_sql('SELECT GET_LOCK(:key, :timeout) AS locked', + $params); + $locked = (bool)($result->locked); + + if ($locked) { + $this->openlocks[$resource] = 1; + return new lock($resource, $this); + } + return false; + } + + /** + * Release a lock that was previously obtained with @lock. + * @param lock $lock - a lock obtained from this factory. + * @return boolean - true if the lock is no longer held (including if it was never held). + */ + public function release_lock(lock $lock) { + $result = $this->db->get_record_sql('SELECT RELEASE_LOCK(:key) AS unlocked', array('key' => $lock->get_key())); + $result = (bool)$result->unlocked; + if ($result) { + unset($this->openlocks[$lock->get_key()]); + } + return $result; + } + + /** + * Extend a lock that was previously obtained with @lock. + * @param lock $lock - a lock obtained from this factory. + * @param int $maxlifetime - the new lifetime for the lock (in seconds). + * @return boolean - true if the lock was extended. + */ + public function extend_lock(lock $lock, $maxlifetime = 86400) { + // Not supported by this factory. + return false; + } + + /** + * Auto release any open locks on shutdown. + * This is required, because we may be using persistent DB connections. + */ + public function auto_release() { + // Called from the shutdown handler. Must release all open locks. + foreach ($this->openlocks as $key => $unused) { + $lock = new lock($key, $this); + $this->release_lock($lock); + } + } +} diff --git a/lib/classes/lock/postgres_lock_factory.php b/lib/classes/lock/postgres_lock_factory.php new file mode 100644 index 00000000000..c98cf523f31 --- /dev/null +++ b/lib/classes/lock/postgres_lock_factory.php @@ -0,0 +1,244 @@ +. + +/** + * Postgres advisory locking factory. + * + * @package core + * @category lock + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\lock; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Postgres advisory locking factory. + * + * Postgres locking implementation using advisory locks. Some important points. Postgres has + * 2 different forms of lock functions, some accepting a single int, and some accepting 2 ints. This implementation + * uses the 2 int version so that it uses a separate namespace from the session locking. The second note, + * is because postgres uses integer keys for locks, we first need to map strings to a unique integer. This is + * done by storing the strings in the lock_db table and using the auto-id returned. There is a static cache for + * id's in this function. + * + * @package core + * @category lock + * @copyright Damyon Wiese 2013 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class postgres_lock_factory implements lock_factory { + + /** @var int $dblockid - used as a namespace for these types of locks (separate from session locks) */ + protected $dblockid = -1; + + /** @var array $lockidcache - static cache for string -> int conversions required for pg advisory locks. */ + protected static $lockidcache = array(); + + /** @var moodle_database $db Hold a reference to the global $DB */ + protected $db; + + /** @var string $type Used to prefix lock keys */ + protected $type; + + /** @var array $openlocks - List of held locks - used by auto-release */ + protected $openlocks = array(); + + /** + * Calculate a unique instance id based on the database name and prefix. + * @return int. + */ + protected function get_unique_db_instance_id() { + global $CFG; + + $strkey = $CFG->dbname . ':' . $CFG->prefix; + $intkey = crc32($strkey); + // Normalize between 64 bit unsigned int and 32 bit signed ints. Php could return either from crc32. + if (PHP_INT_SIZE == 8) { + if ($intkey > 0x7FFFFFFF) { + $intkey -= 0x100000000; + } + } + + return $intkey; + } + + /** + * Almighty constructor. + * @param string $type - Used to prefix lock keys. + */ + public function __construct($type) { + global $DB; + + $this->type = $type; + $this->dblockid = $this->get_unique_db_instance_id(); + // Save a reference to the global $DB so it will not be released while we still have open locks. + $this->db = $DB; + + \core_shutdown_manager::register_function(array($this, 'auto_release')); + } + + /** + * Is available. + * @return boolean - True if this lock type is available in this environment. + */ + public function is_available() { + return $this->db->get_dbfamily() === 'postgres'; + } + + /** + * Return information about the blocking behaviour of the lock type on this platform. + * @return boolean - Defer to the DB driver. + */ + public function supports_timeout() { + return true; + } + + /** + * Will this lock type will be automatically released when a process ends. + * + * @return boolean - Via shutdown handler. + */ + public function supports_auto_release() { + return true; + } + + /** + * Multiple locks for the same resource can be held by a single process. + * @return boolean - Defer to the DB driver. + */ + public function supports_recursion() { + return true; + } + + /** + * This function generates the unique index for a specific lock key. + * Once an index is assigned to a key, it never changes - so this is + * statically cached. + * + * @param string $key + * @return int + */ + protected function get_index_from_key($key) { + global $DB; + + if (isset(self::$lockidcache[$key])) { + return self::$lockidcache[$key]; + } + + $index = 0; + $record = $this->db->get_record('lock_db', array('resourcekey' => $key)); + if ($record) { + $index = $record->id; + } + + if (!$index) { + $record = new \stdClass(); + $record->resourcekey = $key; + try { + $index = $this->db->insert_record('lock_db', $record); + } catch (dml_exception $de) { + // Race condition - never mind - now the value is guaranteed to exist. + $record = $this->db->get_record('lock_db', array('resourcekey' => $key)); + if ($record) { + $index = $record->id; + } + } + } + + if (!$index) { + throw new moodle_exception('Could not generate unique index for key'); + } + + self::$lockidcache[$key] = $index; + return $index; + } + + /** + * Create and get a lock + * @param string $resource - The identifier for the lock. Should use frankenstyle prefix. + * @param int $timeout - The number of seconds to wait for a lock before giving up. + * @param int $maxlifetime - Unused by this lock type. + * @return boolean - true if a lock was obtained. + */ + public function get_lock($resource, $timeout, $maxlifetime = 86400) { + $giveuptime = time() + $timeout; + + $token = $this->get_index_from_key($resource); + + $params = array('locktype' => $this->dblockid, + 'token' => $token); + + $locked = false; + + do { + $result = $this->db->get_record_sql('SELECT pg_try_advisory_lock(:locktype, :token) AS locked', $params); + $locked = $result->locked === 't'; + if (!$locked) { + usleep(rand(10000, 250000)); // Sleep between 10 and 250 milliseconds. + } + // Try until the giveup time. + } while (!$locked && time() < $giveuptime); + + if ($locked) { + $this->openlocks[$token] = 1; + return new lock($token, $this); + } + return false; + } + + /** + * Release a lock that was previously obtained with @lock. + * @param lock $lock - a lock obtained from this factory. + * @return boolean - true if the lock is no longer held (including if it was never held). + */ + public function release_lock(lock $lock) { + $params = array('locktype' => $this->dblockid, + 'token' => $lock->get_key()); + $result = $this->db->get_record_sql('SELECT pg_advisory_unlock(:locktype, :token) AS unlocked', $params); + $result = $result->unlocked === 't'; + if ($result) { + unset($this->openlocks[$lock->get_key()]); + } + return $result; + } + + /** + * Extend a lock that was previously obtained with @lock. + * @param lock $lock - a lock obtained from this factory. + * @param int $maxlifetime - the new lifetime for the lock (in seconds). + * @return boolean - true if the lock was extended. + */ + public function extend_lock(lock $lock, $maxlifetime = 86400) { + // Not supported by this factory. + return false; + } + + /** + * Auto release any open locks on shutdown. + * This is required, because we may be using persistent DB connections. + */ + public function auto_release() { + // Called from the shutdown handler. Must release all open locks. + foreach ($this->openlocks as $key => $unused) { + $lock = new lock($key, $this); + $this->release_lock($lock); + } + } + +} diff --git a/lib/db/install.xml b/lib/db/install.xml index 99a8360ee80..4dd6833181b 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1,5 +1,5 @@ - @@ -3066,5 +3066,21 @@ + + + + + + + + + + + + + + + +
diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 49376288a78..dac47994ad9 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -2927,5 +2927,32 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2014011701.00); } + if ($oldversion < 2014012400.00) { + // Define table lock_db to be created. + $table = new xmldb_table('lock_db'); + + // Adding fields to table lock_db. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('resourcekey', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null); + $table->add_field('expires', XMLDB_TYPE_INTEGER, '10', null, null, null, null); + $table->add_field('owner', XMLDB_TYPE_CHAR, '36', null, null, null, null); + + // Adding keys to table lock_db. + $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id')); + + // Adding indexes to table lock_db. + $table->add_index('resourcekey_uniq', XMLDB_INDEX_UNIQUE, array('resourcekey')); + $table->add_index('expires_idx', XMLDB_INDEX_NOTUNIQUE, array('expires')); + $table->add_index('owner_idx', XMLDB_INDEX_NOTUNIQUE, array('owner')); + + // Conditionally launch create table for lock_db. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2014012400.00); + } + return true; } diff --git a/lib/setuplib.php b/lib/setuplib.php index 0e6c4776224..799e53d3add 100644 --- a/lib/setuplib.php +++ b/lib/setuplib.php @@ -1255,7 +1255,7 @@ function disable_output_buffering() { */ function redirect_if_major_upgrade_required() { global $CFG; - $lastmajordbchanges = 2013100400.02; + $lastmajordbchanges = 2014012400.00; if (empty($CFG->version) or (float)$CFG->version < $lastmajordbchanges or during_initial_install() or !empty($CFG->adminsetuppending)) { try { diff --git a/lib/tests/lock_config_test.php b/lib/tests/lock_config_test.php new file mode 100644 index 00000000000..63aef074d70 --- /dev/null +++ b/lib/tests/lock_config_test.php @@ -0,0 +1,76 @@ +. + +/** + * lock unit tests + * + * @package core + * @category lock + * @copyright 2013 Damyon Wiese + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + + +/** + * Unit tests for our locking configuration. + * + * @package core + * @category lock + * @copyright 2013 Damyon Wiese + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class lock_config_testcase extends advanced_testcase { + + /** + * Tests the static parse charset method + * @return void + */ + public function test_lock_config() { + global $CFG; + $original = null; + if (isset($CFG->lock_factory)) { + $original = $CFG->lock_factory; + } + + // Test no configuration. + unset($CFG->lock_factory); + + $factory = \core\lock\lock_config::get_lock_factory('cache'); + + $this->assertNotEmpty($factory, 'Get a default factory with no configuration'); + + $CFG->lock_factory = '\core\lock\file_lock_factory'; + + $factory = \core\lock\lock_config::get_lock_factory('cache'); + $this->assertTrue($factory instanceof \core\lock\file_lock_factory, + 'Get a default factory with a set configuration'); + + $CFG->lock_factory = '\core\lock\db_record_lock_factory'; + + $factory = \core\lock\lock_config::get_lock_factory('cache'); + $this->assertTrue($factory instanceof \core\lock\db_record_lock_factory, + 'Get a default factory with a changed configuration'); + + if ($original) { + $CFG->lock_factory = $original; + } else { + unset($CFG->lock_factory); + } + } +} + diff --git a/lib/tests/lock_test.php b/lib/tests/lock_test.php new file mode 100644 index 00000000000..17c8e57cb1e --- /dev/null +++ b/lib/tests/lock_test.php @@ -0,0 +1,111 @@ +. + +/** + * lock unit tests + * + * @package core + * @category test + * @copyright 2013 Damyon Wiese + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + + +/** + * Unit tests for our locking implementations. + * + * @package core + * @category test + * @copyright 2013 Damyon Wiese + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class lock_testcase extends advanced_testcase { + + /** + * Some lock types will store data in the database. + */ + protected function setUp() { + $this->resetAfterTest(true); + } + + /** + * Run a suite of tests on a lock factory. + * @param \core\lock\lock_factory $lockfactory - A lock factory to test + */ + protected function run_on_lock_factory(\core\lock\lock_factory $lockfactory) { + + if ($lockfactory->is_available()) { + // This should work. + $lock1 = $lockfactory->get_lock('abc', 2); + $this->assertNotEmpty($lock1, 'Get a lock'); + + if ($lockfactory->supports_timeout()) { + if ($lockfactory->supports_recursion()) { + $lock2 = $lockfactory->get_lock('abc', 2); + $this->assertNotEmpty($lock2, 'Get a stacked lock'); + $this->assertTrue($lock2->release(), 'Release a stacked lock'); + } else { + // This should timeout. + $lock2 = $lockfactory->get_lock('abc', 2); + $this->assertFalse($lock2, 'Cannot get a stacked lock'); + } + } + // Release the lock. + $this->assertTrue($lock1->release(), 'Release a lock'); + // Get it again. + $lock3 = $lockfactory->get_lock('abc', 2); + + $this->assertNotEmpty($lock3, 'Get a lock again'); + // Release the lock again. + $this->assertTrue($lock3->release(), 'Release a lock again'); + // Release the lock again (shouldn't hurt). + $this->assertFalse($lock3->release(), 'Release a lock that is not held'); + if (!$lockfactory->supports_auto_release()) { + // Test that a lock can be claimed after the timeout period. + $lock4 = $lockfactory->get_lock('abc', 2, 2); + $this->assertNotEmpty($lock4, 'Get a lock'); + sleep(3); + + $lock5 = $lockfactory->get_lock('abc', 2, 2); + $this->assertNotEmpty($lock5, 'Get another lock after a timeout'); + $this->assertTrue($lock5->release(), 'Release the lock'); + $this->assertTrue($lock4->release(), 'Release the lock'); + } + } + } + + /** + * Tests the testable lock factories. + * @return void + */ + public function test_locks() { + // Run the suite on the current configured default (may be non-core). + $defaultfactory = \core\lock\lock_config::get_lock_factory('default'); + $this->run_on_lock_factory($defaultfactory); + + // Manually create the core no-configuration factories. + $dblockfactory = new \core\lock\db_record_lock_factory('test'); + $this->run_on_lock_factory($dblockfactory); + + $filelockfactory = new \core\lock\file_lock_factory('test'); + $this->run_on_lock_factory($filelockfactory); + + } + +} + diff --git a/lib/upgrade.txt b/lib/upgrade.txt index bd1a65200c6..c8279d4fef9 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -27,6 +27,8 @@ JavaSript: * The findChildNodes global function has been deprecated. Y.all should be used instead. +* New locking api and admin settings to configure the system locking type. + === 2.6 === * Use new methods from core_component class instead of get_core_subsystems(), get_plugin_types(), diff --git a/version.php b/version.php index 71015a71cc4..0c3562f8d54 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2014012300.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2014012400.00; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes.