cache

package
v0.19.2 Latest Latest
Warning

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

Go to latest
Published: Oct 5, 2026 License: MIT Imports: 3 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 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 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.Define(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",
		errs.WithExitCode(exitConfig))

	// 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.Define(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")

	// 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.Define(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")

	// 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.Define(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",
		errs.WithExitCode(exitDataErr))

	// 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.Define(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",
		errs.WithExitCode(exitConfig))

	// 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.Define(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")
)

Functions

This section is empty.

Types

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 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 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.

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