Skip to content

Digging deeper

Cache

A typed cache over memory or database stores, with stampede-safe Remember, counters and locks.

Last updated:

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.

Go
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. Remember runs 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), then TryAcquire, Block(ctx, wait, interval) and Release (only the owner can release).

Stores#

StoreNotes
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)
Redisvukem.WithCacheStore(rc.CacheStore("cache:"))
VariableDefault
CACHE_STOREmemory
CACHE_PREFIXvukem:
CACHE_TABLEcache
CACHE_MAX_ENTRIES10000

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:

Go
func (s *MyStore) Shared() bool { return true } // backed by a service every server reaches

The 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

app/controllers/posts.go
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

Go
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

Go
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

.env
CACHE_STORE=database   # a "cache" table in DATABASE_URL, created at boot

Or pass a Redis store with vukem.WithCacheStore. Your code does not change.

Counters and locks#

Go
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.Time comes 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, Forget only clears the local one, and locks protect nothing across servers. Use CACHE_STORE=database or Redis there.
  • Put with TTL 0 never expires. Always pass a TTL unless the key is forgotten explicitly.
  • Flush empties 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_PREFIX per 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 by CACHE_MAX_ENTRIES.