Cache#
When a read comes back from Redis instead of the database, how it is invalidated,
and what to do when you need fresh data. Core: app/Core/Cache/, configuration in
config/Shared/cache.neon.
What actually gets cached#
Not the query — the FINISHED response
What is cached is the wire payload — what leaves the Api presenter (the
output of extractAll() / extractWithReferences()). Not SQL, not entities.
A hit therefore skips building the query, hydration and serialisation alike —
but it also means the cache holds data already assembled for one specific call.
The boundary is the Api presenter. Both the front end and the administration read through it, so both hit the same cache.
The key carries the caller's audience too
The payload differs by who asks: an internal call also gets fields with
Audience::Internal, a privileged one also administrator fields. If the key did not carry
the audience, the first caller would freeze the result for everyone — the front would cache
a payload with internal fields and an external client would receive it. The query cache key
therefore includes an audience component. Masking of personal data happens only on the
finished payload, not in the cache — see
RBAC in the API.
Three switches, stacked#
cache:
config:
enabled: false # the master switch
enabledServices: [] # allowlist of services (FQCN); empty = nothing
writeOnly: false # write but do not read (measuring the warm-up)
queryTtl: 300 # 5 minutes
| Layer | What it does |
|---|---|
enabled: false |
nothing is read or written, the cache is out of the picture |
enabledServices |
switching on service by service — only what is listed gets cached |
writeOnly: true |
writes but reads from the database — to see what would be stored |
enabled: true with an empty enabledServices caches NOTHING
Both must hold. That is deliberate: turning the cache on globally with one switch would turn it on where it has not yet been verified that invalidation actually happens.
The * wildcard only after every service is verified
enabledServices: ['*'] switches everything on at once. Until every service has
its non-ORM writes (raw DBAL) covered, that means serving stale data — and
nobody notices, because the page looks perfectly normal.
Lifetime per service#
The base lifetime is queryTtl (300 s). It can be extended for individual services with the map
cache.config.serviceTtl in config/Shared/cache.neon (key = the full class name of the
service, value = seconds). A service outside the map has queryTtl; a ±10 % spread
(TtlJitter) is added to every value. Config::queryTtlFor() returns the value.
The default configuration gives 3600 s to selected code-list and structural services: blog
(categories, tags), parameters and parameter groups (classifieds, directory, e-shop), category-to-parameter
links, discussion categories, and the system pages, page sections, menus, menu items, routes,
currencies, languages, localizations, payment regions, custom assets, settings and external
connections. A longer lifetime belongs only to a service whose writes all invalidate the cache
and whose result does not depend on time. Listings with a time filter (adverts and discussion
messages — promotion, articles, news, galleries, ad places, e-shop products) stay on 300 s. An entry
in the map applies only to a service that is also in enabledServices.
Manual SQL shows up only after an hour with a long lifetime
A data change outside a manager (manual SQL, a seed, a migration) does not invalidate the
cache. For services with an hourly lifetime you therefore see the change only after an hour,
until you clear the cache (keys app:cache:q:*, or QueryCache::clearOwn()).
bin/migrate.php clears the cache after migrations by itself, and so does deployment. When two
machines point at the same database but each has its own Redis, a write from one does not
invalidate the other's cache.
How the cache is invalidated#
A key carries tags = the FQCNs of the entities the response is built from. The tags are derived automatically from what the query used:
root entity + includes (relations) + fields in filters + fields in ordering
└───────────────── DependencyTagResolver ─────────────────┘
A write then merely bumps the tag's version (bumpFor() / bumpForClass()).
Old keys stop being valid at a stroke — nothing is deleted, they simply stop matching.
That is why nothing has to list what to delete
Saving an article bumps the Article tag, and every response that contained the
article — listings, details, related boxes — is out at once. With no list of keys
that nobody would keep up to date anyway.
A write OUTSIDE the ORM does not move the tag
A raw DBAL UPDATE, updateColumn(), a database trigger — none of them fires a
hook, so no version is bumped and the cache keeps serving stale data until the
TTL runs out. Whoever writes raw SQL has to bump the tag themselves.
Procedure: I need fresh data for one call#
The most common case. It touches no configuration, only that one call:
$payload = $this->bridge->call('api/blog/article/get-all', [
'filters' => $filters,
ApiBridge::PARAM_USE_CACHE => false, // ← this call always goes to the DB
], null, 'POST');
| Value | Meaning |
|---|---|
false |
do not read from the cache and do not write into it |
true |
cache it even if the caller would not otherwise |
null / omitted |
"no opinion" — the configuration decides |
The parameter is stripped from the call and never reaches the Api method
The bridge handles it, not the endpoint. The Api method does not have it in its
signature and must not — otherwise you get Unknown named parameter.
From a manager: ->useCache()#
Front and admin managers have the same on the query object. useCache() without an argument means true:
$variant = $this->assetVariantManager->find($id)->useCache(false)->get();
$items = $this->articleManager->findAll($filters)->include(['translations'])->useCache(false)->execute();
| Layer | Without calling useCache() |
How to change it |
|---|---|---|
| front repository | caches (per configuration) | ->useCache(false) |
admin ApiRepository |
does not read from the cache | only an explicit ->useCache(true) |
The admin default is false even outside an admin request (CLI harness, admin cron), where
the whole-request switch in startup() does not run.
View counters do not invalidate the cache
updateColumns on views, viewsList, viewsDetail does not bump the tag
(SaveTrait::$nonInvalidatingColumns; a manager may extend the list, e.g. discussion
message votes). A bump on every view would drop the cache of all lists. Whoever reads
such a value before a write or computes with it reads with ->useCache(false),
otherwise the stale value is written.
A manager either extends the list (array_merge in the constructor; the discussion
MessageManager adds votesUp/votesDown) or replaces it by declaring the property — then
it must list the default columns it still needs itself. That is what AdvertManager (Base)
does for advert banners: views, viewsList, clicks. Views and clicks are counted by
registerEvent on every page, so they do not tear the cache of banner lists.
An exhausted banner disappears only after the TTL
Counts in the cache are at most queryTtl old. A banner that has just reached
max_clicks or max_views (the generated is_available) therefore still shows in the
list until the cache expires. That is acceptable for ads; where it would not be, do not
put the column on the list.
The flag cannot be set from an HTTP request
Only in-process code may turn the cache off (ApiBridge calls setQueryCacheEnabled()).
As a request parameter it would become a site-wide cache switch that also bypasses the
single-flight lock.
Procedure: I am adding a read that should be cached#
- Verify every write goes through a manager — that is, that the bump really fires.
- Hunt down non-ORM writes in the same module (raw DBAL,
updateColumn, triggers) and addbumpForClass()to them. - Switch on
writeOnly: trueand walk the page — writing into Redis starts, reads still come from the database. - Compare what got stored with what the page is supposed to show.
- Add the service to
enabledServices— only now is the cache read from. - Click through the write → read scenario: save, load immediately, and verify the new value is there.
Step 6 cannot be skipped
Missing invalidation does not surface as an error but as an old value — and an old value looks like the truth. The cheapest moment to catch it is right now.
A filter holding a Closure breaks the query
The key is built from the property values of the filters. A Closure cannot be hashed
deterministically, so QueryKeyHelper throws InvalidArgumentException on it — skipping
it is not an option, two filters differing only in the callback would share a key and swap
their results. Such a query does not belong in the cache: pass the value as a scalar, or
use NonCacheableFilter.
Custom read actions: rememberTagged()#
The main path caches the standard reads (get, get-all, get-by) at the Api presenter
boundary. Actions that are not standard — prices, counts, aggregates, settings, connections —
have no includes or filters from which tags could be derived. For them there is
QueryCache::rememberTagged():
$payload = $this->queryCache->rememberTagged(
serviceClass: $this->getServiceClass(),
method: 'currentPrices',
keyParams: ['setIds' => $ids, 'currencyId' => $currencyId, 'day' => $day],
tags: [ProductSet::class, ProductSetPrice::class],
producer: fn (): array => $this->buildPrices($ids, $currencyId),
ttlCap: fn (array $payload): ?int => $this->secondsToNextPriceChange($payload),
);
| Parameter | Meaning |
|---|---|
serviceClass |
the service whose row in enabledServices switches the cache on |
method, keyParams |
the key; must carry every input the result depends on (currency, region, group, date…) |
tags |
FQCNs of the Api entities the result reads — passed explicitly by the caller |
producer |
the DB read; runs only on a miss or with the cache off |
useCache |
false = no cache |
ttlCap |
gets the finished payload and returns how many seconds it may live at most; null = normal TTL, ≤ 0 = do not store |
The same safeguards as on the main path: the snapshot of tag versions is taken BEFORE the DB read, so a write between the snapshot and the store leaves the next read out of the cache and stale data is never served; one request fills the cache (single-flight); a Redis outage switches the cache off.
User-specific data does not belong in the key, and an error result is not cached
If the result depends on the signed-in user, the key must carry the user — or it is not cached. The producer throws an exception for an error result, it does not return "empty": an empty result would be stored and look like the truth.
The TTL cap handles changes WITHOUT a write
A scheduled price applies from a certain time and nothing is written then. The payload
therefore carries validUntil (the nearest future published_time) and ttlCap does not
exceed it; jitter may only shorten the TTL.
Used by: set prices and ends of promotions (ProductSetPresenter, 12 tags in
PRICE_CACHE_TAGS), product counts in categories (ProductCategoryPresenter), settings rows
(SettingManager, tag Setting) and external connections (ExternalConnections, see
External connections). Stock is deliberately not cached; only
in-request memory for vacation windows (StockReader) and warehouse semaphores
(WarehouseManager).
Date and time in the key#
A DateTime has no PHP properties, so reflection would turn every date into the same
fingerprint and two queries differing only by the date would share a key (the event calendar
returned the events of the first cached day). QueryKeyHelper therefore puts the value in
explicitly, to a precision of 5 minutes (DATETIME_BUCKET_SECONDS = 300) plus the zone
offset. Whole days and hours stay distinguished, and a "now" filter (new DateTime()) does not
create a new key every second. Staleness is at most one interval, i.e. the same as the TTL.
Writes outside the ORM: the bumps that were added#
A raw write, updateColumn() and a trigger do not raise the tag, so a bump has to be called
by hand (bumpForClass(), on a manager additionalCacheTags). These cases were completed:
| Where | What it raises |
|---|---|
ProductSetManager::softDelete, ProductManager::softDelete, ProductVariantManager::softDelete |
set, product, variant |
ProductSetManager::recountPrices (only on a change) |
set prices |
PollAnswerManager::registerVotes |
PollAnswer and Poll |
Bazaar CategoryManager |
CategoryParameterRel (through additionalCacheTags) |
admin ParameterRuleManager (adverts, catalogue, e-shop) |
the parameter entities from cacheTagClasses(), see Forms |
In a manager the bump is one of two BaseManager methods, depending on whose data the raw write changes:
| Method | What it bumps | When |
|---|---|---|
bumpCacheTagsForClass($class, $extra) |
the class and the classes of its owned collections (cascade persist/remove, orphan removal) | the manager changes its own entity or rows owned by it (CustomAssetManager, PageManager after writing custom-code links) |
bumpCacheTagsExplicit($classes) |
only the listed classes | a write affecting another entity with no link to the manager's own entity (an admin manager changing a foreign Api table with raw SQL) |
bumpCacheTagsExplicit() on the own entity skips the owned collections
Owned collections are inlined in the parent's cached graph. Bumping only the parent class leaves stale lists of its child rows in the cache until the TTL.
Tree operations have no entity, so additionalCacheTags() does not reach them
additionalCacheTags(object $entity) receives an entity, so a bump without one
(moving or renumbering nodes in TreeTrait::treeTransaction()) does not call it. Return
a tag that does not depend on a concrete entity from classCacheTags(): array (declared
on BaseManager with an empty default). Otherwise, after a node move, the cache of the
dependent entity stays stale until the TTL.
Manual SQL (seeds, migrations) does not invalidate the cache
Wait for the TTL (5 minutes, an hour for services in serviceTtl) or call QueryCache::clearOwn(). On deployment the Redis
cache is cleared in the workflow, see
Deployment checklist.
Which services to enable (enabledServices)#
The cache caches only what is in cache.config.enabledServices. The server's local.neon
keeps the list itself (it is not shared through git), so after deploying code with a new
cached action the service must be added there too. Without it the application works but reads
from the database.
| Group | Services (App\UI\Api\…) |
|---|---|
| E-shop | Eshop\Models\Services\{ProductSetService, ProductCategoryService, ParameterService, ParameterGroupService} |
| System | System\Models\Services\{PageSectionService, AssetService, CustomAssetService, PaymentRegionService, SettingService, ExternalConnectionService} |
| Blog, Base | Blog\Models\Services\FlashMessageService, Base\Models\Services\{AdvertPlaceService, AdvertService, PollService, PollPlaceService} |
| Classifieds, directory | Bazaar\…\CategoryParameterRelService, Comcat\…\CategoryParameterRelService |
SettingService and ExternalConnectionService are the ones that get forgotten: when they are
missing there is one extra SQL query on every request.
Settings in Redis carry non-public values too
The cache holds settings rows including the SMTP password, OAuth and the cron token. Redis
must therefore listen only on 127.0.0.1 (or with a password) and have no public port.
What is deliberately not cached#
| Where | Why |
|---|---|
| The administration | the whole-request policy is switched off in startup() — an administrator must see what they just saved |
| Stock (availability, semaphores) | stays live; only in-request memory |
| Elasticsearch search | the result changes without a database write (reindex, score changes), so the cache would have nothing to invalidate on |
Queries with a NonCacheableFilter |
the key would have to contain the entire list of ids |
The administration switches the cache off for front-end managers too
When a read through a front-end manager runs inside an admin request, it applies to that as well. Otherwise an administrator would see fresh data in one place and stale data in another.
Other caches in the system#
| Cache | Where | Note |
|---|---|---|
| Route table | app/Core/Cache/Route/RouteTableCache.php |
its own routerEnabled switch, independent of the master one |
| Category slug maps | app/Core/Cache/Route/SlugMapCache.php |
switched on by the same routerEnabled, see below |
| Session | Redis | two lifetimes, see below |
| Doctrine, DI container, Latte | temp/cache/ |
files, not Redis |
Slug maps (SlugMapCache)#
The category translators (CategoryNameTranslator in Bazaar, Comcat, Blog, Discussion and
ProductCategoryNameTranslator in the e-shop), on the first nameToId() / idToName(),
loaded all categories of the module and built two maps id ⇄ slug from them — 461
categories hydrated on every request. SlugMapCache::load($name, $tags, $producer) stores
those maps directly.
| Property | Detail |
|---|---|
| key | app:cache:route:slugmap:<name>, global (routing has no user context) |
| invalidation | a tag snapshot as with the route table (entity + translation); a bump from the manager is free |
| TTL | with ±10 % jitter, a staleness cap for writes outside the manager |
| Redis outage | the first PredisException switches the cache off for the rest of the request |
| manual delete | SlugMapCache::clear($name) |
In addition: the placeholder segment - and a not-found region slug no longer go to the
database on every link (RegionNameTranslator), and the category translators answer -
without a warmup.
SlugHelper#
SlugHelper::webalize() is a copy of Strings::webalize() for hot paths (the router
translators). Nette Strings::toAscii() creates a \Transliterator on every call; for
1,383 conversions it was 125 ms, with a shared transliterator and memoisation (a cap of 20,000
inputs) 15 ms. 15 translators use it.
The output must stay bit-identical with Strings::webalize()
Public addresses are made from it. SlugHelperTest guards it; after updating nette/utils
run it, and if the algorithm in the vendor changes, fix the copy, not the test.
Sessions in Redis#
Every visitor gets a session (forms carry a CSRF token). With autoStart and a 31-day
expiry, every request without a cookie — i.e. every robot request — left a key in Redis for
31 days, so tens of thousands of keys quickly filled Redis.
App\Core\Session\TieredTtlSessionHandler (wired by DI\SessionTtlExtension, which must be
in the configuration after redis) has two lifetimes:
| Session | Lifetime |
|---|---|
| written by a request without its cookie (new) | sessionTtl.newSessionTtl = 7200 s (2 h) |
| the browser came back with the cookie | the full time (redis.sessions.ttl, 31 days) |
A robot does not return the cookie, so its sessions soon expire. Sign-in creates a new session id, so the sign-in request itself writes with the short lifetime and the following redirect (already with the new cookie) extends it.
sessionTtl:
newSessionTtl: 7200 # 0 = off (always the full time)
skipCrawlerSessions: true # a crawler's cookie-less session is not stored
The session of a known crawler without a cookie (CrawlerDetector, also an empty User-Agent)
is not stored at all: the page renders, and the session (CSRF tokens included) lives only in
the request's memory. A request that sent a cookie is never treated this way, so a logged-in user
with an odd User-Agent keeps working. Switch it off with sessionTtl.skipCrawlerSessions: false
(default true).
The old robot keys expire by themselves
After deployment nobody touches the existing 31-day keys; they disappear within 31 days.
The same handler keeps a file fallback. On the first Predis exception in a request it
switches that request's sessions to files in temp/sessions; the next request tries Redis
again. A Redis outage therefore no longer brings the site down (before, every request ended
with a 500).
What an outage costs
A .redis-down marker sends one line per hour to warning.log, and a line to info.log
when Redis is back. Sessions written to files are not read once Redis returns, so sign-ins
made during the outage are lost. The query cache and Elasticsearch are fail-open.
The translation catalogue does not refresh itself
A new or changed language .neon has no effect until temp/cache/translation/
is deleted. Nette regenerates almost everything else on its own — not this — and
it shows up as raw keys printed instead of texts.
The settings read probe#
Which keys of cms_system_settings are actually read in operation is told neither by the
code nor by the database: there are several readers (SettingContext::get(),
getSettings()[m][k], the constants of the settings presenters). Without this information a key
cannot be safely deleted or renamed. The probe (App\Core\Cache\Probe\SettingReadProbe)
records the reads; it is off by default and independent of the main cache switch.
Procedure: turn the probe on and read the result#
- In the server's
config/Shared/local.neon(the probe is off everywhere in it):
- Delete
temp/cache(the new configuration value goes through the DI container). - Let the site run. The longer and more varied the traffic (cron, the administration, all languages), the more reliable the data.
php bin/settings-usage.phpprints the result:
| Switch | Output |
|---|---|
| (none) | an overview: keys from the DB with a read count, unread keys, keys read outside the DB, readers of the whole map |
--module=5 |
one module only |
--unread |
only the DB keys the probe did not record |
--json |
machine output |
- After collecting, turn the probe off and delete
temp/cache. The data in Redis disappears on its own after switching off (settingsProbeTtl, 90 days by default, renewed on every write;0= no TTL).
The tool only reads (the database and Redis), it deletes and renames nothing. On a machine with the probe off, or before any request has run, it says the counters are empty — and that you cannot conclude from this that keys are not read. An unavailable Redis is reported as an error, not as empty data.
How the probe counts#
- Reads are summed in the request's memory and at its end go to Redis in one write
(
HINCRBYper field); with PHP-FPM only after the response has been sent. A disabled probe does nothing. - It records three kinds of reads:
hit(the key was in the map),miss(aget(module, key)call did not find the key — a silentNULL) andbulk(reading the whole map, by the class of the caller). - A Redis outage breaks nothing: the first error switches writing off until the end of the request.
The probe sees only reads of a single key through SettingContext::get()
A key read through getSettings()[m][k] or through the settings presenters' constants shows
as "unread" although it is read. Before deleting such a key, trace it through the list of
whole-map readers (bulk) and by searching the code. Delete or rename keys only after a long
collection from live operation.
Lock, compression and counters#
The remaining options in config/Shared/cache.neon. Only the first two depend on the master switch.
| Option | Default | What it does |
|---|---|---|
lockMs |
3000 | single-flight lock after a miss (see below); 0 = off |
statsEnabled |
true |
hit counters in Redis; when off, not a single extra command is sent |
compressData, compressionThreshold, compressionLevel |
true, 256 B, 6 |
DEFLATE for values above the threshold; hard-coded in the cache.config service definition, not in parameters |
routerEnabled, routerTtl |
false, 300 |
route table and slug maps, see Routing |
settingsProbeEnabled, settingsProbeTtl |
false, 90 days |
settings read probe |
Single-flight lock#
When a hot list expires, we do not want a hundred requests rebuilding it from the database at
once. After a miss (and after the tag-version snapshot) a request tries
SET {prefix}cache:lock:… 1 NX PX lockMs:
- Whoever gets the lock reads the database, stores the payload and deletes the lock.
- The others try the cache three times, 100 ms apart (
QueryCache::LOCK_POLL_ATTEMPTS,LOCK_POLL_INTERVAL_US). A payload found while waiting goes through the same version check as a normal hit. - If no payload appears, they read the database but do not store — otherwise the stampede would double.
lockMs caps how long the lock survives a crashed holder. An exception from the producer
releases the lock at once; a Redis error while locking sends the request straight to the
database. The route table and slug maps have no lock (one key, cheap rebuild).
Procedure: measure how well the cache hits#
- Check
statsEnabled: true(the default). - Click through the scenario you care about.
- Read the hashes in Redis:
redis-cli HGETALL app:cache:stats:_total
redis-cli HGETALL 'app:cache:stats:App\UI\Api\Blog\Models\Services\ArticleService'
| Field | Meaning |
|---|---|
hit |
a valid payload from the cache |
miss_cold |
the key is missing, expired or unreadable |
miss_invalidated |
a payload was there but the tag (or format) versions do not match — a write invalidated it |
set |
a store after a database read |
bump |
a tag version raised; only in _total, since the bump runs after commit without a service context |
The counters are fail-open: an error writing them is swallowed and brings down neither the
request nor the stored data. Many miss_invalidated for a service means frequent writes to its
tags, not a cache bug.
When the cache seems to be lying#
Work from the cheapest check up
- Repeat the call with
PARAM_USE_CACHE => false. If the result is correct, the problem is invalidation, not the query. - Find how that value is written — through a manager, or raw SQL?
- Check that the tag matches the entity the change happened on; it is derived from includes and filters, so an unexpected relation may be missing.
- Only then touch the TTL. A TTL is a safety net for holes, not a fix.
A Redis outage switches the cache off silently
The first connection error takes it out for the whole request and the database takes over. That is deliberate — the site must work without Redis — but it means "the cache does not seem to do anything" may mean "Redis is down".