Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Cache

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.

cache:
    config:
        serviceTtl:
            'App\UI\Api\System\Models\Services\SettingService': 3600

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#

  1. Verify every write goes through a manager — that is, that the bump really fires.
  2. Hunt down non-ORM writes in the same module (raw DBAL, updateColumn, triggers) and add bumpForClass() to them.
  3. Switch on writeOnly: true and walk the page — writing into Redis starts, reads still come from the database.
  4. Compare what got stored with what the page is supposed to show.
  5. Add the service to enabledServices — only now is the cache read from.
  6. 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).

sessionTtl:
    fallbackDir: %tempDir%/sessions   # the default; null = no fallback (500 as before)

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#

  1. In the server's config/Shared/local.neon (the probe is off everywhere in it):
cache:
    config:
        settingsProbeEnabled: true
  1. Delete temp/cache (the new configuration value goes through the DI container).
  2. Let the site run. The longer and more varied the traffic (cron, the administration, all languages), the more reliable the data.
  3. php bin/settings-usage.php prints 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
  1. 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 (HINCRBY per 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 (a get(module, key) call did not find the key — a silent NULL) and bulk (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:

  1. Whoever gets the lock reads the database, stores the payload and deletes the lock.
  2. 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.
  3. 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#

  1. Check statsEnabled: true (the default).
  2. Click through the scenario you care about.
  3. 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

  1. Repeat the call with PARAM_USE_CACHE => false. If the result is correct, the problem is invalidation, not the query.
  2. Find how that value is written — through a manager, or raw SQL?
  3. 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.
  4. 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".

Cookie settings