diff --git a/core/includes/cache-install.inc b/core/includes/cache-install.inc index ec46ae0..c8981f6 100644 --- a/core/includes/cache-install.inc +++ b/core/includes/cache-install.inc @@ -33,7 +33,7 @@ class DrupalFakeCache extends DrupalDatabaseCache implements DrupalCacheInterfac /** * Overrides DrupalDatabaseCache::set(). */ - function set($cid, $data, $expire = CACHE_PERMANENT) { + function set($cid, $data, $expire = CACHE_PERMANENT, $tags = array()) { } /** diff --git a/core/includes/cache.inc b/core/includes/cache.inc index e8c7477..9b2cae8 100644 --- a/core/includes/cache.inc +++ b/core/includes/cache.inc @@ -30,10 +30,8 @@ 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', 'DrupalDatabaseCache'); - } + $cache_backends = variable_get('cache_classes', array('cache' => 'DrupalDatabaseCache')); + $class = isset($cache_backends[$bin]) ? $cache_backends[$bin] : $cache_backends['cache']; $cache_objects[$bin] = new $class($bin); } return $cache_objects[$bin]; @@ -145,8 +143,14 @@ interface DrupalCacheInterface { * 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. @@ -162,7 +166,7 @@ interface DrupalCacheInterface { * @param $cids * An array of $cids to delete. */ - function deleteMultiple(Array $cids); + function deleteMultiple(array $cids); /** * Deletes items from the cache using a wildcard prefix. @@ -234,7 +238,7 @@ class DrupalNullCache implements DrupalCacheInterface { /** * Implements DrupalCacheInterface::set(). */ - function set($cid, $data, $expire = CACHE_PERMANENT) {} + function set($cid, $data, $expire = CACHE_PERMANENT, $tags = array()) {} /** * Implements DrupalCacheInterface::delete(). @@ -280,9 +284,11 @@ class DrupalNullCache implements DrupalCacheInterface { * This is Drupal's default cache implementation. It uses the database to store * cached data. Each cache bin corresponds to a database table by the same name. */ -class DrupalDatabaseCache implements DrupalCacheInterface { +class DrupalDatabaseCache implements DrupalCacheInterface, DrupalCacheTagsInterface { protected $bin; + protected $tag_cache = array(); + /** * Constructs a new DrupalDatabaseCache object. */ @@ -296,6 +302,89 @@ class DrupalDatabaseCache implements DrupalCacheInterface { } /** + * 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 integer @tags + * An array of tags to calculate a checksum for. + * @return boolean + * TRUE if the checksums match, FALSE otherwise. + */ + protected function validTags($checksum, $tags) { + return $checksum == $this->checksumTags($tags); + } + + /** + * Flattens a tags array into a numeric array suitable for string storage. + * + * @param $tags + * Associative array of tags to flatten. + * + * @return + * Numeric array of flattened tag identifiers. + */ + protected function flattenTags($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; + } + + public function invalidateTags($tags) { + foreach ($this->flattenTags($tags) as $tag) { + unset($this->tag_cache[$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 $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->tag_cache[$tag])) { + $checksum += $this->tag_cache[$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->tag_cache = array_merge($this->tag_cache, $db_tags); + $checksum += array_sum($db_tags); + } + } + return $checksum; + } + + /** * Implements DrupalCacheInterface::get(). */ function get($cid) { @@ -319,7 +408,7 @@ class DrupalDatabaseCache implements DrupalCacheInterface { // 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); @@ -367,6 +456,14 @@ class DrupalDatabaseCache implements DrupalCacheInterface { 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 ($cache->serialized) { $cache->data = unserialize($cache->data); } @@ -377,11 +474,13 @@ class DrupalDatabaseCache implements DrupalCacheInterface { /** * Implements DrupalCacheInterface::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); @@ -415,7 +514,7 @@ class DrupalDatabaseCache implements DrupalCacheInterface { /** * Implements DrupalCacheInterface::deleteMultiple(). */ - function deleteMultiple(Array $cids) { + function deleteMultiple(array $cids) { // Delete in chunks when a large array is passed. do { db_delete($this->bin) @@ -510,3 +609,53 @@ class DrupalDatabaseCache implements DrupalCacheInterface { return empty($result); } } + +/** + * Defines an interface for cache backends that support tags. + * + * Each cache backend that supports tags should implement this interface. + * + * @see cache_invalidate() + * @see DrupalDatabaseCache + */ +interface DrupalCacheTagsInterface { + /** + * Invalidates each tag in the $tags array. + * + * @param $tags + * Associative array of tags, in the same format that is passed to + * DrupalCacheInterface::set(). + * + * @see DrupalCacheInterface::set() + */ + function invalidateTags($tags); +} + +/** + * 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 that implements DrupalCacheTagsInterface. + * + * @param $tags + * The list of tags to invalidate cache items for. + */ +function cache_invalidate(array $tags) { + foreach (cache_get_backends() as $bin => $class) { + if (in_array('DrupalCacheTagsInterface', class_implements($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' => 'DrupalDatabaseCache')); +} + diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc index 6bdbf4d..7cc989c 100644 --- a/core/includes/install.core.inc +++ b/core/includes/install.core.inc @@ -278,7 +278,7 @@ function install_begin_request(&$install_state) { // suspect, due to the fact that Drupal is not fully set up yet. require_once DRUPAL_ROOT . '/core/includes/cache.inc'; require_once DRUPAL_ROOT . '/core/includes/cache-install.inc'; - $conf['cache_default_class'] = 'DrupalFakeCache'; + $conf['cache_classes'] = array('cache' => 'DrupalFakeCache'); // 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/modules/simpletest/tests/cache.test b/core/modules/simpletest/tests/cache.test index bca4e25..3e3c45e 100644 --- a/core/modules/simpletest/tests/cache.test +++ b/core/modules/simpletest/tests/cache.test @@ -277,6 +277,51 @@ class CacheClearCase extends CacheTestCase { } /** + * 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.')); + } + + /** * Test clearing using an array. */ function testClearArray() { diff --git a/core/modules/system/system.install b/core/modules/system/system.install index 20e1dc1..424cc9d 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -673,6 +673,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'), @@ -689,6 +701,25 @@ function system_schema() { $schema['cache_menu']['description'] = 'Cache table for the menu system to store router information as well as generated link trees for various menu/page/user combinations.'; $schema['cache_path'] = $schema['cache']; $schema['cache_path']['description'] = 'Cache table for path alias lookup.'; + $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['date_format_type'] = array( 'description' => 'Stores configured date format types.', @@ -1661,6 +1692,38 @@ function system_update_8002() { } /** + * Adds the {cache_tags} table. + */ +function system_update_8003() { + $table = drupal_get_schema_unprocessed('system', 'cache_tags'); + db_create_table('cache_tags', $table); +} + +/** + * Modifies existing cache tables, adding support for cache tags. + */ +function system_update_8004() { + foreach (module_list(TRUE) as $module) { + foreach (drupal_get_schema_unprocessed($module) as $table => $schema) { + if (($table == 'cache' || strpos($table, 'cache_') === 0) && isset($schema['fields']['cid'])) { + 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. */