redis-8.x-1.x-dev/src/Cache/RedisBackend.php
src/Cache/RedisBackend.php
<?php
namespace Drupal\redis\Cache;
use DateInterval;
use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Serialization\SerializationInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Cache\CacheTagsChecksumInterface;
use Drupal\Core\Cache\CacheTagsChecksumPreloadInterface;
use Drupal\Core\Cache\ChainedFastBackend;
use Drupal\Core\Site\Settings;
use Drupal\redis\ClientInterface;
use Drupal\redis\RedisPrefixTrait;
/**
* Base class for redis cache backends.
*/
class RedisBackend implements CacheBackendInterface {
use RedisPrefixTrait;
/**
* Default lifetime for permanent items.
* Approximatively 1 year.
*/
const LIFETIME_PERM_DEFAULT = 31536000;
/**
* Latest delete all flush KEY name.
*/
const LAST_DELETE_ALL_KEY = '_redis_last_delete_all';
/**
* Default TTL for CACHE_PERMANENT items.
*
* See "Default lifetime for permanent items" section of README.md
* file for a comprehensive explanation of why this exists.
*
* @var int
*/
protected int $permTtl = self::LIFETIME_PERM_DEFAULT;
/**
* The last delete timestamp.
*
* @var float
*/
protected $lastDeleteAll = NULL;
/**
* Delayed deletions for deletions during a transaction.
*
* @var string[]
*/
protected $delayedDeletions = [];
/**
* Get TTL for CACHE_PERMANENT items.
*
* @return int
* Lifetime in seconds.
*/
public function getPermTtl() {
return $this->permTtl;
}
public function __construct(protected string $bin, protected ClientInterface $client, protected CacheTagsChecksumInterface $checksumProvider, protected SerializationInterface $serializer) {
$this->setPermTtl();
// Exclude bins that should not be kept in memory
$this->client->addIgnorePattern($this->getKey('*'));
}
/**
* Returns whether this cache bin should be kept in memory.
*
* @return bool
* TRUE if the Relay memory cache should be used.
*/
protected function keepBinInMemory(): bool {
$in_memory_bins = Settings::get('redis_relay_memory_bins', ['container', 'bootstrap', 'config', 'discovery']);
return in_array($this->bin, $in_memory_bins);
}
/**
* Checks whether the cache id is the last write timestamp.
*
* Cache requests for this are streamlined to bypass the full cache API as
* that needs two extra requests to check for delete or invalidate all flags.
*
* Most requests will only fetch this single timestamp from bins using the
* ChainedFast backend.
*
* @param string $cid
* The requested cache id.
*
* @return bool
*/
protected function isLastWriteTimestamp(string $cid): bool {
return $cid === ChainedFastBackend::LAST_WRITE_TIMESTAMP_PREFIX . 'cache_' . $this->bin;
}
/**
* {@inheritdoc}
*/
public function get($cid, $allow_invalid = FALSE) {
if ($this->isLastWriteTimestamp($cid)) {
$timestamp = $this->client->get($this->getPrefix() . ':' . $cid);
return $timestamp ? (object) ['data' => $timestamp] : NULL;
}
$cids = [$cid];
$cache = $this->getMultiple($cids, $allow_invalid);
return reset($cache);
}
/**
* {@inheritdoc}
*/
public function setMultiple(array $items) {
// Register cache tags of each item for preloading.
if (method_exists($this->checksumProvider, 'registerCacheTagsForPreload')) {
$tags_for_preload = [];
foreach ($items as $item) {
if (!empty($item['tags'])) {
assert(Inspector::assertAllStrings($item['tags']), 'Cache Tags must be strings.');
$tags_for_preload[] = $item['tags'];
}
}
$this->checksumProvider->registerCacheTagsForPreload(array_merge(...$tags_for_preload));
}
foreach ($items as $cid => $item) {
$this->set($cid, $item['data'], isset($item['expire']) ? $item['expire'] : CacheBackendInterface::CACHE_PERMANENT, isset($item['tags']) ? $item['tags'] : []);
}
}
/**
* {@inheritdoc}
*/
public function delete($cid) {
$this->deleteMultiple([$cid]);
}
/**
* {@inheritdoc}
*/
public function deleteMultiple(array $cids) {
/** @phpstan-ignore-next-line */
$database = \Drupal::hasContainer() ? \Drupal::database() : NULL;
$in_transaction = $database?->inTransaction();
if ($in_transaction) {
if (empty($this->delayedDeletions)) {
if (method_exists($database, 'transactionManager')) {
$database->transactionManager()->addPostTransactionCallback([$this, 'postRootTransactionCommit']);
}
else {
/** @phpstan-ignore-next-line */
$database->addRootTransactionEndCallback([$this, 'postRootTransactionCommit']);
}
}
$this->delayedDeletions = array_unique(array_merge($this->delayedDeletions, $cids));
}
elseif ($cids) {
$keys = array_map([$this, 'getKey'], $cids);
$this->client->del($keys);
}
}
/**
* {@inheritdoc}
*/
public function getMultiple(&$cids, $allow_invalid = FALSE) {
// Avoid an error when there are no cache ids.
if (empty($cids)) {
return [];
}
$return = [];
// Build the list of keys to fetch.
$keys = array_map([$this, 'getKey'], $cids);
$this->client->pipeline();
foreach ($keys as $key) {
$this->client->hgetall($key);
}
$result = $this->client->exec();
// Before checking the validity of each item individually, register the
// cache tags for all returned cache items for preloading, this allows the
// cache tag service to optimize cache tag lookups.
if (method_exists($this->checksumProvider, 'registerCacheTagsForPreload')) {
$tags_for_preload = [];
foreach ($result as $item) {
if (!empty($item['tags'])) {
$tags_for_preload[] = explode(' ', $item['tags']);
}
}
$this->checksumProvider->registerCacheTagsForPreload(array_merge(...$tags_for_preload));
}
// Loop over the cid values to ensure numeric indexes.
foreach (array_values($cids) as $index => $key) {
// Check if a valid result was returned from Redis.
if (isset($result[$index]) && is_array($result[$index])) {
// Check expiration and invalidation and convert into an object.
$item = $this->expandEntry($result[$index], $allow_invalid);
if ($item) {
$return[$item->cid] = $item;
}
}
}
// Remove fetched cids from the list.
$cids = array_diff($cids, array_keys($return));
return $return;
}
/**
* {@inheritdoc}
*/
public function set($cid, $data, $expire = Cache::PERMANENT, array $tags = []) {
if ($this->isLastWriteTimestamp($cid)) {
$this->client->set($this->getPrefix() . ':' . $cid, $data);
return;
}
$ttl = $this->getExpiration($expire);
$key = $this->getKey($cid);
// If the item is already expired, delete it.
if ($ttl <= 0) {
$this->delete($key);
}
// Build the cache item and save it as a hash array.
$entry = $this->createEntryHash($cid, $data, $expire, $tags);
$this->client->pipeline();
$this->client->hMset($key, $entry);
$this->client->expire($key, $ttl);
$this->client->exec();
}
/**
* Callback to be invoked after a database transaction gets committed.
*
* Invalidates all delayed cache deletions.
*
* @param bool $success
* Whether or not the transaction was successful.
*/
public function postRootTransactionCommit($success) {
if ($success && $this->delayedDeletions) {
$keys = array_map([$this, 'getKey'], $this->delayedDeletions);
$this->client->del($keys);
}
$this->delayedDeletions = [];
}
/**
* {@inheritdoc}
*/
public function removeBin() {
$this->deleteAll();
}
/**
* {@inheritdoc}
*/
public function invalidate($cid) {
$this->invalidateMultiple([$cid]);
}
/**
* Return the key for the given cache key.
*/
public function getKey($cid = NULL) {
if (NULL === $cid) {
return $this->getPrefix() . ':' . $this->bin;
}
else {
return $this->getPrefix() . ':' . $this->bin . ':' . $cid;
}
}
/**
* Calculate the correct ttl value for redis.
*
* @param int $expire
* The expiration time provided for the cache set.
*
* @return int
* The default TTL if expire is PERMANENT or higher than the default.
* Otherwise, the adjusted lifetime of the cache if setting
* redis_ttl_offset is set >= 0. May return negative values if the item
* is already expired.
*/
protected function getExpiration($expire) {
$redis_ttl_offset = Settings::get('redis_ttl_offset', NULL);
if ($expire == Cache::PERMANENT || $redis_ttl_offset === NULL) {
return $this->permTtl;
}
/** @phpstan-ignore-next-line */
$expire_ttl = $expire - \Drupal::time()->getRequestTime();
if ($expire_ttl > $this->permTtl) {
return $this->permTtl;
}
return $expire_ttl + $redis_ttl_offset;
}
/**
* Return the key for the tag used to specify the bin of cache-entries.
*/
protected function getTagForBin() {
return 'x-redis-bin:' . $this->bin;
}
/**
* Set the permanent TTL.
*/
public function setPermTtl($ttl = NULL) {
if (isset($ttl)) {
$this->permTtl = $ttl;
}
else {
// Attempt to set from settings, fall back to old settings key.
$ttl = Settings::get('redis_perm_ttl_' . $this->bin);
if ($ttl === NULL) {
$ttl = Settings::get('redis.settings', [])['perm_ttl_' . $this->bin] ?? NULL;
}
if ($ttl) {
if ($ttl === (int) $ttl) {
$this->permTtl = $ttl;
}
else {
if ($iv = DateInterval::createFromDateString($ttl)) {
// http://stackoverflow.com/questions/14277611/convert-dateinterval-object-to-seconds-in-php
$this->permTtl = ($iv->y * 31536000 + $iv->m * 2592000 + $iv->d * 86400 + $iv->h * 3600 + $iv->i * 60 + $iv->s);
}
else {
// Log error about invalid ttl.
trigger_error(sprintf("Parsed TTL '%s' has an invalid value: switching to default", $ttl));
$this->permTtl = self::LIFETIME_PERM_DEFAULT;
}
}
}
}
}
/**
* Prepares a cached item.
*
* Checks that items are either permanent or did not expire, and unserializes
* data as appropriate.
*
* @param array $values
* The hash returned from redis or false.
* @param bool $allow_invalid
* If FALSE, the method returns FALSE if the cache item is not valid.
*
* @return mixed|false
* The item with data unserialized as appropriate and a property indicating
* whether the item is valid, or FALSE if there is no valid item to load.
*/
protected function expandEntry(array $values, $allow_invalid) {
// Check for entry being valid.
if (empty($values['cid'])) {
return FALSE;
}
// Ignore items that are scheduled for deletion.
if (in_array($values['cid'], $this->delayedDeletions)) {
return FALSE;
}
$cache = (object) $values;
$cache->tags = $cache->tags ? explode(' ', $cache->tags) : [];
// Check expire time, allow to have a cache invalidated explicitly, don't
// check if already invalid.
if ($cache->valid) {
/** @phpstan-ignore-next-line */
$cache->valid = $cache->expire == Cache::PERMANENT || $cache->expire >= \Drupal::time()->getRequestTime();
// Check if invalidateTags() has been called with any of the items's tags.
if ($cache->valid && !$this->checksumProvider->isValid($cache->checksum, $cache->tags)) {
$cache->valid = FALSE;
}
if (Settings::get('redis_invalidate_all_as_delete', FALSE) === FALSE) {
// Remove the bin cache tag to not expose that, otherwise it is reused
// by the fast backend in the FastChained implementation.
$cache->tags = array_diff($cache->tags, [$this->getTagForBin()]);
}
}
// Ensure the entry does not predate the last delete all time.
$last_delete_timestamp = $this->getLastDeleteAll();
if ($last_delete_timestamp && ((float)$values['created']) < $last_delete_timestamp) {
return FALSE;
}
if (!$allow_invalid && !$cache->valid) {
return FALSE;
}
if (!empty($cache->gz)) {
// Uncompress, suppress warnings e.g. for broken CRC32.
$cache->data = @gzuncompress($cache->data);
// In such cases, void the cache entry.
if ($cache->data === FALSE) {
return FALSE;
}
}
if ($cache->serialized) {
$cache->data = $this->serializer->decode($cache->data);
}
return $cache;
}
/**
* Create cache entry.
*
* @param string $cid
* @param mixed $data
* @param int $expire
* @param string[] $tags
*
* @return array
*/
protected function createEntryHash($cid, $data, $expire, array $tags) {
// Always add a cache tag for the current bin, so that we can use that for
// invalidateAll().
if (Settings::get('redis_invalidate_all_as_delete', FALSE) === FALSE) {
$tags[] = $this->getTagForBin();
}
assert(Inspector::assertAllStrings($tags), 'Cache Tags must be strings.');
$hash = [
'cid' => $cid,
'created' => round(microtime(TRUE), 3),
'expire' => $expire,
'tags' => implode(' ', $tags),
'valid' => 1,
'checksum' => $this->checksumProvider->getCurrentChecksum($tags),
];
// Let Redis handle the data types itself.
if (!is_string($data)) {
$hash['data'] = $this->serializer->encode($data);
$hash['serialized'] = 1;
}
else {
$hash['data'] = $data;
$hash['serialized'] = 0;
}
if (Settings::get('redis_compress_length', 0) && strlen($hash['data']) > Settings::get('redis_compress_length', 0)) {
$hash['data'] = @gzcompress($hash['data'], Settings::get('redis_compress_level', 1));
$hash['gz'] = TRUE;
}
return $hash;
}
/**
* {@inheritdoc}
*/
public function invalidateMultiple(array $cids) {
// Loop over all cache items, they are stored as a hash, so we can access
// the valid flag directly, only write if it exists and is not 0.
foreach ($cids as $cid) {
$key = $this->getKey($cid);
if ($this->client->hGet($key, 'valid')) {
$this->client->hSet($key, 'valid', 0);
}
}
}
/**
* {@inheritdoc}
*/
public function invalidateAll() {
@trigger_error("CacheBackendInterface::invalidateAll() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use CacheBackendInterface::deleteAll() or cache tag invalidation instead. See https://www.drupal.org/node/3500622", E_USER_DEPRECATED);
if (Settings::get('redis_invalidate_all_as_delete', FALSE) === FALSE) {
// To invalidate the whole bin, we invalidate a special tag for this bin.
$this->checksumProvider->invalidateTags([$this->getTagForBin()]);
}
else {
// If the optimization for invalidate all is enabled, treat it as a
// deleteAll() so we only have to check one thing.
$this->deleteAll();
}
}
/**
* {@inheritdoc}
*/
public function garbageCollection() {
// @todo Do we need to do anything here?
}
/**
* Returns the last delete all timestamp.
*
* @return float
* The last delete timestamp as a timestamp with a millisecond precision.
*/
protected function getLastDeleteAll() {
// Cache the last delete all timestamp.
if ($this->lastDeleteAll === NULL) {
$this->lastDeleteAll = (float) $this->client->get($this->getKey(static::LAST_DELETE_ALL_KEY));
}
return $this->lastDeleteAll;
}
/**
* {@inheritdoc}
*/
public function deleteAll() {
// The last delete timestamp is in milliseconds, ensure that no cache
// was written in the same millisecond.
// @todo This is needed to make the tests pass, is this safe enough for real
// usage?
usleep(1000);
$this->lastDeleteAll = round(microtime(TRUE), 3);
$this->client->set($this->getKey(static::LAST_DELETE_ALL_KEY), $this->lastDeleteAll);
}
}
