How RadishDB handles per-key time-to-live and background expiration
RadishDB supports per-key expiration using the same model as Redis: you attach a TTL (time-to-live) to a key when you set it, and the key is automatically deleted once that time has elapsed.Expiration is handled by two complementary mechanisms: passive expiry (delete on access) and active expiry (background sweep).
Pass EX seconds to SET to attach an expiration time:
>>> SET session:abc token123 EX 3600OK>>> TTL session:abc3599
Internally, RadishDB converts the relative seconds to an absolute Unix timestamp and stores it in entry->expires_at:
hashtable.c
// When SET ... EX seconds is received:entry->expires_at = time(NULL) + seconds;
A key set without EX has expires_at == 0, which means “never expires”.
RadishDB stores an absolute timestamp, not a countdown. This means TTL accuracy does not degrade if the process sleeps or is paused — the expiry time is fixed at the moment of the SET command.
Passive expiry happens inline during a read operation. When GET or TTL looks up a key, it checks the timestamp before returning the value:
hashtable.c (ht_get)
char *ht_get(HashTable *ht, const char *key) { // ... locate entry by key ... if (entry->expires_at != 0 && time(NULL) >= entry->expires_at) { ht_delete(ht, key); // delete and free immediately return NULL; // report as not found } return entry->value;}
If the key is expired, it is deleted from the hash table and its memory is freed before NULL is returned. From the caller’s perspective, the key never existed.Passive expiry alone is sufficient for correctness, but it cannot reclaim memory for keys that are never read again. That is the role of the active sweeper.
The active sweeper scans a portion of the hash table on every command, regardless of what command was issued:
expires.c
static size_t expire_cursor = 0;void expire_sweep(HashTable *ht, size_t max_checks) { // Scans up to max_checks entries starting at expire_cursor // Deletes entries where expires_at != 0 && expires_at <= now // Advances cursor through buckets (wraps around when it reaches end)}
Both frontends call expire_sweep(ht, 10) before processing each command:
repl.c / server.c
while (1) { expire_sweep(ht, 10); // scan 10 entries // ... read and execute command}
The cursor advances by 10 entries per command. For a database with 1,000 entries, the entire table is scanned approximately every 100 commands. Expired keys are deleted and their memory is freed immediately.
Passive expiry
Triggered on GET and TTL. Instant — the key is gone the moment you try to read it after its deadline.
Active expiry
Triggered on every command. Scans 10 entries per command cycle, advancing a cursor that wraps around the full table.
long ht_ttl(HashTable *ht, const char *key) { // returns -1 if the key exists but has no expiration // returns -2 if the key does not exist (or has already expired) // returns N remaining seconds otherwise (N > 0)}
>>> SET persistent myvalueOK>>> TTL persistent-1>>> SET temp myvalue EX 60OK>>> TTL temp58>>> TTL nonexistent-2
When the AOF is rewritten, keys with a TTL are written using the remaining time-to-live at the moment of the rewrite, not the original TTL:
aof.c (during rewrite)
// For a key with expires_at = 1700001234 and current time = 1700000000:remaining = 1700001234 - 1700000000; // = 1234 seconds// Writes: "SET key value EX 1234"
This means a key’s expiry continues from where it was, not from when it was originally set. If RadishDB restarts and replays the AOF, the key will still expire at the originally intended wall-clock time — provided the restart happens before the key’s deadline.
If RadishDB is down for longer than the remaining TTL of a key, that key will be absent from the restored state. This is correct behavior: the key genuinely expired while the process was stopped.