Shared cache
Askr ships a small shared-memory cache exposed to PHP. It's a fixed-slot hash table living in an anonymous shared mmap the master creates before forking, so every worker process sees the same physical table — no IPC, and reads take no lock at all.
This gives you cache, atomic counters (rate limiting) and locks in the Askr binary — no Redis for small/mid deployments.
Enabling it
askr serve --root ./public --worker-script examples/laravel-worker.php \
--cache-slots 16384
or in askr.toml:
[cache]
slots = 16384 # each slot is ~4.3 KB; 16384 ≈ 70 MB
The cache is off when slots = 0 (the default). Sizing: pick roughly
expected_entries × 1.3. Each slot holds one entry inline; keys are capped at
250 bytes and values at ~4 KB (larger values simply aren't cached).
PHP API
When the cache is enabled, these functions are available to any PHP running under Askr (web workers, the worker loop, and queue/scheduler sidecars):
| Function | |
|---|---|
askr_cache_get(string $key): ?string |
Value, or null on miss/expiry. |
askr_cache_set(string $key, string $value, int $ttl = 0): bool |
ttl seconds (0 = forever). false if too large. |
askr_cache_delete(string $key): bool |
true if it existed. |
askr_cache_increment(string $key, int $delta = 1, int $ttl = 0): int |
Atomic add; returns the new value. |
askr_cache_touch(string $key, int $ttl = 0): bool |
Atomically refresh a key's TTL without rewriting its value (false if absent). |
askr_cache_flush(): void |
Empty the calling application's entries — here and in the response cache — and nobody else's. |
askr_cache_set('greeting', 'hello', 60);
echo askr_cache_get('greeting'); // hello (from any worker)
$n = askr_cache_increment('rate:'.$ip, 1, 60); // atomic across all workers
The counter is atomic across every worker process — ideal for rate limiting.
Laravel cache driver
examples/AskrCacheStore.php is a Laravel
Store backed by these functions. Register it in your worker script or a service
provider:
use Illuminate\Support\Facades\Cache;
require '/opt/askr/examples/AskrCacheStore.php';
Cache::extend('askr', fn ($app) =>
Cache::repository(new AskrCacheStore(config('cache.prefix', ''))));
Add a store in config/cache.php (or set CACHE_STORE=askr):
'askr' => ['driver' => 'askr'],
Then use the cache normally — it's shared across all workers:
Cache::put('user:1', $user, 300);
$user = Cache::get('user:1');
Cache::increment('hits'); // atomic
Cache::remember('report', 600, fn () => build()); // computed once, shared
Integers/floats are stored unserialized so increment()/decrement() (used by
Laravel's rate limiter) are truly atomic in shared memory; other values are
serialized.
Large values, sessions & locks (0.6.0)
Two size classes: a small region (--cache-slots, 4 KB values — counters,
locks, small entries) and an optional large region (--cache-large-slots /
[cache] large_slots, 64 KB values — sessions, cached fragments, serialized
collections). set routes by size and clears the key from the other region;
get/delete check both. This keeps big values working without wasting 64 KB
per counter.
With the large region enabled, the AskrCacheStore covers what usually needs
Redis on a single box:
// sessions
// .env: SESSION_DRIVER=cache SESSION_STORE=askr
// atomic locks (across all worker processes, via askr_cache_add)
Cache::lock('deploy', 10)->get(fn () => run_migration());
askr_cache_add($key, $value, $ttl) is an atomic set-if-absent — the primitive
behind Cache::add() and Cache::lock(). The store implements Laravel's
LockProvider, so Cache::lock() is truly atomic in shared memory.
| Redis use | Askr replacement |
|---|---|
| cache | askr_cache_* + AskrCacheStore (small + large regions) |
| rate limiting / counters | askr_cache_increment (atomic) |
| atomic locks | askr_cache_add + Cache::lock() |
| sessions | large region + SESSION_STORE=askr |
| pub/sub | broadcast ring + Pusher WS (see BROADCAST) |
| queues | askr_queue_* + AskrQueue driver (shared-memory, delayed + retries) |
Automatic page caching (Laravel)
The kwhorne/askr-laravel package can derive the tags for you, so there's no list to
keep correct:
Route::get('/products/{product}', ProductController::class)
->middleware('askr.cache:300');
Every Eloquent model the response read becomes a tag, so $product->save() clears
exactly the pages that showed it. Responses that read many models degrade to
class-level tags (products instead of products:42), and a response with more
dependencies than an entry can hold is not cached at all — an entry that can't be
invalidated is worse than a cache miss.
See the package README
for the full behaviour, and askr cache-report for deciding
which routes deserve it.
Semantics & limits
-
Eviction: the probe window (16 slots) evicts an expired entry, else the oldest-written one;
askr_cache_evictions_totalcounts evictions. Sizing the table generously avoids eviction under load. -
Oversize values: a value larger than the largest slot (64 KB) isn't cached —
setreturnsfalseandaskr_cache_oversize_totalincrements (also logged at debug), so dropped large sessions/fragments are visible rather than silent. -
Deletion is tombstoned: a deleted/expired slot becomes a tombstone (not an empty hole), so a colliding key stored later in the same probe chain stays reachable; inserts reuse tombstones.
-
TTL is lazy (checked on read) — expired entries free their slot on the next access to it.
-
Reads are lock-free (1.5.0). A read samples a per-slot version counter, copies, and samples again; a change means a writer overlapped the copy, so the read is retried and then — bounded — falls back to taking the lock. Writes still take the per-slot spinlock and are serialised as before.
This matters because reads used to take that spinlock exclusively, so every worker reading one hot key (a session, a shared config blob) queued behind the others. Measured on a 12-performance-core machine, reads of a single key across concurrent readers:
readers locked lock-free 1 22.1M/s 22.9M/s 4 6.0M/s 75.0M/s 12 1.5M/s 19.1M/s The locked column is the point: it falls as readers are added — a spinlock under contention burns cycles rather than waiting. Reproduce it with
cargo test --release -p askr --bins cache_read_scaling -- --ignored --nocapture, which measures both paths in one process. -
Best-effort under crashes: a per-slot spinlock is stolen if a holder dies mid-operation, so a crashed worker can't deadlock the table; a torn write can only yield a stale/garbage value (never memory unsafety — reads are length clamped), which a cache tolerates. A writer killed mid-update leaves its slot's version counter odd, which sends readers to the lock until the next write repairs it — correct, just not lock-free for that one slot in the meantime.
-
Namespaced per application (1.5.1). Keys are prefixed with a namespace derived from the docroot before they reach the table, so two applications hosted in one instance (
[[site]]) cannot read each other's keys — or sessions, which are keys — andaskr_cache_flush()empties only the caller's application — in the response cache too, since 1.7.2, where it used to empty every application's pages. Two domains serving one docroot share, as one application should. The prefix is 17 bytes, so the effective maximum key length is 233. See Hosting. -
Not persistent: the table lives in RAM and is empty on restart.
For very large values, many GB of data, or cross-host sharing, use a real cache (Redis/Memcached) — Askr's cache targets the common single-host case.