cache

package
v0.27.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 3, 2026 License: MIT Imports: 6 Imported by: 0

README

cache (core)

The cache domain contract: Store[V] (Fetch / Set / Delete), the EntryValue[V] a caller stores, and the three siblings a store advertises by type assertion — EntryFetcher[V], Tagger, Loader[V].

var store corecache.Store[Profile] // built in internal/service/data/cache

if tagger, ok := store.(corecache.Tagger); ok {
    removed, err := tagger.InvalidateTag(ctx, "user:42")
}

The read verb is Fetch, not Get: a hit mutates the store's recency order, so a read is not free. Store is frozen at three methods (ADR 0039) — new capabilities arrive as siblings.

Stampede protection (Loader.Load) collapses concurrent misses within one process only; it does not coordinate across replicas.

Concrete stores: internal/service/data/cache. Public facade: pkg/v1/data/cache. ADR 0049 (amends ADR 0025). See CLAUDE.md.

The ports — Store, EntryFetcher, Tagger and Loader — are generated from design/data/cache.yaml into design_gen.go (ADR 0163): a port changes in the design, then kit gen.

Documentation

Overview

Package cache declares the SDK's cache DOMAIN: the Store port that owns a set of keyed entries, the EntryValue a caller hands it, and the three sibling interfaces through which a store advertises what else it can do. A core sibling admitted by ADR 0049, which amends ADR 0025.

This is not internal/kernel/collections/cache

github.com/kitsunium/sdk/internal/kernel/collections/cache is a PRIMITIVE: a generic LRU + TTL map with no port, no error surface and no vocabulary. It stays exactly what it is, and the concrete store in internal/service/data/cache is built on top of it.

This package is the DOMAIN: an interface a caller can hold without naming an implementation, plus the three things a primitive deliberately does not have — invalidation by tag, protection against a stampede of concurrent misses, and the ability to put one store in front of another.

A read is not free, and the port says so

The read verb is Store.Fetch, not Get, and the name is load-bearing. A hit promotes the entry in the store's recency order and moves its counters; in the concrete memory store it takes a WRITE lock for exactly that reason. Two concurrent Fetches of one key are therefore not interchangeable with one, and a Fetch is not something to sprinkle through a function that must not mutate. ADR 0025 named the primitive's method Fetch for this reason; the port keeps the name so the honesty survives the layer boundary.

There is no registry

Like proc (ADR 0016), resilience (ADR 0026), net (ADR 0029), scheduler (ADR 0041), token (ADR 0042), session (ADR 0045) and validation (ADR 0046), this domain has no name-to-implementation registry.

The domain reason is session's: the set of backends is closed and small, and picking one is a deployment decision made in code. Resolving it from a configuration string would let a typo silently downgrade a shared cache to a per-process one — and unlike a downgraded logger, a downgraded cache still answers every call correctly, so nothing would ever surface.

The mechanical reason is decisive on its own: Store is generic in V, and Go has no map from a name to Store[V] for an open V. A registry here would have to erase the type and hand it back as any, which is precisely the unchecked cast the typed port exists to remove.

Keys are strings, values are not

Store fixes the key type to string while leaving V generic. The primitive is generic in both, and the domain deliberately narrows one half: a domain key appears in a tag bucket, in an error field and in a log line, and a generic K would need a formatting rule for each of those before it could. A caller with a non-string key formats it once, at the edge, where the encoding is a decision rather than a default.

Package cache — the sibling interfaces a store advertises by type assertion (ADR 0039), kept out of cache.go so the FROZEN port stands alone in the file that names the package.

Package cache — range 0.2.18.* (ADR 0049 core/data/cache block), and the chained store's 0.3.48.* (ADR 0049 service/data/cache block, declared here since ADR 0160).

Package cache — the immutable value a caller hands to a Store.

Package cache — declares the sentinel *errs.Error port outcomes, and the two the chained store in internal/service/data/cache emits (ADR 0160: every code is declared in the core, at the service's path). Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.

Index

Constants

View Source
const CodeCacheAbsent errs.Code = 0x00_02_12_05 // 0.2.18.5

CodeCacheAbsent marks a fill's failure as an answer to cache: the key does not exist at the origin. Absent puts it on the wrap trail of the fill's own error, and a store's Load then keeps a negative entry for the lifetime the mark carries.

View Source
const CodeCacheBackendFailed errs.Code = 0x00_02_12_02 // 0.2.18.2

CodeCacheBackendFailed identifies a store that could not serve an operation for a reason of its own — the transport, the medium, the process it depends on. A plain MISS is not this: a cache that does not hold something has done nothing wrong and reports (zero, false, nil).

View Source
const CodeCacheChainMisconfigured errs.Code = 0x00_03_30_01 // 0.3.48.1

CodeCacheChainMisconfigured identifies a NewChain refused AT CONSTRUCTION: fewer than two tiers, a nil tier, or a tier that cannot answer the whole contract a tier must answer (see the errors.go sentinel for why that is checked once, up front, rather than discovered per call).

View Source
const CodeCacheEntryRejected errs.Code = 0x00_02_12_04 // 0.2.18.4

CodeCacheEntryRejected identifies an entry a store refuses to store: an empty key, an empty tag, or a negative TTL that is not cache.NoExpiry.

Each of the three is a value that has no meaning rather than an unusual one, and storing it would produce an entry the caller can never deliberately fetch, invalidate or replace.

View Source
const CodeCacheFillFailed errs.Code = 0x00_02_12_03 // 0.2.18.3

CodeCacheFillFailed identifies a Loader.Load whose caller-supplied fill returned an error. NOTHING was stored: a failed fill must not be cached, or one bad minute at the origin becomes a TTL's worth of bad answers.

It applies only when the fill's error carries no SDK code of its own. One that does propagates untouched, because relabelling it would discard the origin's own diagnosis (ADR 0005 §origin wins).

View Source
const CodeCacheLeaseFailed errs.Code = 0x00_03_30_03 // 0.3.48.3

CodeCacheLeaseFailed identifies a LoadLeased whose locker could not answer: the cross-process lease on a key could be neither taken nor given up (#830).

View Source
const CodeCacheMisconfigured errs.Code = 0x00_02_12_01 // 0.2.18.1

CodeCacheMisconfigured identifies a store refused AT CONSTRUCTION because its configuration cannot be honoured — a non-positive capacity, a negative default TTL. It is never an operational outcome: no entry was ever stored.

View Source
const CodeCacheTierFailed errs.Code = 0x00_03_30_02 // 0.3.48.2

CodeCacheTierFailed identifies a chained operation that failed inside one specific tier.

It is distinct from CACHE_BACKEND_FAILED because a chain has more than one backend, and "the cache is broken" and "the far tier is broken" are different incidents with different responses: the first is an outage, the second is a degradation the near tier is still absorbing.

View Source
const NoExpiry time.Duration = -1

NoExpiry is the EntryValue.TTL that stores an entry with no deadline.

It is a distinct value from the zero TTL, which means "use the store's default". A cache with a one-minute default holding one entry that must never expire is a real combination, and reading both meanings out of 0 would make it unspellable — the caller would have to know the default and restate it, which stops being true the day the default changes.

Variables

View Source
var (
	// CacheMisconfigured is returned by a store CONSTRUCTOR whose
	// configuration it cannot honour.
	//
	// It is ADR 0031's refuse half. A capacity is the caller's intent and any
	// number the SDK picked would be arbitrary, so a non-positive one is
	// refused rather than clamped — and refusing at construction is what keeps
	// the other outcome off the table: a cache built with an unusable
	// configuration still answers every call correctly, it just never HOLDS
	// anything, and nothing downstream can tell the difference between that
	// and a genuinely cold workload.
	CacheMisconfigured = errs.Static(CodeCacheMisconfigured, "CACHE_MISCONFIGURED",
		"The cache cannot be built from the given configuration",
		"core/data/cache: a store constructor received a capacity or a default TTL it cannot honour; the fields name the option and the value",
		0,
		78)

	// CacheBackendFailed is returned when the store itself could not serve the
	// operation. A miss is NOT this — a miss is (zero, false, nil).
	CacheBackendFailed = errs.Static(CodeCacheBackendFailed, "CACHE_BACKEND_FAILED",
		"The cache backend could not serve the operation",
		"core/data/cache: a store failed for a reason of its own rather than reporting a miss; the fields name the operation and the key",
		0,
		0)

	// CacheFillFailed is returned by Loader.Load when the caller's fill fails
	// and its error carries no SDK code of its own.
	//
	// Nothing is stored. Caching a failure would turn one bad minute at the
	// origin into a full TTL of bad answers, served confidently, to every
	// caller — the failure mode a cache is least able to explain afterwards.
	CacheFillFailed = errs.Static(CodeCacheFillFailed, "CACHE_FILL_FAILED",
		"The cache fill function failed and nothing was stored",
		"core/data/cache: a Loader.Load fill returned an error carrying no SDK code; the fields name the key",
		0,
		0)

	// CacheEntryRejected is returned by Set for an entry the store refuses:
	// an empty key, an empty tag, or a negative TTL other than NoExpiry.
	//
	// Refusing is the point. An empty key is fetchable only by another empty
	// key, an empty tag names a set nobody can ask for, and a negative TTL
	// would be read by the underlying primitive as "no expiry" — so the three
	// silent outcomes of accepting them are an entry nobody meant to write, an
	// entry nobody can invalidate, and an entry that never goes away.
	CacheEntryRejected = errs.Static(CodeCacheEntryRejected, "CACHE_ENTRY_REJECTED",
		"The cache entry is not storable as given",
		"core/data/cache: Set received an empty key, an empty tag, or a negative TTL that is not cache.NoExpiry; the fields name the offending part",
		0,
		65)

	// CacheAbsent is what [Absent] records for a cause that carries no SDK code
	// of its own: the key does not exist at the origin, and the store keeps that
	// answer for the lifetime the mark carries.
	CacheAbsent = errs.Static(CodeCacheAbsent, "CACHE_ABSENT",
		"The requested entry does not exist",
		"core/data/cache: a fill answered that the key does not exist at the origin; the store keeps a negative entry for the absent_ttl_ns field's lifetime",
		0,
		65)

	// CacheChainMisconfigured is returned by NewChain for a tier set it cannot
	// honour.
	//
	// The interesting refusal is the third one. Every tier must implement
	// [EntryFetcher] and [Tagger], and that is checked ONCE, at construction,
	// rather than per call. The reason is the whole point of tagging:
	// promoting a value into a nearer tier WITHOUT its tags produces a copy
	// that InvalidateTag can no longer reach, so the invalidation reports
	// success, the far tier forgets the entry, and the near tier keeps
	// serving it until its TTL runs out. A tag invalidation that silently
	// misses one copy is worse than one that was never offered — so the chain
	// refuses to be built rather than degrade at the moment nobody is looking.
	CacheChainMisconfigured = errs.Static(CodeCacheChainMisconfigured, "CACHE_CHAIN_MISCONFIGURED",
		"The cache chain cannot be built from the given tiers",
		"service/data/cache: NewChain received fewer than two tiers, a nil tier, or a tier that does not implement EntryFetcher and Tagger; the fields name the position and the missing capability",
		0,
		78)

	// CacheTierFailed is returned when a chained operation failed inside one
	// tier. Its fields name the tier's position and the operation, because a
	// chain's caller holds one Store and would otherwise have no way to tell
	// which of two backends broke.
	CacheTierFailed = errs.Static(CodeCacheTierFailed, "CACHE_TIER_FAILED",
		"A tier of the cache chain could not serve the operation",
		"service/data/cache: one tier of a chained store returned an error; the fields name the tier position, the operation and the key",
		0,
		0)

	// CacheLeaseFailed is returned by LoadLeased when its locker could not
	// answer — the lease on the key could be neither taken nor given up. The
	// locker's own error is kept: errors.Is still finds it.
	CacheLeaseFailed = errs.Static(CodeCacheLeaseFailed, "CACHE_LEASE_FAILED",
		"The cache could not coordinate the fill with other processes",
		"service/data/cache: the locker handed to LoadLeased returned an error; the fields name the operation (acquire or release) and the key",
		0,
		0)
)

Functions

func Absent added in v0.27.0

func Absent(cause error, ttl time.Duration) error

Absent marks a fill's failure as an ANSWER to cache: the key does not exist at the origin, and asking again before ttl has passed would get the same answer. A store whose Load receives it stores a negative entry for ttl; until it expires, Load returns this same error — errors.Is still finds cause — and calls no fill, while Fetch reports a miss and a Set replaces it.

row, err := db.Get(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
	return cache.EntryValue[Row]{}, cache.Absent(err, time.Minute)
}

The mark is an errs.Wrap onto cause: an SDK cause keeps its own Code, Reason and Public, CodeCacheAbsent joins its wrap trail and the lifetime rides as a field; any other cause records CacheAbsent itself. A ttl that is not positive caches nothing: the failure is returned as any other.

func AbsentTTLOf added in v0.27.0

func AbsentTTLOf(err error) (ttl time.Duration, marked bool)

AbsentTTLOf returns the lifetime Absent put on err, and whether it put one. When err was marked more than once, the outermost mark's lifetime is returned.

Types

type BulkFetcher added in v0.27.0

type BulkFetcher[V any] interface {
	// FetchMany returns the values of the keys the store holds, and no entry for
	// a key it does not. It mutates the store as Fetch does. Duplicate keys
	// are read once.
	FetchMany(ctx context.Context, keys []string) (map[string]V, error)
}

BulkFetcher is the sibling that reads many keys in one call (ADR 0039, #840): one lock acquisition per stripe on a memory store, one pass per tier on a chain, one round trip on a remote tier — where N calls of Fetch pay N.

type BulkSetter added in v0.27.0

type BulkSetter[V any] interface {
	// SetMany stores every entry under its key. Every entry is validated before
	// any is stored: one a store refuses — an empty key or tag, a TTL Set would
	// refuse — fails the call with [CacheEntryRejected] and stores nothing.
	SetMany(ctx context.Context, entries map[string]EntryValue[V]) error
}

BulkSetter is the sibling that stores many entries in one call (ADR 0039, #840).

type EntryFetcher

type EntryFetcher[V any] interface {
	// FetchEntry is [Store.Fetch] returning the stored entry — value, remaining
	// TTL and tags. It mutates the store for the same reasons Fetch does.
	//
	// The TTL it reports is the REMAINING time, not the one originally
	// supplied: promoting an entry with its original TTL would extend its life
	// every time it moved between tiers.
	FetchEntry(ctx context.Context, key string) (EntryValue[V], bool, error)
}

EntryFetcher is the sibling that returns the whole entry rather than only its value (ADR 0039). It exists because promotion between tiers has to carry TTL and tags: a value promoted into a near tier without its tags is a value that Tagger.InvalidateTag can no longer reach, and an invalidation that silently misses one copy is worse than one that is not offered at all.

type EntryValue

type EntryValue[V any] struct {
	// Value is the payload.
	Value V
	// TTL is how long the entry stays fetchable.
	//
	//   > 0        this exact lifetime
	//   0          the store's configured default
	//   [NoExpiry] no deadline at all
	//
	// Any other negative value is a mistake, not a third meaning, and is
	// refused with [CacheEntryRejected] rather than reinterpreted.
	TTL time.Duration
	// Tags label the entry for bulk invalidation. Order is not significant and
	// duplicates are collapsed. An empty tag is refused: it names a set nobody
	// can ask for, so accepting it would index the entry under a key that can
	// only ever be reached by accident.
	//
	// Tags cost memory in the store's reverse index — see
	// internal/service/data/cache/CLAUDE.md, which states the per-pair cost and
	// measures it.
	Tags []string
}

EntryValue is what a caller stores: the value, how long it should live, and the tags by which it can later be invalidated in bulk.

It is a VALUE — a store copies what it needs and never retains the slice the caller passed, so a caller may reuse or mutate its Tags slice afterwards.

type Expirer added in v0.27.0

type Expirer interface {
	// Expire removes every entry whose deadline has passed and reports how many
	// it removed. A chain sweeps every tier that can, far to near, and reports
	// the total of removals, as InvalidateTag counts them.
	Expire(ctx context.Context) (removed int, err error)
}

Expirer is the sibling for stores that can remove their expired entries on demand (ADR 0039, #1059). Expiry is otherwise lazy: an entry past its TTL is reaped by a read of its own key or by an eviction, and until then it holds capacity, a place in the tag index and — for a value that must not outlive its TTL — memory. The store starts no goroutine: the caller sweeps on its own ticker or scheduler, which is the cadence ADR 0049 left to it.

type Fill

type Fill[V any] func(ctx context.Context) (EntryValue[V], error)

Fill computes the entry for a key that the cache does not hold. It is the caller's code: a database query, an HTTP call, a derivation.

It is a FUNC port rather than a one-method interface, the shape internal/core already admits for resilience.Operation, scheduler.Job and validation.Constraint — and, per ADR 0039, a func type satisfies the no-widening rule structurally, because it cannot grow a method at all.

type Leaser added in v0.27.0

type Leaser[V any] interface {
	// LoadLeased is Load with a lease named "cache:" + key taken from locker
	// on a miss, after the in-process collapse. The holder reads the store
	// again, fills, stores and releases. A process that finds the lease held
	// fills nothing: it reads the store again every poll step of the store's
	// clock until the holder's value lands or its own ctx ends — or until the
	// lease comes free without a value (the holder failed, died, or let a lease
	// that can expire lapse), when it takes the lease and reads once more
	// before filling. A nil locker makes it Load. A locker that cannot answer
	// fails the call with [CacheLeaseFailed]. A lease that can expire is not
	// renewed during the fill: its lifetime must outlast the fill.
	LoadLeased(ctx context.Context, key string, fill Fill[V], locker lock.Locker) (V, error)
}

Leaser is the sibling that carries stampede protection across PROCESSES (ADR 0039, #830). Loader.Load collapses concurrent misses within one process; LoadLeased also takes a named lease, on a miss, from a lock.Locker the processes share — the lock domain's file locker on one machine, whose lease dies with the process holding it — so that one process at a time fills a key.

What the others then get depends on what they share. A chain whose far tier every process reaches answers the waiting ones with the value the holder stored: the origin sees ONE fill. A memory store alone shares no value: the lease then only takes turns — one fill at a time, never two at once, each process filling its own copy once the holder has finished.

type Loader

type Loader[V any] interface {
	// Load returns the value for key, calling fill exactly once across all
	// concurrent callers of this key in this process if the cache does not
	// hold it. A fill that fails stores nothing and returns
	// [CacheFillFailed] — unless the failure already carries an SDK code, in
	// which case it propagates untouched (ADR 0005 §origin wins).
	//
	// fill does NOT receive ctx. It receives the shared call's context: the
	// first caller's values with every deadline removed, cancelled only once
	// every waiting caller has gone. A caller whose own ctx ends while it
	// waits gets its own ctx.Err() and leaves the fill running for the others.
	Load(ctx context.Context, key string, fill Fill[V]) (V, error)
}

Loader is the sibling for stores that can fill a miss while ensuring that concurrent misses on ONE key run the fill ONCE (ADR 0039).

What it protects against, and what it does not

A cache stampede is what happens when a hot key expires: every request that was being served from it misses at the same instant and every one of them calls the origin. The origin sees, in one moment, the traffic the cache was hiding — which is the load the cache was bought to prevent, arriving when the system is least able to absorb it.

Loader.Load collapses those calls into one WITHIN ONE PROCESS. It does NOT coordinate across replicas: N instances of a service each running this still produce N concurrent fills. Cross-process collapsing needs a shared lease, which is a distributed system and not something a cache port can promise. A reader who takes "stampede protection" to mean "my origin sees one request" would be wrong by a factor of N, so the limit is stated here, in the port, and again in every document that mentions the feature. Leaser is the sibling that takes such a lease, from a lock.Locker the processes share, and says what it can and cannot promise.

type Refresher added in v0.27.0

type Refresher[V any] interface {
	// LoadRefreshing is Load, except on a hit whose remaining lifetime is below
	// before: the value is returned at once and ONE background fill per key
	// starts, on the caller's context values without its cancellation, bounded
	// by the entry's remaining lifetime; what it returns replaces the entry, and
	// a failure leaves the entry to expire. A hit outside that window starts
	// nothing. A before that is not positive is refused with
	// [CacheMisconfigured].
	LoadRefreshing(ctx context.Context, key string, fill Fill[V], before time.Duration) (V, error)
}

Refresher is the sibling that refreshes a hot entry BEFORE it expires (ADR 0039, #838): stale-while-revalidate. A Load collapses the stampede at expiry; it does not hide the latency, since every reader of that instant waits for the origin. LoadRefreshing answers from the cache and refills in the background while the entry is still valid.

type Store

type Store[V any] interface {
	// Fetch returns the value stored under key and whether it was present and
	// unexpired. It MUTATES the store — recency, counters, lazy expiry — which
	// is why it is not called Get. A miss is (zero, false, nil), never an
	// error: a cache that does not hold something has done nothing wrong.
	Fetch(ctx context.Context, key string) (V, bool, error)
	// Set stores entry under key, replacing whatever was there. An empty key,
	// an empty tag, or a negative TTL that is not [NoExpiry] is refused with
	// [CacheEntryRejected] rather than stored.
	Set(ctx context.Context, key string, entry EntryValue[V]) error
	// Delete removes key. It is idempotent: deleting a key that names nothing
	// reports nil, because a caller who wanted it gone has got what they asked
	// for.
	Delete(ctx context.Context, key string) error
}

Store is the cache port. Implementations MUST be safe for concurrent use.

The method set is deliberately three operations wide and is FROZEN: pkg/v1 aliases this interface, so under ADR 0039 a fourth method would break every downstream implementer at compile time with no deprecation window. New capabilities arrive as SIBLING interfaces discovered by type assertion — EntryFetcher, Tagger and Loader are the three that ship, and the model is codec's Appender (ADR 0037).

IFACE-PLUGIN: the concrete stores stay unexported behind their constructors in internal/service/data/cache.

type Tagger

type Tagger interface {
	// InvalidateTag removes every entry carrying tag and reports how many it
	// removed. A tag that names nothing removes zero and reports nil.
	//
	// The cost is proportional to the number of entries carrying the tag, NOT
	// to the size of the store — see internal/service/data/cache/BENCH.md, which
	// measures it at two store sizes for exactly this claim.
	InvalidateTag(ctx context.Context, tag string) (removed int, err error)
}

Tagger is the sibling for stores that index entries by tag (ADR 0039).

Tagging exists because the thing a caller wants to invalidate is usually not a key. "Every entry derived from user 42" is a set whose members are known to the writer and not to the invalidator, and without a tag the invalidator's only options are to guess the key shape or to purge everything.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL