Skip to content

Storing values

What goes in, what comes back, and the two operations that are not plain "store this" — a write that only happens when nothing is there, and a counter whose window is its own TTL.

Values, keys and TTLs

The Go type decides the encoding — string, []byte and json.RawMessage go in unchanged, everything else is JSON. Plus key naming, WithPrefix, and why every key should expire.

go
package main

import (
	"encoding/json"
	"errors"
	"time"

	core "gitlab.finema.co/finema/idin-core/v2"
)

// --- Example 1: values, keys and TTLs ---------------------------------------
//
// The cache stores bytes. What decides the encoding is the Go type, not a flag:
// string, []byte and json.RawMessage go in unchanged, everything else is JSON.
// The distinction is worth having in the type system — a token stored as JSON
// comes back with quotes around it, and that bug surfaces wherever the value is
// compared rather than where it was written.
//
// Every read can miss, and a miss is an ordinary outcome rather than a failure.
// Code that treats one as an error turns a redis restart into an outage.

type user struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

func runValues(ctx core.IContext) {
	c := ctx.Cache()

	// Keys are one flat namespace shared by everything on the instance, so give
	// them a structure and keep to it: <type>:<version>:<id>. The type first is
	// what makes DelByPrefix work on a whole class of key; the version means a
	// deploy that changes the struct never has to guess whether the values an
	// older deploy wrote are still readable.
	_ = c.Set("user:v1:42", user{ID: "42", Name: "ann"}, 5*time.Minute) // JSON
	_ = c.Set("token:42", "abc123", time.Hour)                          // raw bytes
	_ = c.Set("flags", json.RawMessage(`{"beta":true}`), time.Hour)     // passed through

	var u user
	if err := c.Get("user:v1:42", &u); err != nil && !errors.Is(err, core.ErrCacheMiss) {
		// A cache that is answering badly is worth recording — it explains the
		// timeout somebody will ask about later — but it is not worth failing
		// the request for.
		ctx.Log().Warn("cache read failed", "err", err)
	}

	var token string
	_ = c.Get("token:42", &token) // "abc123", not "\"abc123\""
	ctx.Log().Info("read back", "name", u.Name, "token", token)

	// The one branch every cache read needs.
	if err := c.Get("user:v1:nobody", &u); errors.Is(err, core.ErrCacheMiss) {
		ctx.Log().Info("miss: recompute, do not fail")
	}

	runTTLs(ctx)
	runOneShotValues(ctx)
	runNamespaces(ctx)
}

// runTTLs covers the three TTL constants and the two questions worth asking
// about a key that is already there.
func runTTLs(ctx core.IContext) {
	c := ctx.Cache()

	// Prefer a TTL to NoExpiry even for a value that never changes. A key with
	// no expiry and no owner is a leak that surfaces months later as a memory
	// alert, and a TTL is the cheapest correctness guarantee a cache has:
	// whatever goes stale fixes itself.
	_ = c.Set("config:rates", map[string]int{"THB": 1}, core.NoExpiry)
	_ = c.Set("user:v1:42", user{ID: "42", Name: "ann"}, core.KeepTTL) // rewrite, same expiry

	exists, _ := c.Exists("user:v1:42")

	ttl, err := c.TTL("user:v1:42")
	switch {
	case errors.Is(err, core.ErrCacheMiss):
		ttl = 0 // the key is not there at all
	case ttl == core.TTLNoExpiry:
		ctx.Log().Warn("key never expires — is that deliberate?", "key", "user:v1:42")
	}

	// Expire reports whether the key existed, which is the only way to tell a
	// refreshed session apart from one that had already lapsed.
	refreshed, _ := c.Expire("session:abc", 30*time.Minute)

	ctx.Log().Info("values", "exists", exists, "ttl", ttl, "session_refreshed", refreshed)
}

// runOneShotValues covers the two operations that are not plain "store this":
// a write that happens only when nothing is there, and a read that consumes.
func runOneShotValues(ctx core.IContext) {
	c := ctx.Cache()

	// SetNX is the primitive behind "handle this request exactly once". Give it
	// a TTL longer than the window a client would retry in, and shorter than
	// forever.
	first, _ := c.SetNX("charge:idem-1", "1", 24*time.Hour)
	if !first {
		ctx.Log().Info("duplicate request, already handled")
	}
	// On a disabled cache SetNX answers true for everybody, so deduplication is
	// one of the few things that genuinely needs a real cache. Say so out loud
	// rather than discovering it from a double charge.
	if !c.Enabled() {
		ctx.Log().Warn("no cache configured: duplicate requests are not detected")
	}

	// GetDel reads and deletes in one round trip. As two calls, two requests
	// that arrive together can both accept the same one-time token.
	var refreshToken string
	if err := c.GetDel("refresh:once", &refreshToken); errors.Is(err, core.ErrCacheMiss) {
		ctx.Log().Info("token already used or expired")
	}

	// One MGet plus one query for whatever missed is the difference between two
	// round trips and N. Absent keys are simply left out of the result, so
	// iterate the keys you asked for rather than the ones you got back.
	keys := []string{"user:v1:1", "user:v1:2"}
	found, _ := core.GetJSONMany[user](c, keys...)
	for _, key := range keys {
		if _, hit := found[key]; !hit {
			ctx.Log().Debug("load this one from the database", "key", key)
		}
	}

	_ = c.Del("charge:idem-1") // deleting an absent key is not an error
}

// runNamespaces: CACHE_PREFIX already namespaces the service and the
// environment; WithPrefix narrows further, which is how a per-feature namespace
// stays one thing that can be cleared in a single call.
func runNamespaces(ctx core.IContext) {
	otp := ctx.Cache().WithPrefix("otp") // stores "<CACHE_PREFIX>otp:<key>"
	_ = otp.Set("0812345678", "123456", 5*time.Minute)

	// Clearing "" under a narrowed handle clears exactly that namespace and
	// nothing else. DelByPrefix SCANs in batches instead of running KEYS, so it
	// is safe against a production instance — but it is still O(keyspace), not
	// O(matches): it walks every key on the instance to find yours. Fine on a
	// deploy or an admin action, wrong in a request handler.
	cleared, _ := otp.DelByPrefix("")

	// The raw client is the escape hatch for sorted sets, streams and Lua. Two
	// things it will not do: exist on the memory and disabled backends (it is
	// nil), and apply the prefix — build keys with Prefix() when you use it.
	if rdb := ctx.Cache().Redis(); rdb != nil {
		_ = rdb // e.g. rdb.ZAdd(ctx, ctx.Cache().Prefix()+"leaderboard", …)
	}

	ctx.Log().Info("namespaces", "prefix", otp.Prefix(), "cleared", cleared)
}

Counters and rate limits

Incr applies its TTL only when it created the counter, which is what makes a fixed-window limiter one call instead of three — and why INCR plus EXPIRE by hand turns into a ban. See Counters.

go
package main

import (
	"time"

	core "gitlab.finema.co/finema/idin-core/v2"
)

// --- Example 2: counters and rate limits ------------------------------------
//
// Incr adds delta and returns the new value; a negative delta subtracts and a
// delta of 0 reads it, creating it at zero. The interesting argument is the
// TTL: it is applied only when *this call* created the counter, in one atomic
// script.
//
// That is the whole reason Incr takes a TTL at all. Written by hand as INCR
// followed by EXPIRE, the second call pushes the expiry out on every request,
// and a client that keeps knocking is never let through again — a rate limiter
// that quietly became a ban. One operation, one window.

func runCounters(ctx core.IContext) {
	c := ctx.Cache()

	// A fixed-window limiter is one call, not three. The window lives in the
	// key's TTL, so there is no clock arithmetic and nothing to clean up.
	n, err := c.Incr("rate:login:[email protected]", 1, 15*time.Minute)
	switch {
	case err != nil:
		// A cache failure is not a 429. A limiter that fails closed turns a
		// redis blip into a full outage, so fail open — unless the thing being
		// limited is more expensive than being down.
		ctx.Log().Warn("rate limiter unavailable, letting the request through", "err", err)
	case n > 5:
		ctx.Log().Info("too many attempts", "count", n)
	}

	// On a disabled cache Incr answers zero, which is the only honest answer —
	// without a shared counter there is nothing to count. It also means the
	// comparison above never trips: an environment with no redis enforces no
	// rate limits at all. On a laptop that is right; on a deployment that lost
	// its CACHE_* configuration by accident it is a silent hole.
	if !c.Enabled() {
		ctx.Log().Warn("rate limiting is disabled: no cache configured")
	}

	countersWorthHaving(ctx)
	_ = verifyOTP(ctx, "0812345678", "999999")
	_ = takeStock(ctx, "sku-1")
}

// countersWorthHaving: rate-limit the thing being protected. By IP alone, a
// distributed attempt walks straight through and one office NAT gets everybody
// locked out; by account alone, one attacker sprays a thousand accounts. Do
// both, with different limits.
func countersWorthHaving(ctx core.IContext) {
	c := ctx.Cache()

	_, _ = c.Incr("rate:login:203.0.113.5", 1, 15*time.Minute)     // per source
	_, _ = c.Incr("rate:login:[email protected]", 1, 15*time.Minute) // per account
	_, _ = c.Incr("export:tenant-1", 1, time.Hour)                 // per tenant, expensive work
	_, _ = c.Incr("views:post:7", 1, 24*time.Hour)                 // a metric nobody queries the DB for

	views, _ := c.Incr("views:post:7", 0, core.NoExpiry) // delta 0 reads it
	ctx.Log().Info("counters", "views", views)

	// Two things this shape is not:
	//
	//   - Durable. A counter is lost on a flush, an eviction or a restart with
	//     no persistence. Fine for a rate limit (the window resets, nobody is
	//     harmed) and not fine for anything anyone will read as a total. For a
	//     view count that must eventually be right, count in redis and flush to
	//     the database on a schedule: the cache absorbs the write rate, the
	//     database holds the truth.
	//
	//   - Smooth. A fixed window resets all at once, so a client can spend its
	//     whole budget at 0:59 and again at 1:00 — twice the intended rate
	//     across two seconds. Acceptable for protecting a database, not
	//     acceptable for metering anything billable. That wants a sliding
	//     window over a sorted set on Redis(), or a token bucket in Lua.
}

// verifyOTP is the counter shape most services actually need: count the
// failures, not the successes, and let the window clear itself.
func verifyOTP(ctx core.IContext, phone, entered string) core.IError {
	c := ctx.Cache()
	attemptsKey := "otp:attempts:" + phone

	attempts, _ := c.Incr(attemptsKey, 0, core.NoExpiry)
	if attempts >= 5 {
		return core.New(429, "OTP_LOCKED", "too many attempts, request a new code")
	}

	// Get rather than GetDel: a typo should not burn the code. What bounds the
	// guessing is the attempt counter, not the code being single-use — and the
	// counter outlives the code on purpose, so re-requesting one cannot reset
	// the budget.
	var code string
	if err := c.Get("otp:code:"+phone, &code); err != nil {
		return core.New(400, "OTP_EXPIRED", "the code has expired")
	}
	if code != entered {
		_, _ = c.Incr(attemptsKey, 1, 15*time.Minute)
		return core.New(400, "OTP_INVALID", "wrong code")
	}

	_ = c.Del("otp:code:"+phone, attemptsKey) // success consumes both
	return nil
}

// takeStock is the decrement that is easy to get wrong. Reading and then
// decrementing lets two callers both see one remaining; decrementing first and
// checking the result is the part that is atomic, so exactly one of them can be
// the caller that took it to zero.
//
// And for real stock, do this in the database. A redis counter that disagrees
// with the orders table is an incident, not a cache miss.
func takeStock(ctx core.IContext, sku string) bool {
	c := ctx.Cache()

	left, err := c.Incr("stock:"+sku, -1, core.NoExpiry)
	if err != nil {
		return false // nothing was taken, so nothing to put back
	}
	if left < 0 {
		_, _ = c.Incr("stock:"+sku, 1, core.NoExpiry)
		ctx.Log().Info("out of stock", "sku", sku)
		return false
	}
	return true
}

Maintained by Passakon Puttasuwan & Dev Core Team.