diff --git a/core/includes/cache.inc b/core/includes/cache.inc index d3c3414..1b709f5 100644 --- a/core/includes/cache.inc +++ b/core/includes/cache.inc @@ -32,16 +32,40 @@ function cache($bin = 'cache') { // storage of a cache bin mid-request. static $cache_objects; if (!isset($cache_objects[$bin])) { - $class = variable_get('cache_class_' . $bin); - if (!isset($class)) { - $class = variable_get('cache_default_class', 'Drupal\Core\Cache\DatabaseBackend'); - } + $cache_backends = cache_get_backends(); + $class = isset($cache_backends[$bin]) ? $cache_backends[$bin] : $cache_backends['cache']; $cache_objects[$bin] = new $class($bin); } return $cache_objects[$bin]; } /** + * Invalidates the items associated with given list of tags. + * + * Many sites have more than one active cache backend, and each backend my use + * a different strategy for storing tags against cache items, and invalidating + * cache items associated with a given tag. + * + * When invalidating a given list of tags, we iterate over each cache backend, + * and call invalidate on each. + * + * @param array $tags + * The list of tags to invalidate cache items for. + */ +function cache_invalidate(array $tags) { + foreach (cache_get_backends() as $bin => $class) { + cache($bin)->invalidateTags($tags); + } +} + +/** + * Returns a list of cache backends for this site. + */ +function cache_get_backends() { + return variable_get('cache_classes', array('cache' => 'Drupal\Core\Cache\DatabaseBackend')); +} + +/** * Expires data from the block and page caches. */ function cache_clear_all() { diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc index 07e25a0..0a1c1be 100644 --- a/core/includes/install.core.inc +++ b/core/includes/install.core.inc @@ -297,7 +297,7 @@ function install_begin_request(&$install_state) { // because any data put in the cache during the installer is inherently // suspect, due to the fact that Drupal is not fully set up yet. require_once DRUPAL_ROOT . '/core/includes/cache.inc'; - $conf['cache_default_class'] = 'Drupal\Core\Cache\InstallBackend'; + $conf['cache_classes'] = array('cache' => 'Drupal\Core\Cache\InstallBackend'); // Prepare for themed output. We need to run this at the beginning of the // page request to avoid a different theme accidentally getting set. (We also diff --git a/core/lib/Drupal/Core/Cache/CacheBackendInterface.php b/core/lib/Drupal/Core/Cache/CacheBackendInterface.php index b4c21ce..e0f2eb2 100644 --- a/core/lib/Drupal/Core/Cache/CacheBackendInterface.php +++ b/core/lib/Drupal/Core/Cache/CacheBackendInterface.php @@ -99,8 +99,14 @@ interface CacheBackendInterface { * general cache wipe. * - A Unix timestamp: Indicates that the item should be kept at least until * the given time, after which it behaves like CACHE_TEMPORARY. + * @param $tags + * An array of tags to be stored with the cache item. These should normally + * identify objects used to build the cache item, which should trigger + * cache invalidation when updated. For example if a cached item represents + * a node, both the node ID and the author's user ID might be passed in as + * tags. For example array('node' => array(123), 'user' => array(92)).* */ - function set($cid, $data, $expire = CACHE_PERMANENT); + function set($cid, $data, $expire = CACHE_PERMANENT, $tags = array()); /** * Deletes an item from the cache. @@ -137,6 +143,17 @@ interface CacheBackendInterface { function expire(); /** + * Invalidates each tag in the $tags array. + * + * @param $tags + * Associative array of tags, in the same format that is passed to + * CacheBackendInterface::set(). + * + * @see CacheBackendInterface::set() + */ + function invalidateTags($tags); + + /** * Performs garbage collection on a cache bin. */ function garbageCollection(); diff --git a/core/lib/Drupal/Core/Cache/DatabaseBackend.php b/core/lib/Drupal/Core/Cache/DatabaseBackend.php index 44a4111..bd4e7d3 100644 --- a/core/lib/Drupal/Core/Cache/DatabaseBackend.php +++ b/core/lib/Drupal/Core/Cache/DatabaseBackend.php @@ -23,6 +23,11 @@ class DatabaseBackend implements CacheBackendInterface { protected $bin; /** + * A static cache of all tags checked during the request. + */ + protected static $tagCache; + + /** * Implements Drupal\Core\Cache\CacheBackendInterface::__construct(). */ function __construct($bin) { @@ -58,7 +63,7 @@ class DatabaseBackend implements CacheBackendInterface { // is used here only due to the performance overhead we would incur // otherwise. When serving an uncached page, the overhead of using // db_select() is a much smaller proportion of the request. - $result = db_query('SELECT cid, data, created, expire, serialized FROM {' . db_escape_table($this->bin) . '} WHERE cid IN (:cids)', array(':cids' => $cids)); + $result = db_query('SELECT cid, data, created, expire, serialized, tags, checksum FROM {' . db_escape_table($this->bin) . '} WHERE cid IN (:cids)', array(':cids' => $cids)); $cache = array(); foreach ($result as $item) { $item = $this->prepareItem($item); @@ -104,6 +109,14 @@ class DatabaseBackend implements CacheBackendInterface { return FALSE; } + // The cache data is invalid if any of its tags have been cleared since. + if ($cache->tags) { + $cache->tags = explode(' ', $cache->tags); + if (!$this->validTags($cache->checksum, $cache->tags)) { + return FALSE; + } + } + // If the data is permanent or not subject to a minimum cache lifetime, // unserialize and return the cached data. if ($cache->serialized) { @@ -116,11 +129,13 @@ class DatabaseBackend implements CacheBackendInterface { /** * Implements Drupal\Core\Cache\CacheBackendInterface::set(). */ - function set($cid, $data, $expire = CACHE_PERMANENT) { + function set($cid, $data, $expire = CACHE_PERMANENT, $tags = array()) { $fields = array( 'serialized' => 0, 'created' => REQUEST_TIME, 'expire' => $expire, + 'tags' => implode(' ', $this->flattenTags($tags)), + 'checksum' => $this->checksumTags($tags), ); if (!is_string($data)) { $fields['data'] = serialize($data); @@ -154,7 +169,7 @@ class DatabaseBackend implements CacheBackendInterface { /** * Implements Drupal\Core\Cache\CacheBackendInterface::deleteMultiple(). */ - function deleteMultiple(Array $cids) { + function deleteMultiple(array $cids) { // Delete in chunks when a large array is passed. do { db_delete($this->bin) @@ -254,6 +269,94 @@ class DatabaseBackend implements CacheBackendInterface { } /** + * Compares two checksums of tags. Used to determine whether to serve a cached + * item or treat it as invalidated. + * + * @param integer @checksum + * The initial checksum to compare against. + * @param array @tags + * An array of tags to calculate a checksum for. + * + * @return boolean + * TRUE if the checksums match, FALSE otherwise. + */ + protected function validTags($checksum, array $tags) { + return $checksum == $this->checksumTags($tags); + } + + /** + * Flattens a tags array into a numeric array suitable for string storage. + * + * @param array $tags + * Associative array of tags to flatten. + * + * @return + * Numeric array of flattened tag identifiers. + */ + protected function flattenTags(array $tags) { + if (isset($tags[0])) { + return $tags; + } + + $flat_tags = array(); + foreach ($tags as $namespace => $values) { + if (is_array($values)) { + foreach ($values as $value) { + $flat_tags[] = "$namespace:$value"; + } + } + else { + $flat_tags[] = "$namespace:$values"; + } + } + return $flat_tags; + } + + /** + * Implements Drupal\Core\Cache\CacheBackendInterface::invalidateTags(). + */ + public function invalidateTags($tags) { + foreach ($this->flattenTags($tags) as $tag) { + unset($this->tagCache[$tag]); + db_merge('cache_tags') + ->key(array('tag' => $tag)) + ->fields(array('invalidations' => 1)) + ->expression('invalidations', 'invalidations + 1') + ->execute(); + } + } + + /** + * Returns the sum total of validations for a given set of tags. + * + * @param array $tags + * Associative array of tags. + * + * @return integer + * Sum of all invalidations. + */ + protected function checksumTags($tags) { + $checksum = 0; + $query_tags = array(); + + foreach ($this->flattenTags($tags) as $tag) { + if (isset($this->tagCache[$tag])) { + $checksum += $this->tagCache[$tag]; + } + else { + $query_tags[] = $tag; + } + } + if ($query_tags) { + if ($db_tags = db_query('SELECT tag, invalidations FROM {cache_tags} WHERE tag IN (:tags)', array(':tags' => $query_tags))->fetchAllKeyed()) { + $this->tagCache = array_merge($this->tag_cache, $db_tags); + $checksum += array_sum($db_tags); + } + } + return $checksum; + } + + /** * Implements Drupal\Core\Cache\CacheBackendInterface::isEmpty(). */ function isEmpty() { diff --git a/core/lib/Drupal/Core/Cache/InstallBackend.php b/core/lib/Drupal/Core/Cache/InstallBackend.php index 53f9b23..94b1a35 100644 --- a/core/lib/Drupal/Core/Cache/InstallBackend.php +++ b/core/lib/Drupal/Core/Cache/InstallBackend.php @@ -50,7 +50,7 @@ class InstallBackend extends DatabaseBackend { /** * Overrides Drupal\Core\Cache\CacheBackendInterface::set(). */ - function set($cid, $data, $expire = CACHE_PERMANENT) {} + function set($cid, $data, $expire = CACHE_PERMANENT, $tags = array()) {} /** * Implements Drupal\Core\Cache\CacheBackendInterface::delete(). @@ -88,6 +88,15 @@ class InstallBackend extends DatabaseBackend { catch (Exception $e) {} } + function invalidateTags($tags) { + try { + if (class_exists('Database')) { + parent::invalidateTags($tags); + } + } + catch (Exception $e) {} + } + /** * Implements Drupal\Core\Cache\CacheBackendInterface::flush(). */ diff --git a/core/modules/simpletest/tests/cache.test b/core/modules/simpletest/tests/cache.test index 7040aef..8d27204 100644 --- a/core/modules/simpletest/tests/cache.test +++ b/core/modules/simpletest/tests/cache.test @@ -370,6 +370,51 @@ class CacheClearCase extends CacheTestCase { $cached = cache('page')->get($data); $this->assertFalse($cached, 'Cached item was invalidated'); } + + /** + * Test clearing using cache tags. + */ + function testClearTags() { + $cache = cache($this->default_bin); + $cache->set('test_cid_clear1', $this->default_value, CACHE_PERMANENT, array('test_tag' => array(1))); + $cache->set('test_cid_clear2', $this->default_value, CACHE_PERMANENT, array('test_tag' => array(1))); + $this->assertTrue($this->checkCacheExists('test_cid_clear1', $this->default_value) + && $this->checkCacheExists('test_cid_clear2', $this->default_value), + t('Two cache items were created.')); + cache_invalidate(array('test_tag' => array(1))); + $this->assertFalse($this->checkCacheExists('test_cid_clear1', $this->default_value) + || $this->checkCacheExists('test_cid_clear2', $this->default_value), + t('Two caches removed after clearing a cache tag.')); + + $cache->set('test_cid_clear1', $this->default_value, CACHE_PERMANENT, array('test_tag' => array(1))); + $cache->set('test_cid_clear2', $this->default_value, CACHE_PERMANENT, array('test_tag' => array(2))); + $cache->set('test_cid_clear3', $this->default_value, CACHE_PERMANENT, array('test_tag_foo' => array(3))); + $this->assertTrue($this->checkCacheExists('test_cid_clear1', $this->default_value) + && $this->checkCacheExists('test_cid_clear2', $this->default_value) + && $this->checkCacheExists('test_cid_clear3', $this->default_value), + t('Two cached items were created.')); + cache_invalidate(array('test_tag_foo' => array(3))); + $this->assertTrue($this->checkCacheExists('test_cid_clear1', $this->default_value) + && $this->checkCacheExists('test_cid_clear2', $this->default_value), + t('Cached items not matching the tag were not cleared.')); + + $this->assertFalse($this->checkCacheExists('test_cid_clear3', $this->default_value), + t('Cached item matching the tag was removed.')); + + // For our next trick, we will attempt to clear data in multiple bins. + $tags = array('test_tag' => array(1, 2, 3)); + $bins = array('cache', 'cache_page', 'cache_bootstrap'); + foreach ($bins as $bin) { + cache($bin)->set('test', $this->default_value, CACHE_PERMANENT, $tags); + $this->assertTrue($this->checkCacheExists('test', $this->default_value, $bin), t('Cache item was set in %bin.', array('%bin' => $bin))); + } + cache_invalidate(array('test_tag' => array(2))); + foreach ($bins as $bin) { + $this->assertFalse($this->checkCacheExists('test', $this->default_value, $bin), t('Tag expire affected item in %bin.', array('%bin' => $bin))); + } + $this->assertFalse($this->checkCacheExists('test_cid_clear2', $this->default_value), t('Cached items matching tag were cleared.')); + $this->assertTrue($this->checkCacheExists('test_cid_clear1', $this->default_value), t('Cached items not matching tag were not cleared.')); + } } /** diff --git a/core/modules/system/system.install b/core/modules/system/system.install index 905d11d..e3517d7 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -645,6 +645,26 @@ function system_schema() { 'primary key' => array('iid'), ); + $schema['cache_tags'] = array( + 'description' => 'Cache table for tracking cache tags related to the cache bin.', + 'fields' => array( + 'tag' => array( + 'description' => 'Namespace-prefixed tag string.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'invalidations' => array( + 'description' => 'Number incremented when the tag is invalidated.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ), + ), + 'primary key' => array('tag'), + ); + $schema['cache'] = array( 'description' => 'Generic cache table for caching things not separated out into their own tables. Contributed modules may also use this to store cached items.', 'fields' => array( @@ -680,6 +700,18 @@ function system_schema() { 'not null' => TRUE, 'default' => 0, ), + 'tags' => array( + 'description' => 'Space-separated list of cache tags for this entry.', + 'type' => 'text', + 'size' => 'big', + 'not null' => FALSE, + ), + 'checksum' => array( + 'description' => 'The tag invalidation sum when this entry was saved.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ), ), 'indexes' => array( 'expire' => array('expire'), @@ -1761,6 +1793,59 @@ function system_update_8005() { db_drop_field('session', 'cache'); } + /** + * Adds the {cache_tags} table. + */ +function system_update_8006() { + $table = array( + 'description' => 'Cache table for tracking cache tags related to the cache bin.', + 'fields' => array( + 'tag' => array( + 'description' => 'Namespace-prefixed tag string.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'invalidations' => array( + 'description' => 'Number incremented when the tag is invalidated.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ), + ), + 'primary key' => array('tag'), + ); + db_create_table('cache_tags', $table); +} + +/** + * Modifies existing cache tables, adding support for cache tags. + */ +function system_update_8007() { + // Find all potential cache tables. + $tables = db_find_tables(Database::getConnection()->prefixTables('{cache}') . '%'); + + foreach ($tables as $table) { + // Assume we have a valid cache table if there is both 'cid' and 'data' + // columns. + if (db_field_exists($table, 'cid') && db_field_exists($table, 'data')) { + db_add_field($table, 'tags', array( + 'description' => 'Space-separated list of cache tags for this entry.', + 'type' => 'text', + 'size' => 'big', + 'not null' => FALSE, + )); + db_add_field($table, 'checksum', array( + 'description' => 'The tag invalidation sum when this entry was saved.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + )); + } + } +} + /** * @} End of "defgroup updates-7.x-to-8.x" * The next series of updates should start at 9000.