Skip to content

A cache in this process

nilo_cache is an expiring cache in the memory of the program that opened it: a cart looked up on every request, a rendered page, the answer from somebody else's API that does not change for five minutes. A tool module: no event loop, no allocator after open, and it imports nothing, so zig test cache/cache.zig runs the whole of it and a program that is not a server can take it on its own (ADR 109).

It does not leave this process. Two instances of your program have two caches that do not agree, neither survives a restart, and nothing here reaches a network. That is the trade the module is for, and ADR 110 is where the other answer — a Redis client — was named and not built.

const cache = @import("nilo_cache");

and in build.zig, beside nilo_http:

.{ .name = "nilo_cache", .module = nilo.module("nilo_cache") },

The whole of it

A Space is a named, typed keyspace — the cache's answer to what a Bucket is to an object store. Declare it once beside your other types:

const Cart = struct { owner: u64, items: u16, total_cents: u64 };

const Carts = cache.Space("cart", Cart, .{ .ttl_s = 300 });

Open the Store once, where the program starts, and the Spaces off it:

store = try cache.open(gpa, .{ .bytes = 64 << 20 });
defer store.deinit();
carts = Carts.open(&store);
try app.provide(&carts);

And wherever the work is:

fn cart(carts: *Carts, owner: u64) !Cart {
    var key: [24]u8 = undefined;
    const name = try std.fmt.bufPrint(&key, "u{d}", .{owner});

    if (carts.get(name)) |found| return found;

    const fresh: Cart = .{ .owner = owner, .items = 0, .total_cents = 0 };
    carts.put(name, fresh);
    return fresh;
}

A Space is a value a handler holds — by pointer, as a service, or by value inside one. Two Spaces over one Store share its memory and cannot read each other's keys: the name is hashed while compiling into the seed every key goes through, and stored in each entry, so a fingerprint collision cannot cross a Space boundary.

cache.open(gpa, .{ .bytes = n }) !Store — all the memory, taken here and never again
cache.Space(name, V, .{ .ttl_s = s }) a keyspace, as a type
Space.open(&store) the value a handler holds
space.put(key, value) for the Space's ttl_s
space.putFor(key, value, ttl_s) for a life of its own. 0 is until the ring writes over it
space.get(key) ?V for a flat value; ?[]const u8 and a *Held for bytes
space.del(key) bool — was there anything to forget
space.putIfAbsent(key, value) store only if the key is free, and say whether it was. One lock around the scan and the write, so two callers racing get one true between them — a claim, not a get and a put. What nilo.Idempotent claims a key with
space.getInto(key, buf) the bytes, into a buffer of your choosing rather than a Held — for a caller whose buffer is the request arena
store.stats() hits, and the three different ways of missing
store.bytesHeld() every byte it will ever hold, and it never moves
store.shardCount() how many it got, which is at most the shards asked for
store.clear() forget everything

get answers null for every kind of not-here — never written, written and expired, written and evicted — and stats() is what tells those apart.

Space options

The third argument to cache.Space:

Field Default
ttl_s 0 seconds an entry lives. Zero means until the ring writes over it, which for a cache is a perfectly good answer — nothing here sweeps
max_bytes 4096 the largest value a []const u8 Space will hold, and the size of its Held. Read only for that shape

An empty name is a compile error, because the name is what keeps one Space's keys out of another's. So is a []const u8 Space with a max_bytes of zero, or of more than 65,535 — the length is stored in sixteen bits so that four ways of a bucket are one cache line.

The value type decides the shape of get

The value get
flat — a number, an enum, a struct or array with no pointer anywhere in it get(key) ?V — by value, no buffer anywhere
[]const u8 get(key, &held) ?[]const u8 — into an array you declared

A flat value has a size known while compiling, so it comes back by value and nobody declares anything. Bytes do not, so the Space says how large one can be and hands out the array to read into:

const Pages = cache.Space("page", []const u8, .{ .max_bytes = 4096 });

fn page(pages: *Pages, path: []const u8) ![]const u8 {
    var held: Pages.Held = undefined;
    if (pages.get(path, &held)) |cached| return cached;
    const html = "…";
    try pages.put(path, html);
    return html;
}

Held is your stack, and stack is held per connection for the life of it (ADR 062). A handler declaring a 4 KiB Held has added 4 KiB to every connection that reaches it. It is written as an array you declare rather than a buffer the cache hides because that is the only way the number is yours to see. A put of bytes is the one call that can fail: error.TooLarge when the value is over max_bytes, or over a quarter of one shard's ring.

A value with a pointer in it is a compile error, and the field is named. A cache entry outlives the call that wrote it — that is the entire point of one — so a slice stored in it would point at a request that has ended. Go's cache stores interface{} and gets away with it because a collector holds the other end; there is none here. Encode it and use a Space of []const u8, or keep an id in the cache and look the rest up.

JSON is the ordinary encoding once the value has more than one field. put takes the bytes std.json.Stringify wrote into a buffer of your own; getInto reads them back into a buffer of your own too — here the request arena, since the value is going out in a response — and std.json.parseFromSliceLeaky turns them back into the struct:

const Note = struct { owner: u64, text: []const u8 };

const Notes = cache.Space("note", []const u8, .{ .max_bytes = 256 });

fn saveNote(notes: *Notes, key: []const u8, note: Note) !void {
    var buf: [256]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    try std.json.Stringify.value(note, .{}, &w);
    try notes.put(key, w.buffered());
}

fn loadNote(notes: *Notes, key: []const u8, c: *nilo.Ctx) !?Note {
    const buf = try c.arena().alloc(u8, 256);
    const bytes = notes.getInto(key, buf) orelse return null;
    return try std.json.parseFromSliceLeaky(Note, c.arena(), bytes, .{});
}

parseFromSliceLeaky rather than parseFromSlice, because there is nothing here to call .deinit() on — the arena is what frees it, the same rule every row this module hands back already follows.

There is no allocator to pass anywhere in this module, and the signatures are what say so: nothing allocates per operation.

A Space of integers counts

Five OTPs per phone number an hour, failed sign-ins per email, a quota per API key: a count under a key, and get then put loses one whenever two requests land between them. A Space whose value is an integer has incr (ADR 109):

const Attempts = cache.Space("signin", u32, .{ .ttl_s = 3600 });

fn signIn(attempts: *Attempts, email: []const u8) !void {
    if (attempts.incr(email, 1) > 5) {
        return nilo.fail.tooManyRequests("too many attempts; try again in an hour", .{});
    }
    // … check the password, and `attempts.del(email)` when it matches …
}

incr(key, delta) answers the new count, and the read, the add and the write happen under the shard's lock — the same lock a put's copy is under, and one add is not a wait, which is the sentence the rule in ADR 109 turned out to be about. Two requests arriving at once count two. A key nobody wrote counts from zero and lives ttl_s; one already there keeps the expiry it had, so the hour above is the hour of the first attempt rather than a window that slides with every one, and del is what opens it early. The arithmetic saturates: a counter at its type's ceiling stays there rather than wrapping to zero and opening the quota again, and incr(key, 0) reads the count under the lock. delta is the Space's own type, so an unsigned Space counts up only; one that has to count down is a Space of i64.

nilo.Allowance is the same thing keyed by address and nothing else; this is the one to reach for when the key is a user, a phone number or an API key. A Space of anything but an integer has no incr, and says so while compiling.

Store options

Given to cache.open:

Field Default
bytes 8 MiB the whole budget, and a ceiling rather than a target. The ring the values live in and the table that points at them come out of it together, and bytesHeld() is never above it
entries derived how many entries the table can point at, when the five sixths the ring takes by default is the wrong split. Clamped to the budget rather than added to it — raise it for many small values, lower it for few large ones
shards 64 how many independent tables and rings, and so how many writers can be inside at once. A fixed number rather than the core count, so the same program holds the same memory on two machines

open answers error.TooSmall when the numbers do not divide into a working cache — under 64 KiB of value memory, or fewer entries than the shards have ways to hold them — and error.OutOfMemory when the machine will not give the budget.

One number decides the memory and it never moves. Nothing is allocated after open, nothing grows, and there is no sweep: an entry goes when its time is up or when the ring writes over it. That number is the whole of it, which is worth saying because the two Go caches of this shape mean something narrower by it — they bound their values and put the index on top, unbounded, so 200,000 entries on a 12 MiB budget cost them 25.2 and 28.5 MiB of RSS against this module's 12.0 (bench/result/cache.md).

Sizing it

Ask for the hit rate you want rather than for a multiple of the data. Measured on a Zipf 0.99 workload — which is what traffic looks like — a ring at a fortieth of the working set answers 63.9% of lookups and one at a fifth answers 94.1%, and both are 96–98% of what a cache that size could reach at all. Pick a budget, run it, read stats().

An entry costs about 20 bytes over its value: 8 of table slot, 12 of header, and the key. 64.3 bytes an entry on 200,000 of them, against go-cache's 100.2, freecache's 132.0 and bigcache's 149.4.

A new entry has to be asked for twice before it gets the run of the ring (ADR 109). It lands in a tenth of the ring and is copied into the rest when something reads it again, so a flood of keys nobody asks for twice cannot flush what the cache is holding. Two things follow that are worth knowing rather than discovering: a cache with room still admits freely, and a cache written to and never read holds its first entries indefinitely rather than forgetting the oldest.

Why is it not hitting

store.stats() counts hits apart from the three ways of missing, which is the question every cache eventually gets asked:

Counter
hits
misses nothing in the table under that key — it was never written, or the key is wrong
evicted the table knew the key and the ring had moved past it. This is the number that says the ring is too small
expired found, and past its time
puts
refused a value that did not fit an entry, so nothing was stored
rescued warm entries a read moved out of the write cursor's way. This is the number that says the policy is doing something
evictionRate() of the lookups that found nothing, how many were the ring being small

High evictionRate() means the cache wants more bytes; low, with few hits, means it is being asked about keys nobody wrote. The counters are exact and the reading is not a snapshot — nothing is locked while they are summed, because a lookup takes no lock either.

What it costs

A get takes no lock at all (ADR 152): it copies the value out and then asks the ring's write cursor whether anything wrote over those bytes while it read them. Readers do not queue behind each other — 124.5M reads a second on eight threads, against 108.8M when they did. A put takes one lock, per shard, across a memcpy and nothing else.

That last rule is what makes the module safe to hold under a fiber. Zig's std.Io.Mutex needs an Io this layer has none of, so the lock spins — and a critical section with nothing in it that waits always finishes and releases. It is also why nothing that waits may ever go inside one, which is a constraint on the module and not on you.

Per request, nothing: a lookup is one cache line touched for the slot and one copy for the value, and the request path's allocation budget is untouched. Per connection, whatever Held you declared.

The TTL clock is the coarse monotonic one — a page the kernel updates on its own tick rather than a vDSO call — because an operation costing a hundred nanoseconds should not spend a fifth of it on accuracy a TTL measured in seconds has no use for.

A route that says "cache this answer for a minute"

The most ordinary use of a cache in a web app is a page that costs four queries and changes once a minute. That is one argument on the handler, and the handler is otherwise the one you were going to write:

const FrontPages = cache.Space("front", []const u8, .{ .max_bytes = 32 << 10 });

const Front = struct { headline: Str, stories: u32 };

fn frontPage(kept: nilo.Cached(FrontPages, .{ .ttl_s = 60 })) !Front {
    _ = kept;
    // …the four queries…
    return .{ .headline = .static("Selamat pagi"), .stories = 12 };
}
GET /                       → 200 {"headline":"Selamat pagi","stories":12}   Cache-Status: nilo; fwd=miss
GET /  (within a minute)    → 200 {"headline":"Selamat pagi","stories":12}   Cache-Status: nilo; hit
GET /?lang=en               → the handler runs: another query is another entry
GET /  (a minute later)     → the handler runs, and its new answer is the one kept

The first request runs the handler and keeps what it returned — the status, the body, and a Response(T)'s own headers — under the path and the query. Every request for the same inside ttl_s gets that back, byte for byte, and the handler does not run. What the handler failed with is not kept; the next request runs it again.

FrontPages is a bytes Space, opened on the Store and handed to the App as a service, the way Replays is — the record in it is the same record, and Cached is Idempotent with the key made of the request line instead of a header (ADR 188). The TTL is the route's rather than the Space's, so one Space may hold a page kept a minute beside one kept an hour.

When the entry expires, everybody does not run the handler. Twenty browsers asking for the front page in the same hundred milliseconds are what a cache is for, and a get followed by a put would run the four queries twenty times. The first request claims the key; the other nineteen find the claim and wait for its answer — reading again every 10 ms, for at most two seconds or half of what nilo.deadline left the route — and get it when it lands. One that waits the bound out runs the handler itself. This is the one thing nilo_cache cannot do on its own, because its lock spins and nothing that waits may go inside it; the server is the layer with an Io to wait on.

What the key is made of is .by: .path_and_query unless said, .path for a handler that ignores the query, or .{ .header = "Accept-Language" } for a page that answers differently per language — the same thing Vary says. The query is taken as it arrived, so ?a=1&b=2 and ?b=2&a=1 are two entries; nothing is normalised. Cookie and Authorization are refused as keys: a cache keyed on a credential is a session store with a stranger's answers in it. Answer per user without the cache.

GET and HEAD only. A kept answer is served to whoever asks next, and a POST's second client did not send the first client's body. app.post(…) refuses it while compiling; app.route(.POST, …) refuses it when the route is registered. For a write answered once per client, that is Idempotent.

It costs what Idempotent costs, on the route that asks and nowhere else: one arena allocation of max_bytes on a hit, one to encode the answer on a miss, and one to join the path and the query when there is a query. Nothing on the stack.

Testing

A Store opens on std.testing.allocator with any budget over 64 KiB, and a handler that takes a *Carts is an ordinary function:

test "a cart is remembered for the next request" {
    var store = try cache.open(testing.allocator, .{ .bytes = 1 << 20, .shards = 4 });
    defer store.deinit();
    var carts = Carts.open(&store);

    _ = try cart(&carts, 42);
    try testing.expect(carts.get("u42") != null);
}

An expiry is tested by the TTL rather than by waiting: putFor(key, value, 1) and a std.Thread.sleep of a second is the honest way, and the module's own suite does it once so that yours need not.

See also

  • The reference — the surface as a list.
  • Services — how the Space reaches a handler.
  • bench/result/cache.md — every number above, how it was run, and the single-threaded rows where go-cache is faster.
  • ADR 109 — why the value may hold no pointer and why the lock spins.