Memoize any Go function in one line, with TTL, eviction and stampede protection.
Use obcache-go when an expensive call, usually a query or an API request, should be cached without writing get, set and invalidate around it. Concurrent callers for the same key share one call.
Use something else when:
- You need maximum throughput or hit ratio under contention: ristretto
- You hold millions of entries and want them out of the garbage collector's way: freecache
- A small TTL map is enough: go-cache, with singleflight if you need stampede protection
- All your caching is shared across services: a plain Redis client
Requires Go 1.26 or later.
go get github.com/1mb-dev/obcache-go/v2/pkg/obcachepackage main
import (
"fmt"
"time"
"github.com/1mb-dev/obcache-go/v2/pkg/obcache"
)
func expensiveFunction(id int) (string, error) {
time.Sleep(100 * time.Millisecond) // Simulate expensive work
return fmt.Sprintf("result-%d", id), nil
}
func main() {
cache, err := obcache.New(obcache.NewDefaultConfig())
if err != nil {
panic(err)
}
defer cache.Close()
// Wrap once, at setup
cachedFunc := obcache.Wrap(cache, expensiveFunction)
result1, _ := cachedFunc(123) // slow: cache miss
result2, _ := cachedFunc(123) // fast: cache hit
fmt.Println(result1, result2)
}The defaults are 1000 entries, a 5 minute TTL, and LRU eviction.
package main
import (
"fmt"
"time"
"github.com/1mb-dev/obcache-go/v2/pkg/obcache"
)
func main() {
config := obcache.NewDefaultConfig().
WithMaxEntries(1000).
WithDefaultTTL(30 * time.Minute).
WithEvictionType(obcache.EvictionLFU) // or EvictionLRU, EvictionFIFO
cache, err := obcache.New(config)
if err != nil {
panic(err)
}
defer cache.Close()
_ = cache.Set("key", "value", time.Hour)
if value, found := cache.Get("key"); found {
fmt.Println("Found:", value)
}
_ = cache.Delete("key")
fmt.Printf("Hit rate: %.1f%%\n", cache.Stats().HitRate())
}On Redis, every Wrap needs WithName or WithKeyFunc, because the name is what processes share. A return type must survive JSON, and anyone who can write to your Redis can forge results; see Limits.
package main
import (
"fmt"
"time"
"github.com/1mb-dev/obcache-go/v2/pkg/obcache"
)
type User struct {
ID int64
Name string
}
func loadUser(id int64) (*User, error) {
return &User{ID: id, Name: "Ann"}, nil
}
func main() {
config := obcache.NewRedisConfig("localhost:6379").
WithDefaultTTL(time.Hour)
config.Redis.KeyPrefix = "myapp:"
cache, err := obcache.New(config)
if err != nil {
panic(err)
}
defer cache.Close()
cachedUser := obcache.Wrap(cache, loadUser, obcache.WithName("user"))
user, _ := cachedUser(7)
fmt.Println(user.Name)
}Compression stores large values smaller, for a shared Redis or a memory limit. Values under MinSize stay plain. With compression on, values pass through JSON in memory too, so the Redis limits apply.
package main
import (
"fmt"
"strings"
"time"
"github.com/1mb-dev/obcache-go/v2/pkg/compression"
"github.com/1mb-dev/obcache-go/v2/pkg/obcache"
)
func main() {
config := obcache.NewDefaultConfig().
WithCompression(&compression.Config{
Enabled: true,
Algorithm: compression.CompressorGzip, // or CompressorDeflate
MinSize: 1000, // Only compress values over 1KB
})
cache, err := obcache.New(config)
if err != nil {
panic(err)
}
defer cache.Close()
_ = cache.Set("report", strings.Repeat("row\n", 1000), time.Hour)
value, _ := cache.Get("report")
fmt.Println(len(value.(string)))
}pkg/metrics holds the exporter. Importing only pkg/obcache does not link the Prometheus client. Counters reach Prometheus every ReportingInterval; with it unset, nothing is exported.
go get github.com/1mb-dev/obcache-go/v2/pkg/metricspackage main
import (
"time"
"github.com/1mb-dev/obcache-go/v2/pkg/metrics"
"github.com/1mb-dev/obcache-go/v2/pkg/obcache"
"github.com/prometheus/client_golang/prometheus"
)
func main() {
exporter, err := metrics.NewPrometheusExporter(metrics.NewDefaultConfig(), &metrics.PrometheusConfig{
Registry: prometheus.NewRegistry(), // serve this with promhttp
})
if err != nil {
panic(err)
}
cache, err := obcache.New(obcache.NewDefaultConfig().WithMetrics(&obcache.MetricsConfig{
Enabled: true,
Exporter: exporter,
CacheName: "users",
ReportingInterval: 15 * time.Second,
}))
if err != nil {
panic(err)
}
defer cache.Close()
}package main
import (
"context"
"log"
"time"
"github.com/1mb-dev/obcache-go/v2/pkg/obcache"
)
func main() {
hooks := obcache.NewHooks()
hooks.AddOnMiss(func(_ context.Context, _ string) { log.Print("cache miss") })
hooks.AddOnEvict(func(_ context.Context, _ string, _ any, reason obcache.EvictReason) {
log.Printf("evicted: %s", reason)
})
cache, err := obcache.New(obcache.NewDefaultConfig().WithHooks(hooks))
if err != nil {
panic(err)
}
defer cache.Close()
cache.Get("absent")
_ = cache.Set("key", "value", time.Hour)
}Name the function, then delete by the key its arguments produce. Key panics on a name no Wrap bound in this Cache, and on arguments that differ from the function's parameters, because either would make Delete a silent no-op. A process that only invalidates binds the name with one uncalled Wrap of a function with the same signature.
package main
import (
"fmt"
"github.com/1mb-dev/obcache-go/v2/pkg/obcache"
)
func loadPrice(sku string) (int, error) {
return 42, nil
}
func main() {
cache, err := obcache.New(obcache.NewDefaultConfig())
if err != nil {
panic(err)
}
defer cache.Close()
cachedPrice := obcache.Wrap(cache, loadPrice, obcache.WithName("price"))
_, _ = cachedPrice("sku-1")
// The price changed
if err := cache.Delete(cache.Key("price", "sku-1")); err != nil {
panic(err)
}
fmt.Println(cache.Has(cache.Key("price", "sku-1")))
}- Function wrapping - Cache any function with
Wrap, including context-first, multi-return, error-only and variadic functions - Stampede protection - Concurrent callers for one key share one call, within a process
- TTL - Per-entry expiration, with a default for each cache
- Eviction - LRU, LFU or FIFO, for the memory store
- Thread safety - Safe for concurrent use
- Redis backend - A shared store, keyed under a prefix
- Compression - Gzip or deflate above a size threshold
- Prometheus metrics - An exporter in
pkg/metrics - Statistics - Hits, misses, hit rate and key count
- Hooks - Context-aware callbacks on hit, miss, eviction and invalidation
- Memory:
GetandWrapreturn the stored value, not a copy. Do not mutate results. - Side effects: a wrapped call that hits does not run the function. A wrapped function with no results runs once per key per TTL: use that to dedupe, or call the function directly if every call must run.
- Redis or compression: every value passes through
encoding/json, at any size.Wrapdecodes hits to the declared return type and panics at startup on return types that cannot round-trip: interfaces (includinganyandmap[string]any), unexported fields,json:"-", func, chan, complex, one-way marshalers. For a dynamic shape, returnjson.RawMessage. A value JSON cannot encode (NaN, cycles) is not cached. JSON also loses invalid UTF-8, a time zone's name, and an empty slice or map underomitempty. SetandGeton those paths are unchecked: unexported fields are lost, and numbers return asfloat64, losing precision above 2^53.- Redis names: a name is shared by every process on that Redis and key prefix, and binding is per
Cache, so services sharing Redis need distinctKeyPrefixvalues. A field added to a return struct under a kept name reads as zero until TTL; bump the name. A field renamed or removed is a miss. - Identity: a name binds one
Wrap, and only a top-level function mayWrapunder it again. Wrap once, at setup. For a closure or method value wrapped per request, useWithKeyFunc; unnamed, it never hits. A closure or method value underWithNamepanics on its secondWrap. - Test the backend you ship: refusals apply only on Redis and compression, so a program tested on memory can panic at its first start on Redis. Name every
Wrapif any environment uses Redis, andWrapat startup. - Context: ctx is not in the key. Anything that varies by user belongs in the arguments.
- Trust: anyone who can write to your Redis can forge any return value. Keys up to 64 bytes hold arguments in clear; a hash above that is not secrecy. A custom key function must be injective and namespaced.
MarshalTexton an argument type must be injective. - Errors:
WithErrorCachingpanics atWrapon Redis or compression. - Concurrency: stampede protection is per process. A function with no error return cannot honor a waiter's ctx.
OnEvictdoes not fire on Redis. - Outages: a Redis error is a miss, and
Wrapdrops a failed write; there is no error stat or hook. With Redis down, every call runs the function after waiting on Redis, about a second with a client obcache builds. - Operations: a client you pass in needs
ContextTimeoutEnabledand its own timeouts, and is the route to TLS.Newpings a client it builds; pass aClientto skip that.StatsandLenscan the prefix on Redis.DebugHandlerhas no authentication.
See examples/ for complete programs:
Upgrading from 2.0.x: see CHANGELOG.md.
MIT License - see LICENSE file.