Skip to content

Caching

Precious Lyson edited this page Nov 13, 2026 · 2 revisions

The Machinjiri Caching System is a PSR-inspired caching layer that lets you store expensive computation results, database queries, or API responses so they can be retrieved instantly on subsequent requests.

It is designed around three principles:

  • Uniform API — Every driver implements the same CacheStore contract. You can swap drivers without changing application code.
  • Composable features — Serialization, compression, tagging, metrics, and stampede protection are orthogonal layers that can be enabled independently.
  • Progressive complexity — A beginner can call $cache->set('key', $value) and be productive in a minute. Advanced users can wire up tagged invalidation with per-table TTL strategies and Redis-backed distributed locks.

Features

Feature Description
Multiple Drivers Array (in-memory), File (filesystem), Redis (distributed).
Unified API Every store implements CacheStore.
TTL Support Per-item time-to-live; null = no expiry.
Tagged Cache Invalidate groups of keys at once via version bumping.
Stampede Protection Prevents cache stampedes ("thundering herd") with locks.
Serialization Pluggable serializers (JSON included).
Compression Optional zlib/snappy/lz4 compression layer.
Eviction Policies LRU and LFU for the ArrayStore; size-based eviction for FileStore.
Metrics Hit/miss counts, hit rate, writes, per-table invalidations, latency, result sizes.
Per-Table Strategies Map tables to named TTL strategies.
Atomic Counters increment / decrement for counters and rate limits.
Bulk Operations getMultiple, setMultiple, deleteMultiple.
Custom Exceptions CacheException extends the framework MachinjiriException.

Installation

The caching system lives inside the Machinjiri framework namespace:

Mlangeni\Machinjiri\Core\Artisans\Caching

Ensure your autoloader maps Mlangeni\Machinjiri\ to the framework source directory. For Redis support, the RedisAdapter (Predis-based) must be installed.


Quick Start (Beginner)

The CacheManager is the entry point. It lazily resolves stores from configuration.

use Mlangeni\Machinjiri\Core\Artisans\Caching\CacheManager;

// Create a manager with default config
$cache = new CacheManager();

// Store a value (uses default TTL = 3600 seconds)
$cache->set('user:42', ['name' => 'Ada', 'role' => 'admin']);

// Retrieve it
$user = $cache->get('user:42');        // array
$missing = $cache->get('nope', 'N/A'); // 'N/A' (default)

// Delete
$cache->delete('user:42');

// Clear everything in the active store
$cache->clear();

Remember: compute-once

The remember method is the most useful shortcut — it returns the cached value or executes the callback, stores the result, and returns it:

$report = $cache->remember('monthly-report', function () use ($db) {
    return $db->query('SELECT * FROM reports WHERE month = ?', [date('m')]);
}, 600); // cache for 10 minutes

Choosing a different store

$cache->store('file')->set('key', 'value', 300);
$cache->store('redis')->set('key', 'value', 300);

Configuration

The CacheManager accepts a configuration array merged over sensible defaults:

$config = [
    'default' => 'redis',                 // default store name
    'stores'  => [
        'array' => [
            'driver'    => 'array',
            'max_items' => 1000,
            'eviction'  => 'lru',          // or 'lfu'
        ],
        'file'  => [
            'driver'    => 'file',
            'path'      => '/var/cache/myapp',
            'max_files' => 5000,
            'file_perm' => 0644,
        ],
        'redis' => [
            'driver'   => 'redis',
            'host'     => '127.0.0.1',
            'port'     => 6379,
            'password' => null,
            'database' => 0,
            'prefix'   => 'cache:',
        ],
    ],
    'serializer'            => 'json',
    'compression'           => false,
    'compression_algorithm' => 'zlib',     // zlib | snappy | lz4
    'prefix'                => 'app',
    'default_ttl'           => 3600,
    'stampede_protection'   => true,
    'circuit_breaker'       => [
        'enabled'           => false,
        'failures_threshold'=> 5,
        'timeout'           => 30,
    ],
    'table_strategies' => [                // table => strategy name
        'users'   => 'short',
        'reports' => 'long',
    ],
    'strategy_ttls' => [                   // strategy => seconds
        'default' => 3600,
        'short'   => 300,
        'long'    => 7200,
    ],
];

Config Keys Explained

Key Purpose
default Which store $cache->store() returns when no name is given.
stores Driver definitions (see Driver Reference).
serializer Serializer to use (json is the only built-in).
compression Master switch for compression.
compression_algorithm zlib, snappy, or lz4.
prefix Logical application prefix.
default_ttl TTL used when set()/remember() gets null.
stampede_protection Enables the lock-based StampedeProtector.
circuit_breaker Reserved for fail-fast behavior when a backend is down.
table_strategies Maps a table name to a strategy name.
strategy_ttls Maps a strategy name to a TTL in seconds.

Core Concepts

Cache Stores

A store is a concrete backend. Every store implements CacheStore:

interface CacheStore
{
    public function get(string $key, mixed $default = null): mixed;
    public function set(string $key, mixed $value, ?int $ttl = null): bool;
    public function delete(string $key): bool;
    public function clear(): bool;
    public function has(string $key): bool;
    public function increment(string $key, int $value = 1): int|false;
    public function decrement(string $key, int $value = 1): int|false;
    public function getMultiple(array $keys, mixed $default = null): array;
    public function setMultiple(array $values, ?int $ttl = null): bool;
    public function deleteMultiple(array $keys): bool;
    public function getStoreName(): string;
}

Note: CacheManager::has() is currently an alias of get() (see CacheManager.php). Use the store directly ($cache->store()->has($key)) for a strict boolean check.

The CacheManager

The CacheManager is a factory + facade:

  • Factory: It lazily resolves and caches store instances in $this->stores.
  • Facade: It forwards get/set/delete/clear/increment to the default store.
  • Extras: It holds the shared CacheMetrics and StampedeProtector, and exposes tags() for tagged caching.
$cache->store();          // default store
$cache->store('file');    // named store
$cache->getMetrics();     // shared metrics object
$cache->tags(['users']);  // TaggedCache wrapper

CacheItem

CacheItem is the in-memory record used by ArrayStore. It stores the value, an optional absolute expiry timestamp, a hit counter, and the last access time.

$item = new CacheItem('value', 60); // expires in 60s
$item->isExpired();                 // false
$item->hit();                       // hitCount = 1, lastAccess updated
  • $ttl === null means no expiry (expiresAt stays null).
  • hit() is what powers LFU eviction (fewest hits evicted first).

Serializers & Compression

Values must be serialized before they hit disk/Redis. The default is JsonSerializer:

$s = new JsonSerializer();
$s->serialize(['a' => 1]);   // '{"a":1}'
$s->unserialize('{"a":1}');  // ['a' => 1]

Compression is applied after serialization and reversed before deserialization:

serialize(value) → compress(data) → store
store → uncompress(data) → unserialize(data) → value

ArrayStore currently stores raw PHP values (no serialization needed for in-memory), but the serializer/compressor are injected for symmetry and future use.

Metrics

CacheMetrics is a shared statistics collector. All built-in stores record hits, misses, and writes on it.

$metrics = $cache->getMetrics();
$metrics->getHitRate();   // 0.0 – 1.0
$metrics->getStats();     // structured array

getStats() returns:

[
    'hits'                    => 120,
    'misses'                  => 30,
    'writes'                  => 80,
    'hit_rate'                => 0.8,
    'avg_key_generation_time' => 0.0004,
    'avg_result_size_bytes'   => 512.5,
    'invalidations_by_table'  => ['users' => 3, 'reports' => 1],
]

Tagged Cache

Tagged caching lets you group keys and invalidate them together. Instead of deleting every key (impossible on many backends), it uses version bumping:

  1. Each tag has a version stored under tag_version:<tag> (default 1).
  2. A cache key is built as tags:<sha1 of tag:version pairs>:<original key>.
  3. Calling clear() increments each tag's version. All old keys become unreachable (orphaned) and will be garbage-collected later.
$cache->tags(['users', 'active'])->set('list', $users, 600);

// Later: invalidate everything tagged 'users'
$cache->tags(['users'])->clear();

Key construction example:

tags:8f14e45fceea167a5a36dedd4bea2543:user:42

Stampede Protection

A cache stampede occurs when a hot key expires and thousands of concurrent requests all try to recompute it at once. StampedeProtector uses a simple lock:

  1. Check the cache. If hit → return.
  2. Try to acquire lock:<key>.
  3. Winner: compute, store, release lock, return.
  4. Losers: poll up to 5 times (50 ms apart). If the winner populates the cache, they return that value. Otherwise they fall back to computing.
$value = $cache->remember('expensive', fn () => doHeavyWork(), 300);
// Automatically uses StampedeProtector when enabled

⚠️ Important: The built-in acquireLock() uses an in-process array, so it only protects against concurrency within a single PHP process. For true multi-process/distributed protection, you must back the lock with Redis SET NX (see Extending the System).


Advanced Usage

Per-Table Strategies & TTLs

The manager can map a table to a named TTL strategy — useful when you invalidate per-table in an ORM layer.

$cache->getTableStrategy('users');   // 'short'
$cache->getStrategyTtl('short');     // 300

$ttl = $cache->getStrategyTtl(
    $cache->getTableStrategy('users') ?? 'default'
);

Atomic Counters

$cache->increment('page:views');      // 1
$cache->increment('page:views', 5);   // 6
$cache->store()->decrement('page:views'); // 5

RedisStore uses native INCRBY/DECRBY (atomic). ArrayStore/FileStore do a read-modify-write, which is not atomic across processes.

Bulk Operations

$cache->store()->setMultiple([
    'a' => 1,
    'b' => 2,
], 300);

$values = $cache->store()->getMultiple(['a', 'b', 'c'], 'missing');

$cache->store()->deleteMultiple(['a', 'b']);

RedisStore pipelines setMultiple for efficiency.

Custom Stores

Implement CacheStore and register it by extending CacheManager::resolveStore():

class MemcachedStore implements CacheStore
{
    // ... implement all interface methods
    public function getStoreName(): string { return 'memcached'; }
}

Then extend the manager:

class MyCacheManager extends CacheManager
{
    protected function resolveStore(string $name): CacheStore
    {
        if (($this->config['stores'][$name]['driver'] ?? null) === 'memcached') {
            return new MemcachedStore(/* ... */);
        }
        return parent::resolveStore($name);
    }
}

Custom Serializers

Implement SerializerInterface:

class MsgPackSerializer implements SerializerInterface
{
    public function serialize(mixed $value): string
    {
        return msgpack_pack($value);
    }
    public function unserialize(string $data): mixed
    {
        return msgpack_unpack($data);
    }
}

Then extend CacheManager::getSerializer() to return it based on $this->config['serializer'].


Driver Reference

ArrayStore

Best for: single-request caches, unit tests, CLI scripts.

Aspect Detail
Storage PHP array of CacheItem objects.
Eviction lru (default) or lfu.
Limit max_items (default 1000).
TTL Per-item via CacheItem::isExpired().
Persistence None — gone at end of request.
$store = new ArrayStore(1000, 'lru', new JsonSerializer(), null, new CacheMetrics());

Behavior notes

  • get() on an expired key deletes it and records a miss.
  • get() updates the LRU order and increments hitCount.
  • evictIfNeeded() triggers only when count >= maxItems before inserting.
  • increment() on a non-numeric value returns false.

FileStore

Best for: medium-traffic apps without Redis; persistent across requests.

Aspect Detail
Storage One .cache file per key, named sha1(key).
Format serialize(['expires' => ?int, 'value' => string]).
Eviction Deletes oldest files (by lastModified) when count exceeds max_files.
Atomicity Not guaranteed; write() is not atomic-rename by default.
$store = new FileStore($filesystem, 5000, $serializer, $compressor, $metrics);

Behavior notes

  • Keys are hashed, so the real key is not visible on disk.
  • clear() iterates the directory listing (non-recursive).
  • Corrupt/unserializable files are treated as misses.
  • evictIfNeeded() scans the entire directory on every set() — expensive at high file counts. Consider increasing max_files or batching writes.

Heads-up: The constructor references $this->directory = 'store' and the path() method prefixes it. If your Filesystem is rooted at cache/app, files land in cache/app/store/. Verify your LocalAdapter root matches your expectation.

RedisStore

Best for: distributed, high-throughput, multi-server deployments.

Aspect Detail
Client RedisAdapter wrapping Predis (ClientInterface).
Prefixing Predis adds the configured prefix automatically.
Counter Native INCRBY/DECRBY (atomic).
Bulk MGET and pipeline-based SET.
Expiry SETEX when TTL is set; SET otherwise.
$store = new RedisStore($config, $serializer, $compressor, $metrics);

Behavior notes

  • getClient() returns the raw Predis client (used by TaggedCache::gc()).
  • clear() lists all keys matching * and deletes them — scoped to the Predis prefix, so it won't wipe other apps only if they use a different prefix/database. Prefer flushdb on a dedicated database.
  • Throws CacheException if the Redis adapter class is missing or the connection fails.

API Reference

CacheStore Interface

Method Returns Notes
get(string $key, mixed $default = null) mixed $default on miss/expiry.
set(string $key, mixed $value, ?int $ttl = null) bool null TTL = forever.
delete(string $key) bool
clear() bool Removes all entries in store.
has(string $key) bool Non-expired presence.
increment(string $key, int $value = 1) int|false false if non-numeric (Array/File).
decrement(string $key, int $value = 1) int|false
getMultiple(array $keys, mixed $default = null) array Key-preserved.
setMultiple(array $values, ?int $ttl = null) bool
deleteMultiple(array $keys) bool
getStoreName() string array | file | redis.

CacheManager Methods

Method Description
__construct(array $config = []) Merge config over defaults.
store(?string $name = null): CacheStore Resolve (and memoize) a store.
getTableStrategy(string $table): ?string Strategy for a table.
getStrategyTtl(string $strategy): ?int TTL for a strategy.
getMetrics(): CacheMetrics Shared metrics.
getStampedeProtector(): StampedeProtector Shared protector.
get(string $key, mixed $default = null) Default-store get.
has(string $key, mixed $default = null) ⚠️ Alias of get.
set(string $key, mixed $value, ?int $ttl = null) Default-store set (falls back to default_ttl).
remember(string $key, callable $cb, ?int $ttl = null, array $tags = []) Get-or-compute with optional tags + stampede protection.
delete(string $key) Default-store delete.
increment(string $key, int $value = 1) Default-store increment.
clear() Default-store clear.
tags(array $names): TaggedCache Tagged wrapper.

TaggedCache Methods

Method Description
get(string $key, mixed $default = null) Reads using the versioned key.
set(string $key, mixed $value, ?int $ttl = null) Writes using the versioned key.
delete(string $key) Deletes the versioned key.
remember(string $key, callable $cb, int $ttl) Get-or-compute using stampede protection if provided.
clear() / flush() Bumps every tag's version (invalidates all tagged keys).
gc() No-op unless the store is a RedisStore. See note below.

⚠️ Note on TaggedCache::remember: CacheManager::tags() constructs TaggedCache without passing the StampedeProtector, so the fallback simple get/set path is used. To get stampede protection with tags, construct it manually:

$tc = new TaggedCache($cache->store(), ['users'], $cache->getStampedeProtector());

⚠️ Note on TaggedCache::gc(): The method scans tags:* keys but the matched hash is never compared to current tag versions — the loop body is effectively empty. As written, it does not reclaim orphans. It is safe to call but provides no benefit until completed. A production implementation should:

  1. For each scanned key, extract the embedded tag:version pairs.
  2. Compare against the current tag_version:<tag> values.
  3. Delete keys whose version no longer matches.

Performance Tuning

Concern Recommendation
ArrayStore eviction cost evictLFU() is O(n). Keep max_items modest or stick with lru (O(n) due to array_splice, but small n).
FileStore directory scans set() lists the whole directory each call. Raise max_files and monitor; consider a sharded filesystem or a periodic GC cron instead.
FileStore write atomicity For concurrent writers, wrap writes in a temp file + rename() in your LocalAdapter.
Redis clear() KEYS * is O(n) and blocks. Use SCAN (as gc() attempts) or a dedicated DB and FLUSHDB.
Compression Enable only when payloads are large (>1 KB). zlib is CPU-cheap and universal; snappy/lz4 need extensions.
Tagged keys growth Since clear() orphans keys rather than deleting them, run gc() regularly (once completed) or set TTLs on all tagged entries.
Serialization JSON is human-readable but lossy for PHP objects (arrays only by default). Use a PHP serializer for objects.
Stampede locks The default lock is per-process. Use Redis SET NX EX for distributed safety.

Troubleshooting

Symptom Likely Cause Fix
Cache store [x] not configured Store name missing in stores. Add it to config.
Unsupported cache driver [x] driver value not array/file/redis. Fix the driver name or register a custom resolver.
Redis adapter is not available Predis / RedisAdapter not installed. Install the adapter or use another store.
Redis connection failed Wrong host/port/password/db. Verify Redis is up and credentials are correct.
Failed to write cache file Bad path/permissions. Ensure the cache dir exists and is writable (0755/0775).
FileStore entries never found Filesystem root vs. directory = 'store' mismatch. Align the LocalAdapter root with cache/app.
increment() returns false Existing value is non-numeric. Delete the key first or store a number.
Hit rate stuck at 0 Reading through store() directly without metrics? No — stores record automatically. Check you're not resetting metrics. Use getMetrics()->getHitRate().
Tagged keys accumulate clear() orphans; no GC. Set TTLs and/or implement gc().
Stampede still occurs Locks are in-process only. Back locks with Redis SET NX.

Best Practices

  1. Always use remember() for expensive operations. It encapsulates the check-compute-store cycle.
  2. Set TTLs on everything unless you truly need permanent entries — it bounds memory and avoids orphan buildup.
  3. Prefer Redis in production for multi-server setups; use FileStore for small single-server apps; use ArrayStore in tests.
  4. Tag by domain, not by route: tags(['users', 'profiles']) beats tags(['page:user-list']).
  5. Don't cache secrets in the FileStore unless the directory is outside the webroot and permissions are restrictive.
  6. Monitor getHitRate() — a low hit rate means your TTLs are too short or keys are too granular.
  7. Version your key schema (e.g. v2:user:42) when the cached shape changes, to avoid deserializing stale structures.
  8. Wrap cache calls defensively when using Redis in critical paths; a cache outage should degrade, not crash.
  9. Use getMultiple/setMultiple in loops to reduce round-trips (Redis pipelines them).
  10. Treat clear() as a last resort — prefer tag invalidation or targeted delete().

Extending the System

Distributed Stampede Lock (Redis)

Replace the in-memory lock with Redis atomic SET NX EX:

protected function acquireLock(string $key): bool
{
    return (bool) $this->redis->set($key, '1', ['nx', 'ex' => 5]);
}

protected function releaseLock(string $key): void
{
    $this->redis->del($key);
}

Circuit Breaker

The circuit_breaker config is reserved. A typical implementation wraps store calls:

if ($this->breaker->isOpen($storeName)) {
    return $default; // fail fast
}
try {
    return $store->get($key, $default);
} catch (CacheException $e) {
    $this->breaker->recordFailure($storeName);
    return $default;
}

Complete TaggedCache::gc()

A working GC should:

  1. SCAN keys matching tags:*.
  2. Parse the hash's constituent tag versions (or store a manifest at write time).
  3. Compare each tag version to tag_version:<tag>.
  4. Delete mismatched keys.

To make step 2 feasible, store a sidecar manifest when writing:

$this->store->set("tags:manifest:{$hash}", $parts, null);

Then gc() can compare $parts against current versions.

PSR-16 / PSR-6 Adapter

Wrap CacheStore in a PSR-16 CacheInterface implementation by delegating get/set/delete/clear/getMultiple/setMultiple/deleteMultiple/has. You'll need to translate PSR's DateInterval TTLs into integer seconds.


FAQ

Q: What's the difference between has() on the manager and on the store? On the store, has() is a true boolean presence check. On the manager, has(string $key, mixed $default = null) is currently an alias of get() — it returns the value, not a boolean. Call $cache->store()->has($key) when you need a boolean.

Q: Why does ArrayStore take a serializer and compressor it never uses? For interface symmetry and to allow future features (e.g., simulating serialization cost in benchmarks, or switching to serialized storage to bound memory). They are currently unused in the in-memory path.

Q: Does clear() on Redis wipe other applications? Only if they share the same database and your Predis prefix is empty. With a non-empty prefix, KEYS * from Predis is scoped to that prefix. Safest: dedicated database + FLUSHDB.

Q: Are tag versions shared across stores? No. TaggedCache operates on whatever store you pass it. Version keys live in that store. Invalidate tags per-store.

Q: How do I cache objects, not just arrays? JSON serialization turns objects into arrays. To preserve classes, register a SerializerInterface using serialize()/unserialize() (PHP's native format) — but be aware of security implications when unserializing untrusted data.

Q: Can I use multiple serializers at once? Not currently. CacheManager::getSerializer() selects one based on config['serializer']. Extend the match to support more names.

Q: Why does FileStore::set() scan the directory every time? evictIfNeeded() runs on every write to enforce max_files. For write-heavy workloads with thousands of files, this is the dominant cost. Consider a debounced eviction (e.g., only every N writes) or a background cron.

Q: Is increment() atomic on Redis? Yes — it uses native INCRBY/DECRBY. On Array/File stores it's a read-modify-write and is not atomic across processes.

Q: How do I test the cache? Use the array store in tests:

$cache = new CacheManager(['default' => 'array']);
$cache->set('k', 'v');
$this->assertSame('v', $cache->get('k'));

Each test gets a fresh manager (or call $cache->clear() in setUp()).


Summary Cheat Sheet

// Setup
$cache = new CacheManager($config);

// Basics
$cache->set('k', $v, 300);
$cache->get('k', $default);
$cache->delete('k');
$cache->clear();

// Remember (with stampede protection)
$cache->remember('k', fn () => compute(), 300);

// Tags
$cache->tags(['users'])->set('list', $users, 600);
$cache->tags(['users'])->clear();

// Counters
$cache->increment('views', 1);

// Bulk
$cache->store()->setMultiple(['a' => 1, 'b' => 2], 300);
$cache->store()->getMultiple(['a', 'b'], null);

// Metrics
$cache->getMetrics()->getHitRate();

// Per-table TTLs
$ttl = $cache->getStrategyTtl($cache->getTableStrategy('users') ?? 'default');

Clone this wiki locally