cistern

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 23, 2026 License: MIT Imports: 22 Imported by: 1

README

cistern

A Go library for two-level cache-aside caching — an in-process L1 and a shared Redis L2 — with load coalescing, negative caching, and invalidation by key, by tag and by event.

The name is the reservoir inside the fortress: you drink from it instead of going to the river, and it keeps serving when the river is slow or under siege.

Status: unreleased (phase C8 next). The library is feature-complete for v0.1: two levels, coalescing, negative caching, tag invalidation, a cross-instance Bus, hooks, and the examples module. What remains is validation under load with sapper, a clean-session audit and the release — see Roadmap. No version is tagged yet, and the API may still change.


Why this exists

A cache that makes the happy path faster is easy. The work is in what happens around it: a hot key expiring under load and sending every request to the database at once; a key built without the user id serving one user's data to another; a Redis outage turning a performance optimization into an application outage; a decoded value from Redis that nobody validated.

cistern takes positions on those and writes down the reasoning:

Quick start

client := redis.NewClient(&redis.Options{
	Addr:                  "redis:6379",
	ContextTimeoutEnabled: true, // required: see redisstore/README.md
})
l2, _ := redisstore.New(client, redisstore.WithTimeout(50*time.Millisecond))
b, _ := redisstore.NewBus(client)
l1, _ := memory.New(memory.WithMaxEntries(10_000))

// The owner goes in the key and in the tag (RS-02, ADR-0005).
lists, err := cistern.New[int, []Task]("tasks",
	func(owner int) string { return fmt.Sprintf("user:%d:lists", owner) },
	cistern.WithL1(l1), cistern.WithL2(l2), cistern.WithBus(b),
	cistern.WithTTL(5*time.Minute), cistern.WithL1TTL(5*time.Second),
	cistern.WithTags(func(owner int) []string { return []string{fmt.Sprintf("user:%d:lists", owner)} }),
	cistern.WithNegativeTTL(10*time.Second),
	cistern.WithHooks(hooks), // see examples/crierhooks
)
defer lists.Close()

// Reads: concurrent misses share one load; Redis down means the loader
// answers, never an error.
tasks, err := lists.GetOrLoad(ctx, 42, func(ctx context.Context) ([]Task, error) {
	return repo.ListByOwner(ctx, 42)
})

// Writes: change the source of truth, then invalidate — every replica.
err = lists.InvalidateTag(ctx, "user:42:lists")

examples/ holds runnable versions: the task-api Repository Decorator, a bastion circuit breaker around Redis, hooks exported to crier, and how to keep moat secrets out of the cache.

Properties

Property How
No stampede Per-key load coalescing plus TTL jitter; a hung loader cannot take a key down (RF-04, RF-05, ADR-0007)
Redis down ≠ app down Reads fail open, bounded by a short per-call timeout and an optional circuit breaker; invalidation fails loud (RNF-02, RNF-03, RNF-04, ADR-0002)
No cross-user leakage Mandatory namespace; owner in the key and in the tag, each enough on its own (RS-01, RS-02, ADR-0008)
L2 data is untrusted Versioned, size-limited envelope; the data never picks its codec; a bad entry is a miss, never a panic (RS-06, ADR-0009)
Invalidation without SCAN Tag generation counters checked on read, one round trip; an evicted counter can never resurrect an entry (RF-10, ADR-0005)
Replicas agree quickly A Bus drops other replicas' copies at once; a lost event costs at most one L1 TTL (RF-11, RNF-11, ADR-0006)
Callers never share a value Both levels hold bytes; every hit decodes a fresh copy (ADR-0014)
Secrets are never cached A type marked NoCache cannot be a cache's value type (RS-04)
Observable without leaking Hooks for hits, misses, loads and every swallowed error; keys withheld unless asked (RF-15, RS-08, ADR-0015)
No dependencies in the core Standard library only; Redis lives in a separate module (RNF-01, ADR-0001)
And what it will not do
  • Be a source of truth. Nothing may depend on the cache for correctness.
  • Close the cache-aside race for untagged keys. Tag invalidation closes it; an untagged Delete racing a load can leave a stale value for up to its TTL (ADR-0011).
  • Protect you from a key and a tag that both omit the owner. cistern forces a namespace; your key and tag functions have to encode the user.
  • Protect you from whoever can write your Redis. Values are validated, not signed.
  • HTTP caching, write-behind, Redis Cluster — out of scope for v0.x.

Operating it

  • Redis: a dedicated ACL user with the tested minimum, TLS outside development, maxmemory-policy allkeys-lru, and never an instance shared with moat's rate limiter or cairn's link store (a logical database is not enough) — redisstore/README.md has the exact lines.
  • Staleness between replicas is at most the L1 TTL when a Bus event is lost; keep it short (seconds).
  • Wire OnError. Fail-open means a Redis outage returns no errors to your code; the hook is where it shows.

Security model in brief

The full model is docs/THREAT-MODEL.md.

Threat Mitigation What is left
One user served another's data (T-01) Mandatory namespace; owner in key and tag A consumer omitting the owner from both
Hostile or corrupt L2 entry (T-05) Envelope validation, codec pinned by the cache, fuzzed decoder Anyone who can write Redis can serve plausible values
Forged Bus event (T-06) Events carry invalidation only Forced misses
Keys in logs (T-07) Hooks withhold keys by default A consumer who opts in
Stampede, penetration (T-10, T-11) Coalescing, jitter, negative caching Coalescing is per process
Redis outage (T-12) Fail-open, per-call timeout, circuit breaker The source carries the load
Stale write-back (T-13) Generations recorded before the load Untagged keys, up to their TTL
Evicted generation counter (T-14) Recreated at an unpredictable value None known

Design shape

cistern/             core — Cache[K,V], options, GetOrLoad, tags, hooks  (stdlib only)
├── memory/          L1: LRU over bytes, entry and byte limits, TTL, eviction hook
├── codec/           Codec interface + JSON
├── bus/             Bus interface, in-process bus, event codec
├── cisterntest/     conformance suites for Store, TagStore and Bus
└── internal/        envelope, singleflight

cistern/redisstore/  separate module — Redis L2, tag generations, Pub/Sub Bus
cistern/examples/    not published — task-api decorator, bastion, crier, moat secrets

Roadmap

Phase Delivery
C0 Bootstrap Repository, modules, CI, conventions, ADR-0001 and ADR-0013 (done)
C1 Public API + memory Cache[K,V], options, sentinel errors, codec, LRU L1 with TTL and jitter (done)
C2 Loading GetOrLoad, coalescing, negative caching (done)
C3 Encoding & validation Envelope, key validation, value limit, NoCache (done)
C4 redisstore Redis L2, fail-open, timeouts, Guard, testcontainers (done)
C5 Composition L1+L2, cisterntest passing for both stores (done)
C6 Invalidation Tag generation counters, in-process and Pub/Sub Bus (done)
C7 Hooks & examples Hooks, examples module, README (done)
C8 Validation & release sapper scenarios, clean-session audit, v0.1.0

In parallel from C7, the task-api track: T0 baseline with sapper → T1 endpoint choice → T2 CachedTaskRepository → T3 re-measure and release.

Ecosystem

Library Concern
moat HTTP security middleware
crier Log control and export
cairn Short links
bastion Circuit breaking
cistern Two-level caching

License

MIT.

Documentation

Overview

Package cistern is a two-level cache-aside library: an in-process L1 and an optional shared L2, with load coalescing, negative caching, and invalidation by key, by tag and by event.

Its requirements are in REQUIREMENTS.md, its threat model in docs/THREAT-MODEL.md, and its decisions in docs/adr.

A cache is never a source of truth. Every read path is fail-open: when L2 is unavailable, cistern degrades to L1 and the loader rather than failing the read (RNF-02).

Index

Examples

Constants

View Source
const (
	DefaultLoadTimeout   = 10 * time.Second
	DefaultMaxValueBytes = 1 << 20
)

Defaults used when the corresponding option is not given.

View Source
const MaxKeyBytes = 256

MaxKeyBytes is the longest consumer key stored verbatim (ADR-0008). A longer key is refused with ErrInvalidKey unless the cache was built with WithKeyHashing.

Variables

View Source
var (
	// ErrNotFound reports that a value does not exist in the source of truth.
	//
	// A loader returns it (possibly wrapped) to say the key has no value; with
	// negative caching enabled, cistern remembers that absence for a short TTL
	// instead of calling the loader again (RF-06). A read that finds a
	// remembered absence returns it. A plain cache miss is not ErrNotFound.
	ErrNotFound = errors.New("cistern: not found")

	// ErrInvalidConfig reports a cache that cannot be built as configured —
	// for example an empty namespace (RS-01) or an L1 TTL longer than the L2
	// TTL (RF-08). Constructors return it wrapped with the reason; they never
	// panic (RF-16).
	ErrInvalidConfig = errors.New("cistern: invalid configuration")

	// ErrValueTooLarge reports a value whose encoding exceeds the cache's
	// value limit (WithMaxValueBytes, RS-05). Set returns it; GetOrLoad
	// returns the loaded value without caching it instead.
	ErrValueTooLarge = errors.New("cistern: value too large")

	// ErrInvalidKey reports a consumer key that is empty, not valid UTF-8,
	// contains a control character, or is longer than MaxKeyBytes without
	// WithKeyHashing (RS-03, ADR-0008). It is the caller's bug, so every
	// operation returns it rather than treating it as a miss.
	ErrInvalidKey = errors.New("cistern: invalid key")

	// ErrUncacheable reports a value type or value marked NoCache (RS-04).
	ErrUncacheable = errors.New("cistern: value must not be cached")
)

Functions

func NewGeneration

func NewGeneration() (uint64, error)

NewGeneration returns an unpredictable generation in [1, 2^62), the value a TagStore gives a counter it creates (ADR-0005). The range leaves room for more increments than will ever happen.

Types

type Cache

type Cache[K comparable, V any] struct {
	// contains filtered or unexported fields
}

Cache is a typed cache-aside cache over one or two levels.

A Cache is safe for concurrent use. It never holds a value object: both levels store encoded bytes, and every hit decodes a fresh copy, so a caller that mutates what it received cannot change what the next caller receives (ADR-0014).

func New

func New[K comparable, V any](namespace string, key func(K) string, opts ...Option) (*Cache[K, V], error)

New returns a Cache whose entries live under namespace.

The namespace names a domain, such as "tasks", and must match [a-z0-9._-]{1,64}; there is no cache without one (RS-01). key turns a K into the consumer key. When entries belong to a user or tenant, key must put the owner in it — "user:42:list" — because cistern cannot tell a key that omits the owner from one that does not need it (RS-02, ADR-0008).

At least one of WithL1 and WithL2, and WithTTL, are required. New returns an error wrapping ErrInvalidConfig for any invalid configuration.

Example

The key function puts the owner first, so two users can never address the same entry (RS-02).

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/JonasBorgesLM/cistern"
	"github.com/JonasBorgesLM/cistern/memory"
)

type listKey struct {
	UserID int
	ListID int
}

type taskList struct {
	Title string   `json:"title"`
	Tasks []string `json:"tasks"`
}

func main() {
	l1, err := memory.New(memory.WithMaxEntries(10_000))
	if err != nil {
		panic(err)
	}
	lists, err := cistern.New[listKey, taskList]("tasks",
		func(k listKey) string { return fmt.Sprintf("user:%d:list:%d", k.UserID, k.ListID) },
		cistern.WithL1(l1),
		cistern.WithTTL(30*time.Second),
		cistern.WithJitter(0.1),
	)
	if err != nil {
		panic(err)
	}
	ctx := context.Background()

	err = lists.Set(ctx, listKey{UserID: 42, ListID: 1}, taskList{Title: "today", Tasks: []string{"review ADR-0005"}})
	if err != nil {
		panic(err)
	}

	own, ok, _ := lists.Get(ctx, listKey{UserID: 42, ListID: 1})
	fmt.Println(own.Title, ok)

	_, ok, _ = lists.Get(ctx, listKey{UserID: 43, ListID: 1})
	fmt.Println("another user's list cached:", ok)
}
Output:
today true
another user's list cached: false

func (*Cache[K, V]) Close

func (c *Cache[K, V]) Close()

Close stops the cache listening to its Bus. It is safe to call more than once, and on a cache without a Bus.

func (*Cache[K, V]) Delete

func (c *Cache[K, V]) Delete(ctx context.Context, k K) error

Delete removes k from every configured level. L1 is always evicted, and an error from L2 is returned: a failed invalidation that went unreported would leave stale data on every replica until it expired (ADR-0002).

Example

After writing the source of truth, invalidate rather than Set (ADR-0003).

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/JonasBorgesLM/cistern"
	"github.com/JonasBorgesLM/cistern/memory"
)

func main() {
	l1, _ := memory.New()
	cache, _ := cistern.New[string, string]("profiles", func(id string) string { return "user:" + id }, cistern.WithL1(l1), cistern.WithTTL(time.Minute))
	ctx := context.Background()

	_ = cache.Set(ctx, "42", "old display name")

	// ... the profile is updated in the database ...
	if err := cache.Delete(ctx, "42"); err != nil {
		fmt.Println("invalidation failed; the cache may serve stale data:", err)
	}

	_, ok, _ := cache.Get(ctx, "42")
	fmt.Println("cached after invalidation:", ok)
}
Output:
cached after invalidation: false

func (*Cache[K, V]) Get

func (c *Cache[K, V]) Get(ctx context.Context, k K) (value V, ok bool, err error)

Get returns the cached value for k, reading L1 before L2. ok reports a hit; a miss is not an error. A remembered absence (negative caching) returns ErrNotFound.

Reads fail open (ADR-0002): a level that errors, or an entry that does not decode, is treated as a miss. Apart from ErrNotFound, Get returns an error only for an invalid key (ErrInvalidKey) or when ctx is done.

func (*Cache[K, V]) GetOrLoad

func (c *Cache[K, V]) GetOrLoad(ctx context.Context, k K, load Loader[V], opts ...EntryOption) (V, error)

GetOrLoad returns the cached value for k, or loads it with load, stores it and returns it. Concurrent calls for the same missing key share one load (RF-05); each caller still decodes its own copy.

Like Get, it fails open: a cache level that errors is a miss, and a value that cannot be stored is still returned. A loader error is returned as is and not cached, except ErrNotFound when negative caching is enabled (WithNegativeTTL), which is remembered and returned as ErrNotFound until it expires. A load that exceeds the load timeout returns an error wrapping context.DeadlineExceeded.

Example

GetOrLoad is cache-aside in one call: a miss runs the loader and stores what it returns (RF-01, RF-05). With WithNegativeTTL, a loader's ErrNotFound is remembered too, so a missing key does not reach the source on every request (RF-06). TTL overrides the cache's TTL for one entry (RF-03).

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/JonasBorgesLM/cistern"
	"github.com/JonasBorgesLM/cistern/memory"
)

func main() {
	l1, _ := memory.New()
	profiles, _ := cistern.New[string, string]("profiles", func(id string) string { return "user:" + id },
		cistern.WithL1(l1), cistern.WithTTL(time.Minute), cistern.WithNegativeTTL(10*time.Second))
	ctx := context.Background()

	queries := 0
	fromDB := func(id string) cistern.Loader[string] {
		return func(context.Context) (string, error) {
			queries++
			if id == "42" {
				return "Ada", nil
			}
			return "", cistern.ErrNotFound // the loader's way to say "no such row"
		}
	}

	for range 2 {
		name, _ := profiles.GetOrLoad(ctx, "42", fromDB("42"), cistern.TTL(5*time.Minute))
		fmt.Println(name)
	}
	for range 2 {
		_, err := profiles.GetOrLoad(ctx, "7", fromDB("7"))
		fmt.Println(errors.Is(err, cistern.ErrNotFound))
	}
	fmt.Println("database queries:", queries)
}
Output:
Ada
Ada
true
true
database queries: 2

func (*Cache[K, V]) InvalidateTag

func (c *Cache[K, V]) InvalidateTag(ctx context.Context, tag string) error

InvalidateTag retires every entry carrying tag, in every replica, without scanning (RF-10, ADR-0005): it advances the tag's generation counter, and entries that recorded an older generation read as misses from then on.

Like Delete, it fails loud (ADR-0002). It returns ErrInvalidConfig on a cache built without WithTags and ErrInvalidKey for an invalid tag.

Example

Tags group entries that one write invalidates together (RF-10). The owner is in the tag as well as in the key, so invalidating one user's lists never touches another's (RS-02, ADR-0005).

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/JonasBorgesLM/cistern"
	"github.com/JonasBorgesLM/cistern/memory"
)

type listKey struct {
	UserID int
	ListID int
}

func main() {
	l1, _ := memory.New()
	lists, _ := cistern.New[listKey, string]("tasks",
		func(k listKey) string { return fmt.Sprintf("user:%d:list:%d", k.UserID, k.ListID) },
		cistern.WithL1(l1), cistern.WithTTL(time.Minute),
		cistern.WithTags(func(k listKey) []string { return []string{fmt.Sprintf("user:%d:lists", k.UserID)} }))
	ctx := context.Background()

	_ = lists.Set(ctx, listKey{UserID: 42, ListID: 1}, "today")
	_ = lists.Set(ctx, listKey{UserID: 42, ListID: 2}, "someday")
	_ = lists.Set(ctx, listKey{UserID: 43, ListID: 1}, "groceries")

	// ... user 42 creates a task in the database ...
	if err := lists.InvalidateTag(ctx, "user:42:lists"); err != nil {
		fmt.Println("invalidation failed; the cache may serve stale data:", err)
	}

	for _, k := range []listKey{{42, 1}, {42, 2}, {43, 1}} {
		_, ok, _ := lists.Get(ctx, k)
		fmt.Printf("user %d list %d cached: %v\n", k.UserID, k.ListID, ok)
	}
}
Output:
user 42 list 1 cached: false
user 42 list 2 cached: false
user 43 list 1 cached: true

func (*Cache[K, V]) Set

func (c *Cache[K, V]) Set(ctx context.Context, k K, v V, opts ...EntryOption) error

Set stores v under k in every configured level, L2 first. Unlike a read, it fails loud (ADR-0002): an error from any level is returned, so a caller that asked to store a value knows when it was not stored.

After writing the source of truth, prefer Delete to Set: two concurrent Sets can reach the cache in the opposite order from the source, while two Deletes can only cost a miss (ADR-0003).

type CoalescedEvent

type CoalescedEvent struct {
	Namespace string
	Key       string // empty unless WithHookKeys
}

CoalescedEvent describes a caller served by another caller's load.

type EntryOption

type EntryOption func(*entryConfig)

EntryOption configures a single call.

func TTL

func TTL(d time.Duration) EntryOption

TTL overrides the cache's TTL for one entry. It must be positive. With both levels configured it sets the L2 TTL; the L1 TTL is the smaller of it and the configured L1 TTL (ADR-0004). In GetOrLoad, the options of the call that starts a load are the ones that apply to what it stores.

type ErrorEvent

type ErrorEvent struct {
	Namespace string
	Key       string // empty unless WithHookKeys
	Op        Op
	Level     Level
	Err       error
}

ErrorEvent describes a swallowed error. Level is zero when the error is not tied to one level.

type HitEvent

type HitEvent struct {
	Namespace string
	Key       string // empty unless WithHookKeys
	Level     Level
	Absent    bool
}

HitEvent describes a served read. Absent reports a remembered absence.

type Hooks

type Hooks struct {
	// OnHit fires when a read is served, with the level that served it.
	OnHit func(ctx context.Context, e HitEvent)
	// OnMiss fires when a read finds nothing usable.
	OnMiss func(ctx context.Context, e MissEvent)
	// OnLoad fires when a loader returns.
	OnLoad func(ctx context.Context, e LoadEvent)
	// OnCoalesced fires for a caller served by another caller's load (RF-05).
	OnCoalesced func(ctx context.Context, e CoalescedEvent)
	// OnError fires for an error that fail-open swallowed (ADR-0002). Errors
	// that are returned to the caller are not reported here.
	OnError func(ctx context.Context, e ErrorEvent)
	// OnInvalidate fires when a key or tag is invalidated, by this instance
	// or, with a Bus, by another.
	OnInvalidate func(ctx context.Context, e InvalidateEvent)
}

Hooks observe a cache (RF-15, ADR-0015). Every field may be nil.

Hooks are called synchronously and must not block: a hook that blocks is a read that blocks. A hook that panics is recovered and ignored. Events carry the namespace; the consumer key only with WithHookKeys, because keys usually embed a user id and hooks usually feed logs (RS-08).

type InvalidateEvent

type InvalidateEvent struct {
	Namespace string
	Key       string // empty unless WithHookKeys, for tag invalidations, and for a Remote one of a hashed key (only its hash travels)
	Tag       string
	Remote    bool
}

InvalidateEvent describes an invalidation: of a key (Tag is empty) or of a tag. Remote reports one that arrived from another instance over the Bus. Tags are always present — they name groups, not entries.

type Level

type Level uint8

Level identifies a cache level in hook events.

const (
	LevelL1 Level = iota + 1
	LevelL2
)

The levels. The zero Level means an event is not tied to one level.

func (Level) String

func (l Level) String() string

String returns "L1", "L2", or "" for the zero Level.

type LoadEvent

type LoadEvent struct {
	Namespace string
	Key       string // empty unless WithHookKeys
	Duration  time.Duration
	Err       error
}

LoadEvent describes a loader call.

type Loader

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

Loader fetches a value from the source of truth. It returns ErrNotFound, possibly wrapped, when the value does not exist.

The context it receives carries the values of the caller that started the load but not its cancellation, and is bounded by the load timeout (ADR-0007).

type MissEvent

type MissEvent struct {
	Namespace string
	Key       string // empty unless WithHookKeys
}

MissEvent describes a read that found nothing usable.

type NoCache

type NoCache interface {
	NoCache()
}

NoCache marks a type whose values must never be cached (RS-04): secrets, tokens, sessions. A cache whose value type implements it — directly or through a pointer — cannot be built, and Set and GetOrLoad refuse such values behind an interface value type, with ErrUncacheable.

The mark is a guard rail, not a classifier: a secret inside a type that does not implement NoCache is not detected (docs/THREAT-MODEL.md, T-03).

type Op

type Op string

Op names the operation whose error a fail-open read swallowed.

const (
	OpRead     Op = "read"     // a level could not answer
	OpDecode   Op = "decode"   // an entry was malformed, from another codec, or did not decode (RS-06)
	OpPopulate Op = "populate" // a loaded value could not be stored
	OpBackfill Op = "backfill" // an L2 entry could not be copied into L1
	OpEvent    Op = "event"    // a Bus event could not be applied locally
)

The operations reported through OnError.

type Option

type Option func(*config)

Option configures a Cache. Options are validated together by New, which returns an error wrapping ErrInvalidConfig rather than panicking (RF-16).

func WithBus

func WithBus(b bus.Bus) Option

WithBus connects the cache to other instances through b (RF-11, ADR-0006): Delete and InvalidateTag publish an event, and events from other instances evict the local L1 copy of a key, or retire a tag locally, at once instead of after the L1 TTL. The cache subscribes in New; call Close to stop.

The Bus is best-effort; a lost event costs at most one L1 TTL of staleness (RNF-11). A publish failure is returned by the invalidation that caused it.

Example

Each replica has its own L1; a Bus carries invalidations between them, so a Delete on one drops the others' copies at once instead of after their L1 TTL (RF-11, RF-12, ADR-0006). Across processes the Bus is redisstore.NewBus; bus.NewLocal connects caches in one process.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/JonasBorgesLM/cistern"
	"github.com/JonasBorgesLM/cistern/bus"
	"github.com/JonasBorgesLM/cistern/memory"
)

func main() {
	b := bus.NewLocal()
	replica := func() *cistern.Cache[string, string] {
		l1, _ := memory.New()
		c, err := cistern.New[string, string]("profiles", func(id string) string { return "user:" + id },
			cistern.WithL1(l1), cistern.WithTTL(time.Hour), cistern.WithBus(b))
		if err != nil {
			panic(err)
		}
		return c
	}
	a, c := replica(), replica()
	defer a.Close() // ends the cache's subscription
	defer c.Close()
	ctx := context.Background()

	_ = a.Set(ctx, "42", "Ada")
	_ = c.Set(ctx, "42", "Ada")

	// ... the profile changes; replica a handles the write ...
	_ = a.Delete(ctx, "42")

	_, ok, _ := c.Get(ctx, "42")
	fmt.Println("replica c still cached:", ok)
}
Output:
replica c still cached: false

func WithCodec

func WithCodec(cd codec.Codec) Option

WithCodec sets how values are encoded. It defaults to codec.JSON.

func WithHookKeys

func WithHookKeys() Option

WithHookKeys puts the consumer key in hook events. Without it events carry only the namespace, because keys usually embed a user id and hooks usually feed logs (RS-08).

func WithHooks

func WithHooks(h Hooks) Option

WithHooks sets the hooks the cache reports to (RF-15, ADR-0015).

Example

Hooks are where fail-open reads report what they swallowed (ADR-0015). Events carry the namespace; the key only with WithHookKeys (RS-08).

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/JonasBorgesLM/cistern"
	"github.com/JonasBorgesLM/cistern/memory"
)

func main() {
	l1, _ := memory.New()
	var misses int
	cache, _ := cistern.New[string, string]("profiles", func(id string) string { return "user:" + id },
		cistern.WithL1(l1), cistern.WithTTL(time.Minute),
		cistern.WithHooks(cistern.Hooks{
			OnMiss: func(_ context.Context, e cistern.MissEvent) { misses++ },
			OnError: func(_ context.Context, e cistern.ErrorEvent) {
				fmt.Println("swallowed:", e.Op, e.Level, e.Err)
			},
		}))

	_, _, _ = cache.Get(context.Background(), "42")
	fmt.Println("misses:", misses)
}
Output:
misses: 1

func WithJitter

func WithJitter(f float64) Option

WithJitter shortens each entry's TTL by a random fraction of up to f, so entries written together do not expire together (RF-04). f must be in [0, 1). Jitter never lengthens a TTL, and the same draw applies to both levels, so L1 still never outlives L2.

func WithKeyHashing

func WithKeyHashing() Option

WithKeyHashing stores a consumer key longer than MaxKeyBytes as its SHA-256 instead of refusing it (RS-03, ADR-0008). Keys within the limit are never hashed, and every key is still checked for control characters and UTF-8.

func WithL1

func WithL1(s Store) Option

WithL1 sets the in-process level, typically a memory.Store.

func WithL1TTL

func WithL1TTL(d time.Duration) Option

WithL1TTL caps how long an entry lives in L1. It must be positive and no longer than the TTL (RF-08), and requires WithL1. It defaults to the TTL.

With more than one replica, it is also the worst-case staleness: a replica that misses an invalidation serves its copy until its L1 entry expires (ADR-0004, RNF-11).

func WithL2

func WithL2(s Store) Option

WithL2 sets the shared level, typically a redisstore.Store.

func WithLoadTimeout

func WithLoadTimeout(d time.Duration) Option

WithLoadTimeout bounds how long a load may run (ADR-0007). It must be positive and defaults to DefaultLoadTimeout. When it expires, the callers waiting on the load get an error wrapping context.DeadlineExceeded and the next caller starts a fresh load, even if the loader never returns.

func WithMaxValueBytes

func WithMaxValueBytes(n int) Option

WithMaxValueBytes bounds the encoded size of a value (RS-05). It must be positive and defaults to DefaultMaxValueBytes. Set fails with ErrValueTooLarge over it; GetOrLoad returns an oversized loaded value without caching it; a stored entry over it is read as a miss (ADR-0009).

func WithNegativeTTL

func WithNegativeTTL(d time.Duration) Option

WithNegativeTTL enables negative caching (RF-06): when a loader returns ErrNotFound, the absence is remembered for d instead of calling the loader again on every request for the missing key (T-11). d must be positive and no longer than the TTL; the L1 copy of an absence also obeys the L1 TTL.

func WithTTL

func WithTTL(d time.Duration) Option

WithTTL sets how long an entry lives, unless a call overrides it with TTL. It is required and must be positive. With both levels configured it is the L2 TTL.

func WithTags

func WithTags[K comparable](tags func(K) []string) Option

WithTags declares how a key's tags are derived, which enables InvalidateTag (RF-10, ADR-0005): every entry records the generation of its tags, and a tag invalidated since reads as a miss. Reads and writes derive tags from the same function, so they can never disagree. At most 8 tags per key, each valid like a key; order and duplicates do not matter.

The cache's authoritative level — L2 if there is one, else L1 — must implement TagStore; New checks it.

type Store

type Store interface {
	// Get returns the value stored under key. ok is false when there is no
	// live entry; that is not an error. An error means the store could not
	// answer.
	Get(ctx context.Context, key string) (value []byte, ok bool, err error)

	// Set stores value under key, replacing any existing entry, for ttl.
	// ttl must be positive.
	Set(ctx context.Context, key string, value []byte, ttl time.Duration) error

	// Delete removes the entry under key. Deleting a missing key is not an
	// error.
	Delete(ctx context.Context, key string) error
}

Store is one cache level: in-process (the memory package) or shared (the redisstore module). Both levels hold encoded bytes behind this one interface (ADR-0014), so any implementation can be checked against the same conformance suite (RF-14).

Keys are physical keys already built and validated by Cache (ADR-0008). Implementations must be safe for concurrent use, must honour ctx, must not retain the value slice passed to Set, and must not let a caller's changes to a slice returned by Get reach the stored entry.

type TagStore

type TagStore interface {
	Store

	// GetTagged returns the value stored under key, as Get does, together with
	// the current generation of each counter in genKeys, in order. A missing
	// counter is created, with genTTL. Where the store allows, value and
	// generations are read in one round trip.
	GetTagged(ctx context.Context, key string, genKeys []string, genTTL time.Duration) (value []byte, ok bool, gens []uint64, err error)

	// Bump advances each counter in genKeys to a value it has never held,
	// creating a missing one as GetTagged does, and sets its TTL to genTTL.
	Bump(ctx context.Context, genKeys []string, genTTL time.Duration) error
}

TagStore is a Store that also keeps tag generation counters, which tag invalidation needs from the cache's authoritative level: L2 when there is one, else L1 (RF-10, ADR-0005).

Counters are addressed by keys the Cache builds. A missing counter — never written, expired or evicted — must be created at an unpredictable value in [1, 2^62) (NewGeneration), never at a small or fixed one: an evicted counter that restarted low could repeat a generation an old entry recorded, and resurrect it (T-14).

Directories

Path Synopsis
Package bus carries invalidation events between cache instances (RF-11, ADR-0006).
Package bus carries invalidation events between cache instances (RF-11, ADR-0006).
Package cisterntest is the conformance suite for cistern.Store implementations (RF-14).
Package cisterntest is the conformance suite for cistern.Store implementations (RF-14).
Package codec defines how cistern turns values into the bytes it stores, and provides the JSON default (RF-13).
Package codec defines how cistern turns values into the bytes it stores, and provides the JSON default (RF-13).
internal
envelope
Package envelope is the storage format of every cistern entry (ADR-0009, amended by ADR-0005): a header — version, flags, logical expiry, codec id, tag generations — followed by the encoded value.
Package envelope is the storage format of every cistern entry (ADR-0009, amended by ADR-0005): a header — version, flags, logical expiry, codec id, tag generations — followed by the encoded value.
singleflight
Package singleflight coalesces concurrent loads of the same key into one call (RF-05), with the semantics ADR-0007 records: the load runs detached from any single caller, is bounded by a timeout after which the key is free again, and a panic reaches every caller still waiting.
Package singleflight coalesces concurrent loads of the same key into one call (RF-05), with the semantics ADR-0007 records: the load runs detached from any single caller, is bounded by a timeout after which the key is free again, and a panic reaches every caller still waiting.
Package memory is cistern's in-process L1 Store: an LRU over encoded bytes, bounded by an entry count and a byte size, with a TTL per entry.
Package memory is cistern's in-process L1 Store: an LRU over encoded bytes, bounded by an entry count and a byte size, with a TTL per entry.
redisstore module

Jump to

Keyboard shortcuts

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