├── composer.json ├── license.md ├── readme.md └── src ├── Bridges ├── CacheDI │ └── CacheExtension.php ├── CacheLatte │ ├── CacheExtension.php │ ├── Nodes │ │ └── CacheNode.php │ └── Runtime.php └── Psr │ └── PsrCacheAdapter.php ├── Caching ├── BulkReader.php ├── BulkWriter.php ├── Cache.php ├── OutputHelper.php ├── Storage.php └── Storages │ ├── DevNullStorage.php │ ├── FileStorage.php │ ├── Journal.php │ ├── MemcachedStorage.php │ ├── MemoryStorage.php │ ├── SQLiteJournal.php │ └── SQLiteStorage.php └── compatibility.php /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nette/caching", 3 | "description": "⏱ Nette Caching: library with easy-to-use API and many cache backends.", 4 | "keywords": ["nette", "cache", "journal", "sqlite", "memcached"], 5 | "homepage": "https://nette.org", 6 | "license": ["BSD-3-Clause", "GPL-2.0-only", "GPL-3.0-only"], 7 | "authors": [ 8 | { 9 | "name": "David Grudl", 10 | "homepage": "https://davidgrudl.com" 11 | }, 12 | { 13 | "name": "Nette Community", 14 | "homepage": "https://nette.org/contributors" 15 | } 16 | ], 17 | "require": { 18 | "php": "8.1 - 8.4", 19 | "nette/utils": "^4.0" 20 | }, 21 | "require-dev": { 22 | "nette/tester": "^2.4", 23 | "nette/di": "^3.1 || ^4.0", 24 | "latte/latte": "^3.0.12", 25 | "tracy/tracy": "^2.9", 26 | "psr/simple-cache": "^2.0 || ^3.0", 27 | "phpstan/phpstan-nette": "^2.0@stable" 28 | }, 29 | "conflict": { 30 | "latte/latte": "<3.0.12" 31 | }, 32 | "suggest": { 33 | "ext-pdo_sqlite": "to use SQLiteStorage or SQLiteJournal" 34 | }, 35 | "autoload": { 36 | "classmap": ["src/"], 37 | "psr-4": { 38 | "Nette\\": "src" 39 | } 40 | }, 41 | "minimum-stability": "dev", 42 | "scripts": { 43 | "phpstan": "phpstan analyse", 44 | "tester": "tester tests -s" 45 | }, 46 | "extra": { 47 | "branch-alias": { 48 | "dev-master": "4.0-dev" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Licenses 2 | ======== 3 | 4 | Good news! You may use Nette Framework under the terms of either 5 | the New BSD License or the GNU General Public License (GPL) version 2 or 3. 6 | 7 | The BSD License is recommended for most projects. It is easy to understand and it 8 | places almost no restrictions on what you can do with the framework. If the GPL 9 | fits better to your project, you can use the framework under this license. 10 | 11 | You don't have to notify anyone which license you are using. You can freely 12 | use Nette Framework in commercial projects as long as the copyright header 13 | remains intact. 14 | 15 | Please be advised that the name "Nette Framework" is a protected trademark and its 16 | usage has some limitations. So please do not use word "Nette" in the name of your 17 | project or top-level domain, and choose a name that stands on its own merits. 18 | If your stuff is good, it will not take long to establish a reputation for yourselves. 19 | 20 | 21 | New BSD License 22 | --------------- 23 | 24 | Copyright (c) 2004, 2014 David Grudl (https://davidgrudl.com) 25 | All rights reserved. 26 | 27 | Redistribution and use in source and binary forms, with or without modification, 28 | are permitted provided that the following conditions are met: 29 | 30 | * Redistributions of source code must retain the above copyright notice, 31 | this list of conditions and the following disclaimer. 32 | 33 | * Redistributions in binary form must reproduce the above copyright notice, 34 | this list of conditions and the following disclaimer in the documentation 35 | and/or other materials provided with the distribution. 36 | 37 | * Neither the name of "Nette Framework" nor the names of its contributors 38 | may be used to endorse or promote products derived from this software 39 | without specific prior written permission. 40 | 41 | This software is provided by the copyright holders and contributors "as is" and 42 | any express or implied warranties, including, but not limited to, the implied 43 | warranties of merchantability and fitness for a particular purpose are 44 | disclaimed. In no event shall the copyright owner or contributors be liable for 45 | any direct, indirect, incidental, special, exemplary, or consequential damages 46 | (including, but not limited to, procurement of substitute goods or services; 47 | loss of use, data, or profits; or business interruption) however caused and on 48 | any theory of liability, whether in contract, strict liability, or tort 49 | (including negligence or otherwise) arising in any way out of the use of this 50 | software, even if advised of the possibility of such damage. 51 | 52 | 53 | GNU General Public License 54 | -------------------------- 55 | 56 | GPL licenses are very very long, so instead of including them here we offer 57 | you URLs with full text: 58 | 59 | - [GPL version 2](http://www.gnu.org/licenses/gpl-2.0.html) 60 | - [GPL version 3](http://www.gnu.org/licenses/gpl-3.0.html) 61 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | Nette Caching 2 | ============= 3 | 4 | [![Downloads this Month](https://img.shields.io/packagist/dm/nette/caching.svg)](https://packagist.org/packages/nette/caching) 5 | [![Tests](https://github.com/nette/caching/workflows/Tests/badge.svg?branch=master)](https://github.com/nette/caching/actions) 6 | [![Coverage Status](https://coveralls.io/repos/github/nette/caching/badge.svg?branch=master)](https://coveralls.io/github/nette/caching?branch=master) 7 | [![Latest Stable Version](https://poser.pugx.org/nette/caching/v/stable)](https://github.com/nette/caching/releases) 8 | [![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://github.com/nette/caching/blob/master/license.md) 9 | 10 | 11 | Introduction 12 | ============ 13 | 14 | Cache accelerates your application by storing data - once hardly retrieved - for future use. 15 | 16 | Documentation can be found on the [website](https://doc.nette.org/caching). 17 | 18 | 19 | [Support Me](https://github.com/sponsors/dg) 20 | -------------------------------------------- 21 | 22 | Do you like Nette Caching? Are you looking forward to the new features? 23 | 24 | [![Buy me a coffee](https://files.nette.org/icons/donation-3.svg)](https://github.com/sponsors/dg) 25 | 26 | Thank you! 27 | 28 | 29 | Installation 30 | ------------ 31 | 32 | ``` 33 | composer require nette/caching 34 | ``` 35 | 36 | It requires PHP version 8.1 and supports PHP up to 8.4. 37 | 38 | 39 | Basic Usage 40 | =========== 41 | 42 | The center of work with the cache is the object [Nette\Caching\Cache](https://api.nette.org/3.0/Nette/Caching/Cache.html). We create its instance and pass the so-called storage to the constructor as a parameter. Which is an object representing the place where the data will be physically stored (database, Memcached, files on disk, ...). You will find out all the essentials in [section Storages](#Storages). 43 | 44 | For the following examples, suppose we have an alias `Cache` and a storage in the variable `$storage`. 45 | 46 | ```php 47 | use Nette\Caching\Cache; 48 | 49 | $storage // instance of Nette\Caching\IStorage 50 | ``` 51 | 52 | The cache is actually a *key–value store*, so we read and write data under keys just like associative arrays. Applications consist of a number of independent parts, and if they all used one storage (for idea: one directory on a disk), sooner or later there would be a key collision. The Nette Framework solves the problem by dividing the entire space into namespaces (subdirectories). Each part of the program then uses its own space with a unique name and no collisions can occur. 53 | 54 | The name of the space is specified as the second parameter of the constructor of the Cache class: 55 | 56 | ```php 57 | $cache = new Cache($storage, 'Full Html Pages'); 58 | ``` 59 | 60 | We can now use object `$cache` to read and write from the cache. The method `load()` is used for both. The first argument is the key and the second is the PHP callback, which is called when the key is not found in the cache. The callback generates a value, returns it and caches it: 61 | 62 | ```php 63 | $value = $cache->load($key, function () use ($key) { 64 | $computedValue = ...; // heavy computations 65 | return $computedValue; 66 | }); 67 | ``` 68 | 69 | If the second parameter is not specified `$value = $cache->load($key)`, the `null` is returned if the item is not in the cache. 70 | 71 | The great thing is that any serializable structures can be cached, not only strings. And the same applies for keys. 72 | 73 | The item is cleared from the cache using method `remove()`: 74 | 75 | ```php 76 | $cache->remove($key); 77 | ``` 78 | 79 | You can also cache an item using method `$cache->save($key, $value, array $dependencies = [])`. However, the above method using `load()` is preferred. 80 | 81 | 82 | 83 | 84 | Memoization 85 | =========== 86 | 87 | Memoization means caching the result of a function or method so you can use it next time instead of calculating the same thing again and again. 88 | 89 | Methods and functions can be called memoized using `call(callable $callback, ...$args)`: 90 | 91 | ```php 92 | $result = $cache->call('gethostbyaddr', $ip); 93 | ``` 94 | 95 | The function `gethostbyaddr()` is called only once for each parameter `$ip` and the next time the value from the cache will be returned. 96 | 97 | It is also possible to create a memoized wrapper for a method or function that can be called later: 98 | 99 | ```php 100 | function factorial($num) 101 | { 102 | return ...; 103 | } 104 | 105 | $memoizedFactorial = $cache->wrap('factorial'); 106 | 107 | $result = $memoizedFactorial(5); // counts it 108 | $result = $memoizedFactorial(5); // returns it from cache 109 | ``` 110 | 111 | 112 | Expiration & Invalidation 113 | ========================= 114 | 115 | With caching, it is necessary to address the question that some of the previously saved data will become invalid over time. Nette Framework provides a mechanism, how to limit the validity of data and how to delete them in a controlled way ("to invalidate them", using the framework's terminology). 116 | 117 | The validity of the data is set at the time of saving using the third parameter of the method `save()`, eg: 118 | 119 | ```php 120 | $cache->save($key, $value, [ 121 | Cache::Expire => '20 minutes', 122 | ]); 123 | ``` 124 | 125 | Or using the `$dependencies` parameter passed by reference to the callback in the `load()` method, eg: 126 | 127 | ```php 128 | $value = $cache->load($key, function (&$dependencies) { 129 | $dependencies[Cache::Expire] = '20 minutes'; 130 | return ...; 131 | ]); 132 | ``` 133 | 134 | Or using 3rd parameter in the `load()` method, eg: 135 | 136 | ```php 137 | $value = $cache->load($key, function () { 138 | return ...; 139 | ], [Cache::Expire => '20 minutes']); 140 | ``` 141 | 142 | In the following examples, we will assume the second variant and thus the existence of a variable `$dependencies`. 143 | 144 | 145 | Expiration 146 | ---------- 147 | 148 | The simplest exiration is the time limit. Here's how to cache data valid for 20 minutes: 149 | 150 | ```php 151 | // it also accepts the number of seconds or the UNIX timestamp 152 | $dependencies[Cache::Expire] = '20 minutes'; 153 | ``` 154 | 155 | If we want to extend the validity period with each reading, it can be achieved this way, but beware, this will increase the cache overhead: 156 | 157 | ```php 158 | $dependencies[Cache::Sliding] = true; 159 | ``` 160 | 161 | The handy option is the ability to let the data expire when a particular file is changed or one of several files. This can be used, for example, for caching data resulting from procession these files. Use absolute paths. 162 | 163 | ```php 164 | $dependencies[Cache::Files] = '/path/to/data.yaml'; 165 | // nebo 166 | $dependencies[Cache::Files] = ['/path/to/data1.yaml', '/path/to/data2.yaml']; 167 | ``` 168 | 169 | We can let an item in the cache expired when another item (or one of several others) expires. This can be used when we cache the entire HTML page and fragments of it under other keys. Once the snippet changes, the entire page becomes invalid. If we have fragments stored under keys such as `frag1` and `frag2`, we will use: 170 | 171 | ```php 172 | $dependencies[Cache::Items] = ['frag1', 'frag2']; 173 | ``` 174 | 175 | Expiration can also be controlled using custom functions or static methods, which always decide when reading whether the item is still valid. For example, we can let the item expire whenever the PHP version changes. We will create a function that compares the current version with the parameter, and when saving we will add an array in the form `[function name, ...arguments]` to the dependencies: 176 | 177 | ```php 178 | function checkPhpVersion($ver): bool 179 | { 180 | return $ver === PHP_VERSION_ID; 181 | } 182 | 183 | $dependencies[Cache::Callbacks] = [ 184 | ['checkPhpVersion', PHP_VERSION_ID] // expire when checkPhpVersion(...) === false 185 | ]; 186 | ``` 187 | 188 | Of course, all criteria can be combined. The cache then expires when at least one criterion is not met. 189 | 190 | ```php 191 | $dependencies[Cache::Expire] = '20 minutes'; 192 | $dependencies[Cache::Files] = '/path/to/data.yaml'; 193 | ``` 194 | 195 | 196 | 197 | Invalidation using Tags 198 | ----------------------- 199 | 200 | Tags are a very useful invalidation tool. We can assign a list of tags, which are arbitrary strings, to each item stored in the cache. For example, suppose we have an HTML page with an article and comments, which we want to cache. So we specify tags when saving to cache: 201 | 202 | ```php 203 | $dependencies[Cache::Tags] = ["article/$articleId", "comments/$articleId"]; 204 | ``` 205 | 206 | Now, let's move to the administration. Here we have a form for article editing. Together with saving the article to a database, we call the `clean()` command, which will delete cached items by tag: 207 | 208 | ```php 209 | $cache->clean([ 210 | Cache::Tags => ["article/$articleId"], 211 | ]); 212 | ``` 213 | 214 | Likewise, in the place of adding a new comment (or editing a comment), we will not forget to invalidate the relevant tag: 215 | 216 | ```php 217 | $cache->clean([ 218 | Cache::Tags => ["comments/$articleId"], 219 | ]); 220 | ``` 221 | 222 | What have we achieved? That our HTML cache will be invalidated (deleted) whenever the article or comments change. When editing an article with ID = 10, the tag `article/10` is forced to be invalidated and the HTML page carrying the tag is deleted from the cache. The same happens when you insert a new comment under the relevant article. 223 | 224 | Tags require [Journal](#Journal). 225 | 226 | 227 | Invalidation by Priority 228 | ------------------------ 229 | 230 | We can set the priority for individual items in the cache, and it will be possible to delete them in a controlled way when, for example, the cache exceeds a certain size: 231 | 232 | ```php 233 | $dependencies[Cache::Priority] = 50; 234 | ``` 235 | 236 | Delete all items with a priority equal to or less than 100: 237 | 238 | ```php 239 | $cache->clean([ 240 | Cache::Priority => 100, 241 | ]); 242 | ``` 243 | 244 | Priorities require so-called [Journal](#Journal). 245 | 246 | 247 | Clear Cache 248 | ----------- 249 | 250 | The `Cache::All` parameter clears everything: 251 | 252 | ```php 253 | $cache->clean([ 254 | Cache::All => true, 255 | ]); 256 | ``` 257 | 258 | 259 | Bulk Reading 260 | ============ 261 | 262 | For bulk reading and writing to cache, the `bulkLoad()` method is used, where we pass an array of keys and obtain an array of values: 263 | 264 | ```php 265 | $values = $cache->bulkLoad($keys); 266 | ``` 267 | 268 | Method `bulkLoad()` works similarly to `load()` with the second callback parameter, to which the key of the generated item is passed: 269 | 270 | ```php 271 | $values = $cache->bulkLoad($keys, function ($key, &$dependencies) { 272 | $computedValue = ...; // heavy computations 273 | return $computedValue; 274 | }); 275 | ``` 276 | 277 | 278 | Output Caching 279 | ============== 280 | 281 | The output can be captured and cached very elegantly: 282 | 283 | ```php 284 | if ($capture = $cache->start($key)) { 285 | 286 | echo ... // printing some data 287 | 288 | $capture->end(); // save the output to the cache 289 | } 290 | ``` 291 | 292 | In case that the output is already present in the cache, the `start()` method prints it and returns `null`, so the condition will not be executed. Otherwise, it starts to buffer the output and returns the `$capture` object using which we finally save the data to the cache. 293 | 294 | 295 | Caching in Latte 296 | ================ 297 | 298 | Caching in templates [Latte](https://latte.nette.org) is very easy, just wrap part of the template with tags `{cache}...{/cache}`. The cache is automatically invalidated when the source template changes (including any included templates within the `{cache}` tags). Tags `{cache}` can be nested, and when a nested block is invalidated (for example, by a tag), the parent block is also invalidated. 299 | 300 | In the tag it is possible to specify the keys to which the cache will be bound (here the variable `$id`) and set the expiration and [invalidation tags](#invalidation-using-tags). 301 | 302 | ```html 303 | {cache $id, expire => '20 minutes', tags => [tag1, tag2]} 304 | ... 305 | {/cache} 306 | ``` 307 | 308 | All parameters are optional, so you don't have to specify expiration, tags, or keys. 309 | 310 | The use of the cache can also be conditioned by `if` - the content will then be cached only if the condition is met: 311 | 312 | ```html 313 | {cache $id, if => !$form->isSubmitted()} 314 | {$form} 315 | {/cache} 316 | ``` 317 | 318 | 319 | Storages 320 | ======== 321 | 322 | A storage is an object that represents where data is physically stored. We can use a database, a Memcached server, or the most available storage, which are files on disk. 323 | 324 | Storage | Description 325 | --------|---------------------- 326 | FileStorage | default storage with saving to files on disk 327 | MemcachedStorage | uses the `Memcached` server 328 | MemoryStorage | data are temporarily in memory 329 | SQLiteStorage | data is stored in SQLite database 330 | DevNullStorage | data aren't stored - for testing purposes 331 | 332 | 333 | FileStorage 334 | ----------- 335 | 336 | Writes the cache to files on disk. The storage `Nette\Caching\Storages\FileStorage` is very well optimized for performance and above all ensures full atomicity of operations. What does it mean? That when using the cache, it cannot happen that we read a file that has not yet been completely written by another thread, or that someone would delete it "under your hands". The use of the cache is therefore completely safe. 337 | 338 | This storage also has an important built-in feature that prevents an extreme increase in CPU usage when the cache is cleared or cold (ie not created). This is [cache stampede](https://en.wikipedia.org/wiki/Cache_stampede) prevention. 339 | It happens that at one moment there are several concurrent requests that want the same thing from the cache (eg the result of an expensive SQL query) and because it is not cached, all processes start executing the same SQL query. 340 | The processor load is multiplied and it can even happen that no thread can respond within the time limit, the cache is not created and the application crashes. 341 | Fortunately, the cache in Nette works in such a way that when there are multiple concurrent requests for one item, it is generated only by the first thread, the others wait and then use the generated result. 342 | 343 | Example of creating a FileStorage: 344 | 345 | ```php 346 | // the storage will be the directory '/path/to/temp' on the disk 347 | $storage = new Nette\Caching\Storages\FileStorage('/path/to/temp'); 348 | ``` 349 | 350 | MemcachedStorage 351 | ---------------- 352 | 353 | The server [Memcached](https://memcached.org) is a high-performance distributed storage system whose adapter is `Nette\Caching\Storages\MemcachedStorage`. 354 | 355 | Requires PHP extension `memcached`. 356 | 357 | ```php 358 | $storage = new Nette\Caching\Storages\MemcachedStorage('10.0.0.158'); 359 | ``` 360 | 361 | MemoryStorage 362 | ------------- 363 | 364 | `Nette\Caching\Storages\MemoryStorage` is a storage that stores data in a PHP array and is thus lost when the request is terminated. 365 | 366 | ```php 367 | $storage = new Nette\Caching\Storages\MemoryStorage; 368 | ``` 369 | 370 | 371 | SQLiteStorage 372 | ------------- 373 | 374 | The SQLite database and adapter `Nette\Caching\Storages\SQLiteStorage` offer a way to cache in a single file on disk. The configuration will specify the path to this file. 375 | 376 | Requires PHP extensions `pdo` and `pdo_sqlite`. 377 | 378 | ```php 379 | $storage = new Nette\Caching\Storages\SQLiteStorage('/path/to/cache.sdb'); 380 | ``` 381 | 382 | 383 | DevNullStorage 384 | -------------- 385 | 386 | A special implementation of storage is `Nette\Caching\Storages\DevNullStorage`, which does not actually store data at all. It is therefore suitable for testing if we want to eliminate the effect of the cache. 387 | 388 | ```php 389 | $storage = new Nette\Caching\Storages\DevNullStorage; 390 | ``` 391 | 392 | 393 | Journal 394 | ======= 395 | 396 | Nette stores tags and priorities in a so-called journal. By default, SQLite and file `journal.s3db` are used for this, and **PHP extensions `pdo` and `pdo_sqlite` are required.** 397 | 398 | If you like Nette, **[please make a donation now](https://github.com/sponsors/dg)**. Thank you! 399 | -------------------------------------------------------------------------------- /src/Bridges/CacheDI/CacheExtension.php: -------------------------------------------------------------------------------- 1 | tempDir)) { 30 | throw new Nette\InvalidArgumentException("Cache directory must be absolute, '$this->tempDir' given."); 31 | } 32 | FileSystem::createDir($this->tempDir); 33 | if (!is_writable($this->tempDir)) { 34 | throw new Nette\InvalidStateException("Make directory '$this->tempDir' writable."); 35 | } 36 | 37 | $builder = $this->getContainerBuilder(); 38 | 39 | if (extension_loaded('pdo_sqlite')) { 40 | $builder->addDefinition($this->prefix('journal')) 41 | ->setType(Nette\Caching\Storages\Journal::class) 42 | ->setFactory(Nette\Caching\Storages\SQLiteJournal::class, [$this->tempDir . '/journal.s3db']); 43 | } 44 | 45 | $builder->addDefinition($this->prefix('storage')) 46 | ->setType(Nette\Caching\Storage::class) 47 | ->setFactory(Nette\Caching\Storages\FileStorage::class, [$this->tempDir]); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Bridges/CacheLatte/CacheExtension.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 31 | } 32 | 33 | 34 | public function beforeCompile(Latte\Engine $engine): void 35 | { 36 | $this->used = false; 37 | } 38 | 39 | 40 | public function getTags(): array 41 | { 42 | return [ 43 | 'cache' => function (Tag $tag): \Generator { 44 | $this->used = true; 45 | return yield from Nodes\CacheNode::create($tag); 46 | }, 47 | ]; 48 | } 49 | 50 | 51 | public function getPasses(): array 52 | { 53 | return [ 54 | 'cacheInitialization' => function (TemplateNode $node): void { 55 | if ($this->used) { 56 | $node->head->append(new AuxiliaryNode(fn() => '$this->global->cache->initialize($this);')); 57 | } 58 | }, 59 | ]; 60 | } 61 | 62 | 63 | public function getProviders(): array 64 | { 65 | return [ 66 | 'cache' => new Runtime($this->storage), 67 | ]; 68 | } 69 | 70 | 71 | public function getCacheKey(Latte\Engine $engine): array 72 | { 73 | return ['version' => 2]; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Bridges/CacheLatte/Nodes/CacheNode.php: -------------------------------------------------------------------------------- 1 | */ 31 | public static function create(Tag $tag): \Generator 32 | { 33 | $node = $tag->node = new static; 34 | $node->args = $tag->parser->parseArguments(); 35 | [$node->content, $endTag] = yield; 36 | $node->endLine = $endTag?->position; 37 | return $node; 38 | } 39 | 40 | 41 | public function print(PrintContext $context): string 42 | { 43 | return $context->format( 44 | <<<'XX' 45 | if ($this->global->cache->createCache(%dump, %node?)) %line 46 | try { 47 | %node 48 | $this->global->cache->end() %line; 49 | } catch (\Throwable $ʟ_e) { 50 | $this->global->cache->rollback(); 51 | throw $ʟ_e; 52 | } 53 | 54 | 55 | XX, 56 | base64_encode(random_bytes(10)), 57 | $this->args, 58 | $this->position, 59 | $this->content, 60 | $this->endLine, 61 | ); 62 | } 63 | 64 | 65 | public function &getIterator(): \Generator 66 | { 67 | yield $this->args; 68 | yield $this->content; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Bridges/CacheLatte/Runtime.php: -------------------------------------------------------------------------------- 1 | */ 25 | private array $stack = []; 26 | 27 | 28 | public function __construct( 29 | private Nette\Caching\Storage $storage, 30 | ) { 31 | } 32 | 33 | 34 | public function initialize(Latte\Runtime\Template $template): void 35 | { 36 | if ($this->stack) { 37 | $file = (new \ReflectionClass($template))->getFileName(); 38 | if (@is_file($file)) { // @ - may trigger error 39 | end($this->stack)->dependencies[Cache::Files][] = $file; 40 | } 41 | } 42 | } 43 | 44 | 45 | /** 46 | * Starts the output cache. Returns true if buffering was started. 47 | */ 48 | public function createCache(string $key, ?array $args = null): bool 49 | { 50 | if ($args) { 51 | if (array_key_exists('if', $args) && !$args['if']) { 52 | $this->stack[] = new \stdClass; 53 | return true; 54 | } 55 | 56 | $key = array_merge([$key], array_intersect_key($args, range(0, count($args)))); 57 | } 58 | 59 | if ($this->stack) { 60 | end($this->stack)->dependencies[Cache::Items][] = $key; 61 | } 62 | 63 | $cache = new Cache($this->storage, 'Nette.Templating.Cache'); 64 | if ($helper = $cache->capture($key)) { 65 | $this->stack[] = $helper; 66 | 67 | if (isset($args['dependencies'])) { 68 | $args += $args['dependencies'](); 69 | } 70 | 71 | $helper->dependencies[Cache::Tags] = $args['tags'] ?? null; 72 | $helper->dependencies[Cache::Expire] = $args['expiration'] ?? $args['expire'] ?? '+ 7 days'; 73 | } 74 | 75 | return (bool) $helper; 76 | } 77 | 78 | 79 | /** 80 | * Ends the output cache. 81 | */ 82 | public function end(): void 83 | { 84 | $helper = array_pop($this->stack); 85 | if ($helper instanceof OutputHelper) { 86 | $helper->end(); 87 | } 88 | } 89 | 90 | 91 | public function rollback(): void 92 | { 93 | $helper = array_pop($this->stack); 94 | if ($helper instanceof OutputHelper) { 95 | $helper->rollback(); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Bridges/Psr/PsrCacheAdapter.php: -------------------------------------------------------------------------------- 1 | storage->read($key) ?? $default; 28 | } 29 | 30 | 31 | public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool 32 | { 33 | $dependencies = []; 34 | if ($ttl !== null) { 35 | $dependencies[Nette\Caching\Cache::Expire] = self::ttlToSeconds($ttl); 36 | } 37 | 38 | $this->storage->write($key, $value, $dependencies); 39 | 40 | return true; 41 | } 42 | 43 | 44 | public function delete(string $key): bool 45 | { 46 | $this->storage->remove($key); 47 | return true; 48 | } 49 | 50 | 51 | public function clear(): bool 52 | { 53 | $this->storage->clean([Nette\Caching\Cache::All => true]); 54 | return true; 55 | } 56 | 57 | 58 | /** 59 | * @return \Generator 60 | */ 61 | public function getMultiple(iterable $keys, mixed $default = null): \Generator 62 | { 63 | foreach ($keys as $name) { 64 | yield $name => $this->get($name, $default); 65 | } 66 | } 67 | 68 | 69 | /** 70 | * @param iterable $values 71 | */ 72 | public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool 73 | { 74 | $ttl = self::ttlToSeconds($ttl); 75 | 76 | foreach ($values as $key => $value) { 77 | $this->set((string) $key, $value, $ttl); 78 | } 79 | 80 | return true; 81 | } 82 | 83 | 84 | public function deleteMultiple(iterable $keys): bool 85 | { 86 | foreach ($keys as $value) { 87 | $this->delete($value); 88 | } 89 | 90 | return true; 91 | } 92 | 93 | 94 | public function has(string $key): bool 95 | { 96 | return $this->storage->read($key) !== null; 97 | } 98 | 99 | 100 | private static function ttlToSeconds(null|int|DateInterval $ttl = null): ?int 101 | { 102 | if ($ttl instanceof DateInterval) { 103 | return self::dateIntervalToSeconds($ttl); 104 | } 105 | 106 | return $ttl; 107 | } 108 | 109 | 110 | private static function dateIntervalToSeconds(DateInterval $dateInterval): int 111 | { 112 | $now = new \DateTimeImmutable; 113 | $expiresAt = $now->add($dateInterval); 114 | return $expiresAt->getTimestamp() - $now->getTimestamp(); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Caching/BulkReader.php: -------------------------------------------------------------------------------- 1 | value pairs, missing items are omitted 21 | */ 22 | function bulkRead(array $keys): array; 23 | } 24 | 25 | 26 | class_exists(IBulkReader::class); 27 | -------------------------------------------------------------------------------- /src/Caching/BulkWriter.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 76 | $this->namespace = $namespace . self::NamespaceSeparator; 77 | } 78 | 79 | 80 | /** 81 | * Returns cache storage. 82 | */ 83 | final public function getStorage(): Storage 84 | { 85 | return $this->storage; 86 | } 87 | 88 | 89 | /** 90 | * Returns cache namespace. 91 | */ 92 | final public function getNamespace(): string 93 | { 94 | return substr($this->namespace, 0, -1); 95 | } 96 | 97 | 98 | /** 99 | * Returns new nested cache object. 100 | */ 101 | public function derive(string $namespace): static 102 | { 103 | return new static($this->storage, $this->namespace . $namespace); 104 | } 105 | 106 | 107 | /** 108 | * Reads the specified item from the cache or generate it. 109 | */ 110 | public function load(mixed $key, ?callable $generator = null, ?array $dependencies = null): mixed 111 | { 112 | $storageKey = $this->generateKey($key); 113 | $data = $this->storage->read($storageKey); 114 | if ($data === null && $generator) { 115 | $this->storage->lock($storageKey); 116 | try { 117 | $data = $generator(...[&$dependencies]); 118 | } catch (\Throwable $e) { 119 | $this->storage->remove($storageKey); 120 | throw $e; 121 | } 122 | 123 | $this->save($key, $data, $dependencies); 124 | } 125 | 126 | return $data; 127 | } 128 | 129 | 130 | /** 131 | * Reads multiple items from the cache. 132 | */ 133 | public function bulkLoad(array $keys, ?callable $generator = null): array 134 | { 135 | if (count($keys) === 0) { 136 | return []; 137 | } 138 | 139 | foreach ($keys as $key) { 140 | if (!is_scalar($key)) { 141 | throw new Nette\InvalidArgumentException('Only scalar keys are allowed in bulkLoad()'); 142 | } 143 | } 144 | 145 | $result = []; 146 | if (!$this->storage instanceof BulkReader) { 147 | foreach ($keys as $key) { 148 | $result[$key] = $this->load( 149 | $key, 150 | $generator 151 | ? fn(&$dependencies) => $generator(...[$key, &$dependencies]) 152 | : null, 153 | ); 154 | } 155 | 156 | return $result; 157 | } 158 | 159 | $storageKeys = array_map([$this, 'generateKey'], $keys); 160 | $cacheData = $this->storage->bulkRead($storageKeys); 161 | foreach ($keys as $i => $key) { 162 | $storageKey = $storageKeys[$i]; 163 | if (isset($cacheData[$storageKey])) { 164 | $result[$key] = $cacheData[$storageKey]; 165 | } elseif ($generator) { 166 | $result[$key] = $this->load($key, fn(&$dependencies) => $generator(...[$key, &$dependencies])); 167 | } else { 168 | $result[$key] = null; 169 | } 170 | } 171 | 172 | return $result; 173 | } 174 | 175 | 176 | /** 177 | * Writes item into the cache. 178 | * Dependencies are: 179 | * - Cache::Priority => (int) priority 180 | * - Cache::Expire => (timestamp) expiration, infinite if null 181 | * - Cache::Sliding => (bool) use sliding expiration? 182 | * - Cache::Tags => (array) tags 183 | * - Cache::Files => (array|string) file names 184 | * - Cache::Items => (array|string) cache items 185 | * - Cache::Constants => (array|string) cache items 186 | * @return mixed value itself 187 | * @throws Nette\InvalidArgumentException 188 | */ 189 | public function save(mixed $key, mixed $data, ?array $dependencies = null): mixed 190 | { 191 | $key = $this->generateKey($key); 192 | 193 | if ($data instanceof \Closure) { 194 | $this->storage->lock($key); 195 | try { 196 | $data = $data(...[&$dependencies]); 197 | } catch (\Throwable $e) { 198 | $this->storage->remove($key); 199 | throw $e; 200 | } 201 | } 202 | 203 | if ($data === null) { 204 | $this->storage->remove($key); 205 | return null; 206 | } else { 207 | $dependencies = $this->completeDependencies($dependencies); 208 | if (isset($dependencies[self::Expire]) && $dependencies[self::Expire] <= 0) { 209 | $this->storage->remove($key); 210 | } else { 211 | $this->storage->write($key, $data, $dependencies); 212 | } 213 | 214 | return $data; 215 | } 216 | } 217 | 218 | 219 | /** 220 | * Writes multiple items into cache 221 | */ 222 | public function bulkSave(array $items, ?array $dependencies = null): void 223 | { 224 | $write = $remove = []; 225 | 226 | if (!$this->storage instanceof BulkWriter) { 227 | foreach ($items as $key => $data) { 228 | $this->save($key, $data, $dependencies); 229 | } 230 | return; 231 | } 232 | 233 | $dependencies = $this->completeDependencies($dependencies); 234 | if (isset($dependencies[self::Expire]) && $dependencies[self::Expire] <= 0) { 235 | $this->storage->bulkRemove(array_map(fn($key) => $this->generateKey($key), array_keys($items))); 236 | return; 237 | } 238 | 239 | foreach ($items as $key => $data) { 240 | $key = $this->generateKey($key); 241 | if ($data === null) { 242 | $remove[] = $key; 243 | } else { 244 | $write[$key] = $data; 245 | } 246 | } 247 | 248 | if ($remove) { 249 | $this->storage->bulkRemove($remove); 250 | } 251 | 252 | if ($write) { 253 | $this->storage->bulkWrite($write, $dependencies); 254 | } 255 | } 256 | 257 | 258 | private function completeDependencies(?array $dp): array 259 | { 260 | // convert expire into relative amount of seconds 261 | if (isset($dp[self::Expire])) { 262 | $dp[self::Expire] = Nette\Utils\DateTime::from($dp[self::Expire])->format('U') - time(); 263 | } 264 | 265 | // make list from TAGS 266 | if (isset($dp[self::Tags])) { 267 | $dp[self::Tags] = array_values((array) $dp[self::Tags]); 268 | } 269 | 270 | // make list from NAMESPACES 271 | if (isset($dp[self::Namespaces])) { 272 | $dp[self::Namespaces] = array_values((array) $dp[self::Namespaces]); 273 | } 274 | 275 | // convert FILES into CALLBACKS 276 | if (isset($dp[self::Files])) { 277 | foreach (array_unique((array) $dp[self::Files]) as $item) { 278 | $dp[self::Callbacks][] = [[self::class, 'checkFile'], $item, @filemtime($item) ?: null]; // @ - stat may fail 279 | } 280 | 281 | unset($dp[self::Files]); 282 | } 283 | 284 | // add namespaces to items 285 | if (isset($dp[self::Items])) { 286 | $dp[self::Items] = array_unique(array_map([$this, 'generateKey'], (array) $dp[self::Items])); 287 | } 288 | 289 | // convert CONSTS into CALLBACKS 290 | if (isset($dp[self::Constants])) { 291 | foreach (array_unique((array) $dp[self::Constants]) as $item) { 292 | $dp[self::Callbacks][] = [[self::class, 'checkConst'], $item, constant($item)]; 293 | } 294 | 295 | unset($dp[self::Constants]); 296 | } 297 | 298 | if (!is_array($dp)) { 299 | $dp = []; 300 | } 301 | 302 | return $dp; 303 | } 304 | 305 | 306 | /** 307 | * Removes item from the cache. 308 | */ 309 | public function remove(mixed $key): void 310 | { 311 | $this->save($key, null); 312 | } 313 | 314 | 315 | /** 316 | * Removes items from the cache by conditions. 317 | * Conditions are: 318 | * - Cache::Priority => (int) priority 319 | * - Cache::Tags => (array) tags 320 | * - Cache::All => true 321 | */ 322 | public function clean(?array $conditions = null): void 323 | { 324 | $conditions = (array) $conditions; 325 | if (isset($conditions[self::Tags])) { 326 | $conditions[self::Tags] = array_values((array) $conditions[self::Tags]); 327 | } 328 | 329 | $this->storage->clean($conditions); 330 | } 331 | 332 | 333 | /** 334 | * Caches results of function/method calls. 335 | */ 336 | public function call(callable $function): mixed 337 | { 338 | $key = func_get_args(); 339 | if (is_array($function) && is_object($function[0])) { 340 | $key[0][0] = get_class($function[0]); 341 | } 342 | 343 | return $this->load($key, fn() => $function(...array_slice($key, 1))); 344 | } 345 | 346 | 347 | /** 348 | * Caches results of function/method calls. 349 | */ 350 | public function wrap(callable $function, ?array $dependencies = null): \Closure 351 | { 352 | return function () use ($function, $dependencies) { 353 | $key = [$function, $args = func_get_args()]; 354 | if (is_array($function) && is_object($function[0])) { 355 | $key[0][0] = get_class($function[0]); 356 | } 357 | 358 | return $this->load($key, function (&$deps) use ($function, $args, $dependencies) { 359 | $deps = $dependencies; 360 | return $function(...$args); 361 | }); 362 | }; 363 | } 364 | 365 | 366 | /** 367 | * Starts the output cache. 368 | */ 369 | public function capture(mixed $key): ?OutputHelper 370 | { 371 | $data = $this->load($key); 372 | if ($data === null) { 373 | return new OutputHelper($this, $key); 374 | } 375 | 376 | echo $data; 377 | return null; 378 | } 379 | 380 | 381 | /** 382 | * @deprecated use capture() 383 | */ 384 | public function start($key): ?OutputHelper 385 | { 386 | trigger_error(__METHOD__ . '() was renamed to capture()', E_USER_DEPRECATED); 387 | return $this->capture($key); 388 | } 389 | 390 | 391 | /** 392 | * Generates internal cache key. 393 | */ 394 | protected function generateKey($key): string 395 | { 396 | return $this->namespace . hash('xxh128', is_scalar($key) ? (string) $key : serialize($key)); 397 | } 398 | 399 | 400 | /********************* dependency checkers ****************d*g**/ 401 | 402 | 403 | /** 404 | * Checks CALLBACKS dependencies. 405 | */ 406 | public static function checkCallbacks(array $callbacks): bool 407 | { 408 | foreach ($callbacks as $callback) { 409 | if (!array_shift($callback)(...$callback)) { 410 | return false; 411 | } 412 | } 413 | 414 | return true; 415 | } 416 | 417 | 418 | /** 419 | * Checks CONSTS dependency. 420 | */ 421 | private static function checkConst(string $const, $value): bool 422 | { 423 | return defined($const) && constant($const) === $value; 424 | } 425 | 426 | 427 | /** 428 | * Checks FILES dependency. 429 | */ 430 | private static function checkFile(string $file, ?int $time): bool 431 | { 432 | return @filemtime($file) == $time; // @ - stat may fail 433 | } 434 | } 435 | -------------------------------------------------------------------------------- /src/Caching/OutputHelper.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 28 | $this->key = $key; 29 | ob_start(); 30 | } 31 | 32 | 33 | /** 34 | * Stops and saves the cache. 35 | */ 36 | public function end(array $dependencies = []): void 37 | { 38 | if ($this->cache === null) { 39 | throw new Nette\InvalidStateException('Output cache has already been saved.'); 40 | } 41 | 42 | $this->cache->save($this->key, ob_get_flush(), $dependencies + $this->dependencies); 43 | $this->cache = null; 44 | } 45 | 46 | 47 | /** 48 | * Stops and throws away the output. 49 | */ 50 | public function rollback(): void 51 | { 52 | ob_end_flush(); 53 | $this->cache = null; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Caching/Storage.php: -------------------------------------------------------------------------------- 1 | timestamp) 41 | MetaCallbacks = 'callbacks'; // array of callbacks (function, args) 42 | 43 | /** additional cache structure */ 44 | private const 45 | File = 'file', 46 | Handle = 'handle'; 47 | 48 | /** probability that the clean() routine is started */ 49 | public static float $gcProbability = 0.001; 50 | 51 | private string $dir; 52 | private ?Journal $journal; 53 | private array $locks; 54 | 55 | 56 | public function __construct(string $dir, ?Journal $journal = null) 57 | { 58 | if (!is_dir($dir) || !Nette\Utils\FileSystem::isAbsolute($dir)) { 59 | throw new Nette\DirectoryNotFoundException("Directory '$dir' not found or is not absolute."); 60 | } 61 | 62 | $this->dir = $dir; 63 | $this->journal = $journal; 64 | 65 | if (mt_rand() / mt_getrandmax() < static::$gcProbability) { 66 | $this->clean([]); 67 | } 68 | } 69 | 70 | 71 | public function read(string $key): mixed 72 | { 73 | $meta = $this->readMetaAndLock($this->getCacheFile($key), LOCK_SH); 74 | return $meta && $this->verify($meta) 75 | ? $this->readData($meta) // calls fclose() 76 | : null; 77 | } 78 | 79 | 80 | /** 81 | * Verifies dependencies. 82 | */ 83 | private function verify(array $meta): bool 84 | { 85 | do { 86 | if (!empty($meta[self::MetaDelta])) { 87 | // meta[file] was added by readMetaAndLock() 88 | if (filemtime($meta[self::File]) + $meta[self::MetaDelta] < time()) { 89 | break; 90 | } 91 | 92 | touch($meta[self::File]); 93 | 94 | } elseif (!empty($meta[self::MetaExpire]) && $meta[self::MetaExpire] < time()) { 95 | break; 96 | } 97 | 98 | if (!empty($meta[self::MetaCallbacks]) && !Cache::checkCallbacks($meta[self::MetaCallbacks])) { 99 | break; 100 | } 101 | 102 | if (!empty($meta[self::MetaItems])) { 103 | foreach ($meta[self::MetaItems] as $depFile => $time) { 104 | $m = $this->readMetaAndLock($depFile, LOCK_SH); 105 | if (($m[self::MetaTime] ?? null) !== $time || ($m && !$this->verify($m))) { 106 | break 2; 107 | } 108 | } 109 | } 110 | 111 | return true; 112 | } while (false); 113 | 114 | $this->delete($meta[self::File], $meta[self::Handle]); // meta[handle] & meta[file] was added by readMetaAndLock() 115 | return false; 116 | } 117 | 118 | 119 | public function lock(string $key): void 120 | { 121 | $cacheFile = $this->getCacheFile($key); 122 | if (!is_dir($dir = dirname($cacheFile))) { 123 | @mkdir($dir); // @ - directory may already exist 124 | } 125 | 126 | $handle = fopen($cacheFile, 'c+b'); 127 | if (!$handle) { 128 | return; 129 | } 130 | 131 | $this->locks[$key] = $handle; 132 | flock($handle, LOCK_EX); 133 | } 134 | 135 | 136 | public function write(string $key, $data, array $dp): void 137 | { 138 | $meta = [ 139 | self::MetaTime => microtime(), 140 | ]; 141 | 142 | if (isset($dp[Cache::Expire])) { 143 | if (empty($dp[Cache::Sliding])) { 144 | $meta[self::MetaExpire] = $dp[Cache::Expire] + time(); // absolute time 145 | } else { 146 | $meta[self::MetaDelta] = (int) $dp[Cache::Expire]; // sliding time 147 | } 148 | } 149 | 150 | if (isset($dp[Cache::Items])) { 151 | foreach ($dp[Cache::Items] as $item) { 152 | $depFile = $this->getCacheFile($item); 153 | $m = $this->readMetaAndLock($depFile, LOCK_SH); 154 | $meta[self::MetaItems][$depFile] = $m[self::MetaTime] ?? null; 155 | unset($m); 156 | } 157 | } 158 | 159 | if (isset($dp[Cache::Callbacks])) { 160 | $meta[self::MetaCallbacks] = $dp[Cache::Callbacks]; 161 | } 162 | 163 | if (!isset($this->locks[$key])) { 164 | $this->lock($key); 165 | if (!isset($this->locks[$key])) { 166 | return; 167 | } 168 | } 169 | 170 | $handle = $this->locks[$key]; 171 | unset($this->locks[$key]); 172 | 173 | $cacheFile = $this->getCacheFile($key); 174 | 175 | if (isset($dp[Cache::Tags]) || isset($dp[Cache::Priority])) { 176 | if (!$this->journal) { 177 | throw new Nette\InvalidStateException('CacheJournal has not been provided.'); 178 | } 179 | 180 | $this->journal->write($cacheFile, $dp); 181 | } 182 | 183 | ftruncate($handle, 0); 184 | 185 | if (!is_string($data)) { 186 | $data = serialize($data); 187 | $meta[self::MetaSerialized] = true; 188 | } 189 | 190 | $head = serialize($meta); 191 | $head = str_pad((string) strlen($head), 6, '0', STR_PAD_LEFT) . $head; 192 | $headLen = strlen($head); 193 | 194 | do { 195 | if (fwrite($handle, str_repeat("\x00", $headLen)) !== $headLen) { 196 | break; 197 | } 198 | 199 | if (fwrite($handle, $data) !== strlen($data)) { 200 | break; 201 | } 202 | 203 | fseek($handle, 0); 204 | if (fwrite($handle, $head) !== $headLen) { 205 | break; 206 | } 207 | 208 | flock($handle, LOCK_UN); 209 | fclose($handle); 210 | return; 211 | } while (false); 212 | 213 | $this->delete($cacheFile, $handle); 214 | } 215 | 216 | 217 | public function remove(string $key): void 218 | { 219 | unset($this->locks[$key]); 220 | $this->delete($this->getCacheFile($key)); 221 | } 222 | 223 | 224 | public function clean(array $conditions): void 225 | { 226 | $all = !empty($conditions[Cache::All]); 227 | $collector = empty($conditions); 228 | $namespaces = $conditions[Cache::Namespaces] ?? null; 229 | 230 | // cleaning using file iterator 231 | if ($all || $collector) { 232 | $now = time(); 233 | foreach (Nette\Utils\Finder::find('_*')->from($this->dir)->childFirst() as $entry) { 234 | $path = (string) $entry; 235 | if ($entry->isDir()) { // collector: remove empty dirs 236 | @rmdir($path); // @ - removing dirs is not necessary 237 | continue; 238 | } 239 | 240 | if ($all) { 241 | $this->delete($path); 242 | 243 | } else { // collector 244 | $meta = $this->readMetaAndLock($path, LOCK_SH); 245 | if (!$meta) { 246 | continue; 247 | } 248 | 249 | if ((!empty($meta[self::MetaDelta]) && filemtime($meta[self::File]) + $meta[self::MetaDelta] < $now) 250 | || (!empty($meta[self::MetaExpire]) && $meta[self::MetaExpire] < $now) 251 | ) { 252 | $this->delete($path, $meta[self::Handle]); 253 | continue; 254 | } 255 | 256 | flock($meta[self::Handle], LOCK_UN); 257 | fclose($meta[self::Handle]); 258 | } 259 | } 260 | 261 | if ($this->journal) { 262 | $this->journal->clean($conditions); 263 | } 264 | 265 | return; 266 | 267 | } elseif ($namespaces) { 268 | foreach ($namespaces as $namespace) { 269 | $dir = $this->dir . '/_' . urlencode($namespace); 270 | if (!is_dir($dir)) { 271 | continue; 272 | } 273 | 274 | foreach (Nette\Utils\Finder::findFiles('_*')->in($dir) as $entry) { 275 | $this->delete((string) $entry); 276 | } 277 | 278 | @rmdir($dir); // may already contain new files 279 | } 280 | } 281 | 282 | // cleaning using journal 283 | if ($this->journal) { 284 | foreach ($this->journal->clean($conditions) as $file) { 285 | $this->delete($file); 286 | } 287 | } 288 | } 289 | 290 | 291 | /** 292 | * Reads cache data from disk. 293 | */ 294 | protected function readMetaAndLock(string $file, int $lock): ?array 295 | { 296 | $handle = @fopen($file, 'r+b'); // @ - file may not exist 297 | if (!$handle) { 298 | return null; 299 | } 300 | 301 | flock($handle, $lock); 302 | 303 | $size = (int) stream_get_contents($handle, self::MetaHeaderLen); 304 | if ($size) { 305 | $meta = stream_get_contents($handle, $size, self::MetaHeaderLen); 306 | $meta = unserialize($meta); 307 | $meta[self::File] = $file; 308 | $meta[self::Handle] = $handle; 309 | return $meta; 310 | } 311 | 312 | flock($handle, LOCK_UN); 313 | fclose($handle); 314 | return null; 315 | } 316 | 317 | 318 | /** 319 | * Reads cache data from disk and closes cache file handle. 320 | */ 321 | protected function readData(array $meta): mixed 322 | { 323 | $data = stream_get_contents($meta[self::Handle]); 324 | flock($meta[self::Handle], LOCK_UN); 325 | fclose($meta[self::Handle]); 326 | 327 | return empty($meta[self::MetaSerialized]) ? $data : unserialize($data); 328 | } 329 | 330 | 331 | /** 332 | * Returns file name. 333 | */ 334 | protected function getCacheFile(string $key): string 335 | { 336 | $file = urlencode($key); 337 | if ($a = strrpos($file, '%00')) { // %00 = urlencode(Nette\Caching\Cache::NamespaceSeparator) 338 | $file = substr_replace($file, '/_', $a, 3); 339 | } 340 | 341 | return $this->dir . '/_' . $file; 342 | } 343 | 344 | 345 | /** 346 | * Deletes and closes file. 347 | * @param resource $handle 348 | */ 349 | private static function delete(string $file, $handle = null): void 350 | { 351 | if (@unlink($file)) { // @ - file may not already exist 352 | if ($handle) { 353 | flock($handle, LOCK_UN); 354 | fclose($handle); 355 | } 356 | 357 | return; 358 | } 359 | 360 | if (!$handle) { 361 | $handle = @fopen($file, 'r+'); // @ - file may not exist 362 | } 363 | 364 | if (!$handle) { 365 | return; 366 | } 367 | 368 | flock($handle, LOCK_EX); 369 | ftruncate($handle, 0); 370 | flock($handle, LOCK_UN); 371 | fclose($handle); 372 | @unlink($file); // @ - file may not already exist 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /src/Caching/Storages/Journal.php: -------------------------------------------------------------------------------- 1 | prefix = $prefix; 52 | $this->journal = $journal; 53 | $this->memcached = new \Memcached; 54 | if ($host) { 55 | $this->addServer($host, $port); 56 | } 57 | } 58 | 59 | 60 | public function addServer(string $host = 'localhost', int $port = 11211): void 61 | { 62 | if (@$this->memcached->addServer($host, $port, 1) === false) { // @ is escalated to exception 63 | $error = error_get_last(); 64 | throw new Nette\InvalidStateException("Memcached::addServer(): $error[message]."); 65 | } 66 | } 67 | 68 | 69 | public function getConnection(): \Memcached 70 | { 71 | return $this->memcached; 72 | } 73 | 74 | 75 | public function read(string $key): mixed 76 | { 77 | $key = urlencode($this->prefix . $key); 78 | $meta = $this->memcached->get($key); 79 | if (!$meta) { 80 | return null; 81 | } 82 | 83 | // meta structure: 84 | // array( 85 | // data => stored data 86 | // delta => relative (sliding) expiration 87 | // callbacks => array of callbacks (function, args) 88 | // ) 89 | 90 | // verify dependencies 91 | if (!empty($meta[self::MetaCallbacks]) && !Cache::checkCallbacks($meta[self::MetaCallbacks])) { 92 | $this->memcached->delete($key, 0); 93 | return null; 94 | } 95 | 96 | if (!empty($meta[self::MetaDelta])) { 97 | $this->memcached->replace($key, $meta, $meta[self::MetaDelta] + time()); 98 | } 99 | 100 | return $meta[self::MetaData]; 101 | } 102 | 103 | 104 | public function bulkRead(array $keys): array 105 | { 106 | $prefixedKeys = array_map(fn($key) => urlencode($this->prefix . $key), $keys); 107 | $keys = array_combine($prefixedKeys, $keys); 108 | $metas = $this->memcached->getMulti($prefixedKeys) ?: []; 109 | $result = []; 110 | $deleteKeys = []; 111 | foreach ($metas as $prefixedKey => $meta) { 112 | if (!empty($meta[self::MetaCallbacks]) && !Cache::checkCallbacks($meta[self::MetaCallbacks])) { 113 | $deleteKeys[] = $prefixedKey; 114 | } else { 115 | $result[$keys[$prefixedKey]] = $meta[self::MetaData]; 116 | } 117 | 118 | if (!empty($meta[self::MetaDelta])) { 119 | $this->memcached->replace($prefixedKey, $meta, $meta[self::MetaDelta] + time()); 120 | } 121 | } 122 | 123 | if (!empty($deleteKeys)) { 124 | $this->memcached->deleteMulti($deleteKeys, 0); 125 | } 126 | 127 | return $result; 128 | } 129 | 130 | 131 | public function lock(string $key): void 132 | { 133 | } 134 | 135 | 136 | public function write(string $key, $data, array $dp): void 137 | { 138 | if (isset($dp[Cache::Items])) { 139 | throw new Nette\NotSupportedException('Dependent items are not supported by MemcachedStorage.'); 140 | } 141 | 142 | $key = urlencode($this->prefix . $key); 143 | $meta = [ 144 | self::MetaData => $data, 145 | ]; 146 | 147 | $expire = 0; 148 | if (isset($dp[Cache::Expire])) { 149 | $expire = (int) $dp[Cache::Expire]; 150 | if (!empty($dp[Cache::Sliding])) { 151 | $meta[self::MetaDelta] = $expire; // sliding time 152 | } 153 | } 154 | 155 | if (isset($dp[Cache::Callbacks])) { 156 | $meta[self::MetaCallbacks] = $dp[Cache::Callbacks]; 157 | } 158 | 159 | if (isset($dp[Cache::Tags]) || isset($dp[Cache::Priority])) { 160 | if (!$this->journal) { 161 | throw new Nette\InvalidStateException('CacheJournal has not been provided.'); 162 | } 163 | 164 | $this->journal->write($key, $dp); 165 | } 166 | 167 | $this->memcached->set($key, $meta, $expire); 168 | } 169 | 170 | 171 | public function bulkWrite(array $items, array $dp): void 172 | { 173 | if (isset($dp[Cache::Items])) { 174 | throw new Nette\NotSupportedException('Dependent items are not supported by MemcachedStorage.'); 175 | } 176 | 177 | $meta = $records = []; 178 | $expire = 0; 179 | if (isset($dp[Cache::Expire])) { 180 | $expire = (int) $dp[Cache::Expire]; 181 | if (!empty($dp[Cache::Sliding])) { 182 | $meta[self::MetaDelta] = $expire; // sliding time 183 | } 184 | } 185 | 186 | if (isset($dp[Cache::Callbacks])) { 187 | $meta[self::MetaCallbacks] = $dp[Cache::Callbacks]; 188 | } 189 | 190 | foreach ($items as $key => $meta[self::MetaData]) { 191 | $key = urlencode($this->prefix . $key); 192 | $records[$key] = $meta; 193 | 194 | if (isset($dp[Cache::Tags]) || isset($dp[Cache::Priority])) { 195 | if (!$this->journal) { 196 | throw new Nette\InvalidStateException('CacheJournal has not been provided.'); 197 | } 198 | 199 | $this->journal->write($key, $dp); 200 | } 201 | } 202 | 203 | $this->memcached->setMulti($records, $expire); 204 | } 205 | 206 | 207 | public function remove(string $key): void 208 | { 209 | $this->memcached->delete(urlencode($this->prefix . $key), 0); 210 | } 211 | 212 | 213 | public function bulkRemove(array $keys): void 214 | { 215 | $this->memcached->deleteMulti(array_map(fn($key) => urlencode($this->prefix . $key), $keys), 0); 216 | } 217 | 218 | 219 | public function clean(array $conditions): void 220 | { 221 | if (!empty($conditions[Cache::All])) { 222 | $this->memcached->flush(); 223 | 224 | } elseif ($this->journal) { 225 | foreach ($this->journal->clean($conditions) as $entry) { 226 | $this->memcached->delete($entry, 0); 227 | } 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/Caching/Storages/MemoryStorage.php: -------------------------------------------------------------------------------- 1 | data[$key] ?? null; 26 | } 27 | 28 | 29 | public function lock(string $key): void 30 | { 31 | } 32 | 33 | 34 | public function write(string $key, $data, array $dependencies): void 35 | { 36 | $this->data[$key] = $data; 37 | } 38 | 39 | 40 | public function remove(string $key): void 41 | { 42 | unset($this->data[$key]); 43 | } 44 | 45 | 46 | public function clean(array $conditions): void 47 | { 48 | if (!empty($conditions[Nette\Caching\Cache::All])) { 49 | $this->data = []; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Caching/Storages/SQLiteJournal.php: -------------------------------------------------------------------------------- 1 | path = $path; 33 | } 34 | 35 | 36 | private function open(): void 37 | { 38 | if ($this->path !== ':memory:' && !is_file($this->path)) { 39 | touch($this->path); // ensures ordinary file permissions 40 | } 41 | 42 | $this->pdo = new \PDO('sqlite:' . $this->path); 43 | $this->pdo->exec(' 44 | PRAGMA foreign_keys = OFF; 45 | PRAGMA journal_mode = WAL; 46 | CREATE TABLE IF NOT EXISTS tags ( 47 | key BLOB NOT NULL, 48 | tag BLOB NOT NULL 49 | ); 50 | CREATE TABLE IF NOT EXISTS priorities ( 51 | key BLOB NOT NULL, 52 | priority INT NOT NULL 53 | ); 54 | CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag); 55 | CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_key_tag ON tags(key, tag); 56 | CREATE UNIQUE INDEX IF NOT EXISTS idx_priorities_key ON priorities(key); 57 | CREATE INDEX IF NOT EXISTS idx_priorities_priority ON priorities(priority); 58 | PRAGMA synchronous = NORMAL; 59 | '); 60 | } 61 | 62 | 63 | public function write(string $key, array $dependencies): void 64 | { 65 | if (!isset($this->pdo)) { 66 | $this->open(); 67 | } 68 | 69 | $this->pdo->exec('BEGIN'); 70 | 71 | if (!empty($dependencies[Cache::Tags])) { 72 | $this->pdo->prepare('DELETE FROM tags WHERE key = ?')->execute([$key]); 73 | 74 | foreach ($dependencies[Cache::Tags] as $tag) { 75 | $arr[] = $key; 76 | $arr[] = $tag; 77 | } 78 | 79 | $this->pdo->prepare('INSERT INTO tags (key, tag) SELECT ?, ?' . str_repeat('UNION SELECT ?, ?', count($arr) / 2 - 1)) 80 | ->execute($arr); 81 | } 82 | 83 | if (!empty($dependencies[Cache::Priority])) { 84 | $this->pdo->prepare('REPLACE INTO priorities (key, priority) VALUES (?, ?)') 85 | ->execute([$key, (int) $dependencies[Cache::Priority]]); 86 | } 87 | 88 | $this->pdo->exec('COMMIT'); 89 | } 90 | 91 | 92 | public function clean(array $conditions): ?array 93 | { 94 | if (!isset($this->pdo)) { 95 | $this->open(); 96 | } 97 | 98 | if (!empty($conditions[Cache::All])) { 99 | $this->pdo->exec(' 100 | BEGIN; 101 | DELETE FROM tags; 102 | DELETE FROM priorities; 103 | COMMIT; 104 | '); 105 | 106 | return null; 107 | } 108 | 109 | $unions = $args = []; 110 | if (!empty($conditions[Cache::Tags])) { 111 | $tags = (array) $conditions[Cache::Tags]; 112 | $unions[] = 'SELECT DISTINCT key FROM tags WHERE tag IN (?' . str_repeat(', ?', count($tags) - 1) . ')'; 113 | $args = $tags; 114 | } 115 | 116 | if (!empty($conditions[Cache::Priority])) { 117 | $unions[] = 'SELECT DISTINCT key FROM priorities WHERE priority <= ?'; 118 | $args[] = (int) $conditions[Cache::Priority]; 119 | } 120 | 121 | if (empty($unions)) { 122 | return []; 123 | } 124 | 125 | $unionSql = implode(' UNION ', $unions); 126 | 127 | $this->pdo->exec('BEGIN IMMEDIATE'); 128 | 129 | $stmt = $this->pdo->prepare($unionSql); 130 | $stmt->execute($args); 131 | $keys = $stmt->fetchAll(\PDO::FETCH_COLUMN, 0); 132 | 133 | if (empty($keys)) { 134 | $this->pdo->exec('COMMIT'); 135 | return []; 136 | } 137 | 138 | $this->pdo->prepare("DELETE FROM tags WHERE key IN ($unionSql)")->execute($args); 139 | $this->pdo->prepare("DELETE FROM priorities WHERE key IN ($unionSql)")->execute($args); 140 | $this->pdo->exec('COMMIT'); 141 | 142 | return $keys; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Caching/Storages/SQLiteStorage.php: -------------------------------------------------------------------------------- 1 | pdo = new \PDO('sqlite:' . $path); 31 | $this->pdo->exec(' 32 | PRAGMA foreign_keys = ON; 33 | CREATE TABLE IF NOT EXISTS cache ( 34 | key BLOB NOT NULL PRIMARY KEY, 35 | data BLOB NOT NULL, 36 | expire INTEGER, 37 | slide INTEGER 38 | ); 39 | CREATE TABLE IF NOT EXISTS tags ( 40 | key BLOB NOT NULL REFERENCES cache ON DELETE CASCADE, 41 | tag BLOB NOT NULL 42 | ); 43 | CREATE INDEX IF NOT EXISTS cache_expire ON cache(expire); 44 | CREATE INDEX IF NOT EXISTS tags_key ON tags(key); 45 | CREATE INDEX IF NOT EXISTS tags_tag ON tags(tag); 46 | PRAGMA synchronous = OFF; 47 | '); 48 | } 49 | 50 | 51 | public function read(string $key): mixed 52 | { 53 | $stmt = $this->pdo->prepare('SELECT data, slide FROM cache WHERE key=? AND (expire IS NULL OR expire >= ?)'); 54 | $stmt->execute([$key, time()]); 55 | if (!$row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 56 | return null; 57 | } 58 | 59 | if ($row['slide'] !== null) { 60 | $this->pdo->prepare('UPDATE cache SET expire = ? + slide WHERE key=?')->execute([time(), $key]); 61 | } 62 | 63 | return unserialize($row['data']); 64 | } 65 | 66 | 67 | public function bulkRead(array $keys): array 68 | { 69 | $stmt = $this->pdo->prepare('SELECT key, data, slide FROM cache WHERE key IN (?' . str_repeat(',?', count($keys) - 1) . ') AND (expire IS NULL OR expire >= ?)'); 70 | $stmt->execute(array_merge($keys, [time()])); 71 | $result = []; 72 | $updateSlide = []; 73 | foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { 74 | if ($row['slide'] !== null) { 75 | $updateSlide[] = $row['key']; 76 | } 77 | 78 | $result[$row['key']] = unserialize($row['data']); 79 | } 80 | 81 | if (!empty($updateSlide)) { 82 | $stmt = $this->pdo->prepare('UPDATE cache SET expire = ? + slide WHERE key IN(?' . str_repeat(',?', count($updateSlide) - 1) . ')'); 83 | $stmt->execute(array_merge([time()], $updateSlide)); 84 | } 85 | 86 | return $result; 87 | } 88 | 89 | 90 | public function lock(string $key): void 91 | { 92 | } 93 | 94 | 95 | public function write(string $key, $data, array $dependencies): void 96 | { 97 | $expire = isset($dependencies[Cache::Expire]) 98 | ? $dependencies[Cache::Expire] + time() 99 | : null; 100 | $slide = isset($dependencies[Cache::Sliding]) 101 | ? $dependencies[Cache::Expire] 102 | : null; 103 | 104 | $this->pdo->exec('BEGIN TRANSACTION'); 105 | $this->pdo->prepare('REPLACE INTO cache (key, data, expire, slide) VALUES (?, ?, ?, ?)') 106 | ->execute([$key, serialize($data), $expire, $slide]); 107 | 108 | if (!empty($dependencies[Cache::Tags])) { 109 | foreach ($dependencies[Cache::Tags] as $tag) { 110 | $arr[] = $key; 111 | $arr[] = $tag; 112 | } 113 | 114 | $this->pdo->prepare('INSERT INTO tags (key, tag) SELECT ?, ?' . str_repeat('UNION SELECT ?, ?', count($arr) / 2 - 1)) 115 | ->execute($arr); 116 | } 117 | 118 | $this->pdo->exec('COMMIT'); 119 | } 120 | 121 | 122 | public function remove(string $key): void 123 | { 124 | $this->pdo->prepare('DELETE FROM cache WHERE key=?') 125 | ->execute([$key]); 126 | } 127 | 128 | 129 | public function clean(array $conditions): void 130 | { 131 | if (!empty($conditions[Cache::All])) { 132 | $this->pdo->prepare('DELETE FROM cache')->execute(); 133 | 134 | } else { 135 | $sql = 'DELETE FROM cache WHERE expire < ?'; 136 | $args = [time()]; 137 | 138 | if (!empty($conditions[Cache::Tags])) { 139 | $tags = $conditions[Cache::Tags]; 140 | $sql .= ' OR key IN (SELECT key FROM tags WHERE tag IN (?' . str_repeat(',?', count($tags) - 1) . '))'; 141 | $args = array_merge($args, $tags); 142 | } 143 | 144 | $this->pdo->prepare($sql)->execute($args); 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/compatibility.php: -------------------------------------------------------------------------------- 1 |