Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 valueBehavior
0Use global default (set_ttl())
1..u64::MAX-1Expire after N insertions
u64::MAXNever 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:

  1. get() / peek() — returns None for expired entries (no cleanup)
  2. 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

FeatureRedis TTLPulseMap TTL
Time unitSeconds / millisecondsInsertions
Per-entry TTL✅ Yes✅ Yes (v0.6.1+)
Background expiry✅ Yes❌ Lazy only
Refresh on accessManual (EXPIRE cmd)Re-insert
Network hop~100μs0 (in-process)
Memory boundNo✅ 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.