data entries */ protected const IDENTIFIER_DATA_PREFIX = 'identData:'; /** * Key prefix for identifier->tags sets */ protected const IDENTIFIER_TAGS_PREFIX = 'identTags:'; /** * Key prefix for tag->identifiers sets */ protected const TAG_IDENTIFIERS_PREFIX = 'tagIdents:'; protected \Redis $redis; /** * Indicates whether the server is connected */ protected bool $connected = false; /** * Persistent connection */ protected bool $persistentConnection = false; /** * Hostname / IP of the Redis server, defaults to 127.0.0.1. */ protected string $hostname = '127.0.0.1'; /** * Port of the Redis server, defaults to 6379 */ protected int $port = 6379; /** * Number of selected database, defaults to 0 */ protected int $database = 0; /** * Username for authentication */ protected ?string $username = null; /** * Password for authentication */ protected ?string $password = null; /** * Indicates whether data is compressed or not (requires php zlib) */ protected bool $compression = false; /** * -1 to 9, indicates zlib compression level: -1 = default level 6, 0 = no compression, 9 maximum compression */ protected int $compressionLevel = -1; /** * limit in seconds (default is 0 meaning unlimited) */ protected int $connectionTimeout = 0; /** * Used as prefix for all Redis keys/identifiers */ protected string $keyPrefix = ''; public function __construct(array $options = []) { if (!extension_loaded('redis')) { throw new Exception('The PHP extension "redis" must be installed and loaded in order to use the redis backend.', 1279462933); } parent::__construct($options); } public function initializeObject(): void { $this->redis = new \Redis(); try { if ($this->persistentConnection) { $this->connected = $this->redis->pconnect($this->hostname, $this->port, $this->connectionTimeout, (string)$this->database); } else { $this->connected = $this->redis->connect($this->hostname, $this->port, $this->connectionTimeout); } } catch (\Exception $e) { $this->logger->alert('Could not connect to redis server.', ['exception' => $e]); } if ($this->connected) { $authentication = $this->getAuthentication(); if ($authentication !== null) { $success = $this->redis->auth($this->getAuthentication()); if (!$success) { throw new Exception('Authentication to Redis failed”.', 1279765134); } } if ($this->database >= 0) { $success = $this->redis->select($this->database); if (!$success) { throw new Exception('The given database "' . $this->database . '" could not be selected.', 1279765144); } } } } protected function setPersistentConnection(bool $persistentConnection): void { $this->persistentConnection = $persistentConnection; } protected function setHostname(string $hostname): void { $this->hostname = $hostname; } protected function setPort(int $port): void { $this->port = $port; } protected function setDatabase(int $database): void { if ($database < 0) { throw new \InvalidArgumentException('The specified database "' . $database . '" must be greater or equal than zero.', 1279763534); } $this->database = $database; } protected function setUsername(string $username): void { $this->username = $username; } /** * Setter for authentication password */ protected function setPassword(#[\SensitiveParameter] string $password): void { $this->password = $password; } protected function setCompression(bool $compression): void { $this->compression = $compression; } /** * Set data compression level. * If compression is enabled and this is not set, * gzcompress default level will be used. * * @param int $compressionLevel -1 to 9: Compression level */ protected function setCompressionLevel(int $compressionLevel): void { if ($compressionLevel >= -1 && $compressionLevel <= 9) { $this->compressionLevel = $compressionLevel; } else { throw new \InvalidArgumentException('The specified compression level must be an integer between -1 and 9.', 1289679155); } } /** * Set connection timeout. * This value in seconds is used as a maximum number * of seconds to wait if a connection can be established. * * @param int $connectionTimeout limit in seconds, a value greater or equal than 0 */ protected function setConnectionTimeout(int $connectionTimeout): void { if ($connectionTimeout < 0) { throw new \InvalidArgumentException('The specified connection timeout "' . $connectionTimeout . '" must be greater or equal than zero.', 1487849326); } $this->connectionTimeout = $connectionTimeout; } protected function setKeyPrefix(string $keyPrefix): void { $this->keyPrefix = $keyPrefix; } /** * Save data in the cache * * Scales O(1) with number of cache entries * Scales O(n) with number of tags */ public function set(string $entryIdentifier, string $data, array $tags = [], ?int $lifetime = null): void { $lifetime ??= $this->defaultLifetime; if ($lifetime < 0) { throw new \InvalidArgumentException('The specified lifetime "' . $lifetime . '" must be greater or equal than zero.', 1279487573); } if ($this->connected) { $expiration = $lifetime === 0 ? self::FAKED_UNLIMITED_LIFETIME : $lifetime; if ($this->compression) { $data = gzcompress($data, $this->compressionLevel); } $this->redis->setex($this->getDataIdentifier($entryIdentifier), $expiration, $data); $addTags = $tags; $removeTags = []; $existingTags = $this->redis->sMembers($this->getTagsIdentifier($entryIdentifier)); if (!empty($existingTags)) { $addTags = array_diff($tags, $existingTags); $removeTags = array_diff($existingTags, $tags); } if (!empty($removeTags) || !empty($addTags)) { $queue = $this->redis->multi(\Redis::PIPELINE); foreach ($removeTags as $tag) { $queue->sRem($this->getTagsIdentifier($entryIdentifier), $tag); $queue->sRem($this->getTagIdentifier($tag), $entryIdentifier); } foreach ($addTags as $tag) { $queue->sAdd($this->getTagsIdentifier($entryIdentifier), $tag); $queue->sAdd($this->getTagIdentifier($tag), $entryIdentifier); } $queue->exec(); } } } /** * Loads data from the cache. * * Scales O(1) with number of cache entries */ public function get(string $entryIdentifier): mixed { $storedEntry = false; if ($this->connected) { $storedEntry = $this->redis->get($this->getDataIdentifier($entryIdentifier)); } if ($this->compression && (string)$storedEntry !== '') { return gzuncompress((string)$storedEntry); } return $storedEntry; } /** * Checks if a cache entry with the specified identifier exists. * * Scales O(1) with number of cache entries */ public function has(string $entryIdentifier): bool { return $this->connected && $this->redis->exists($this->getDataIdentifier($entryIdentifier)); } /** * Removes all cache entries matching the specified identifier. * * Scales O(1) with number of cache entries * Scales O(n) with number of tags */ public function remove(string $entryIdentifier): bool { if (!$this->connected) { return false; } if (!$this->redis->exists($this->getDataIdentifier($entryIdentifier))) { return false; } $assignedTags = $this->redis->sMembers($this->getTagsIdentifier($entryIdentifier)); $queue = $this->redis->multi(\Redis::PIPELINE); foreach ($assignedTags as $tag) { $queue->sRem($this->getTagIdentifier($tag), $entryIdentifier); } $queue->del($this->getDataIdentifier($entryIdentifier), $this->getTagsIdentifier($entryIdentifier)); $queue->exec(); return true; } /** * Finds and returns all cache entry identifiers which are tagged by the specified tag. * * Scales O(1) with number of cache entries * Scales O(n) with number of tag entries */ public function findIdentifiersByTag(string $tag): array { if (!$this->connected) { return []; } return $this->redis->sMembers($this->getTagIdentifier($tag)); } public function flush(): void { if (!$this->connected) { return; } // unless we have a key prefix all data can be flushed if ($this->keyPrefix === '') { $this->redis->flushDB(); return; } $keys = $this->redis->keys($this->keyPrefix . '*'); $queue = $this->redis->multi(); $queue->del($keys); $queue->exec(); } /** * Removes all cache entries of this cache which are tagged with the specified tag. * * Scales O(1) with number of cache entries * Scales O(n^2) with number of tag entries */ public function flushByTag(string $tag): void { if (!$this->connected) { return; } $identifiers = $this->redis->sMembers($this->getTagIdentifier($tag)); if (!empty($identifiers)) { $this->removeIdentifierEntriesAndRelations($identifiers, [$tag]); } } public function flushByTags(array $tags): void { array_walk($tags, $this->flushByTag(...)); } /** * With the current internal structure, only the identifier to data entries * have a redis internal lifetime. If an entry expires, attached * identifier to tags and tag to identifiers entries will be left over. * This method finds those entries and cleans them up. * * Scales O(n*m) with number of cache entries (n) and number of tags (m) */ public function collectGarbage(): void { $identifierToTagsKeys = $this->redis->keys($this->getTagsIdentifier('*')); foreach ($identifierToTagsKeys as $identifierToTagsKey) { [, $identifier] = explode(':', $identifierToTagsKey); // Check if the data entry still exists if (!$this->redis->exists($this->getDataIdentifier($identifier))) { $tagsToRemoveIdentifierFrom = $this->redis->sMembers($identifierToTagsKey); $queue = $this->redis->multi(\Redis::PIPELINE); $queue->del($identifierToTagsKey); foreach ($tagsToRemoveIdentifierFrom as $tag) { $queue->sRem($this->getTagIdentifier($tag), $identifier); } $queue->exec(); } } } /** * Helper method for flushByTag() * Gets list of identifiers and tags and removes all relations of those tags * * Scales O(1) with number of cache entries * Scales O(n^2) with number of tags */ protected function removeIdentifierEntriesAndRelations(array $identifiers, array $tags): void { // Set a temporary entry which holds all identifiers that need to be removed from // the tag to identifiers sets $uniqueTempKey = 'temp:' . StringUtility::getUniqueId(); $prefixedKeysToDelete = [$uniqueTempKey]; $prefixedIdentifierToTagsKeysToDelete = []; foreach ($identifiers as $identifier) { $prefixedKeysToDelete[] = $this->getDataIdentifier($identifier); $prefixedIdentifierToTagsKeysToDelete[] = $this->getTagsIdentifier($identifier); } foreach ($tags as $tag) { $prefixedKeysToDelete[] = $this->getTagIdentifier($tag); } $tagToIdentifiersSetsToRemoveIdentifiersFrom = $this->redis->sUnion(...$prefixedIdentifierToTagsKeysToDelete); // Remove the tag to identifier set of the given tags, they will be removed anyway $tagToIdentifiersSetsToRemoveIdentifiersFrom = array_diff($tagToIdentifiersSetsToRemoveIdentifiersFrom, $tags); // Diff all identifiers that must be removed from tag to identifiers sets off from a // tag to identifiers set and store result in same tag to identifiers set again $queue = $this->redis->multi(\Redis::PIPELINE); foreach ($identifiers as $identifier) { $queue->sAdd($uniqueTempKey, $identifier); } foreach ($tagToIdentifiersSetsToRemoveIdentifiersFrom as $tagToIdentifiersSet) { $queue->sDiffStore($this->getTagIdentifier($tagToIdentifiersSet), $this->getTagIdentifier($tagToIdentifiersSet), $uniqueTempKey); } $queue->del(array_merge($prefixedKeysToDelete, $prefixedIdentifierToTagsKeysToDelete)); $queue->exec(); } protected function getDataIdentifier(string $identifier): string { return $this->keyPrefix . self::IDENTIFIER_DATA_PREFIX . $identifier; } protected function getTagsIdentifier(string $identifier): string { return $this->keyPrefix . self::IDENTIFIER_TAGS_PREFIX . $identifier; } protected function getTagIdentifier(string $tag): string { return $this->keyPrefix . self::TAG_IDENTIFIERS_PREFIX . $tag; } /** * Build the authentication value based on the configuration, returning an associative array * in case `username` and `password` has been configured, the `password` as string if only * password has been configured or `null` to indicate no-authentication configuration, which * is also possible to be used with `redis`. */ protected function getAuthentication(): array|string|null { return match (true) { // Username and password configured for authentication, build associative array // out of possible and supported array variants by `php-redis::auth()`. ($this->username !== null && $this->password !== null) => [ 'user' => $this->username, 'pass' => $this->password, ], // Password-only authentication configured. ($this->username === null && $this->password !== null) => $this->password, // No authentication configured. default => null, }; } }