Rows That Expire
LastDB can now give a schema a lifetime. One command sets it. The node erases rows older than that lifetime on its own, through the same hard-delete path a user delete takes. That one knob changes what belongs in the database. Telemetry, health samples, job history, and other data that is useful for a while can now live in LastDB without growing it forever.
lastdb schema-retention set --schema MetricRollup --ttl 30d lastdb schema-retention get --schema MetricRollup lastdb schema-retention clear --schema MetricRollup
The question
The request, in August, was one sentence: store short-lived telemetry in LastDB and make sure it does not keep making the database bigger.
We already had most of the parts. A hard delete existed. It erased a record from every read path at once and wrote an audit row. A small reaper existed too. It drained two of the node’s own internal telemetry series down to a row cap on a timer, settled when it found nothing, and gave up loudly after repeated failures. A TTL is that reaper generalized on two axes: it counts time instead of rows, and it applies to any schema instead of two hard-coded ones. The mechanism was three to five days of work. The decisions around it took longer.
The rule we had to read again
LastDB has a settled rule about delete: it erases now. No grace period, no trash can, no timer. A TTL looked like a timer, so the first agent assigned the work stopped and asked.
The two are opposites. The delete rule protects a decision the user made: when you delete a record, nothing waits around to be reaped later. A TTL schedules an erasure nobody asked for, on data nobody wrote by hand. The first rule forbids a timer that delays an erasure. The second adds a timer that causes one. We wrote that distinction into the rule itself, so the next agent to read “no retention timer” beside a TTL task does not stall the same way.
Where the lifetime lives
The design went through three drafts in two days. The first draft put a ttl_seconds field on the published schema definition. We rejected it, for a reason that turned out to be wrong: we said the field would change the schema’s identity hash and stop two otherwise-identical schemas from merging. We then read the identity algorithm instead of the struct. Retention never enters it. The objection was asserted from the shape of the code, not from what it computes.
The second draft split it: a retention class on the schema, an enforced window on the node. The third draft is the one that shipped, and it is simpler than both. A lifetime is always a local setting. Nothing about retention enters the published schema, its content hash, or the signals that decide whether two schemas are the same thing.
Three reasons, in order of weight:
- Sovereignty. LastDB is local-first and user-owned. A lifetime declared on the published schema would let a remote schema author schedule deletion on your machine. Deletion is the one operation where the node owner’s say should be final. The earlier draft optimized for “the declaration travels” and undervalued this.
- Correctability. Published schemas are immutable. A mistaken one-hour lifetime on data that needs ninety days would be frozen forever. A local setting is one command to fix.
- Precedent. Retention is a deployment property in every comparable system we could name: metric stores, log pipelines, message queues, log rotation. None of them put it in the data definition.
The cost is that the declaration does not travel. A second node that adopts the same schema learns nothing about its intended lifetime, and each operator sets each series by hand. We accepted that cost. A proposal where an app proposes a default at install time and the node owner keeps the final say is written down but not built.
One sweep pass
A policy is a small record in the node’s local schema state: the schema name, the lifetime in seconds, and for partitioned series the list of partitions it covers. The node’s periodic self-check starts a sweep. One sweep runs at a time.
The sweep keeps every safety property the old reaper had:
- It never creates a schema. A node that never wrote a series must not be given one by the thing meant to shrink it.
- It skips while a cloud backup snapshot is in progress, so a backup never sees half a purge.
- It selects expired keys without a scan. Time-ordered series use their range key: every key below
now − ttlis expired. Keyed and single-record schemas use a small written-at index the write path maintains. - It purges at most 64 keys per batch, through the same hard-delete path a user delete takes, and each batch writes an audit row.
- It reports.
lastdb statusshows the policy count, passes, rows reaped, failures, and the exclusive hold time each policy cost. The kill switch isLASTDB_TTL_SWEEP=0.
The first pass reaped zero rows
On August 26 we set four policies on the run history of our own scheduled agent jobs and asked how much came back. The answer was zero. Four separate causes, all real, none of them the purge path:
- A partitioned policy stored with an empty partition list produced zero filters. The sweep did nothing and said nothing.
- One series used ISO timestamp strings as its range key. The cutoff is a zero-padded number. A string like
2026-08-…never sorts below a twenty-digit number, so nothing ever expired. - One series had a single live row. Its 30 MiB was version churn under that row, which a lifetime cannot touch. That is a separate retention window on superseded versions, shipped the same week.
- The sweep latched settled after its first empty pass, and nothing released the latch. Even the corrected policy waited for a restart.
After a supervised restart the first real reap ran: 5,430 rows purged in 64-key chunks over about 35 minutes, node healthy throughout. The latch fix landed a few days later. The sweep now re-checks its policies on every tick, because time and local policy both change without a restart.
Today the primary node reports enabled=true policies=3 passes=1065 failures=0 settled=false. The constraints that remain are stated, not hidden. A time-ordered series needs a numeric, zero-padded time as its range key. A partitioned policy needs an explicit partition list, so a dynamic set of millions of partitions is not a fit for this sweep yet. And a purge makes rows unreachable; the disk comes back at compaction, not at purge time.
What this lets us keep in LastDB
Before this, telemetry-class data had two bad homes. Keep it in LastDB and watch the store grow without bound. Keep it somewhere else and lose the point-read, the sync, and the encryption at rest that the rest of the app already gets. A lifetime gives it a third home. It also forced a rule we now apply to every new schema: shape follows lifecycle. A fact has to justify its key, its write rate, its history, and its physical row count before it gets a schema.
For anything that pulses, the shape is three planes:
- Current state — one keyed row per source, overwritten in place. A heartbeat, a lease, the last known status. It never grows, so it needs no lifetime.
- Rollups — one bounded row per fixed period, keyed by source and time bucket, with a lifetime. This is where a low-rate series lives directly.
- Raw samples — a high-rate series never gets one permanent row per pulse. It goes into compressed chunks in file storage, with a manifest row in LastDB that carries the chunk reference, count, checksum, and time bounds. The manifest carries the lifetime.
We run LastDB on LastDB, so the first consumers were our own. The node’s own health samples live in the database it serves and now expire after thirty days. Our fleet of scheduled agent jobs writes a summary of every run and a current status per job; those keep ninety days. None of that data needed a second system. All of it would have been a slow leak without a lifetime.
What a lifetime is not
A lifetime bounds the live history. It does not make a row cheaper while the row exists, and it does not return bytes the moment a row expires. If a series is too hot to store one row per sample, a lifetime does not fix that; a rollup or a chunk does. A lifetime is a design input, in the same sentence as the key layout. Name the fact, name the reads, state how long it lives and who owns that number, and estimate the rows. Then set the policy.
Related: Thin Tips and Honest History · Last Store · Blog index