High-Performance, Production-Ready Cache Abstraction for Go
cachex is a generic cache library for Go 1.23+ that unifies in-memory, Redis, and Hybrid caching behind a single clean interface. It ships with built-in stampede protection, Prometheus metrics, OpenTelemetry tracing, and a first-class mock for testing — zero external dependencies for the core in-memory engine.
- Features
- Installation
- Quick Start
- Core Interface
- Configuration Options
- Backends
- GetOrLoad & Stampede Protection
- Stale-While-Revalidate
- TTL Jitter
- Metrics (Prometheus)
- Tracing (OpenTelemetry)
- Testing with MockCache
- Error Handling
- Architecture
- Benchmark (Apple M1 Pro)
- Full Examples
| Feature | Details |
|---|---|
| Generic API | Cache[T any] — no interface{}, no type assertions |
| Stampede Protection | Built-in singleflight — concurrent callers for the same key share one load |
| Multiple Backends | In-memory, Redis (via rueidis), Hybrid L1+L2 |
| Stale-While-Revalidate | Return cached data immediately, refresh in background |
| TTL Jitter | Randomised expiry prevents thundering herd at cache warm-up |
| Prometheus Metrics | Hits, misses, evictions, sets, deletes — zero config |
| OpenTelemetry Tracing | Wrap any cache with WithTracing[T]() |
| First-Class Mock | MockCache[T] — drop-in replacement for tests, no infrastructure needed |
| W-TinyLFU Eviction | Window (1%) + main SLRU (Probation/Protected) + Count-Min Sketch admission |
| 256-Shard Architecture | Memory store split into 256 shards to reduce lock contention |
| Zero Core Dependencies | In-memory engine uses only Go stdlib + golang.org/x/sync |
| Context-Aware | Every operation accepts context.Context |
go get github.com/OprekerSejati/cachexRequires Go 1.23+.
Optional dependencies are pulled automatically by go mod tidy:
| Feature | Dependency |
|---|---|
| Redis backend | github.com/redis/rueidis |
| Prometheus metrics | github.com/prometheus/client_golang |
| OpenTelemetry tracing | go.opentelemetry.io/otel |
package main
import (
"context"
"fmt"
"time"
"github.com/OprekerSejati/cachex"
)
func main() {
// Create an in-memory cache for string values.
c, err := cachex.New[string](
cachex.WithTTL(10 * time.Minute),
cachex.WithMaxSize(50_000),
)
if err != nil {
panic(err)
}
defer c.Close()
ctx := context.Background()
// Store a value.
_ = c.Set(ctx, "greeting", "hello world", time.Minute)
// Retrieve it.
val, ok, err := c.Get(ctx, "greeting")
if err != nil {
panic(err)
}
if ok {
fmt.Println(val) // hello world
}
// GetOrLoad: fetch from cache or execute a loader if missing.
user, err := c.GetOrLoad(ctx, "user:42", func(ctx context.Context) (string, error) {
// This function is called at most once, even under concurrent load.
return fetchUserFromDB(ctx, 42)
})
fmt.Println(user, err)
}
func fetchUserFromDB(ctx context.Context, id int) (string, error) {
return fmt.Sprintf("Alice (id=%d)", id), nil
}Every cachex backend implements the same interface:
type Cache[T any] interface {
Get(ctx context.Context, key string) (T, bool, error)
Set(ctx context.Context, key string, value T, ttl time.Duration) error
GetOrLoad(ctx context.Context, key string, loader func(ctx context.Context) (T, error)) (T, error)
Delete(ctx context.Context, key string) error
Clear(ctx context.Context) error
Stats() Stats
Close() error
}val, ok, err := c.Get(ctx, "key")- Returns
(zero, false, nil)on a cache miss — not an error. - Returns
(zero, false, err)only on a store-level failure. - Returns
(value, true, nil)on a hit.
err := c.Set(ctx, "key", value, 5*time.Minute)ttl = 0means no expiry (entry lives until evicted or deleted).- Overwrites any existing value for the key.
val, err := c.GetOrLoad(ctx, "key", func(ctx context.Context) (T, error) {
return expensiveQuery(ctx)
})The loader is called at most once per key, regardless of how many goroutines call GetOrLoad concurrently (see Stampede Protection).
err := c.Delete(ctx, "key")Removes a single key. No-op if the key does not exist.
err := c.Clear(ctx)Removes all entries from the cache. For the Redis backend with a key prefix set, only keys matching that prefix are deleted.
s := c.Stats()
fmt.Printf("hits=%d misses=%d evictions=%d\n", s.Hits, s.Misses, s.Evictions)Returns a snapshot of runtime counters. See also Metrics for Prometheus integration.
type Stats struct {
Hits uint64
Misses uint64
Evictions uint64
Sets uint64
Deletes uint64
}err := c.Close()Shuts down background goroutines (GC loop) and releases resources. Always call Close() when the cache is no longer needed, typically via defer.
Options are passed to New[T](), NewWithStore[T](), or NewHybrid[T]() as variadic arguments.
c, err := cachex.New[MyType](
cachex.WithTTL(5 * time.Minute),
cachex.WithMaxSize(100_000),
cachex.WithMetrics("myapp"),
cachex.WithJitter(0.15),
cachex.WithStaleWhileRevalidate(),
cachex.WithRefreshBeforeExpiry(30 * time.Second),
)| Option | Default | Description |
|---|---|---|
WithTTL(d time.Duration) |
5m |
Default TTL applied by GetOrLoad. Does not affect explicit Set calls. |
WithMaxSize(n int) |
10,000 |
Maximum number of entries in the in-memory store. |
WithMetrics(namespace string) |
"cachex" (enabled) |
Enable Prometheus metrics with a custom namespace. |
WithoutMetrics() |
— | Disable Prometheus metrics entirely. |
WithOTel() |
disabled | Register the cache with the global OpenTelemetry provider (see also WithTracing). |
WithStaleWhileRevalidate() |
disabled | On a hit with refreshBeforeExpiry configured, serve stale and refresh in background. |
WithRefreshBeforeExpiry(d time.Duration) |
0 |
How far before expiry to trigger background refresh. Requires WithStaleWhileRevalidate(). |
WithJitter(fraction float64) |
0.1 (10%) |
TTL randomisation factor. 0.1 varies TTL by ±10%. |
defaultTTL = 5 * time.Minute
maxSize = 10,000
metricsEnabled = true
metricsNamespace = "cachex"
jitterFraction = 0.1The default backend. Created automatically by New[T]().
c, err := cachex.New[string](
cachex.WithTTL(10 * time.Minute),
cachex.WithMaxSize(500_000),
)Characteristics:
- 256-shard design (
numShards = 256) to reduce lock contention. - Each shard is thread-safe with
sync.RWMutex. - Background GC goroutine sweeps expired entries every
TTL/2. - Eviction uses per-shard W-TinyLFU, not random sampling.
- TTL jitter is applied on every
Setto spread expiry times and prevent thundering herd at cache warm-up. Get/GetExuseRLock, then enqueue policy updates asynchronously through a lossy access buffer (64 slots) so readers do not block on eviction bookkeeping.- Access buffering uses value-type records (
key,hash) sent to a buffered channel to keep policy-update hot path allocation-free.
Each shard uses a full Window TinyLFU policy:
- Frequency estimator: Count-Min Sketch with 4-bit atomic counters.
- Window (1%): New entries always enter this LRU segment first.
- Main (99%): Segmented LRU split into:
- Probation: first stop after admission from Window.
- Protected: high-value entries promoted from Probation, capped at 80% of Main.
- Admission: when Window overflows, candidate from Window tail competes with Probation tail; candidate is admitted only if sketch frequency is higher.
- Promotion: Probation hit promotes to Protected; Protected overflow demotes tail back to Probation.
You can also construct the memory store directly for use in NewWithStore or NewHybrid:
import "github.com/OprekerSejati/cachex/store"
memStore := store.NewMemory[string](
100_000, // maxSize
5*time.Minute, // defaultTTL (used for GC interval)
0.1, // jitter fraction
)Uses rueidis — the fastest Go Redis client available.
import "github.com/OprekerSejati/cachex/store"
redisStore, err := store.NewRedis[string](store.RedisOptions{
InitAddress: []string{"127.0.0.1:6379"},
KeyPrefix: "myapp",
})
if err != nil {
log.Fatal(err)
}
c, err := cachex.NewWithStore[string](redisStore,
cachex.WithTTL(1 * time.Hour),
cachex.WithMetrics("myapp_redis"),
)Characteristics:
- Values are serialised to JSON before storage.
Tmust be JSON-marshallable. - Key prefix is prepended as
<prefix>:<key>. Clear()with a prefix usesSCAN+DELto remove only matching keys. Without a prefix, it issuesFLUSHDB— use with caution.ttl = 0inSetstores the key without expiry in Redis.
Pass a rueidis.ClientOption for full control — TLS, auth, sentinel, cluster:
import "github.com/redis/rueidis"
redisStore, err := store.NewRedis[MyStruct](store.RedisOptions{
KeyPrefix: "svc",
ClientOption: rueidis.ClientOption{
InitAddress: []string{"redis-sentinel:26379"},
Password: "secret",
SelectDB: 2,
TLSConfig: tlsCfg,
// See rueidis docs for full option list.
},
})Two-tier cache: L1 (in-memory) is checked first; on a miss, L2 (Redis) is consulted and the result is promoted to L1 asynchronously.
import (
"github.com/OprekerSejati/cachex"
"github.com/OprekerSejati/cachex/store"
)
l1 := store.NewMemory[string](10_000, 5*time.Minute, 0.1)
l2, err := store.NewRedis[string](store.RedisOptions{
InitAddress: []string{"127.0.0.1:6379"},
KeyPrefix: "myapp",
})
if err != nil {
log.Fatal(err)
}
c, err := cachex.NewHybrid[string](l1, l2,
cachex.WithTTL(30 * time.Minute),
cachex.WithMetrics("myapp_hybrid"),
)Read path:
Get(key)
└─ L1 hit? → return immediately
└─ L1 miss → L2 hit? → promote to L1 (async) → return
→ L2 miss → return (false, nil, nil)
Write path:
Set(key, value, ttl)
└─ L1 write (synchronous, returns error on failure)
└─ L2 write (async, best-effort, 5s timeout)
Delete path:
Delete(key)
└─ L1 delete (synchronous)
└─ L2 delete (async, best-effort, 5s timeout)
Key design decisions:
- L2 writes are fire-and-forget — callers are never blocked by Redis latency on writes.
- L1 promotion on L2 hit is also async — the value is returned to the caller immediately.
- L1 promotion preserves remaining TTL from L2 when available (
GetExTTL orTTL()when L2 implementsTTLer), and falls back to configured promotion TTL otherwise.
GetOrLoad is the primary pattern for read-through caching. It combines a cache lookup with a loader function and protects against cache stampede (also called thundering herd): when a key expires, many goroutines simultaneously trigger an expensive load.
type Product struct {
ID int
Name string
Price float64
}
productCache, _ := cachex.New[Product](cachex.WithTTL(10 * time.Minute))
func GetProduct(ctx context.Context, id int) (Product, error) {
key := fmt.Sprintf("product:%d", id)
return productCache.GetOrLoad(ctx, key, func(ctx context.Context) (Product, error) {
// Called at most ONCE even if 10,000 goroutines request this key simultaneously.
return db.QueryProduct(ctx, id)
})
}How singleflight works:
- All concurrent callers for the same key arrive at
GetOrLoad. - The first caller acquires a slot and executes the loader.
- All subsequent callers wait (respecting their own
ctxdeadline). - When the loader completes, all waiters receive the same value.
- Only one database/network call is made regardless of concurrency.
Context cancellation during wait:
If a waiting goroutine's context is cancelled before the in-flight loader completes, that goroutine receives ctx.Err() — the loader itself continues for the original caller.
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
val, err := c.GetOrLoad(ctx, "slow-key", slowLoader)
if errors.Is(err, context.DeadlineExceeded) {
// This goroutine timed out waiting, but the loader may still be running
// for another caller who had a longer deadline.
}When enabled, a cache hit near expiry will return the stale value immediately and trigger a background refresh, so callers never wait for a reload.
c, _ := cachex.New[string](
cachex.WithTTL(5 * time.Minute),
cachex.WithStaleWhileRevalidate(),
cachex.WithRefreshBeforeExpiry(30 * time.Second),
)Behaviour:
- If the entry is found and
refreshBeforeExpiryhas not elapsed: return hit, no refresh. - If the entry is found and within the
refreshBeforeExpirywindow: return the stale value immediately and launch a background goroutine to call the loader and update the cache. - If the entry is not found: block and call the loader normally (with singleflight protection).
This eliminates latency spikes at expiry time entirely.
Note: Background refresh uses a detached
context.Background()with a timeout equal todefaultTTL. It does not inherit the caller's context deadline.
When many keys are loaded at the same time (e.g., after a restart), all entries will expire at roughly the same time, causing a thundering herd. TTL jitter randomises expiry to spread the load.
// Default: ±10% jitter
c, _ := cachex.New[string](cachex.WithTTL(time.Minute))
// Actual TTLs will be in range [54s, 66s]
// Custom: ±20% jitter
c, _ := cachex.New[string](
cachex.WithTTL(time.Minute),
cachex.WithJitter(0.20),
)
// Actual TTLs will be in range [48s, 72s]
// Disable jitter
c, _ := cachex.New[string](
cachex.WithTTL(time.Minute),
cachex.WithJitter(0),
)Jitter is applied to TTL values passed to the in-memory store's Set method. It does not affect explicit Set calls from user code — jitter only applies internally when GetOrLoad writes to the store using defaultTTL.
Prometheus metrics are enabled by default with namespace "cachex". Each metric is a counter:
| Metric | Description |
|---|---|
{namespace}_hits_total |
Total cache hits |
{namespace}_misses_total |
Total cache misses |
{namespace}_evictions_total |
Total cache evictions |
{namespace}_sets_total |
Total Set operations |
{namespace}_deletes_total |
Total Delete operations |
// Differentiates multiple caches in the same process.
userCache, _ := cachex.New[User](cachex.WithMetrics("myapp_users"))
productCache, _ := cachex.New[Product](cachex.WithMetrics("myapp_products"))This produces metrics like myapp_users_hits_total and myapp_products_hits_total.
c, _ := cachex.New[string](cachex.WithoutMetrics())Use this in tests or when you only want the Stats() snapshot without Prometheus registration.
cachex uses the default Prometheus registry. Expose it as usual:
import (
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
http.Handle("/metrics", promhttp.Handler())
http.ListenAndServe(":9090", nil)s := c.Stats()
hitRate := float64(s.Hits) / float64(s.Hits+s.Misses) * 100
fmt.Printf("Hit rate: %.1f%%\n", hitRate)Wrap any Cache[T] with WithTracing to add OpenTelemetry spans to every operation. This works with any backend — memory, Redis, or hybrid.
import (
"github.com/OprekerSejati/cachex"
"go.opentelemetry.io/otel"
)
// Set up your OTel provider first (Jaeger, OTLP, etc.)
// otel.SetTracerProvider(tp)
c, _ := cachex.New[string](cachex.WithTTL(5 * time.Minute))
tracedCache := cachex.WithTracing[string](c)
// Now all operations produce spans.
val, err := tracedCache.GetOrLoad(ctx, "key", loader)Spans created:
| Span Name | Attributes |
|---|---|
cachex.Get |
cache.key, cache.hit (bool) |
cachex.Set |
cache.key, cache.ttl_ms |
cachex.GetOrLoad |
cache.key |
cachex.Delete |
cache.key |
cachex.Clear |
— |
Errors are recorded on the span via span.RecordError(err).
l1 := store.NewMemory[Product](10_000, 5*time.Minute, 0.1)
l2, _ := store.NewRedis[Product](store.RedisOptions{InitAddress: []string{"localhost:6379"}})
hybrid, _ := cachex.NewHybrid[Product](l1, l2)
traced := cachex.WithTracing[Product](hybrid)MockCache[T] is a thread-safe, TTL-free in-memory implementation of Cache[T] for use in unit tests. It does not require any running infrastructure.
import "github.com/OprekerSejati/cachex"
func TestMyService(t *testing.T) {
mock := cachex.NewMock[string]()
// Pre-seed data.
_ = mock.Set(context.Background(), "user:1", "Alice", 0)
// Inject into your service.
svc := NewMyService(mock)
result, err := svc.GetUser(context.Background(), 1)
if err != nil || result != "Alice" {
t.Fatalf("unexpected result: %v, %v", result, err)
}
// Inspect counters.
stats := mock.Stats()
if stats.Hits != 1 {
t.Fatalf("expected 1 hit, got %d", stats.Hits)
}
}type MockCache[T any] struct {
Hits int
Misses int
Sets int
Deletes int
// ... (unexported fields)
}Hits, Misses, Sets, and Deletes are public and can be read directly for simple assertions. For richer inspection, use Stats().
mock := cachex.NewMock[string]()
mock.OnHit(func(key string) {
t.Logf("cache hit: %s", key)
})
mock.OnMiss(func(key string) {
t.Logf("cache miss: %s", key)
})| Behaviour | Production | MockCache |
|---|---|---|
| TTL enforcement | Yes — entries expire | No — all entries live forever |
| Eviction | Yes — maxSize enforced | No — unbounded |
| Stampede protection | Yes — singleflight | No — loader called per-miss |
| Metrics | Prometheus counters | Counters only (no Prometheus) |
The MockCache is designed to be simple and predictable in tests. For tests that need TTL or eviction behaviour, use a real New[T]() cache with WithoutMetrics().
var (
ErrNotFound = errors.New("cachex: key not found")
ErrClosed = errors.New("cachex: cache is closed")
ErrNilLoader = errors.New("cachex: loader function is nil")
ErrEmptyKey = errors.New("cachex: key must not be empty")
)Check with errors.Is:
_, _, err := c.Get(ctx, "")
if errors.Is(err, cachex.ErrEmptyKey) {
// handle invalid key
}When the loader function passed to GetOrLoad returns an error, it is wrapped in *LoaderError:
val, err := c.GetOrLoad(ctx, "key", loader)
if err != nil {
var le *cachex.LoaderError
if errors.As(err, &le) {
fmt.Printf("loader failed for key %q: %v\n", le.Key, le.Err)
// Unwrap the original error:
if errors.Is(le.Unwrap(), sql.ErrNoRows) {
return zero, ErrNotFound
}
}
return zero, err
}Store-level failures (e.g., Redis connection errors) are wrapped in *StoreError:
_, _, err := c.Get(ctx, "key")
if err != nil {
var se *cachex.StoreError
if errors.As(err, &se) {
fmt.Printf("store op=%q key=%q failed: %v\n", se.Op, se.Key, se.Err)
}
}| Situation | Error returned |
|---|---|
| Key not found | (zero, false, nil) — not an error |
| Empty key | ErrEmptyKey |
| Cache is closed | ErrClosed (from store) |
| Nil loader | ErrNilLoader |
| Loader returns error | *LoaderError wrapping the original |
| Redis failure | *StoreError or wrapped rueidis error |
| Context cancelled | context.Canceled or context.DeadlineExceeded |
cachex/
├── cache.go Public API — Cache[T] interface, New, NewWithStore
├── options.go Functional options (WithTTL, WithMaxSize, etc.)
├── loader.go GetOrLoad orchestration + singleflight + stale-while-revalidate
├── hybrid.go NewHybrid[T] constructor
├── memory.go Bridge: newMemoryStore[T]
├── mock.go MockCache[T] for tests
├── otel.go WithTracing[T] OpenTelemetry wrapper
├── errors.go Sentinel errors + LoaderError + StoreError
│
├── store/
│ ├── interface.go Store[T] low-level interface
│ ├── memory.go In-memory implementation (256 shards, jitter, GC, W-TinyLFU, lossy access buffer)
│ ├── redis.go Rueidis wrapper (JSON codec, key prefix, SCAN-based Clear)
│ └── hybrid.go L1→L2 layered store (async L2 writes, async L1 promotion)
│
└── internal/
├── eviction/
│ └── wtinylfu.go CountMinSketch (4-bit atomic counters) + W-TinyLFU Policy
├── singleflight/
│ └── group.go Generic context-aware Group[T] with Forget support
└── metrics/
└── recorder.go Atomic counters + Prometheus counter registration
Generic throughout. Cache[T], Store[T], and singleflight.Group[T] are all generic. There are no interface{} casts anywhere in the codebase.
store.Store[T] is internal. The public Cache[T] interface is what users program against. Stores are plumbing — they handle raw get/set/delete with no singleflight or metrics logic. This makes it straightforward to add new backends without touching the core.
loader.go is the orchestration layer. It sits between Cache[T] and Store[T], adding singleflight deduplication, metrics recording, and stale-while-revalidate logic. The cache itself (cache.go) delegates GetOrLoad entirely to the loader.
Async writes in hybrid. L2 (Redis) writes and L1 promotions are always non-blocking. The caller returns as soon as L1 is updated. L2 failures are silent (best-effort) — appropriate for a write-behind cache where Redis is a secondary tier.
Shard-first memory engine. Memory store is partitioned into 256 shards. This lowers lock contention under parallel load and keeps lock scope local to shard-level data.
Low-contention read path. Get and GetEx use shard RLock for map read and expiry check, then enqueue policy access asynchronously into a lossy 64-slot shard buffer. Reader never waits for eviction-policy mutation.
W-TinyLFU retention strategy. Each shard combines Window LRU (1%), Main SLRU (Probation + Protected), and Count-Min Sketch admission to prioritize frequently reused keys.
singleflight.Group[T] is context-aware. Unlike golang.org/x/sync/singleflight, waiting goroutines honour their context deadline. A cancelled waiter returns immediately without cancelling the in-flight loader.
Metrics are decoupled from the store. internal/metrics.Recorder maintains both atomic counters (for Stats()) and Prometheus counters (for /metrics). The store layer has no knowledge of metrics — all recording happens in cache.go and loader.go.
Benchmark environment:
goos=darwin,goarch=arm64, CPU: Apple M1 Pro- Workload: Zipfian distribution (
s=1.01,v=1) - Capacity:
100,000 - Read/Write mix:
90% / 10%
Results (results.txt):
| Benchmark | ns/op |
|---|---|
| cachex (1 CPU) | 320.8 |
| cachex (8 CPU) | 148.6 |
| cachex (16 CPU) | 148.8 |
| Otter (16 CPU) | 135.5 |
At high core counts, cachex stays stable (148.6 -> 148.8 from 8 to 16 CPUs) while remaining competitive with Otter on 16 CPUs.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/OprekerSejati/cachex"
)
type WeatherData struct {
City string `json:"city"`
Temperature float64 `json:"temperature"`
Humidity int `json:"humidity"`
}
var weatherCache cachex.Cache[WeatherData]
func init() {
var err error
weatherCache, err = cachex.New[WeatherData](
cachex.WithTTL(15 * time.Minute),
cachex.WithMaxSize(1_000),
cachex.WithMetrics("weather_cache"),
cachex.WithJitter(0.1),
)
if err != nil {
panic(err)
}
}
func GetWeather(ctx context.Context, city string) (WeatherData, error) {
return weatherCache.GetOrLoad(ctx, "weather:"+city, func(ctx context.Context) (WeatherData, error) {
return fetchWeatherAPI(ctx, city)
})
}
func fetchWeatherAPI(ctx context.Context, city string) (WeatherData, error) {
req, _ := http.NewRequestWithContext(ctx, "GET",
"https://api.weather.example.com/v1/current?city="+city, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return WeatherData{}, fmt.Errorf("weather API: %w", err)
}
defer resp.Body.Close()
var data WeatherData
return data, json.NewDecoder(resp.Body).Decode(&data)
}package main
import (
"context"
"log"
"time"
"github.com/OprekerSejati/cachex"
"github.com/OprekerSejati/cachex/store"
)
type UserProfile struct {
ID int64
Name string
Email string
}
func NewUserCache() (cachex.Cache[UserProfile], error) {
l1 := store.NewMemory[UserProfile](5_000, 5*time.Minute, 0.1)
l2, err := store.NewRedis[UserProfile](store.RedisOptions{
InitAddress: []string{"redis:6379"},
KeyPrefix: "users",
})
if err != nil {
return nil, err
}
return cachex.NewHybrid[UserProfile](l1, l2,
cachex.WithTTL(1*time.Hour),
cachex.WithMetrics("users"),
cachex.WithStaleWhileRevalidate(),
cachex.WithRefreshBeforeExpiry(5*time.Minute),
)
}
func main() {
cache, err := NewUserCache()
if err != nil {
log.Fatal(err)
}
defer cache.Close()
ctx := context.Background()
user, err := cache.GetOrLoad(ctx, "user:1001", func(ctx context.Context) (UserProfile, error) {
return queryUserFromDB(ctx, 1001)
})
if err != nil {
log.Fatal(err)
}
log.Printf("User: %s <%s>", user.Name, user.Email)
}
func queryUserFromDB(ctx context.Context, id int64) (UserProfile, error) {
// Real DB query here.
return UserProfile{ID: id, Name: "Alice", Email: "alice@example.com"}, nil
}package service_test
import (
"context"
"testing"
"github.com/OprekerSejati/cachex"
"myapp/service"
)
func TestProductService_GetProduct_CacheHit(t *testing.T) {
mock := cachex.NewMock[service.Product]()
ctx := context.Background()
expected := service.Product{ID: 1, Name: "Widget", Price: 9.99}
_ = mock.Set(ctx, "product:1", expected, 0)
svc := service.NewProductService(mock)
got, err := svc.GetProduct(ctx, 1)
if err != nil {
t.Fatal(err)
}
if got != expected {
t.Errorf("want %+v, got %+v", expected, got)
}
stats := mock.Stats()
if stats.Hits != 1 || stats.Misses != 0 {
t.Errorf("unexpected stats: %+v", stats)
}
}
func TestProductService_GetProduct_CacheMiss_CallsDB(t *testing.T) {
mock := cachex.NewMock[service.Product]()
ctx := context.Background()
dbCallCount := 0
svc := service.NewProductServiceWithDB(mock, func(ctx context.Context, id int) (service.Product, error) {
dbCallCount++
return service.Product{ID: id, Name: "Gadget"}, nil
})
_, _ = svc.GetProduct(ctx, 42)
_, _ = svc.GetProduct(ctx, 42) // second call should hit mock cache
if dbCallCount != 1 {
t.Errorf("DB called %d times, want 1", dbCallCount)
}
}package main
import (
"context"
"log"
"time"
"github.com/OprekerSejati/cachex"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/sdk/trace"
)
func initTracer() func() {
exp, err := otlptracehttp.New(context.Background(),
otlptracehttp.WithEndpoint("localhost:4318"),
otlptracehttp.WithInsecure(),
)
if err != nil {
log.Fatal(err)
}
tp := trace.NewTracerProvider(trace.WithBatcher(exp))
otel.SetTracerProvider(tp)
return func() { _ = tp.Shutdown(context.Background()) }
}
func main() {
shutdown := initTracer()
defer shutdown()
c, _ := cachex.New[string](cachex.WithTTL(10 * time.Minute))
traced := cachex.WithTracing[string](c)
defer traced.Close()
ctx := context.Background()
_, _ = traced.GetOrLoad(ctx, "config:feature-flags", func(ctx context.Context) (string, error) {
return loadConfigFromDB(ctx)
})
}
func loadConfigFromDB(ctx context.Context) (string, error) {
return `{"featureX": true}`, nil
}MIT