Cache
What it is for
Section titled “What it is for”gas/cache implements gas.CacheProvider, the core interface for a
key-value cache of []byte values with a TTL: Get, Set, Delete and
Exists. It has two backends:
| Package | Backend | Use it for |
|---|---|---|
cache/memory |
a map in the process | development, tests, and a single instance |
cache/valkey |
a Valkey or other Redis-compatible server, through valkey-go | anything running more than one process |
Services depend on the interface, so which backend runs is a wiring decision.
The cache package itself holds the two sentinel errors both backends
return, and cache/cachetest holds a recording mock.
Install
Section titled “Install”go get github.com/gasmod/gas/cacheWiring
Section titled “Wiring”app := gas.NewApp( // Both backends resolve these two from the container. gas.WithServiceInstance[gas.ConfigProvider](cfg), gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// Services depend on gas.CacheProvider, not on a backend. gas.WithSingletonService[gas.CacheProvider](cachemem.New()),)func production() gas.Option { return gas.WithSingletonService[gas.CacheProvider](cachevk.New())}Both memory.New(opts...) and valkey.New(opts...) return a constructor that
the container calls with gas.ConfigProvider and gas.Logger, so both must
be registered. The constructors never return an error and do no I/O. Register
them under gas.CacheProvider: the container matches by exact type, so a
backend registered as *memory.Service or *valkey.Service does not satisfy
a gas.CacheProvider dependency. See Services and DI.
Each backend takes one option, WithConfig(cfg), which replaces the whole
configuration and skips binding from the gas.ConfigProvider. Start from the
package’s DefaultConfig():
func boundedMemory() gas.Option { cfg := cachemem.DefaultConfig() cfg.Cache.MaxEntries = 10_000 cfg.Cache.DefaultTTL = 10 * time.Minute
return gas.WithSingletonService[gas.CacheProvider]( cachemem.New(cachemem.WithConfig(cfg)), )}Cache-aside
Section titled “Cache-aside”type Profiles struct { cache gas.CacheProvider logger gas.Logger}
func NewProfiles(c gas.CacheProvider, logger gas.Logger) *Profiles { return &Profiles{cache: c, logger: logger}}
// Profile reads through the cache, falls back to the source of truth, then// populates the cache.func (s *Profiles) Profile(ctx context.Context, id string) (*Profile, error) { key := "profile:" + id
raw, err := s.cache.Get(ctx, key) switch { case err == nil: var p Profile if err := json.Unmarshal(raw, &p); err == nil { return &p, nil } // A corrupt entry is replaced below. s.logger.Warn("corrupt profile cache entry").Str("key", key).Err("error", err).Send() case errors.Is(err, cache.ErrKeyNotFound): // A miss: load and populate. default: // A broken cache surfaces instead of turning into load on the database. return nil, err }
p, err := s.loadProfile(ctx, id) if err != nil { return nil, err }
raw, err = json.Marshal(p) if err != nil { return nil, err } if err := s.cache.Set(ctx, key, raw, 5*time.Minute); err != nil { // The read succeeded; a failed populate only costs the next reader a miss. s.logger.Warn("cache populate failed").Str("key", key).Err("error", err).Send() } return p, nil}A miss is cache.ErrKeyNotFound, a sentinel error rather than a nil value, so
an empty value stays distinguishable from an absent one. The example returns
a cache error that is not a miss rather than falling through, so a broken
cache surfaces instead of quietly turning into load on the database, and it
logs a failed Set instead of failing a read that succeeded.
Invalidating
Section titled “Invalidating”func (s *Profiles) Rename(ctx context.Context, id, name string) error { if err := s.saveName(ctx, id, name); err != nil { return err } // Delete rather than overwrite: the next read repopulates from the // source of truth, so a failed write cannot leave a stale entry behind. return s.cache.Delete(ctx, "profile:"+id)}Prefer deleting over overwriting. If the write to the source of truth succeeds
and the cache write then fails, an overwrite leaves a stale entry that
outlives the request; a delete only costs the next reader a miss. Delete of
a missing key succeeds on both backends.
Set takes a TTL per entry, and the backends treat a TTL of zero
differently:
| TTL | memory | valkey |
|---|---|---|
| positive | expires after the TTL | expires after the TTL, set in milliseconds on the server |
| zero | uses cache.defaultttl; when that is also zero, never expires |
never expires |
| negative | never expires | never expires |
cache.defaultttl is zero by default, so on the in-memory backend an app
that always passes zero builds an unbounded cache. Set defaultttl or
maxentries when keys come from user input.
The in-memory backend
Section titled “The in-memory backend”- Expired entries are invisible to
GetandExists.Getdeletes the expired entry it finds; the rest are removed by a background goroutine everycache.cleanupinterval, whichInitstarts when the interval is positive andClosestops. - When
cache.maxentriesis reached, aSetof a new key first removes one expired entry, picked in map iteration order rather than by age. When none has expired, theSetfails withgas/cache/memory: max entries (<n>) reached. Overwriting an existing key always succeeds. There is no LRU eviction. - Values are stored and returned as the same slice, not copies. Modifying a
slice you passed to
Setor got fromGetchanges the cached value. Closedrops every entry. A service started again begins with an empty cache.
The Valkey backend
Section titled “The Valkey backend”Init creates the client and pings the server, bounded by
cache.dialtimeout. When that fails and cache.connretries is above zero, it
waits cache.connretryinterval, retries, and doubles the wait after each
failure, for at most connretries retries; an interval of zero or less uses
2 seconds. Each retry is logged at warn level. The ping and the waits run
under the context Init receives, so when that context ends during a wait,
Init stops with an error wrapping both the context’s error and the last
connection error. The context does not cover what comes before the PING:
valkey-go’s NewClient takes no context, so the dial and handshake it runs
ignore it. Against a server that accepts the connection and never answers,
Init runs until cache.dialtimeout ends the attempt, however soon the
context ends (KI-052, see Known issues). When every retry fails, Init returns
gas/cache/valkey: connect failed after <n> retries: <last error>.
For commands gas.CacheProvider does not cover, Client() on
*valkey.Service returns the underlying valkey.Client. It returns nil
before Init first connects, and the closed client after Close.
// Hits counts page views with INCR, a command gas.CacheProvider does not// cover. It needs the Valkey backend.func Hits(ctx context.Context, c gas.CacheProvider, page string) (int64, error) { vk, ok := c.(*cachevk.Service) if !ok { return 0, errors.New("hit counter needs the valkey cache backend") } client := vk.Client() if client == nil { return 0, cache.ErrClosed } return client.Do(ctx, client.B().Incr().Key("hits:"+page).Build()).AsInt64()}Configuration
Section titled “Configuration”Unless WithConfig is given, Init binds the settings from the
gas.ConfigProvider under the cache key, over the backend’s
DefaultConfig(), so a key you do not set keeps its default. Both backends
read the same cache key, each its own fields. Keys are case-insensitive; see
Config for how keys map to environment variables and
files.
memory
| Key | Type | Default | Description |
|---|---|---|---|
cache.maxentries |
int | 0 |
Maximum entries; 0 is unlimited |
cache.cleanupinterval |
duration | 1m |
How often expired entries are removed; 0 or less disables the cleanup goroutine |
cache.defaultttl |
duration | 0 |
TTL for a Set with a TTL of 0; 0 never expires |
No memory setting is validated.
valkey
| Key | Type | Default | Description |
|---|---|---|---|
cache.addr |
string | localhost:6379 |
Server address, host:port. Required |
cache.password |
string | Password; empty means no authentication | |
cache.db |
int | 0 |
Database number; must not be negative |
cache.dialtimeout |
duration | 5s |
Timeout for new connections, and the bound on the startup ping |
cache.writetimeout |
duration | 3s |
Passed to valkey-go as ConnWriteTimeout, its write deadline |
cache.connretries |
int | 0 |
Connection retries after the first failed attempt; 0 fails at once |
cache.connretryinterval |
duration | 2s |
Wait before the first retry, doubled after each |
The rules on addr and db are validate tags, checked when the config is
bound; a configuration given with WithConfig is used as given.
Errors
Section titled “Errors”| Error | When |
|---|---|
cache.ErrKeyNotFound |
Get of a key that is missing or has expired |
cache.ErrClosed |
Any operation before Init or after Close. On Valkey also an operation that was running when the service closed, or when a restart replaced the client |
Both are returned unwrapped, but match them with errors.Is. Other errors
start with the backend’s service name, gas/cache/memory or
gas/cache/valkey. A Valkey command error also names the command and the
key, as in gas/cache/valkey: get "profile:42": <cause>, and wraps the
valkey-go error.
Health and readiness
Section titled “Health and readiness”The Valkey backend implements gas.HealthReporter and gas.ReadyReporter.
CheckHealth fails, with cache.ErrClosed, only before Init or after
Close: the client reconnects on its own, so a network fault is not a reason
to restart the process. CheckReady sends PING with the caller’s context,
so while the server is unreachable the instance reports not ready and traffic
drains off it.
The in-memory backend implements neither. It has no external dependency, so there is nothing to report beyond the service lifecycle the app already tracks.
Testing
Section titled “Testing”cachetest.MockCache implements gas.CacheProvider, gas.HealthReporter
and gas.ReadyReporter. Each method records the call in Calls and delegates
to the matching field (GetFn, SetFn, DeleteFn, ExistsFn,
CheckHealthFn, CheckReadyFn) when it is set. Unset, Get returns
nil, nil, which cache-aside code reads as a hit with an empty value;
Exists returns false, nil, and the rest return nil. CallCount(method)
counts the calls to one method and Reset clears Calls.
func TestProfileMissPopulatesCache(t *testing.T) { mock := &cachetest.MockCache{ // Without GetFn, Get returns nil, nil: a hit with an empty value. GetFn: func(context.Context, string) ([]byte, error) { return nil, cache.ErrKeyNotFound }, } logger, err := gaslog.NewSlogLogger()() if err != nil { t.Fatal(err) }
p, err := NewProfiles(mock, logger).Profile(t.Context(), "42") if err != nil { t.Fatal(err) } if p.ID != "42" { t.Errorf("ID = %q, want 42", p.ID) } if got := mock.CallCount("Set"); got != 1 { t.Fatalf("Set called %d times, want 1", got) } if ttl := mock.Calls[1].Args[2]; ttl != 5*time.Minute { t.Errorf("Set TTL = %v, want 5m", ttl) }}To test against real cache behavior with no server, use the in-memory
backend: build it with memory.New(...) and call Init yourself, or
register it in a test app. See Testing.