Elyra
Elyra The coding agent eTerm The terminal that knows where each command ends Starf An activity monitor for Apple silicon that never invents a number Litr A small, native web browser for macOS Notr A notebook for macOS e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Refr Local-first PDF workspace for macOS Elyra Workspace A desktop workspace for coding agents Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Release notes
Changelog
Elyra
Shared cache

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_total counts evictions. Sizing the table generously avoids eviction under load.

  • Oversize values: a value larger than the largest slot (64 KB) isn't cached — set returns false and askr_cache_oversize_total increments (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 — and askr_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.