Skip to content

VeloxCoding/scopecache

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

644 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ScopeCache

ScopeCache is a lightweight in-memory datastore/cache and write buffer for Caddy: a simple, scope-partitioned, ordered datastore built for ultra-fast dynamic reads. ScopeCache combines key-value style access with ordered per-scope collections. It runs primarily as a Caddy module, but can also run standalone over a Unix socket.

It stores application data in memory and lets Caddy serve selected reads directly from the web server process. This can reduce pressure on both the database and the application layer.

ScopeCache is not a Redis replacement and not an HTTP response cache. It is an application-managed datastore organized by scope: your application decides what data is stored, under which scope, and when it is updated or removed.

How ScopeCache works

Traditional hot-read paths often look like this:

Caddy -> application runtime (Node.js/PHP/etc.) -> Redis/database -> application runtime -> response

ScopeCache allows selected reads to take a shorter path:

Caddy -> ScopeCache memory -> response

When ScopeCache is compiled into Caddy, it runs in the same OS process as the web server. There is no Redis protocol, no cache-service roundtrip, and no separate cache process on the hot read path.

In benchmark tests, this direct in-process path served data about 9× faster than routing the same request through an app-layer and Redis on the same server. The relevant difference is architectural: ScopeCache removes parts of the request path. It does not claim that Redis itself is slow.

A typical request:

GET /tail?scope=thread:123&limit=100

returns the latest 100 items for the scope thread:123.

Direct PHP access through FrankenPHP

FrankenPHP makes it possible to write PHP extensions in Go, so PHP can access ScopeCache directly inside the same process.

In a end-to-end benchmark on an 8-vCPU VPS, a PHP scopecache_get_by_id() call to ScopeCache and back took around 0.3 microseconds on a 50,000-item random-read workload (best case, single hot key, ~0.14 µs). A regular PHP-to-Redis roundtrip on the same kind of host typically lands around 125 microseconds with a persistent Redis connection, while opening a new Redis connection for a single request adds several hundred microseconds.

The difference is architectural: ScopeCache avoids the extra process and protocol roundtrip that Redis requires.

ScopeCache is not tied to PHP. The same in-process Caddy/cache architecture can be useful in many other use cases and with other platforms as well.

What ScopeCache is

ScopeCache is a small in-memory datastore/cache for workloads where the application already knows which views need to be served quickly.

Good examples include:

  • latest messages or reactions in a thread
  • private inbox views
  • unread counters
  • notification lists
  • view counters
  • rate-limit buckets
  • pre-rendered HTML fragments for HTMX-driven interfaces
  • small materialized JSON, HTML, XML, or text views
  • high-frequency events that need to be drained later

The cache is intentionally disposable. Your source of truth lives elsewhere — usually a database, but it can be any external store (a JSON file, in-memory state, an external API). ScopeCache can be wiped, warmed, or rebuilt from it at any time.

What ScopeCache is not

ScopeCache is not a general-purpose Redis replacement. Redis and similar systems offer a much broader feature set: richer data types, more commands, clustering, persistence options, replication, mature operational tooling, and a large ecosystem.

ScopeCache is also not a traditional HTTP response cache like Varnish or Souin. It does not cache responses automatically based on incoming URLs or cache-control headers.

Instead, your application publishes prepared data into named scopes, and Caddy can serve that data directly.

application -> ScopeCache scope/id -> Caddy serves it

Core model

ScopeCache stores items inside scopes.

A scope is a named partition, similar to a namespace or bucket. Each scope contains an ordered collection of items. Items are addressable only through official top-level fields:

  • scope — required partition key
  • id — optional stable application-owned identifier
  • seq — cache-owned sequence number, monotonically increasing per scope, assigned by ScopeCache on every append
  • ts — cache-owned microsecond timestamp, set by ScopeCache on every write; observability only, not searchable and not used for ordering
  • payload — required JSON value, treated as opaque application data

ScopeCache does not inspect the payload for filtering or querying. Filtering, addressing, and cursoring only operate on official top-level item fields. IDs, when present, are plain strings whose meaning is decided by the application.

Limited filtering, flexible access

ScopeCache deliberately avoids becoming a query engine.

There is no query language, no joins, no arbitrary predicates, no sorting DSL, and no payload inspection beyond validation at write time.

Instead, flexibility comes from creating materialized views ahead of time:

thread:34
user:42:inbox
user:42:unread
tenant:acme:thread:34

If you need “all unread notifications for user 42”, your application stores that view under a scope such as:

user:42:unread

ScopeCache then serves that scope quickly. It does not search through payloads to discover it.

Although filtering is limited, ScopeCache remains flexible — and usable for most real-world use cases — through scope and ID naming. Instead of searching inside payloads, the application encodes access patterns directly into scope names.

Because each scope is ordered by its cache-assigned seq, retrieving the latest 100 unread messages for that user is a native operation:

/tail?scope=user:42:unread&limit=100

ScopeCache combines key-value style access with ordered per-scope collections. Direct lookups by id or seq behave like simple key-value reads, while the built-in sequence order makes operations such as tail, head, and since(seq) natural core primitives.

Main use cases

1. Read cache

ScopeCache can serve hot dynamic data directly from Caddy.

This is useful when normal HTTP response caching is inconvenient or too coarse-grained, for example:

  • private user-specific data
  • frequently changing thread data
  • inboxes
  • notifications
  • counters
  • pre-rendered fragments

Your application prepares the data and writes it into ScopeCache. Caddy then serves selected reads without involving the application runtime.

2. Write buffer

ScopeCache can also buffer high-frequency events such as analytics hits, log lines, or chat messages.

A worker can drain the buffer in batches using endpoints such as /tail and /trim, then process, persist, or forward the data elsewhere.

3. Fronting proxy for prepared content

ScopeCache can serve cached HTML, JSON, XML, or text directly through /get (its default raw mode returns the stored payload bytes, no JSON envelope). To assemble a page from many fragments in one network round-trip, POST /get_many fetches a batch of point-targets and returns a positional array (or, with ?envelope=true, per-target hit/item).

This may look similar to an HTTP response cache, but the model is different. ScopeCache does not decide what to cache from incoming requests. Your application precomputes the content, stores it under a scope and ID, and Caddy serves it directly when requested.

Core endpoints

The core HTTP API is intentionally small.

  • Read: get, head, tail
  • Write: append, upsert, update
  • Bulk / load: warm, rebuild
  • Cleanup: delete, trim, drop_scope, flush
  • Observe: stats, scopelist, help

The exact endpoint contracts are documented in docs/scopecache-core-rfc.md.

Why ScopeCache exists

A lightweight in-memory datastore for hot views

Redis is excellent software. But for narrow hot-read patterns, it can be more infrastructure than the workload requires.

Redis is often used to reduce pressure on the database, but the request still usually passes through the application layer:

web server -> application -> Redis -> application -> response

ScopeCache is built for cases where the cache can safely be disposable, rebuilt from the source of truth, and served directly from the web server process.

For suitable read paths, ScopeCache can reduce pressure on both:

  • the database
  • the application runtime

That means fewer moving parts, lower per-request overhead, and higher HTTP throughput for the specific paths that fit this model.

FrankenPHP

The other major motivation for ScopeCache is FrankenPHP.

FrankenPHP shows how powerful a Caddy-based architecture can be when more of the web stack runs together. Its worker mode improves PHP performance by keeping application workers alive in memory, avoiding much of the overhead of traditional per-request PHP execution.

FrankenPHP also makes distribution simpler: a PHP application can be packaged into a single binary that includes Caddy and Mercure for Server-Sent Events. No separate installation and configuration of a web server or PHP runtime is required.

But when Redis is required for optimal performance, that distribution model becomes less self-contained. Redis remains a separate service that must be run, secured, monitored, and maintained. It also cannot be compiled into the same single binary as the PHP application, web server, and Mercure.

FrankenPHP reduces one service boundary by bringing the PHP runtime closer to Caddy. ScopeCache applies a related idea to data: keep frequently accessed data inside the web server process and avoid an additional cache-service roundtrip on the read path.

Because ScopeCache is a Caddy module, it can be compiled into the same custom FrankenPHP/Caddy binary as Caddy, the PHP runtime, and the SSE hub.

PHP extensions written in Go

One important advantage of FrankenPHP’s design is that PHP extensions can be written in Go. A small Go file with //export_php:function directives can be processed by a generator, making Go functions available in PHP as native function calls.

Because FrankenPHP and Caddy are tightly integrated, Caddy, PHP, and ScopeCache run inside the same OS process: one PID, one address space.

The result is a much cheaper PHP-to-cache path: there is no need for a cURL call and a loopback HTTP request. Reads such as the PHP function scopecache_get_by_id() can run in-process and reach millions of calls per second, while still sharing the same ScopeCache Gateway used by the Caddy module.

// scopecache_append(scope, id, payload)
scopecache_append('users', 'alice', json_encode(['name' => 'Alice', 'age' => 31]));
// {"ok":true,"created":true,"item":{"scope":"users","id":"alice","seq":1,"ts":1715600000123456}}

// scopecache_get_by_id(scope, id)
$json = scopecache_get_by_id('users', 'alice');
// {"name":"Alice","age":31}

There is still a PHP-to-Go extension boundary, including type conversion. But the path is much shorter than a typical PHP-to-Redis lookup.

The extension lives in addons/frankenphp-ext/ — it documents the full set of 20 PHP functions, build steps, and the shared-Gateway model.

To fully unlock the potential of FrankenPHP, you need more than an embedded PHP runtime. You also need application data that can live inside the same process as the application code. ScopeCache is built for that role.

Internals

Storage model

ScopeCache’s internal storage model is deliberately simple.

Each scope owns one ordered slice of items, stored in append order. Around that slice, ScopeCache maintains lightweight hashmap indexes for direct lookup by id and seq.

Conceptually, the core shape is:

type Item struct {
    Scope   string  // required partition key
    ID      string  // optional, application-owned identifier
    Seq     uint64  // cache-assigned, monotonic per scope
    Ts      int64   // cache-assigned UnixMicro timestamp
    Payload []byte  // opaque JSON
}

type scopeBuffer struct {
    items []*Item             // primary storage, in append order
    byID  map[string]*Item    // id  -> item
    bySeq map[uint64]*Item    // seq -> item
    mu    sync.RWMutex        // one lock per scope

    lastSeq        uint64     // scope metadata: next seq to assign on append
    bytes          int64      // scope metadata: running total of stored item bytes
    createdTS      int64      // scope metadata: first-write timestamp (UnixMicro)
    lastWriteTS    int64      // scope metadata: most recent write touch
    lastAccessTS   int64      // scope metadata: most recent read touch
    readCountTotal uint64     // scope metadata: lifetime read counter
}

The slice is the ordered storage. It defines the physical order of the data in memory and makes operations such as head, tail, and cursor-based reads natural.

The maps exist to avoid scanning. A lookup by id or seq is an O(1) hashmap lookup on average, independent of the number of items in the scope.

The slice and both maps hold pointers to the same items, so each item lives in memory once, no matter how many indexes address it.

The scope metadata fields exist for two reasons. lastSeq drives the seq-assignment on the next append, and bytes lets the store-wide byte budget admit-or-reject writes without re-scanning items. The four timestamp/count fields surface on /stats and /scopelist so an operator can see per-scope activity at a glance.

A classical key-value store is conceptually built around:

key -> value

Ordering is not part of that basic model. If you want “the latest 10 items”, you usually build that on top with lists, streams, sorted sets, timestamps, or secondary indexes.

ScopeCache starts from a different shape:

scope -> ordered collection -> indexed items

Each scope is an ordered collection first, with direct lookup indexes around it. That is why operations such as head, tail, and cursor-based reads are native core primitives rather than conventions layered on top of a flat keyspace.

Locking and sharding

Internally, the top-level store is sharded by scope name. A request may briefly touch a shard-level lock to find the scope buffer. After that, the operation is handled by the scope’s own buffer.

That means unrelated scopes do not block each other during normal per-scope operations.

scope "thread:1" -> own buffer -> own lock
scope "thread:2" -> own buffer -> own lock
scope "user:42"  -> own buffer -> own lock

Reads share a per-scope read lock, so multiple reads on the same scope can run concurrently. Writes take the per-scope write lock for the duration of the mutation.

This matches the data model: scopes are not only names, but natural concurrency partitions.

Performance shape

A request such as:

GET /get?scope=X&seq=N

resolves conceptually as:

scope lookup -> bySeq lookup -> item

Both lookup steps are O(1) on average. Ordered reads such as /head and /tail walk the items slice instead.

A single in-process lookup can take tens of nanoseconds. In one benchmark, getBySeq took about 43 ns per lookup on a single CPU core, roughly 23 million lookups per second. Because each scope has its own read/write lock, reads on different scopes scale independently across cores; reads on the same scope share a read-lock and also run concurrently.

These internal numbers refer only to the in-process lookup itself. They do not include Caddy routing, HTTP request parsing, response writing, JSON encoding, or network overhead.

The larger HTTP benchmark numbers in this README measure complete request paths.

Addons and extension model

ScopeCache exposes a public Go API through *Gateway. This documented boundary makes it easier to build focused addons without reaching into internal types such as *store or *scopeBuffer, with all their implementation details and complexity.

The Gateway provides a stable boundary around the cache. It performs uniform validation, applies defensive cloning where needed, and hides the internal implementation details from addons.

Built-in convenience mechanisms

ScopeCache includes one convenience mechanism around the core: a warm-up script hook that runs when the web server starts or reboots after a crash. The script is an external executable that talks to ScopeCache over a per-instance private Unix socket, populates the cache from your source of truth (database, JSON file, etc.), and exits. The public socket only opens after the script returns, so cold-start traffic never sees an empty cache.

Configured via the init_command field on the Caddy module.

Quickstart: Docker

Run Caddy with ScopeCache baked in on localhost:8081:

git clone https://github.com/VeloxCoding/scopecache.git
cd scopecache
docker compose up -d --build caddyscope
curl http://localhost:8081/help

The bundled Caddyfile.caddy-scopecache is already wired for GET and POST:

:8080 {
    scopecache {
        scope_max_items 100000
        max_store_mb    100
        max_item_mb     1
    }
    respond 404
}

Explanation:

  • :8080 { ... } — Caddy listens on port 8080 inside the container; Docker Compose maps that to 8081 on your host.
  • scopecache { ... } — ScopeCache endpoints are mounted at /, so GET /help, GET /tail, POST /append, and other endpoints are available.
  • respond 404 — anything ScopeCache does not recognize returns 404.
  • scope_max_items, max_store_mb, and max_item_mb are capacity limits.

Example: POST and GET round-trip

# Write an item.
curl -X POST http://localhost:8081/append \
  -H 'Content-Type: application/json' \
  -d '{"scope":"demo","payload":{"msg":"hello"}}'

# Read it back.
curl 'http://localhost:8081/tail?scope=demo'

The xcaddy build recipe for a custom Caddy binary lives in Dockerfile.caddy-scopecache.

Quickstart: VPS install FrankenPHP-ScopeCache binary

A prebuilt binary is available for convenience. It includes FrankenPHP, ScopeCache, and the ScopeCache PHP extension functions.

It runs on Linux x86_64 with zero host dependencies:

# 1. Download
curl -L -o frankenphp-scopecache \
  https://github.com/VeloxCoding/scopecache/releases/download/v0.8.58/frankenphp-scopecache-static-linux-amd64

# 2. Make executable + move onto PATH
chmod +x frankenphp-scopecache
sudo mv frankenphp-scopecache /usr/local/bin/

# 3. Run it
frankenphp-scopecache run --config /path/to/your/Caddyfile

The binary is kept as frankenphp-scopecache rather than plain frankenphp so the descriptive name (custom FrankenPHP build with the scopecache module + PHP extension baked in) survives the install, and so it cannot collide with a separately installed upstream frankenphp.

Performance

Benchmark results are hardware-dependent. The numbers below were measured on a stock UpCloud VPS: AMD EPYC 9575F (Zen 5), 8 vCPU, 16 GB RAM, Ubuntu 26.04 — no special tuning, single host running both the bench load and the server under the standard kernel scheduler.

The point of this comparison is the relative gap between request paths, not the absolute throughput. Both numbers will scale roughly linearly with core count on a larger machine.

HTTP path

wrk -t4 -c64 -d3s against a pre-seeded 50,000-item scope, random ?id= lookups, 5-run median. ScopeCache is the v0.8.60 caddyscope binary; both wrk and caddyscope share the same 8 cores under the kernel scheduler.

Route Requests/sec p50 latency
Caddy → ScopeCache (in-process) 209,000 180 µs

For reference, a separate prior benchmark on a different host compared this in-process path to a Caddy/FrankenPHP-worker → Redis route under identical load and found roughly a 9× throughput gap. That comparison isn't reproduced on this VPS (no Redis installed), but the architectural reason hasn't changed: the in-process path simply has fewer hops in the request path.

cgo path (FrankenPHP extension)

When PHP calls ScopeCache directly through the FrankenPHP extension — bypassing the HTTP stack entirely — the same lookup runs much faster. Single PHP thread, pre-warmed, hrtime(true) around a tight loop.

A random read over 50,000 items is the realistic cache workload — every call picks a different id/seq, so CPU caches and branch predictors stay cold across calls:

Call path Ops/sec Per call
scopecache_get_by_id() (random over 50k) 3.29 M 304 ns
scopecache_get_by_seq() (random over 50k) 3.51 M 285 ns

A hot loop hammering the same key is the best-case floor — the in-process cgo + scopecache cost when everything is L1-resident:

Call path Ops/sec Per call
scopecache_get_by_id() (same id, hot loop) 7.0 M 143 ns
scopecache_get_by_seq() (same seq, hot loop) 7.1 M 140 ns

See addons/frankenphp-ext/ for the cgo path.

This is an architectural comparison. The difference is in the request path: a typical PHP or Node route has to leave the webserver, enter the application runtime, call another service such as Redis or a HTTP/cURL, and then build the response. ScopeCache can remove those extra hops for selected hot reads by running inside the Caddy process, and the FrankenPHP extension removes the local HTTP/cURL hop when PHP needs to call ScopeCache directly.

Status

ScopeCache is pre-1.0.

The core HTTP and Go API surfaces may still change between minor versions. After v1.0, the core API is intended to become semver-stable.

Building from source

go build -o scopecache ./cmd/scopecache
go test ./...

Module path:

github.com/VeloxCoding/scopecache

ScopeCache currently uses only the Go standard library.

Documentation

The full design, endpoint contracts, and architectural rationale live in docs/scopecache-core-rfc.md.

License

Apache License, Version 2.0. See LICENSE.

Copyright 2026 VeloxCoding.

About

ScopeCache: a small in-process datastore, cache, and write buffer for Caddy, written in Go.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors