TTL — Automatic Expiry
Global TTL added in v0.6.0 · Per-entry TTL added in v0.6.1 · Migrated to u64 in v0.6.2
PulseMap supports insertion-epoch TTL — entries automatically expire after a fixed number of insertions. No background thread, no timer, zero overhead when disabled.
How It Works
Every insert bumps a monotonic current_epoch: u64 counter. Each slot stores the epoch at which its entry was inserted. On every get() or peek():
age = current_epoch - slot_epoch
if age > effective_ttl → entry is expired → return None
This is a single wrapping subtraction + comparison — effectively free on modern CPUs.
Quick Start — Global TTL
#![allow(unused)]
fn main() {
use pulse_map::PulseMap;
let mut cache = PulseMap::new(1024);
// Set TTL: entries expire after 500 insertions
cache.set_ttl(500);
cache.insert(b"session:abc", b"user_data"); // epoch 1
// High-traffic server: 500 more inserts
for i in 0u64..501 {
cache.insert(&i.to_le_bytes(), b"traffic");
}
// session:abc was inserted at epoch 1
// current_epoch is now 502, age = 501 > ttl = 500
assert_eq!(cache.get(b"session:abc"), None); // expired ✓
}
Per-Entry TTL (v0.6.1+)
Individual entries can have their own TTL, overriding the global default:
#![allow(unused)]
fn main() {
use pulse_map::PulseMap;
let mut cache = PulseMap::new(1024);
cache.set_ttl(500); // global default
// Per-entry overrides
cache.insert_ttl(b"session", b"data", 50); // expires after 50 inserts
cache.insert_ttl(b"config", b"val", u64::MAX); // never expires
cache.insert(b"normal", b"val"); // uses global TTL = 500
}
TTL Parameter Semantics
ttl value | Behavior |
|---|---|
0 | Use global default (set_ttl()) |
1..u64::MAX-1 | Expire after N insertions |
u64::MAX | Never expire — entry lives forever |
Available On All Map Types
#![allow(unused)]
fn main() {
// PulseMap (raw bytes)
map.insert_ttl(b"key", b"val", 100);
// TypedPulseMap<K, V>
map.insert_ttl(42u32, 100u64, 50);
// ConcurrentPulseMap<K, V> (thread-safe)
map.insert_ttl(42u32, 100u64, 50);
// ShardedPulseMap<K, V> (16-shard)
map.insert_ttl(42u32, 100u64, 50);
}
Per-Entry Overrides Global
#![allow(unused)]
fn main() {
let mut map = PulseMap::new(64);
map.set_ttl(100); // global: 100 inserts
// Per-entry TTL = 2 (overrides global 100)
map.insert_ttl(b"short", b"val", 2);
// After 3 more inserts → short expired (age 3 > ttl 2)
// Even though global TTL is 100
}
API
PulseMap (raw &[u8])
#![allow(unused)]
fn main() {
map.set_ttl(n: u64) // global TTL (0 = disabled)
map.get_ttl() -> u64 // current global TTL
map.current_epoch() -> u64 // total insertions
map.insert(key, value) // uses global TTL
map.insert_ttl(key, value, ttl) // per-entry TTL override
}
TypedPulseMap<K, V>
#![allow(unused)]
fn main() {
map.set_ttl(500u64);
map.insert_ttl(key, value, 50); // per-entry TTL
map.get_ttl() // → 500
map.current_epoch() // → total inserts
}
TTL = 0 → Disabled (Default)
By default, default_ttl = 0. The expiry check returns false immediately when both global and per-entry TTL are 0 — zero overhead.
Backward compatible — existing code works without any changes.
Update Refreshes Epoch
Re-inserting the same key resets its epoch and TTL:
#![allow(unused)]
fn main() {
cache.set_ttl(3);
cache.insert_ttl(b"key", b"v1", 3); // epoch 1, TTL=3
cache.insert(b"a", b"x"); // epoch 2
cache.insert(b"b", b"y"); // epoch 3
// Re-insert refreshes both epoch AND TTL
cache.insert_ttl(b"key", b"v2", 3); // epoch 4, TTL=3 (refreshed!)
cache.insert(b"c", b"z"); // epoch 5 → key age = 1 (alive)
assert_eq!(cache.get(b"key"), Some(&b"v2"[..]));
}
This is useful for session stores — each access or heartbeat refreshes the TTL.
Lazy Eviction
Expired slots are not eagerly removed. They are reclaimed lazily:
get()/peek()— returnsNonefor expired entries (no cleanup)insert()— when searching for a free slot, expired slots are treated as available
This means no background thread, no periodic scan, no latency spikes.
Choosing a TTL Value
TTL is measured in insertions, not wall-clock time. To convert:
ttl_epochs = expected_inserts_per_second × desired_ttl_seconds
Example:
Server: 10,000 inserts/sec
Desired TTL: 60 seconds
→ set_ttl(600_000)
This makes TTL workload-proportional — a busier server expires entries faster.
Comparison with Redis TTL
| Feature | Redis TTL | PulseMap TTL |
|---|---|---|
| Time unit | Seconds / milliseconds | Insertions |
| Per-entry TTL | ✅ Yes | ✅ Yes (v0.6.1+) |
| Background expiry | ✅ Yes | ❌ Lazy only |
| Refresh on access | Manual (EXPIRE cmd) | Re-insert |
| Network hop | ~100μs | 0 (in-process) |
| Memory bound | No | ✅ Fixed |
PulseMap TTL is ideal for in-process hot caches. Use Redis when you need cross-process TTL or millisecond precision.
Implementation Details
#![allow(unused)]
fn main() {
// PulseMapRaw fields (raw.rs) — v0.6.1
#[derive(Clone, Copy, Default)]
pub(crate) struct SlotTTL {
epoch: u64, // insertion epoch
ttl: u64, // 0 = use default, u64::MAX = never
}
slots_ttl: Vec<SlotTTL>, // one per slot
current_epoch: u64, // global counter
default_ttl: u64, // set via set_ttl()
// On every insert:
self.current_epoch = self.current_epoch.wrapping_add(1);
self.slots_ttl[idx] = SlotTTL { epoch: self.current_epoch, ttl };
// On get():
fn is_expired(&self, bucket_idx: usize, slot_idx: u8) -> bool {
let entry = self.slots_ttl[bucket_idx * 4 + slot_idx as usize];
let effective_ttl = if entry.ttl == 0 { self.default_ttl } else { entry.ttl };
if effective_ttl == 0 || effective_ttl == u64::MAX { return false; }
self.current_epoch.wrapping_sub(entry.epoch) > effective_ttl
}
}
Memory overhead: num_buckets × 4 × 16 bytes (one SlotTTL per slot).
For 1024 buckets: 1024 × 4 × 16 = 64 KB — negligible.