cache

package
v0.1.2 Latest Latest
Warning

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

Go to latest
Published: Oct 18, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package cache is nForge's key/value cache: one non-generic *Cache backed by Redis or by a bounded in-process map, chosen by configuration.

Objects (Get, Set, SetIfAbsent, GetOrLoad) are serialized with the framework codec registry in the configured format (json or cbor), with its payload limit, required fields and validators. Raw byte operations (GetBytes, SetBytes, SetBytesIfAbsent) and counters (Increment, decimal ASCII) bypass the codec. No envelope is added to stored values, so a key written with one codec cannot be read with another: changing cache.codec needs the old entries to expire, be invalidated, or a new cache.prefix.

Every key is namespaced (see keyspace); with a tenant resolver the tenant of the operation's context is part of the namespace and is required.

The package depends only on the codec package and the standard library (plus go-redis behind unexported adapters).

Index

Constants

View Source
const (
	DriverRedis  = "redis"
	DriverMemory = "memory"
)

Drivers.

View Source
const (
	CodecJSON = "json"
	CodecCBOR = "cbor"
)

Object codecs (short names of the framework codec registry).

View Source
const (
	B  ByteSize = 1
	KB          = 1024 * B
	MB          = 1024 * KB
	GB          = 1024 * MB
)

Size units.

Variables

View Source
var (
	ErrDisabled          = errors.New("cache: disabled")
	ErrClosed            = errors.New("cache: closed")
	ErrInvalidConfig     = errors.New("cache: invalid config")
	ErrUnsupportedDriver = errors.New("cache: unsupported driver")
	ErrUnsupportedCodec  = errors.New("cache: unsupported codec")
	ErrInvalidKey        = errors.New("cache: invalid key")
	ErrTenantRequired    = errors.New("cache: tenant required")
	ErrInvalidTTL        = errors.New("cache: invalid ttl")
	ErrInvalidTarget     = errors.New("cache: invalid target")
	ErrNotFound          = errors.New("cache: not found")
	ErrEncode            = errors.New("cache: encode failed")
	ErrDecode            = errors.New("cache: decode failed")
	ErrValueTooLarge     = errors.New("cache: value too large")
	ErrNotInteger        = errors.New("cache: value is not an integer")
	ErrCounterOverflow   = errors.New("cache: counter overflow")
	ErrUnavailable       = errors.New("cache: backend unavailable")
	// ErrOutcomeUnknown means a mutation may have reached Redis but no
	// authoritative reply arrived: it may or may not have been applied, and
	// it is never replayed automatically. It also matches ErrUnavailable.
	ErrOutcomeUnknown = errors.New("cache: outcome unknown")
	ErrLoadCapacity   = errors.New("cache: too many concurrent loads")
	ErrLoaderPanic    = errors.New("cache: loader panicked")
)

Sentinel categories. Every error returned by a Cache method (except the application errors returned by a GetOrLoad loader, which are passed through) matches one of them with errors.Is; details are in *Error.

Functions

func ApplyDefaults

func ApplyDefaults(cfg *Config)

ApplyDefaults fills the zero values with the defaults. read_retries is a pointer so that an explicit 0 (no retry) differs from "unset".

func ValidateConfig

func ValidateConfig(cfg Config) error

ValidateConfig checks cfg after applying the defaults to a copy. A disabled config is always valid; only the selected driver's section is checked, and nothing is dialed or read from disk.

Types

type ByteSize

type ByteSize int64

ByteSize is a size in bytes that accepts units in YAML and env files: "1mb", "512kb", "1.5gb", "100b" or a plain number of bytes. Units are base 1024 and case-insensitive ("5MB", "5 MiB"), the same syntax as config.ByteSize (the cache package cannot import config).

func ParseByteSize

func ParseByteSize(s string) (ByteSize, error)

ParseByteSize parses a size such as "1mb" or "1048576".

func (*ByteSize) UnmarshalText

func (b *ByteSize) UnmarshalText(text []byte) error

UnmarshalText implements encoding.TextUnmarshaler (env files).

func (*ByteSize) UnmarshalYAML

func (b *ByteSize) UnmarshalYAML(unmarshal func(any) error) error

UnmarshalYAML accepts both "1mb" and 1048576 in YAML. It uses the unmarshal-function form so the package needs no YAML dependency.

type Cache

type Cache struct {
	// contains filtered or unexported fields
}

Cache is safe for concurrent use. Create it with Open and release it with Shutdown; after Shutdown every method returns ErrClosed.

func Open

func Open(ctx context.Context, cfg Config, opts ...Option) (*Cache, error)

Open validates a deep copy of cfg (after ApplyDefaults), creates the selected driver and pings it with ctx. A disabled cfg returns ErrDisabled. On any failure the partially created resources are released. Open never panics or exits; deciding that a failure is fatal is the caller's job.

func (*Cache) Delete

func (c *Cache) Delete(ctx context.Context, keys ...string) (int64, error)

Delete removes the given keys (exact keys only: no patterns) and returns how many distinct existing keys were removed. Every key is validated before anything is deleted; duplicates are removed once.

func (*Cache) Driver

func (c *Cache) Driver() string

Driver returns the active driver name ("redis" or "memory").

func (*Cache) Exists

func (c *Cache) Exists(ctx context.Context, key string) (bool, error)

Exists reports whether a live entry exists (false, nil when missing or expired).

func (*Cache) Expire

func (c *Cache) Expire(ctx context.Context, key string, ttl time.Duration) (bool, error)

Expire sets a new TTL (> 0) on an existing entry without touching its value; false, nil when the key is missing. To make an entry persistent, write it again with ttl 0.

func (*Cache) Get

func (c *Cache) Get(ctx context.Context, key string, target any) error

Get decodes the object stored at key into target (a non-nil pointer). On a miss (ErrNotFound) or any error the target is left unchanged. A decode failure is ErrDecode, never a miss.

func (*Cache) GetBytes

func (c *Cache) GetBytes(ctx context.Context, key string) ([]byte, error)

GetBytes returns an independent copy of a raw value; a present empty value is a non-nil empty slice. A missing key is ErrNotFound.

func (*Cache) GetOrLoad

func (c *Cache) GetOrLoad(ctx context.Context, key string, target any, ttl time.Duration,
	load func(context.Context) (any, error)) error

GetOrLoad reads the object at key into target; on a miss it calls load, stores the encoded result with ttl and decodes it into target.

  • A hit never calls load. Any read error other than ErrNotFound (Redis down, corrupt data) is returned without calling load.
  • Concurrent callers of the same full key (tenant included) in this process share one load: the leader rechecks the cache, runs load once with a context detached from the first caller (values kept, cancellation not) and bounded by cache.load_timeout, and encodes the result once. Each caller decodes its own copy into its own target. The leader's loader and TTL govern the flight, so callers of one key must use compatible loaders and TTLs. It is not a distributed lock: other instances may load the same key concurrently.
  • Load errors are returned unchanged (and not cached); application errors must not be sent blindly to HTTP clients. A panic in load becomes ErrLoaderPanic for every caller.
  • Storing the result is best effort: a backend write failure, a full memory cache or a value larger than max_value_bytes is logged as a warning and the loaded value is still returned.
  • A caller whose context ends returns its context error; the load is canceled only when every caller has left (or on Shutdown).
  • At most cache.limits.max_inflight_loads distinct flights run at once; beyond that a new key fails with ErrLoadCapacity (joining an existing flight is always allowed).

target is only assigned on success.

func (*Cache) Increment

func (c *Cache) Increment(ctx context.Context, key string, delta int64) (int64, error)

Increment atomically adds delta (any sign, or zero) to a decimal int64 counter and returns the new value. A missing key starts at 0 and becomes a persistent counter; an existing TTL is preserved. Non-integer contents give ErrNotInteger and overflow ErrCounterOverflow, both without changing the entry. Counter keys must not be used for objects (and vice versa).

func (*Cache) Ping

func (c *Cache) Ping(ctx context.Context) error

Ping checks the backend (Redis PING; the memory driver only checks that the cache is open). It needs no tenant and is used by readiness probes.

func (*Cache) Set

func (c *Cache) Set(ctx context.Context, key string, value any, ttl time.Duration) error

Set encodes value with the configured codec and stores it, replacing any previous value and TTL. ttl 0 = no expiration.

func (*Cache) SetBytes

func (c *Cache) SetBytes(ctx context.Context, key string, value []byte, ttl time.Duration) error

SetBytes stores a copy of value unchanged (nil is an empty value, not a deletion), replacing any previous value and TTL. ttl 0 = no expiration.

func (*Cache) SetBytesIfAbsent

func (c *Cache) SetBytesIfAbsent(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error)

SetBytesIfAbsent atomically stores value and its TTL only when no live entry exists. It returns false, nil when the key exists (value and TTL untouched). A lost Redis reply is ErrOutcomeUnknown, never false.

func (*Cache) SetIfAbsent

func (c *Cache) SetIfAbsent(ctx context.Context, key string, value any, ttl time.Duration) (bool, error)

SetIfAbsent is Set only when no live entry exists; see SetBytesIfAbsent.

func (*Cache) Shutdown

func (c *Cache) Shutdown(ctx context.Context) error

Shutdown stops admitting operations (permanently: ErrClosed), stops the memory cleanup worker, cancels shared loaders and waiting GetOrLoad callers, waits for the operations in progress and then closes the driver (Redis pool, or clears the memory). If ctx ends first it returns the context error and leaves the cache closed; a later Shutdown keeps waiting for the remaining cooperative work. It is idempotent and safe to call concurrently.

func (*Cache) TTL

func (c *Cache) TTL(ctx context.Context, key string) (Expiration, error)

TTL reports whether key exists and how long it has left.

type Config

type Config struct {
	Enabled     bool          `yaml:"enabled"`      // default: false (deps.Cache is nil)
	Driver      string        `yaml:"driver"`       // redis (default) | memory
	Codec       string        `yaml:"codec"`        // object codec: json (default) | cbor; must be enabled in the codec registry
	Prefix      string        `yaml:"prefix"`       // optional; "" derives nforge:<env>:[<tenant>:]<app.name>:
	Limits      LimitsConfig  `yaml:"limits"`       //
	LoadTimeout time.Duration `yaml:"load_timeout"` // bounds a shared GetOrLoad loader; default: 5s
	Redis       RedisConfig   `yaml:"redis"`        // used only when driver is redis
	Memory      MemoryConfig  `yaml:"memory"`       // used only when driver is memory
}

Config is the `cache:` section. It is standalone (no framework imports): config.Config embeds it and deps opens it; other programs can call Open directly. Zero values of caps and timeouts take the defaults below; see ApplyDefaults.

func (Config) LogValue

func (cfg Config) LogValue() slog.Value

LogValue reports the shape of the configuration for slog, without secrets, paths or addresses.

type Error

type Error struct {
	Op     string // public method, e.g. "get", "set_if_absent"
	Driver string // "redis" | "memory" | "" (before a backend exists)
	Kind   error  // the sentinel category
	Reason string // bounded machine-readable detail, e.g. "timeout", "auth"
	// contains filtered or unexported fields
}

Error is the typed error of the cache. Its message never contains keys, tenant IDs, cached contents, credentials, URLs or Redis error text: only the operation, the driver, the category and a bounded reason. Unwrap exposes only safe causes: context errors and framework codec errors.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Is

func (e *Error) Is(target error) bool

Is matches the category and the extra categories.

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the safe cause (a context error or a codec error), if any.

type Expiration

type Expiration struct {
	Exists     bool
	Persistent bool
	// Remaining is the time left of an expiring key; never negative, and it
	// may round to zero while the key is still live.
	Remaining time.Duration
}

Expiration is the result of TTL: a missing key, a persistent key or an expiring one. Redis's negative sentinels (-1, -2) never leak out.

type LimitsConfig

type LimitsConfig struct {
	MaxKeyBytes      int      `yaml:"max_key_bytes"`      // full resolved key (prefix included); default: 1024, at most 64 KiB
	MaxValueBytes    ByteSize `yaml:"max_value_bytes"`    // encoded or raw value; default: 1mb, at most 64 MiB
	MaxInflightLoads int      `yaml:"max_inflight_loads"` // distinct concurrent GetOrLoad flights; default: 1024
}

LimitsConfig bounds keys, values and concurrent loads.

type MemoryConfig

type MemoryConfig struct {
	MaxEntries      int           `yaml:"max_entries"`      // default: 10000
	MaxBytes        ByteSize      `yaml:"max_bytes"`        // key + value bytes; default: 64mb
	CleanupInterval time.Duration `yaml:"cleanup_interval"` // expired-entry sweep; default: 30s
}

MemoryConfig bounds the in-process driver. The budget counts key and value bytes only, not the Go map/list/allocator overhead: it is not a hard cap on the process memory.

type Option

type Option func(*options) error

Option configures Open. Nil values and conflicting duplicates are rejected by Open with ErrInvalidConfig.

func WithCodecRegistry

func WithCodecRegistry(registry *codec.Registry) Option

WithCodecRegistry binds the registry used for object values. Without it, Open captures codec.Default() once; the registry never changes afterwards.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger sets the logger for the few events the cache logs itself (best-effort GetOrLoad write failures). Default: a discarding logger.

func WithNamespace

func WithNamespace(env, appName string) Option

WithNamespace provides the trusted environment and application name used by the default prefix nforge:<env>:[<tenant>:]<app>:. It is required when cache.prefix is empty.

func WithTenantResolver

func WithTenantResolver(resolve func(context.Context) (string, error)) Option

WithTenantResolver enables tenant isolation: every operation resolves the tenant from its context and inserts it into the key namespace; a missing tenant fails with ErrTenantRequired before any I/O. It is an adapter to the framework's existing tenant context (deps binds database.TenantFromContext), not a separate tenant system. The resolver must return a trusted, already validated ID.

type RedisConfig

type RedisConfig struct {
	URL      string          `yaml:"url"`      // optional redis:// or rediss:// URL; wins for address, credentials, database and TLS
	Host     string          `yaml:"host"`     // default: localhost
	Port     int             `yaml:"port"`     // default: 6379
	Username string          `yaml:"username"` // optional (ACL user)
	Password string          `yaml:"password"` // optional; no AUTH is sent when username and password are empty
	Database int             `yaml:"database"` // default: 0
	TLS      RedisTLSConfig  `yaml:"tls"`      //
	Pool     RedisPoolConfig `yaml:"pool"`     //
	Timeouts RedisTimeouts   `yaml:"timeouts"` //
	Retry    RedisRetry      `yaml:"retry"`    //
}

RedisConfig configures the Redis driver. Never log it with a plain formatter expecting secrets: Format and LogValue redact the URL, the password and the CA file path.

func (RedisConfig) Format

func (r RedisConfig) Format(f fmt.State, verb rune)

Format redacts the URL, password and CA file path for every verb (%v, %+v, %#v, %s), including when RedisConfig is printed inside Config.

type RedisPoolConfig

type RedisPoolConfig struct {
	Size            int           `yaml:"size"`               // default: 32
	MinIdleConns    int           `yaml:"min_idle_conns"`     // default: 2
	MaxIdleConns    int           `yaml:"max_idle_conns"`     // default: 32
	MaxActiveConns  int           `yaml:"max_active_conns"`   // hard cap of open connections; default: 32
	MaxConnIdleTime time.Duration `yaml:"max_conn_idle_time"` // default: 5m
	MaxConnLifetime time.Duration `yaml:"max_conn_lifetime"`  // default: 30m
}

RedisPoolConfig sizes the connection pool.

type RedisRetry

type RedisRetry struct {
	ReadRetries *int          `yaml:"read_retries"` // extra read attempts, 0..3; default: 1 (explicit 0 disables)
	MinBackoff  time.Duration `yaml:"min_backoff"`  // default: 50ms
	MaxBackoff  time.Duration `yaml:"max_backoff"`  // default: 500ms
}

RedisRetry configures the retries of pure reads (Get, GetBytes, Exists, TTL, Ping) on transport failures. Mutations are never retried.

type RedisTLSConfig

type RedisTLSConfig struct {
	Enabled    bool   `yaml:"enabled"`     // default: false; a rediss:// URL always enables TLS
	ServerName string `yaml:"server_name"` // default: the Redis host
	CAFile     string `yaml:"ca_file"`     // PEM roots; default: the system roots
}

RedisTLSConfig enables TLS with hostname verification and TLS 1.2+. There is deliberately no option to skip verification.

type RedisTimeouts

type RedisTimeouts struct {
	Dial      time.Duration `yaml:"dial"`      // default: 1s
	Read      time.Duration `yaml:"read"`      // default: 1s
	Write     time.Duration `yaml:"write"`     // default: 1s
	Pool      time.Duration `yaml:"pool"`      // wait for a free connection; default: 500ms
	Operation time.Duration `yaml:"operation"` // whole operation, retries included; default: 2s
}

RedisTimeouts bound every phase of an operation; a caller deadline that is earlier always wins.

Jump to

Keyboard shortcuts

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