Askr · · 7 min read

Askr, four releases later: serve the warm page, fix it in the background, and finally delete Redis

A field journal through Askr 0.9.0 → 0.9.3: stale-while-revalidate, RSS-based worker recycling, shadow traffic, native queue autoscaling, a durable L2 tier, and the broadcasting driver that finally deletes Redis from Laravel.

Askr, four releases later: serve the warm page, fix it in the background, and finally delete Redis

Last time I wrote here, Askr had just grown up: an in-process PHP application server in Rust, no FastCGI, no FPM, running real Laravel apps. The number I kept coming back to was uncomfortable and clarifying at the same time — PHP execution is ~99.5% of a request; I/O is the rounding error. Once you internalize that, your whole roadmap flips. You stop polishing the 0.5% and start asking a better question:

How often can I not run PHP at all — and when I must run it, how do I keep it alive and honest?

The last four releases are basically four answers to that question. Let me walk you through them the way I'd tell a friend over coffee: why each one exists, and exactly how you'd use it.

1. Serve the warm page, fix it in the background (swr=)

Every response cache has the same sad moment: the entry expires, and the next visitor pays for the recompute. They wait for PHP so everyone after them doesn't have to. It's a tax we've all just accepted.

Stale-while-revalidate refuses the tax. When a page is past its fresh window but still inside a grace window, Askr hands the visitor the stale copy instantly and kicks off a single background refresh that re-runs the controller off the request path.

// Fresh for 60s; then serve stale for up to 600s more while ONE
// background refresh repopulates the cache. Nobody waits for PHP.
header('Askr-Cache: 60, swr=600, tags=posts');

You can watch it happen. I wired a counter into a test app and hit it:

1) X-Askr-Cache: MISS    run=1      # PHP ran
2) X-Askr-Cache: HIT     run=1      # from cache, PHP idle
   … 3s later, past the fresh window …
3) X-Askr-Cache: STALE   run=1      # served instantly + refresh fired
4) X-Askr-Cache: HIT     run=2      # background render already landed

The refresh is coalesced through the same singleflight table the cache already used, so a hundred simultaneous stale hits trigger one recompute, not a hundred. And because the cache now compresses once at store time (not on every hit — that was an embarrassing little CPU leak I fixed the same week), a warm hit does zero compression work.

Why it matters: the hottest pages on your site stop touching PHP almost entirely, and they never show a visitor a spinner while the cache warms.

2. Recycle before the crash, not after (--max-rss)

Here's a truth about long-lived PHP workers nobody likes to say out loud: they leak. Not always your fault — a framework singleton here, an array session there — but over enough requests the heap creeps toward memory_limit, and then a worker face-plants into a fatal mid-request.

Askr already survived that gracefully (a leaked worker exits and the supervisor respawns it — no 502 flood). But surviving a crash is not the same as avoiding one. So now the supervisor watches:

askr serve --root public --worker-script worker.php \
  --workers 4 --max-rss 400        # recycle a worker before it OOMs

Every second the master samples each worker's RSS from /proc. When one crosses the cap, it gets a graceful SIGTERM — finish the in-flight request, then exit — and a fresh worker takes its place. Predictive, not reactive.

I tortured it with a worker that leaks 100 KB per request and pointed 10,000+ requests at it:

RSS held at ~230 MB against a 200 MB cap · 0 OOMs · 0 non-2xx

Same leak without the feature? A steady drizzle of OOM-and-respawn. The difference between "degrades gracefully" and "never degrades at all" is one /proc read a second.

3. Test the new version with real traffic — safely (--shadow-to)

You can't fully trust a deploy until production traffic hits it, but production traffic is exactly what you can't afford to gamble. So mirror it.

askr serve … --shadow-to http://127.0.0.1:8081 --shadow-sample 10

After Askr serves the real response, it quietly replays a sampled slice of safe requests (GET/HEAD, no cookies — never a write, never someone's session) to a shadow upstream running your next version, and compares the two responses. The client never notices; the mirror is fire-and-forget.

Point --shadow-to at a staging deploy of the new code and watch:

askr_shadow_total 36
askr_shadow_match_total 23
askr_shadow_mismatch_total 13     # the new build diverges here — go look
askr_shadow_error_total 0

If mismatch stays at zero under load, the new version is byte-for-byte compatible. If it doesn't, you get the URL and both statuses in the log — before you flip traffic over. It's GitHub-Scientist-style "test in production" without the "in production" part touching a single user.

4. The queue consumer is ours too (--queue-max)

This one started as a throwaway observation from a friend and turned into the thesis of the whole quarter:

The "Redis replacement" isn't a product. Redis is only the data layer — you still run Horizon to consume the queue and cron to run the scheduler. Askr owns both: the store and the worker pool, scheduler, and queue consumer.

Once you see it, one feature becomes obvious. Askr can read the queue backlog (it's in shared memory) and it owns the worker processes (it forks them). So it can do Horizon's balance=auto natively, with no extra daemon:

askr serve … --queue 1 --queue-max 8 \
  --queue-slots 8192 --queue-script worker.php --admin 127.0.0.1:9090

--queue is the floor, --queue-max the ceiling. Throw a burst at it and the pool sizes itself:

enqueue 200 jobs
+2s  → 8 workers   (jumped to target)
+6s  → backlog cleared, draining begins
…    → 6 → 5 → 4 → 3 → 2 → 1 (one worker every ~2s, gracefully)

Scale up fast on the burst, drain gently after, all visible on /metrics (askr_queue_workers, askr_queue_ready, askr_queue_oldest_seconds). No Horizon, no supervisor config, no Redis. The runtime and the data live in the same binary, so the autoscaler is just… a loop in the master.

5. Two tiers: shared memory first, durable when you need it (L1 + L2)

Everything above lives in L1 — shared memory mapped before fork, lock-free-ish, blisteringly fast, and gone when the box reboots. Perfect for cache, sessions, counters, a single-box queue.

But some things want to survive a restart or span two machines. So there's now an optional L2: a durable, replicated backend over SQL Anywhere, gated behind a build feature so the default build and CI don't change at all.

# opt in at build time
cargo build --release --features sql-backend

opt in at runtime — unset falls straight back to L1

export ASKR_CACHE_DB=/var/lib/askr/cache.db export ASKR_QUEUE_DB=/var/lib/askr/queue.db export ASKR_BROADCAST_DB=/var/lib/askr/events.db

The trick that makes this painless: L2 speaks the exact same bridge as L1. Your PHP (askr_cache_, askr_queue_, askr_broadcast()) and the Laravel drivers don't change a line — only the backend does. Each backend is verified against the same conformance contracts (TTL and atomic add/increment for cache; visibility-timeout claim and at-least-once delivery for the queue; append-and-tail for pub/sub).

And when you enable both, L1 becomes a write-through read cache in front of L2: hot reads hit shared memory and skip the database round-trip entirely; writes go to L2, the source of truth, and warm or invalidate L1. Durable and fast, no app change. Even queue autoscaling followed along — balance=auto now reads its backlog from the L2 database when ASKR_QUEUE_DB is set, and forks workers exactly as before.

6. The Redis-free Laravel surface is complete

The last brick landed in 0.9.3: a broadcasting driver. Laravel Echo now works over Askr's in-binary pub/sub — no Redis, no separate WebSocket server — via the Pusher-compatible fan-out Askr already had.

Which means a single-box Laravel app's .env can now look like this, end to end:

SESSION_DRIVER=askr
CACHE_STORE=askr
QUEUE_CONNECTION=askr
BROADCAST_CONNECTION=askr
composer require kwhorne/askr-laravel

Session, cache, queue, and broadcasting — the four things you'd normally reach for Redis (and Horizon, and Reverb) to cover — all served from one binary, with an optional durable tier when you outgrow one box. Measured on the session driver alone: ~11–15k req/s with a flat 8 MB per worker, because sessions live in shared memory instead of piling into the PHP heap.

Where this leaves us

Four releases, one idea: own the whole request lifecycle, and most of your "infrastructure" turns into a function call. Serving a warm page becomes a header. Not-crashing becomes a /proc read. A safe deploy becomes a mirrored GET. Autoscaling the queue becomes a loop. Deleting Redis becomes four lines of .env.

None of it is magic — it's just what you can do when the store and the runtime stop being two programs shouting across a socket and start being one binary that already knows everything.

Askr is v0.9.3 today: ghcr.io/kwhorne/askr:0.9.3, multi-arch, PHP 8.5, Laravel 13. Still pre-1.0, still exploratory, still the most fun I've had with a side project in years.

Next up on the bench: HTTP/3, OpenTelemetry traces that can finally show you "97% PHP, 0.5% I/O" as a flamegraph, and maybe — maybe — teaching two Askr boxes to trust each other completely.

See you at 1.0. 🌳