Skip to content

Cache expensive work

gas/cache implements gas.CacheProvider twice: in-memory for development and single-instance deployments, and Valkey (Redis-compatible) for anything running more than one process. Your services depend on the interface, so which one is running is a wiring decision.

Terminal window
go get github.com/gasmod/gas/cache
main.go
app := gas.NewApp(
gas.WithServiceInstance[gas.ConfigProvider](cfg),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// Swap cachemem.New() for cachevk.New() and nothing else changes:
// services depend on gas.CacheProvider, not on either backend.
gas.WithSingletonService[gas.CacheProvider](cachemem.New()),
)
func production() gas.Option {
return gas.WithSingletonService[gas.CacheProvider](cachevk.New())
}
profiles/service.go
type Service struct {
cache gas.CacheProvider
db gas.DatabaseProvider
}
// Read through the cache, fall back to the database, then populate. A cache
// miss is a sentinel error, not a nil value, so an empty result stays
// distinguishable from an absent one.
func (s *Service) Profile(ctx context.Context, id string) (*Profile, error) {
key := "profile:" + id
if raw, err := s.cache.Get(ctx, key); err == nil {
var p Profile
if json.Unmarshal(raw, &p) == nil {
return &p, nil
}
// A corrupt entry is not fatal; fall through and refresh it.
} else if !errors.Is(err, cache.ErrKeyNotFound) {
return nil, err
}
p, err := s.loadProfile(ctx, id)
if err != nil {
return nil, err
}
if raw, err := json.Marshal(p); err == nil {
_ = s.cache.Set(ctx, key, raw, 5*time.Minute)
}
return p, nil
}

Two details worth copying. A miss is cache.ErrKeyNotFound, a sentinel rather than a nil value, so an empty result stays distinguishable from an absent one. And a cache failure that is not a miss is returned rather than swallowed, so a broken cache surfaces instead of quietly turning into load on your database.

func (s *Service) 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 just costs the next reader a miss.

Set takes a TTL per entry. Passing zero uses Cache.DefaultTTL, which is itself zero by default, meaning entries never expire. On the in-memory backend that makes an unbounded cache, so set either DefaultTTL or MaxEntries if keys are user-controlled.

Memory

Field Default Description
Cache.MaxEntries 0 Maximum entries; 0 is unlimited
Cache.CleanupInterval 1m How often expired entries are evicted; 0 disables
Cache.DefaultTTL 0 TTL when Set is called with 0; 0 never expires

Valkey

Field Default Description
Cache.Addr localhost:6379 Server address
Cache.Password Empty means no auth
Cache.DB 0 Database number
Cache.DialTimeout 5s Timeout for new connections
Cache.WriteTimeout 3s Write and periodic PING deadline
Cache.ConnRetries 0 Connection retry attempts
Cache.ConnRetryInterval 2s Base retry interval, doubling each attempt

The Valkey backend implements both. CheckHealth reports only whether the service is closed, because the client reconnects internally and a restart would not fix a transient network fault. CheckReady issues a PING, so traffic drains off an instance while the dependency is unreachable.

The in-memory backend implements neither on purpose: it has no external dependency and no warmup, so liveness and readiness are exactly the service lifecycle the framework already tracks.

cachetest.MockCache records calls and takes GetFn, SetFn, DeleteFn, and ExistsFn overrides, so cache-aside logic can be tested for both the hit and the miss path without a server. See Test a service.