Files
summercms/modules/cooler
Jakub Zych fd7f204979 feat(19.3-01): add cooler.Cache with otter memory and Redis drivers
- Cache: Get, Set, Has, Forget, Remember (degrades on cache errors), Increment, Flush
  with per-entry TTLs under <prefix>cache:
- memory driver on otter v2.3.0 bounded by cache.memory.max_entries; Redis driver
  with SET PX, INCRBY and a Flush limited to SCAN MATCH <prefix>cache:* plus UNLINK
- Install publishes the Cache (sharing one Redis client with the rate limiter);
  FromApp serves jobs and commands without a dial
- new docs/services/caching.md, configuration rows, cooler README
2026-10-15 17:40:23 +02:00
..

cooler

Cache drivers and shared counters for SummerCMS: memory and Redis caches with a key prefix and TTLs, and a Redis-backed rate-limit store.

import "git.golem15.com/golem15/summercms/modules/cooler"

Name: The cool box you bring to the beach; it keeps things fresh and at hand for everyone in the group.

Overview

cooler holds the state that several application instances share. cooler.Cache is a Laravel-core cache (get, set, has, forget, remember, increment, flush) with per-entry TTLs under a per-application key prefix, kept in process by an otter cache or shared through Redis. The second piece is a Redis-backed rate-limit store: with http.rate_limit.store: redis, the serve command of surf calls cooler.Install, which builds a Redis client from redis.url, and publishes the store so the throttle middleware counts in Redis instead of in the process. Two instances behind one load balancer then enforce one limit together. It is the counterpart of Laravel's Cache facade with its array and Redis stores, and of the Redis store behind RateLimiter and the throttle middleware.

Role in the framework

A service module on backpack and compass, below surf: surf imports cooler, never the reverse. cooler.RateLimitStore has the method set of surf.Store (through cooler.Attempter), so surf takes no Redis dependency and cooler imports neither surf nor cabana. The framework wires cooler at boot; applications import nothing.

Use cases:

  • Cache an expensive query or an upstream answer for a few minutes, in a plugin or a job.
  • Keep throttle limits exact when an application runs as several instances behind a load balancer.
  • Share one Redis connection setting (redis.url) between the framework services that need Redis.

Features

  • cooler.Cache methods: cooler.Cache.Get, cooler.Cache.Set, cooler.Cache.Has, cooler.Cache.Forget, cooler.Cache.Remember, cooler.Cache.Increment and cooler.Cache.Flush, with a TTL per entry (0 or less: no expiry). No tags and no locks.
  • Memory driver (cache.driver: memory, the default): cooler.NewMemoryCache on otter v2, bounded by cache.memory.max_entries (default cooler.DefaultMaxEntries, 10000) so request-derived keys cannot grow memory without limit. Values are copied in and out, and cooler.Cache.Increment is atomic per key.
  • Redis driver (cache.driver: redis): cooler.NewRedisCache with keys <prefix>cache:<key>, SET … PX for TTLs, EXISTS, UNLINK and INCRBY. cooler.Cache.Flush walks SCAN MATCH <prefix>cache:* in batches of 500 and removes each batch with UNLINK; it never empties the whole database, so rate-limit counters and other applications' keys stay.
  • cooler.Cache.Increment starts a missing key at 0 without a TTL and keeps an existing key's expiry, as Redis INCRBY does.
  • cooler.Cache.Remember degrades instead of failing the page: a failed read still runs the callback and returns its value, a failed write is skipped, and both are logged at most once per minute. The other methods return their errors.
  • cooler.Install reads cache.driver and http.rate_limit.store (each memory, the default, or redis), publishes the Cache on the app (reusing one cooler.FromApp published earlier) and, when either selects Redis, builds one client from redis.url, shared by both, and pings it within cooler.PingTimeout (3 s).
  • cooler.FromApp gives commands that do not run serve (a dedicated job worker, console commands) the published Cache, or builds the configured one and publishes it, without a ping: go-redis dials on the first command.
  • Start-up never fails because Redis is down: an unanswered ping is one warning that names the endpoint (host and port, never the URL), the limiter counts per instance and cooler.Cache.Remember degrades until Redis answers. A missing or malformed redis.url, an unknown driver name or a cache.memory.max_entries below 1 fails start-up with an error that names the key and never contains the URL or its password.
  • cooler.RateLimitStore runs one atomic Lua script per attempt with the semantics of surf.MemoryStore: the first attempt opens the window, a denied attempt does not increment, the window never extends, and the retry time is what is left of the window. Redis keeps the clock, so instance clock skew does not matter.
  • Each attempt is bounded by cooler.AttemptTimeout (500 ms). When Redis fails, the attempt is counted in the fallback store surf passes in (a per-instance surf.MemoryStore) and a warning is logged at most once per minute.
  • Keys carry a per-application prefix: cache.prefix, or by default app.name lower-cased with every run of other characters than a-z and 0-9 replaced by _, then : (summer: when app.name is empty). Cache keys are <prefix>cache:<key>, rate-limit keys <prefix>rl:<key>.
  • cooler.OpenRedis accepts redis://, rediss:// (TLS) and unix:// URLs, does not dial at construction, and skips the CLIENT SETINFO and maintenance-notification handshakes.
  • Only serve installs cooler: route:list and surf.Assemble in tests keep the in-process store and never dial Redis.

Usage

A plugin or job reads and writes the cache the framework published:

package blog

import (
	"context"
	"time"

	"git.golem15.com/golem15/summercms/modules/backpack"
	"git.golem15.com/golem15/summercms/modules/cooler"
)

// Sidebar returns the rendered sidebar, cached for five minutes.
func Sidebar(ctx context.Context, app *backpack.App, render func(context.Context) ([]byte, error)) ([]byte, error) {
	cache, err := cooler.FromApp(app)
	if err != nil {
		return nil, err
	}
	return cache.Remember(ctx, "blog:sidebar", 5*time.Minute, render)
}

cooler.FromApp returns the Cache serve published, so in a request handler app.Lookup[cooler.Cache]() works as well. In a separate queue:work process or a console command, cooler.FromApp builds the configured Cache the first time.

Select Redis for the cache, the rate limiter or both:

# config/cache.yaml
driver: redis

Select the shared rate-limit store in the application's config and keep the URL, which may carry a password, in the environment (SUMMER_REDIS__URL):

# config/http.yaml
rate_limit:
  store: redis
# config/redis.yaml (or SUMMER_REDIS__URL=redis://:secret@10.0.0.7:6379/0)
url: redis://10.0.0.7:6379/0

Nothing else changes: routes keep throttle:60,1 or throttle:<bucket>, and serve publishes the Redis store before it builds the router.

A tool outside serve can use the store directly:

package blog

import (
	"context"
	"time"

	"git.golem15.com/golem15/summercms/modules/backpack"
	"git.golem15.com/golem15/summercms/modules/cooler"
)

// AllowExport admits five exports a minute per user across all instances.
func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempter, userKey string) (bool, error) {
	client, err := cooler.OpenRedis(ctx, app.Config.String(cooler.RedisURLKey))
	if err != nil {
		return false, err
	}
	defer client.Close()
	store := cooler.NewRateLimitStore(client, "acme:", fallback)
	allowed, _, _ := store.Attempt("export|"+userKey, 5, time.Minute)
	return allowed, nil
}

API reference

Identifier Description
cooler.Cache The cache: Get, Set, Has, Forget, Remember, Increment and Flush with per-entry TTLs under the app's key prefix.
cooler.Cache.Remember Returns the cached value or computes, stores and returns it; degrades on cache errors.
cooler.Cache.Increment Adds to an integer value; a missing key starts at 0 without a TTL.
cooler.Cache.Flush Removes this application's cache keys only.
cooler.NewMemoryCache In-process driver on otter v2, bounded to a number of entries.
cooler.NewRedisCache Redis driver on a go-redis client, keys <prefix>cache:<key>.
cooler.FromApp Returns the published Cache, or builds the configured one without a ping and publishes it.
cooler.Install Reads cooler's config, publishes the Cache, opens and pings Redis when a Redis driver is selected, and returns a cooler.Handle. Called by surf's serve.
cooler.Handle What cooler.Install set up.
cooler.Handle.Cache The Cache cooler.Install published or found.
cooler.Handle.Close Closes the Redis client cooler.Install opened, if any.
cooler.Handle.RateLimitRedis Reports whether http.rate_limit.store selects the Redis store.
cooler.Handle.RateLimitStore Returns the Redis rate-limit store with the given fallback, or the fallback itself when Redis is not selected.
cooler.Attempter One atomic fixed-window admission; the method set of surf.Store.
cooler.RateLimitStore Redis fixed-window store shared by all instances, with a fallback while Redis fails.
cooler.NewRateLimitStore Builds a cooler.RateLimitStore from a go-redis client, a key prefix and a fallback.
cooler.RateLimitStore.Attempt Admits and counts one request in one Lua script; falls back on a Redis error.
cooler.OpenRedis Builds a go-redis client from a redis://, rediss:// or unix:// URL without dialing; errors never contain the URL.
cooler.RedisURLKey redis.url.
cooler.RateLimitStoreKey http.rate_limit.store.
cooler.CacheDriverKey cache.driver.
cooler.CachePrefixKey cache.prefix.
cooler.CacheMaxEntriesKey cache.memory.max_entries.
cooler.DefaultMaxEntries Default size bound of the memory driver (10000).
cooler.PingTimeout Bound of the start-up ping (3 s).
cooler.AttemptTimeout Bound of one rate-limit attempt on Redis (500 ms).

Configuration

cooler.Install reads these keys from the app's compass config:

Key Default Controls
cache.driver memory Cache driver: memory (per instance, otter) or redis (shared). Any other value fails at boot. Environment: SUMMER_CACHE__DRIVER.
cache.memory.max_entries 10000 Size bound of the memory driver; below 1 fails at boot. Set it in YAML (environment values are strings).
http.rate_limit.store memory Where throttle counters live: memory (per instance) or redis (shared). Any other value fails at boot. Environment: SUMMER_HTTP__RATE_LIMIT__STORE.
redis.url none Redis URL (redis://, rediss:// or unix://). Required when a Redis cache or rate-limit store is selected; keep it in the environment when it carries a password. Environment: SUMMER_REDIS__URL.
cache.prefix from app.name Prefix of every cooler key. Environment: SUMMER_CACHE__PREFIX.

Dependencies

  • SummerCMS modules: backpack, compass.
  • Third-party: github.com/redis/go-redis/v9 (Redis client), github.com/maypok86/otter/v2 (memory driver).
  • Standard library: context, errors, fmt, log/slog, strconv, strings, sync, time.
  • Tests additionally use github.com/testcontainers/testcontainers-go (Redis container).

Testing

go test ./modules/cooler/...

TestRedisCacheSmoke starts a Redis container through testcontainers-go and needs Docker; go test -short ./modules/cooler/... skips it, and the other tests need no external services. The cross-instance rate-limit test lives in surf (go test ./modules/surf -run TestRateLimitAcrossInstancesSmoke), starts a Redis container through testcontainers-go and needs Docker; -short skips it.