├── .github ├── .kodiak.toml └── workflows │ ├── codesniffer.yml │ ├── coverage.yml │ ├── phpstan.yml │ └── tests.yml ├── LICENSE ├── composer.json ├── phpstan-baseline.neon └── src ├── Caching ├── RedisJournal.php └── RedisStorage.php ├── DI ├── RedisExtension.php └── RedisExtension24.php ├── Exception ├── Logic │ └── InvalidStateException.php └── LogicalException.php ├── Serializer ├── DefaultSerializer.php ├── IgbinarySerializer.php ├── Serializer.php └── SnappySerializer.php └── Tracy ├── RedisPanel.php └── templates └── panel.phtml /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [merge] 4 | automerge_label = "automerge" 5 | blacklist_title_regex = "^WIP.*" 6 | blacklist_labels = ["WIP"] 7 | method = "rebase" 8 | delete_branch_on_merge = true 9 | notify_on_conflict = true 10 | optimistic_updates = false 11 | -------------------------------------------------------------------------------- /.github/workflows/codesniffer.yml: -------------------------------------------------------------------------------- 1 | name: "Codesniffer" 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | push: 8 | branches: ["*"] 9 | 10 | schedule: 11 | - cron: "0 8 * * 1" 12 | 13 | jobs: 14 | codesniffer: 15 | name: "Codesniffer" 16 | uses: contributte/.github/.github/workflows/codesniffer.yml@master 17 | with: 18 | php: "8.3" 19 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: "Coverage" 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | push: 8 | branches: ["*"] 9 | 10 | schedule: 11 | - cron: "0 8 * * 1" 12 | 13 | jobs: 14 | coverage: 15 | name: "Nette Tester" 16 | uses: contributte/.github/.github/workflows/nette-tester-coverage-v2.yml@master 17 | with: 18 | php: "8.3" 19 | -------------------------------------------------------------------------------- /.github/workflows/phpstan.yml: -------------------------------------------------------------------------------- 1 | name: "Phpstan" 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | push: 8 | branches: ["*"] 9 | 10 | schedule: 11 | - cron: "0 8 * * 1" 12 | 13 | jobs: 14 | phpstan: 15 | name: "Phpstan" 16 | uses: contributte/.github/.github/workflows/phpstan.yml@master 17 | with: 18 | php: "8.3" 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: "Nette Tester" 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | push: 8 | branches: [ "*" ] 9 | 10 | schedule: 11 | - cron: "0 8 * * 1" 12 | 13 | jobs: 14 | test84: 15 | name: "Nette Tester" 16 | uses: contributte/.github/.github/workflows/nette-tester.yml@master 17 | with: 18 | php: "8.4" 19 | 20 | test83: 21 | name: "Nette Tester" 22 | uses: contributte/.github/.github/workflows/nette-tester.yml@master 23 | with: 24 | php: "8.3" 25 | 26 | test82: 27 | name: "Nette Tester" 28 | uses: contributte/.github/.github/workflows/nette-tester.yml@master 29 | with: 30 | php: "8.2" 31 | 32 | testlower: 33 | name: "Nette Tester" 34 | uses: contributte/.github/.github/workflows/nette-tester.yml@master 35 | with: 36 | php: "8.2" 37 | composer: "composer update --no-interaction --no-progress --prefer-dist --prefer-stable --prefer-lowest" 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Contributte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contributte/redis", 3 | "description": "Redis client integration into Nette framework", 4 | "keywords": [ 5 | "nette", 6 | "redis", 7 | "cache", 8 | "predis" 9 | ], 10 | "type": "library", 11 | "license": "MIT", 12 | "homepage": "https://github.com/contributte/redis", 13 | "authors": [ 14 | { 15 | "name": "Milan Felix Šulc", 16 | "homepage": "https://f3l1x.io" 17 | } 18 | ], 19 | "require": { 20 | "ext-json": "*", 21 | "php": ">=8.2", 22 | "nette/di": "^3.2.4", 23 | "predis/predis": "^2.3.0" 24 | }, 25 | "suggest": { 26 | "ext-igbinary": "For Igbinary serialization", 27 | "ext-snappy": "For Snappy serialization" 28 | }, 29 | "require-dev": { 30 | "nette/caching": "^3.1.3", 31 | "nette/http": "^3.0.1", 32 | "contributte/qa": "^0.4.0", 33 | "contributte/phpstan": "^0.3.0", 34 | "contributte/tester": "^0.4.0", 35 | "mockery/mockery": "^1.5.1", 36 | "tracy/tracy": "^2.10.9" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "Contributte\\Redis\\": "src" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "Tests\\": "tests" 46 | } 47 | }, 48 | "minimum-stability": "dev", 49 | "prefer-stable": true, 50 | "config": { 51 | "sort-packages": true, 52 | "allow-plugins": { 53 | "dealerdirect/phpcodesniffer-composer-installer": true 54 | } 55 | }, 56 | "extra": { 57 | "branch-alias": { 58 | "dev-master": "0.7.x-dev" 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: '#^Cannot cast mixed to string\.$#' 5 | identifier: cast.string 6 | count: 6 7 | path: src/Caching/RedisJournal.php 8 | 9 | - 10 | message: '#^Casting to array something that''s already array\.$#' 11 | identifier: cast.useless 12 | count: 1 13 | path: src/Caching/RedisJournal.php 14 | 15 | - 16 | message: '#^Casting to array\ something that''s already array\\.$#' 17 | identifier: cast.useless 18 | count: 3 19 | path: src/Caching/RedisJournal.php 20 | 21 | - 22 | message: '#^Casting to int something that''s already int\.$#' 23 | identifier: cast.useless 24 | count: 1 25 | path: src/Caching/RedisJournal.php 26 | 27 | - 28 | message: '#^Offset ''priority'' on array\{tags\: array\, priority\: int\} in isset\(\) always exists and is not nullable\.$#' 29 | identifier: isset.offset 30 | count: 1 31 | path: src/Caching/RedisJournal.php 32 | 33 | - 34 | message: '#^Offset ''tags'' on array\{tags\: array\, priority\: int\} in isset\(\) always exists and is not nullable\.$#' 35 | identifier: isset.offset 36 | count: 1 37 | path: src/Caching/RedisJournal.php 38 | 39 | - 40 | message: '#^Parameter \#2 \$dependencies \(array\{tags\: array\, priority\: int\}\) of method Contributte\\Redis\\Caching\\RedisJournal\:\:write\(\) should be contravariant with parameter \$dependencies \(array\) of method Nette\\Caching\\Storages\\Journal\:\:write\(\)$#' 41 | identifier: method.childParameterType 42 | count: 1 43 | path: src/Caching/RedisJournal.php 44 | 45 | - 46 | message: '#^Strict comparison using \!\=\= between array and null will always evaluate to true\.$#' 47 | identifier: notIdentical.alwaysTrue 48 | count: 1 49 | path: src/Caching/RedisJournal.php 50 | 51 | - 52 | message: '#^Strict comparison using \!\=\= between array\ and null will always evaluate to true\.$#' 53 | identifier: notIdentical.alwaysTrue 54 | count: 2 55 | path: src/Caching/RedisJournal.php 56 | 57 | - 58 | message: '#^Binary operation "\+" between array\{key\: string\} and mixed results in an error\.$#' 59 | identifier: binaryOp.invalid 60 | count: 1 61 | path: src/Caching/RedisStorage.php 62 | 63 | - 64 | message: '#^Binary operation "\+" between mixed and int\<1, max\> results in an error\.$#' 65 | identifier: binaryOp.invalid 66 | count: 1 67 | path: src/Caching/RedisStorage.php 68 | 69 | - 70 | message: '#^Cannot access offset 0 on mixed\.$#' 71 | identifier: offsetAccess.nonOffsetAccessible 72 | count: 1 73 | path: src/Caching/RedisStorage.php 74 | 75 | - 76 | message: '#^Cannot cast mixed to int\.$#' 77 | identifier: cast.int 78 | count: 1 79 | path: src/Caching/RedisStorage.php 80 | 81 | - 82 | message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' 83 | identifier: empty.notAllowed 84 | count: 2 85 | path: src/Caching/RedisStorage.php 86 | 87 | - 88 | message: '#^Do\-while loop condition is always false\.$#' 89 | identifier: doWhile.alwaysFalse 90 | count: 1 91 | path: src/Caching/RedisStorage.php 92 | 93 | - 94 | message: '#^Instanceof between string\|null and Predis\\Response\\Status will always evaluate to false\.$#' 95 | identifier: instanceof.alwaysFalse 96 | count: 1 97 | path: src/Caching/RedisStorage.php 98 | 99 | - 100 | message: '#^Method Contributte\\Redis\\Caching\\RedisStorage\:\:readMeta\(\) should return array\\|null but returns mixed\.$#' 101 | identifier: return.type 102 | count: 1 103 | path: src/Caching/RedisStorage.php 104 | 105 | - 106 | message: '#^Only booleans are allowed in &&, array\|null given on the left side\.$#' 107 | identifier: booleanAnd.leftNotBoolean 108 | count: 1 109 | path: src/Caching/RedisStorage.php 110 | 111 | - 112 | message: '#^Only booleans are allowed in a negated boolean, string\|null given\.$#' 113 | identifier: booleanNot.exprNotBoolean 114 | count: 1 115 | path: src/Caching/RedisStorage.php 116 | 117 | - 118 | message: '#^Parameter \#1 \$data of method Contributte\\Redis\\Serializer\\Serializer\:\:unserialize\(\) expects string, mixed given\.$#' 119 | identifier: argument.type 120 | count: 1 121 | path: src/Caching/RedisStorage.php 122 | 123 | - 124 | message: '#^Parameter \#1 \$json of function json_decode expects string, string\|null given\.$#' 125 | identifier: argument.type 126 | count: 1 127 | path: src/Caching/RedisStorage.php 128 | 129 | - 130 | message: '#^Parameter \#1 \$key of static method Contributte\\Redis\\Caching\\RedisStorage\:\:processStoredValue\(\) expects string, mixed given\.$#' 131 | identifier: argument.type 132 | count: 1 133 | path: src/Caching/RedisStorage.php 134 | 135 | - 136 | message: '#^Parameter \#1 \$meta of method Contributte\\Redis\\Caching\\RedisStorage\:\:verify\(\) expects array\, mixed given\.$#' 137 | identifier: argument.type 138 | count: 2 139 | path: src/Caching/RedisStorage.php 140 | 141 | - 142 | message: '#^Parameter \#1 \$stored of method Contributte\\Redis\\Caching\\RedisStorage\:\:getUnserializedValue\(\) expects array\, mixed given\.$#' 143 | identifier: argument.type 144 | count: 1 145 | path: src/Caching/RedisStorage.php 146 | 147 | - 148 | message: '#^Parameter \#2 \$meta of method Contributte\\Redis\\Serializer\\Serializer\:\:unserialize\(\) expects array\, mixed given\.$#' 149 | identifier: argument.type 150 | count: 1 151 | path: src/Caching/RedisStorage.php 152 | 153 | - 154 | message: '#^Result of && is always false\.$#' 155 | identifier: booleanAnd.alwaysFalse 156 | count: 1 157 | path: src/Caching/RedisStorage.php 158 | -------------------------------------------------------------------------------- /src/Caching/RedisJournal.php: -------------------------------------------------------------------------------- 1 | client = $client; 27 | } 28 | 29 | /** 30 | * Writes entry information into the journal. 31 | * 32 | * @param array{tags: string[], priority: int} $dependencies 33 | */ 34 | public function write(string $key, array $dependencies): void 35 | { 36 | $this->cleanEntry($key); 37 | 38 | $this->client->multi(); 39 | 40 | // add entry to each tag & tag to entry 41 | $tags = !isset($dependencies[Cache::Tags]) ? [] : (array) $dependencies[Cache::Tags]; 42 | foreach (array_unique($tags) as $tag) { 43 | $this->client->sadd($this->formatKey($tag, self::SUFFIX_KEYS), [$key]); 44 | $this->client->sadd($this->formatKey($key, self::SUFFIX_TAGS), [$tag]); 45 | } 46 | 47 | if (isset($dependencies[Cache::Priority])) { 48 | $this->client->zadd($this->formatKey(self::KEY_PRIORITY), [$key => (int) $dependencies[Cache::Priority]]); 49 | } 50 | 51 | $this->client->exec(); 52 | } 53 | 54 | /** 55 | * Deletes all keys from associated tags and all priorities 56 | * 57 | * @param mixed[]|string $keys 58 | */ 59 | public function cleanEntry(array|string $keys): void 60 | { 61 | foreach (is_array($keys) ? $keys : [$keys] as $key) { 62 | $entries = $this->entryTags((string) $key); 63 | 64 | $this->client->multi(); 65 | foreach ($entries as $tag) { 66 | $this->client->srem($this->formatKey((string) $tag, self::SUFFIX_KEYS), (string) $key); 67 | } 68 | 69 | // drop tags of entry and priority, in case there are some 70 | $this->client->del($this->formatKey((string) $key, self::SUFFIX_TAGS)); 71 | $this->client->zrem($this->formatKey(self::KEY_PRIORITY), (string) $key); 72 | 73 | $this->client->exec(); 74 | } 75 | } 76 | 77 | /** 78 | * Cleans entries from journal. 79 | * 80 | * @param mixed[] $conditions 81 | * @return mixed[] of removed items or NULL when performing a full cleanup 82 | */ 83 | public function clean(array $conditions): ?array 84 | { 85 | if (isset($conditions[Cache::All])) { 86 | $all = $this->client->keys(self::NS_PREFIX . ':*'); 87 | 88 | $this->client->multi(); 89 | call_user_func_array([$this->client, 'del'], $all); 90 | $this->client->exec(); 91 | 92 | return null; 93 | } 94 | 95 | $entries = []; 96 | if (isset($conditions[Cache::Tags])) { 97 | foreach ((array) $conditions[Cache::Tags] as $tag) { 98 | $this->cleanEntry($found = $this->tagEntries((string) $tag)); 99 | $entries[] = $found; 100 | } 101 | 102 | $entries = array_merge(...$entries); 103 | } 104 | 105 | if (isset($conditions[Cache::Priority])) { 106 | $this->cleanEntry($found = $this->priorityEntries($conditions[Cache::Priority])); 107 | $entries = array_merge($entries, $found); 108 | } 109 | 110 | return array_unique($entries); 111 | } 112 | 113 | /** 114 | * @return mixed[] 115 | */ 116 | private function priorityEntries(int $priority): array 117 | { 118 | $result = $this->client->zrangebyscore($this->formatKey(self::KEY_PRIORITY), 0, $priority); 119 | 120 | return $result !== null ? (array) $result : []; 121 | } 122 | 123 | /** 124 | * @return mixed[] 125 | */ 126 | private function entryTags(string $key): array 127 | { 128 | $result = $this->client->smembers($this->formatKey($key, self::SUFFIX_TAGS)); 129 | 130 | return $result !== null ? (array) $result : []; 131 | } 132 | 133 | /** 134 | * @return mixed[] 135 | */ 136 | private function tagEntries(string $tag): array 137 | { 138 | $result = $this->client->smembers($this->formatKey($tag, self::SUFFIX_KEYS)); 139 | 140 | return $result !== null ? (array) $result : []; 141 | } 142 | 143 | private function formatKey(string $key, ?string $suffix = null): string 144 | { 145 | return self::NS_PREFIX . ':' . $key . ($suffix !== null ? ':' . $suffix : ''); 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /src/Caching/RedisStorage.php: -------------------------------------------------------------------------------- 1 | timestamp) 34 | private const META_ITEMS = 'di'; 35 | 36 | // array of callbacks (function, args) 37 | private const META_CALLBACKS = 'callbacks'; 38 | 39 | // additional cache structure 40 | private const KEY = 'key'; 41 | 42 | private ClientInterface $client; 43 | 44 | private Journal|null $journal; 45 | 46 | private Serializer $serializer; 47 | 48 | public function __construct(ClientInterface $client, ?Journal $journal = null, ?Serializer $serializer = null) 49 | { 50 | $this->client = $client; 51 | $this->journal = $journal; 52 | $this->serializer = $serializer ?? new DefaultSerializer(); 53 | } 54 | 55 | public function setSerializer(Serializer $serializer): void 56 | { 57 | $this->serializer = $serializer; 58 | } 59 | 60 | public function getClient(): ClientInterface 61 | { 62 | return $this->client; 63 | } 64 | 65 | /** 66 | * Read from cache. 67 | */ 68 | public function read(string $key): mixed 69 | { 70 | $stored = $this->doRead($key); 71 | 72 | if ($stored === null || !$this->verify($stored[0])) { 73 | return null; 74 | } 75 | 76 | return $this->getUnserializedValue($stored); 77 | } 78 | 79 | /** 80 | * Removes item from the cache. 81 | */ 82 | public function remove(string $key): void 83 | { 84 | $this->client->del([$this->formatEntryKey($key)]); 85 | 86 | if ($this->journal instanceof RedisJournal) { 87 | $this->journal->cleanEntry($this->formatEntryKey($key)); 88 | } 89 | } 90 | 91 | /** 92 | * Read multiple entries from cache (using mget) 93 | * 94 | * @param mixed[] $keys 95 | * @return mixed[] 96 | */ 97 | public function multiRead(array $keys): array 98 | { 99 | $values = []; 100 | foreach ($this->doMultiRead($keys) as $key => $stored) { 101 | $values[$key] = null; 102 | if ($stored !== null && $this->verify($stored[0])) { 103 | $values[$key] = $this->getUnserializedValue($stored); 104 | } 105 | } 106 | 107 | return $values; 108 | } 109 | 110 | public function lock(string $key): void 111 | { 112 | // unsupported now 113 | } 114 | 115 | /** 116 | * Writes item into the cache. 117 | * 118 | * @param mixed[] $dependencies 119 | */ 120 | public function write(string $key, mixed $data, array $dependencies): void 121 | { 122 | $meta = [ 123 | self::META_TIME => microtime(), 124 | ]; 125 | 126 | if (isset($dependencies[Cache::Expire])) { 127 | if (!isset($dependencies[Cache::Sliding])) { 128 | $meta[self::META_EXPIRE] = $dependencies[Cache::Expire] + time(); // absolute time 129 | 130 | } else { 131 | $meta[self::META_DELTA] = (int) $dependencies[Cache::Expire]; // sliding time 132 | } 133 | } 134 | 135 | if (isset($dependencies[Cache::Items])) { 136 | foreach ((array) $dependencies[Cache::Items] as $itemName) { 137 | $m = $this->readMeta($itemName); 138 | $meta[self::META_ITEMS][$itemName] = $m[self::META_TIME] ?? null; // may be null 139 | unset($m); 140 | } 141 | } 142 | 143 | if (isset($dependencies[Cache::Callbacks])) { 144 | $meta[self::META_CALLBACKS] = $dependencies[Cache::Callbacks]; 145 | } 146 | 147 | $cacheKey = $this->formatEntryKey($key); 148 | 149 | if (isset($dependencies[Cache::Tags]) || isset($dependencies[Cache::Priority])) { 150 | if ($this->journal === null) { 151 | throw new InvalidStateException('CacheJournal has not been provided.'); 152 | } 153 | 154 | $this->journal->write($cacheKey, $dependencies); 155 | } 156 | 157 | $data = $this->serializer->serialize($data, $meta); 158 | $store = json_encode($meta) . self::NS_SEPARATOR . $data; 159 | 160 | try { 161 | if (isset($dependencies[Cache::Expire])) { 162 | $this->client->setex($cacheKey, (int) $dependencies[Cache::Expire], $store); 163 | 164 | } else { 165 | $this->client->set($cacheKey, $store); 166 | } 167 | 168 | $this->unlock($key); 169 | 170 | } catch (PredisException $e) { 171 | $this->remove($key); 172 | 173 | throw new InvalidStateException($e->getMessage(), $e->getCode(), $e); 174 | } 175 | } 176 | 177 | /** 178 | * @internal 179 | */ 180 | public function unlock(string $key): void 181 | { 182 | // unsupported 183 | } 184 | 185 | /** 186 | * Removes items from the cache by conditions & garbage collector. 187 | * 188 | * @param mixed[] $conditions 189 | */ 190 | public function clean(array $conditions): void 191 | { 192 | // cleaning using file iterator 193 | if (isset($conditions[Cache::All])) { 194 | $this->client->flushdb(); 195 | 196 | return; 197 | } 198 | 199 | // cleaning using journal 200 | if ($this->journal !== null) { 201 | $keys = $this->journal->clean($conditions); 202 | if ($keys !== null) { 203 | $this->client->del($keys); 204 | } 205 | } 206 | } 207 | 208 | /** 209 | * @return mixed[]|null 210 | */ 211 | protected function readMeta(string $key): ?array 212 | { 213 | $stored = $this->doRead($key); 214 | 215 | if ($stored === null) { 216 | return null; 217 | } 218 | 219 | return $stored[0]; 220 | } 221 | 222 | /** 223 | * @return mixed[] 224 | */ 225 | private static function processStoredValue(string $key, string $storedValue): array 226 | { 227 | [$meta, $data] = explode(self::NS_SEPARATOR, $storedValue, 2) + [null, null]; 228 | 229 | return [[self::KEY => $key] + json_decode($meta, true), $data]; 230 | } 231 | 232 | private function formatEntryKey(string $key): string 233 | { 234 | return self::NS_PREFIX . ':' . str_replace(self::NS_SEPARATOR, ':', $key); 235 | } 236 | 237 | /** 238 | * Verifies dependencies. 239 | * 240 | * @param mixed[] $meta 241 | */ 242 | private function verify(array $meta): bool 243 | { 244 | do { 245 | if (isset($meta[self::META_DELTA]) && $meta[self::META_DELTA] !== '') { 246 | $this->client->expire($this->formatEntryKey($meta[self::KEY]), (int) $meta[self::META_DELTA]); 247 | 248 | } elseif (isset($meta[self::META_EXPIRE]) && $meta[self::META_EXPIRE] < time()) { 249 | break; 250 | } 251 | 252 | if (!empty($meta[self::META_CALLBACKS]) && !Cache::checkCallbacks($meta[self::META_CALLBACKS])) { 253 | break; 254 | } 255 | 256 | if (!empty($meta[self::META_ITEMS])) { 257 | foreach ($meta[self::META_ITEMS] as $itemKey => $time) { 258 | $m = $this->readMeta($itemKey); 259 | $metaTime = $m[self::META_TIME] ?? null; 260 | if ($metaTime !== $time || ($m && !$this->verify($m))) { 261 | break 2; 262 | } 263 | } 264 | } 265 | 266 | return true; 267 | } while (false); 268 | 269 | $this->remove($meta[self::KEY]); // meta[handle] & meta[file] was added by readMetaAndLock() 270 | 271 | return false; 272 | } 273 | 274 | /** 275 | * @param mixed[] $stored 276 | */ 277 | private function getUnserializedValue(array $stored): mixed 278 | { 279 | return $this->serializer->unserialize($stored[1], $stored[0]); 280 | } 281 | 282 | /** 283 | * @return mixed[]|null 284 | */ 285 | private function doRead(string $key): ?array 286 | { 287 | $stored = $this->client->get($this->formatEntryKey($key)); 288 | if ($stored instanceof Status && $stored->getPayload() === 'QUEUED') { 289 | return null; 290 | } 291 | 292 | if (!$stored) { 293 | return null; 294 | } 295 | 296 | return self::processStoredValue($key, $stored); 297 | } 298 | 299 | /** 300 | * @param mixed[] $keys 301 | * @return mixed[] 302 | */ 303 | private function doMultiRead(array $keys): array 304 | { 305 | $formattedKeys = array_map([$this, 'formatEntryKey'], $keys); 306 | 307 | $result = []; 308 | foreach ($this->client->mget($formattedKeys) as $index => $stored) { 309 | $key = $keys[$index]; 310 | $result[$key] = $stored ? self::processStoredValue($key, $stored) : null; 311 | } 312 | 313 | return $result; 314 | } 315 | 316 | } 317 | -------------------------------------------------------------------------------- /src/DI/RedisExtension.php: -------------------------------------------------------------------------------- 1 | Expect::bool(false), 32 | 'serializer' => Expect::anyOf(Expect::string()), 33 | 'connection' => Expect::arrayOf(Expect::structure([ 34 | 'autowired' => Expect::bool(null), 35 | 'uri' => Expect::anyOf(Expect::string()->dynamic(), Expect::listOf(Expect::string()->dynamic()))->default('tcp://127.0.0.1:6379'), 36 | 'options' => Expect::array(), 37 | 'storageAutowired' => Expect::bool(null), 38 | 'storageClass' => Expect::string()->default(RedisStorage::class), 39 | 'storage' => Expect::bool(false), 40 | 'sessions' => Expect::anyOf( 41 | Expect::bool(), 42 | Expect::array() 43 | )->default(false), 44 | ])), 45 | 'clientFactory' => Expect::string(Client::class), 46 | ]); 47 | } 48 | 49 | public function loadConfiguration(): void 50 | { 51 | $builder = $this->getContainerBuilder(); 52 | $config = $this->config; 53 | 54 | $connections = []; 55 | 56 | foreach ($config->connection as $name => $connection) { 57 | $autowired = $connection->autowired ?? ($name === 'default'); 58 | 59 | $client = $builder->addDefinition($this->prefix('connection.' . $name . '.client')) 60 | ->setType(ClientInterface::class) 61 | ->setFactory($config->clientFactory, [$connection->uri, $connection->options]) 62 | ->setAutowired($autowired); 63 | 64 | $connections[] = [ 65 | 'name' => $name, 66 | 'client' => $client, 67 | 'uri' => $connection->uri, 68 | 'options' => $connection->options, 69 | ]; 70 | } 71 | 72 | if ($config->debug && $config->connection !== []) { 73 | $builder->addDefinition($this->prefix('panel')) 74 | ->setFactory(RedisPanel::class, [$connections]); 75 | } 76 | } 77 | 78 | public function beforeCompile(): void 79 | { 80 | $this->beforeCompileStorage(); 81 | $this->beforeCompileSession(); 82 | } 83 | 84 | public function beforeCompileStorage(): void 85 | { 86 | $builder = $this->getContainerBuilder(); 87 | $config = $this->config; 88 | $storages = 0; 89 | 90 | foreach ($config->connection as $name => $connection) { 91 | $autowired = $connection->autowired ?? ($name === 'default'); 92 | 93 | // Skip if replacing storage is disabled 94 | if (!$connection->storage) { 95 | continue; 96 | } 97 | 98 | // Validate needed services 99 | if ($builder->getByType(Storage::class) === null) { 100 | throw new RuntimeException(sprintf('Please install nette/caching package. %s is required', Storage::class)); 101 | } 102 | 103 | if ($storages === 0) { 104 | $builder->getDefinitionByType(Storage::class) 105 | ->setAutowired(false); 106 | } 107 | 108 | $builder->addDefinition($this->prefix('connection.' . $name . '.journal')) 109 | ->setFactory(RedisJournal::class) 110 | ->setArguments([ 111 | 'client' => $builder->getDefinition($this->prefix('connection.' . $name . '.client')), 112 | ]) 113 | ->setAutowired(false); 114 | 115 | $builder->addDefinition($this->prefix('connection.' . $name . '.storage')) 116 | ->setFactory($connection->storageClass) 117 | ->setArguments([ 118 | 'client' => $builder->getDefinition($this->prefix('connection.' . $name . '.client')), 119 | 'journal' => $builder->getDefinition($this->prefix('connection.' . $name . '.journal')), 120 | 'serializer' => $config->serializer, 121 | ]) 122 | ->setAutowired($connection->storageAutowired ?? $autowired); 123 | 124 | $storages++; 125 | } 126 | } 127 | 128 | public function beforeCompileSession(): void 129 | { 130 | $builder = $this->getContainerBuilder(); 131 | $config = $this->config; 132 | 133 | $sessionHandlingConnection = null; 134 | 135 | foreach ($config->connection as $name => $connection) { 136 | // Skip if replacing session is disabled 137 | if ($connection->sessions === false) { 138 | continue; 139 | } 140 | 141 | if ($sessionHandlingConnection === null) { 142 | $sessionHandlingConnection = $name; 143 | } else { 144 | throw new InvalidStateException(sprintf( 145 | 'Connections "%s" and "%s" both try to register session handler. Only one of them could have session handler enabled.', 146 | $sessionHandlingConnection, 147 | $name 148 | )); 149 | } 150 | 151 | // Validate needed services 152 | if ($builder->getByType(Session::class) === null) { 153 | throw new RuntimeException(sprintf('Please install nette/http package. %s is required', Session::class)); 154 | } 155 | 156 | // Validate session config 157 | $sessionConfig = $connection->sessions === true ? [ 158 | 'ttl' => null, 159 | ] : (array) $connection->sessions; 160 | 161 | $sessionHandler = $builder->addDefinition($this->prefix('connection.' . $name . 'sessionHandler')) 162 | ->setType(Handler::class) 163 | ->setArguments([$this->prefix('@connection.' . $name . '.client'), ['gc_maxlifetime' => $sessionConfig['ttl'] ?? null]]); 164 | 165 | $session = $builder->getDefinitionByType(Session::class); 166 | assert($session instanceof ServiceDefinition); 167 | $session->addSetup('setHandler', [$sessionHandler]); 168 | } 169 | } 170 | 171 | public function afterCompile(ClassType $class): void 172 | { 173 | $config = $this->config; 174 | 175 | if ($config->debug && $config->connection !== []) { 176 | $initialize = $class->getMethod('initialize'); 177 | $initialize->addBody('$this->getService(?)->addPanel($this->getService(?));', ['tracy.bar', $this->prefix('panel')]); 178 | } 179 | } 180 | 181 | } 182 | -------------------------------------------------------------------------------- /src/DI/RedisExtension24.php: -------------------------------------------------------------------------------- 1 | false, 25 | 'serializer' => null, 26 | 'connection' => [], 27 | 'clientFactory' => Client::class, 28 | ]; 29 | 30 | /** @var mixed[] */ 31 | private $connectionDefaults = [ 32 | 'uri' => 'tcp://127.0.0.1:6379', 33 | 'options' => [], 34 | 'storage' => false, 35 | 'sessions' => false, 36 | ]; 37 | 38 | /** @var mixed[] */ 39 | private $sessionDefaults = [ 40 | 'ttl' => null, 41 | ]; 42 | 43 | public function loadConfiguration(): void 44 | { 45 | $builder = $this->getContainerBuilder(); 46 | $config = $this->validateConfig($this->defaults); 47 | 48 | if (!isset($config['connection']['default'])) { 49 | throw new InvalidStateException(sprintf('%s.connection.default is required.', $this->name)); 50 | } 51 | 52 | $connections = []; 53 | 54 | foreach ($config['connection'] as $name => $connection) { 55 | $autowired = $name === 'default'; 56 | $connection = $this->validateConfig($this->connectionDefaults, $connection, $this->prefix('connection.' . $name)); 57 | 58 | $client = $builder->addDefinition($this->prefix('connection.' . $name . '.client')) 59 | ->setType(ClientInterface::class) 60 | ->setFactory($config['clientFactory'], [$connection['uri'], $connection['options']]) 61 | ->setAutowired($autowired); 62 | 63 | $connections[] = [ 64 | 'name' => $name, 65 | 'client' => $client, 66 | 'uri' => $connection['uri'], 67 | 'options' => $connection['options'], 68 | ]; 69 | } 70 | 71 | if ($config['debug'] === true) { 72 | $builder->addDefinition($this->prefix('panel')) 73 | ->setFactory(RedisPanel::class, [$connections]); 74 | } 75 | } 76 | 77 | public function beforeCompile(): void 78 | { 79 | $this->beforeCompileStorage(); 80 | $this->beforeCompileSession(); 81 | } 82 | 83 | public function beforeCompileStorage(): void 84 | { 85 | $builder = $this->getContainerBuilder(); 86 | $config = $this->validateConfig($this->defaults); 87 | $storages = 0; 88 | 89 | foreach ($config['connection'] as $name => $connection) { 90 | $autowired = $name === 'default'; 91 | $connection = $this->validateConfig($this->connectionDefaults, $connection, $this->prefix('connection.' . $name)); 92 | 93 | // Skip if replacing storage is disabled 94 | if ($connection['storage'] === false) { 95 | continue; 96 | } 97 | 98 | // Validate needed services 99 | if ($builder->getByType(IStorage::class) === null) { 100 | throw new RuntimeException(sprintf('Please install nette/caching package. %s is required', IStorage::class)); 101 | } 102 | 103 | if ($storages === 0) { 104 | $builder->getDefinitionByType(IStorage::class) 105 | ->setAutowired(false); 106 | } 107 | 108 | $builder->addDefinition($this->prefix('connection.' . $name . '.journal')) 109 | ->setFactory(RedisJournal::class) 110 | ->setAutowired(false); 111 | 112 | $builder->addDefinition($this->prefix('connection.' . $name . '.storage')) 113 | ->setFactory(RedisStorage::class) 114 | ->setArguments([ 115 | 'client' => $builder->getDefinition($this->prefix('connection.' . $name . '.client')), 116 | 'journal' => $builder->getDefinition($this->prefix('connection.' . $name . '.journal')), 117 | 'serializer' => $config['serializer'], 118 | ]) 119 | ->setAutowired($autowired); 120 | 121 | $storages++; 122 | } 123 | } 124 | 125 | public function beforeCompileSession(): void 126 | { 127 | $builder = $this->getContainerBuilder(); 128 | $config = $this->validateConfig($this->defaults); 129 | 130 | $sessionHandlingConnection = null; 131 | 132 | foreach ($config['connection'] as $name => $connection) { 133 | $connection = $this->validateConfig($this->connectionDefaults, $connection, $this->prefix('connection.' . $name)); 134 | 135 | // Skip if replacing session is disabled 136 | if ($connection['sessions'] === false) { 137 | continue; 138 | } 139 | 140 | if ($sessionHandlingConnection === null) { 141 | $sessionHandlingConnection = $name; 142 | } else { 143 | throw new InvalidStateException(sprintf( 144 | 'Connections "%s" and "%s" both try to register session handler. Only one of them could have session handler enabled.', 145 | $sessionHandlingConnection, 146 | $name 147 | )); 148 | } 149 | 150 | // Validate given config 151 | Validators::assert($connection['sessions'], 'bool|array'); 152 | 153 | // Validate needed services 154 | if ($builder->getByType(Session::class) === null) { 155 | throw new RuntimeException(sprintf('Please install nette/http package. %s is required', Session::class)); 156 | } 157 | 158 | // Validate session config 159 | $sessionConfig = $connection['sessions'] === true ? $this->sessionDefaults : $this->validateConfig($this->sessionDefaults, $connection['sessions'], $this->prefix('connection.' . $name . 'sessions')); 160 | 161 | $sessionHandler = $builder->addDefinition($this->prefix('connection.' . $name . 'sessionHandler')) 162 | ->setType(Handler::class) 163 | ->setArguments([$this->prefix('@connection.' . $name . '.client'), ['gc_maxlifetime' => $sessionConfig['ttl']]]); 164 | 165 | $builder->getDefinitionByType(Session::class) 166 | ->addSetup('setHandler', [$sessionHandler]); 167 | } 168 | } 169 | 170 | public function afterCompile(ClassType $class): void 171 | { 172 | $config = $this->validateConfig($this->defaults); 173 | 174 | if ($config['debug'] === true) { 175 | $initialize = $class->getMethod('initialize'); 176 | $initialize->addBody('$this->getService(?)->addPanel($this->getService(?));', ['tracy.bar', $this->prefix('panel')]); 177 | } 178 | } 179 | 180 | } 181 | -------------------------------------------------------------------------------- /src/Exception/Logic/InvalidStateException.php: -------------------------------------------------------------------------------- 1 | , options: array}> */ 13 | private $connections; 14 | 15 | /** 16 | * @param array, options: array}> $connections 17 | */ 18 | public function __construct(array $connections) 19 | { 20 | $this->connections = $connections; 21 | } 22 | 23 | /** 24 | * Renders HTML code for custom tab. 25 | */ 26 | public function getTab(): string 27 | { 28 | return '' 29 | . '' 30 | . ''; 31 | } 32 | 33 | /** 34 | * Renders HTML code for custom panel. 35 | */ 36 | public function getPanel(): string 37 | { 38 | ob_start(); 39 | 40 | $connections = $this->connections; 41 | 42 | foreach ($connections as $key => $connection) { 43 | $start = microtime(true); 44 | try { 45 | $connections[$key]['ping'] = $connection['client']->ping('ok'); 46 | } catch (Throwable $e) { 47 | $connections[$key]['ping'] = 'failed'; 48 | } finally { 49 | $connections[$key]['duration'] = (microtime(true) - $start) * 1000; 50 | } 51 | 52 | try { 53 | $connections[$key]['dbSize'] = $connection['client']->dbsize(); 54 | } catch (Throwable $e) { 55 | $connections[$key]['dbSize'] = $e->getMessage(); 56 | } 57 | } 58 | 59 | require __DIR__ . '/templates/panel.phtml'; 60 | 61 | return (string) ob_get_clean(); 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/Tracy/templates/panel.phtml: -------------------------------------------------------------------------------- 1 |

Redis

2 |
3 |
4 | 5 | 6 | 7 | 8 | 9 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 41 | 42 | 43 | 44 | 45 | 46 |
Connected: 10 | isConnected()) : ?> 11 | Yes 12 | 13 | No 14 | 16 | 17 |
Name:
Ping: ( ms)
DB size: keys
Server: 34 | 38 | 39 | 40 |
Connection options:
47 | $i): ?> 48 |
49 | 50 | 51 | 52 |
53 |
54 | --------------------------------------------------------------------------------