A cache keeps the result of slow work (a heavy query, an external API call) for a while, so the next request reads it instead of doing the work again. Every value has a key and a TTL (time to live). Start with the step-by-step guide at the end of this page if you are new to caching.
c, err := app.Cache() // CACHE_STORE=memory (default) or database
user, err := cache.Remember(ctx, c, "user:42", time.Hour, func(ctx context.Context) (models.User, error) {
return orm.QueryOf[models.User](db).Find(ctx, 42)
})
err = c.Put(ctx, "settings", settings, 10*time.Minute)
v, ok, err := cache.Get[Settings](ctx, c, "settings")
err = c.Forget(ctx, "settings")
n, err := c.Increment(ctx, "visits", 1, 24*time.Hour)- Values are JSON.
Rememberruns the function once for concurrent misses of a key in the process (singleflight) and never caches errors. - Locks:
lock := c.NewLock("reports:daily", time.Minute), thenTryAcquire,Block(ctx, wait, interval)andRelease(only the owner can release).
Stores#
| Store | Notes |
|---|---|
cache.NewMemory(maxEntries) | LRU with TTLs, in process |
cache.NewDatabase(db, table) | SQLite, PostgreSQL and MySQL; Increment is one atomic upsert, so counters stay exact across servers; CreateTable (indexes expires_at), Prune for expired rows in batches (every app with CACHE_STORE=database schedules cache:prune hourly, run by the scheduler inside serve unless SCHEDULE_IN_SERVE=false) |
| Redis | vukem.WithCacheStore(rc.CacheStore("cache:")) |
| Variable | Default |
|---|---|
CACHE_STORE | memory |
CACHE_PREFIX | vukem: |
CACHE_TABLE | cache |
CACHE_MAX_ENTRIES | 10000 |
Custom stores implement cache.Store and must pass cache/cachetest.Run.
Shared stores#
Features that coordinate servers through the cache (OnOneServer() tasks, RATE_LIMIT_STORE=cache) ask cache.Shared(store) whether every server sees the same entries. A store answers by implementing cache.Sharer:
func (s *MyStore) Shared() bool { return true } // backed by a service every server reachesThe built-in stores declare it: memory is not shared, database and Redis (drivers/redis) are. The SQLite database store counts as shared only between processes on the same host; servers on several hosts need PostgreSQL, MySQL or Redis. A wrapper (metrics, tracing) forwards the answer of the store it wraps, either with its own Shared() or by exposing that store with Unwrap() cache.Store. A store that does neither is treated as not shared (fail closed), so a metrics wrapper around the memory cache cannot pass for a shared lock store. cachetest.Run fails a store that does not implement cache.Sharer.
Step by step: cache a slow query#
1. Get the cache once and keep it
type PostController struct {
db *database.DB
cache *cache.Cache
}
func NewPostController(app *vukem.App) (*PostController, error) {
db, err := app.DB()
if err != nil {
return nil, err
}
c, err := app.Cache() // memory by default; CACHE_STORE=database or Redis to share it
if err != nil {
return nil, err
}
return &PostController{db: db, cache: c}, nil
}2. Wrap the slow call in Remember
func (p *PostController) Popular(c *web.Ctx) error {
posts, err := cache.Remember(c.Context(), p.cache, "posts:popular", 10*time.Minute,
func(ctx context.Context) ([]models.Post, error) {
return orm.QueryOf[models.Post](p.db).OrderBy("views", orm.Desc).Limit(10).Get(ctx)
})
if err != nil {
return err
}
return c.JSON(http.StatusOK, posts)
}The first request runs the query and stores the JSON for 10 minutes; the next ones read it. If 100 requests miss the key at once, the query still runs once in this process.
3. Forget the key when the data changes
func (p *PostController) Update(c *web.Ctx) error {
// ... save the post ...
if err := p.cache.Forget(c.Context(), "posts:popular"); err != nil {
return err
}
return c.RedirectRoute("posts.index")
}4. Share the cache between servers
CACHE_STORE=database # a "cache" table in DATABASE_URL, created at bootOr pass a Redis store with vukem.WithCacheStore. Your code does not change.
Counters and locks#
views, err := c.Increment(ctx, "post:42:views", 1, 24*time.Hour) // the TTL is set when the key is created
lock := c.NewLock("reports:daily", 5*time.Minute) // expires on its own if the holder crashes
ok, err := lock.TryAcquire(ctx)
if err != nil || !ok {
return err // someone else is generating the report
}
defer lock.Release(ctx)lock.Block(ctx, 10*time.Second, 200*time.Millisecond) waits up to 10 seconds, polling every 200 ms, and returns cache.ErrNotAcquired when the time runs out.
Pitfalls#
- Values round-trip through JSON. Unexported fields are lost and
time.Timecomes back with its offset, not its*time.Location. Cache plain data structs. - Changing a cached struct. Old entries may not decode into the new shape. Put a version in the key (
posts:popular:v2) when you change the type. - The memory store is per process. With several servers, each has its own copy,
Forgetonly clears the local one, and locks protect nothing across servers. UseCACHE_STORE=databaseor Redis there. Putwith TTL 0 never expires. Always pass a TTL unless the key is forgotten explicitly.Flushempties the whole store. The same store may also hold rate-limit counters (RATE_LIMIT_STORE=cache), idempotency keys and webhook dedupe ids; flushing resets all of them.- Errors are not cached. A failing function runs again on the next call; protect a failing dependency with a timeout, not with the cache.
Security notes#
- Never cache a response that depends on the user under a shared key. Put the user id in the key (
user:42:dashboard) or do not cache it. - Do not store secrets or tokens in the cache: the database store keeps them in a plain table, and Redis is often shared between apps. Keep a distinct
CACHE_PREFIXper app on a shared Redis. - Keys built from user input should be bounded and normalised (
strings.ToLower, length limit), so a client cannot fill the store with unique keys; the memory store is bounded byCACHE_MAX_ENTRIES.