Skip to content

OprekerSejati/cachex

Repository files navigation

cachex

Go Report Card GoDoc License: MIT

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.


Table of Contents

  1. Features
  2. Installation
  3. Quick Start
  4. Core Interface
  5. Configuration Options
  6. Backends
  7. GetOrLoad & Stampede Protection
  8. Stale-While-Revalidate
  9. TTL Jitter
  10. Metrics (Prometheus)
  11. Tracing (OpenTelemetry)
  12. Testing with MockCache
  13. Error Handling
  14. Architecture
  15. Benchmark (Apple M1 Pro)
  16. Full Examples

Features

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

Installation

go get github.com/OprekerSejati/cachex

Requires 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

Quick Start

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
}

Core Interface

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
}

Method Reference

Get

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.

Set

err := c.Set(ctx, "key", value, 5*time.Minute)
  • ttl = 0 means no expiry (entry lives until evicted or deleted).
  • Overwrites any existing value for the key.

GetOrLoad

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).

Delete

err := c.Delete(ctx, "key")

Removes a single key. No-op if the key does not exist.

Clear

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.

Stats

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
}

Close

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.


Configuration Options

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 Reference

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%.

Defaults

defaultTTL       = 5 * time.Minute
maxSize          = 10,000
metricsEnabled   = true
metricsNamespace = "cachex"
jitterFraction   = 0.1

Backends

In-Memory Backend

The 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 Set to spread expiry times and prevent thundering herd at cache warm-up.
  • Get/GetEx use RLock, 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.

W-TinyLFU Eviction Details

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
)

Redis Backend

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. T must be JSON-marshallable.
  • Key prefix is prepended as <prefix>:<key>.
  • Clear() with a prefix uses SCAN + DEL to remove only matching keys. Without a prefix, it issues FLUSHDB — use with caution.
  • ttl = 0 in Set stores the key without expiry in Redis.

Advanced Redis Configuration

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.
    },
})

Hybrid Backend

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 (GetEx TTL or TTL() when L2 implements TTLer), and falls back to configured promotion TTL otherwise.

GetOrLoad & Stampede Protection

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:

  1. All concurrent callers for the same key arrive at GetOrLoad.
  2. The first caller acquires a slot and executes the loader.
  3. All subsequent callers wait (respecting their own ctx deadline).
  4. When the loader completes, all waiters receive the same value.
  5. 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.
}

Stale-While-Revalidate

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 refreshBeforeExpiry has not elapsed: return hit, no refresh.
  • If the entry is found and within the refreshBeforeExpiry window: 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 to defaultTTL. It does not inherit the caller's context deadline.


TTL Jitter

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.


Metrics (Prometheus)

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

Custom Namespace

// 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.

Disable Metrics

c, _ := cachex.New[string](cachex.WithoutMetrics())

Use this in tests or when you only want the Stats() snapshot without Prometheus registration.

Expose Metrics Endpoint

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)

Reading Stats Programmatically

s := c.Stats()
hitRate := float64(s.Hits) / float64(s.Hits+s.Misses) * 100
fmt.Printf("Hit rate: %.1f%%\n", hitRate)

Tracing (OpenTelemetry)

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).

Composing Tracing with Hybrid Cache

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)

Testing with MockCache

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)
    }
}

MockCache Fields

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().

Callbacks

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)
})

MockCache Behaviour Differences from Production

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().


Error Handling

Sentinel Errors

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
}

LoaderError

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
}

StoreError

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)
    }
}

Error Propagation Summary

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

Architecture

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

Key Design Decisions

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 (Apple M1 Pro)

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.


Full Examples

Example 1: API Response Cache

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)
}

Example 2: Hybrid Cache for User Profiles

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
}

Example 3: Unit Testing a Service

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)
    }
}

Example 4: OpenTelemetry with Jaeger

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
}

License

MIT

About

High-performance, production-ready Go caching library featuring W-TinyLFU eviction, 256-shard architecture for extreme concurrency, and hybrid L1/L2 (Memory + Redis) support. Zero-dependency core engine.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages