Redis-Backed Cacheable[T] Sibling & Multi-Key Cacheable — adapters/redis, codex¶
Status: Design complete — ready to implement. Explicitly SEQUENCED to implement AFTER
codex.Cacheable[T](codex/cacheable.go) ships — that dependency is now SATISFIED (seedocs/concepts/codec.md's Getter/Setter subsection for the shipped in-memory design this doc's sibling type composes). ← Back to RoadmapThis doc preserves the "Redis-backed
Cacheable[T]: pluggable backing store" section and the "generic multi-keyCacheable" idea from the now-retiredcache-parity-and-cacheable.md(fully shipped — Part 1,ports.Cachetemplate-unification parity, and Part 2, the in-memorycodex.Cacheable[T], both landed; nothing else was left to track). See also:ports.RefreshingCacheable[T](auto-refresh wrapper, depends on the Redis-backed sibling here OR the in-memorycodex.Cacheable[T]— designed to compose either interchangeably) · Redis Cache Adapter (shipped) · Redis — Phase 2: Pub/Sub (keyspace-notification-driven invalidation is THAT doc's own unscoped idea, not this one — though the Redis-backedInvalidate(ctx)below would be a natural call site for it once both exist).
Motivation¶
codex.Cacheable[T] (shipped) is deliberately an IN-PROCESS, single-
value memoization cell — no network, no ctx, no error from Get. Per
explicit request when that design was drafted: once it existed, adapt
ports.Cache/adapters/redis so the SAME Cacheable[T]-shaped behavior
(get a value with a freshness flag, set a new one, invalidate, ask
whether stale) can be backed by an ACTUAL Redis entry instead of
in-process memory — a caller picks per-instance where the value lives,
standalone in-memory codex.Cacheable[T] or Redis-backed, without
changing how they read it.
This walks back codex.Cacheable[T]'s own framing ("deliberately
NOT a competing implementation of ports.Cache/adapters/redis... no
dependency on adapters/redis at all") for the SPECIFIC case of wanting
one value's storage to be pluggable — the in-memory/local vs.
remote/shared distinction still explains why codex.Cacheable[T] ITSELF
stays a plain in-memory cell; this doc instead adds a SIBLING type in
adapters/redis satisfying an analogous (but not identical) shape.
API surface¶
Cannot have an IDENTICAL signature to codex.Cacheable[T] — a real
network round trip needs a context.Context and can fail, so Get must
be Get(ctx context.Context) (T, bool, error), not the in-memory
Get() (T, bool) (no ctx, never fails). This is a deliberate, necessary
shape difference — mirrors how Const/Immutable/Mutable/Cacheable
are already separate concrete types satisfying a shared but narrower
interface, not forcing one identical signature everywhere an I/O
boundary differs. Consequently this sibling does NOT satisfy
codex.FreshGetter[T] itself — only the SHAPE (Get returns a freshness
flag) carries over, not the interface.
package redis // adapters/redis
// Cacheable is a Redis-backed sibling to [codex.Cacheable] — same
// freshness-aware Get/Set/Invalidate/IsStale SHAPE, but Get is
// ctx-aware and fallible (a real network round trip), and Invalidate
// issues an actual Redis DEL instead of an in-process flag flip.
//
// Composes a SINGLE, already-built [ports.Cache] entry (a fixed key —
// NOT a {var}-templated one; templated keys stay ports.Cache[T]'s own
// direct Get/Set API, this type is for the "one specific cached value"
// case codex.Cacheable[T] itself addresses in-memory) + a [Commands]
// client.
type Cacheable[T any] struct { /* ... */ }
// NewCacheable composes cache (a single, already-BuildKey'd entry —
// vars supplies whatever {var}s cache.Key still has, typically none)
// with client into a Redis-backed Cacheable[T].
func NewCacheable[T any](client Commands, cache ports.Cache[T], vars map[string]string, opts ...CacheableOpt[T]) (*Cacheable[T], error)
// Get performs a real Redis GET (via redis.GetAdapter's underlying
// mechanism) and reports freshness from the Redis TTL — a MISS reports
// (zero, false, nil), NOT an error (mirrors ports.Cache/redis.GetAdapter's
// existing miss-is-not-an-error convention).
func (c *Cacheable[T]) Get(ctx context.Context) (value T, fresh bool, err error)
// Set writes value via redis.SetAdapter's underlying mechanism, using
// cache.TTL as the Redis expiry.
func (c *Cacheable[T]) Set(ctx context.Context, value T) error
// Invalidate issues a Redis DEL on the entry's key — the NEXT Get
// reports a miss (fresh=false) until the next Set.
func (c *Cacheable[T]) Invalidate(ctx context.Context) error
// IsStale performs a lightweight Redis TTL/EXISTS check without
// decoding the value — analogous to codex.Cacheable.IsStale, but this
// one is also ctx-aware and fallible.
func (c *Cacheable[T]) IsStale(ctx context.Context) (bool, error)
CacheableOpt[T] mirrors codex.CacheableOpt[T]'s shape (an Observer
wiring option at minimum) — reuses codex.ReloadObserver/
codex.InvalidateObserver (shipped) for Set/Invalidate events,
exactly like the in-memory type does, so a caller monitoring both the
in-memory and Redis-backed siblings needs only one Observer
implementation.
Test plan¶
| Test | Verifies |
|---|---|
TestRedisCacheable_Get_Miss_ReturnsNotFreshNoError |
A cache miss reports (zero, false, nil) — NOT an error, mirroring redis.GetAdapter's existing convention |
TestRedisCacheable_Get_Hit_ReturnsValueAndFreshness |
A hit within the Redis TTL reports (value, true, nil) |
TestRedisCacheable_Get_ExpiredTTL_ReturnsStale |
A hit past the Redis TTL reports (value, false, nil) (or a miss, depending on Redis's own TTL-expiry semantics — needs verifying against the fake Commands implementation's TTL model) |
TestRedisCacheable_Set_WritesWithCacheTTL |
Set writes via the underlying SetAdapter mechanism using cache.TTL as the Redis expiry |
TestRedisCacheable_Invalidate_IssuesRedisDel |
Invalidate issues a real Redis DEL; the NEXT Get reports a miss |
TestRedisCacheable_IsStale_MatchesGetFreshness |
IsStale(ctx) and Get(ctx)'s fresh bool never disagree, for the same underlying state |
TestRedisCacheable_Set_CallsReloadObserver |
Mirrors codex.Cacheable's own observer test |
TestRedisCacheable_Invalidate_CallsInvalidateObserver |
Mirrors codex.Cacheable's own observer test |
ExampleCacheable (adapters/redis) |
pkg.go.dev-visible usage sketch |
Files to create¶
| File | Responsibility |
|---|---|
adapters/redis/cacheable.go |
redis.Cacheable[T], redis.CacheableOpt[T], NewCacheable, ctx-aware Get/Set/Invalidate/IsStale |
adapters/redis/cacheable_test.go |
Full test plan above (miss-is-not-error, TTL-driven freshness, DEL-based invalidation, Observer wiring) |
docs/features/redis.md (doc-only) |
New subsection introducing the Redis-backed Cacheable[T] sibling alongside the existing Cache/adapter docs |
Also captured here: a generic multi-key Cacheable¶
A generic multi-key Cacheable (an in-process map of many cached
values, e.g. keyed by ID) — no driver yet; codex.Cacheable[T]
stays a single-value cell, mirroring Mutable[T]'s own single-value
scope. This applies to the Redis-backed sibling too — one
redis.Cacheable[T] instance is one fixed key, not a templated family.
If a driver appears, this would likely need its own roadmap doc (the
design space — sharding/eviction policy for the in-memory map, whether
per-key TTLs can differ, whether the Redis-backed sibling would need a
{var}-templated ports.Cache instead of a single fixed key — is
large enough to warrant a dedicated pass, not a quick addition here).
Out of scope¶
- Any change to
ports.Cache's PUBLIC shape —Cache[T]itself,CacheKeyParam/MergedCacheKeyParam[T]/NewCacheKeyParam[T], and every existing adapter function signature are UNCHANGED. The Redis-backedCacheable[T]sibling is a NEW, ADDITIVE type inadapters/redis— it does not changeports.Cacheitself. - Redis keyspace-notification-driven invalidation — belongs to
redis-pubsub.md's own, still-unscoped Phase 2 idea, not this doc (though the Redis-backedCacheable[T]'sInvalidate(ctx)would be a natural call site for it, once both exist). - Auto-refresh (fetch a fresh value automatically when stale) for
EITHER the in-memory or Redis-backed
Cacheable[T]— designed separately as its own follow-on: seeports.RefreshingCacheable[T].