Skip to content

Cache

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.

Terminal window
go get github.com/gasmod/gas/cache
main.go
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)),
)
}
profiles/service.go
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.

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.

  • Expired entries are invisible to Get and Exists. Get deletes the expired entry it finds; the rest are removed by a background goroutine every cache.cleanupinterval, which Init starts when the interval is positive and Close stops.
  • When cache.maxentries is reached, a Set of a new key first removes one expired entry, picked in map iteration order rather than by age. When none has expired, the Set fails with gas/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 Set or got from Get changes the cached value.
  • Close drops every entry. A service started again begins with an empty cache.

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()
}

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.

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.

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.

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.

profiles/service_test.go
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.