Since the first release, Lithair had one load-bearing constraint: every item you registered with with_model lived in RAM, in full, for the lifetime of the server. No eviction, no on-demand reload. The README said it plainly, and every “is this the right tool?” answer hinged on it — if your dataset didn’t fit in memory, the answer was no.

v0.12.0 changes that answer. The framework is still memory-first, but it is no longer memory-only.

What shipped

Retention tiering — #[retention(...)] and #[pinned] (PRs #96, #99, #100, #101)

A model can now declare how much of itself stays projected in RAM and which fields survive eviction:

#[derive(DeclarativeModel, Serialize, Deserialize, Clone, Debug)]
#[retention(memory = 1000)]        // keep the most recent 1000 items hot
pub struct Email {
    #[pinned] pub from: String,    // stays in RAM after eviction
    #[pinned] pub subject: String, // stays in RAM after eviction
    pub body: String,              // evicted with the item, reloaded on demand
}

Three retention dimensions, and they compose:

  • memory = 1000 — count-based: cap the number of fully-hot items.
  • memory = "30d" — duration-based: evict items older than the cutoff (s/m/h/d/w/y).
  • max_mb = 512 — budget-based: evict oldest until the hot set fits the byte budget.

When an item is evicted, its #[pinned] fields stay behind in a lightweight warm map. Listing and filtering on pinned fields stays instant — you can still page through subjects and senders without touching disk. The non-pinned fields (body here) are dropped from memory and reloaded from the event store when something actually reads that item, via a reverse scan that short-circuits on aggregate_id so it doesn’t deserialize unrelated events.

The whole thing stays 100% event-sourced. There is no second database, no separate query path, no cache to invalidate. The event log was already the source of truth; retention just stops insisting that the entire projection live in memory at once.

Tune it at deploy time, no recompile. Each dimension has an environment override, so the same binary can run with different retention on different hosts:

LT_EMAIL_MEMORY_RETENTION=5000     # count
LT_EMAIL_MEMORY_DURATION=90d       # duration
LT_EMAIL_MEMORY_MAX_MB=2048        # budget

The model name is the last segment of the type name, uppercased.

The lead-up: v0.10 and v0.11

Retention is the headline, but two SSE releases landed first and are worth recording.

  • v0.10.0 wired the builder-level SSE broadcaster onto handlers registered through with_handler (and therefore with_model_ref) at serve() time. Before that, programmatic writes from a background worker silently didn’t reach /api/{model}/stream subscribers — the broadcaster was only attached on the with_model factory path.
  • v0.11.0 stopped buffering. The route layer used to collect each handler’s body into a Full<Bytes> before handing it to hyper, which meant an infinite SSE stream delivered zero events until the connection closed. RouteResponse moved to a BoxBody, the collect().await calls came out, and events now stream incrementally as they happen.

Together they close the live-update story: writes from HTTP, from a programmatic handle, or replicated from a peer all broadcast on the same per-model channel, and subscribers see them immediately.

Why it matters

The memory-only constraint was honest, but it was also the most common reason to walk away from Lithair. “Fits in RAM” is a real ceiling for audit logs, mail archives, and anything with a long cold tail. Retention tiering doesn’t pretend that ceiling away — the hot working set should still fit comfortably in memory — but it lets the cold tail spill to the log it was already being written to, and come back when needed.

It is a deliberately small surface. You annotate the struct, you optionally pin a few fields, and the framework handles eviction and reload. There is no new storage engine to operate.

What we learned

This was the release where automated review earned its place. Two of the fixes folded into v0.12.0 were correctness bugs caught in review before they shipped:

  • A warm/hot desync under contention: promote_from_warm ran before try_entry, so a failed acquisition could clear the warm entry without registering anything hot — silent data loss. The promotion now happens inside the success branch, paired with the eviction bookkeeping.
  • A duplicate-on-update: updating an evicted item briefly left it in both the warm and hot maps, and the list endpoint emitted it twice. The warm entry is now cleared before the hot insert, preserving the exactly-one-place invariant.

Neither would have surfaced in a quick manual read. Both came out of review on the PRs that built the feature, which is the kind of thing that matters more on storage code than anywhere else — a desync here isn’t a rendering glitch, it’s a lost record.

Upgrade notes

lithair-macros is bumped to 0.12 because the derive now declares retention and pinned as helper attributes; without recompiling, the compiler silently ignores them. If you don’t annotate anything, behavior is identical to v0.11 — every model stays fully in RAM, exactly as before. Retention is entirely opt-in.

[dependencies]
lithair-core = "0.12"