[← Blog]

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.

WHERE THE LIFETIME LIVES PUBLISHED SCHEMA fields · key layout immutable · shared ADOPT YOUR NODE NODE-LOCAL STATE RETENTION POLICY ttl = 30d set by the owner THE SCHEMA TRAVELS. THE LIFETIME DOES NOT.
Fig. 1 — A published schema is immutable and shared. The retention policy is a record in node-local state, set by the node owner.

Three reasons, in order of weight:

  1. 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.
  2. 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.
  3. 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.

ONE SWEEP PASS TIME (RANGE KEY) CUTOFF = NOW − TTL EXPIRED KEPT BACKUP CUT HELD? YES → SKIP THIS TICK NO PURGE ≤ 64 keys per batch AUDIT ROW STATUS: policies · passes · rows reaped · failures · hold time per policy SAME HARD-DELETE PATH AS A USER DELETE. NEVER CREATES AN ABSENT SCHEMA.
Fig. 2 — Expired keys are those below the cutoff. The sweep skips while a backup snapshot is held, then purges in bounded batches through the audited hard-delete path.

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 − ttl is 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 status shows the policy count, passes, rows reaped, failures, and the exclusive hold time each policy cost. The kill switch is LASTDB_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.

THREE PLANES FOR ONE SOURCE SOURCE a node, a job, a device CURRENT STATE Hash(source) · one row, overwritten no lifetime needed TTL 30d ROLLUPS HashRange(source#bucket, period) one bounded row per period CHUNK MANIFEST RAW SAMPLES compressed chunk in file storage, manifest row in LastDB · TTL 90d record one row sealed chunk next period, not yet written
Fig. 3 — One source, three planes. Current state is one row. Rollups are bounded rows with a lifetime. Raw high-rate samples are sealed chunks with a manifest row.

For anything that pulses, the shape is three planes:

  1. 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.
  2. 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.
  3. 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

[← Blog]