|
| 1 | +# Caching internals |
| 2 | + |
| 3 | +How `nette/caching` works underneath, for agents editing it. Medium depth, one |
| 4 | +file: `Cache` → `Storage` → `Journal` is a single layered mechanism, and the traps |
| 5 | +(overloaded `null`, the cross-method lock, the FileStorage format) only make sense |
| 6 | +together. |
| 7 | + |
| 8 | +## The three layers and where responsibility sits |
| 9 | + |
| 10 | +- **`Cache`** is the high-level, storage-agnostic API: namespacing, dependency |
| 11 | + normalization, memoization, output capture. It never touches files or sockets. |
| 12 | +- **`Storage`** (interface) is the backend contract: `read`/`write`/`remove`/ |
| 13 | + `lock`/`clean` (+ optional `BulkReader`/`BulkWriter`). |
| 14 | +- **`Journal`** (SQLite) is an **optional** side-index for tags and priorities. |
| 15 | + FileStorage needs one **only** for `Tags`/`Priority` and throws if a write uses |
| 16 | + them without a journal; everything else works journal-less. |
| 17 | + |
| 18 | +Keys are namespaced by `Cache`: the namespace has a `NamespaceSeparator` |
| 19 | +(`"\x00"`) appended, and `generateKey` = `namespace . xxh128(key)`. `derive()` |
| 20 | +nests namespaces by concatenation. FileStorage later turns the `"\x00"` into a |
| 21 | +subdirectory (`getCacheFile`), so a namespace is a directory on disk. |
| 22 | + |
| 23 | +## `null` is overloaded; `save($key, null)` **is** `remove()` |
| 24 | + |
| 25 | +`remove()` is literally `save($key, null)`, and `null` carries three meanings: |
| 26 | + |
| 27 | +- from `read()`/`load()` → **cache miss**; |
| 28 | +- as data passed to `save()` → **delete this key**; |
| 29 | +- an `Expire <= 0` (already expired) → **delete instead of write**. |
| 30 | + |
| 31 | +So a generator that returns `null` does not cache a null — it removes the key. |
| 32 | +Do not add a code path that stores `null` as a value; the whole layer assumes |
| 33 | +`null` means absence. |
| 34 | + |
| 35 | +## Dependencies are rewritten before they reach the storage |
| 36 | + |
| 37 | +`completeDependencies()` normalizes the user's dependency array so that storages |
| 38 | +see a **smaller, uniform** set — this is why the "which backend supports which |
| 39 | +dependency" matrix is partly an illusion: |
| 40 | + |
| 41 | +- **`Files` and `Constants` are desugared into `Callbacks`** (a `checkFile`/ |
| 42 | + `checkConst` closure with an mtime/value snapshot). Storages never see `Files` |
| 43 | + or `Constants` — so those two work on **every** backend that supports |
| 44 | + `Callbacks`. |
| 45 | +- `Expire` is converted to a **relative second count** (`DateTime::from - time()`). |
| 46 | +- `Items` keys are run through `generateKey` (namespaced); `Tags`/`Namespaces` |
| 47 | + are flattened to lists. |
| 48 | + |
| 49 | +What genuinely needs backend support is therefore narrower than it looks: |
| 50 | +`Tags`/`Priority` need a `Journal`, `Items` and `Sliding` need storage-level |
| 51 | +handling, and unsupported dependencies degrade (historically an |
| 52 | +`E_USER_WARNING`). An agent adding a backend must reason about this **post-desugar** |
| 53 | +set, not the public constant list. |
| 54 | + |
| 55 | +## The lock spans two method calls (and is mostly a FileStorage thing) |
| 56 | + |
| 57 | +The `Storage::lock` contract is explicit in the interface: *"Lock is released by |
| 58 | +`write()` or `remove()`."* This is the non-local invariant: |
| 59 | + |
| 60 | +- `Cache::load`/`save` call `storage->lock($key)` **before** running a generator, |
| 61 | + then either `write()` (success) or `remove()` (generator threw) releases it. |
| 62 | +- `FileStorage::lock` opens the file `c+b`, takes `flock(LOCK_EX)`, and stores the |
| 63 | + handle in `$locks[$key]`; `write()` **reuses that same handle** (removing it from |
| 64 | + `$locks`) and unlocks at the end. The lock therefore lives across |
| 65 | + `lock → write`, not within one call. |
| 66 | +- **Stampede protection is real only for `FileStorage`.** Every other built-in |
| 67 | + storage (`MemoryStorage`, `Memcached`, `SQLite`, `DevNull`) implements `lock()` |
| 68 | + as a **no-op**, so concurrent `load($key, $generator)` calls can all generate at |
| 69 | + once there. "Nette caching prevents stampede" is a FileStorage guarantee, not a |
| 70 | + universal one. |
| 71 | + |
| 72 | +## FileStorage on-disk format and atomic writes |
| 73 | + |
| 74 | +The file layout is: **a 6-byte zero-padded meta-length header + serialized meta + |
| 75 | +data**. The write sequence is the subtle part and is a commit protocol: |
| 76 | + |
| 77 | +1. under the held `LOCK_EX`, `ftruncate(0)`; |
| 78 | +2. write a **zero-filled** header placeholder, then the data; |
| 79 | +3. `fseek(0)` and write the **real** header **last**. |
| 80 | + |
| 81 | +Writing the real (non-zero) header last means a reader that sees a non-zero size |
| 82 | +is guaranteed a complete record — the header doubles as the commit marker. Reads |
| 83 | +take `LOCK_SH`; the class docblock enumerates the three atomic operations |
| 84 | +(read/delete/write) and the **NTFS-vs-ext3 delete fallback**: `unlink` can fail on |
| 85 | +a locked NTFS file, so `delete()` falls back to `lock(EX) → truncate → close → |
| 86 | +unlink`. |
| 87 | + |
| 88 | +Two more non-obvious behaviors: |
| 89 | + |
| 90 | +- **Reads self-heal.** `verify()` checks expiration, sliding (`touch`es the file to |
| 91 | + extend it), callbacks, and — **recursively** — `Items` dependencies (each |
| 92 | + dependent file's `MetaTime` must still match). Any failure **deletes** the entry |
| 93 | + on the spot, so a stale read is also a cleanup. |
| 94 | +- **GC runs in the constructor, not at shutdown.** Each `FileStorage` |
| 95 | + instantiation runs `clean([])` with probability `$gcProbability` (0.001) — |
| 96 | + probabilistic garbage collection at construction time. |
| 97 | + |
| 98 | +## Backends honor different subsets of dependencies |
| 99 | + |
| 100 | +The built-in storages are `FileStorage`, `SQLiteStorage`, `MemcachedStorage`, |
| 101 | +`MemoryStorage`, and `DevNullStorage` (Redis and others live outside this |
| 102 | +package). They do **not** all honor the same dependencies, and the weakest are |
| 103 | +easy traps: |
| 104 | + |
| 105 | +- **`MemoryStorage` ignores every dependency** — its `write()` stores the raw |
| 106 | + value and drops `$dependencies` entirely, so there is **no expiration, no tags, |
| 107 | + no invalidation** (only `clean(All)` empties it). It is a process-local map, fine |
| 108 | + for a single request, wrong for anything that must expire. |
| 109 | +- **`DevNullStorage`** discards everything (always a miss). |
| 110 | +- `FileStorage` is the reference implementation with the fullest support (via the |
| 111 | + meta header + optional `Journal`). |
| 112 | + |
| 113 | +When adding or choosing a backend, verify the specific storage rather than |
| 114 | +assuming the public dependency constants are universally honored. |
| 115 | + |
| 116 | +## Navigation map |
| 117 | + |
| 118 | +| Concern | Where | |
| 119 | +|---|---| |
| 120 | +| Namespacing, key hashing | `Cache::generateKey`, `NamespaceSeparator`, `derive` | |
| 121 | +| `null` = remove, expire semantics | `Cache::save`, `remove` | |
| 122 | +| Dependency desugaring | `Cache::completeDependencies` | |
| 123 | +| Lock lifetime contract | `Storage::lock` (doc), `Cache::load`, `FileStorage::lock`/`write` | |
| 124 | +| On-disk format, commit-last header | `FileStorage::write`, `readMetaAndLock` | |
| 125 | +| Read-time verify + self-delete | `FileStorage::verify` | |
| 126 | +| Namespace→subdir, GC | `FileStorage::getCacheFile`, constructor | |
0 commit comments