Commit 2c6cd929 authored by catch's avatar catch

Issue #2340123 by Wim Leers: Setting cache tags can be tricky: use strings...

Issue #2340123 by Wim Leers: Setting cache tags can be tricky: use strings instead of nested arrays to improve DX.
parent a1a0cc86
......@@ -29,7 +29,6 @@
use Drupal\Core\PhpStorage\PhpStorageFactory;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\EventSubscriber\HtmlViewSubscriber;
use Drupal\Core\Routing\GeneratorNotInitializedException;
use Drupal\Core\Template\Attribute;
use Drupal\Core\Render\Element;
......@@ -1768,41 +1767,6 @@ function drupal_merge_attached(array $a, array $b) {
return NestedArray::mergeDeep($a, $b);
}
/**
* Merges sets of cache tags.
*
* The cache tags array is returned in a format that is valid for
* \Drupal\Core\Cache\CacheBackendInterface::set().
*
* When caching elements, it is necessary to collect all cache tags into a
* single array, from both the element itself and all child elements. This
* allows items to be invalidated based on all tags attached to the content
* they're constituted from.
*
* @param array $tags
* The first set of cache tags.
* @param array $other
* The other set of cache tags.
*
* @return array
* The merged set of cache tags.
*/
function drupal_merge_cache_tags(array $tags, array $other) {
foreach ($other as $namespace => $values) {
if (is_array($values)) {
foreach ($values as $value) {
$tags[$namespace][$value] = $value;
}
}
else {
if (!isset($tags[$namespace])) {
$tags[$namespace] = $values;
}
}
}
return $tags;
}
/**
* Adds attachments to a render() structure.
*
......@@ -2303,7 +2267,7 @@ function drupal_page_set_cache(Response $response, Request $request) {
$expire = ($date > (new DateTime())) ? $date->getTimestamp() : Cache::PERMANENT;
$cid = drupal_page_cache_get_cid($request);
$tags = HtmlViewSubscriber::convertHeaderToCacheTags($response->headers->get('X-Drupal-Cache-Tags'));
$tags = explode(' ', $response->headers->get('X-Drupal-Cache-Tags'));
\Drupal::cache('render')->set($cid, $response, $expire, $tags);
}
......@@ -2494,11 +2458,11 @@ function drupal_prepare_page($page) {
// @todo Remove this once https://drupal.org/node/1869476 lands.
if (theme_get_setting('features.main_menu') && count(menu_main_menu())) {
$main_links_source = _menu_get_links_source('main_links', 'main');
$page['page_top']['#cache']['tags']['menu'][$main_links_source] = $main_links_source;
$page['page_top']['#cache']['tags'][] = 'menu:' . $main_links_source;
}
if (theme_get_setting('features.secondary_menu') && count(menu_secondary_menu())) {
$secondary_links_source = _menu_get_links_source('secondary_links', 'account');
$page['page_top']['#cache']['tags']['menu'][$secondary_links_source] = $secondary_links_source;
$page['page_top']['#cache']['tags'][] = 'menu:' . $secondary_links_source;
}
// If no module has taken care of the main content, add it to the page now.
......@@ -2710,7 +2674,7 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
$frame = $stack->top();
// Update the frame, but also update the current element, to ensure it
// contains up-to-date information in case it gets render cached.
$frame->tags = $element['#cache']['tags'] = drupal_merge_cache_tags($element['#cache']['tags'], $frame->tags);
$frame->tags = $element['#cache']['tags'] = Cache::mergeTags($element['#cache']['tags'], $frame->tags);
$frame->attached = $element['#attached'] = drupal_merge_attached($element['#attached'], $frame->attached);
$frame->postRenderCache = $element['#post_render_cache'] = NestedArray::mergeDeep($element['#post_render_cache'], $frame->postRenderCache);
};
......@@ -2726,7 +2690,7 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
// Merge the current and the parent stack frame.
$current = $stack->pop();
$parent = $stack->pop();
$current->tags = drupal_merge_cache_tags($current->tags, $parent->tags);
$current->tags = Cache::mergeTags($current->tags, $parent->tags);
$current->attached = drupal_merge_attached($current->attached, $parent->attached);
$current->postRenderCache = NestedArray::mergeDeep($current->postRenderCache, $parent->postRenderCache);
$stack->push($current);
......@@ -2943,7 +2907,7 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
$stack->push(new RenderStackFrame());
_drupal_render_process_post_render_cache($elements);
$post_render_additions = $stack->pop();
$elements['#cache']['tags'] = drupal_merge_cache_tags($elements['#cache']['tags'], $post_render_additions->tags);
$elements['#cache']['tags'] = Cache::mergeTags($elements['#cache']['tags'], $post_render_additions->tags);
$elements['#attached'] = drupal_merge_attached($elements['#attached'], $post_render_additions->attached);
$elements['#post_render_cache'] = NestedArray::mergeDeep($elements['#post_render_cache'], $post_render_additions->postRenderCache);
}
......@@ -3136,17 +3100,15 @@ function drupal_render_cache_set(&$markup, array $elements) {
$data['#post_render_cache'] = $elements['#post_render_cache'];
}
// Tag every render cache item with the "rendered" cache tag. This allows us
// to invalidate the entire render cache, regardless of the cache bin.
$cache_tags = $elements['#cache']['tags'] ?: array();
$cache_tags['rendered'] = TRUE;
// Persist cache tags associated with this element.
$data['#cache']['tags'] = $cache_tags;
// Persist cache tags associated with this element. Also associate the
// "rendered" cache tag. This allows us to invalidate the entire render cache,
// regardless of the cache bin.
$data['#cache']['tags'] = $elements['#cache']['tags'];
$data['#cache']['tags'][] = 'rendered';
$bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'render';
$expire = isset($elements['#cache']['expire']) ? $elements['#cache']['expire'] : Cache::PERMANENT;
\Drupal::cache($bin)->set($cid, $data, $expire, $cache_tags);
\Drupal::cache($bin)->set($cid, $data, $expire, $data['#cache']['tags']);
}
/**
......
......@@ -69,7 +69,7 @@ function system_list_reset() {
// @todo Trigger an event upon module install/uninstall and theme
// enable/disable, and move this into an event subscriber.
// @see https://drupal.org/node/2206347
Cache::invalidateTags(array('extension' => TRUE));
Cache::invalidateTags(array('extension'));
}
/**
......
......@@ -194,7 +194,7 @@ public function checkNamedRoute($route_name, array $parameters = array(), Accoun
}
catch (RouteNotFoundException $e) {
// Cacheable until extensions change.
$result = AccessResult::forbidden()->addCacheTags(array('extension' => TRUE));
$result = AccessResult::forbidden()->addCacheTags(array('extension'));
return $return_as_object ? $result : $result->isAllowed();
}
catch (ParamNotConvertedException $e) {
......
......@@ -259,20 +259,7 @@ public function resetCacheContexts() {
* @return $this
*/
public function addCacheTags(array $tags) {
foreach ($tags as $namespace => $values) {
if (is_array($values)) {
foreach ($values as $value) {
$this->tags[$namespace][$value] = $value;
}
ksort($this->tags[$namespace]);
}
else {
if (!isset($this->tags[$namespace])) {
$this->tags[$namespace] = $values;
}
}
}
ksort($this->tags);
$this->tags = Cache::mergeTags($this->tags, $tags);
return $this;
}
......
......@@ -57,7 +57,7 @@ class LibraryDiscoveryCollector extends CacheCollector {
* The library discovery parser.
*/
public function __construct(CacheBackendInterface $cache, LockBackendInterface $lock, LibraryDiscoveryParser $discovery_parser) {
parent::__construct($this->cacheKey, $cache, $lock, array($this->cacheKey => array(TRUE)));
parent::__construct($this->cacheKey, $cache, $lock, array($this->cacheKey));
$this->discoveryParser = $discovery_parser;
}
......
......@@ -482,8 +482,7 @@ public function getCacheTags() {
// If a block plugin's output changes, then it must be able to invalidate a
// cache tag that affects all instances of this block: across themes and
// across regions.
$block_plugin_cache_tag = str_replace(':', '__', $this->getPluginID());
return array('block_plugin' => array($block_plugin_cache_tag));
return array('block_plugin:' . str_replace(':', '__', $this->getPluginID()));
}
/**
......
......@@ -189,11 +189,13 @@ protected function prepareItem($cache, $allow_invalid) {
* {@inheritdoc}
*/
public function set($cid, $data, $expire = CacheBackendInterface::CACHE_PERMANENT, array $tags = array()) {
Cache::validateTags($tags);
$tags = array_unique($tags);
$cache = new \stdClass();
$cache->cid = $cid;
$cache->created = round(microtime(TRUE), 3);
$cache->expire = $expire;
$cache->tags = implode(' ', $this->flattenTags($tags));
$cache->tags = implode(' ', $tags);
$checksum = $this->checksumTags($tags);
$cache->checksum_invalidations = $checksum['invalidations'];
$cache->checksum_deletions = $checksum['deletions'];
......@@ -285,7 +287,7 @@ public function invalidateAll() {
* {@inheritdoc}
*/
public function deleteTags(array $tags) {
foreach ($this->flattenTags($tags) as $tag) {
foreach ($tags as $tag) {
apc_inc($this->deletionsTagsPrefix . $tag, 1, $success);
if (!$success) {
apc_store($this->deletionsTagsPrefix . $tag, 1);
......@@ -297,7 +299,7 @@ public function deleteTags(array $tags) {
* {@inheritdoc}
*/
public function invalidateTags(array $tags) {
foreach ($this->flattenTags($tags) as $tag) {
foreach ($tags as $tag) {
apc_inc($this->invalidationsTagsPrefix . $tag, 1, $success);
if (!$success) {
apc_store($this->invalidationsTagsPrefix . $tag, 1);
......@@ -305,34 +307,6 @@ public function invalidateTags(array $tags) {
}
}
/**
* Flattens a tags array into a numeric array suitable for string storage.
*
* @param array $tags
* Associative array of tags to flatten.
*
* @return array
* Indexed 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;
}
/**
* Returns the sum total of validations for a given set of tags.
*
......@@ -346,7 +320,7 @@ protected function checksumTags(array $tags) {
$checksum = array('invalidations' => 0, 'deletions' => 0);
$query_tags = array('invalidations' => array(), 'deletions' => array());
foreach ($this->flattenTags($tags) as $tag) {
foreach ($tags as $tag) {
foreach (array('deletions', 'invalidations') as $type) {
if (isset(static::$tagCache[$type][$tag])) {
$checksum[$type] += static::$tagCache[$type][$tag];
......
......@@ -21,6 +21,78 @@ class Cache {
*/
const PERMANENT = CacheBackendInterface::CACHE_PERMANENT;
/**
* Merges arrays of cache tags and removes duplicates.
*
* The cache tags array is returned in a format that is valid for
* \Drupal\Core\Cache\CacheBackendInterface::set().
*
* When caching elements, it is necessary to collect all cache tags into a
* single array, from both the element itself and all child elements. This
* allows items to be invalidated based on all tags attached to the content
* they're constituted from.
*
* @param string[] …
* Arrays of cache tags to merge.
*
* @return string[]
* The merged array of cache tags.
*/
public static function mergeTags() {
$cache_tag_arrays = func_get_args();
$cache_tags = [];
foreach ($cache_tag_arrays as $tags) {
static::validateTags($tags);
$cache_tags = array_merge($cache_tags, $tags);
}
$cache_tags = array_unique($cache_tags);
sort($cache_tags);
return $cache_tags;
}
/**
* Validates an array of cache tags.
*
* Can be called before using cache tags in operations, to ensure validity.
*
* @param string[] $tags
* An array of cache tags.
*
* @throws \LogicException
*/
public static function validateTags(array $tags) {
if (empty($tags)) {
return;
}
foreach ($tags as $value) {
if (!is_string($value)) {
throw new \LogicException('Cache tags must be strings, ' . gettype($value) . ' given.');
}
}
}
/**
* Build an array of cache tags from a given prefix and an array of suffixes.
*
* Each suffix will be converted to a cache tag by appending it to the prefix,
* with a colon between them.
*
* @param string $prefix
* A prefix string.
* @param array $suffixes
* An array of suffixes. Will be cast to strings.
*
* @return string[]
* An array of cache tags.
*/
public static function buildTags($prefix, array $suffixes) {
$tags = [];
foreach ($suffixes as $suffix) {
$tags[] = $prefix . ':' . $suffix;
}
return $tags;
}
/**
* Deletes items from all bins with any of the specified tags.
*
......@@ -31,10 +103,11 @@ class Cache {
* When deleting a given list of tags, we iterate over each cache backend, and
* and call deleteTags() on each.
*
* @param array $tags
* @param string[] $tags
* The list of tags to delete cache items for.
*/
public static function deleteTags(array $tags) {
static::validateTags($tags);
foreach (static::getBins() as $cache_backend) {
$cache_backend->deleteTags($tags);
}
......@@ -50,10 +123,11 @@ public static function deleteTags(array $tags) {
* When invalidating a given list of tags, we iterate over each cache backend,
* and call invalidateTags() on each.
*
* @param array $tags
* @param string[] $tags
* The list of tags to invalidate cache items for.
*/
public static function invalidateTags(array $tags) {
static::validateTags($tags);
foreach (static::getBins() as $cache_backend) {
$cache_backend->invalidateTags($tags);
}
......
......@@ -114,7 +114,8 @@ abstract class CacheCollector implements CacheCollectorInterface, DestructableIn
* @param array $tags
* (optional) The tags to specify for the cache item.
*/
public function __construct($cid, CacheBackendInterface $cache, LockBackendInterface $lock, $tags = array()) {
public function __construct($cid, CacheBackendInterface $cache, LockBackendInterface $lock, array $tags = array()) {
Cache::validateTags($tags);
$this->cid = $cid;
$this->cache = $cache;
$this->tags = $tags;
......
......@@ -147,6 +147,10 @@ protected function prepareItem($cache, $allow_invalid) {
* Implements Drupal\Core\Cache\CacheBackendInterface::set().
*/
public function set($cid, $data, $expire = Cache::PERMANENT, array $tags = array()) {
Cache::validateTags($tags);
$tags = array_unique($tags);
// Sort the cache tags so that they are stored consistently in the database.
sort($tags);
$try_again = FALSE;
try {
// The bin might not yet exist.
......@@ -170,13 +174,12 @@ public function set($cid, $data, $expire = Cache::PERMANENT, array $tags = array
* Actually set the cache.
*/
protected function doSet($cid, $data, $expire, $tags) {
$flat_tags = $this->flattenTags($tags);
$deleted_tags = &drupal_static('Drupal\Core\Cache\DatabaseBackend::deletedTags', array());
$invalidated_tags = &drupal_static('Drupal\Core\Cache\DatabaseBackend::invalidatedTags', array());
// Remove tags that were already deleted or invalidated during this request
// from the static caches so that another deletion or invalidation can
// occur.
foreach ($flat_tags as $tag) {
foreach ($tags as $tag) {
if (isset($deleted_tags[$tag])) {
unset($deleted_tags[$tag]);
}
......@@ -184,12 +187,12 @@ protected function doSet($cid, $data, $expire, $tags) {
unset($invalidated_tags[$tag]);
}
}
$checksum = $this->checksumTags($flat_tags);
$checksum = $this->checksumTags($tags);
$fields = array(
'serialized' => 0,
'created' => round(microtime(TRUE), 3),
'expire' => $expire,
'tags' => implode(' ', $flat_tags),
'tags' => implode(' ', $tags),
'checksum_invalidations' => $checksum['invalidations'],
'checksum_deletions' => $checksum['deletions'],
);
......@@ -234,12 +237,15 @@ public function setMultiple(array $items) {
'tags' => array(),
);
$flat_tags = $this->flattenTags($item['tags']);
Cache::validateTags($item['tags']);
$item['tags'] = array_unique($item['tags']);
// Sort the cache tags so that they are stored consistently in the DB.
sort($item['tags']);
// Remove tags that were already deleted or invalidated during this
// request from the static caches so that another deletion or
// invalidation can occur.
foreach ($flat_tags as $tag) {
foreach ($item['tags'] as $tag) {
if (isset($deleted_tags[$tag])) {
unset($deleted_tags[$tag]);
}
......@@ -248,13 +254,13 @@ public function setMultiple(array $items) {
}
}
$checksum = $this->checksumTags($flat_tags);
$checksum = $this->checksumTags($item['tags']);
$fields = array(
'cid' => $cid,
'expire' => $item['expire'],
'created' => round(microtime(TRUE), 3),
'tags' => implode(' ', $flat_tags),
'tags' => implode(' ', $item['tags']),
'checksum_invalidations' => $checksum['invalidations'],
'checksum_deletions' => $checksum['deletions'],
);
......@@ -316,7 +322,7 @@ public function deleteMultiple(array $cids) {
public function deleteTags(array $tags) {
$tag_cache = &drupal_static('Drupal\Core\Cache\CacheBackendInterface::tagCache', array());
$deleted_tags = &drupal_static('Drupal\Core\Cache\DatabaseBackend::deletedTags', array());
foreach ($this->flattenTags($tags) as $tag) {
foreach ($tags as $tag) {
// Only delete tags once per request unless they are written again.
if (isset($deleted_tags[$tag])) {
continue;
......@@ -386,7 +392,7 @@ public function invalidateTags(array $tags) {
try {
$tag_cache = &drupal_static('Drupal\Core\Cache\CacheBackendInterface::tagCache', array());
$invalidated_tags = &drupal_static('Drupal\Core\Cache\DatabaseBackend::invalidatedTags', array());
foreach ($this->flattenTags($tags) as $tag) {
foreach ($tags as $tag) {
// Only invalidate tags once per request unless they are written again.
if (isset($invalidated_tags[$tag])) {
continue;
......@@ -436,46 +442,16 @@ public function garbageCollection() {
}
}
/**
* 'Flattens' a tags array into an array of strings.
*
* @param array $tags
* Associative array of tags to flatten.
*
* @return array
* An indexed 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;
}
/**
* Returns the sum total of validations for a given set of tags.
*
* @param array $tags
* Array of flat tags.
* Array of cache tags.
*
* @return int
* Sum of all invalidations.
*
* @see \Drupal\Core\Cache\DatabaseBackend::flattenTags()
*/
protected function checksumTags($flat_tags) {
protected function checksumTags(array $tags) {
$tag_cache = &drupal_static('Drupal\Core\Cache\CacheBackendInterface::tagCache', array());
$checksum = array(
......@@ -483,7 +459,7 @@ protected function checksumTags($flat_tags) {
'deletions' => 0,
);
$query_tags = array_diff($flat_tags, array_keys($tag_cache));
$query_tags = array_diff($tags, array_keys($tag_cache));
if ($query_tags) {
$db_tags = $this->connection->query('SELECT tag, invalidations, deletions FROM {cachetags} WHERE tag IN (:tags)', array(':tags' => $query_tags))->fetchAllAssoc('tag', \PDO::FETCH_ASSOC);
$tag_cache += $db_tags;
......@@ -492,7 +468,7 @@ protected function checksumTags($flat_tags) {
$tag_cache += array_fill_keys(array_diff($query_tags, array_keys($db_tags)), $checksum);
}
foreach ($flat_tags as $tag) {
foreach ($tags as $tag) {
$checksum['invalidations'] += $tag_cache[$tag]['invalidations'];
$checksum['deletions'] += $tag_cache[$tag]['deletions'];
}
......
......@@ -107,12 +107,16 @@ protected function prepareItem($cache, $allow_invalid) {
* Implements Drupal\Core\Cache\CacheBackendInterface::set().
*/
public function set($cid, $data, $expire = Cache::PERMANENT, array $tags = array()) {
Cache::validateTags($tags);
$tags = array_unique($tags);
// Sort the cache tags so that they are stored consistently in the database.
sort($tags);
$this->cache[$cid] = (object) array(
'cid' => $cid,
'data' => serialize($data),
'created' => REQUEST_TIME,
'expire' => $expire,
'tags' => $this->flattenTags($tags),
'tags' => $tags,
);
}
......@@ -143,9 +147,8 @@ public function deleteMultiple(array $cids) {
* Implements Drupal\Core\Cache\CacheBackendInterface::deleteTags().
*/
public function deleteTags(array $tags) {
$flat_tags = $this->flattenTags($tags);
foreach ($this->cache as $cid => $item) {
if (array_intersect($flat_tags, $item->tags)) {
if (array_intersect($tags, $item->tags)) {
unset($this->cache[$cid]);
}
}
......@@ -180,9 +183,8 @@ public function invalidateMultiple(array $cids) {
* Implements Drupal\Core\Cache\CacheBackendInterface::invalidateTags().
*/
public function invalidateTags(array $tags) {
$flat_tags = $this->flattenTags($tags);
foreach ($this->cache as $cid => $item) {
if (array_intersect($flat_tags, $item->tags)) {
if (array_intersect($tags, $item->tags)) {
$this->cache[$cid]->expire = REQUEST_TIME - 1;
}
}
......@@ -197,34 +199,6 @@ public function invalidateAll() {
}
}
/**
* 'Flattens' a tags array into an array of strings.
*
* @param array $tags
* Associative array of tags to flatten.
*
* @return array
* An indexed array of strings.
*/
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::garbageCollection()
*/
......
......@@ -133,13 +133,14 @@ protected function prepareItem($cache, $allow_invalid) {
* {@inheritdoc}
*/
public function set($cid, $data, $expire = Cache::PERMANENT, array $tags = array()) {
Cache::validateTags($tags);
$item = (object) array(
'cid' => $cid,
'data' => $data,
'created' => round(microtime(TRUE), 3),
'expire' => $expire,
'tags' => array_unique($tags),
);
$item->tags = $this->flattenTags($tags);
$this->writeItem($this->normalizeCid($cid), $item);
}
......@@ -163,10 +164,9 @@ public function deleteMultiple(array $cids) {
* {@inheritdoc}
*/
public function deleteTags(array $tags) {
$flat_tags = $this->flattenTags($tags);
foreach ($this->storage()->listAll() as $cidhash) {
$item = $this->getByHash($cidhash);
if (is_object($item) && array_intersect($flat_tags, $item->tags)) {
if (is_object($item) && array_intersect($tags, $item->tags)) {
$this->delete($item->cid);
}
}
......@@ -212,10 +212,9 @@ public function invalidateMultiple(array $cids) {
* {@inheritdoc}
*/
public function invalidateTags(array $tags) {
$flat_tags = $this->flattenTags($tags);
foreach ($this->storage()->listAll() as $cidhash) {
$item = $this->getByHash($cidhash);
if ($item && array_intersect($flat_tags, $item->tags)) {
if ($item && array_intersect($tags, $item->tags)) {
$this->invalidate($item->cid);
}
}
......@@ -230,34 +229,6 @@ public function invalidateAll() {
}
}
/**
* 'Flattens' a tags array into an array of strings.
*
* @param array $tags
* Associative array of tags to flatten.
*
* @return array
* An indexed array of strings.
*/
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";