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..3fdb202 100644 --- a/core/includes/cache.inc +++ b/core/includes/cache.inc @@ -145,8 +145,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. @@ -234,7 +240,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(). @@ -319,7 +325,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 +373,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 (!cache_tags()->isValid($cache->checksum, $cache->tags)) { + return FALSE; + } + } + if ($cache->serialized) { $cache->data = unserialize($cache->data); } @@ -377,11 +391,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(' ', DrupalCacheTags::flatten($tags)), + 'checksum' => cache_tags()->checksum($tags), ); if (!is_string($data)) { $fields['data'] = serialize($data); @@ -510,3 +526,162 @@ class DrupalDatabaseCache implements DrupalCacheInterface { return empty($result); } } + + +/** + * Interface for cache tag implementations. + * + * Cache tags and their corresponding invalidation counters are stored + * independently from the cache itself. Cache tag storage is stored on a + * site-wide level (as opposed to per-bin) so only one cache tag storage engine + * can be used at one time. + * + * To make Drupal use your cache tags implementation, you must set a variable + * (either with variable_set() or in settings.php via $conf): + * @code + * variable_set('cache_tags_class', 'MyCustomCacheTags'); + * @endcode + * + * @see cache_tags() + * @see DrupalDatabaseCacheTags + */ +interface DrupalCacheTagsInterface { + /** + * Invalidate each tag in the $tags array. + * + * Cache entries tagged with any one of these will subsequently return + * FALSE for cache_get(). + * + * @param $tags + * Associative array of tags, in the same format that is passed to + * DrupalCacheInterface::set(). + * + * @see DrupalCacheInterface::set() + */ + function invalidate($tags); + + /** + * Calculate the sum of invalidations of the given tags. + * + * @param $tags + * Associative array of tags to calculate checksum for. + * + * @return + * Integer representing the sum of invalidations of the given tags. + */ + function checksum($tags); + + /** + * Determine if the checksum is current and valid. + * + * @param $checksum + * A checksum to test against. + * @param $tags + * Associative array of tags, in the same format that is passed to + * DrupalCacheInterface::set(). + * + * @return + * Boolean TRUE if $checksum is current for the given tags, FALSE otherwise. + */ + function isValid($checksum, $tags); +} + +/** + * Base abstract class for cache tags implementations. + * + * Provides default implementations and helper methods. + */ +abstract class DrupalCacheTags implements DrupalCacheTagsInterface { + function isValid($checksum, $tags) { + return $checksum == $this->checksum($tags); + } + + /** + * Flatten 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. + */ + static function flatten($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; + } +} + +/** + * Default cache tags implementation, using the database. + */ +class DrupalDatabaseCacheTags extends DrupalCacheTags { + protected $tag_cache = array(); + + function invalidate($tags) { + foreach (DrupalCacheTags::flatten($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(); + } + } + + function checksum($tags) { + $checksum = 0; + $query_tags = array(); + + foreach (DrupalCacheTags::flatten($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; + } +} + +/** + * Factory for instantiating and statically caching the cache tags instance. + * + * By default, this returns an instance of the DrupalDatabaseCacheTags class. + * To override this, you must set a variable (either with variable_set() or + * in settings.php via $conf): + * @code + * variable_set('cache_tags_class', 'MyCustomCacheTags'); + * @endcode + * + * @see DrupalCacheTagsInterface + * + * @return DrupalCacheTagsInterface + * The cache tags object. + */ +function cache_tags() { + $object = &drupal_static(__FUNCTION__); + if (!isset($object)) { + $class = variable_get('cache_tags_class', 'DrupalDatabaseCacheTags'); + $object = new $class; + } + return $object; +} diff --git a/core/modules/simpletest/tests/cache.test b/core/modules/simpletest/tests/cache.test index bca4e25..b1bb555 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_tags()->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_tags()->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_tags()->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..79a87a2 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() { } /** + * Add the {cache_tags} table. + */ +function system_update_8003() { + $table = drupal_get_schema_unprocessed('system', 'cache_tags'); + db_create_table('cache_tags', $table); +} + +/** + * Modify 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. */