On this page

Cache

Cache keeps replaceable data close to an operation. Inject cache.Store into the service that needs it, choose a finite TTL, and keep authoritative data in its owning repository. Start with cache.Remember to load a value only when absent.

Configure a driver#

For one-process development:

CACHE_DRIVER=memory

For shared cache across application replicas:

CACHE_DRIVER=redis
CACHE_REDIS_URL=redis://127.0.0.1:6379/0
CACHE_NAMESPACE=myapp

The code default is disabled, which leaves Resources.Cache nil. The example environment selects Redis. Required cache dependencies should be checked during service construction; optional caching needs an explicit bypass path.

Driver Scope and capacity Expiration and ownership
memory One process; bounded count and total bytes; rejects full writes Lazy expiration; app owns and closes the store
redis Shared backend; capacity and eviction are backend policy Native TTL; store borrows a Redis client
database Shared PostgreSQL/MySQL table Expired rows are unreadable; explicit cleanup reclaims them; pool is borrowed

Changing a driver does not transfer cached values. Namespaces separate keys, not permissions. Do not cache secrets or cross-tenant results without a deliberate access and retention policy.

Load on a miss and work with multiple values#

The two loading forms share the same behavior:

Need API Return value
Preserve a known Go result type cache.Remember(ctx, store, key, ttl, load) Your loader's type
Work with dynamic JSON store.Remember(ctx, key, ttl, load) any containing JSON objects, arrays, or scalars

Batch operations are covered below; use Remember first for an ordinary single-resource lookup.

Store and typed loading#

This complete helper caches a transport-independent product summary. Place it in the service that owns the query and inject its store and loader:

package service

import (
	"context"
	"time"

	"github.com/wisalgroup/labena/pkg/cache"
)

type ProductSummary struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

func CachedProduct(ctx context.Context, store cache.Store, id string,
	load func(context.Context) (ProductSummary, error),
) (ProductSummary, error) {
	return cache.Remember(ctx, store, "catalog:v1:product:"+id,
		5*time.Minute, load)
}

This proposed Catalog helper requires a non-nil store. The caller authorizes the operation and chooses a key containing every dimension that changes the result, such as tenant or locale. The loader reads authoritative data using its supplied context. The first successful call loads and caches; later calls return the cached summary until expiry or deletion.

The dynamic form uses the same cache path:

value, err := store.Remember(ctx, "settings:v1", 5*time.Minute,
    func(ctx context.Context) (any, error) {
        return map[string]any{"theme": "dark"}, nil
    })
if err != nil {
    return err
}
// value is a map[string]any on both misses and hits.
_ = value

Dynamic objects become map[string]any, arrays become []any, and numbers become json.Number. The typed helper restores its concrete type. Both forms JSON-encode and decode on a miss as well as a hit, producing caller-owned values with the same shape on both paths. Values must round-trip through JSON.

Understand Remember's guarantees#

Event Behavior
Hit Decode and return; keep the existing TTL
Miss Call the loader, encode, then store; TTL starts after loading
Loader failure Return it; do not cache the failure
Backend read failure or corrupt cached JSON Return an error; do not call the loader
Encoding or write failure Return an error, even if loading succeeded
Concurrent misses More than one loader may run
Cancellation during loading Loader should stop; canceled results are not published afterward

There is no automatic stampede lock or stale-while-revalidate behavior. A loader racing Delete or Clear can refill a key. Use versioned keys or an explicit consistency strategy where this matters. Do not put irreversible work in a loader.

Store, read, and delete#

The base store reads and writes raw bytes. This fragment belongs in an operation that already has a store and serialized encodedProduct:

const key = "catalog:v1:product:42"
if err := store.Set(ctx, key, encodedProduct, 5*time.Minute); err != nil {
    return err
}
value, found, err := store.Get(ctx, key)
if err != nil {
    return err
}
if found {
    // Decode value using the format used by the writer.
    _ = value
}
return store.Delete(ctx, key)

Import time; the injected interface is github.com/wisalgroup/labena/pkg/cache.Store. Nil and empty byte slices are valid entries, so use found, not len(value), to recognize a hit. Deleting a missing key succeeds.

Set atomically replaces one value and its expiry. TTL must be positive; zero does not mean forever. Get does not extend TTL. Expired entries are misses, and remote drivers round positive TTLs up to whole milliseconds.

Keys contain 1–255 UTF-8 bytes without NUL, are case-sensitive, and preserve trailing spaces. The size limit includes key and value. Returned data belongs to the caller; retained inputs are copied, but do not mutate slices or maps while a call is running.

Check presence#

found, err := store.Exists(ctx, "catalog:v1:product:42")
if err != nil {
    return err
}

Use found for a presence-only operation. An Exists followed by Set is not an atomic “create if absent” operation; the interface provides no uniqueness lock.

Raw and typed batches#

Use store methods for bytes:

if err := store.SetMulti(ctx, map[string][]byte{
    "binary:v1": {0, 255, 128},
    "empty:v1":  nil,
}, time.Minute); err != nil {
    return err
}
raw, err := store.GetMulti(ctx, []string{"binary:v1", "empty:v1", "missing:v1"})
if err != nil {
    return err
}
// raw contains the two hits; missing keys are omitted.
_ = raw

Use package helpers for JSON:

if err := cache.SetMulti(ctx, store, map[string]ProductSummary{
    "catalog:v1:product:42": {ID: "42", Name: "Starter product"},
}, time.Minute); err != nil {
    return err
}
products, err := cache.GetMulti[ProductSummary](ctx, store,
    []string{"catalog:v1:product:42"})
if err != nil {
    return err
}
_ = products

ProductSummary is the type defined above. cache.GetMulti[any] supports dynamic JSON. Do not read arbitrary binary values with JSON helpers; incompatible or corrupt data is an error, not a miss.

Batches accept at most 1,000 keys/items and 8 MiB of key/value data, subject to each entry's size limit. Writes validate the batch first; a later backend failure may leave partial writes. Reads are not a cross-key snapshot. A read or JSON-decoding error returns no partial result and does not mutate cached entries.

Invalidate after a write#

Delete the relevant key after the authoritative write has committed. This optional cache example runs outside any caller SQL transaction. A successful repository call inside a joined transaction is not yet a commit; the outer operation must invalidate only after its transaction completes.

if err := s.products.Rename(ctx, id, name); err != nil {
    return err
}
if err := s.cache.Delete(ctx, "catalog:v1:product:"+id); err != nil {
    logging.FromContext(ctx).WarnContext(ctx, "product cache invalidation failed",
        "error_category", logging.ErrorCategory(err))
}
return nil

This proposed service fragment uses its repository and cache dependencies and imports github.com/wisalgroup/labena/pkg/logging. It returns the committed result even if cache invalidation fails. The cached value can remain stale until its TTL expires; the earlier example bounds that lifetime to five minutes. Choose this policy only when that stale window is acceptable. Stricter consistency needs an explicit design; reporting an error cannot roll back an already committed SQL write.

Commit-before-invalidation alone does not eliminate stale-fill races. Version keys when a type or representation changes, and use a deliberate policy for concurrent writes and loaders. Cache is not the source of truth.

Clear a namespace#

if err := store.Clear(ctx); err != nil {
    return err
}

This administrative operation keeps the store usable. Memory clears the instance; Redis scans/unlinks matching namespace keys across primaries; SQL deletes within its namespace and current transaction. A remote error may leave a partial clear, and concurrent writers can repopulate data. Quiesce writers when a complete clear is required. Clear never closes a store or its borrowed connection.

Capacity, timeouts, and tuning#

The main defaults are:

Setting Default Meaning
CACHE_NAMESPACE default Remote key namespace
CACHE_MAX_ENTRY_BYTES 1048576 Key plus value limit
CACHE_MEMORY_CAPACITY 10000 Retained entry count
CACHE_MEMORY_MAX_BYTES 67108864 Retained key/value budget; 64 MiB
CACHE_OPERATION_TIMEOUT 5s Remote operation bound
CACHE_DATABASE_TABLE cache_entries SQL table

Only configure values you need to change. Process memory also includes map metadata and in-flight copies. Memory returns cache.ErrFull instead of evicting live entries; a rejected replacement leaves its old value intact. Expired entries are reclaimed on reads or admission pressure, without a maintenance goroutine.

Concrete memory stores additionally expose Usage(), PruneExpired(ctx, limit), and GetInto(ctx, key, buf[:0]) for explicit cleanup or buffer reuse. Portable services should depend on cache.Store unless they need those controls.

Redis keys use cache:<namespace>:<key>. The URL-based app setup opens a standalone client. pkg/redis.Open also supports explicit Sentinel/Cluster configuration for independent integrations; changing a URL alone does not describe every cluster topology. Backend eviction can remove entries before their TTL.

Database cache#

Set CACHE_DRIVER=database with the database driver and URL, then publish and apply the cache table:

go run ./cmd/cli cache tables
go run ./cmd/cli migrate up

Expired rows remain stored until cleanup. With a concrete database cache store:

removed, err := store.PruneExpired(ctx, 1000)

Handle err before using the deleted count. PruneExpired belongs to cache/database.Store, not the portable interface. It removes a bounded batch in that namespace; locked rows can remain for a later pass. Schedule maintenance explicitly using the application's central scheduling.

Database cache operations resolve database.Executor(ctx, pool) and join a SQL transaction using the same pool. Rollback undoes its writes and deletes. TTL starts at Set, not commit. Memory and Redis do not share that transaction behavior. See transactions.

Misses, failures, and invalidation#

A backend failure is not a miss. Decide explicitly whether your service should fail or fall back to authoritative storage. Remember itself propagates the error; it does not silently fall back. Avoid logging cached payloads.

Symptom Check
Cache dependency is nil CACHE_DRIVER and constructor validation
Every replica loads separately The memory driver is process-local
ErrInvalidTTL Supply a positive duration; no forever entries
ErrFull Memory capacity/byte budget and expired-entry cleanup
ErrTooLarge Key + value size and batch limits
ErrInvalidValue Writer/reader encoding and key version agree
SQL table missing Publish cache tables, rebuild, and apply migrations
Deleted value reappears Concurrent loader or writer refilled it

Use errors.Is with stable cache.Err… values. There are no portable counters, locks, compare-and-swap operations, or atomic multi-key transactions in this store.

Ownership and example#

App owns resource cleanup once, after users stop. Remote stores borrow their clients; modules must not close shared clients or pools. Constructors do not create tables or start background work. Standalone code owns the resources it opens.

CACHE_DRIVER=memory go run ./examples/cache

The example loads central configuration, writes and reads an entry, and closes its resources. Remote variants need the selected backend and, for SQL, applied migrations. Test hit/miss behavior, expiry, loader failures, wrong encodings, and invalidation outcomes against the driver your application actually uses.

Continue with database queries, transactions, and testing.

Documentation

Search guides, sections, and API names.