Commit 8a0f7060 authored by sam marshall's avatar sam marshall
Browse files

MDL-72837 core_cache: Add versioned cache support

Adds new set_versioned and get_versioned APIs to cache, which means you can
request a specific version from cache and it will not return an outdated
version.

This is important when using multi-layer-caches where a local cache might have
an outdated version of the cache, but the shared cache has a current version.
With this feature, the content of the cache does not have to be rebuilt, as
it will automatically retrieve it from the shared cache if necessary.
parent 97778526
......@@ -48,6 +48,34 @@ interface cache_loader {
*/
public function get($key, $strictness = IGNORE_MISSING);
/**
* Retrieves the value and actual version for the given key, with at least the required version.
*
* If there is no value for the key, or there is a value but it doesn't have the required
* version, then this function will return false (or throw an exception if you set strictness
* to MUST_EXIST).
*
* This function can be used to make it easier to support localisable caches (where the cache
* could be stored on a local server as well as a shared cache). Specifying the version means
* that it will automatically retrieve the correct version if available, either from the local
* server or [if that has an older version] from the shared server.
*
* If the cached version is newer than specified version, it will be returned regardless. For
* example, if you request version 4, but the locally cached version is 5, it will be returned.
* If you request version 6, and the locally cached version is 5, then the system will look in
* higher-level caches (if any); if there still isn't a version 6 or greater, it will return
* null.
*
* You must use this function if you use set_versioned.
*
* @param string|int $key The key for the data being requested.
* @param int $requiredversion Minimum required version of the data
* @param int $strictness One of IGNORE_MISSING or MUST_EXIST.
* @param mixed $actualversion If specified, will be set to the actual version number retrieved
* @return mixed Data from the cache, or false if the key did not exist or was too old
*/
public function get_versioned($key, int $requiredversion, int $strictness = IGNORE_MISSING, &$actualversion = null);
/**
* Retrieves an array of values for an array of keys.
*
......@@ -82,6 +110,29 @@ interface cache_loader {
*/
public function set($key, $data);
/**
* Sets the value for the given key with the given version.
*
* The cache does not store multiple versions - any existing version will be overwritten with
* this one. This function should only be used if there is a known 'current version' (e.g.
* stored in a database table). It only ensures that the cache does not return outdated data.
*
* This function can be used to help implement localisable caches (where the cache could be
* stored on a local server as well as a shared cache). The version will be recorded alongside
* the item and get_versioned will always return the correct version.
*
* The version number must be an integer that always increases. This could be based on the
* current time, or a stored value that increases by 1 each time it changes, etc.
*
* If you use this function you must use get_versioned to retrieve the data.
*
* @param string|int $key The key for the data being set.
* @param int $version Integer for the version of the data
* @param mixed $data The data to set against the key.
* @return bool True on success, false otherwise.
*/
public function set_versioned($key, int $version, $data): bool;
/**
* Sends several key => value pairs to the cache.
*
......@@ -436,6 +487,32 @@ interface cache_data_source {
public function load_many_for_cache(array $keys);
}
/**
* Versionable cache data source.
*
* This interface extends the main cache data source interface to add an extra required method if
* the data source is to be used for a versioned cache.
*
* @package core_cache
*/
interface cache_data_source_versionable extends cache_data_source {
/**
* Loads the data for the key provided ready formatted for caching.
*
* If there is no data for that key, or if the data for the required key has an older version
* than the specified $requiredversion, then this returns null.
*
* If there is data then $actualversion should be set to the actual version number retrieved
* (may be the same as $requiredversion or newer).
*
* @param string|int $key The key to load.
* @param int $requiredversion Minimum required version
* @param mixed $actualversion Should be set to the actual version number retrieved
* @return mixed What ever data should be returned, or false if it can't be loaded.
*/
public function load_for_cache_versioned($key, int $requiredversion, &$actualversion);
}
/**
* Cacheable object.
*
......@@ -521,4 +598,4 @@ interface cache_lock_interface {
* Things such as unfortunate timeouts etc could cause this situation.
*/
public function __destruct();
}
\ No newline at end of file
}
......@@ -42,6 +42,11 @@ defined('MOODLE_INTERNAL') || die();
*/
class cache implements cache_loader {
/**
* @var int Constant for cache entries that do not have a version number
*/
const VERSION_NONE = -1;
/**
* We need a timestamp to use within the cache API.
* This stamp needs to be used for all ttl and time based operations to ensure that we don't end up with
......@@ -397,13 +402,101 @@ class cache implements cache_loader {
* @throws coding_exception
*/
public function get($key, $strictness = IGNORE_MISSING) {
return $this->get_implementation($key, self::VERSION_NONE, $strictness);
}
/**
* Retrieves the value and actual version for the given key, with at least the required version.
*
* If there is no value for the key, or there is a value but it doesn't have the required
* version, then this function will return null (or throw an exception if you set strictness
* to MUST_EXIST).
*
* This function can be used to make it easier to support localisable caches (where the cache
* could be stored on a local server as well as a shared cache). Specifying the version means
* that it will automatically retrieve the correct version if available, either from the local
* server or [if that has an older version] from the shared server.
*
* If the cached version is newer than specified version, it will be returned regardless. For
* example, if you request version 4, but the locally cached version is 5, it will be returned.
* If you request version 6, and the locally cached version is 5, then the system will look in
* higher-level caches (if any); if there still isn't a version 6 or greater, it will return
* null.
*
* You must use this function if you use set_versioned.
*
* @param string|int $key The key for the data being requested.
* @param int $requiredversion Minimum required version of the data
* @param int $strictness One of IGNORE_MISSING or MUST_EXIST.
* @param mixed $actualversion If specified, will be set to the actual version number retrieved
* @return mixed Data from the cache, or false if the key did not exist or was too old
* @throws \coding_exception If you call get_versioned on a non-versioned cache key
*/
public function get_versioned($key, int $requiredversion, int $strictness = IGNORE_MISSING, &$actualversion = null) {
return $this->get_implementation($key, $requiredversion, $strictness, $actualversion);
}
/**
* Checks returned data to see if it matches the specified version number.
*
* For versioned data, this returns the version_wrapper object (or false). For other
* data, it returns the actual data (or false).
*
* @param mixed $result Result data
* @param int $requiredversion Required version number or VERSION_NONE if there must be no version
* @return bool True if version is current, false if not (or if result is false)
* @throws \coding_exception If unexpected type of data (versioned vs non-versioned) is found
*/
protected static function check_version($result, int $requiredversion): bool {
if ($requiredversion === self::VERSION_NONE) {
if ($result instanceof \core_cache\version_wrapper) {
throw new \coding_exception('Unexpectedly found versioned cache entry');
} else {
// No version checks, so version is always correct.
return true;
}
} else {
// If there's no result, obviously it doesn't meet the required version.
if (!$result) {
return false;
}
if (!($result instanceof \core_cache\version_wrapper)) {
throw new \coding_exception('Unexpectedly found non-versioned cache entry');
}
// If the result doesn't match the required version tag, return false.
if ($result->version < $requiredversion) {
return false;
}
// The version meets the requirement.
return true;
}
}
/**
* Retrieves the value for the given key from the cache.
*
* @param string|int $key The key for the data being requested.
* It can be any structure although using a scalar string or int is recommended in the interests of performance.
* In advanced cases an array may be useful such as in situations requiring the multi-key functionality.
* @param int $requiredversion Minimum required version of the data or cache::VERSION_NONE
* @param int $strictness One of IGNORE_MISSING | MUST_EXIST
* @param mixed $actualversion If specified, will be set to the actual version number retrieved
* @return mixed|false The data from the cache or false if the key did not exist within the cache.
* @throws coding_exception
*/
protected function get_implementation($key, int $requiredversion, int $strictness, &$actualversion = null) {
// 1. Get it from the static acceleration array if we can (only when it is enabled and it has already been requested/set).
$usesstaticacceleration = $this->use_static_acceleration();
if ($usesstaticacceleration) {
$result = $this->static_acceleration_get($key);
if ($result !== false) {
return $result;
if ($result && self::check_version($result, $requiredversion)) {
if ($requiredversion === self::VERSION_NONE) {
return $result;
} else {
$actualversion = $result->version;
return $result->data;
}
}
}
......@@ -412,18 +505,58 @@ class cache implements cache_loader {
// 3. Get it from the store. Obviously wasn't in the static acceleration array.
$result = $this->store->get($parsedkey);
if ($result) {
// Check the result has at least the required version.
try {
$validversion = self::check_version($result, $requiredversion);
} catch (\coding_exception $e) {
// If we get an exception because there is incorrect data in the cache (not
// versioned when it ought to be), delete it so this exception goes away next time.
// The exception should only happen if there is a code bug (which is why we still
// throw it) but there are unusual scenarios where it might happen and that would
// be annoying if it doesn't fix itself.
$this->store->delete($parsedkey);
throw $e;
}
if (!$validversion) {
// If the result was too old, don't use it.
$result = false;
// Also delete it immediately. This improves performance in the
// case when the cache item is large and there may be multiple clients simultaneously
// requesting it - they won't all have to do a megabyte of IO just in order to find
// that it's out of date.
$this->store->delete($parsedkey);
}
}
if ($result !== false) {
if ($result instanceof cache_ttl_wrapper) {
if ($result->has_expired()) {
// Look to see if there's a TTL wrapper. It might be inside a version wrapper.
if ($requiredversion !== self::VERSION_NONE) {
$ttlconsider = $result->data;
} else {
$ttlconsider = $result;
}
if ($ttlconsider instanceof cache_ttl_wrapper) {
if ($ttlconsider->has_expired()) {
$this->store->delete($parsedkey);
$result = false;
} else if ($requiredversion === self::VERSION_NONE) {
// Use the data inside the TTL wrapper as the result.
$result = $ttlconsider->data;
} else {
$result = $result->data;
// Put the data from the TTL wrapper directly inside the version wrapper.
$result->data = $ttlconsider->data;
}
}
if ($usesstaticacceleration) {
$this->static_acceleration_set($key, $result);
}
// Remove version wrapper if necessary.
if ($requiredversion !== self::VERSION_NONE) {
$actualversion = $result->version;
$result = $result->data;
}
if ($result instanceof cache_cached_object) {
$result = $result->restore_object();
}
......@@ -439,9 +572,23 @@ class cache implements cache_loader {
// We must pass the original (unparsed) key to the next loader in the chain.
// The next loader will parse the key as it sees fit. It may be parsed differently
// depending upon the capabilities of the store associated with the loader.
$result = $this->loader->get($key);
if ($requiredversion === self::VERSION_NONE) {
$result = $this->loader->get($key);
} else {
$result = $this->loader->get_versioned($key, $requiredversion, IGNORE_MISSING, $actualversion);
}
} else if ($this->datasource !== false) {
$result = $this->datasource->load_for_cache($key);
if ($requiredversion === self::VERSION_NONE) {
$result = $this->datasource->load_for_cache($key);
} else {
if (!$this->datasource instanceof cache_data_source_versionable) {
throw new \coding_exception('Data source is not versionable');
}
$result = $this->datasource->load_for_cache_versioned($key, $requiredversion, $actualversion);
if ($result && $actualversion < $requiredversion) {
throw new \coding_exception('Data source returned outdated version');
}
}
}
$setaftervalidation = ($result !== false);
} else if ($this->perfdebug) {
......@@ -452,9 +599,14 @@ class cache implements cache_loader {
if ($strictness === MUST_EXIST && $result === false) {
throw new coding_exception('Requested key did not exist in any cache stores and could not be loaded.');
}
// 6. Set it to the store if we got it from the loader/datasource.
// 6. Set it to the store if we got it from the loader/datasource. Only set to this direct
// store; parent method will have set it to all stores if needed.
if ($setaftervalidation) {
$this->set($key, $result);
if ($requiredversion === self::VERSION_NONE) {
$this->set_implementation($key, self::VERSION_NONE, $result, false);
} else {
$this->set_implementation($key, $actualversion, $result, false);
}
}
// 7. Make sure we don't pass back anything that could be a reference.
// We don't want people modifying the data in the cache.
......@@ -630,10 +782,52 @@ class cache implements cache_loader {
* @return bool True on success, false otherwise.
*/
public function set($key, $data) {
if ($this->loader !== false) {
return $this->set_implementation($key, self::VERSION_NONE, $data);
}
/**
* Sets the value for the given key with the given version.
*
* The cache does not store multiple versions - any existing version will be overwritten with
* this one. This function should only be used if there is a known 'current version' (e.g.
* stored in a database table). It only ensures that the cache does not return outdated data.
*
* This function can be used to help implement localisable caches (where the cache could be
* stored on a local server as well as a shared cache). The version will be recorded alongside
* the item and get_versioned will always return the correct version.
*
* The version number must be an integer that always increases. This could be based on the
* current time, or a stored value that increases by 1 each time it changes, etc.
*
* If you use this function you must use get_versioned to retrieve the data.
*
* @param string|int $key The key for the data being set.
* @param int $version Integer for the version of the data
* @param mixed $data The data to set against the key.
* @return bool True on success, false otherwise.
*/
public function set_versioned($key, int $version, $data): bool {
return $this->set_implementation($key, $version, $data);
}
/**
* Sets the value for the given key, optionally with a version tag.
*
* @param string|int $key The key for the data being set.
* @param int $version Version number for the data or cache::VERSION_NONE if none
* @param mixed $data The data to set against the key.
* @param bool $setparents If true, sets all parent loaders, otherwise only this one
* @return bool True on success, false otherwise.
*/
protected function set_implementation($key, int $version, $data, bool $setparents = true): bool {
if ($this->loader !== false && $setparents) {
// We have a loader available set it there as well.
// We have to let the loader do its own parsing of data as it may be unique.
$this->loader->set($key, $data);
if ($version === self::VERSION_NONE) {
$this->loader->set($key, $data);
} else {
$this->loader->set_versioned($key, $version, $data);
}
}
$usestaticacceleration = $this->use_static_acceleration();
......@@ -648,7 +842,12 @@ class cache implements cache_loader {
}
if ($usestaticacceleration) {
$this->static_acceleration_set($key, $data);
// Static acceleration cache should include the cache version wrapper, but not TTL.
if ($version === self::VERSION_NONE) {
$this->static_acceleration_set($key, $data);
} else {
$this->static_acceleration_set($key, new \core_cache\version_wrapper($data, $version));
}
}
if ($this->has_a_ttl() && !$this->store_supports_native_ttl()) {
......@@ -656,6 +855,10 @@ class cache implements cache_loader {
}
$parsedkey = $this->parse_key($key);
if ($version !== self::VERSION_NONE) {
$data = new \core_cache\version_wrapper($data, $version);
}
$success = $this->store->set($parsedkey, $data);
if ($this->perfdebug) {
cache_helper::record_cache_set($this->store, $this->definition, 1,
......@@ -1505,14 +1708,16 @@ class cache_application extends cache implements cache_loader_with_locking {
* </code>
*
* @param string|int $key The key for the data being requested.
* @param int $version Version number
* @param mixed $data The data to set against the key.
* @param bool $setparents If true, sets all parent loaders, otherwise only this one
* @return bool True on success, false otherwise.
*/
public function set($key, $data) {
protected function set_implementation($key, int $version, $data, bool $setparents = true): bool {
if ($this->requirelockingwrite && !$this->acquire_lock($key)) {
return false;
}
$result = parent::set($key, $data);
$result = parent::set_implementation($key, $version, $data, $setparents);
if ($this->requirelockingwrite && !$this->release_lock($key)) {
debugging('Failed to release cache lock on set operation... this should not happen.', DEBUG_DEVELOPER);
}
......@@ -1569,15 +1774,17 @@ class cache_application extends cache implements cache_loader_with_locking {
* Retrieves the value for the given key from the cache.
*
* @param string|int $key The key for the data being requested.
* @param int $requiredversion Minimum required version of the data or cache::VERSION_NONE
* @param int $strictness One of IGNORE_MISSING | MUST_EXIST
* @param mixed &$actualversion If specified, will be set to the actual version number retrieved
* @return mixed|false The data from the cache or false if the key did not exist within the cache.
*/
public function get($key, $strictness = IGNORE_MISSING) {
protected function get_implementation($key, int $requiredversion, int $strictness, &$actualversion = null) {
if ($this->requirelockingread && $this->check_lock_state($key) === false) {
// Read locking required and someone else has the read lock.
return false;
}
return parent::get($key, $strictness);
return parent::get_implementation($key, $requiredversion, $strictness, $actualversion);
}
/**
......@@ -1830,16 +2037,18 @@ class cache_session extends cache {
* @param string|int $key The key for the data being requested.
* It can be any structure although using a scalar string or int is recommended in the interests of performance.
* In advanced cases an array may be useful such as in situations requiring the multi-key functionality.
* @param int $requiredversion Minimum required version of the data or cache::VERSION_NONE
* @param int $strictness One of IGNORE_MISSING | MUST_EXIST
* @param mixed &$actualversion If specified, will be set to the actual version number retrieved
* @return mixed|false The data from the cache or false if the key did not exist within the cache.
* @throws coding_exception
*/
public function get($key, $strictness = IGNORE_MISSING) {
protected function get_implementation($key, int $requiredversion, int $strictness, &$actualversion = null) {
// Check the tracked user.
$this->check_tracked_user();
// Use parent code.
return parent::get($key, $strictness);
return parent::get_implementation($key, $requiredversion, $strictness, $actualversion);
}
/**
......
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_cache;
/**
* Class wrapping information in the cache that is tagged with a version number.
*
* @package core_cache
* @copyright 2021 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class version_wrapper {
/**
* The data being stored.
* @var mixed
*/
public $data;
/**
* Version number for the data
* @var int
*/
public $version;
/**
* Constructs a version tag wrapper.
*
* @param mixed $data
* @param int $version Version number
*/
public function __construct($data, int $version) {
$this->data = $data;
$this->version = $version;
}
}
......@@ -61,14 +61,27 @@ class cache_disabled extends cache {
* Gets a key from the cache.
*
* @param int|string $key
* @param int $requiredversion Minimum required version of the data or cache::VERSION_NONE
* @param int $strictness Unused.
* @param mixed &$actualversion If specified, will be set to the actual version number retrieved
* @return bool
*/
public function get($key, $strictness = IGNORE_MISSING) {
if ($this->get_datasource() !== false) {
return $this->get_datasource()->load_for_cache($key);
protected function get_implementation($key, int $requiredversion, int $strictness, &$actualversion = null) {
$datasource = $this->get_datasource();
if ($datasource !== false) {
if ($requiredversion === cache::VERSION_NONE) {
return $datasource->load_for_cache($key);
} else {
if (!$datasource instanceof cache_data_source_versionable) {
throw new \coding_exception('Data source is not versionable');
}
$result = $datasource->load_for_cache_versioned($key, $requiredversion, $actualversion);
if ($result && $actualversion < $requiredversion) {
throw new \coding_exception('Data source returned outdated version');
}
return $result;
}
}
return false;
}
......@@ -91,10 +104,12 @@ class cache_disabled extends cache {
* Sets a key value pair in the cache.
*
* @param int|string $key Unused.
* @param int $version Unused.
* @param mixed $data Unused.
* @param bool $setparents Unused.
* @return bool
*/
public function set($key, $data) {
protected function set_implementation($key, int $version, $data, bool $setparents = true): bool {
return false;
}
......
......@@ -225,4 +225,4 @@ class cacheable_object_array extends ArrayObject implements cacheable_object {
$class = __CLASS__;
return new $class($result);
}
}
\ No newline at end of file
}
This diff is collapsed.
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* A dummy datasource which supports versioning.
*
* @package core_cache
* @copyright 2021 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class cache_phpunit_dummy_datasource_versionable extends cache_phpunit_dummy_datasource
implements cache_data_source_versionable {
/** @var array Data in cache */
protected $data = [];
/** @var cache_phpunit_dummy_datasource_versionable Last created instance */
protected static $lastinstance;
/**
* Returns an instance of this object for use with the cache.
*
* @param cache_definition $definition
* @return cache_phpunit_dummy_datasource New object
*/
public static function get_instance_for_cache(cache_definition $definition):
cache_phpunit_dummy_datasource_versionable {
self::$lastinstance = new cache_phpunit_dummy_datasource_versionable();
return self::$lastinstance;
}
/**
* Gets the last instance that was created.
*
* @return cache_phpunit_dummy_datasource_versionable
*/
public static function get_last_instance(): cache_phpunit_dummy_datasource_versionable {
return self::$lastinstance;
}
/**
* Sets up the datasource so that it has a value for a particular key.