TTL Semantics
Understand how time-to-live (TTL) works across different operations.
Three knobs control TTL on every write
Per-schema TTL, per-call override, cascade floor, or forced uniform TTL
TTL modes
| Mode | How to invoke | Root/list TTL | Embedded child TTL |
|---|---|---|---|
| Default | { ttl: N } | N (or schema TTL if omitted) | Each child's schema TTL |
| Cascade floor | { ttl: N, cascadeTTL: true } | N (or schema TTL if omitted) | max(child schema TTL, parent TTL) — never demoted |
| Force exact | { ttl: N, forceTTL: true } | N | Exactly N — overrides every child's schema TTL |
When to use which mode
- Default — you want to pin one specific entity's TTL without touching its relations. Most common case.
- Cascade floor — your parent has a longer TTL than some embedded child. Without cascade, the child expires first and the parent reads back with null. Cascade lifts the child to at least the parent's TTL, fixing the coherence hole. This is the safe default for nested writes.
- Force exact — you want an explicit, uniform expiry across everything in this call: bulk-shorten for testing, manual cache shaping, matching an external eviction policy. forceTTL always wins over cascadeTTL (force is the stronger semantic).
// Default: short TTL on the post only; embedded category keeps its schema TTL
await cache.writeEntity({ entityType: 'post', data: postData, ttl: 100 });
// Cascade floor: post 1h, embedded category bumped from 5min schema to 1h
await cache.writeEntity({
entityType: 'post',
data: postData,
ttl: 3600,
cascadeTTL: true,
});
// Force: every key in this batch gets exactly 100 seconds
await cache.writeList({
listType: 'postList',
params: { page: 1 },
items: postList,
ttl: 100,
forceTTL: true,
});Cascade-floor invariant
cascadeTTL is a floor, not an exact set. Passing { ttl: 100, cascadeTTL: true } against a child whose schema TTL is 3600 keeps the child at 3600 — the floor doesn't lower a longer TTL because doing so would silently shorten cached data. If you really want to shorten everything, use forceTTL: true.
Scope of ttl per method
| Method | ttl overrides |
|---|---|
writeEntity / updateEntityIfExists | The root entity's TTL (the key entityType:id). Embedded relations keep their schema TTL unless cascadeTTL: true lifts them. |
writeList / writeIndexedList | The list key's own TTL (and, with cascadeTTL, becomes the new cascade floor for embedded entities). |
addListItem / addIndexedListItem | The entity being added (forwarded to writeEntity). The list key's TTL is deliberately not touched because the list is shared state. |
Edge cases
- ttl: 0 — treated as "no expiry" (Redis PERSIST semantics applied via the same code path as schema TTL 0). With cascadeTTL: true, this makes every child also never-expire, by design.
- ttl: undefined (or simply omitted) — falls back to schema TTL. This is the default and matches pre-override behaviour exactly.
- Negative / NaN ttl — ignored by the resolver; falls back to schema TTL. This is a defence against caller bugs; you should never pass these intentionally.
TTL refresh on write
Every successful write resets the TTL on the keys it touches (Redis SET ... EX semantics, applied through the atomic CAS Lua script). Calling writeList / writeEntity again on already-cached keys will refresh those keys to whatever TTL the resolver picks for this call.
TTL cascade between parent and child entities
The problem
Each entity in your schema has its own TTL. When you write a Post (TTL 3600) that embeds a Category (schema TTL 60), the Category key is stored with TTL 60 — its own schema TTL, not the Post's. After 60 seconds the Category key expires while the Post key is still alive, so reading the Post returns it with category: null (or the relation absent). This is a real cache-coherence issue when parents are longer-lived than the reference data they embed.
The fix: opt-in cascadeTTL per write
Every write method accepts an optional { cascadeTTL?: boolean } as its last argument. When true, every entity written by that call uses TTL = max(its schema TTL, parentTtl).
// Without cascadeTTL — Category gets its own short TTL
await cache.writeEntity('post', postWithCategory);
// With cascadeTTL — Category gets max(60, 3600) = 3600 inside this write
await cache.writeEntity('post', postWithCategory, { cascadeTTL: true });
// Same option on every write method
await cache.updateEntityIfExists('post', patch, { cascadeTTL: true });
await cache.writeList('recentPosts', {}, items, { cascadeTTL: true });
await cache.addListItem('recentPosts', {}, post, { cascadeTTL: true });
await cache.writeIndexedList('globalFeed', {}, items, { cascadeTTL: true });
await cache.addIndexedListItem('globalFeed', {}, post, { cascadeTTL: true });Edge cases the resolver handles correctly
| Scenario | Behaviour |
|---|---|
cascadeTTL: false or omitted | Identical to prior versions; per-key schema TTL applies |
parentTtl === 0 (parent never expires) | All children get TTL 0 too — they outlive a never-expiring parent |
| Child's own schema TTL is 0 (explicitly persistent) | Left at 0, never demoted |
| Child's TTL ≥ parentTtl already | Left unchanged |
parentTtl is NaN / negative (shouldn't happen) | Resolver falls back to per-key TTL; no surprise |
When to use cascadeTTL
The simplest fix is to set the right TTLs in your schema in the first place. Reference data (Category, Tag, User profile) should have a TTL ≥ the longest-lived parent that embeds it. The cascadeTTL flag is for cases where:
- You can't easily change schema TTLs because other code paths rely on them being short
- You want a short TTL on direct reads of the child entity (so it refreshes from DB regularly), but you don't want embedded copies to disappear from cached parents mid-flight
- You're writing a long-lived feed (indexedList with TTL 24h) and want every entity in that feed to outlive a possible 1-hour TTL on the entity schema
What cascadeTTL does NOT do
- It does not affect entities that already exist in cache and are not touched by this write. The next write that does touch them (without cascadeTTL) will revert them to schema TTL.
- It does not refresh TTLs on read. Reading a Post does not bump the Category's TTL. If the Category's TTL hasn't been touched by a cascading write recently, it will still expire on its schema TTL.
- It does not propagate transitively to entities written by separate calls. If your write of a Post touches an Author, and the Author's schema later does its own write of an Address, that second write does not inherit the Post's TTL.
Why it's not the default
Making cascade implicit silently overrides deliberate short TTLs on shared reference data. A Category configured with TTL 60 because you want it to be re-read from DB every minute would effectively live as long as the longest-TTL Post that referenced it. That's a worse default than the current explicit short-TTL-wins behaviour.