keyvalue-store
[Translate to English:] Redis/Valkey-basiertes Caching, Session-Storage und Locking für TYPO3 mit Sentinel-Support, TLS/mTLS und PHPRedis 6.3+.
Run TYPO3 file-based caches safely across Kubernetes pods — without RWX volumes. cluster-file-backend keeps cache payloads pod-local and cache validity cluster-wide. It replaces TYPO3’s FileBackend / SimpleFileBackend for cache workloads, supports tag-based invalidation, and avoids shared filesystem coupling in multi-pod deployments.
moselwal/cluster-file-backend:^2.3cluster_file_backendFileBackend or SimpleFileBackendFileBackend is sufficientOne-time setup: register the metadata cache, rewrite the cache configurations, mount an emptyDir. No TYPO3 core changes, no application code changes.
Production: moselwal/keyvalue-store with KeyValueBackend (Valkey/Redis, sub-ms latency, taggable). Getting started without an extra dependency: TYPO3 Core’s Typo3DatabaseBackend.
Since v2.2 ClusterFileBackend also implements PhpCapableBackendInterface — a single backend serves both VariableFrontend caches (pages, extbase, …) and PhpFrontend caches (typoscript, fluid_template). The payload store appends a .php suffix for PhpFrontend caches so OPcache can ingest the files directly. Compression is forced to none for PHP code. Cluster coherence comes from the BackendVersion-folded hash path — every deploy produces a new path, OPcache cools automatically, no opcache_invalidate() needed.
0x00 = uncompressed, 0x01 = zstd, 0x02 = gzip. The reader picks the decompressor from the marker; the writer can mix codecs (e.g. skip-compress for tiny payloads, zstd for the rest).
Option minCompressedBytes (default 1024): payloads below this threshold are stored uncompressed. Avoids the fixed cost of gzdeflate/zstd_compress on tiny values. 0 = always compress.
has() / get() / remove() hit an in-memory map of recent CacheMetadata lookups. Identical identifiers in the same request skip the Valkey/DB round-trip — ~200× faster on repeated has() calls.
Fully decoded payloads are cached in RAM with LRU (default cap: 32 entries / 4 MB). Repeated get() calls on a hot identifier drop from ~90 µs to ~0.5 µs — 9–20× faster than SimpleFileBackend on every repeated read. Controlled via payloadL1MaxEntries and payloadL1MaxBytes. PhpFrontend caches skip the payload L1 (OPcache is the better in-memory representation).
cache_l1_hit_total (v2.3)Alongside cache_hit_total and cache_miss_total, the new counter cache_l1_hit_total makes the three-layer hit distribution visible in production. Alert on cache_miss_total{reason=metadata-error} for early detection of metadata cache outages.
┌─ FrankenPHP worker RAM ─────────────────────────────────────────┐
│ OPcache (compiled PHP code) → PhpFrontend .php files │
│ Payload L1 (decompressed bytes) → VariableFrontend caches │
│ Metadata L1 (CacheMetadata objects) → all caches │
└─────────────────────────────────────────────────────────────────┘
▲
┌─ Pod-local emptyDir ──────────────────────────────────────────┐
│ /<localPath>/<shard>/<sha256>[.php] │
│ • VariableFrontend: 1-byte marker + compressed bytes │
│ • PhpFrontend: plain-text PHP with .php suffix │
│ → source of truth for payload bytes │
└─────────────────────────────────────────────────────────────────┘
▲
┌─ Metadata cache (Valkey / DB) ─────────────────────────────────┐
│ ~300-byte records: { hash, checksum, lifetime, tags, state } │
│ → source of truth for cluster-wide cache validity │
│ → no PHP code, no payload bytes │
└─────────────────────────────────────────────────────────────────┘This package knows nothing about Redis/Valkey/KV stores. It only speaks the TYPO3 cache API and delegates cluster persistence to a TYPO3 cache backend of your choice.
TYPO3 Cache API → ClusterFileBackend
│
├─► Metadata cache (a second TYPO3 cache frontend;
│ backend is your choice: Typo3DatabaseBackend,
│ KeyValueBackend, MemcachedBackend, …)
│
└─► Local payload store (pod-local, emptyDir)
TaggableBackendInterface)clusterfilebackend:gc), delegated to the metadata cache backendclusterfilebackend:warmup) and a listener on TYPO3’s CacheWarmupEventvar/cache/code/core stays in the image), a session store, a generic blob store or a distributed filesystemKeyValueBackend from moselwal/keyvalue-store) and point ClusterFileBackend at it via metadataCacheIdentifierext_emconf.php, no classic mode)moselwal/cluster-file-backend from ^2.3, extension key cluster_file_backend, namespace Moselwal\Typo3ClusterCache\Planning a TYPO3 Kubernetes migration? I can review your cache, storage and deployment architecture before it becomes an operations problem. Get in touch →
The package intentionally does not register any caches automatically. Hostnames, ports, TLS, paths are inherently site-specific. The steps below are a one-time setup.
composer require moselwal/cluster-file-backend:^2.3Typo3DatabaseBackend — works without extra dependencies as long as the database is reachable from every pod (Galera, RDS Multi-AZ, …). For higher performance, install moselwal/keyvalue-store and use its KeyValueBackend.cluster_meta) that holds the metadata.pages, pagesection, rootline, imagesizes, assets, hash) to use ClusterFileBackend and reference cluster_meta via metadataCacheIdentifier.emptyDir at /app/var/cache/cluster/ (or wherever localPath points).| Artefact | Path | Purpose |
|---|---|---|
| Default config (no extra deps) | Configuration/Example/cache-configurations.example.php | Database-backed metadata plus cluster file caches — works on any TYPO3 install |
| Redis/Valkey config (optional) | Configuration/Example/cache-configurations-redis.example.php | High-performance variant using moselwal/keyvalue-store |
| JSON Schema | Configuration/Backend/ClusterFileBackend.options.schema.json | Validated at backend construction — misconfiguration raises InvalidCacheException with the offending field |
| CLI commands | Configuration/Commands.php | clusterfilebackend:gc, clusterfilebackend:warmup |
| Event listener | Configuration/Services.yaml | Hooks into TYPO3’s CacheWarmupEvent — bin/typo3 cache:warmup triggers the cluster warm-up too |
| DI bindings | Configuration/Services.yaml | Auto-discovery for MetricsPort, ClockPort, CompressorPort |
The ClusterFileBackend constructor validates options against a JSON schema. Mandatory fields (otherwise InvalidCacheException): localPath (absolute path), metadataCacheIdentifier (name of the metadata cache frontend), namespace.environment (prod, staging, testing or development) and namespace.instance (slug [a-z0-9-]{1,64}). If the configured metadataCacheIdentifier is not registered as a TYPO3 cache, the constructor fails immediately with a message naming the config path — no silent failure on first set().
| Option | Default | Meaning |
|---|---|---|
compression | zstd | zstd | gzip | none. Forced to none for PhpFrontend caches. |
serializer | igbinary | igbinary | php. Switching invalidates existing entries. |
defaultLifetimeSeconds | 3600 | TTL when the caller passes null. Minimum 1 (schema rejects 0). |
maxPayloadBytes | 10485760 (10 MB) | Writes larger than this are rejected with InvalidDataException. Also the upper bound for decompressed reads (zstd-bomb mitigation). |
minCompressedBytes | 1024 | Payloads below this size are stored uncompressed (1-byte marker 0x00). Avoids the fixed cost of zstd_compress/gzdeflate on tiny values. 0 = always compress. |
payloadL1MaxEntries | 32 | Maximum entries in the request-scoped payload L1. 0 disables the payload L1 (metadata L1 stays active). LRU eviction. |
payloadL1MaxBytes | 4194304 (4 MB) | Soft memory budget for the payload L1. Entries that would push the total above this are evicted in insertion order; a single payload larger than this bypasses the L1 entirely. 0 = no byte budget. |
backendVersionEnvVar | IMAGE_TAG | Env var carrying the deploy identifier. Folded into every payload hash — every deploy gets a fresh path tree. Override for CI conventions like CI_COMMIT_SHA. |
Copy the contents of vendor/moselwal/cluster-file-backend/Configuration/Example/cache-configurations.example.php into your config/system/settings.php (or additional.php) and adjust environment, instance and localPath to your deployment. This example uses TYPO3 Core's Typo3DatabaseBackend for the metadata cache — cluster-safe when your database is clustered.
For sub-millisecond metadata latency, copy Configuration/Example/cache-configurations-redis.example.php instead. It uses the KeyValueBackend from moselwal/keyvalue-store with optional TLS and Sentinel support.
Step 1: Define a TYPO3 cache frontend that persists metadata. Any backend that implements TaggableBackendInterface (for flushByTag) works.
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['cluster_meta'] = [
'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
'backend' => \TYPO3\CMS\Core\Cache\Backend\Typo3DatabaseBackend::class,
'options' => [],
'groups' => ['system'],
];
Step 2: Point ClusterFileBackend at the metadata cache — for all file-based caches at once.
foreach (['pages', 'pagesection', 'rootline'] as $cacheName) {
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations'][$cacheName] = [
'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
'backend' => \Moselwal\Typo3ClusterCache\Infrastructure\Cache\Backend\ClusterFileBackend::class,
'options' => [
'localPath' => '/app/var/cache/cluster/' . $cacheName,
'metadataCacheIdentifier' => 'cluster_meta',
'namespace' => [
'environment' => 'prod',
'instance' => 'website-a',
],
],
'groups' => ['pages'],
];
}
volumes:
- name: cluster-cache
emptyDir: { sizeLimit: 2Gi }
volumeMounts:
- name: cluster-cache
mountPath: /app/var/cache/cluster
After a rolling deploy each new pod should typically verify that it can reach the metadata cache and that its localPath is writable before it starts serving traffic. Trigger the warm-up explicitly:
./vendor/bin/typo3 clusterfilebackend:warmup \
--namespace=cfb:prod:website-a:pages \
--namespace=cfb:prod:website-a:pagesection \
--namespace=cfb:prod:website-a:rootline
The command emits one JSON line per namespace and exits non-zero if any namespace fails its health checks. Hook it into your readiness/startup probe or a post-deploy job.
Alternatively, run TYPO3's standard warm-up — the event listener hooks in automatically:
./vendor/bin/typo3 cache:warmup
apiVersion: batch/v1
kind: CronJob
metadata:
name: clusterfilebackend-gc-pages
spec:
schedule: "*/15 * * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
containers:
- name: typo3-cli
args: ["clusterfilebackend:gc", "--namespace=cfb:prod:website-a:pages"]
DDD 4-layer (Domain → Application → Infrastructure → Presentation), enforced via deptrac. The only outside interface for “central truth” is MetadataCachePort, implemented by the Typo3MetadataCache adapter, which accepts any TYPO3 FrontendInterface.
Frequently asked: “When an editor clicks Clear all caches in the TYPO3 backend, how do we make sure all pods see it?”
Short answer: the pod handling the click clears the central metadata cache. All other pods see it on their next get() because they query the central metadata cache, not their local filesystem. No pod-to-pod sync needed, because metadata truth never lives on a pod.
Pod A: TYPO3 backend “Clear all caches” / editor saves page /
`bin/typo3 cache:flush`
│
▼
ClusterFileBackend::flush() on pod A
│
▼ delegates to metadata cache frontend (e.g. cluster_meta)
$metadataCache->flush()
│
▼ TYPO3 cache API calls the configured backend
KeyValueBackend / DatabaseBackend / MemcachedBackend → flush()
│
▼ happens SERVER-SIDE (Redis FLUSHDB, SQL TRUNCATE, Memcached flush_all)
All pods see the empty metadata immediately
On the next get(id) on any pod:
$metadata = $this->metadataCache->get($identifier); // → null (cache flushed)
if ($metadata === null) {
// cache_miss_total{reason=no-metadata}++
return null; // ← pod does NOT consult its local FS
}
Tests/Unit/Deployment/CrossPodFlushTest.php contains five tests that prove this: flush() propagates to pod B immediately, no sync; flushByTag() invalidates only matching entries; the local file survives the flush as a harmless orphan; re-write after flush re-establishes consistency; flush works for arbitrary numbers of pods (no scaling assumption).
Let C be the set of all cache entries and Ct ⊆ C the subset of entries tagged with t. Write n := |C| for the total count and m := |Ct| for the count of t-tagged entries — with m ≤ n. This makes the difference precise: ClusterFileBackend sits in a different complexity class, not merely at a smaller argument, because it uses backend-native algorithms and does not multiply by a pod factor.
Let P denote the number of pods and e ≤ n the number of expired entries.
| Operation | TYPO3 Core FileBackend | ClusterFileBackend | Speedup |
|---|---|---|---|
flushByTag | Θ(n) per pod — DirectoryIterator across every cache file, 2× file_get_contents per file | Θ(m) — backend reads the tag index directly | Different complexity class plus tag indexes |
findIdentifiersByTag | Θ(n) per pod | O(m) | same |
collectGarbage | Θ(n) per pod, total Θ(n · P) | O(1) active (Redis TTL auto-expire) or O(e) server-side (DB) | Backend-native plus cluster-once |
flush | Θ(n) per pod, total Θ(n · P) | Θ(n) once server-side | Pod factor disappears, constants ~100–1000× smaller |
n = 10,000 cache entries, of which m = 100 are tagged site_1, P = 5 pods.
| Setup | File reads | unlink calls | Round-trips |
|---|---|---|---|
Core FileBackend on flushByTag('site_1') | 2 · n = 20,000 | m = 100 | ≈ 2n + m = 20,100 local FS I/O per pod |
| ClusterFileBackend (Redis) | 0 | 0 | 2 (SMEMBERS + pipeline DEL) once cluster-wide |
During a rolling deploy old and new pods serve traffic simultaneously. ClusterFileBackend preserves correctness in every skew scenario, but two cases change the performance profile during the deploy window — worth understanding.
If the new image writes a different payload shape for the same cache identifier (extra fields, changed serialised classes, modified value objects) and you do not explicitly invalidate, the following happens:
The bigger risk is silent layout drift: if pod-new can technically deserialise pod-old’s bytes but the resulting object is wrong (missing fields, old enum cases, removed properties), the user sees stale or corrupt content. PHP’s unserialize does not verify class shape beyond the class name.
So that every release automatically gets a new BackendVersion and stale entries become unreachable, ClusterFileBackend reads an environment variable — by default IMAGE_TAG — and folds its value into the payload hash via crc32. In your deployment manifest:
# Helm values, Kustomize patch or plain Pod spec
env:
- name: IMAGE_TAG
value: "{{ .Values.image.tag }}" # or $CI_COMMIT_SHA, release semver, ...
You can override the variable name per cache if your CI convention differs:
'options' => [
'localPath' => '/app/var/cache/cluster/pages',
'metadataCacheIdentifier' => 'cluster_meta',
'namespace' => ['environment' => 'prod', 'instance' => 'site'],
'backendVersionEnvVar' => 'CI_COMMIT_SHA',
],
When the variable is unset or empty, the backend falls back to the package-internal BackendVersion::current() — safe for local development. In production wire the variable explicitly to get deploy-scoped invalidation.
clusterfilebackend:warmup in the deploy pipeline — drains stale entries before the new image takes traffic.pages → pages_v2 in cacheConfigurations). Heavy hammer, only for larger schema reworks.For non-breaking layout changes (additive, ignored by the old code) you can accept the temporary thrashing — correctness is preserved.
The identity hash includes PHP_MAJOR.PHP_MINOR (Classes/Application/Hash/ComputePayloadHash.php). PHP 8.4 ↔ 8.5 (or any other major/minor jump) automatically produces divergent hashes — no manual action needed. Correctness guaranteed. The cost is the same thrashing as in (A) for the duration of the rollout. Watch blob_miss_total in Prometheus; a sustained spike beyond the deploy window indicates the version skew did not converge (e.g. a pod stuck in the old image).
PHP patch updates (8.5.4 → 8.5.5) do not invalidate — only major and minor are in the hash.
Recreate strategy or pre-flush via the warm-up command.Cache lifetimes are evaluated against each pod’s local clock. If pods disagree on wall-clock time by more than a few seconds, a pod with a fast clock treats entries as expired earlier than peers — correctness is preserved but performance degrades. In Kubernetes this is normally a non-issue (nodes synchronise via chrony/systemd-timesyncd). Sanity check during incidents:
kubectl exec deploy/typo3 -- date -u
A skew above ~30 seconds across pods is the threshold where blob_miss_total and cache_miss_total{reason=expired} start to drift visibly in Prometheus.
The metadata cache (Redis/Valkey/DB) is the single source of truth. When it is unreachable:
The metadata cache must use a backend that implements TaggableBackendInterface; otherwise flushByTag becomes a no-op. Verified backends:
| Backend | Taggable | Notes |
|---|---|---|
Typo3DatabaseBackend | ✅ | Zero-dependency default |
KeyValueBackend (moselwal/keyvalue-store) | ✅ | Redis/Valkey, recommended for high-traffic |
MemcachedBackend (TYPO3 Core) | ❌ | Does not support tags — incompatible |
RedisBackend (TYPO3 Core) | ❌ | Not taggable — use moselwal/keyvalue-store instead |
Every container pointing at the same metadata cache backend must see the same IMAGE_TAG (or whichever variable is set via backendVersionEnvVar). If the web pod runs IMAGE_TAG=1.2.3 and a worker/cron pod still runs IMAGE_TAG=1.2.2, the two compute different BackendVersion values and treat each other’s writes as blob-misses. Symptom: persistent thrashing in mixed deployments. Helm/Kustomize tip: extract the tag into a single value and reference it from every Pod spec.
Lifetime::unlimited() maps to expiresAt = 2147483647 (mirrors TYPO3 Core’s Typo3DatabaseBackend::FAKED_UNLIMITED_EXPIRE). On 2038-01-19 03:14:07 UTC that timestamp becomes “now” and any entry stored as unlimited will be considered expired. Practical impact before then: none.
BackendVersion::fromString(…) folds the deploy identifier via crc32 to a 32-bit integer. Birthday collisions occur at ~77 000 unique deploy identifiers — extremely unlikely under realistic release cadence. If you regularly cycle through thousands of distinct identifiers, prefer a stable, human-readable semver string over raw commit SHAs.
localPath must be writable. With a read-only /app image, mount emptyDir / tmpfs at that path.IMAGE_TAG (or your equivalent) in production. Without it the backend uses a package-internal version constant that does NOT change across deploys — breaking cache-layout changes can then silently serve stale or corrupt content. See “Rolling deploys with version skew”.metadataCacheIdentifier must be registered before any cache that uses ClusterFileBackend. TYPO3 loads cacheConfigurations in array insertion order — define cluster_meta first.ext_emconf.php, no classic mode.Running TYPO3 on Kubernetes?
If you run TYPO3 on a K8s cluster without an RWX volume, or want to take an existing FileBackend setup into multi-pod operation, cluster-file-backend is the right primitive. Get in touch for architecture advice, migration or platform setup.
Or email us directly: kontakt@moselwal.de
This package handles the file-based cache layer in TYPO3 Kubernetes — one of the prerequisites for multi-pod clusters as described under Open Source & Digital Sovereignty. Managed variant: AI-Ready CMS as a Service.