diff --git a/.agents/skills/go-memoize-package/SKILL.md b/.agents/skills/go-memoize-package/SKILL.md
new file mode 100644
index 0000000..8d81f8f
--- /dev/null
+++ b/.agents/skills/go-memoize-package/SKILL.md
@@ -0,0 +1,89 @@
+---
+name: go-memoize-package
+description: Work on the go_memoize Go package. Use when changing direct memoization, the root cache engine, stores, background refresh, loader snapshots, metrics, benchmarks, profiling docs, package docs, package README, or package examples.
+license: Apache-2.0
+compatibility: Requires Go and git. Run verification from the repository root.
+---
+
+# go_memoize Package
+
+## Purpose
+
+Use this skill to make correct package-level decisions in `go_memoize`. The root package is the only public module and contains direct function memoization plus the cache engine with stores, stale refresh, metrics, background values, and loaders.
+
+## First Decision
+
+Choose the API and store before editing:
+
+| User Need | Use |
+|---|---|
+| Simple function memoization with comparable args | Root package `Memoize`, `Memoize1` ... `Memoize7`, or `E`/`Ctx` variants |
+| Explicit cache key, TTL, stale refresh, metrics | `memoize.New[K,V]` and `memoize.Cache[K,V]` |
+| One logical value or one hot key | `memory.NewSingle[K,V]()` or `background.Keep` |
+| Many bounded in-memory keys | `memory.New[K,V](capacity)` |
+| Many concurrent supported primitive keys | `memory.NewSharded[K,V](capacity)` |
+| Two-tier cache | `chain.New[K,V](l1, l2)` |
+| File-backed local cache | `local.New[V](dir)` |
+| One writer publishes a full snapshot to many readers | Writer: `background.Keep` + `background.WriteThrough`; readers: `background.Mirror` or `background.MustMirror` |
+| Readiness-gated periodic local value | `loader.New` |
+
+## Public API Rules
+
+- Root public module only: `github.com/agkloop/go_memoize`. Do not add public import examples using legacy module paths or helper packages.
+- Direct memoizers use non-generic `memoize.Opts()` and return `(func, error)`.
+- Direct memoizers hash comparable function arguments to `uint64`; custom direct stores must be `memoize.Store[uint64,V]`.
+- Explicit caches use typed keys through `memoize.New[K,V]`; production examples usually use `K=string` for business keys.
+- Error-returning direct memoizers use the root cache engine and support stale-on-error through `WithStaleTTL(...).KeepStaleOnError()`.
+- `memoize.New[K,V]` has no default store and no default expiration policy. Choose `WithStore` plus `WithTTL`, `NoExpiration`, or `Bypass`.
+- Direct memoizers create an internal unbounded `Store[uint64,V]` when `WithStore` is omitted, but still require `WithTTL`, `NoExpiration`, or `Bypass`.
+
+## Cache Invariants
+
+- `memory.New[K,V](capacity)` is exact LRU by default and stores key type `K` directly.
+- `memory.NewSharded[K,V](capacity)` improves distributed-key concurrency for supported primitive key types. It does not reduce contention for one hot key; one key maps to one shard.
+- `memory.NewSingle[K,V]()` is read-mostly and avoids LRU/hash overhead for one logical value.
+- Stores persist raw `memoize.Stored[V]` envelopes. The cache engine owns fresh, stale, and expired decisions.
+- Built-in stores may expose private fast paths used by the cache engine. Do not document those as public extension points.
+- `WithGetRecencySample(n)` makes direct `Store.Get` recency approximate when `n > 1`.
+- Metrics use one public method: `RecordMetric(memoize.MetricEvent)`.
+- `background.Keep` and `loader.New` share internal periodic refresh-loop infrastructure. Cache stale refresh uses cache flight machinery, not the shared refresh loop.
+- Source compatibility may change for performance or clarity; users can pin module versions.
+- Keep core code standard-library-only unless the user explicitly approves a dependency.
+
+## Background And Loader Semantics
+
+- `background.Keep` is producer-side: it calls `fn` immediately, stores the value in local process memory, then refreshes on the interval.
+- `background.WriteThrough(key, store)` publishes every successful `Keep` refresh to a shared `memoize.Store[string,V]` as a no-expiration snapshot.
+- `background.Mirror` is reader-side: it reads one shared `Store[string,V]` key immediately, copies `entry.Value` into local process memory, then polls the store on the interval.
+- `background.MustMirror` is `Mirror` for startup paths; it panics when the initial remote read fails.
+- `background.Value.Get()` is an atomic local memory read. It does not call Redis, SQL, MySQL, S3, or any remote dependency; it does not block and does not return an error.
+- Values returned by `Value.Get()` are shared memory. Keep them immutable, or copy maps, slices, and pointer-heavy fields before mutation.
+- `loader.New` is readiness-oriented: callers use `Value(ctx)` to block until the first successful load, then read the latest loaded value after readiness.
+
+## Docs Sync Rule
+
+- When changing public docs, examples, README, API behavior, store behavior, background/loader semantics, metrics, benchmarks, or production recommendations, update this skill in the same change if future agents need to know the new rule.
+- Run `scripts/check-docs-skill-sync.sh` before finishing docs-heavy work. The repo also includes `.githooks/pre-commit` for teams that opt in with `git config core.hooksPath .githooks`.
+
+## Verification
+
+Run checks from the repository root:
+
+```sh
+go test ./... -count=1
+go test ./... -race -count=1
+```
+
+Benchmark cache/store work with an explicit command and record the result:
+
+```sh
+go test ./benchmarks/ -bench=. -benchmem -benchtime=1s -count=1
+```
+
+## Gotchas
+
+- Do not optimize single-hot-key workloads by adding more LRU tuning; use `memory.NewSingle`.
+- Do not claim sharding fixes single-key contention; one key maps to one shard.
+- Do not document legacy module paths or helper-package imports in public examples.
+- Do not update profiling docs without the exact benchmark command and observed numbers.
+- Do not add behavior changes without tests first.
diff --git a/.agents/skills/go-performance-optimization/SKILL.md b/.agents/skills/go-performance-optimization/SKILL.md
new file mode 100644
index 0000000..f2839de
--- /dev/null
+++ b/.agents/skills/go-performance-optimization/SKILL.md
@@ -0,0 +1,68 @@
+---
+name: go-performance-optimization
+description: Use when optimizing Go hot paths, reducing allocations, investigating slow benchmarks, reading pprof output, checking escape analysis, or applying goperf.dev/go-optimization-guide patterns to Go code.
+license: Apache-2.0
+compatibility: Requires Go tooling, benchmarks, pprof, and git.
+---
+
+# Go Performance Optimization
+
+## Overview
+
+Optimize from measurements, not folklore. Apply `goperf.dev` patterns only when they fit the observed bottleneck and keep the code simpler or measurably faster.
+
+## Workflow
+
+1. Establish a focused benchmark with `go test -bench ... -benchmem -count=1`.
+2. Profile before changing code when the cause is not obvious: add `-cpuprofile` and `-memprofile`, then inspect with `go tool pprof -top`.
+3. Use escape analysis for allocation questions: `go test -gcflags=-m=2 ./path`.
+4. Rank hypotheses by expected impact and risk.
+5. Change one variable at a time.
+6. Re-run the same benchmark and compare `ns/op`, `B/op`, and `allocs/op`.
+7. Run the package tests, then the repository verification command.
+
+## Applicable Patterns
+
+| Symptom | Prefer | Avoid |
+|---|---|---|
+| Hot-path allocations | Remove closure/interface/string formatting escapes | `sync.Pool` by default |
+| Growing slices/maps | Preallocate with known or bounded capacity | Letting hot buffers resize repeatedly |
+| Interface boxing in loops | Generics or concrete types | `any`, `interface{}`, `fmt` conversions on hot paths |
+| Large dense structs | Field alignment and locality checks | Reordering public structs without compatibility review |
+| Lock contention | Sharding, read-mostly stores, atomics for simple counters | Atomics for multi-step invariants |
+| Context overhead | Keep context at API boundaries and miss/IO paths | Creating timeout contexts on hot hits |
+| GC pressure | Fewer heap objects and shorter object lifetimes | Pooling tiny or long-lived objects |
+
+## Decision Rules
+
+- Keep benchmarks representative: one-hot-key, distributed-key, hit, miss, stale, and parallel workloads have different bottlenecks.
+- Do not optimize exact LRU by replacing its mutex with atomics; linked-list/index/map updates are one invariant.
+- Use `sync.Pool` only for reusable temporary objects that allocate heavily, such as buffers or encoders.
+- Remove `fmt.Sprint`, `fmt.Sprintf`, and `any` conversions from hot paths when a typed alternative exists.
+- Keep error handling and singleflight off hot-hit paths when behavior allows a separate fast path.
+- Prefer shallow, explicit APIs over hidden conversions.
+
+## Verification
+
+Run the benchmark that motivated the change and record before/after numbers:
+
+```sh
+go test ./benchmarks/ -bench='BenchmarkName$' -benchmem -benchtime=1s -count=1 -run '^$'
+```
+
+Run repository checks from the root:
+
+```sh
+git diff --check
+go test ./... -count=1
+go test ./... -race -count=1
+```
+
+## Common Mistakes
+
+- Claiming performance improved without fresh benchmark output.
+- Optimizing a benchmark-only workload while slowing the production path.
+- Adding `sync.Pool` when the real allocation is closure escape or interface boxing.
+- Using `fmt` for generic keys in cache/store hot paths.
+- Treating sharding as a fix for one hot key; one key still maps to one shard.
+- Ignoring `B/op` and `allocs/op` because `ns/op` moved slightly.
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 0000000..e2bd9c9
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,4 @@
+#!/usr/bin/env sh
+set -eu
+
+scripts/pre-commit.sh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b74a6bb..2b8e87d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,7 +12,7 @@ permissions:
contents: read
jobs:
- build:
+ verify:
runs-on: ubuntu-latest
steps:
@@ -22,15 +22,21 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
- go-version: stable
-
- - name: Run tests
- run: go test -v ./...
-
- - name: Check code formatting
- run: gofmt -l .
-
- - name: Run static analysis
- uses: golangci/golangci-lint-action@v6
- with:
- version: v1.64
\ No newline at end of file
+ # Use a patched toolchain for security scanning; go.mod still defines
+ # the module compatibility target.
+ go-version: "1.25.10"
+ check-latest: true
+ cache: true
+ cache-dependency-path: |
+ go.sum
+ adapters/redis/go.sum
+
+ - name: Install security and lint tools
+ run: |
+ go install golang.org/x/vuln/cmd/govulncheck@latest
+ go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+
+ - name: Run full verification suite
+ env:
+ REQUIRE_OPTIONAL_TOOLS: "1"
+ run: scripts/verify-ci-local.sh
diff --git a/.gitignore b/.gitignore
index 936fba8..bd7d940 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,4 +63,6 @@ debug/ # Common directory for debug artifacts
__debug_bin*
qodana.yaml
cmd
-testing
\ No newline at end of file
+testing
+.opencode
+docs/superpowers
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..412b7dd
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,11 @@
+# Agent Skills
+
+Use `.agents/skills/go-memoize-package/SKILL.md` when working on this repository.
+Use `.agents/skills/go-performance-optimization/SKILL.md` when optimizing Go hot paths, benchmarks, allocation profiles, pprof output, or escape analysis.
+
+Key reminders:
+
+- Root package is the only public module and contains both direct memoization and the cache engine.
+- Do not reintroduce `/v2` or `helpers` imports; users can pin module versions.
+- Run verification from the repository root: `go test ./... -count=1` and `go test ./... -race -count=1`.
+- Pick the memory store by workload: `memory.New` for many-key LRU, `memory.NewSharded` for distributed-key concurrency, `memory.NewSingle` for one logical value.
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..cf43916
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,25 @@
+# Package Context
+
+This package has one public Module, `github.com/agkloop/go_memoize`. It contains two primary user-facing Modules: direct memoization for functions and an explicit cache engine for keyed caching.
+
+## Domain Vocabulary
+
+Direct memoization is the Shallow Interface for common function caching. Users pass a function plus a TTL, and the Implementation derives keys from comparable arguments. Use it when Locality matters more than policy control: in-process values, no custom Store Adapter, no stale-while-revalidate, and no explicit metrics wiring.
+
+The cache engine is the Deep Module. Its public Interface is small: `New`, `Cache.Get`, `Cache.Set`, `Cache.Delete`, `Cache.Clear`, `Cache.GetOrCompute`, and `Cache.Stop`. Its Implementation owns TTL policy, expiration ownership, stale-while-revalidate, flight coalescing, fallback on refresh error, and metrics emission. This Depth gives users Leverage because Store Adapters can stay simple while cache behavior remains consistent.
+
+A Store Adapter implements the `Store[K,V]` Interface: `Get`, `Set`, `Delete`, and `Clear`. Stores persist and return raw `Stored[V]` entries. They do not decide whether an entry is fresh, stale, or expired for public cache reads. That expiration ownership belongs to the cache engine. Store-specific cleanup TTLs, such as Redis key expiry, are backend cleanup, not the public freshness policy.
+
+`Stored[V]` is the cache entry envelope. It carries `Value`, `CreatedAt`, `FreshUntil`, `StaleUntil`, `NoExpire`, `Version`, and `Tags`. The cache engine interprets the envelope; stores preserve it.
+
+Stale-while-revalidate means a cache hit can return an entry after `FreshUntil` but before `StaleUntil`, then refresh in the background. If configured with stale-on-error behavior, the cache can keep serving the stale value when recompute fails.
+
+A background value is one locally served value refreshed on a schedule through `background.Keep` or mirrored from a store through `background.Mirror`. It is best for config snapshots, feature flags, rates, and other one-value workloads.
+
+A loader is a periodic refresh Module exposed by `loader.New`. It retries until the first successful load, then `Value(ctx)` returns the latest successful value. Loader and background share an internal periodic refresh Implementation, but expose different public Interfaces for different use cases.
+
+Metrics use one event Interface: `Metrics.RecordMetric(MetricEvent)`. `MetricEventKind` identifies hits, misses, stale hits, refresh start/success/error, set, and delete. `Duration` is meaningful for refresh success latency. `Err` is meaningful for refresh errors.
+
+Private cache/store fast paths are Seams inside the Implementation. They let optimized stores provide fresh-value or peek behavior without making the public Store Interface larger. These Seams are tested through observable behavior and deletion tests: removing a private fast path test should reveal whether the Seam still protects policy Locality and avoids accidental coupling.
+
+Test surface should follow Module boundaries. Public behavior tests cover direct memoization, cache policy, and Store Adapter contracts. Private fast-path tests should stay narrow and verify observable effects, not leak private names into user documentation.
diff --git a/README.md b/README.md
index cf9d8c3..687f4eb 100644
--- a/README.md
+++ b/README.md
@@ -1,428 +1,128 @@
-# go_memoize
+# go_memoize

-`go_memoize` package provides a set of functions to memoize the results of computations, allowing for efficient caching and retrieval of results based on input parameters. This can significantly improve performance for expensive or frequently called functions.
+`go_memoize` is a Go memoization and caching package with one public root module for direct function memoization, explicit cache-engine workflows, in-memory stores, background snapshots, loaders, metrics, and adapters.
-## Features
-- Memoizes functions with TTL, supporting 0 to 7 comparable parameters. [List of Memoize Functions](https://github.com/agkloop/go_memoize/blob/main/memoize.go)
-- Error-aware variants (suffix `E`) that support compute functions returning `(V, error)` and do NOT cache when an error is returned.
-- High performance, zero allocation, and zero dependencies.
-- Utilizes the FNV-1a hash algorithm for caching.
-- Thread-safe and concurrent-safe.
-
-## Installation
-
-To install the package, use `go get`:
+## Install
```sh
go get github.com/agkloop/go_memoize
```
-## Usage
-
-### Basic Memoization
+## Choose The Right API
-The `Memoize` function can be used to memoize a function with no parameters:
+| Need | Use | Why |
+|---|---|---|
+| Memoize a function by comparable args | `memoize.Memoize1(fn, memoize.Opts().WithTTL(ttl))` through `Memoize7` | Small direct API with cache-engine options. |
+| Memoize a function that can fail | `memoize.Memoize1E` or `memoize.MemoizeCtx1E` | Errors are returned and successful values are cached. |
+| Cache explicit business keys | `memoize.New[K,V]` + `Cache.GetOrCompute` | Full control over key type, store, TTL, stale behavior, metrics, and shutdown. |
+| Many bounded in-memory keys | `memory.New[K,V](capacity)` | Exact LRU by default. |
+| One logical value or one hot key | `memory.NewSingle[K,V]()` or `background.Keep` | Avoids unnecessary LRU/hash overhead. |
+| Many hot keys concurrently | `memory.NewSharded[K,V](capacity)` | Distributes different supported primitive keys across shards. |
+| Cross-process or cross-host cache | Redis adapter or a custom shared store | In-process memory stores are not shared between processes. |
+| One scheduled snapshot with instant reads | `background.Keep` or `background.Mirror` | Refreshes independently of request traffic. |
-```go
-computeFn := func() int {
- // Expensive computation
- return 42
-}
+## Quick Examples
-memoizedFn := Memoize(computeFn, 10*time.Second)
-result := memoizedFn()
-```
-The same for functions with Context:
+Direct TTL memoizer:
```go
-computeCtxFn := func(ctx context.Context) int {
- // Expensive computation
- return 42
-}
-memoizedCtxFn := MemoizeCtx(computeCtxFn, 10*time.Second)
-result := memoizedCtxFn(context.Background())
-```
-
-### Error-aware memoization (do not memoize errors)
-
-If your compute function can fail and returns `(V, error)`, use the `E` variants. These versions will NOT store a cached value when the compute function returns a non-nil error. This is useful for transient failures where you want the next call to retry the computation rather than returning a cached error result.
-
-Available `E` variants:
-- `MemoizeE` (no-arg)
-- `Memoize1E` .. `Memoize7E` (1..7 args)
-- `MemoizeCtxE` and `MemoizeCtx1E` .. `MemoizeCtx7E` (context-aware)
-
-Behavior:
-- If a cached value exists for the key, the function returns it and a nil error.
-- If no cached value exists, the compute function is executed.
- - If compute returns `(v, nil)`, `v` is cached and returned.
- - If compute returns `(zeroValue, err)` (err != nil), the error is returned and nothing is cached.
-
-Example:
-
-```go
-computeFn := func(id int) (string, error) {
- // may return an error sometimes
-}
-
-memo := Memoize1E(func(id int) (string, error) { return computeFn(id) }, 30*time.Second)
-
-val, err := memo(123)
+cached, err := memoize.Memoize1(loadUser, memoize.Opts().WithTTL(time.Minute))
if err != nil {
- // transient error; next call will retry since nothing was cached
+ return err
}
+user := cached(42)
```
-### Memoization with Parameters
-
-The package provides functions to memoize functions with up to 7 parameters. Here are some examples:
-
-#### One Parameter
-
-```go
-computeFn := func(a int) int {
- // Expensive computation
- return a * 2
-}
-
-memoizedFn := Memoize1(computeFn, 10*time.Second)
-result := memoizedFn(5)
-```
-
-The same for functions with Context:
+Direct stale-on-error memoizer:
```go
-computeCtxFn := func(ctx context.Context, a int) int {
- // Expensive computation
- return a * 2
+cached, err := memoize.MemoizeCtx1E(repo.LoadProfile,
+ memoize.Opts().
+ WithTTL(time.Minute).
+ WithStaleTTL(5*time.Minute).
+ KeepStaleOnError(),
+)
+if err != nil {
+ return err
}
-
-memoizedCtxFn := MemoizeCtx1(computeCtxFn, 10*time.Second)
-result := memoizedCtxFn(context.Background(), 5)
+profile, err := cached(ctx, 42)
```
-#### Two Parameters
+Explicit cache with business keys:
```go
-computeFn := func(a int, b string) string {
- // Expensive computation
- return fmt.Sprintf("%d-%s", a, b)
-}
-
-memoizedFn := Memoize2(computeFn, 10*time.Second)
-result := memoizedFn(5, "example")
-```
-
-The same for functions with Context:
-
-```go
-computeCtxFn := func(ctx context.Context, a int, b string) string {
- // Expensive computation
- return fmt.Sprintf("%d-%s", a, b)
+cache, err := memoize.New[string, User](
+ memoize.Opts().
+ WithStore(memory.New[string, User](10_000)).
+ WithTTL(time.Minute),
+)
+if err != nil {
+ return err
}
+defer cache.Stop()
-memoizedCtxFn := MemoizeCtx2(computeCtxFn, 10*time.Second)
-result := memoizedCtxFn(context.Background(), 5, "example")
+user, err := cache.GetOrCompute(ctx, "user:42", func(ctx context.Context) (User, error) {
+ return repo.LoadUser(ctx, 42)
+})
```
-#### Three Parameters
-
-```go
-computeFn := func(a int, b string, c float64) string {
- // Expensive computation
- return fmt.Sprintf("%d-%s-%f", a, b, c)
-}
-
-memoizedFn := Memoize3(computeFn, 10*time.Second)
-result := memoizedFn(5, "example", 3.14)
-```
+## Defaults
-The same for functions with Context:
+`memoize.New[K,V]` intentionally does not choose a store or expiration policy for you:
-```go
-computeCtxFn := func(ctx context.Context, a int, b string, c float64) string {
- // Expensive computation
- return fmt.Sprintf("%d-%s-%f", a, b, c)
-}
+| Setting | Default |
+|---|---|
+| Store | None. Use `Opts().WithStore(store)` unless using `Bypass()`. |
+| Expiration policy | None. Choose `WithTTL`, `NoExpiration`, or `Bypass`. |
+| Metrics | Disabled with a noop metrics implementation. Enable with `WithMetrics`. |
+| Clock | Ticker-backed clock with a 1ms tick. Call `cache.Stop()` to release it. |
+| Refresh timeout | 30 seconds for background stale refresh. Override with `WithRefreshTimeout`. |
+| Same-key miss coalescing | Enabled internally for concurrent `GetOrCompute` misses on the same key. |
-memoizedCtxFn := MemoizeCtx3(computeCtxFn, 10*time.Second)
-result := memoizedCtxFn(context.Background(), 5, "example", 3.14)
-```
+Direct `Memoize*` functions use the same options. If `WithStore` is omitted, they create an internal unbounded `Store[uint64,V]` for hashed argument keys. Direct memoizers still require `WithTTL`, `NoExpiration`, or `Bypass`; they do not silently choose an expiration policy.
-### Cache Management
+## Architecture And Performance
-The `Cache` struct is used internally to manage the cached entries. It supports setting, getting, and deleting entries, as well as computing new values if they are not already cached or have expired.
+go_memoize uses one root cache engine for both direct memoizers and explicit caches.
-## Testing
+- Direct memoizers hash comparable arguments to `uint64` and use the same cache engine as `memoize.New`.
+- Stores persist raw `memoize.Stored[V]` envelopes; the cache engine owns fresh, stale, and expired decisions.
+- `GetOrCompute` coalesces same-key concurrent misses through an internal singleflight map.
+- Memory stores provide exact LRU behavior, optional byte limits, and private fast paths used by the cache engine.
+- Cache stale refresh uses cache-engine flight machinery; `background.Keep` and `loader.New` share periodic refresh-loop infrastructure internally.
+- Metrics use one event method, `RecordMetric(MetricEvent)`, to keep hot-path instrumentation small and backend-neutral.
-Unit tests cover the memoization behavior, including the new error-aware variants. To run tests:
+Latest benchmark snapshot from this checkout used:
```sh
-# run all tests
-go test ./...
-
-# run a specific test
-go test ./... -run TestMemoizeE_DoesNotCacheError -v
-```
-
-New tests were added in `memoize_error_test.go` to verify that error results are not cached and that successful results are cached.
-
-## Example
-
-Here is a complete example of using the `memoize` package:
-
-```go
-package main
-
-import (
- "fmt"
- "time"
- m "github.com/agkloop/go_memoize"
-)
-
-func main() {
- computeFn := func(a int, b string) string {
- // Simulate an expensive computation
- time.Sleep(2 * time.Second)
- return fmt.Sprintf("%d-%s", a, b)
- }
-
- memoizedFn := m.Memoize2(computeFn, 10*time.Second)
-
- // First call will compute the result
- result := memoizedFn(5, "example")
- fmt.Println(result) // Output: 5-example
-
- // Subsequent calls within 10 seconds will use the cached result
- result = memoizedFn(5, "example")
- fmt.Println(result) // Output: 5-example
-}
-```
-
-## Functions & Usage Examples
-
-
-
- Function |
- Description |
- Example |
-
-
- Memoize |
- Memoizes a function with no params |
-
-
-memoizedFn := Memoize(func() int { return 1 }, time.Minute)
-result := memoizedFn()
-
- |
-
-
- Memoize1 |
- Memoizes a function with 1 param |
-
-
-memoizedFn := Memoize1(func(a int) int { return a * 2 }, time.Minute)
-result := memoizedFn(5)
-
- |
-
-
- Memoize2 |
- Memoizes a function with 2 params |
-
-
-memoizedFn := Memoize2(func(a int, b string) string { return fmt.Sprintf("%d-%s", a, b) }, time.Minute)
-result := memoizedFn(5, "example")
-
- |
-
-
- Memoize3 |
- Memoizes a function with 3 params |
-
-
-memoizedFn := Memoize3(func(a int, b string, c float64) string { return fmt.Sprintf("%d-%s-%f", a, b, c) }, time.Minute)
-result := memoizedFn(5, "example", 3.14)
-
- |
-
-
- Memoize4 |
- Memoizes a function with 4 params |
-
-
-memoizedFn := Memoize4(func(a, b, c, d int) int { return a + b + c + d }, time.Minute)
-result := memoizedFn(1, 2, 3, 4)
-
- |
-
-
- Memoize5 |
- Memoizes a function with 5 params |
-
-
-memoizedFn := Memoize5(func(a, b, c, d, e int) int { return a + b + c + d + e }, time.Minute)
-result := memoizedFn(1, 2, 3, 4, 5)
-
- |
-
-
- Memoize6 |
- Memoizes a function with 6 params |
-
-
-memoizedFn := Memoize6(func(a, b, c, d, e, f int) int { return a + b + c + d + e + f }, time.Minute)
-result := memoizedFn(1, 2, 3, 4, 5, 6)
-
- |
-
-
- Memoize7 |
- Memoizes a function with 7 params |
-
-
-memoizedFn := Memoize7(func(a, b, c, d, e, f, g int) int { return a + b + c + d + e + f + g }, time.Minute)
-result := memoizedFn(1, 2, 3, 4, 5, 6, 7)
-
- |
-
-
- MemoizeE |
- Memoizes a function with no params, error-aware |
-
-
-memoizedFn := MemoizeE(func() (int, error) { return 1, nil }, time.Minute)
-result, err := memoizedFn()
-
- |
-
-
- Memoize1E |
- Memoizes a function with 1 param, error-aware |
-
-
-memoizedFn := Memoize1E(func(a int) (int, error) { return a * 2, nil }, time.Minute)
-result, err := memoizedFn(5)
-
- |
-
-
- Memoize2E |
- Memoizes a function with 2 params, error-aware |
-
-
-memoizedFn := Memoize2E(func(a int, b string) (string, error) { return fmt.Sprintf("%d-%s", a, b), nil }, time.Minute)
-result, err := memoizedFn(5, "example")
-
- |
-
-
- Memoize3E |
- Memoizes a function with 3 params, error-aware |
-
-
-memoizedFn := Memoize3E(func(a int, b string, c float64) (string, error) { return fmt.Sprintf("%d-%s-%f", a, b, c), nil }, time.Minute)
-result, err := memoizedFn(5, "example", 3.14)
-
- |
-
-
- MemoizeCtx |
- Memoizes a function with context and no params |
-
-
-memoizedCtxFn := MemoizeCtx(func(ctx context.Context) int { return 1 }, time.Minute)
-result := memoizedCtxFn(context.Background())
-
- |
-
-
- MemoizeCtx1 |
- Memoizes a function with context and 1 param |
-
-
-memoizedCtxFn := MemoizeCtx1(func(ctx context.Context, a int) int { return a * 2 }, time.Minute)
-result := memoizedCtxFn(context.Background(), 5)
-
- |
-
-
- MemoizeCtx2 |
- Memoizes a function with context and 2 params |
-
-
-memoizedCtxFn := MemoizeCtx2(func(ctx context.Context, a int, b string) string { return fmt.Sprintf("%d-%s", a, b) }, time.Minute)
-result := memoizedCtxFn(context.Background(), 5, "example")
-
- |
-
-
- MemoizeCtx3 |
- Memoizes a function with context and 3 params |
-
-
-memoizedCtxFn := MemoizeCtx3(func(ctx context.Context, a int, b string, c float64) string { return fmt.Sprintf("%d-%s-%f", a, b, c) }, time.Minute)
-result := memoizedCtxFn(context.Background(), 5, "example", 3.14)
-
- |
-
-
- MemoizeCtx4 |
- Memoizes a function with context and 4 params |
-
-
-memoizedCtxFn := MemoizeCtx4(func(ctx context.Context, a, b, c, d int) int { return a + b + c + d }, time.Minute)
-result := memoizedCtxFn(context.Background(), 1, 2, 3, 4)
-
- |
-
-
- MemoizeCtx5 |
- Memoizes a function with context and 5 params |
-
-
-memoizedCtxFn := MemoizeCtx5(func(ctx context.Context, a, b, c, d, e int) int { return a + b + c + d + e }, time.Minute)
-result := memoizedCtxFn(context.Background(), 1, 2, 3, 4, 5)
-
- |
-
-
- MemoizeCtx6 |
- Memoizes a function with context and 6 params |
-
-
-memoizedCtxFn := MemoizeCtx6(func(ctx context.Context, a, b, c, d, e, f int) int { return a + b + c + d + e + f }, time.Minute)
-result := memoizedCtxFn(context.Background(), 1, 2, 3, 4, 5, 6)
-
- |
-
-
- MemoizeCtx7 |
- Memoizes a function with context and 7 params |
-
-
-memoizedCtxFn := MemoizeCtx7(func(ctx context.Context, a, b, c, d, e, f, g int) int { return a + b + c + d + e + f + g }, time.Minute)
-result := memoizedCtxFn(context.Background(), 1, 2, 3, 4, 5, 6, 7)
-
- |
-
-
-
-### [For more benchmarking results please check this](https://github.com/agkloop/go_memoize/blob/bechmarking/benchmarks/README.md)
-Device "Apple M2 Pro"
-
-```
-goos: darwin
-goarch: arm64
-BenchmarkDo0Mem-10 | 811289566 | 14.77 ns/op | 0 B/op | 0 allocs/op
-BenchmarkDo1Mem-10 | 676579908 | 18.26 ns/op | 0 B/op | 0 allocs/op
-BenchmarkDo2Mem-10 | 578134332 | 20.99 ns/op | 0 B/op | 0 allocs/op
-BenchmarkDo3Mem-10 | 533455237 | 22.67 ns/op | 0 B/op | 0 allocs/op
-BenchmarkDo4Mem-10 | 487471639 | 24.73 ns/op | 0 B/op | 0 allocs/op
-
+go test ./benchmarks/ -bench=. -benchmem -benchtime=1s -count=1
```
-This project is licensed under the Apache License. See the [`LICENSE`](https://github.com/agkloop/go_memoize/blob/main/LICENSE) file for details.
\ No newline at end of file
+Top-line results on this machine: `BenchmarkMemoryHotHit` was `29.80 ns/op` with `0 B/op` and `0 allocs/op`; `BenchmarkSingleHotHit` was `14.74 ns/op`; `BenchmarkGetOrComputeStampede` was `1747 ns/op` with `0 allocs/op`; stale stampede was `399.8 ns/op` with `0 allocs/op`. Sharding did not improve a single hot key (`BenchmarkMemoryHotHitParallel` `150.9 ns/op`, sharded `150.0 ns/op`). See `docs/PERFORMANCE.md` for full output and interpretation.
+
+## Production Recommendations
+
+- Use direct `Memoize*` functions for simple in-process function memoization.
+- Use `Cache.GetOrCompute` when keys are business identifiers, when you need custom stores, or when stale refresh matters.
+- Use `background.Keep` with a shared store for a single-writer snapshot refresher, and `background.Mirror` in reader processes for local atomic reads.
+- Custom stores implement `memoize.Store[K,V]` and must store raw `memoize.Stored[V]` envelopes; the cache engine owns freshness decisions.
+- S3 and other object stores can be custom durable L2 stores behind `chain.New`, but should not be the first cache tier for low-latency hot paths.
+- Use `memory.NewSingle` or `background.Keep` for one logical value such as config, feature flags, or exchange rates.
+- Use `memory.NewSharded` only when many different supported primitive keys are hot concurrently; one key still maps to one shard.
+- Use the Redis adapter or another shared store when multiple processes or hosts need the same backing cache.
+- Always `defer cache.Stop()` for explicit caches using the default ticker clock.
+- Run `go test ./... -count=1` and `go test ./... -race -count=1` before release.
+
+## Documentation
+
+- `docs/GETTING_STARTED.md` - install and first working examples.
+- `docs/CONCEPTS.md` - direct vs explicit cache, keys, stores, TTL/stale, and defaults.
+- `docs/RECIPES.md` - copy-paste usage patterns.
+- `docs/PERFORMANCE.md` - internal architecture, performance optimizations, and benchmark results.
+- `docs/API.md` - full public API reference.
+- `docs/PRODUCTION.md` - production store selection, observability, and failure behavior.
+- `examples/direct_stale_profile_cache` - direct memoizer stale-on-error example.
+- `examples/http_user_cache` - explicit cache with business keys.
+- `adapters/redis/examples/hybrid_profile_cache` - direct memoizer with memory L1 and Redis L2.
diff --git a/adapters/redis/examples/hybrid_profile_cache/README.md b/adapters/redis/examples/hybrid_profile_cache/README.md
new file mode 100644
index 0000000..fca8ee2
--- /dev/null
+++ b/adapters/redis/examples/hybrid_profile_cache/README.md
@@ -0,0 +1,20 @@
+# Hybrid Profile Cache
+
+Working direct memoizer example using a two-tier store in the Redis adapter module. Profiles are derived from Beeceptor's sample user API shape: `GET https://fake-json-api.mock.beeceptor.com/users`.
+
+- Uses `MemoizeCtx1E` for repository calls.
+- Uses `Store[uint64, Profile]` because direct memoizers hash comparable arguments to `uint64` keys.
+- Uses `memory.New[uint64, Profile]` as L1 and Redis as L2 through `chain.New`.
+- Relies on the Redis default key encoder: direct hash keys are formatted as decimal strings after the prefix, for example `profiles:123456789`.
+- Configures TTL, stale TTL, and `KeepStaleOnError` for production outage tolerance.
+- Read `profile_service.go` first for memoize, chain, memory, and Redis adapter usage.
+- Beeceptor HTTP and JSON parsing live in `beeceptor_repository.go`.
+
+Test it directly from the adapter module:
+
+```sh
+cd adapters/redis
+go test ./examples/hybrid_profile_cache -count=1
+```
+
+The unit test uses a memory-backed L1/L2 chain to verify the same direct memoizer and chain-store caching behavior without requiring a Redis server. The `NewProfileService` constructor shows the production Redis wiring.
diff --git a/adapters/redis/examples/hybrid_profile_cache/beeceptor_repository.go b/adapters/redis/examples/hybrid_profile_cache/beeceptor_repository.go
new file mode 100644
index 0000000..5c67f1d
--- /dev/null
+++ b/adapters/redis/examples/hybrid_profile_cache/beeceptor_repository.go
@@ -0,0 +1,93 @@
+package hybridprofilecache
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const BeeceptorUsersURL = "https://fake-json-api.mock.beeceptor.com/users"
+
+var (
+ ErrMissingBaseURL = errors.New("hybridprofilecache: missing base URL")
+ ErrProfileNotFound = errors.New("hybridprofilecache: profile not found")
+)
+
+type BeeceptorProfileRepositoryConfig struct {
+ BaseURL string
+ HTTPClient HTTPClient
+ RequestTimeout time.Duration
+}
+
+type BeeceptorProfileRepository struct {
+ baseURL *url.URL
+ client HTTPClient
+ requestTimeout time.Duration
+}
+
+func NewBeeceptorProfileRepository(cfg BeeceptorProfileRepositoryConfig) (*BeeceptorProfileRepository, error) {
+ baseURL, err := parseBaseURL(defaultString(cfg.BaseURL, "https://fake-json-api.mock.beeceptor.com"))
+ if err != nil {
+ return nil, err
+ }
+ client := cfg.HTTPClient
+ if client == nil {
+ client = http.DefaultClient
+ }
+ return &BeeceptorProfileRepository{baseURL: baseURL, client: client, requestTimeout: defaultDuration(cfg.RequestTimeout, defaultRequestTimeout)}, nil
+}
+
+func (r *BeeceptorProfileRepository) LoadProfile(ctx context.Context, profileID int64) (Profile, error) {
+ ctx, cancel := context.WithTimeout(ctx, r.requestTimeout)
+ defer cancel()
+
+ endpoint := r.baseURL.JoinPath("users")
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+ if err != nil {
+ return Profile{}, err
+ }
+ req.Header.Set("Accept", "application/json")
+ res, err := r.client.Do(req)
+ if err != nil {
+ return Profile{}, err
+ }
+ defer func() { _ = res.Body.Close() }()
+ if res.StatusCode != http.StatusOK {
+ return Profile{}, fmt.Errorf("hybridprofilecache: Beeceptor users status %d", res.StatusCode)
+ }
+
+ var users []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Company string `json:"company"`
+ }
+ if err := json.NewDecoder(res.Body).Decode(&users); err != nil {
+ return Profile{}, err
+ }
+ for _, user := range users {
+ if user.ID == profileID {
+ return Profile{ID: user.ID, DisplayName: user.Name, Email: user.Email, Company: user.Company}, nil
+ }
+ }
+ return Profile{}, ErrProfileNotFound
+}
+
+func parseBaseURL(raw string) (*url.URL, error) {
+ if strings.TrimSpace(raw) == "" {
+ return nil, ErrMissingBaseURL
+ }
+ baseURL, err := url.Parse(raw)
+ if err != nil {
+ return nil, err
+ }
+ if baseURL.Scheme == "" || baseURL.Host == "" {
+ return nil, fmt.Errorf("hybridprofilecache: invalid base URL %q", raw)
+ }
+ return baseURL, nil
+}
diff --git a/adapters/redis/examples/hybrid_profile_cache/profile_service.go b/adapters/redis/examples/hybrid_profile_cache/profile_service.go
new file mode 100644
index 0000000..00333ec
--- /dev/null
+++ b/adapters/redis/examples/hybrid_profile_cache/profile_service.go
@@ -0,0 +1,134 @@
+package hybridprofilecache
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ redisstore "github.com/agkloop/go_memoize/adapters/redis"
+ "github.com/agkloop/go_memoize/serializers"
+ "github.com/agkloop/go_memoize/stores/chain"
+ "github.com/agkloop/go_memoize/stores/memory"
+ "github.com/redis/go-redis/v9"
+)
+
+const (
+ defaultHybridCapacity = 25_000
+ defaultHybridFreshTTL = 30 * time.Second
+ defaultHybridStaleTTL = 5 * time.Minute
+ defaultRequestTimeout = 2 * time.Second
+)
+
+var (
+ ErrMissingRedisClient = errors.New("hybridprofilecache: missing redis client")
+ ErrMissingRepository = errors.New("hybridprofilecache: missing repository")
+ ErrMissingStore = errors.New("hybridprofilecache: missing store")
+)
+
+type HTTPClient interface {
+ Do(*http.Request) (*http.Response, error)
+}
+
+type Profile struct {
+ ID int64 `json:"id"`
+ DisplayName string `json:"display_name"`
+ Email string `json:"email"`
+ Company string `json:"company"`
+}
+
+type ProfileRepository interface {
+ LoadProfile(context.Context, int64) (Profile, error)
+}
+
+type ProfileServiceConfig struct {
+ Repository ProfileRepository
+ RedisClient redis.UniversalClient
+ RedisPrefix string
+ CacheCapacity int
+ FreshTTL time.Duration
+ StaleTTL time.Duration
+ Metrics memoize.Metrics
+}
+
+type ProfileServiceStoreConfig struct {
+ Repository ProfileRepository
+ Store memoize.Store[uint64, Profile]
+ FreshTTL time.Duration
+ StaleTTL time.Duration
+ Metrics memoize.Metrics
+}
+
+type ProfileService struct {
+ loadProfile func(context.Context, int64) (Profile, error)
+}
+
+func NewProfileService(cfg ProfileServiceConfig) (*ProfileService, error) {
+ if cfg.RedisClient == nil {
+ return nil, ErrMissingRedisClient
+ }
+ l1 := memory.New[uint64, Profile](defaultInt(cfg.CacheCapacity, defaultHybridCapacity))
+ l2, err := redisstore.New[uint64, Profile](
+ redisstore.WithClient[uint64, Profile](cfg.RedisClient),
+ redisstore.WithPrefix[uint64, Profile](defaultString(cfg.RedisPrefix, "profiles")),
+ redisstore.WithSerializer[uint64, Profile](serializers.JSON[Profile]{}),
+ )
+ if err != nil {
+ return nil, err
+ }
+ return NewProfileServiceWithStore(ProfileServiceStoreConfig{
+ Repository: cfg.Repository,
+ Store: chain.New[uint64, Profile](l1, l2),
+ FreshTTL: cfg.FreshTTL,
+ StaleTTL: cfg.StaleTTL,
+ Metrics: cfg.Metrics,
+ })
+}
+
+func NewProfileServiceWithStore(cfg ProfileServiceStoreConfig) (*ProfileService, error) {
+ if cfg.Repository == nil {
+ return nil, ErrMissingRepository
+ }
+ if cfg.Store == nil {
+ return nil, ErrMissingStore
+ }
+ opts := memoize.Opts().
+ WithStore(cfg.Store).
+ WithTTL(defaultDuration(cfg.FreshTTL, defaultHybridFreshTTL)).
+ WithStaleTTL(defaultDuration(cfg.StaleTTL, defaultHybridStaleTTL)).
+ KeepStaleOnError()
+ if cfg.Metrics != nil {
+ opts = opts.WithMetrics(cfg.Metrics)
+ }
+ cached, err := memoize.MemoizeCtx1E(cfg.Repository.LoadProfile, opts)
+ if err != nil {
+ return nil, err
+ }
+ return &ProfileService{loadProfile: cached}, nil
+}
+
+func (s *ProfileService) GetProfile(ctx context.Context, profileID int64) (Profile, error) {
+ return s.loadProfile(ctx, profileID)
+}
+
+func defaultString(value, fallback string) string {
+ if value == "" {
+ return fallback
+ }
+ return value
+}
+
+func defaultInt(value, fallback int) int {
+ if value == 0 {
+ return fallback
+ }
+ return value
+}
+
+func defaultDuration(value, fallback time.Duration) time.Duration {
+ if value == 0 {
+ return fallback
+ }
+ return value
+}
diff --git a/adapters/redis/examples/hybrid_profile_cache/profile_service_test.go b/adapters/redis/examples/hybrid_profile_cache/profile_service_test.go
new file mode 100644
index 0000000..77b3760
--- /dev/null
+++ b/adapters/redis/examples/hybrid_profile_cache/profile_service_test.go
@@ -0,0 +1,54 @@
+package hybridprofilecache
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/agkloop/go_memoize/stores/chain"
+ "github.com/agkloop/go_memoize/stores/memory"
+)
+
+func TestProfileServiceCachesThroughHybridStore(t *testing.T) {
+ requests := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests++
+ if r.URL.Path != "/users" {
+ t.Fatalf("path = %q, want /users", r.URL.Path)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"id":9,"name":"Margaret Hamilton","email":"margaret@example.test","company":"Apollo Guidance"}
+ ]`))
+ }))
+ defer server.Close()
+
+ repo, err := NewBeeceptorProfileRepository(BeeceptorProfileRepositoryConfig{BaseURL: server.URL})
+ if err != nil {
+ t.Fatalf("NewBeeceptorProfileRepository failed: %v", err)
+ }
+ hybrid := chain.New[uint64, Profile](memory.New[uint64, Profile](16), memory.New[uint64, Profile](16))
+ service, err := NewProfileServiceWithStore(ProfileServiceStoreConfig{Repository: repo, Store: hybrid, FreshTTL: time.Minute})
+ if err != nil {
+ t.Fatalf("NewProfileServiceWithStore failed: %v", err)
+ }
+
+ ctx := context.Background()
+ first, err := service.GetProfile(ctx, 9)
+ if err != nil {
+ t.Fatalf("first GetProfile failed: %v", err)
+ }
+ second, err := service.GetProfile(ctx, 9)
+ if err != nil {
+ t.Fatalf("second GetProfile failed: %v", err)
+ }
+
+ if first.DisplayName != "Margaret Hamilton" || second != first {
+ t.Fatalf("unexpected cached profile: first=%+v second=%+v", first, second)
+ }
+ if requests != 1 {
+ t.Fatalf("requests = %d, want 1", requests)
+ }
+}
diff --git a/adapters/redis/go.mod b/adapters/redis/go.mod
new file mode 100644
index 0000000..1a230ed
--- /dev/null
+++ b/adapters/redis/go.mod
@@ -0,0 +1,15 @@
+module github.com/agkloop/go_memoize/adapters/redis
+
+go 1.24.0
+
+require (
+ github.com/agkloop/go_memoize v0.0.0
+ github.com/redis/go-redis/v9 v9.19.0
+)
+
+require (
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+)
+
+replace github.com/agkloop/go_memoize => ../..
diff --git a/adapters/redis/go.sum b/adapters/redis/go.sum
new file mode 100644
index 0000000..41952ed
--- /dev/null
+++ b/adapters/redis/go.sum
@@ -0,0 +1,22 @@
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
+github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
+github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
+github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
diff --git a/adapters/redis/integration_test.go b/adapters/redis/integration_test.go
new file mode 100644
index 0000000..6355ed3
--- /dev/null
+++ b/adapters/redis/integration_test.go
@@ -0,0 +1,37 @@
+package redisstore
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/agkloop/go_memoize/serializers"
+ "github.com/redis/go-redis/v9"
+)
+
+func TestRedisStoreIntegration(t *testing.T) {
+ addr := os.Getenv("REDIS_ADDR")
+ if addr == "" {
+ t.Skip("REDIS_ADDR is not set")
+ }
+ ctx := context.Background()
+ client := redis.NewClient(&redis.Options{Addr: addr})
+ t.Cleanup(func() { _ = client.Close() })
+ store, err := New[string, string](WithClient[string, string](client), WithPrefix[string, string]("go_memoize_test"), WithSerializer[string, string](serializers.JSON[string]{}))
+ if err != nil {
+ t.Fatalf("new store failed: %v", err)
+ }
+ entry := memoize.Stored[string]{Value: "redis", CreatedAt: time.Now(), FreshUntil: time.Now().Add(time.Minute)}
+ if err := store.Set(ctx, "key", entry); err != nil {
+ t.Fatalf("set failed: %v", err)
+ }
+ got, ok, err := store.Get(ctx, "key")
+ if err != nil || !ok || got.Value != "redis" {
+ t.Fatalf("get value=%q ok=%v err=%v", got.Value, ok, err)
+ }
+ if err := store.Clear(ctx); err != nil {
+ t.Fatalf("clear failed: %v", err)
+ }
+}
diff --git a/adapters/redis/store.go b/adapters/redis/store.go
new file mode 100644
index 0000000..4112ea6
--- /dev/null
+++ b/adapters/redis/store.go
@@ -0,0 +1,177 @@
+package redisstore
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/redis/go-redis/v9"
+)
+
+var (
+ ErrMissingClient = errors.New("redisstore: missing redis client")
+ ErrMissingSerializer = errors.New("redisstore: missing serializer")
+)
+
+type Store[K comparable, V any] struct {
+ client redis.UniversalClient
+ prefix string
+ serializer memoize.Serializer[V]
+ keyEncoder func(K) string
+}
+
+type Option[K comparable, V any] func(*Store[K, V])
+
+func WithClient[K comparable, V any](client redis.UniversalClient) Option[K, V] {
+ return func(s *Store[K, V]) { s.client = client }
+}
+
+func WithPrefix[K comparable, V any](prefix string) Option[K, V] {
+ return func(s *Store[K, V]) { s.prefix = prefix }
+}
+
+func WithSerializer[K comparable, V any](serializer memoize.Serializer[V]) Option[K, V] {
+ return func(s *Store[K, V]) { s.serializer = serializer }
+}
+
+func WithKeyEncoder[K comparable, V any](encode func(K) string) Option[K, V] {
+ return func(s *Store[K, V]) { s.keyEncoder = encode }
+}
+
+func New[K comparable, V any](opts ...Option[K, V]) (*Store[K, V], error) {
+ s := &Store[K, V]{keyEncoder: defaultKeyEncoder[K]}
+ for _, opt := range opts {
+ opt(s)
+ }
+ if s.client == nil {
+ return nil, ErrMissingClient
+ }
+ if s.serializer == nil {
+ return nil, ErrMissingSerializer
+ }
+ return s, nil
+}
+
+func defaultKeyEncoder[K comparable](key K) string {
+ switch k := any(key).(type) {
+ case string:
+ return k
+ case uint64:
+ return strconv.FormatUint(k, 10)
+ case uint:
+ return strconv.FormatUint(uint64(k), 10)
+ case uint32:
+ return strconv.FormatUint(uint64(k), 10)
+ case uint16:
+ return strconv.FormatUint(uint64(k), 10)
+ case uint8:
+ return strconv.FormatUint(uint64(k), 10)
+ case int:
+ return strconv.FormatInt(int64(k), 10)
+ case int64:
+ return strconv.FormatInt(k, 10)
+ case int32:
+ return strconv.FormatInt(int64(k), 10)
+ case int16:
+ return strconv.FormatInt(int64(k), 10)
+ case int8:
+ return strconv.FormatInt(int64(k), 10)
+ default:
+ return fmt.Sprint(key)
+ }
+}
+
+type envelope struct {
+ Value []byte `json:"value"`
+ CreatedAt time.Time `json:"created_at"`
+ FreshUntil time.Time `json:"fresh_until"`
+ StaleUntil time.Time `json:"stale_until"`
+ NoExpire bool `json:"no_expire"`
+ Version string `json:"version"`
+ Tags []string `json:"tags"`
+}
+
+func (s *Store[K, V]) key(key K) string {
+ encode := s.keyEncoder
+ if encode == nil {
+ encode = defaultKeyEncoder[K]
+ }
+ return s.prefixed(encode(key))
+}
+
+func (s *Store[K, V]) prefixed(encoded string) string {
+ if s.prefix == "" {
+ return encoded
+ }
+ return strings.TrimRight(s.prefix, ":") + ":" + encoded
+}
+
+func (s *Store[K, V]) Get(ctx context.Context, key K) (memoize.Stored[V], bool, error) {
+ var zero memoize.Stored[V]
+ data, err := s.client.Get(ctx, s.key(key)).Bytes()
+ if errors.Is(err, redis.Nil) {
+ return zero, false, nil
+ }
+ if err != nil {
+ return zero, false, err
+ }
+ var env envelope
+ if err := json.Unmarshal(data, &env); err != nil {
+ return zero, false, err
+ }
+ value, err := s.serializer.Unmarshal(env.Value)
+ if err != nil {
+ return zero, false, err
+ }
+ return memoize.Stored[V]{Value: value, CreatedAt: env.CreatedAt, FreshUntil: env.FreshUntil, StaleUntil: env.StaleUntil, NoExpire: env.NoExpire, Version: env.Version, Tags: env.Tags}, true, nil
+}
+
+func (s *Store[K, V]) Set(ctx context.Context, key K, value memoize.Stored[V]) error {
+ encoded, err := s.serializer.Marshal(value.Value)
+ if err != nil {
+ return err
+ }
+ data, err := json.Marshal(envelope{Value: encoded, CreatedAt: value.CreatedAt, FreshUntil: value.FreshUntil, StaleUntil: value.StaleUntil, NoExpire: value.NoExpire, Version: value.Version, Tags: value.Tags})
+ if err != nil {
+ return err
+ }
+ return s.client.Set(ctx, s.key(key), data, storageTTL(value, time.Now())).Err()
+}
+
+func (s *Store[K, V]) Delete(ctx context.Context, key K) error {
+ return s.client.Del(ctx, s.key(key)).Err()
+}
+
+func (s *Store[K, V]) Clear(ctx context.Context) error {
+ pattern := s.prefixed("*")
+ iter := s.client.Scan(ctx, 0, pattern, 100).Iterator()
+ for iter.Next(ctx) {
+ if err := s.client.Del(ctx, iter.Val()).Err(); err != nil {
+ return err
+ }
+ }
+ return iter.Err()
+}
+
+func storageTTL[V any](entry memoize.Stored[V], now time.Time) time.Duration {
+ if entry.NoExpire {
+ return 0
+ }
+ deadline := entry.FreshUntil
+ if entry.StaleUntil.After(deadline) {
+ deadline = entry.StaleUntil
+ }
+ if deadline.IsZero() {
+ return 0
+ }
+ ttl := deadline.Sub(now)
+ if ttl <= 0 {
+ return time.Millisecond
+ }
+ return ttl
+}
diff --git a/adapters/redis/store_test.go b/adapters/redis/store_test.go
new file mode 100644
index 0000000..2b2198d
--- /dev/null
+++ b/adapters/redis/store_test.go
@@ -0,0 +1,42 @@
+package redisstore
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/agkloop/go_memoize/serializers"
+)
+
+func TestNewRequiresClientAndSerializer(t *testing.T) {
+ if _, err := New[string, string](); !errors.Is(err, ErrMissingClient) {
+ t.Fatalf("expected ErrMissingClient, got %v", err)
+ }
+ if _, err := New[string, string](WithClient[string, string](nil)); !errors.Is(err, ErrMissingClient) {
+ t.Fatalf("expected ErrMissingClient for nil client, got %v", err)
+ }
+}
+
+func TestStoreKeyUsesPrefixAndDefaultEncoder(t *testing.T) {
+ store := &Store[uint64, string]{prefix: "profiles:"}
+ if got := store.key(123456789); got != "profiles:123456789" {
+ t.Fatalf("key = %q, want profiles:123456789", got)
+ }
+}
+
+func TestStoreKeyUsesCustomEncoder(t *testing.T) {
+ store := &Store[int, string]{prefix: "users", keyEncoder: func(key int) string { return "id-" + defaultKeyEncoder(key) }}
+ if got := store.key(42); got != "users:id-42" {
+ t.Fatalf("key = %q, want users:id-42", got)
+ }
+}
+
+func TestStorageTTLUsesStaleUntil(t *testing.T) {
+ now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
+ entry := memoize.Stored[string]{FreshUntil: now.Add(time.Minute), StaleUntil: now.Add(3 * time.Minute)}
+ if ttl := storageTTL(entry, now); ttl != 3*time.Minute {
+ t.Fatalf("expected 3m ttl, got %s", ttl)
+ }
+ _ = serializers.JSON[string]{}
+}
diff --git a/background/background.go b/background/background.go
new file mode 100644
index 0000000..4c5f9a8
--- /dev/null
+++ b/background/background.go
@@ -0,0 +1,131 @@
+// v2/background/background.go
+package background
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/agkloop/go_memoize/internal/refreshloop"
+)
+
+var errMirrorRefreshMiss = errors.New("background.Mirror: refresh missed remote key")
+
+// Keep starts a background goroutine that calls fn every interval.
+// Blocks until the first successful call. Returns error if the first call fails.
+// The goroutine stops when ctx is cancelled.
+func Keep[V any](
+ ctx context.Context,
+ fn func(context.Context) (V, error),
+ interval time.Duration,
+ opts ...Option[V],
+) (*Value[V], error) {
+ o := applyOptions(opts)
+
+ v, err := fn(ctx)
+ if err != nil {
+ return nil, err
+ }
+ val := &Value[V]{}
+ val.store(v)
+ o.onRefresh(v)
+ if o.writeThrough != nil {
+ _ = o.writeThrough.store.Set(ctx, o.writeThrough.key, memoize.Stored[V]{Value: v, NoExpire: true})
+ }
+
+ go refreshloop.Run(ctx, interval, fn, refreshloop.Hooks[V]{
+ OnValue: func(refreshed V) {
+ val.store(refreshed)
+ o.onRefresh(refreshed)
+ if o.writeThrough != nil {
+ _ = o.writeThrough.store.Set(ctx, o.writeThrough.key,
+ memoize.Stored[V]{Value: refreshed, NoExpire: true})
+ }
+ },
+ OnError: o.onError,
+ })
+
+ return val, nil
+}
+
+// MustKeep is Keep but panics on initial load error. For use in main().
+func MustKeep[V any](
+ ctx context.Context,
+ fn func(context.Context) (V, error),
+ interval time.Duration,
+ opts ...Option[V],
+) *Value[V] {
+ val, err := Keep(ctx, fn, interval, opts...)
+ if err != nil {
+ panic("background.MustKeep: initial load failed: " + err.Error())
+ }
+ return val
+}
+
+// Mirror starts a background goroutine that reads key from remote every interval,
+// storing the result in a local atomic mirror.
+// Blocks until the first successful read. Returns error if key is missing.
+// The goroutine stops when ctx is cancelled.
+func Mirror[V any](
+ ctx context.Context,
+ key string,
+ remote memoize.Store[string, V],
+ interval time.Duration,
+ opts ...Option[V],
+) (*Value[V], error) {
+ o := applyOptions(opts)
+
+ entry, ok, err := remote.Get(ctx, key)
+ if err != nil {
+ return nil, err
+ }
+ if !ok {
+ return nil, fmt.Errorf("background.Mirror: key %q not found in remote store", key)
+ }
+
+ val := &Value[V]{}
+ val.store(entry.Value)
+ o.onRefresh(entry.Value)
+
+ go refreshloop.Run(ctx, interval, func(ctx context.Context) (V, error) {
+ e, ok, err := remote.Get(ctx, key)
+ if err != nil {
+ var zero V
+ return zero, err
+ }
+ if !ok {
+ var zero V
+ return zero, errMirrorRefreshMiss
+ }
+ return e.Value, nil
+ }, refreshloop.Hooks[V]{
+ OnValue: func(refreshed V) {
+ val.store(refreshed)
+ o.onRefresh(refreshed)
+ },
+ OnError: func(err error) {
+ if !errors.Is(err, errMirrorRefreshMiss) {
+ o.onError(err)
+ }
+ },
+ })
+
+ return val, nil
+}
+
+// MustMirror is Mirror but panics on initial load error. For use in main().
+func MustMirror[V any](
+ ctx context.Context,
+ key string,
+ remote memoize.Store[string, V],
+ interval time.Duration,
+ opts ...Option[V],
+) *Value[V] {
+ val, err := Mirror(ctx, key, remote, interval, opts...)
+ if err != nil {
+ panic("background.MustMirror: initial read failed: " + err.Error())
+ }
+ return val
+}
diff --git a/background/background_test.go b/background/background_test.go
new file mode 100644
index 0000000..0f25646
--- /dev/null
+++ b/background/background_test.go
@@ -0,0 +1,298 @@
+// v2/background/background_test.go
+package background_test
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/agkloop/go_memoize/background"
+)
+
+func TestKeep_InitialLoad(t *testing.T) {
+ calls := 0
+ fn := func(ctx context.Context) (int, error) {
+ calls++
+ return 42, nil
+ }
+ val, err := background.Keep(context.Background(), fn, time.Hour)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got := val.Get(); got != 42 {
+ t.Fatalf("want 42, got %d", got)
+ }
+ if calls != 1 {
+ t.Fatalf("want 1 call, got %d", calls)
+ }
+}
+
+func TestValueGetZeroValueReturnsZero(t *testing.T) {
+ var val background.Value[int]
+ if got := val.Get(); got != 0 {
+ t.Fatalf("want zero value, got %d", got)
+ }
+}
+
+func TestKeep_NonPositiveIntervalDoesNotPanic(t *testing.T) {
+ val, err := background.Keep(context.Background(), func(context.Context) (int, error) {
+ return 42, nil
+ }, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := val.Get(); got != 42 {
+ t.Fatalf("want initial value 42, got %d", got)
+ }
+ time.Sleep(10 * time.Millisecond)
+}
+
+func TestMirror_NonPositiveIntervalDoesNotPanic(t *testing.T) {
+ store := &fakeStore[int]{entry: memoize.Stored[int]{Value: 42, NoExpire: true}, ok: true}
+ val, err := background.Mirror[int](context.Background(), "k", store, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := val.Get(); got != 42 {
+ t.Fatalf("want initial value 42, got %d", got)
+ }
+ time.Sleep(10 * time.Millisecond)
+}
+
+func TestKeep_InitialLoadError(t *testing.T) {
+ fn := func(ctx context.Context) (int, error) {
+ return 0, errors.New("boom")
+ }
+ _, err := background.Keep(context.Background(), fn, time.Hour)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestKeep_Refreshes(t *testing.T) {
+ var counter atomic.Int32
+ fn := func(ctx context.Context) (int32, error) {
+ return counter.Add(1), nil
+ }
+ val, err := background.Keep(context.Background(), fn, 20*time.Millisecond)
+ if err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(70 * time.Millisecond)
+ if got := val.Get(); got < 3 {
+ t.Fatalf("want >= 3 refreshes, got %d", got)
+ }
+}
+
+func TestKeep_KeepsStaleOnError(t *testing.T) {
+ var fail atomic.Bool
+ fn := func(ctx context.Context) (int, error) {
+ if fail.Load() {
+ return 0, errors.New("transient")
+ }
+ return 99, nil
+ }
+ val, err := background.Keep(context.Background(), fn, 20*time.Millisecond)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fail.Store(true)
+ time.Sleep(50 * time.Millisecond)
+ if got := val.Get(); got != 99 {
+ t.Fatalf("want stale 99, got %d", got)
+ }
+}
+
+func TestKeep_OnError_Called(t *testing.T) {
+ var fail atomic.Bool
+ var errCount atomic.Int32
+ fn := func(ctx context.Context) (int, error) {
+ if fail.Load() {
+ return 0, errors.New("transient")
+ }
+ return 1, nil
+ }
+ _, err := background.Keep(context.Background(), fn, 20*time.Millisecond,
+ background.OnError[int](func(e error) { errCount.Add(1) }),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ fail.Store(true)
+ time.Sleep(60 * time.Millisecond)
+ if errCount.Load() == 0 {
+ t.Fatal("OnError was never called")
+ }
+}
+
+func TestKeep_StopsOnCtxCancel(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ var calls atomic.Int32
+ fn := func(ctx context.Context) (int, error) {
+ calls.Add(1)
+ return 1, nil
+ }
+ _, err := background.Keep(ctx, fn, 10*time.Millisecond)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cancel()
+ time.Sleep(50 * time.Millisecond)
+ snapshot := calls.Load()
+ time.Sleep(30 * time.Millisecond)
+ if calls.Load() != snapshot {
+ t.Fatal("goroutine did not stop after ctx cancel")
+ }
+}
+
+// fakeStore is an in-memory Store[V] for tests.
+type fakeStore[V any] struct {
+ mu sync.Mutex
+ entry memoize.Stored[V]
+ ok bool
+ err error
+}
+
+func (f *fakeStore[V]) Get(_ context.Context, _ string) (memoize.Stored[V], bool, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.entry, f.ok, f.err
+}
+func (f *fakeStore[V]) Set(_ context.Context, _ string, v memoize.Stored[V]) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.entry = v
+ f.ok = true
+ return nil
+}
+func (f *fakeStore[V]) Delete(_ context.Context, _ string) error { return nil }
+func (f *fakeStore[V]) Clear(_ context.Context) error { return nil }
+
+func TestMirror_InitialRead(t *testing.T) {
+ store := &fakeStore[string]{
+ entry: memoize.Stored[string]{Value: "hello", NoExpire: true},
+ ok: true,
+ }
+ val, err := background.Mirror[string](context.Background(), "k", store, time.Hour)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := val.Get(); got != "hello" {
+ t.Fatalf("want hello, got %s", got)
+ }
+}
+
+func TestMirror_InitialMissErrors(t *testing.T) {
+ store := &fakeStore[string]{ok: false}
+ _, err := background.Mirror[string](context.Background(), "k", store, time.Hour)
+ if err == nil {
+ t.Fatal("expected error on miss")
+ }
+}
+
+func TestMirror_Refreshes(t *testing.T) {
+ var mu sync.Mutex
+ n := 0
+ store := &fakeStore[int]{entry: memoize.Stored[int]{Value: 1, NoExpire: true}, ok: true}
+
+ val, err := background.Mirror[int](context.Background(), "k", store, 20*time.Millisecond)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ go func() {
+ ticker := time.NewTicker(15 * time.Millisecond)
+ defer ticker.Stop()
+ for range ticker.C {
+ mu.Lock()
+ n++
+ v := n
+ mu.Unlock()
+ _ = store.Set(context.Background(), "k", memoize.Stored[int]{Value: v, NoExpire: true})
+ }
+ }()
+
+ time.Sleep(100 * time.Millisecond)
+ mu.Lock()
+ want := n
+ mu.Unlock()
+ if got := val.Get(); got < 2 {
+ t.Fatalf("want refreshed value >= 2, got %d (store at %d)", got, want)
+ }
+}
+
+func TestMirror_KeepsStaleOnStoreMiss(t *testing.T) {
+ store := &fakeStore[string]{
+ entry: memoize.Stored[string]{Value: "stale", NoExpire: true},
+ ok: true,
+ }
+ val, err := background.Mirror[string](context.Background(), "k", store, 20*time.Millisecond)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // make store return miss
+ store.mu.Lock()
+ store.ok = false
+ store.mu.Unlock()
+
+ time.Sleep(50 * time.Millisecond)
+ if got := val.Get(); got != "stale" {
+ t.Fatalf("want stale, got %s", got)
+ }
+}
+
+func TestMirror_StopsOnCtxCancel(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ store := &fakeStore[int]{entry: memoize.Stored[int]{Value: 1, NoExpire: true}, ok: true}
+ var calls atomic.Int32
+ _, err := background.Mirror[int](ctx, "k", store, 10*time.Millisecond,
+ background.OnRefresh[int](func(int) { calls.Add(1) }),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ cancel()
+ time.Sleep(50 * time.Millisecond)
+ snapshot := calls.Load()
+ time.Sleep(30 * time.Millisecond)
+ if calls.Load() != snapshot {
+ t.Fatal("goroutine did not stop")
+ }
+}
+
+func TestKeep_WriteThrough(t *testing.T) {
+ store := &fakeStore[int]{}
+ calls := 0
+ fn := func(ctx context.Context) (int, error) {
+ calls++
+ return calls * 10, nil
+ }
+
+ _, err := background.Keep(context.Background(), fn, 20*time.Millisecond,
+ background.WriteThrough[int]("mykey", store),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // initial write happened synchronously
+ store.mu.Lock()
+ got := store.entry.Value
+ store.mu.Unlock()
+ if got != 10 {
+ t.Fatalf("want 10 after initial write, got %d", got)
+ }
+
+ // wait for a refresh cycle
+ time.Sleep(40 * time.Millisecond)
+ store.mu.Lock()
+ got = store.entry.Value
+ store.mu.Unlock()
+ if got < 20 {
+ t.Fatalf("want >= 20 after refresh, got %d", got)
+ }
+}
diff --git a/background/options.go b/background/options.go
new file mode 100644
index 0000000..2722d3f
--- /dev/null
+++ b/background/options.go
@@ -0,0 +1,50 @@
+// v2/background/options.go
+package background
+
+import (
+ memoize "github.com/agkloop/go_memoize"
+)
+
+type writeThroughCfg[V any] struct {
+ key string
+ store memoize.Store[string, V]
+}
+
+type options[V any] struct {
+ onError func(error)
+ onRefresh func(V)
+ writeThrough *writeThroughCfg[V]
+}
+
+// Option configures Keep or Mirror.
+type Option[V any] func(*options[V])
+
+// WriteThrough writes each successfully refreshed value to store under key.
+// Used by writer processes to publish to a shared store (e.g. Redis).
+func WriteThrough[V any](key string, store memoize.Store[string, V]) Option[V] {
+ return func(o *options[V]) {
+ o.writeThrough = &writeThroughCfg[V]{key: key, store: store}
+ }
+}
+
+// OnError is called when a refresh fails after the initial load.
+// The stale value is kept automatically.
+func OnError[V any](fn func(error)) Option[V] {
+ return func(o *options[V]) { o.onError = fn }
+}
+
+// OnRefresh is called after each successful refresh with the new value.
+func OnRefresh[V any](fn func(V)) Option[V] {
+ return func(o *options[V]) { o.onRefresh = fn }
+}
+
+func applyOptions[V any](opts []Option[V]) options[V] {
+ o := options[V]{
+ onError: func(error) {},
+ onRefresh: func(V) {},
+ }
+ for _, opt := range opts {
+ opt(&o)
+ }
+ return o
+}
diff --git a/background/value.go b/background/value.go
new file mode 100644
index 0000000..e50d97f
--- /dev/null
+++ b/background/value.go
@@ -0,0 +1,29 @@
+// v2/background/value.go
+package background
+
+import "sync/atomic"
+
+// Value holds a locally-mirrored copy of V refreshed in the background.
+// Get is a single atomic pointer load — sub-nanosecond, never errors.
+// Callers must not mutate the returned value; it is shared memory.
+type Value[V any] struct {
+ v atomic.Pointer[V]
+}
+
+// Get returns the current value. Always local — never blocks, never errors.
+func (val *Value[V]) Get() V {
+ if val == nil {
+ var zero V
+ return zero
+ }
+ ptr := val.v.Load()
+ if ptr == nil {
+ var zero V
+ return zero
+ }
+ return *ptr
+}
+
+func (val *Value[V]) store(v V) {
+ val.v.Store(&v)
+}
diff --git a/benchmarks/benchmark_test.go b/benchmarks/benchmark_test.go
index 8375fd4..9d947eb 100644
--- a/benchmarks/benchmark_test.go
+++ b/benchmarks/benchmark_test.go
@@ -6,6 +6,15 @@ import (
"time"
)
+const directBenchLRUCapacity = 1024
+
+func mustMemoized[F any](fn F, err error) F {
+ if err != nil {
+ panic(err)
+ }
+ return fn
+}
+
func DoSomThingZero() string { return "a" }
func DoSomThing1(a string) string { return a }
func DoSomThing2(a, b string) string { return a + b }
@@ -25,25 +34,69 @@ func DoSomThing4(a string, b string, c string, s int) string {
}
func BenchmarkDo0Mem(b *testing.B) {
- DoSomThingZeroMemoized := M.Memoize(DoSomThingZero, 10*time.Minute)
+ DoSomThingZeroMemoized := mustMemoized(M.Memoize(DoSomThingZero, M.Opts().WithTTL(10*time.Minute)))
+ b.ReportAllocs()
+
+ for b.Loop() {
+ DoSomThingZeroMemoized()
+ }
+}
+
+func BenchmarkDo0LRU(b *testing.B) {
+ DoSomThingZeroMemoized := lruMemoize(DoSomThingZero, 10*time.Minute, 1)
+ b.ReportAllocs()
- for i := 0; i < b.N; i++ {
+ for b.Loop() {
DoSomThingZeroMemoized()
}
}
func BenchmarkDo1Mem(b *testing.B) {
- DoSomThing1Memoized := M.Memoize1(DoSomThing1, 10*time.Minute)
+ DoSomThing1Memoized := mustMemoized(M.Memoize1(DoSomThing1, M.Opts().WithTTL(10*time.Minute)))
+ params := []string{"1111", "2222", "3333", "4444"}
+ idx := 0
+ b.ReportAllocs()
+ for b.Loop() {
+ DoSomThing1Memoized(params[idx%len(params)])
+ idx++
+ }
+}
+
+func BenchmarkDo1LRU(b *testing.B) {
+ DoSomThing1Memoized := lruMemoize1(DoSomThing1, 10*time.Minute, directBenchLRUCapacity)
params := []string{"1111", "2222", "3333", "4444"}
- for i := 0; i < b.N; i++ {
- DoSomThing1Memoized(params[i%4])
+ idx := 0
+ b.ReportAllocs()
+
+ for b.Loop() {
+ DoSomThing1Memoized(params[idx%len(params)])
+ idx++
}
}
func BenchmarkDo2Mem(b *testing.B) {
- DoSomThing2Memoized := M.Memoize2(DoSomThing2, 10*time.Minute)
+ DoSomThing2Memoized := mustMemoized(M.Memoize2(DoSomThing2, M.Opts().WithTTL(10*time.Minute)))
+ params := []struct {
+ a string
+ b string
+ }{
+ {"1-", "1111"},
+ {"2-", "2222"},
+ {"3-", "3333"},
+ {"4-", "4444"},
+ }
+ idx := 0
+ b.ReportAllocs()
+
+ for b.Loop() {
+ DoSomThing2Memoized(params[idx%len(params)].a, params[idx%len(params)].b)
+ idx++
+ }
+}
+func BenchmarkDo2LRU(b *testing.B) {
+ DoSomThing2Memoized := lruMemoize2(DoSomThing2, 10*time.Minute, directBenchLRUCapacity)
params := []struct {
a string
b string
@@ -53,14 +106,36 @@ func BenchmarkDo2Mem(b *testing.B) {
{"3-", "3333"},
{"4-", "4444"},
}
- for i := 0; i < b.N; i++ {
- DoSomThing2Memoized(params[i%4].a, params[i%4].b)
+ idx := 0
+ b.ReportAllocs()
+
+ for b.Loop() {
+ DoSomThing2Memoized(params[idx%len(params)].a, params[idx%len(params)].b)
+ idx++
}
}
func BenchmarkDo3Mem(b *testing.B) {
- DoSomThing3Memoized := M.Memoize3(DoSomThing3, 10*time.Minute)
+ DoSomThing3Memoized := mustMemoized(M.Memoize3(DoSomThing3, M.Opts().WithTTL(10*time.Minute)))
+ params := []struct {
+ a, b, c string
+ }{
+ {"1111", "2222", "3333"},
+ {"4444", "5555", "6666"},
+ {"7777", "8888", "9999"},
+ {"aaaa", "bbbb", "cccc"},
+ }
+ idx := 0
+ b.ReportAllocs()
+
+ for b.Loop() {
+ DoSomThing3Memoized(params[idx%len(params)].a, params[idx%len(params)].b, params[idx%len(params)].c)
+ idx++
+ }
+}
+func BenchmarkDo3LRU(b *testing.B) {
+ DoSomThing3Memoized := lruMemoize3(DoSomThing3, 10*time.Minute, directBenchLRUCapacity)
params := []struct {
a, b, c string
}{
@@ -69,13 +144,37 @@ func BenchmarkDo3Mem(b *testing.B) {
{"7777", "8888", "9999"},
{"aaaa", "bbbb", "cccc"},
}
- for i := 0; i < b.N; i++ {
- DoSomThing3Memoized(params[i%4].a, params[i%4].b, params[i%4].c)
+ idx := 0
+ b.ReportAllocs()
+
+ for b.Loop() {
+ DoSomThing3Memoized(params[idx%len(params)].a, params[idx%len(params)].b, params[idx%len(params)].c)
+ idx++
}
}
func BenchmarkDo4Mem(b *testing.B) {
- DoSomThing4Memoized := M.Memoize4(DoSomThing4, 10*time.Minute)
+ DoSomThing4Memoized := mustMemoized(M.Memoize4(DoSomThing4, M.Opts().WithTTL(10*time.Minute)))
+ params := []struct {
+ a, b, c string
+ s int
+ }{
+ {"1111", "2222", "3333", 1},
+ {"4444", "5555", "6666", 2},
+ {"7777", "8888", "9999", 3},
+ {"aaaa", "bbbb", "cccc", 4},
+ }
+ idx := 0
+ b.ReportAllocs()
+
+ for b.Loop() {
+ DoSomThing4Memoized(params[idx%len(params)].a, params[idx%len(params)].b, params[idx%len(params)].c, params[idx%len(params)].s)
+ idx++
+ }
+}
+
+func BenchmarkDo4LRU(b *testing.B) {
+ DoSomThing4Memoized := lruMemoize4(DoSomThing4, 10*time.Minute, directBenchLRUCapacity)
params := []struct {
a, b, c string
s int
@@ -85,7 +184,11 @@ func BenchmarkDo4Mem(b *testing.B) {
{"7777", "8888", "9999", 3},
{"aaaa", "bbbb", "cccc", 4},
}
- for i := 0; i < b.N; i++ {
- DoSomThing4Memoized(params[i%4].a, params[i%4].b, params[i%4].c, params[i%4].s)
+ idx := 0
+ b.ReportAllocs()
+
+ for b.Loop() {
+ DoSomThing4Memoized(params[idx%len(params)].a, params[idx%len(params)].b, params[idx%len(params)].c, params[idx%len(params)].s)
+ idx++
}
}
diff --git a/benchmarks/cache_benchmark_test.go b/benchmarks/cache_benchmark_test.go
new file mode 100644
index 0000000..de14b67
--- /dev/null
+++ b/benchmarks/cache_benchmark_test.go
@@ -0,0 +1,247 @@
+package benchmarks
+
+import (
+ "context"
+ "fmt"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/agkloop/go_memoize/stores/memory"
+)
+
+func BenchmarkMemoryHotHit(b *testing.B) {
+ ctx := context.Background()
+ cache, err := memoize.New[string, string](memoize.Opts().WithStore(memory.New[string, string](1024)).WithTTL(time.Minute))
+ if err != nil {
+ b.Fatal(err)
+ }
+ if err := cache.Set(ctx, "key", "value"); err != nil {
+ b.Fatal(err)
+ }
+ b.ReportAllocs()
+ for b.Loop() {
+ _, _, _ = cache.Get(ctx, "key")
+ }
+}
+
+func BenchmarkMemoryColdMiss(b *testing.B) {
+ ctx := context.Background()
+ cache, err := memoize.New[string, int](memoize.Opts().WithStore(memory.New[string, int](1024)).WithTTL(time.Minute))
+ if err != nil {
+ b.Fatal(err)
+ }
+ idx := 0
+ b.ReportAllocs()
+ for b.Loop() {
+ key := string(rune('a'+(idx%26))) + string(rune('a'+((idx/26)%26)))
+ _, _ = cache.GetOrCompute(ctx, key, func(context.Context) (int, error) { return idx, nil })
+ idx++
+ }
+}
+
+func BenchmarkLRUHotHit(b *testing.B) {
+ ctx := context.Background()
+ store := memory.New[string, string](1000)
+ c, _ := memoize.New[string, string](
+ memoize.Opts().WithStore(store).WithTTL(time.Hour),
+ )
+ defer c.Stop()
+ compute := func(context.Context) (string, error) { return "v", nil }
+ _, _ = c.GetOrCompute(ctx, "hot", compute)
+ b.ResetTimer()
+ b.ReportAllocs()
+ for b.Loop() {
+ _, _ = c.GetOrCompute(ctx, "hot", compute)
+ }
+}
+
+func BenchmarkSingleHotHit(b *testing.B) {
+ ctx := context.Background()
+ store := memory.NewSingle[string, string]()
+ c, _ := memoize.New[string, string](
+ memoize.Opts().WithStore(store).WithTTL(time.Hour),
+ )
+ defer c.Stop()
+ compute := func(context.Context) (string, error) { return "v", nil }
+ _, _ = c.GetOrCompute(ctx, "hot", compute)
+ b.ResetTimer()
+ b.ReportAllocs()
+ for b.Loop() {
+ _, _ = c.GetOrCompute(ctx, "hot", compute)
+ }
+}
+
+func BenchmarkShardedHotHit(b *testing.B) {
+ ctx := context.Background()
+ store := memory.NewSharded[string, string](100, memory.WithShards[string, string](16))
+ c, _ := memoize.New[string, string](
+ memoize.Opts().WithStore(store).WithTTL(time.Hour),
+ )
+ defer c.Stop()
+ compute := func(context.Context) (string, error) { return "v", nil }
+ _, _ = c.GetOrCompute(ctx, "hot", compute)
+ b.ResetTimer()
+ b.ReportAllocs()
+ for b.Loop() {
+ _, _ = c.GetOrCompute(ctx, "hot", compute)
+ }
+}
+
+func BenchmarkParallelHotHit(b *testing.B) {
+ ctx := context.Background()
+ store := memory.New[string, string](1000)
+ c, _ := memoize.New[string, string](
+ memoize.Opts().WithStore(store).WithTTL(time.Hour),
+ )
+ defer c.Stop()
+ _ = c.Set(ctx, "hot", "value")
+ b.ResetTimer()
+ b.ReportAllocs()
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ _, _, _ = c.Get(ctx, "hot")
+ }
+ })
+}
+
+func BenchmarkParallelSingleHotHit(b *testing.B) {
+ ctx := context.Background()
+ store := memory.NewSingle[string, string]()
+ c, _ := memoize.New[string, string](
+ memoize.Opts().WithStore(store).WithTTL(time.Hour),
+ )
+ defer c.Stop()
+ _ = c.Set(ctx, "hot", "value")
+ b.ResetTimer()
+ b.ReportAllocs()
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ _, _, _ = c.Get(ctx, "hot")
+ }
+ })
+}
+
+func BenchmarkParallelShardedHotHit(b *testing.B) {
+ ctx := context.Background()
+ store := memory.NewSharded[string, string](100, memory.WithShards[string, string](32))
+ c, _ := memoize.New[string, string](
+ memoize.Opts().WithStore(store).WithTTL(time.Hour),
+ )
+ defer c.Stop()
+ _ = c.Set(ctx, "hot", "value")
+ b.ResetTimer()
+ b.ReportAllocs()
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ _, _, _ = c.Get(ctx, "hot")
+ }
+ })
+}
+
+func BenchmarkMixedWorkload(b *testing.B) {
+ ctx := context.Background()
+ const keyspace = 10_000
+ store := memory.New[string, int](keyspace)
+ c, _ := memoize.New[string, int](
+ memoize.Opts().WithStore(store).WithTTL(time.Hour),
+ )
+ defer c.Stop()
+ keys := make([]string, keyspace)
+ for i := range keyspace {
+ keys[i] = fmt.Sprintf("key-%d", i)
+ _ = c.Set(ctx, keys[i], i)
+ }
+ var workerStart atomic.Int64
+ b.ResetTimer()
+ b.ReportAllocs()
+ b.RunParallel(func(pb *testing.PB) {
+ n := int(workerStart.Add(1))
+ for pb.Next() {
+ key := keys[n%keyspace]
+ if n%5 == 0 { // 20% writes
+ _ = c.Set(ctx, key, n)
+ } else { // 80% reads
+ _, _, _ = c.Get(ctx, key)
+ }
+ n++
+ }
+ })
+}
+
+func BenchmarkEvictionPressure(b *testing.B) {
+ ctx := context.Background()
+ const keyspace = 10_000
+ keys := make([]string, keyspace)
+ for i := range keyspace {
+ keys[i] = fmt.Sprintf("k%d", i)
+ }
+ // Tiny store forces eviction on almost every Set
+ store := memory.New[string, string](100)
+ c, _ := memoize.New[string, string](
+ memoize.Opts().WithStore(store).WithTTL(time.Hour),
+ )
+ defer c.Stop()
+ var idx atomic.Int64
+ b.ResetTimer()
+ b.ReportAllocs()
+ b.RunParallel(func(pb *testing.PB) {
+ const batchSize = 256
+ n := idx.Add(batchSize) - batchSize
+ end := n + batchSize
+ for pb.Next() {
+ if n == end {
+ n = idx.Add(batchSize) - batchSize
+ end = n + batchSize
+ }
+ key := keys[int(n%keyspace)]
+ _ = c.Set(ctx, key, key)
+ n++
+ }
+ })
+}
+
+func BenchmarkGetOrComputeStampede(b *testing.B) {
+ ctx := context.Background()
+ store := memory.New[string, string](1)
+ c, _ := memoize.New[string, string](
+ memoize.Opts().WithStore(store).WithTTL(time.Nanosecond).WithClock(memoize.ClockFunc(time.Now)),
+ )
+ defer c.Stop()
+ compute := func(context.Context) (string, error) {
+ time.Sleep(10 * time.Microsecond)
+ return "value", nil
+ }
+ b.ResetTimer()
+ b.ReportAllocs()
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ _, _ = c.GetOrCompute(ctx, "shared", compute)
+ }
+ })
+}
+
+func BenchmarkGetOrComputeStaleStampede(b *testing.B) {
+ ctx := context.Background()
+ store := memory.New[string, string](1)
+ c, _ := memoize.New[string, string](
+ memoize.Opts().WithStore(store).WithTTL(time.Nanosecond).WithStaleTTL(time.Hour).WithClock(memoize.ClockFunc(time.Now)),
+ )
+ defer c.Stop()
+ if err := c.Set(ctx, "shared", "stale"); err != nil {
+ b.Fatal(err)
+ }
+ time.Sleep(time.Microsecond)
+ compute := func(context.Context) (string, error) {
+ time.Sleep(10 * time.Microsecond)
+ return "fresh", nil
+ }
+ b.ResetTimer()
+ b.ReportAllocs()
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ _, _ = c.GetOrCompute(ctx, "shared", compute)
+ }
+ })
+}
diff --git a/benchmarks/lru_memoize_helpers_test.go b/benchmarks/lru_memoize_helpers_test.go
new file mode 100644
index 0000000..6a3a363
--- /dev/null
+++ b/benchmarks/lru_memoize_helpers_test.go
@@ -0,0 +1,137 @@
+package benchmarks
+
+import (
+ "context"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ internalhash "github.com/agkloop/go_memoize/internal/hash"
+ "github.com/agkloop/go_memoize/stores/memory"
+)
+
+var benchLRUClock = memoize.ClockFunc(func() time.Time { return time.Unix(1, 0) })
+
+func newLRUCache[V any](ttl time.Duration, capacity int) *memoize.Cache[uint64, V] {
+ cache, err := memoize.New[uint64, V](
+ memoize.Opts().WithStore(memory.New[uint64, V](capacity)).WithTTL(ttl).WithClock(benchLRUClock),
+ )
+ if err != nil {
+ panic(err)
+ }
+ return cache
+}
+
+func lruMemoize[V any](computeFn func() V, ttl time.Duration, capacity int) func() V {
+ ctx := context.Background()
+ cache := newLRUCache[V](ttl, capacity)
+ return func() V {
+ if value, ok, err := cache.Get(ctx, 0); err != nil {
+ panic(err)
+ } else if ok {
+ return value
+ }
+ value, err := cache.GetOrCompute(ctx, 0, func(context.Context) (V, error) {
+ return computeFn(), nil
+ })
+ if err != nil {
+ panic(err)
+ }
+ return value
+ }
+}
+
+func lruMemoize1[K comparable, V any](computeFn func(K) V, ttl time.Duration, capacity int) func(K) V {
+ ctx := context.Background()
+ cache := newLRUCache[V](ttl, capacity)
+ return func(key K) V {
+ cacheKey := benchHash1(key)
+ if value, ok, err := cache.Get(ctx, cacheKey); err != nil {
+ panic(err)
+ } else if ok {
+ return value
+ }
+ value, err := cache.GetOrCompute(ctx, cacheKey, func(context.Context) (V, error) {
+ return computeFn(key), nil
+ })
+ if err != nil {
+ panic(err)
+ }
+ return value
+ }
+}
+
+func lruMemoize2[K1, K2 comparable, V any](computeFn func(K1, K2) V, ttl time.Duration, capacity int) func(K1, K2) V {
+ ctx := context.Background()
+ cache := newLRUCache[V](ttl, capacity)
+ return func(key1 K1, key2 K2) V {
+ cacheKey := benchHash2(key1, key2)
+ if value, ok, err := cache.Get(ctx, cacheKey); err != nil {
+ panic(err)
+ } else if ok {
+ return value
+ }
+ value, err := cache.GetOrCompute(ctx, cacheKey, func(context.Context) (V, error) {
+ return computeFn(key1, key2), nil
+ })
+ if err != nil {
+ panic(err)
+ }
+ return value
+ }
+}
+
+func lruMemoize3[K1, K2, K3 comparable, V any](computeFn func(K1, K2, K3) V, ttl time.Duration, capacity int) func(K1, K2, K3) V {
+ ctx := context.Background()
+ cache := newLRUCache[V](ttl, capacity)
+ return func(key1 K1, key2 K2, key3 K3) V {
+ cacheKey := benchHash3(key1, key2, key3)
+ if value, ok, err := cache.Get(ctx, cacheKey); err != nil {
+ panic(err)
+ } else if ok {
+ return value
+ }
+ value, err := cache.GetOrCompute(ctx, cacheKey, func(context.Context) (V, error) {
+ return computeFn(key1, key2, key3), nil
+ })
+ if err != nil {
+ panic(err)
+ }
+ return value
+ }
+}
+
+func lruMemoize4[K1, K2, K3, K4 comparable, V any](computeFn func(K1, K2, K3, K4) V, ttl time.Duration, capacity int) func(K1, K2, K3, K4) V {
+ ctx := context.Background()
+ cache := newLRUCache[V](ttl, capacity)
+ return func(key1 K1, key2 K2, key3 K3, key4 K4) V {
+ cacheKey := benchHash4(key1, key2, key3, key4)
+ if value, ok, err := cache.Get(ctx, cacheKey); err != nil {
+ panic(err)
+ } else if ok {
+ return value
+ }
+ value, err := cache.GetOrCompute(ctx, cacheKey, func(context.Context) (V, error) {
+ return computeFn(key1, key2, key3, key4), nil
+ })
+ if err != nil {
+ panic(err)
+ }
+ return value
+ }
+}
+
+func benchHash1[A comparable](key A) uint64 {
+ return internalhash.Comparable(internalhash.Offset64, key)
+}
+
+func benchHash2[A, B comparable](key1 A, key2 B) uint64 {
+ return internalhash.Comparable(internalhash.Comparable(internalhash.Offset64, key1), key2)
+}
+
+func benchHash3[A, B, C comparable](key1 A, key2 B, key3 C) uint64 {
+ return internalhash.Comparable(internalhash.Comparable(internalhash.Comparable(internalhash.Offset64, key1), key2), key3)
+}
+
+func benchHash4[A, B, C, D comparable](key1 A, key2 B, key3 C, key4 D) uint64 {
+ return internalhash.Comparable(internalhash.Comparable(internalhash.Comparable(internalhash.Comparable(internalhash.Offset64, key1), key2), key3), key4)
+}
diff --git a/benchmarks/lru_memoize_test.go b/benchmarks/lru_memoize_test.go
new file mode 100644
index 0000000..5b22d80
--- /dev/null
+++ b/benchmarks/lru_memoize_test.go
@@ -0,0 +1,46 @@
+package benchmarks
+
+import (
+ "testing"
+ "time"
+)
+
+func TestLRUMemoizeCachesZeroArgValue(t *testing.T) {
+ calls := 0
+ memoized := lruMemoize(func() int {
+ calls++
+ return calls
+ }, time.Minute, 1)
+
+ if got := memoized(); got != 1 {
+ t.Fatalf("first call = %d, want 1", got)
+ }
+ if got := memoized(); got != 1 {
+ t.Fatalf("second call = %d, want cached 1", got)
+ }
+ if calls != 1 {
+ t.Fatalf("calls = %d, want 1", calls)
+ }
+}
+
+func TestLRUMemoize1CachesPerKey(t *testing.T) {
+ calls := 0
+ memoized := lruMemoize1(func(key string) int {
+ calls++
+ return len(key) + calls
+ }, time.Minute, 2)
+
+ firstA := memoized("a")
+ firstB := memoized("bb")
+ secondA := memoized("a")
+
+ if firstA != secondA {
+ t.Fatalf("second call for same key = %d, want cached %d", secondA, firstA)
+ }
+ if firstB == firstA {
+ t.Fatalf("different keys produced same value %d", firstB)
+ }
+ if calls != 2 {
+ t.Fatalf("calls = %d, want 2", calls)
+ }
+}
diff --git a/benchmarks/profiling_test.go b/benchmarks/profiling_test.go
new file mode 100644
index 0000000..bd02bff
--- /dev/null
+++ b/benchmarks/profiling_test.go
@@ -0,0 +1,43 @@
+package benchmarks
+
+import (
+ "os"
+ "runtime"
+ "runtime/pprof"
+ "testing"
+)
+
+// TestMain enables opt-in CPU and memory profiling when BENCH_PROFILE=1.
+// Usage: BENCH_PROFILE=1 go test ./benchmarks/ -bench=. -benchtime=10s
+func TestMain(m *testing.M) {
+ if os.Getenv("BENCH_PROFILE") != "1" {
+ os.Exit(m.Run())
+ }
+
+ // CPU profile
+ cpuF, err := os.Create("cpu.prof")
+ if err != nil {
+ panic(err)
+ }
+ if err := pprof.StartCPUProfile(cpuF); err != nil {
+ panic(err)
+ }
+
+ code := m.Run()
+
+ pprof.StopCPUProfile()
+ _ = cpuF.Close()
+
+ // Heap profile
+ memF, err := os.Create("mem.prof")
+ if err != nil {
+ panic(err)
+ }
+ runtime.GC()
+ if err := pprof.WriteHeapProfile(memF); err != nil {
+ panic(err)
+ }
+ _ = memF.Close()
+
+ os.Exit(code)
+}
diff --git a/cache.go b/cache.go
index 41d600b..8aa955a 100644
--- a/cache.go
+++ b/cache.go
@@ -1,137 +1,313 @@
-package go_memoize
+package memoize
import (
+ "context"
+ "fmt"
"sync"
- "sync/atomic"
"time"
)
-// zeroValue returns the zero value for any type T.
-func zeroValue[T any]() T {
- var zero T
- return zero
+type flight[V any] struct {
+ wg sync.WaitGroup
+ value V
+ err error
}
-// cacheGroup manages multiple caches with a shared ticker.
-type cacheGroup struct {
- now atomic.Value
- ticker *time.Ticker
- tickInterval time.Duration
- done chan struct{}
+// peekingStore lets GetOrCompute inspect stored entry state without applying
+// Store.Get side effects such as recency updates before it chooses a policy.
+type peekingStore[K comparable, V any] interface {
+ Peek(ctx context.Context, key K) (Stored[V], bool, error)
}
-// newCacheGroup creates a new cache group with a shared ticker.
-func newCacheGroup() *cacheGroup {
- group := &cacheGroup{
- done: make(chan struct{}),
- tickInterval: time.Millisecond,
+// freshValueStore is the first cache policy check: stores that can prove a
+// value is fresh return it directly before entry loading or flight handling.
+type freshValueStore[K comparable, V any] interface {
+ PeekFreshValue(ctx context.Context, key K, now time.Time) (V, bool, error)
+}
+
+func metricKey[K comparable](key K) string {
+ if s, ok := any(key).(string); ok {
+ return s
}
- group.now.Store(time.Now().Unix())
- group.startTicker()
- return group
+ return fmt.Sprint(key)
}
-// startTicker starts the ticker for the cache group.
-func (g *cacheGroup) startTicker() {
- g.ticker = time.NewTicker(g.tickInterval) // Initialize the ticker
- go func() {
- for {
- select {
- case <-g.ticker.C:
- g.now.Store(time.Now().Unix())
- case <-g.done:
- g.ticker.Stop()
- return
- }
- }
- }()
+func (c *Cache[K, V]) emitHit(key K) {
+ if !c.metricsEnabled {
+ return
+ }
+ c.metrics.RecordMetric(MetricEvent{Kind: MetricHit, Key: metricKey(key)})
+}
+
+func (c *Cache[K, V]) emitMiss(key K) {
+ if !c.metricsEnabled {
+ return
+ }
+ c.metrics.RecordMetric(MetricEvent{Kind: MetricMiss, Key: metricKey(key)})
+}
+
+func (c *Cache[K, V]) emitStaleHit(key K) {
+ if !c.metricsEnabled {
+ return
+ }
+ c.metrics.RecordMetric(MetricEvent{Kind: MetricStaleHit, Key: metricKey(key)})
+}
+
+func (c *Cache[K, V]) emitRefreshStart(key K) {
+ if !c.metricsEnabled {
+ return
+ }
+ c.metrics.RecordMetric(MetricEvent{Kind: MetricRefreshStart, Key: metricKey(key)})
+}
+
+func (c *Cache[K, V]) emitRefreshSuccess(key K, duration time.Duration) {
+ if !c.metricsEnabled {
+ return
+ }
+ c.metrics.RecordMetric(MetricEvent{Kind: MetricRefreshSuccess, Key: metricKey(key), Duration: duration})
+}
+
+func (c *Cache[K, V]) emitRefreshError(key K, err error) {
+ if !c.metricsEnabled {
+ return
+ }
+ c.metrics.RecordMetric(MetricEvent{Kind: MetricRefreshError, Key: metricKey(key), Err: err})
}
-// var cacheGroupInstance is a singleton instance of cacheGroup.
-var cacheGroupInstance = newCacheGroup()
+func (c *Cache[K, V]) emitSet(key K) {
+ if !c.metricsEnabled {
+ return
+ }
+ c.metrics.RecordMetric(MetricEvent{Kind: MetricSet, Key: metricKey(key)})
+}
-// entry represents a cache entry with a value and a timestamp.
-type entry[V any] struct {
- value V
- timeStamp int64
+func (c *Cache[K, V]) emitDelete(key K) {
+ if !c.metricsEnabled {
+ return
+ }
+ c.metrics.RecordMetric(MetricEvent{Kind: MetricDelete, Key: metricKey(key)})
}
-// Cache is a generic cache with a time-to-live (TTL) for each entry.
-type Cache[K comparable, V any] struct {
- entries map[K]entry[V]
- ttl int64
- cacheGroup *cacheGroup
- mu sync.RWMutex
- zeroVal V
+func (c *Cache[K, V]) getEntry(ctx context.Context, key K) (Stored[V], bool, error) {
+ if c.peeker != nil {
+ return c.peeker.Peek(ctx, key)
+ }
+ return c.store.Get(ctx, key)
}
-// NewCache creates a new cache with the specified TTL.
-func NewCache[K comparable, V any](ttl int64) *Cache[K, V] {
- return &Cache[K, V]{
- entries: make(map[K]entry[V]),
- cacheGroup: cacheGroupInstance,
- ttl: ttl,
- zeroVal: zeroValue[V](),
+func (c *Cache[K, V]) getFreshValue(ctx context.Context, key K, now time.Time) (V, bool, bool, error) {
+ if store, ok := c.store.(freshValueStore[K, V]); ok {
+ value, fresh, err := store.PeekFreshValue(ctx, key, now)
+ return value, fresh, true, err
}
+ var zero V
+ return zero, false, false, nil
}
-// NewCacheSized creates a new cache with the specified size and TTL.
-func NewCacheSized[K comparable, V any](size int, ttl int64) *Cache[K, V] {
- return &Cache[K, V]{
- entries: make(map[K]entry[V], size),
- cacheGroup: cacheGroupInstance,
- ttl: ttl,
- zeroVal: zeroValue[V](),
+func (c *Cache[K, V]) waitForFlight(key K) (V, bool, error) {
+ c.flightMu.Lock()
+ existing := c.flights[key]
+ c.flightMu.Unlock()
+ if existing == nil {
+ var zero V
+ return zero, false, nil
}
+ existing.wg.Wait()
+ return existing.value, true, existing.err
}
-// NowUnix returns the current Unix timestamp from the cache group.
-func (c *Cache[K, V]) NowUnix() int64 {
- return c.cacheGroup.now.Load().(int64)
+func (c *Cache[K, V]) startFlight(key K) (*flight[V], bool) {
+ c.flightMu.Lock()
+ if existing := c.flights[key]; existing != nil {
+ c.flightMu.Unlock()
+ return existing, false
+ }
+ f := &flight[V]{}
+ f.wg.Add(1)
+ c.flights[key] = f
+ c.flightMu.Unlock()
+ return f, true
}
-// GetOrCompute retrieves the value for the given key or computes it using the provided function if not present or expired.
-func (c *Cache[K, V]) GetOrCompute(key K, computeFn func() V) V {
- c.mu.RLock()
- existingEntry, ok := c.entries[key]
- c.mu.RUnlock()
+func (c *Cache[K, V]) finishFlight(key K, f *flight[V], value V, err error) {
+ f.value = value
+ f.err = err
+ f.wg.Done()
+
+ c.flightMu.Lock()
+ delete(c.flights, key)
+ c.flightMu.Unlock()
+}
- now := c.NowUnix()
- if ok && (c.ttl == 0 || now-existingEntry.timeStamp < c.ttl) {
- return existingEntry.value
+func (c *Cache[K, V]) do(key K, fn func() (V, error)) (V, error) {
+ f, leader := c.startFlight(key)
+ if !leader {
+ f.wg.Wait()
+ return f.value, f.err
}
- c.mu.Lock()
- newVal := computeFn()
- c.entries[key] = entry[V]{value: newVal, timeStamp: now}
- c.mu.Unlock()
- return newVal
+ value, err := fn()
+ c.finishFlight(key, f, value, err)
+ return value, err
+}
+
+func (c *Cache[K, V]) refresh(key K, compute func(context.Context) (V, error)) {
+ f, leader := c.startFlight(key)
+ if !leader {
+ return
+ }
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), c.refreshTimeout)
+ defer cancel()
+ started := c.clock.Now()
+ c.emitRefreshStart(key)
+ value, err := compute(ctx)
+ if err != nil {
+ c.emitRefreshError(key, err)
+ c.finishFlight(key, f, value, err)
+ return
+ }
+ if err := c.Set(ctx, key, value); err != nil {
+ c.emitRefreshError(key, err)
+ c.finishFlight(key, f, value, err)
+ return
+ }
+ c.emitRefreshSuccess(key, c.clock.Now().Sub(started))
+ c.finishFlight(key, f, value, nil)
+ }()
}
-// Delete removes the entry for the given key from the cache.
-func (c *Cache[K, V]) Delete(key K) {
- c.mu.Lock()
- delete(c.entries, key)
- c.mu.Unlock()
+func (c *Cache[K, V]) Get(ctx context.Context, key K) (V, bool, error) {
+ var zero V
+ if c.bypass {
+ return zero, false, nil
+ }
+ if c.store == nil {
+ return zero, false, ErrMissingStore
+ }
+ now := c.clock.Now()
+ if value, ok, _, err := c.getFreshValue(ctx, key, now); err != nil || ok {
+ if ok {
+ c.emitHit(key)
+ }
+ return value, ok, err
+ }
+ entry, ok, err := c.getEntry(ctx, key)
+ if err != nil || !ok {
+ return zero, false, err
+ }
+ if entry.state(now) != entryFresh {
+ return zero, false, nil
+ }
+ c.emitHit(key)
+ return entry.Value, true, nil
}
-// Set adds or updates the value for the given key in the cache.
-func (c *Cache[K, V]) Set(key K, value V) {
- timeStamp := c.NowUnix()
- c.mu.Lock()
- c.entries[key] = entry[V]{value: value, timeStamp: timeStamp}
- c.mu.Unlock()
+func (c *Cache[K, V]) Set(ctx context.Context, key K, value V) error {
+ if c.bypass {
+ return nil
+ }
+ if c.store == nil {
+ return ErrMissingStore
+ }
+ now := c.clock.Now()
+ entry := Stored[V]{Value: value, CreatedAt: now, NoExpire: c.noExpiration}
+ if !c.noExpiration {
+ entry.FreshUntil = now.Add(c.ttl)
+ if c.staleTTL > 0 {
+ entry.StaleUntil = entry.FreshUntil.Add(c.staleTTL)
+ }
+ }
+ if err := c.store.Set(ctx, key, entry); err != nil {
+ return err
+ }
+ c.emitSet(key)
+ return nil
}
-// Get retrieves the value for the given key from the cache if present and not expired.
-func (c *Cache[K, V]) Get(key K) (V, bool) {
- c.mu.RLock()
- entry, ok := c.entries[key]
- c.mu.RUnlock()
+func (c *Cache[K, V]) Delete(ctx context.Context, key K) error {
+ if c.store == nil {
+ return ErrMissingStore
+ }
+ if err := c.store.Delete(ctx, key); err != nil {
+ return err
+ }
+ c.emitDelete(key)
+ return nil
+}
- if ok && (c.ttl == 0 || c.NowUnix()-entry.timeStamp < c.ttl) {
- return entry.value, true
+func (c *Cache[K, V]) Clear(ctx context.Context) error {
+ if c.store == nil {
+ return ErrMissingStore
}
+ return c.store.Clear(ctx)
+}
- return c.zeroVal, false
+// GetOrCompute follows the cache policy order: fresh-value fast path,
+// active-flight wait for non-stale caches, stored entry state handling,
+// stale-while-revalidate, miss computation, then configured stale fallback on
+// compute error.
+func (c *Cache[K, V]) GetOrCompute(ctx context.Context, key K, compute func(context.Context) (V, error)) (V, error) {
+ if c.bypass {
+ c.emitMiss(key)
+ return compute(ctx)
+ }
+ if c.store == nil {
+ var zero V
+ return zero, ErrMissingStore
+ }
+ now := c.clock.Now()
+ value, ok, supportsFreshValue, err := c.getFreshValue(ctx, key, now)
+ if err != nil || ok {
+ if ok {
+ c.emitHit(key)
+ }
+ return value, err
+ }
+ if supportsFreshValue && c.staleTTL == 0 && !c.keepStaleOnError {
+ if value, ok, err := c.waitForFlight(key); ok || err != nil {
+ return value, err
+ }
+ }
+ entry, ok, err := c.getEntry(ctx, key)
+ if err != nil {
+ var zero V
+ return zero, err
+ }
+ if ok {
+ switch entry.state(now) {
+ case entryFresh:
+ c.emitHit(key)
+ return entry.Value, nil
+ case entryStale:
+ c.emitStaleHit(key)
+ c.refresh(key, compute)
+ return entry.Value, nil
+ }
+ }
+ c.emitMiss(key)
+ value, err = c.do(key, func() (V, error) {
+ now := c.clock.Now()
+ if value, ok, _, err := c.getFreshValue(ctx, key, now); err != nil || ok {
+ return value, err
+ }
+ entry, ok, err := c.getEntry(ctx, key)
+ if err != nil {
+ var zero V
+ return zero, err
+ }
+ if ok && entry.state(now) == entryFresh {
+ return entry.Value, nil
+ }
+ computed, computeErr := compute(ctx)
+ if computeErr != nil {
+ return computed, computeErr
+ }
+ return computed, c.Set(ctx, key, computed)
+ })
+ if err != nil && ok && c.keepStaleOnError {
+ c.emitRefreshError(key, err)
+ return entry.Value, nil
+ }
+ return value, err
}
diff --git a/cache_engine_test.go b/cache_engine_test.go
new file mode 100644
index 0000000..bdd4d2e
--- /dev/null
+++ b/cache_engine_test.go
@@ -0,0 +1,391 @@
+package memoize_test
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/agkloop/go_memoize/stores/memory"
+)
+
+type testClock struct{ now time.Time }
+
+func (c *testClock) Now() time.Time { return c.now }
+
+type recordingMetrics struct {
+ mu sync.Mutex
+ events []memoize.MetricEvent
+}
+
+func (m *recordingMetrics) RecordMetric(event memoize.MetricEvent) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.events = append(m.events, event)
+}
+
+func (m *recordingMetrics) count(kind memoize.MetricEventKind) int {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ count := 0
+ for _, event := range m.events {
+ if event.Kind == kind {
+ count++
+ }
+ }
+ return count
+}
+
+func (m *recordingMetrics) contains(kind memoize.MetricEventKind, key string) bool {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for _, event := range m.events {
+ if event.Kind == kind && event.Key == key {
+ return true
+ }
+ }
+ return false
+}
+
+func (m *recordingMetrics) waitFor(kind memoize.MetricEventKind, key string, timeout time.Duration) bool {
+ deadline := time.Now().Add(timeout)
+ for {
+ if m.contains(kind, key) {
+ return true
+ }
+ if time.Now().After(deadline) {
+ return false
+ }
+ time.Sleep(time.Millisecond)
+ }
+}
+
+type recordingPeekStore[V any] struct {
+ entry memoize.Stored[V]
+ ok bool
+ getCalls int
+ peekCalls int
+}
+
+func (s *recordingPeekStore[V]) Get(context.Context, string) (memoize.Stored[V], bool, error) {
+ s.getCalls++
+ return s.entry, s.ok, nil
+}
+
+func (s *recordingPeekStore[V]) Peek(context.Context, string) (memoize.Stored[V], bool, error) {
+ s.peekCalls++
+ return s.entry, s.ok, nil
+}
+
+func (s *recordingPeekStore[V]) Set(_ context.Context, _ string, value memoize.Stored[V]) error {
+ s.entry = value
+ s.ok = true
+ return nil
+}
+
+func (s *recordingPeekStore[V]) Delete(context.Context, string) error {
+ s.ok = false
+ return nil
+}
+
+func (s *recordingPeekStore[V]) Clear(context.Context) error {
+ s.ok = false
+ return nil
+}
+
+func TestGetOrComputeCachesFreshValue(t *testing.T) {
+ ctx := context.Background()
+ clock := &testClock{now: time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)}
+ cache, err := memoize.New[string, string](memoize.Opts().WithStore(memory.New[string, string](1024)).WithTTL(time.Minute).WithClock(clock))
+ if err != nil {
+ t.Fatalf("new cache failed: %v", err)
+ }
+ calls := 0
+ compute := func(context.Context) (string, error) {
+ calls++
+ return "value", nil
+ }
+
+ first, err := cache.GetOrCompute(ctx, "key", compute)
+ if err != nil || first != "value" {
+ t.Fatalf("first call returned %q err=%v", first, err)
+ }
+ second, err := cache.GetOrCompute(ctx, "key", compute)
+ if err != nil || second != "value" {
+ t.Fatalf("second call returned %q err=%v", second, err)
+ }
+ if calls != 1 {
+ t.Fatalf("expected 1 compute call, got %d", calls)
+ }
+}
+
+func TestGetOrComputeUsesPeekForFreshHit(t *testing.T) {
+ ctx := context.Background()
+ now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
+ store := &recordingPeekStore[string]{
+ entry: memoize.Stored[string]{Value: "value", CreatedAt: now, FreshUntil: now.Add(time.Minute)},
+ ok: true,
+ }
+ cache, err := memoize.New[string, string](memoize.Opts().WithStore(store).WithTTL(time.Minute).WithClock(&testClock{now: now}))
+ if err != nil {
+ t.Fatalf("new cache failed: %v", err)
+ }
+
+ got, err := cache.GetOrCompute(ctx, "key", func(context.Context) (string, error) {
+ t.Fatal("compute should not run for fresh hit")
+ return "", nil
+ })
+ if err != nil || got != "value" {
+ t.Fatalf("got %q err=%v", got, err)
+ }
+ if store.peekCalls != 1 || store.getCalls != 0 {
+ t.Fatalf("expected one peek and no get, got peek=%d get=%d", store.peekCalls, store.getCalls)
+ }
+}
+
+func TestBypassComputesWithoutStoring(t *testing.T) {
+ ctx := context.Background()
+ cache, err := memoize.New[string, int](memoize.Opts().Bypass())
+ if err != nil {
+ t.Fatalf("new cache failed: %v", err)
+ }
+ calls := 0
+ compute := func(context.Context) (int, error) {
+ calls++
+ return calls, nil
+ }
+ first, err := cache.GetOrCompute(ctx, "key", compute)
+ if err != nil || first != 1 {
+ t.Fatalf("first call returned %d err=%v", first, err)
+ }
+ second, err := cache.GetOrCompute(ctx, "key", compute)
+ if err != nil || second != 2 {
+ t.Fatalf("second call returned %d err=%v", second, err)
+ }
+}
+
+func TestDeleteAndClear(t *testing.T) {
+ ctx := context.Background()
+ cache, err := memoize.New[string, string](memoize.Opts().WithStore(memory.New[string, string](1024)).NoExpiration())
+ if err != nil {
+ t.Fatalf("new cache failed: %v", err)
+ }
+ if err := cache.Set(ctx, "a", "one"); err != nil {
+ t.Fatalf("set a failed: %v", err)
+ }
+ if err := cache.Set(ctx, "b", "two"); err != nil {
+ t.Fatalf("set b failed: %v", err)
+ }
+ if err := cache.Delete(ctx, "a"); err != nil {
+ t.Fatalf("delete failed: %v", err)
+ }
+ if _, ok, err := cache.Get(ctx, "a"); err != nil || ok {
+ t.Fatalf("deleted key returned ok=%v err=%v", ok, err)
+ }
+ if err := cache.Clear(ctx); err != nil {
+ t.Fatalf("clear failed: %v", err)
+ }
+ if _, ok, err := cache.Get(ctx, "b"); err != nil || ok {
+ t.Fatalf("cleared key returned ok=%v err=%v", ok, err)
+ }
+}
+
+func TestStaleHitReturnsStaleAndRefreshes(t *testing.T) {
+ ctx := context.Background()
+ clock := &testClock{now: time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)}
+ metrics := &recordingMetrics{}
+ cache, err := memoize.New[string, string](memoize.Opts().WithStore(memory.New[string, string](1024)).WithTTL(time.Second).WithStaleTTL(time.Minute).WithClock(clock).WithMetrics(metrics))
+ if err != nil {
+ t.Fatalf("new cache failed: %v", err)
+ }
+ if err := cache.Set(ctx, "key", "old"); err != nil {
+ t.Fatalf("set failed: %v", err)
+ }
+ clock.now = clock.now.Add(2 * time.Second)
+ refreshDone := make(chan struct{})
+ got, err := cache.GetOrCompute(ctx, "key", func(context.Context) (string, error) {
+ defer close(refreshDone)
+ return "new", nil
+ })
+ if err != nil || got != "old" {
+ t.Fatalf("stale call returned %q err=%v", got, err)
+ }
+ select {
+ case <-refreshDone:
+ case <-time.After(time.Second):
+ t.Fatal("refresh did not complete")
+ }
+ if !metrics.waitFor(memoize.MetricRefreshSuccess, "key", time.Second) {
+ t.Fatalf("refresh success metric was not recorded: %#v", metrics)
+ }
+ deadline := time.After(time.Second)
+ for {
+ got, ok, err := cache.Get(ctx, "key")
+ if err != nil {
+ t.Fatalf("refreshed value error: %v", err)
+ }
+ if ok && got == "new" {
+ break
+ }
+ select {
+ case <-deadline:
+ t.Fatalf("refreshed value=%q ok=%v, want new", got, ok)
+ default:
+ time.Sleep(time.Millisecond)
+ }
+ }
+ if metrics.count(memoize.MetricStaleHit) != 1 || metrics.count(memoize.MetricRefreshStart) != 1 || metrics.count(memoize.MetricRefreshSuccess) != 1 {
+ t.Fatalf("unexpected metrics: %#v", metrics)
+ }
+}
+
+func TestKeepStaleOnErrorAfterStaleWindow(t *testing.T) {
+ ctx := context.Background()
+ clock := &testClock{now: time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)}
+ metrics := &recordingMetrics{}
+ cache, err := memoize.New[string, string](memoize.Opts().WithStore(memory.New[string, string](1024)).WithTTL(time.Second).WithStaleTTL(time.Second).KeepStaleOnError().WithClock(clock).WithMetrics(metrics))
+ if err != nil {
+ t.Fatalf("new cache failed: %v", err)
+ }
+ if err := cache.Set(ctx, "key", "old"); err != nil {
+ t.Fatalf("set failed: %v", err)
+ }
+ clock.now = clock.now.Add(3 * time.Second)
+ got, err := cache.GetOrCompute(ctx, "key", func(context.Context) (string, error) {
+ return "", context.Canceled
+ })
+ if err != nil || got != "old" {
+ t.Fatalf("expected stale fallback, got %q err=%v", got, err)
+ }
+ if metrics.count(memoize.MetricRefreshError) != 1 {
+ t.Fatalf("expected refresh error metric, got %#v", metrics)
+ }
+}
+
+func TestWithMetricsRecordsTypedEvents(t *testing.T) {
+ ctx := context.Background()
+ clock := &testClock{now: time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)}
+ metrics := &recordingMetrics{}
+ cache, err := memoize.New[string, string](memoize.Opts().WithStore(memory.New[string, string](1024)).WithTTL(time.Second).WithStaleTTL(time.Minute).WithClock(clock).WithMetrics(metrics))
+ if err != nil {
+ t.Fatalf("new cache failed: %v", err)
+ }
+
+ if _, err := cache.GetOrCompute(ctx, "typed", func(context.Context) (string, error) { return "fresh", nil }); err != nil {
+ t.Fatalf("miss compute failed: %v", err)
+ }
+ if _, err := cache.GetOrCompute(ctx, "typed", func(context.Context) (string, error) {
+ t.Fatal("compute should not run for fresh hit")
+ return "", nil
+ }); err != nil {
+ t.Fatalf("hit get failed: %v", err)
+ }
+ clock.now = clock.now.Add(2 * time.Second)
+ refreshDone := make(chan struct{})
+ if _, err := cache.GetOrCompute(ctx, "typed", func(context.Context) (string, error) {
+ defer close(refreshDone)
+ return "refreshed", nil
+ }); err != nil {
+ t.Fatalf("stale hit failed: %v", err)
+ }
+ select {
+ case <-refreshDone:
+ case <-time.After(time.Second):
+ t.Fatal("refresh did not complete")
+ }
+ if !metrics.waitFor(memoize.MetricRefreshSuccess, "typed", time.Second) {
+ t.Fatalf("refresh success metric was not recorded: %#v", metrics)
+ }
+ if err := cache.Delete(ctx, "typed"); err != nil {
+ t.Fatalf("delete failed: %v", err)
+ }
+ if err := cache.Set(ctx, "error", "old"); err != nil {
+ t.Fatalf("set error key failed: %v", err)
+ }
+ clock.now = clock.now.Add(2 * time.Second)
+ refreshErrorDone := make(chan struct{})
+ if _, err := cache.GetOrCompute(ctx, "error", func(context.Context) (string, error) {
+ defer close(refreshErrorDone)
+ return "", context.Canceled
+ }); err != nil {
+ t.Fatalf("stale error refresh returned err=%v", err)
+ }
+ select {
+ case <-refreshErrorDone:
+ case <-time.After(time.Second):
+ t.Fatal("error refresh did not complete")
+ }
+ if !metrics.waitFor(memoize.MetricRefreshError, "error", time.Second) {
+ t.Fatalf("refresh error metric was not recorded: %#v", metrics)
+ }
+
+ for _, want := range []memoize.MetricEventKind{
+ memoize.MetricMiss,
+ memoize.MetricSet,
+ memoize.MetricHit,
+ memoize.MetricStaleHit,
+ memoize.MetricRefreshStart,
+ memoize.MetricRefreshSuccess,
+ memoize.MetricDelete,
+ memoize.MetricRefreshError,
+ } {
+ if metrics.count(want) == 0 {
+ t.Fatalf("missing metric kind %v in %#v", want, metrics.events)
+ }
+ }
+ if !metrics.contains(memoize.MetricMiss, "typed") || !metrics.contains(memoize.MetricDelete, "typed") || !metrics.contains(memoize.MetricRefreshError, "error") {
+ t.Fatalf("events recorded wrong keys: %#v", metrics.events)
+ }
+}
+
+func TestSynchronousComputeRespectsContextCancellation(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ cache, err := memoize.New[string, string](memoize.Opts().WithStore(memory.New[string, string](1024)).WithTTL(time.Minute))
+ if err != nil {
+ t.Fatalf("new cache failed: %v", err)
+ }
+ _, err = cache.GetOrCompute(ctx, "key", func(ctx context.Context) (string, error) {
+ return "", ctx.Err()
+ })
+ if err != context.Canceled {
+ t.Fatalf("expected context.Canceled, got %v", err)
+ }
+}
+
+func TestConcurrentMissComputesOnce(t *testing.T) {
+ ctx := context.Background()
+ cache, err := memoize.New[string, int](memoize.Opts().WithStore(memory.New[string, int](1024)).WithTTL(time.Minute))
+ if err != nil {
+ t.Fatalf("new cache failed: %v", err)
+ }
+ start := make(chan struct{})
+ done := make(chan int, 8)
+ calls := 0
+ compute := func(context.Context) (int, error) {
+ calls++
+ <-start
+ return 42, nil
+ }
+ for i := 0; i < 8; i++ {
+ go func() {
+ value, err := cache.GetOrCompute(ctx, "key", compute)
+ if err != nil {
+ done <- -1
+ return
+ }
+ done <- value
+ }()
+ }
+ time.Sleep(20 * time.Millisecond)
+ close(start)
+ for i := 0; i < 8; i++ {
+ if value := <-done; value != 42 {
+ t.Fatalf("goroutine returned %d", value)
+ }
+ }
+ if calls != 1 {
+ t.Fatalf("expected 1 compute call, got %d", calls)
+ }
+}
diff --git a/cache_fast_path_test.go b/cache_fast_path_test.go
new file mode 100644
index 0000000..407ea2b
--- /dev/null
+++ b/cache_fast_path_test.go
@@ -0,0 +1,179 @@
+package memoize
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+type fastPathClock struct{ now time.Time }
+
+func (c *fastPathClock) Now() time.Time { return c.now }
+
+const fastPathTestTimeout = time.Second
+const fastPathNoResultWindow = 100 * time.Millisecond
+
+func waitForFastPathSignal(t *testing.T, ch <-chan struct{}, name string) {
+ t.Helper()
+ select {
+ case <-ch:
+ case <-time.After(fastPathTestTimeout):
+ t.Fatalf("timed out waiting for %s", name)
+ }
+}
+
+func waitForFastPathResult(t *testing.T, ch <-chan string, name string) string {
+ t.Helper()
+ select {
+ case got := <-ch:
+ return got
+ case <-time.After(fastPathTestTimeout):
+ t.Fatalf("timed out waiting for %s", name)
+ }
+ return ""
+}
+
+func assertNoFastPathResult(t *testing.T, result <-chan string, computeCalled <-chan struct{}) {
+ t.Helper()
+ select {
+ case got := <-result:
+ t.Fatalf("GetOrCompute returned %q before active flight completed", got)
+ case <-computeCalled:
+ t.Fatal("compute ran while an active flight exists")
+ case <-time.After(fastPathNoResultWindow):
+ }
+}
+
+type recordingFreshStore struct {
+ entry Stored[string]
+ ok bool
+ freshCalls int
+ peekCalls int
+ getCalls int
+ freshSeen chan struct{}
+ freshReturn chan struct{}
+}
+
+func (s *recordingFreshStore) PeekFreshValue(context.Context, string, time.Time) (string, bool, error) {
+ s.freshCalls++
+ if s.freshSeen != nil {
+ close(s.freshSeen)
+ }
+ if s.freshReturn != nil {
+ <-s.freshReturn
+ }
+ return s.entry.Value, s.ok, nil
+}
+
+func (s *recordingFreshStore) Get(context.Context, string) (Stored[string], bool, error) {
+ s.getCalls++
+ return s.entry, s.ok, nil
+}
+
+func (s *recordingFreshStore) Peek(context.Context, string) (Stored[string], bool, error) {
+ s.peekCalls++
+ return s.entry, s.ok, nil
+}
+
+func (s *recordingFreshStore) Set(_ context.Context, _ string, value Stored[string]) error {
+ s.entry = value
+ s.ok = true
+ return nil
+}
+
+func (s *recordingFreshStore) Delete(context.Context, string) error {
+ s.ok = false
+ return nil
+}
+
+func (s *recordingFreshStore) Clear(context.Context) error {
+ s.ok = false
+ return nil
+}
+
+func TestGetOrComputeUsesFreshValuePathForFreshHit(t *testing.T) {
+ ctx := context.Background()
+ now := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)
+ store := &recordingFreshStore{
+ entry: Stored[string]{Value: "value", CreatedAt: now, FreshUntil: now.Add(time.Minute)},
+ ok: true,
+ freshSeen: make(chan struct{}),
+ freshReturn: make(chan struct{}),
+ }
+ cache, err := New[string, string](Opts().WithStore(store).WithTTL(time.Minute).WithClock(&fastPathClock{now: now}))
+ if err != nil {
+ t.Fatalf("new cache failed: %v", err)
+ }
+
+ result := make(chan string, 1)
+ go func() {
+ got, err := cache.GetOrCompute(ctx, "key", func(context.Context) (string, error) {
+ t.Error("compute should not run for fresh hit")
+ return "", nil
+ })
+ if err != nil {
+ t.Errorf("GetOrCompute returned error: %v", err)
+ }
+ result <- got
+ }()
+ waitForFastPathSignal(t, store.freshSeen, "fresh-value seam")
+ if _, ok, err := cache.waitForFlight("key"); err != nil || ok {
+ t.Fatalf("fresh-value hit should not start a flight, ok=%v err=%v", ok, err)
+ }
+ close(store.freshReturn)
+
+ got := waitForFastPathResult(t, result, "fresh-value result")
+ if got != "value" {
+ t.Fatalf("got %q, want value", got)
+ }
+ if store.freshCalls != 1 || store.peekCalls != 0 || store.getCalls != 0 {
+ t.Fatalf("expected one fresh-value call only, got fresh=%d peek=%d get=%d", store.freshCalls, store.peekCalls, store.getCalls)
+ }
+}
+
+func TestGetOrComputeWaitsForActiveFlightAfterFreshMiss(t *testing.T) {
+ ctx := context.Background()
+ now := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)
+ store := &recordingFreshStore{freshSeen: make(chan struct{}), freshReturn: make(chan struct{})}
+ cache, err := New[string, string](Opts().WithStore(store).WithTTL(time.Minute).WithClock(&fastPathClock{now: now}))
+ if err != nil {
+ t.Fatalf("new cache failed: %v", err)
+ }
+
+ f := &flight[string]{}
+ f.wg.Add(1)
+ cache.flightMu.Lock()
+ cache.flights["key"] = f
+ cache.flightMu.Unlock()
+ defer func() {
+ cache.flightMu.Lock()
+ delete(cache.flights, "key")
+ cache.flightMu.Unlock()
+ }()
+
+ result := make(chan string, 1)
+ computeCalled := make(chan struct{})
+ go func() {
+ got, err := cache.GetOrCompute(ctx, "key", func(context.Context) (string, error) {
+ close(computeCalled)
+ t.Error("compute should not run while an active flight exists")
+ return "", nil
+ })
+ if err != nil {
+ t.Errorf("GetOrCompute returned error: %v", err)
+ }
+ result <- got
+ }()
+ waitForFastPathSignal(t, store.freshSeen, "fresh-value seam")
+ close(store.freshReturn)
+ assertNoFastPathResult(t, result, computeCalled)
+ f.value = "computed"
+ f.wg.Done()
+
+ if got := waitForFastPathResult(t, result, "active-flight result"); got != "computed" {
+ t.Fatalf("got %q, want computed", got)
+ }
+ if store.freshCalls != 1 || store.peekCalls != 0 || store.getCalls != 0 {
+ t.Fatalf("expected fresh miss then active-flight wait without peek/get, got fresh=%d peek=%d get=%d", store.freshCalls, store.peekCalls, store.getCalls)
+ }
+}
diff --git a/clock.go b/clock.go
new file mode 100644
index 0000000..ed49396
--- /dev/null
+++ b/clock.go
@@ -0,0 +1,51 @@
+package memoize
+
+import (
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+type Clock interface {
+ Now() time.Time
+}
+
+type ClockFunc func() time.Time
+
+func (f ClockFunc) Now() time.Time {
+ return f()
+}
+
+type TickerClock struct {
+ now atomic.Int64
+ stop chan struct{}
+ once sync.Once
+}
+
+func NewTickerClock(interval time.Duration) *TickerClock {
+ tc := &TickerClock{stop: make(chan struct{})}
+ tc.now.Store(time.Now().UnixMilli())
+ go tc.run(interval)
+ return tc
+}
+
+func (tc *TickerClock) run(interval time.Duration) {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ tc.now.Store(time.Now().UnixMilli())
+ case <-tc.stop:
+ return
+ }
+ }
+}
+
+func (tc *TickerClock) Stop() {
+ tc.once.Do(func() { close(tc.stop) })
+}
+
+func (tc *TickerClock) Now() time.Time {
+ return time.UnixMilli(tc.now.Load())
+}
diff --git a/direct_options.go b/direct_options.go
new file mode 100644
index 0000000..1b3e894
--- /dev/null
+++ b/direct_options.go
@@ -0,0 +1,146 @@
+package memoize
+
+import (
+ "reflect"
+ "time"
+)
+
+type Options struct {
+ store any
+ hasStore bool
+ ttlSet bool
+ ttl time.Duration
+ staleTTL time.Duration
+ noExpiration bool
+ bypass bool
+ keepStaleOnError bool
+ metrics Metrics
+ metricsEnabled bool
+ clock Clock
+ refreshTimeout time.Duration
+ tickerInterval time.Duration
+}
+
+func Opts() Options { return Options{} }
+
+func (o Options) WithStore(store any) Options {
+ o.hasStore = true
+ o.store = store
+ return o
+}
+
+func (o Options) WithTTL(ttl time.Duration) Options {
+ o.ttlSet = true
+ o.ttl = ttl
+ return o
+}
+
+func (o Options) WithStaleTTL(ttl time.Duration) Options {
+ o.staleTTL = ttl
+ return o
+}
+
+func (o Options) KeepStaleOnError() Options {
+ o.keepStaleOnError = true
+ return o
+}
+
+func (o Options) WithMetrics(metrics Metrics) Options {
+ if metrics != nil {
+ o.metrics = metrics
+ o.metricsEnabled = true
+ }
+ return o
+}
+
+func (o Options) WithClock(clock Clock) Options {
+ if clock != nil {
+ o.clock = clock
+ }
+ return o
+}
+
+func (o Options) WithRefreshTimeout(timeout time.Duration) Options {
+ if timeout > 0 {
+ o.refreshTimeout = timeout
+ }
+ return o
+}
+
+func (o Options) WithTickerClock(interval time.Duration) Options {
+ if interval > 0 {
+ o.tickerInterval = interval
+ }
+ return o
+}
+
+func (o Options) NoExpiration() Options {
+ o.noExpiration = true
+ return o
+}
+
+func (o Options) Bypass() Options {
+ o.bypass = true
+ return o
+}
+
+func applyOptions[K comparable, V any](c *Cache[K, V], opts Options) error {
+ if opts.hasStore {
+ store, ok := opts.store.(Store[K, V])
+ if !ok || isNilStore(store) {
+ return ErrInvalidStore
+ }
+ c.store = store
+ c.peeker, _ = store.(peekingStore[K, V])
+ }
+ if opts.ttlSet {
+ c.ttlSet = true
+ c.ttl = opts.ttl
+ }
+ if opts.staleTTL != 0 {
+ c.staleTTL = opts.staleTTL
+ }
+ if opts.noExpiration {
+ c.noExpiration = true
+ }
+ if opts.bypass {
+ c.bypass = true
+ }
+ if opts.keepStaleOnError {
+ c.keepStaleOnError = true
+ }
+ if opts.metricsEnabled {
+ c.metrics = opts.metrics
+ c.metricsEnabled = true
+ }
+ if opts.clock != nil {
+ c.clock = opts.clock
+ }
+ if opts.refreshTimeout > 0 {
+ c.refreshTimeout = opts.refreshTimeout
+ }
+ if opts.tickerInterval > 0 {
+ c.clock = NewTickerClock(opts.tickerInterval)
+ }
+ return nil
+}
+
+func isNilStore[K comparable, V any](store Store[K, V]) bool {
+ if store == nil {
+ return true
+ }
+ v := reflect.ValueOf(store)
+ switch v.Kind() {
+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
+ return v.IsNil()
+ default:
+ return false
+ }
+}
+
+func newDirectCache[V any](opts Options) (*Cache[uint64, V], error) {
+ if !opts.hasStore {
+ opts = opts.WithStore(newDirectStore[V]())
+ }
+ return New[uint64, V](opts)
+}
diff --git a/direct_store.go b/direct_store.go
new file mode 100644
index 0000000..dd4edcc
--- /dev/null
+++ b/direct_store.go
@@ -0,0 +1,55 @@
+package memoize
+
+import (
+ "context"
+ "sync"
+ "time"
+)
+
+type directStore[V any] struct {
+ mu sync.RWMutex
+ entries map[uint64]Stored[V]
+}
+
+func newDirectStore[V any]() *directStore[V] {
+ return &directStore[V]{entries: make(map[uint64]Stored[V])}
+}
+
+func (s *directStore[V]) Get(ctx context.Context, key uint64) (Stored[V], bool, error) {
+ s.mu.RLock()
+ value, ok := s.entries[key]
+ s.mu.RUnlock()
+ return value, ok, nil
+}
+
+func (s *directStore[V]) PeekFreshValue(ctx context.Context, key uint64, now time.Time) (V, bool, error) {
+ s.mu.RLock()
+ entry, ok := s.entries[key]
+ s.mu.RUnlock()
+ if !ok || entry.state(now) != entryFresh {
+ var zero V
+ return zero, false, nil
+ }
+ return entry.Value, true, nil
+}
+
+func (s *directStore[V]) Set(ctx context.Context, key uint64, value Stored[V]) error {
+ s.mu.Lock()
+ s.entries[key] = value
+ s.mu.Unlock()
+ return nil
+}
+
+func (s *directStore[V]) Delete(ctx context.Context, key uint64) error {
+ s.mu.Lock()
+ delete(s.entries, key)
+ s.mu.Unlock()
+ return nil
+}
+
+func (s *directStore[V]) Clear(ctx context.Context) error {
+ s.mu.Lock()
+ s.entries = make(map[uint64]Stored[V])
+ s.mu.Unlock()
+ return nil
+}
diff --git a/docs/API.md b/docs/API.md
new file mode 100644
index 0000000..c75e203
--- /dev/null
+++ b/docs/API.md
@@ -0,0 +1,546 @@
+# API Reference
+
+This reference covers the public API for `github.com/agkloop/go_memoize` and its subpackages. The root module is the only public module; examples should use the root import paths shown below.
+
+## Import Paths
+
+Use the root package for direct memoizers, explicit caches, options, root interfaces, errors, metrics events, and serializers interfaces:
+
+```go
+import memoize "github.com/agkloop/go_memoize"
+```
+
+Subpackages provide store implementations, serializers, background values, loaders, and optional adapters:
+
+```go
+import (
+ "github.com/agkloop/go_memoize/background"
+ "github.com/agkloop/go_memoize/loader"
+ "github.com/agkloop/go_memoize/metrics"
+ "github.com/agkloop/go_memoize/serializers"
+ "github.com/agkloop/go_memoize/stores/chain"
+ "github.com/agkloop/go_memoize/stores/local"
+ "github.com/agkloop/go_memoize/stores/memory"
+)
+```
+
+The Redis adapter is its own module under `adapters/redis`:
+
+```go
+import redisstore "github.com/agkloop/go_memoize/adapters/redis"
+```
+
+## Direct Memoizers
+
+Direct memoizers wrap functions whose cache keys can be derived from comparable arguments. They return `(func, error)` because they build an internal `memoize.Cache[uint64,V]` and validate the same root `memoize.Opts()` options as explicit caches.
+
+| Function family | Function shape | Notes |
+|---|---|---|
+| `Memoize` | `func() V` | No-argument value memoization. |
+| `Memoize1` ... `Memoize7` | `func(A...) V` | One to seven comparable args; arguments are hashed into a `uint64` key. |
+| `MemoizeE` | `func() (V, error)` | Caches successful values only; errors are returned and not stored. |
+| `Memoize1E` ... `Memoize7E` | `func(A...) (V, error)` | One to seven comparable args; successful values only. |
+| `MemoizeCtx` | `func(context.Context) V` | Context is passed through but is not part of the cache key. |
+| `MemoizeCtx1` ... `MemoizeCtx7` | `func(context.Context, A...) V` | Context is not the key; comparable args are hashed. |
+| `MemoizeCtxE` | `func(context.Context) (V, error)` | Context-aware, successful values only. |
+| `MemoizeCtx1E` ... `MemoizeCtx7E` | `func(context.Context, A...) (V, error)` | Context-aware, one to seven comparable args; successful values only. |
+
+TTL example:
+
+```go
+cached, err := memoize.Memoize2(func(tenantID string, userID int64) Profile {
+ return loadProfile(tenantID, userID)
+}, memoize.Opts().WithTTL(time.Minute))
+if err != nil {
+ return err
+}
+
+profile := cached("acme", 42)
+```
+
+Context/error example:
+
+```go
+cached, err := memoize.MemoizeCtx1E(func(ctx context.Context, id int64) (User, error) {
+ return repo.LoadUser(ctx, id)
+}, memoize.Opts().WithTTL(30*time.Second))
+if err != nil {
+ return err
+}
+
+user, err := cached(ctx, 42)
+```
+
+Stale-on-error example:
+
+```go
+cached, err := memoize.MemoizeCtx1E(loadProfile,
+ memoize.Opts().
+ WithTTL(time.Minute).
+ WithStaleTTL(5*time.Minute).
+ KeepStaleOnError(),
+)
+if err != nil {
+ return err
+}
+```
+
+Custom direct store example:
+
+```go
+var store memoize.Store[uint64, Profile] = memory.New[uint64, Profile](10_000)
+
+cached, err := memoize.MemoizeCtx1E(loadProfile,
+ memoize.Opts().WithStore(store).WithTTL(time.Minute),
+)
+```
+
+Use direct memoizers for simple function caching. Use `memoize.New[K,V]` when callers need explicit keys, direct cache operations, tag invalidation, or a store key type other than `uint64`.
+
+## Explicit Cache Engine
+
+`memoize.New[K,V](opts ...memoize.Options) (*memoize.Cache[K,V], error)` creates an explicit cache. Most application caches use `K=string`; caches that mirror direct memoizer keying can use `K=uint64`.
+
+```go
+cache, err := memoize.New[string, User](
+ memoize.Opts().WithStore(memory.New[string, User](10_000)).WithTTL(time.Minute),
+)
+if err != nil {
+ return err
+}
+defer cache.Stop()
+```
+
+`Cache[K,V]` methods:
+
+| Method | Meaning |
+|---|---|
+| `Get(ctx, key)` | Reads a fresh value. Missing, expired, or stale entries return a miss. |
+| `Set(ctx, key, value)` | Stores a value using the cache expiration policy. |
+| `Delete(ctx, key)` | Deletes one key. |
+| `Clear(ctx)` | Clears the backing store. |
+| `GetOrCompute(ctx, key, fn)` | Returns a fresh cached value or computes, stores, and returns it. Concurrent misses for the same key are coalesced. |
+| `Stop()` | Releases ticker-clock resources. Safe to call more than once. |
+
+The cache engine owns freshness decisions. Stores persist `memoize.Stored[V]` envelopes and return entries even when they might be stale or expired; `Cache[K,V]` decides whether to serve, refresh, or miss.
+
+`GetOrCompute` example:
+
+```go
+user, err := cache.GetOrCompute(ctx, "user:42", func(ctx context.Context) (User, error) {
+ return repo.LoadUser(ctx, 42)
+})
+```
+
+Stale-while-revalidate with stale-on-error:
+
+```go
+cache, err := memoize.New[string, User](
+ memoize.Opts().
+ WithStore(memory.New[string, User](10_000)).
+ WithTTL(time.Minute).
+ WithStaleTTL(5*time.Minute).
+ KeepStaleOnError(),
+)
+```
+
+## Options
+
+Build options with the non-generic root builder `memoize.Opts()`.
+
+| Option | Applies to | Meaning | Validation |
+|---|---|---|---|
+| `WithStore(store)` | Direct memoizers and explicit caches | Sets the backing `memoize.Store[K,V]`. Direct memoizers require `Store[uint64,V]`; explicit caches require a store matching `K,V`. | Store must implement the exact typed `Store[K,V]`; wrong or nil store returns `ErrInvalidStore`. Explicit caches also need a store unless `Bypass()` is set. |
+| `WithTTL(ttl)` | Direct memoizers and explicit caches | Fresh duration for stored values. | Must be greater than zero when set, or `New` returns `ErrInvalidTTL`. |
+| `WithStaleTTL(ttl)` | Direct memoizers and explicit caches | Additional stale-serving window after freshness expires. Enables stale-while-revalidate behavior. | Must not be negative and requires a positive TTL, or `New` returns `ErrInvalidStaleTTL`. |
+| `KeepStaleOnError()` | Direct memoizers and explicit caches | If recompute fails while a stale entry exists, return the stale value instead of the recompute error. | Meaningful only with `WithTTL` plus `WithStaleTTL`; no separate validation. |
+| `NoExpiration()` | Direct memoizers and explicit caches | Values remain fresh until overwritten, deleted, or cleared. | Satisfies the required expiration-policy validation. |
+| `Bypass()` | Direct memoizers and explicit caches | Always computes and never stores. Useful for feature flags, tests, or temporarily disabling caching. | Satisfies expiration-policy validation and does not require a store. |
+| `WithMetrics(metrics)` | Direct memoizers and explicit caches | Records cache events through `RecordMetric(MetricEvent)`. Nil is ignored. | No error; nil leaves metrics disabled. |
+| `WithClock(clock)` | Direct memoizers and explicit caches | Injects a clock, mainly for tests or custom timing. | Nil is ignored. |
+| `WithTickerClock(interval)` | Direct memoizers and explicit caches | Uses a root ticker-backed clock at the given interval. | Non-positive intervals are ignored. |
+| `WithRefreshTimeout(timeout)` | Direct memoizers and explicit caches | Timeout used for background stale refresh work. | Non-positive values are ignored; default remains in effect. |
+
+Every cache needs exactly one expiration strategy in practice: `WithTTL`, `NoExpiration`, or `Bypass`. `WithStaleTTL` extends a TTL policy; it is not a standalone expiration policy.
+
+## Defaults
+
+| Setting | Explicit `memoize.New[K,V]` | Direct `Memoize*` |
+|---|---|---|
+| Store | No default store. Operations that need storage return `ErrMissingStore` unless `Bypass()` is set. | Injects an internal unbounded `Store[uint64,V]` when `WithStore` is omitted. |
+| Expiration policy | No default expiration policy. `New` returns `ErrMissingExpirationPolicy` unless `WithTTL`, `NoExpiration`, or `Bypass` is configured. | Same validation; direct memoizers still require `WithTTL`, `NoExpiration`, or `Bypass`. |
+| Metrics | Disabled by default; an internal noop recorder is used. | Same. |
+| Clock | `NewTickerClock(time.Millisecond)`. | Same. |
+| Refresh timeout | `30 * time.Second`. | Same. |
+| Concurrent miss coalescing | Enabled by an internal per-key flight map. | Same. |
+
+Call `cache.Stop()` for explicit caches when you own the cache lifetime. Direct memoizers own their internal cache; use an explicit cache if shutdown control is required.
+
+## Errors
+
+| Error | Meaning |
+|---|---|
+| `ErrMissingExpirationPolicy` | `WithTTL`, `NoExpiration`, and `Bypass` were all omitted. |
+| `ErrInvalidTTL` | `WithTTL` was set to zero or a negative duration. |
+| `ErrInvalidStaleTTL` | `WithStaleTTL` was negative or was used without a positive TTL. |
+| `ErrMissingStore` | A cache operation required storage, but no store was configured. |
+| `ErrInvalidStore` | `WithStore` received a nil store or a store whose key/value types do not match the cache. |
+
+## Stores
+
+`memoize.Store[K,V]` is the common storage interface used by memory, chain, local, Redis, and custom stores:
+
+```go
+type Store[K comparable, V any] interface {
+ Get(ctx context.Context, key K) (memoize.Stored[V], bool, error)
+ Set(ctx context.Context, key K, value memoize.Stored[V]) error
+ Delete(ctx context.Context, key K) error
+ Clear(ctx context.Context) error
+}
+```
+
+`memoize.Stored[V]` is the envelope stores must persist and return:
+
+| Field | Meaning |
+|---|---|
+| `Value` | Cached value. |
+| `CreatedAt` | Write timestamp. |
+| `FreshUntil` | Freshness deadline. |
+| `StaleUntil` | Stale-serving deadline. |
+| `NoExpire` | Entry is always fresh when true. |
+| `Version` | Optional application version marker. |
+| `Tags` | Optional invalidation tags. |
+
+`memoize.TaggedStore[K,V]` extends `Store[K,V]` with `DeleteByTag(ctx, tag)`. Stores that support tags should remove entries whose `Stored[V].Tags` contains the tag.
+
+Custom stores should not filter stale or expired entries inside `Get`. Return the raw stored envelope and let `Cache[K,V]` decide freshness.
+
+Custom SQL store shape:
+
+```go
+type SQLStore[K comparable, V any] struct {
+ db *sql.DB
+ encodeKey func(K) string
+ encodeEntry func(memoize.Stored[V]) ([]byte, error)
+ decodeEntry func([]byte) (memoize.Stored[V], error)
+}
+
+func (s *SQLStore[K,V]) Get(ctx context.Context, key K) (memoize.Stored[V], bool, error) {
+ row := s.db.QueryRowContext(ctx, `select entry from cache_entries where key = ?`, s.encodeKey(key))
+ var data []byte
+ if err := row.Scan(&data); errors.Is(err, sql.ErrNoRows) {
+ var zero memoize.Stored[V]
+ return zero, false, nil
+ } else if err != nil {
+ var zero memoize.Stored[V]
+ return zero, false, err
+ }
+ entry, err := s.decodeEntry(data)
+ return entry, err == nil, err
+}
+
+func (s *SQLStore[K,V]) Set(ctx context.Context, key K, entry memoize.Stored[V]) error {
+ data, err := s.encodeEntry(entry)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ `insert into cache_entries(key, entry) values(?, ?)
+ on conflict(key) do update set entry = excluded.entry`,
+ s.encodeKey(key), data,
+ )
+ return err
+}
+
+func (s *SQLStore[K,V]) Delete(ctx context.Context, key K) error {
+ _, err := s.db.ExecContext(ctx, `delete from cache_entries where key = ?`, s.encodeKey(key))
+ return err
+}
+
+func (s *SQLStore[K,V]) Clear(ctx context.Context) error {
+ _, err := s.db.ExecContext(ctx, `delete from cache_entries`)
+ return err
+}
+```
+
+Object stores such as S3 can also implement `Store[K,V]`. Use the object key as the encoded cache key and the object body as the encoded `Stored[V]` envelope. S3 lifecycle policies can clean up old objects, but freshness still belongs to the cache engine through `FreshUntil`, `StaleUntil`, and `NoExpire`. `Clear` should delete only objects under the store prefix. For hot paths, place object storage behind a memory L1 with `stores/chain`.
+
+## Memory Stores
+
+Import:
+
+```go
+import "github.com/agkloop/go_memoize/stores/memory"
+```
+
+`memory.New[K,V](capacity, opts...)` creates an exact-LRU in-memory store for many bounded keys:
+
+```go
+store := memory.New[string, User](10_000)
+```
+
+`memory.NewSharded[K,V](capacity, opts...)` creates a sharded in-memory store for high concurrency across many different keys. Sharding improves distributed-key contention, not one-hot-key contention. Use it with supported primitive key types such as strings and integers; unsupported key types panic when the store chooses a shard:
+
+```go
+store := memory.NewSharded[string, User](100_000, memory.WithShards[string, User](32))
+```
+
+`memory.NewSingle[K,V]()` stores one logical value or one hot key with an atomic read path:
+
+```go
+store := memory.NewSingle[string, Config]()
+```
+
+Memory options:
+
+| Option | Meaning |
+|---|---|
+| `memory.WithMaxBytes(n)` | Shallow byte budget; evicts LRU entries when exceeded. |
+| `memory.WithGetRecencySample(n)` | Refreshes LRU recency every `n` hits. `n <= 1` keeps exact LRU on every get. |
+| `memory.WithShards(n)` | Shard count for `NewSharded`; must be a positive power of two. |
+
+Memory stores support `Get`, `Peek`, `Set`, `Delete`, `Clear`, `DeleteByTag`, `Len`, and `UsedBytes`. `Peek` is used by the cache engine to read without recency updates; user code should usually call `Cache` methods instead.
+
+## Chain Store
+
+Import:
+
+```go
+import "github.com/agkloop/go_memoize/stores/chain"
+```
+
+`chain.New[K,V](tiers ...memoize.Store[K,V])` creates ordered cache tiers. `Get` checks L1, then lower tiers, and backfills higher-priority tiers after a lower-tier hit. `Set`, `Delete`, and `Clear` propagate to every tier.
+
+```go
+l1 := memory.New[string, User](10_000)
+l2 := local.New[User]("/var/cache/myapp/users")
+store := chain.New[string, User](l1, l2)
+
+cache, err := memoize.New[string, User](
+ memoize.Opts().WithStore(store).WithTTL(time.Minute),
+)
+```
+
+## Local Store
+
+Import:
+
+```go
+import "github.com/agkloop/go_memoize/stores/local"
+```
+
+`local.New[V](dir)` creates a file-backed `memoize.Store[string,V]`. It maps string keys to SHA-256 filenames, writes atomically, and stores Gob-encoded `Stored[V]` entries. Values must be Gob-encodable.
+
+```go
+store := local.New[Report]("/var/cache/myapp/reports")
+```
+
+The local store returns the stored envelope as written; the cache engine decides freshness and staleness.
+
+## Serializers
+
+External stores that need byte encoding use `memoize.Serializer[V]`:
+
+```go
+type Serializer[V any] interface {
+ Marshal(V) ([]byte, error)
+ Unmarshal([]byte) (V, error)
+}
+```
+
+Built-in serializer types:
+
+| Type | Meaning |
+|---|---|
+| `serializers.JSON[V]` | JSON marshal/unmarshal. |
+| `serializers.Gob[V]` | Gob marshal/unmarshal. |
+| `serializers.Func[V]` | Custom marshal/unmarshal functions. |
+
+Examples:
+
+```go
+jsonSerializer := serializers.JSON[User]{}
+gobSerializer := serializers.Gob[User]{}
+
+protoSerializer := serializers.Func[User]{
+ MarshalFunc: func(user User) ([]byte, error) {
+ return proto.Marshal(user.ToProto())
+ },
+ UnmarshalFunc: func(data []byte) (User, error) {
+ var msg userpb.User
+ if err := proto.Unmarshal(data, &msg); err != nil {
+ return User{}, err
+ }
+ return UserFromProto(&msg), nil
+ },
+}
+```
+
+Serializers encode the value payload for stores such as Redis. Stores that persist the full envelope must also preserve `Stored[V]` metadata.
+
+## Metrics
+
+The root metrics interface is one method:
+
+```go
+type Metrics interface {
+ RecordMetric(memoize.MetricEvent)
+}
+```
+
+`memoize.MetricEvent` has `Kind memoize.MetricEventKind`, `Key string`, `Duration time.Duration`, and `Err error`. `MetricEventKind` values are `MetricHit`, `MetricMiss`, `MetricStaleHit`, `MetricRefreshStart`, `MetricRefreshSuccess`, `MetricRefreshError`, `MetricSet`, and `MetricDelete`.
+
+Attach metrics with `WithMetrics`:
+
+```go
+m := metrics.NewInMemoryMetrics()
+
+cache, err := memoize.New[string, User](
+ memoize.Opts().WithStore(memory.New[string, User](1024)).WithTTL(time.Minute).WithMetrics(m),
+)
+```
+
+`metrics.InMemoryMetrics` records events in process. `Stats()` returns `map[string]CacheStats` keyed by `MetricEvent.Key`, including hits, misses, stale hits, sets, deletes, refresh counts, hit rate, and refresh latency percentiles. `Reset()` clears counters.
+
+`MetricEvent.Key` is usually the cache entry key. Treat it as high cardinality in production metrics exporters; aggregate, sample, hash, or bucket keys before turning them into labels.
+
+Custom metrics implementation:
+
+```go
+type PromMetrics struct{}
+
+func (PromMetrics) RecordMetric(event memoize.MetricEvent) {
+ switch event.Kind {
+ case memoize.MetricHit:
+ cacheHits.Inc()
+ case memoize.MetricRefreshSuccess:
+ refreshLatency.Observe(event.Duration.Seconds())
+ case memoize.MetricRefreshError:
+ refreshErrors.Inc()
+ }
+}
+```
+
+## Background Values
+
+Import:
+
+```go
+import "github.com/agkloop/go_memoize/background"
+```
+
+Use `background` for one value refreshed on a schedule and served by local atomic reads.
+
+| API | Meaning |
+|---|---|
+| `background.Keep(ctx, fn, interval, opts...)` | Loads once, refreshes with `fn` on the interval, and returns `*background.Value[V]`. The initial load must succeed. |
+| `background.MustKeep(ctx, fn, interval, opts...)` | Same as `Keep`, but panics on initial load failure. |
+| `background.Mirror(ctx, key, store, interval, opts...)` | Reads one `Store[string,V]` key immediately, copies `entry.Value` into local process memory, and refreshes that local mirror on the interval. |
+| `background.MustMirror(ctx, key, store, interval, opts...)` | Same as `Mirror`, but panics on initial read failure. Use it in startup paths when the remote snapshot must exist before serving. |
+| `Value.Get()` | Returns the latest local value atomically. It does not call the remote store, does not block, and does not return an error. |
+
+Options:
+
+| Option | Meaning |
+|---|---|
+| `background.WriteThrough(key, store)` | Writes every successful `Keep` refresh to a shared `Store[string,V]`, useful for publishing snapshots to Redis or another shared store. |
+| `background.OnError(fn)` | Observes refresh errors after the initial load; the previous value is kept. |
+| `background.OnRefresh(fn)` | Observes each successful refresh value. |
+
+High-level patterns:
+
+```go
+value, err := background.Keep(ctx, loadConfig, time.Minute)
+cfg := value.Get()
+
+publisher, err := background.Keep(ctx, loadConfig, time.Minute,
+ background.WriteThrough[Config]("config:current", sharedStore),
+)
+
+mirror, err := background.Mirror(ctx, "config:current", sharedStore, time.Second)
+```
+
+`Mirror` and `MustMirror` are reader-side helpers. The first read must find the remote key. After that, request handlers should call `Value.Get()`; each call is an atomic local memory read of the last successfully mirrored value. The background goroutine is the only part that polls the shared store. Treat returned values as shared memory: keep them immutable, or copy maps and slices before mutating.
+
+## Loader
+
+Import:
+
+```go
+import "github.com/agkloop/go_memoize/loader"
+```
+
+`loader.New(fn, interval, opts...)` creates a fixed-interval background loader. `Value(ctx)` blocks until the first successful load or context cancellation, then returns instantly after readiness. Call `Stop()` to shut down the goroutine.
+
+```go
+l := loader.New(loadConfig, time.Minute, loader.WithOnError[Config](logError))
+defer l.Stop()
+
+cfg, err := l.Value(ctx)
+```
+
+The loader is readiness-oriented. `background.Keep` is producer-side snapshot refresh. `background.Mirror` is reader-side snapshot mirroring from an existing store key.
+
+## Redis Adapter
+
+The Redis adapter lives in the separate `adapters/redis` module:
+
+```sh
+go get github.com/agkloop/go_memoize/adapters/redis
+```
+
+```go
+import redisstore "github.com/agkloop/go_memoize/adapters/redis"
+```
+
+Create a Redis store with a Redis universal client, a serializer, and optional prefix/key encoder:
+
+```go
+redisStore, err := redisstore.New[string, User](
+ redisstore.WithClient[string, User](client),
+ redisstore.WithPrefix[string, User]("users"),
+ redisstore.WithSerializer[string, User](serializers.JSON[User]{}),
+)
+
+cache, err := memoize.New[string, User](
+ memoize.Opts().WithStore(redisStore).WithTTL(time.Minute),
+)
+```
+
+Adapter options:
+
+| Option | Meaning |
+|---|---|
+| `WithClient` | Required Redis universal client. |
+| `WithPrefix` | Optional key prefix. |
+| `WithSerializer` | Required custom serializer for values, such as `serializers.JSON[V]`, `serializers.Gob[V]`, or `serializers.Func[V]`. |
+| `WithKeyEncoder` | Optional typed-key to Redis-key encoder. Defaults to strings, decimal integer formatting, or `fmt.Sprint`. |
+
+Use `K=uint64` for direct memoizer stores:
+
+```go
+redisStore, err := redisstore.New[uint64, Profile](
+ redisstore.WithClient[uint64, Profile](client),
+ redisstore.WithPrefix[uint64, Profile]("profiles"),
+ redisstore.WithSerializer[uint64, Profile](serializers.JSON[Profile]{}),
+)
+
+cached, err := memoize.MemoizeCtx1E(loadProfile,
+ memoize.Opts().WithStore(redisStore).WithTTL(time.Minute),
+)
+```
+
+Use a custom key encoder when Redis keys need stable application formatting instead of default typed-key formatting:
+
+```go
+redisStore, err := redisstore.New[UserKey, User](
+ redisstore.WithClient[UserKey, User](client),
+ redisstore.WithPrefix[UserKey, User]("users"),
+ redisstore.WithSerializer[UserKey, User](serializers.JSON[User]{}),
+ redisstore.WithKeyEncoder[UserKey, User](func(key UserKey) string {
+ return key.TenantID + ":" + strconv.FormatInt(key.UserID, 10)
+ }),
+)
+```
+
+Redis storage TTL is backend cleanup based on the later of `FreshUntil` and `StaleUntil`. It is not the public freshness policy; `Cache[K,V]` still decides whether a returned entry is fresh, stale, or expired.
diff --git a/docs/CONCEPTS.md b/docs/CONCEPTS.md
new file mode 100644
index 0000000..23b2f86
--- /dev/null
+++ b/docs/CONCEPTS.md
@@ -0,0 +1,64 @@
+# Concepts
+
+## Two APIs, One Cache Engine
+
+`go_memoize` has two public APIs backed by the same cache engine:
+
+- Direct memoizers wrap functions such as `Memoize1`, `MemoizeCtx1E`, and other arity/context/error variants.
+- Explicit caches use `memoize.New[K,V]` and `Cache.GetOrCompute` with caller-provided keys.
+
+Use direct memoization when the function arguments are the natural cache key.
+Use an explicit cache when you want business keys, bounded stores, lifecycle control, or a cache object shared across call sites.
+
+## Keys
+
+Direct memoizers hash comparable arguments to `uint64` keys.
+Because of that, direct custom stores must be `Store[uint64,V]`.
+
+Explicit caches let you choose the key type with `memoize.New[K,V]`.
+Explicit cache examples usually use `K=string` because business keys such as `"user:42"` are stable, readable, and easy to share across systems.
+
+## Stores
+
+Stores persist raw `memoize.Stored[V]` entries.
+The store is responsible for saving and returning entries, while the cache engine decides whether each stored value is fresh, stale, or expired.
+
+For direct memoizers, provide custom stores as `Store[uint64,V]`.
+For explicit caches, match the store key type to the cache key type, such as `memory.New[string, User](10_000)` with `memoize.New[string, User]`.
+
+## Fresh, Stale, Expired
+
+`WithTTL` sets how long a value is fresh.
+Fresh values are returned directly without recomputing.
+
+`WithStaleTTL` sets an additional stale window after freshness ends.
+During the stale window, the cache may return the stale value while recomputing according to the cache engine behavior.
+
+`KeepStaleOnError` allows a stale value to be returned when recomputation fails.
+Without stale-on-error, a failed recomputation returns the error when no fresh value can be used.
+
+After the stale window ends, the value is expired.
+Expired values are not usable unless `KeepStaleOnError` is configured to keep serving stale data after recomputation errors.
+
+## Defaults
+
+`memoize.New[K,V]` has no default store and no default expiration policy.
+Explicit caches must choose a store with `WithStore` and choose an expiration mode with `WithTTL`, `NoExpiration`, or `Bypass`.
+
+Direct memoizers create an internal unbounded `Store[uint64,V]` when `WithStore` is omitted.
+They still require an expiration mode: `WithTTL`, `NoExpiration`, or `Bypass`.
+
+## Errors
+
+Error-returning memoizers cache successful results only.
+If the wrapped function returns an error, that failed result is not written as a fresh cached value.
+
+For explicit caches, `GetOrCompute` follows the same principle: successful recomputations are stored, while errors are returned to the caller unless a usable stale value is returned by stale-on-error behavior.
+
+## Shutdown
+
+Explicit caches should call `Stop` when the cache is no longer needed.
+This gives the cache engine a lifecycle hook for background work and store cleanup.
+
+Direct memoizers do not expose a shutdown method.
+If shutdown control is required, use explicit cache construction with `memoize.New[K,V]` and wrap calls around `Cache.GetOrCompute` yourself.
diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md
new file mode 100644
index 0000000..593bcc9
--- /dev/null
+++ b/docs/GETTING_STARTED.md
@@ -0,0 +1,125 @@
+# Getting Started
+
+## Install
+
+Install the root module:
+
+```sh
+go get github.com/agkloop/go_memoize
+```
+
+Import the root package and any stores you need:
+
+```go
+import (
+ "context"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/agkloop/go_memoize/stores/memory"
+)
+```
+
+## Direct Memoization
+
+Use direct memoization when you want to wrap a function with comparable arguments.
+The memoizer hashes the arguments and manages cache lookups for you.
+
+```go
+type User struct {
+ ID int64
+ Name string
+}
+
+func loadUser(id int64) User {
+ return User{ID: id, Name: "Ada"}
+}
+
+cachedLoadUser, err := memoize.Memoize1(loadUser, memoize.Opts().WithTTL(time.Minute))
+if err != nil {
+ return err
+}
+
+user := cachedLoadUser(42)
+```
+
+## Direct Memoization With Errors And Context
+
+Use `Ctx` and `E` variants when the source function accepts a `context.Context` or returns an error.
+Only successful results are cached.
+
+```go
+func loadUser(ctx context.Context, id int64) (User, error) {
+ return User{ID: id, Name: "Ada"}, nil
+}
+
+cachedLoadUser, err := memoize.MemoizeCtx1E(loadUser, memoize.Opts().WithTTL(30*time.Second))
+if err != nil {
+ return err
+}
+
+user, err := cachedLoadUser(ctx, 42)
+if err != nil {
+ return err
+}
+```
+
+## Direct Stale-On-Error Memoization
+
+Use stale-on-error when a previously cached value is better than failing the request.
+`WithTTL` controls the fresh window, `WithStaleTTL` controls how long stale values remain usable, and `KeepStaleOnError` returns stale data if recomputation fails.
+
+```go
+func loadUser(ctx context.Context, id int64) (User, error) {
+ return User{ID: id, Name: "Ada"}, nil
+}
+
+cachedLoadUser, err := memoize.MemoizeCtx1E(
+ loadUser,
+ memoize.Opts().
+ WithTTL(time.Minute).
+ WithStaleTTL(5*time.Minute).
+ KeepStaleOnError(),
+)
+if err != nil {
+ return err
+}
+
+user, err := cachedLoadUser(ctx, 42)
+if err != nil {
+ return err
+}
+```
+
+## Explicit Cache With Business Keys
+
+Use an explicit cache when you already have stable business keys, need a bounded store, or want direct lifecycle control.
+
+```go
+type User struct {
+ ID string
+ Name string
+}
+
+cache, err := memoize.New[string, User](
+ memoize.Opts().
+ WithStore(memory.New[string, User](10_000)).
+ WithTTL(time.Minute),
+)
+if err != nil {
+ return err
+}
+defer cache.Stop()
+
+user, err := cache.GetOrCompute(ctx, "user:42", func(ctx context.Context) (User, error) {
+ return User{ID: "42", Name: "Ada"}, nil
+})
+if err != nil {
+ return err
+}
+```
+
+## Next Steps
+
+Read `docs/CONCEPTS.md` for the model behind keys, stores, freshness, errors, and shutdown.
+Read `docs/API.md` for API details and `docs/PRODUCTION.md` for production guidance.
diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
new file mode 100644
index 0000000..bf52547
--- /dev/null
+++ b/docs/MIGRATION.md
@@ -0,0 +1,99 @@
+# Migration Guide
+
+The package now exposes one public root module: `github.com/agkloop/go_memoize`.
+
+Users can pin module versions with Go modules, so the repository no longer carries a parallel module path or a helper package for memoization wrappers.
+
+## Import Paths
+
+Use the root module for cache-engine imports:
+
+```go
+import memoize "github.com/agkloop/go_memoize"
+```
+
+Use root subpackages directly:
+
+```go
+import "github.com/agkloop/go_memoize/stores/memory"
+```
+
+## Direct Function Memoization
+
+For simple function memoization, use root direct memoizers with `Opts()`:
+
+```go
+cached, err := memoize.Memoize1(func(id int) User {
+ return loadUser(id)
+}, memoize.Opts().WithTTL(time.Minute))
+if err != nil {
+ return err
+}
+```
+
+For functions that can fail, use `E` variants:
+
+```go
+cached, err := memoize.MemoizeCtx1E(func(ctx context.Context, id int) (User, error) {
+ return loadUser(ctx, id)
+}, memoize.Opts().WithTTL(time.Minute))
+if err != nil {
+ return err
+}
+```
+
+## Helper Package Removal
+
+The old helper-wrapper style should be replaced. If you previously used a helper function with an explicit cache and key function, choose one of these replacements.
+
+### Replacement 1: Direct Memoization
+
+Use this when comparable arguments are enough and you do not need a custom store or explicit business key.
+
+```go
+getUser, err := memoize.MemoizeCtx1E(func(ctx context.Context, id int) (User, error) {
+ return loadUser(ctx, id)
+}, memoize.Opts().WithTTL(time.Minute))
+if err != nil {
+ return err
+}
+```
+
+### Replacement 2: Explicit Cache Key
+
+Use this when the key matters, the cache store matters, or you need metrics/stale refresh.
+
+```go
+user, err := cache.GetOrCompute(ctx, fmt.Sprintf("user:%d", id), func(ctx context.Context) (User, error) {
+ return loadUser(ctx, id)
+})
+```
+
+## Store Migration
+
+| Package | Import |
+|---|---|
+| Memory store | `github.com/agkloop/go_memoize/stores/memory` |
+| Chain store | `github.com/agkloop/go_memoize/stores/chain` |
+| Local store | `github.com/agkloop/go_memoize/stores/local` |
+| Background values | `github.com/agkloop/go_memoize/background` |
+| Metrics Adapter | `github.com/agkloop/go_memoize/metrics` |
+| Serializers | `github.com/agkloop/go_memoize/serializers` |
+| Loader | `github.com/agkloop/go_memoize/loader` |
+
+## Verification
+
+After migration, run:
+
+```sh
+go test ./... -count=1
+go test ./... -race -count=1
+```
+
+If you use the Redis adapter, run its module tests too:
+
+```sh
+cd adapters/redis
+go test ./... -count=1
+go test ./... -race -count=1
+```
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
new file mode 100644
index 0000000..a7a60ca
--- /dev/null
+++ b/docs/PERFORMANCE.md
@@ -0,0 +1,123 @@
+# Performance And Architecture
+
+This document explains the performance-sensitive architecture inside `go_memoize` and records the latest local benchmark run. Treat the benchmark numbers as a point-in-time reference, not a portable guarantee.
+
+## Internal Architecture
+
+Direct memoizers and explicit caches share the root cache engine. The public `Memoize`, `Memoize1` through `Memoize7`, and context/error variants build a `Cache[uint64, V]` internally, while explicit cache users build typed caches with `memoize.New[K, V]`.
+
+Direct memoizers hash comparable arguments to a `uint64` cache key. Explicit caches use caller key types such as `string`, `int64`, or other comparable key types selected by the application. Stores persist raw `memoize.Stored[V]` entries; the cache engine owns freshness, staleness, expiration, and stale-on-error policy rather than delegating those decisions to the store.
+
+By default, direct memoizers use an internal unbounded map-backed store. Use `memoize.Opts().WithStore(...)` when direct memoization needs a bounded memory store, sharded store, chain store, or adapter-backed store.
+
+## Cache Engine Hot Path
+
+`Cache.GetOrCompute` is the central hot path. It first checks whether a built-in store can return a fresh value directly, then handles same-key miss coalescing, stored-entry state, stale-while-revalidate, miss computation, and optional stale fallback on compute error.
+
+The cache engine coalesces concurrent same-key misses internally. One caller becomes the leader and computes the value; followers wait for the active flight and receive the same result. This avoids stampedes for cold keys and for refresh paths that converge on the same key.
+
+Explicit caches own a default ticker clock and should be shut down with `Stop` when the cache lifetime ends. `Stop` releases the default clock goroutine and is safe to call more than once.
+
+## Direct Memoizer Keying
+
+Direct memoizers convert function arguments into a `uint64` key before calling the shared cache engine. Zero-argument memoizers use key `0`; one-argument and multi-argument memoizers hash comparable arguments into the direct cache key space.
+
+This design keeps the public direct memoization API simple while allowing the implementation to reuse the same `Cache.GetOrCompute` policy as explicit caches. When a custom store is supplied to a direct memoizer, it must be a `memoize.Store[uint64, V]` because the direct memoizer has already converted the original arguments to a `uint64` key.
+
+## Store Fast Paths
+
+The store fast paths are implementation notes, not public extension points. Built-in stores may implement private interfaces used by the cache engine to avoid avoidable allocation and work on fresh hits.
+
+For example, the memory stores can prove that an entry is fresh and return only the value needed by the caller. That lets fresh hits avoid loading full entry metadata through the generic store interface and skip extra policy work when the entry is already usable.
+
+External stores only need to implement the public `memoize.Store[K, V]` interface. Private fast paths may change as the implementation changes.
+
+## Memory Store Design
+
+`memory.New` keeps an exact LRU with a fixed item capacity. It also supports optional byte limits using a shallow entry-size estimate; heap allocations inside values such as string contents or slice backing arrays are not counted.
+
+`memory.NewSingle` stores one logical value and avoids LRU and hash overhead on the hot read path. Use it for one cached snapshot, one global configuration value, or one hot key. It is not a general replacement for many-key caches.
+
+`memory.NewSharded` splits total capacity across independent LRU shards. It improves distributed-key concurrency by reducing mutex contention across many keys, but it does not improve one-hot-key contention because one key maps to one shard.
+
+## Stale Refresh Loop
+
+Stale refresh returns the stale value immediately and starts a background refresh for that key. The same internal flight mechanism prevents duplicate refreshes for the same key while one refresh is already running.
+
+`background.Keep` and `loader.New` share refresh-loop infrastructure internally. Cache stale refresh uses the cache engine's flight machinery instead because it refreshes one key at a time after stale hits, while `background` and `loader` run periodic whole-value refresh loops.
+
+## Metrics Event Model
+
+Metrics use a single event method: `RecordMetric(memoize.MetricEvent)`. Events carry a kind, string key, optional duration, and optional error. The cache engine emits events for hits, misses, stale hits, refresh start/success/error, set, and delete.
+
+The single event method keeps instrumentation cheap for the cache engine and lets metrics implementations decide whether to aggregate, sample, export, or ignore events.
+
+## Benchmark Methodology
+
+Command:
+
+```sh
+go test ./benchmarks/ -bench=. -benchmem -benchtime=1s -count=1
+```
+
+Go version:
+
+```text
+go version go1.25.5 darwin/arm64
+```
+
+Machine caveat: these results were recorded on `darwin/arm64`, CPU `Apple M2 Pro`. Benchmark numbers depend on Go version, CPU, OS scheduler, power state, background load, and benchmark shape.
+
+Full benchmark output:
+
+```text
+go version go1.25.5 darwin/arm64
+goos: darwin
+goarch: arm64
+pkg: github.com/agkloop/go_memoize/benchmarks
+cpu: Apple M2 Pro
+BenchmarkDo0Mem-10 23160691 52.38 ns/op 24 B/op 1 allocs/op
+BenchmarkDo0LRU-10 47512914 23.81 ns/op 0 B/op 0 allocs/op
+BenchmarkDo1Mem-10 20862187 59.20 ns/op 48 B/op 1 allocs/op
+BenchmarkDo1LRU-10 30650035 39.37 ns/op 0 B/op 0 allocs/op
+BenchmarkDo2Mem-10 17754127 67.13 ns/op 64 B/op 1 allocs/op
+BenchmarkDo2LRU-10 28463869 45.57 ns/op 0 B/op 0 allocs/op
+BenchmarkDo3Mem-10 16592020 73.02 ns/op 80 B/op 1 allocs/op
+BenchmarkDo3LRU-10 26605867 44.54 ns/op 0 B/op 0 allocs/op
+BenchmarkDo4Mem-10 16346425 72.76 ns/op 80 B/op 1 allocs/op
+BenchmarkDo4LRU-10 24477202 46.47 ns/op 0 B/op 0 allocs/op
+BenchmarkMemoryHotHit-10 40423036 29.80 ns/op 0 B/op 0 allocs/op
+BenchmarkMemoryColdMiss-10 17744544 67.53 ns/op 18 B/op 2 allocs/op
+BenchmarkLRUHotHit-10 41497600 28.59 ns/op 0 B/op 0 allocs/op
+BenchmarkSingleHotHit-10 80741475 14.74 ns/op 0 B/op 0 allocs/op
+BenchmarkShardedHotHit-10 44206764 28.13 ns/op 0 B/op 0 allocs/op
+BenchmarkParallelHotHit-10 7884925 150.9 ns/op 0 B/op 0 allocs/op
+BenchmarkParallelSingleHotHit-10 493886186 2.275 ns/op 0 B/op 0 allocs/op
+BenchmarkParallelShardedHotHit-10 8078821 150.0 ns/op 0 B/op 0 allocs/op
+BenchmarkMixedWorkload-10 6738607 186.3 ns/op 0 B/op 0 allocs/op
+BenchmarkEvictionPressure-10 3514544 331.0 ns/op 0 B/op 0 allocs/op
+BenchmarkGetOrComputeStampede-10 702915 1747 ns/op 4 B/op 0 allocs/op
+BenchmarkGetOrComputeStaleStampede-10 3021088 399.8 ns/op 2 B/op 0 allocs/op
+PASS
+ok github.com/agkloop/go_memoize/benchmarks 29.210s
+```
+
+## Latest Benchmark Results
+
+The latest local run shows allocation-free fresh hits for the built-in bounded, single-value, and sharded memory stores. Representative fresh-hit results were `BenchmarkMemoryHotHit` at `29.80 ns/op`, `BenchmarkLRUHotHit` at `28.59 ns/op`, `BenchmarkSingleHotHit` at `14.74 ns/op`, and `BenchmarkShardedHotHit` at `28.13 ns/op`, all with `0 B/op` and `0 allocs/op`.
+
+The single-value store is the fastest result for the one-logical-value workload: `BenchmarkParallelSingleHotHit` measured `2.275 ns/op`, compared with `150.9 ns/op` for the regular memory store and `150.0 ns/op` for the sharded store under the one-hot-key parallel benchmark. This matches the design expectation: sharding helps distributed keys, not one hot key.
+
+Direct memoization benchmarks show the bounded LRU-backed variants avoiding allocations on repeated hits, while the default direct map-backed benchmark path records one allocation in these microbenchmarks.
+
+## Reading The Numbers
+
+Read these numbers as workload-specific signals. They compare implementation choices on this machine under the benchmark shapes in `./benchmarks`, not universal latency guarantees.
+
+The relative shape matters more than any one number: `memory.NewSingle` is best for one logical value, `memory.NewSharded` is for distributed-key concurrency, and `memory.New` is the default exact-LRU choice for many bounded keys. If your workload has larger values, remote stores, serialization, expensive compute functions, different contention patterns, or different TTL behavior, your bottleneck may move.
+
+## When To Benchmark Your Workload
+
+Benchmark your workload when changing key shape, store type, TTL/stale policy, concurrency level, value size, serialization, or compute cost. Also benchmark when choosing between `memory.New`, `memory.NewSingle`, and `memory.NewSharded`; their tradeoffs depend on whether your application has many keys, one logical value, distributed contention, or a single hot key.
+
+Use the package benchmarks as a starting point, then add a benchmark that resembles your production access pattern before optimizing further.
diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md
new file mode 100644
index 0000000..77e032c
--- /dev/null
+++ b/docs/PRODUCTION.md
@@ -0,0 +1,336 @@
+# Production Guide
+
+This guide describes production choices for the root module `github.com/agkloop/go_memoize`. Public examples should import the root package and root subpackages only.
+
+## Production Defaults And Required Choices
+
+`go_memoize` keeps defaults minimal so production code makes cache topology and freshness explicit.
+
+| Area | Behavior | Production choice |
+|---|---|---|
+| Explicit `memoize.New[K,V]` store | No default store. | Pass `memoize.Opts().WithStore(...)`, or intentionally use `Bypass()`. |
+| Explicit `memoize.New[K,V]` freshness | No default expiration policy. | Choose `WithTTL`, `NoExpiration`, or `Bypass`; missing policy returns `ErrMissingExpirationPolicy`. |
+| Direct memoizer store | Internal unbounded `Store[uint64,V]` when `WithStore` is omitted. | Use only when unbounded in-process key growth is acceptable. Pass `WithStore(memory.New[uint64,V](capacity))` or `WithStore(chain.New[uint64,V](...))` for bounded or tiered direct memoizers. |
+| Direct memoizer freshness | No default expiration policy. | Choose `WithTTL`, `NoExpiration`, or `Bypass`. |
+| Metrics | Disabled/noop. | Pass `WithMetrics` where hit rate, stale behavior, refresh errors, or writes need observability. |
+| Refresh timeout | 30 seconds. | Use `WithRefreshTimeout` when stale refresh must respect a stricter dependency SLO. |
+| Clock lifecycle | Default ticker-backed clock. | Call `cache.Stop()` for explicit caches during shutdown. |
+
+## Which API Should I Use?
+
+Use direct memoizers when the function arguments are the cache key and an in-process function wrapper is enough:
+
+```go
+loadProfile, err := memoize.MemoizeCtx1E(
+ repo.LoadProfile,
+ memoize.Opts().WithTTL(time.Minute),
+)
+```
+
+Use explicit caches when the service owns domain cache keys, needs `Get`, `Set`, `Delete`, multi-tier stores, shared stores, or request-path `GetOrCompute`:
+
+```go
+cache, err := memoize.New[string, Profile](
+ memoize.Opts().
+ WithStore(memory.New[string, Profile](50_000)).
+ WithTTL(time.Minute),
+)
+```
+
+Use `background.Keep`, `background.Mirror`, or `loader.New` when there is one logical snapshot value and request handlers should not be responsible for refreshing it.
+
+## Store Selection
+
+| Store or helper | Best fit | Production notes |
+|---|---|---|
+| `memory.New[K,V](capacity)` | Many bounded in-memory keys. | Default in-process LRU choice. Use `K=string` for explicit business keys and `K=uint64` for direct memoizer backing stores. |
+| `memory.NewSharded[K,V](capacity)` | Many distributed hot keys under concurrent access. | Improves distributed-key concurrency; one hot key still maps to one shard. |
+| `memory.NewSingle[K,V]()` | One logical key inside the cache engine. | Avoids LRU/hash overhead for one-value workloads. |
+| `chain.New[K,V](tiers...)` | Multi-tier caches. | Put the fastest tier first; lower-tier hits backfill earlier tiers. |
+| `local.New[V](dir)` | Local restart persistence. | File-backed `Store[string,V]`; values must be Gob-encodable. Not for cross-host sharing. |
+| Redis adapter | Shared multi-process or multi-host cache. | Separate module under `adapters/redis`; use prefixes and serializers deliberately. |
+| Custom SQL store | Durable shared cache with queryable backend. | Implement `memoize.Store[K,V]` and store the full `memoize.Stored[V]` envelope. |
+| S3/object store | Durable object-backed L2 or snapshot storage. | Usually too slow for hot L1; use behind memory with `chain.New`. |
+| `background.Keep` | One periodically refreshed in-process snapshot. | Blocks until first load succeeds, then refreshes in the background and keeps last good value on refresh errors. |
+| `background.Mirror` | Local in-process mirror of one shared `Store[string,V]` key. | Initial remote read must succeed; request handlers call `Value.Get()` for atomic local reads, while the mirror goroutine polls the shared store on the interval. |
+| `loader.New` | Readiness-gated periodic value. | Use when callers should block until first successful load through `Value(ctx)`. |
+
+## Freshness And Stale Behavior
+
+Use `WithTTL` for the fresh window. Add `WithStaleTTL` when stale reads are acceptable and recompute should happen asynchronously after the fresh window.
+
+```go
+cache, err := memoize.New[string, Product](
+ memoize.Opts().
+ WithStore(memory.New[string, Product](100_000)).
+ WithTTL(15*time.Second).
+ WithStaleTTL(5*time.Minute).
+ WithRefreshTimeout(3*time.Second).
+ KeepStaleOnError(),
+)
+```
+
+Fresh hits return immediately. Stale hits return the old value immediately and start a refresh. Expired misses block on recompute. `KeepStaleOnError` keeps the last stored value serving through refresh failures where the cache entry still exists.
+
+Use `NoExpiration` only for values whose lifetime is controlled by explicit writes, deletes, process lifetime, or background replacement.
+
+## Request-Path Caching
+
+Request handlers should pass request contexts to `GetOrCompute`; the compute function receives the same context.
+
+```go
+type UserRepo interface {
+ LoadUser(context.Context, int64) (User, error)
+}
+
+func NewUserCache() (*memoize.Cache[string, User], error) {
+ return memoize.New[string, User](
+ memoize.Opts().
+ WithStore(memory.New[string, User](50_000,
+ memory.WithMaxBytes[string, User](128<<20),
+ )).
+ WithTTL(30*time.Second).
+ WithStaleTTL(2*time.Minute).
+ KeepStaleOnError(),
+ )
+}
+
+func GetUser(ctx context.Context, cache *memoize.Cache[string, User], repo UserRepo, id int64) (User, error) {
+ key := fmt.Sprintf("user:%d", id)
+ return cache.GetOrCompute(ctx, key, func(ctx context.Context) (User, error) {
+ return repo.LoadUser(ctx, id)
+ })
+}
+```
+
+Use explicit string keys that include tenant, account, locale, or authorization scope when those dimensions affect the result.
+
+## Direct Memoizer Production Patterns
+
+Direct memoizers hash arguments to `uint64` keys. They are convenient for repository or client methods where the function signature already defines the cache key.
+
+```go
+m := metrics.NewInMemoryMetrics()
+
+loadProfile, err := memoize.MemoizeCtx1E(
+ repo.LoadProfile,
+ memoize.Opts().
+ WithTTL(time.Minute).
+ WithStaleTTL(5*time.Minute).
+ KeepStaleOnError().
+ WithMetrics(m),
+)
+```
+
+The default internal unbounded direct store is appropriate only when unbounded in-process key growth is acceptable. For production services with unknown cardinality, pass a bounded direct store:
+
+```go
+loadProfile, err := memoize.MemoizeCtx1E(
+ repo.LoadProfile,
+ memoize.Opts().
+ WithStore(memory.New[uint64, Profile](50_000)).
+ WithTTL(time.Minute).
+ WithStaleTTL(5*time.Minute).
+ KeepStaleOnError(),
+)
+```
+
+For tiered direct memoizers, every tier must use `uint64` keys:
+
+```go
+store := chain.New[uint64, Profile](
+ memory.New[uint64, Profile](10_000),
+ directL2Store,
+)
+
+loadProfile, err := memoize.MemoizeCtx1E(
+ repo.LoadProfile,
+ memoize.Opts().WithStore(store).WithTTL(time.Minute),
+)
+```
+
+Prefer explicit `memoize.New[string,V]` when cache keys need to be stable across languages, visible to operators, shared across services, or manually invalidated.
+
+## Multi-Tier Caching
+
+Use `chain.New` to combine fast in-process reads with a slower durable or shared tier.
+
+```go
+l1 := memory.New[string, Report](10_000)
+l2 := local.New[Report]("/var/cache/myapp/reports")
+
+cache, err := memoize.New[string, Report](
+ memoize.Opts().
+ WithStore(chain.New[string, Report](l1, l2)).
+ WithTTL(10*time.Minute),
+)
+```
+
+Put the fastest tier first. Use local files for restart persistence on one host, Redis for cross-process sharing, and object stores as durable lower tiers when their latency is acceptable.
+
+## Redis
+
+The Redis adapter is a separate module under `adapters/redis`. Test it from that module when adapter code or examples change.
+
+```go
+client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
+
+redisStore, err := redisstore.New[string, User](
+ redisstore.WithClient[string, User](client),
+ redisstore.WithPrefix[string, User]("users"),
+ redisstore.WithSerializer[string, User](serializers.JSON[User]{}),
+)
+if err != nil {
+ return err
+}
+
+cache, err := memoize.New[string, User](
+ memoize.Opts().WithStore(redisStore).WithTTL(time.Minute),
+)
+```
+
+Use a prefix per service or domain to avoid key collisions. Choose JSON for debuggability, Gob for Go-only payloads, or `serializers.Func` for custom formats. Redis backend TTL is cleanup through the stale deadline; public freshness still comes from the `memoize.Stored[V]` envelope interpreted by the cache engine.
+
+For direct memoizer stores backed by Redis, use `redisstore.New[uint64,V]` because direct memoizers store hashed argument keys.
+
+## Single Writer, Many Reader Distributed Snapshots
+
+For full snapshots such as e-commerce category trees, avoid making every API replica load the same snapshot from MySQL and avoid making every request hit Redis. Run one cache-refresher writer and many API readers that mirror the shared value into local atomic memory.
+
+```text
+cache-refresher (1 replica)
+ MySQL -> background.Keep -> background.WriteThrough -> Redis/shared store
+
+api-service (many replicas)
+ Redis/shared store -> background.Mirror -> local atomic value -> HTTP responses
+```
+
+The refresher owns the database load and publishes each successful refresh:
+
+```go
+categories, err := background.Keep(ctx, loadCategoriesFromMySQL, time.Minute,
+ background.WriteThrough[CategorySnapshot]("categories:v1", redisStore),
+ background.OnError[CategorySnapshot](func(err error) {
+ log.Printf("category refresh failed: %v", err)
+ }),
+)
+```
+
+Each API pod mirrors Redis into a local atomic value and serves requests from memory:
+
+```go
+categories, err := background.Mirror(ctx, "categories:v1", redisStore, 5*time.Second,
+ background.OnError[CategorySnapshot](func(err error) {
+ log.Printf("category mirror failed: %v", err)
+ }),
+)
+
+func handler(w http.ResponseWriter, r *http.Request) {
+ snapshot := categories.Get()
+ _ = snapshot
+}
+```
+
+Use `background.Keep` plus `WriteThrough` plus `Mirror` for full snapshots where the last known good value should remain until it is replaced. The stored value is written with no-expiration metadata and replacement is controlled by the refresher loop.
+
+`background.MustMirror` loads the shared store value into each API pod's process memory during startup. After startup, `categories.Get()` is only an atomic local memory read of the last successful mirror refresh; it does not call Redis, MySQL, or any other remote dependency on the request path, does not block, and does not return an error. Keep mirrored snapshots immutable or copy mutable fields before editing them.
+
+Use `memoize.Cache.Set` plus `Cache.Get` for normal cache entries that should carry `WithTTL` and `WithStaleTTL` metadata. This is better for independently keyed data where freshness windows and cache misses matter per key.
+
+The refresher deployment must enforce one writer with Kubernetes replicas, leader election, or job semantics. `go_memoize` does not provide distributed leader election.
+
+## Custom Stores Including S3
+
+Implement a custom store when built-in memory, chain, local, and Redis stores do not match your persistence or topology needs. A custom store implements `memoize.Store[K,V]` and stores raw `memoize.Stored[V]` envelopes:
+
+```go
+type Store[K comparable, V any] interface {
+ Get(context.Context, K) (memoize.Stored[V], bool, error)
+ Set(context.Context, K, memoize.Stored[V]) error
+ Delete(context.Context, K) error
+ Clear(context.Context) error
+}
+```
+
+Rules:
+
+- Return stale entries as stored; the cache engine decides whether they are fresh, stale, or expired.
+- Use `K=string` for business keys and `K=uint64` for direct memoizer backing stores.
+- Keep backend cleanup TTL separate from public cache freshness.
+- Make `Clear` safe for the store scope; use prefixes or namespaces for shared backends.
+
+SQL stores should encode the full `Stored[V]` envelope, not only the value, so freshness metadata survives process restarts and cross-process reads.
+
+S3 or any object store can be a custom store. Store the encoded `memoize.Stored[V]` envelope as the object body and map the typed cache key to an object key. S3 is usually a durable L2, not a hot L1, because latency and request cost are higher than memory or Redis:
+
+```go
+store := chain.New[string, User](
+ memory.New[string, User](10_000),
+ s3UserStore, // memoize.Store[string, User]
+)
+
+cache, err := memoize.New[string, User](
+ memoize.Opts().
+ WithStore(store).
+ WithTTL(time.Minute).
+ WithStaleTTL(10*time.Minute),
+)
+```
+
+For direct memoizers, an object-store tier must be `memoize.Store[uint64,V]`. Encode the `uint64` hash as a decimal string or another stable object key format. Use object lifecycle policies only for backend cleanup; do not use them as the cache freshness policy.
+
+## Serializers
+
+External stores that encode values use `memoize.Serializer[V]`:
+
+```go
+type Serializer[V any] interface {
+ Marshal(V) ([]byte, error)
+ Unmarshal([]byte) (V, error)
+}
+```
+
+Use `serializers.JSON[V]` for debuggable payloads, `serializers.Gob[V]` for Go-only payloads, or `serializers.Func[V]` for custom formats such as protobuf, msgpack, compression, or encryption. Serializer implementations should be deterministic enough for operational debugging and should treat decode failures as data corruption or cache misses according to the adapter semantics.
+
+## Metrics And Cardinality
+
+```go
+type Metrics struct{}
+
+func (Metrics) RecordMetric(event memoize.MetricEvent) {
+ switch event.Kind {
+ case memoize.MetricHit:
+ recordCounter("cache_hit")
+ case memoize.MetricMiss:
+ recordCounter("cache_miss")
+ case memoize.MetricStaleHit:
+ recordCounter("cache_stale_hit")
+ case memoize.MetricRefreshSuccess:
+ recordDuration("cache_refresh", event.Duration)
+ case memoize.MetricRefreshError:
+ recordError("cache_refresh", event.Err)
+ }
+}
+```
+
+`MetricEvent.Key` is usually the exact cache entry key. Do not export it as a production metric label unless the keyspace is intentionally bounded; user IDs, URLs, query strings, tenant IDs, and direct memoizer hash keys can create high-cardinality metrics. Prefer labels for cache name, operation, result, store tier, and service.
+
+Track hit rate, stale hits, refresh errors, refresh latency, set/delete rates, and backend errors. `metrics.NewInMemoryMetrics()` is useful for tests and local diagnostics, not as a production metrics backend.
+
+## Shutdown
+
+- Call `cache.Stop()` for explicit caches using the default ticker clock.
+- Cancel the context passed to `background.Keep` or `background.Mirror` to stop refresh loops.
+- Call `loader.Stop()` to stop loader goroutines.
+- Pass request contexts into `GetOrCompute`; compute functions receive the same context.
+- Treat values returned by `background.Value.Get()` as shared memory. Use immutable structs, copied maps, or read-only conventions.
+
+## Testing And Release Checklist
+
+- Run root tests: `go test ./... -count=1`.
+- Run root race tests: `go test ./... -race -count=1`.
+- If Redis adapter code or examples changed, test from `adapters/redis`: `go test ./... -count=1` and `go test ./... -race -count=1`.
+- Run example tests when examples or docs examples change.
+- Confirm public docs and examples use only root-module import paths.
+- Confirm explicit `memoize.New[K,V]` examples choose both a store and an expiration policy.
+- Confirm direct memoizer examples choose `WithTTL`, `NoExpiration`, or `Bypass`, and use bounded or tiered `Store[uint64,V]` when unbounded in-process key growth is not acceptable.
diff --git a/docs/RECIPES.md b/docs/RECIPES.md
new file mode 100644
index 0000000..c626204
--- /dev/null
+++ b/docs/RECIPES.md
@@ -0,0 +1,489 @@
+# Recipes
+
+Copy-paste starting points for common `github.com/agkloop/go_memoize` setups. Direct memoizers hash comparable arguments to `uint64`; explicit caches usually use business keys such as strings.
+
+## Direct TTL Memoizer
+
+Use a direct memoizer when the function arguments are the cache key.
+
+```go
+package profiles
+
+import (
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+)
+
+func NewCachedProfileLoader(loadProfile func(int64) Profile) (func(int64) Profile, error) {
+ return memoize.Memoize1(loadProfile, memoize.Opts().WithTTL(time.Minute))
+}
+
+profile := cached(42)
+```
+
+## Direct Context/Error Memoizer
+
+Use the `Ctx` and `E` variants for context-aware functions that can fail. Errors are not cached.
+
+```go
+cached, err := memoize.MemoizeCtx1E(loadProfile, memoize.Opts().WithTTL(30*time.Second))
+if err != nil {
+ return err
+}
+
+profile, err := cached(ctx, 42)
+if err != nil {
+ return err
+}
+```
+
+## Direct Stale-On-Error Memoizer
+
+Use stale-on-error when a stale value is better than failing the request. This uses the default internal direct memoizer store keyed by hashed args as `uint64`.
+
+```go
+cached, err := memoize.MemoizeCtx1E(loadProfile,
+ memoize.Opts().
+ WithTTL(time.Minute).
+ WithStaleTTL(5*time.Minute).
+ KeepStaleOnError(),
+)
+if err != nil {
+ return err
+}
+
+profile, err := cached(ctx, 42)
+if err != nil {
+ return err
+}
+```
+
+## Direct Memoizer With Custom Store
+
+Direct memoizers accept a `memoize.Store[uint64,V]` because direct keys are hashed arguments.
+
+```go
+store := memory.New[uint64, Profile](10_000)
+
+cached, err := memoize.MemoizeCtx1E(loadProfile,
+ memoize.Opts().WithStore(store).WithTTL(time.Minute),
+)
+if err != nil {
+ return err
+}
+
+profile, err := cached(ctx, 42)
+```
+
+The store must implement the root interface:
+
+```go
+type Store[K comparable, V any] interface {
+ Get(ctx context.Context, key K) (memoize.Stored[V], bool, error)
+ Set(ctx context.Context, key K, value memoize.Stored[V]) error
+ Delete(ctx context.Context, key K) error
+ Clear(ctx context.Context) error
+}
+```
+
+## Explicit Cache With String Keys
+
+Use an explicit cache when the key is already part of your domain.
+
+```go
+store := memory.New[string, Profile](10_000)
+
+cache, err := memoize.New[string, Profile](
+ memoize.Opts().WithStore(store).WithTTL(time.Minute),
+)
+if err != nil {
+ return err
+}
+defer cache.Stop()
+
+profile, err := cache.GetOrCompute(ctx, "profile:42", func(ctx context.Context) (Profile, error) {
+ return repo.LoadProfile(ctx, 42)
+})
+```
+
+## Explicit Stale-While-Revalidate Cache
+
+`WithStaleTTL` returns stale values while one goroutine refreshes the key in the background.
+
+```go
+cache, err := memoize.New[string, Profile](
+ memoize.Opts().
+ WithStore(memory.New[string, Profile](10_000)).
+ WithTTL(time.Minute).
+ WithStaleTTL(5*time.Minute).
+ KeepStaleOnError(),
+)
+if err != nil {
+ return err
+}
+defer cache.Stop()
+
+profile, err := cache.GetOrCompute(ctx, "profile:42", func(ctx context.Context) (Profile, error) {
+ return repo.LoadProfile(ctx, 42)
+})
+```
+
+## Two-Tier Memory And Local Cache
+
+Chain a fast per-process memory L1 over a local file L2 for warm restarts on one machine.
+
+```go
+l1 := memory.New[string, Profile](10_000)
+l2 := local.New[Profile]("/var/cache/myapp/profiles")
+store := chain.New[string, Profile](l1, l2)
+
+cache, err := memoize.New[string, Profile](
+ memoize.Opts().WithStore(store).WithTTL(5*time.Minute),
+)
+if err != nil {
+ return err
+}
+defer cache.Stop()
+```
+
+## Two-Tier Memory And Redis Cache
+
+Chain a per-process memory L1 over Redis when multiple processes need a shared L2.
+
+```go
+redisStore, err := redisstore.New[string, Profile](
+ redisstore.WithClient[string, Profile](redisClient),
+ redisstore.WithPrefix[string, Profile]("profiles"),
+ redisstore.WithSerializer[string, Profile](serializers.JSON[Profile]{}),
+)
+if err != nil {
+ return err
+}
+
+store := chain.New[string, Profile](
+ memory.New[string, Profile](10_000),
+ redisStore,
+)
+
+cache, err := memoize.New[string, Profile](
+ memoize.Opts().WithStore(store).WithTTL(time.Minute).WithStaleTTL(5*time.Minute),
+)
+if err != nil {
+ return err
+}
+defer cache.Stop()
+```
+
+## Single Writer, Many Reader Category Snapshot
+
+Kubernetes pattern: one cache-refresher pod reads MySQL and many API pods read a shared store. `memory.New`, `memory.NewSharded`, and `memory.NewSingle` are per-process only; they do not share values between pods. The shared store must be Redis, SQL, S3/object storage, or another distributed store.
+
+Writer pod:
+
+```go
+categories, err := background.Keep(ctx,
+ func(ctx context.Context) (CategorySnapshot, error) {
+ return mysqlRepo.LoadAllCategories(ctx)
+ },
+ 30*time.Second,
+ background.WriteThrough("categories:all", redisStore),
+ background.OnError(func(err error) {
+ logger.Error("category refresh failed", "err", err)
+ }),
+)
+if err != nil {
+ return err
+}
+
+_ = categories.Get()
+```
+
+API reader pods:
+
+```go
+categories := background.MustMirror(ctx,
+ "categories:all",
+ redisStore,
+ 5*time.Second,
+ background.OnError(func(err error) {
+ logger.Error("category mirror failed", "err", err)
+ }),
+)
+
+func handleCategories(w http.ResponseWriter, r *http.Request) {
+ snapshot := categories.Get()
+ writeJSON(w, snapshot)
+}
+```
+
+`background.MustMirror` performs the first remote read during startup and panics if the shared key is missing. Once startup succeeds, every API pod holds its own local in-memory copy. `categories.Get()` is an atomic local memory read of that copy; it does not call Redis, does not hit MySQL, does not block, and does not return an error. The mirror goroutine is the only code that polls the shared store every `5*time.Second`.
+
+Treat mirrored values as immutable shared memory. If `CategorySnapshot` contains maps, slices, or pointers and a handler needs to mutate them, copy before mutating.
+
+`WriteThrough` stores `memoize.Stored[V]{NoExpire: true}`, so the last good snapshot remains until overwritten or deleted. Use this for category trees, config snapshots, and other read-mostly data where last known good is acceptable.
+
+Stricter freshness variant: when the shared store must carry `WithTTL` or `WithStaleTTL` metadata instead of `NoExpire`, write through an explicit cache in the writer and read through the same cache policy in every API replica.
+
+```go
+writerCache, err := memoize.New[string, CategorySnapshot](
+ memoize.Opts().WithStore(redisStore).WithTTL(time.Minute).WithStaleTTL(10*time.Minute),
+)
+if err != nil {
+ return err
+}
+defer writerCache.Stop()
+
+_, err = background.Keep(ctx,
+ func(ctx context.Context) (CategorySnapshot, error) {
+ snapshot, err := mysqlRepo.LoadAllCategories(ctx)
+ if err != nil {
+ return CategorySnapshot{}, err
+ }
+ return snapshot, writerCache.Set(ctx, "categories:all", snapshot)
+ },
+ 30*time.Second,
+ background.OnError(func(err error) {
+ logger.Error("category refresh failed", "err", err)
+ }),
+)
+```
+
+```go
+readerCache, err := memoize.New[string, CategorySnapshot](
+ memoize.Opts().WithStore(redisStore).WithTTL(time.Minute).WithStaleTTL(10*time.Minute),
+)
+if err != nil {
+ return err
+}
+defer readerCache.Stop()
+
+func handleCategories(w http.ResponseWriter, r *http.Request) {
+ snapshot, ok, err := readerCache.Get(r.Context(), "categories:all")
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.Error(w, "categories unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ writeJSON(w, snapshot)
+}
+```
+
+## One-Value Config Snapshot With background.Keep
+
+Use `background.Keep` for one logical value that should refresh outside request paths.
+
+```go
+config, err := background.Keep(ctx,
+ func(ctx context.Context) (AppConfig, error) {
+ return configAPI.Load(ctx)
+ },
+ time.Minute,
+ background.OnError(func(err error) {
+ logger.Warn("config refresh failed", "err", err)
+ }),
+)
+if err != nil {
+ return err
+}
+
+func handler(w http.ResponseWriter, r *http.Request) {
+ cfg := config.Get()
+ _ = cfg
+}
+```
+
+## Readiness-Gated Loader
+
+Use `loader.Loader` when startup readiness must wait for the first successful load.
+
+```go
+categories := loader.New(
+ func(ctx context.Context) (CategorySnapshot, error) {
+ return repo.LoadAllCategories(ctx)
+ },
+ 30*time.Second,
+ loader.WithOnError(func(err error) {
+ logger.Error("category load failed", "err", err)
+ }),
+)
+defer categories.Stop()
+
+func readiness(w http.ResponseWriter, r *http.Request) {
+ if _, err := categories.Value(r.Context()); err != nil {
+ http.Error(w, "not ready", http.StatusServiceUnavailable)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+```
+
+## No Expiration Cache
+
+Use `NoExpiration` for values invalidated manually.
+
+```go
+cache, err := memoize.New[string, FeatureFlags](
+ memoize.Opts().WithStore(memory.New[string, FeatureFlags](100)).NoExpiration(),
+)
+if err != nil {
+ return err
+}
+defer cache.Stop()
+
+if err := cache.Set(ctx, "tenant:acme", flags); err != nil {
+ return err
+}
+
+flags, ok, err := cache.Get(ctx, "tenant:acme")
+```
+
+## Bypass Mode
+
+Use `Bypass` to disable storage without changing call sites.
+
+```go
+cache, err := memoize.New[string, Profile](memoize.Opts().Bypass())
+if err != nil {
+ return err
+}
+defer cache.Stop()
+
+profile, err := cache.GetOrCompute(ctx, "profile:42", func(ctx context.Context) (Profile, error) {
+ return repo.LoadProfile(ctx, 42)
+})
+```
+
+## Custom Store
+
+Implement `memoize.Store[K,V]` when you need a storage backend not provided by the package.
+
+```go
+type Store[K comparable, V any] interface {
+ Get(ctx context.Context, key K) (memoize.Stored[V], bool, error)
+ Set(ctx context.Context, key K, value memoize.Stored[V]) error
+ Delete(ctx context.Context, key K) error
+ Clear(ctx context.Context) error
+}
+```
+
+Minimal wrapper shape:
+
+```go
+type SQLStore[V any] struct {
+ db *sql.DB
+}
+
+func (s *SQLStore[V]) Get(ctx context.Context, key string) (memoize.Stored[V], bool, error) {
+ // Load and decode the full memoize.Stored[V] envelope.
+ return memoize.Stored[V]{}, false, nil
+}
+
+func (s *SQLStore[V]) Set(ctx context.Context, key string, value memoize.Stored[V]) error {
+ // Encode and persist value, including FreshUntil, StaleUntil, and NoExpire.
+ return nil
+}
+
+func (s *SQLStore[V]) Delete(ctx context.Context, key string) error { return nil }
+func (s *SQLStore[V]) Clear(ctx context.Context) error { return nil }
+```
+
+## S3/Object Store As Durable L2
+
+Use an object store as a durable L2 behind memory when latency is acceptable and persistence matters more than hot-path speed.
+
+```go
+type ObjectStore[V any] struct {
+ bucket string
+ codec memoize.Serializer[memoize.Stored[V]]
+}
+
+func (s *ObjectStore[V]) Get(ctx context.Context, key string) (memoize.Stored[V], bool, error) {
+ data, err := getObject(ctx, s.bucket, key)
+ if errors.Is(err, ErrNotFound) {
+ return memoize.Stored[V]{}, false, nil
+ }
+ if err != nil {
+ return memoize.Stored[V]{}, false, err
+ }
+ entry, err := s.codec.Unmarshal(data)
+ return entry, err == nil, err
+}
+
+func (s *ObjectStore[V]) Set(ctx context.Context, key string, value memoize.Stored[V]) error {
+ data, err := s.codec.Marshal(value)
+ if err != nil {
+ return err
+ }
+ return putObject(ctx, s.bucket, key, data)
+}
+
+func (s *ObjectStore[V]) Delete(ctx context.Context, key string) error { return deleteObject(ctx, s.bucket, key) }
+func (s *ObjectStore[V]) Clear(ctx context.Context) error { return nil }
+
+store := chain.New[string, Profile](
+ memory.New[string, Profile](10_000),
+ &ObjectStore[Profile]{bucket: "profile-cache", codec: serializers.JSON[memoize.Stored[Profile]]{}},
+)
+```
+
+## Custom Serializer
+
+Use `serializers.Func[V]` to adapt an existing codec.
+
+```go
+type User struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+}
+
+serializer := serializers.Func[User]{
+ MarshalFunc: func(user User) ([]byte, error) {
+ return json.Marshal(user)
+ },
+ UnmarshalFunc: func(data []byte) (User, error) {
+ var user User
+ if err := json.Unmarshal(data, &user); err != nil {
+ return User{}, err
+ }
+ return user, nil
+ },
+}
+
+redisStore, err := redisstore.New[string, User](
+ redisstore.WithClient[string, User](redisClient),
+ redisstore.WithSerializer[string, User](serializer),
+)
+```
+
+## Metrics Exporter
+
+Implement `memoize.Metrics` to export cache events to your metrics system.
+
+```go
+type PromMetrics struct {
+ hits *prometheus.CounterVec
+ misses *prometheus.CounterVec
+}
+
+func (m *PromMetrics) RecordMetric(event memoize.MetricEvent) {
+ switch event.Kind {
+ case memoize.MetricHit:
+ m.hits.WithLabelValues(event.Key).Inc()
+ case memoize.MetricMiss:
+ m.misses.WithLabelValues(event.Key).Inc()
+ case memoize.MetricRefreshError:
+ logger.Error("cache refresh failed", "key", event.Key, "err", event.Err)
+ }
+}
+
+cache, err := memoize.New[string, Profile](
+ memoize.Opts().
+ WithStore(memory.New[string, Profile](10_000)).
+ WithTTL(time.Minute).
+ WithMetrics(promMetrics),
+)
+```
diff --git a/errors.go b/errors.go
new file mode 100644
index 0000000..c5680ef
--- /dev/null
+++ b/errors.go
@@ -0,0 +1,11 @@
+package memoize
+
+import "errors"
+
+var (
+ ErrMissingExpirationPolicy = errors.New("memoize: missing expiration policy")
+ ErrInvalidTTL = errors.New("memoize: ttl must be greater than zero")
+ ErrInvalidStaleTTL = errors.New("memoize: stale ttl requires a positive ttl")
+ ErrMissingStore = errors.New("memoize: missing store")
+ ErrInvalidStore = errors.New("memoize: store does not match cache key/value types")
+)
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..11431c1
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,28 @@
+# Production Examples
+
+These examples are working, tested packages that show production-shaped service types, constructor validation, cache shutdown, context handling, and store selection. They are not toy `main` programs.
+
+The examples use Beeceptor's public sample API shapes for realistic data:
+
+- Users: `https://fake-json-api.mock.beeceptor.com/users`
+- Companies: `https://fake-json-api.mock.beeceptor.com/companies`
+
+| Directory | Use Case |
+|---|---|
+| `http_user_cache` | Explicit string-key HTTP/service cache with bounded memory, stale refresh, metrics, and shutdown. |
+| `direct_profile_cache` | Direct function memoization with the default direct store for a context-aware repository method. |
+| `direct_stale_profile_cache` | Direct function memoization with stale-while-revalidate and stale-on-error for a context-aware repository method. |
+| `config_snapshot` | One scheduled configuration snapshot with local atomic reads and refresh error hooks. |
+
+Redis examples live under `adapters/redis/examples` because the Redis adapter is a separate Go module.
+
+Run each root example on its own:
+
+```sh
+go test ./examples/http_user_cache -count=1
+go test ./examples/direct_profile_cache -count=1
+go test ./examples/direct_stale_profile_cache -count=1
+go test ./examples/config_snapshot -count=1
+```
+
+The tests use local `httptest` servers with Beeceptor-compatible payloads so they verify caching behavior without depending on network availability.
diff --git a/examples/config_snapshot/README.md b/examples/config_snapshot/README.md
new file mode 100644
index 0000000..73777a6
--- /dev/null
+++ b/examples/config_snapshot/README.md
@@ -0,0 +1,17 @@
+# Config Snapshot
+
+Working one-value snapshot example for configuration derived from Beeceptor's sample company API shape: `GET https://fake-json-api.mock.beeceptor.com/companies`.
+
+- Uses `background.Keep`, not an LRU cache, because every request reads the same logical value.
+- Blocks startup until the first successful load.
+- Keeps serving the last successful value when later refreshes fail.
+- Copies maps on load and read so callers do not mutate shared memory returned by `background.Value.Get()`.
+- Keeps the `background.Keep` package usage in `config_service.go`.
+- Keeps Beeceptor HTTP and JSON parsing in `beeceptor_source.go`.
+- Exposes `Close()` to cancel the background refresh loop during shutdown.
+
+Test it directly:
+
+```sh
+go test ./examples/config_snapshot -count=1
+```
diff --git a/examples/config_snapshot/beeceptor_source.go b/examples/config_snapshot/beeceptor_source.go
new file mode 100644
index 0000000..5e97394
--- /dev/null
+++ b/examples/config_snapshot/beeceptor_source.go
@@ -0,0 +1,96 @@
+package configsnapshot
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const BeeceptorCompaniesURL = "https://fake-json-api.mock.beeceptor.com/companies"
+
+var ErrMissingBaseURL = errors.New("configsnapshot: missing base URL")
+
+type BeeceptorConfigSourceConfig struct {
+ BaseURL string
+ HTTPClient HTTPClient
+ RequestTimeout time.Duration
+}
+
+type BeeceptorConfigSource struct {
+ baseURL *url.URL
+ client HTTPClient
+ requestTimeout time.Duration
+}
+
+func NewBeeceptorConfigSource(cfg BeeceptorConfigSourceConfig) (*BeeceptorConfigSource, error) {
+ baseURL, err := parseBaseURL(defaultString(cfg.BaseURL, "https://fake-json-api.mock.beeceptor.com"))
+ if err != nil {
+ return nil, err
+ }
+ client := cfg.HTTPClient
+ if client == nil {
+ client = http.DefaultClient
+ }
+ return &BeeceptorConfigSource{baseURL: baseURL, client: client, requestTimeout: defaultDuration(cfg.RequestTimeout, defaultRequestTimeout)}, nil
+}
+
+func (s *BeeceptorConfigSource) LoadConfig(ctx context.Context) (AppConfig, error) {
+ ctx, cancel := context.WithTimeout(ctx, s.requestTimeout)
+ defer cancel()
+
+ endpoint := s.baseURL.JoinPath("companies")
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+ if err != nil {
+ return AppConfig{}, err
+ }
+ req.Header.Set("Accept", "application/json")
+ res, err := s.client.Do(req)
+ if err != nil {
+ return AppConfig{}, err
+ }
+ defer func() { _ = res.Body.Close() }()
+ if res.StatusCode != http.StatusOK {
+ return AppConfig{}, fmt.Errorf("configsnapshot: Beeceptor companies status %d", res.StatusCode)
+ }
+
+ var companies []struct {
+ Name string `json:"name"`
+ Industry string `json:"industry"`
+ }
+ if err := json.NewDecoder(res.Body).Decode(&companies); err != nil {
+ return AppConfig{}, err
+ }
+ flags := map[string]bool{"companies_loaded": len(companies) > 0}
+ for _, company := range companies {
+ if company.Industry != "" {
+ flags["industry:"+company.Industry] = true
+ }
+ }
+ return AppConfig{Version: "beeceptor-companies", FeatureFlags: flags, UpdatedAt: time.Now().UTC()}, nil
+}
+
+func parseBaseURL(raw string) (*url.URL, error) {
+ if strings.TrimSpace(raw) == "" {
+ return nil, ErrMissingBaseURL
+ }
+ baseURL, err := url.Parse(raw)
+ if err != nil {
+ return nil, err
+ }
+ if baseURL.Scheme == "" || baseURL.Host == "" {
+ return nil, fmt.Errorf("configsnapshot: invalid base URL %q", raw)
+ }
+ return baseURL, nil
+}
+
+func defaultString(value, fallback string) string {
+ if value == "" {
+ return fallback
+ }
+ return value
+}
diff --git a/examples/config_snapshot/config_service.go b/examples/config_snapshot/config_service.go
new file mode 100644
index 0000000..1c5de9d
--- /dev/null
+++ b/examples/config_snapshot/config_service.go
@@ -0,0 +1,100 @@
+package configsnapshot
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/agkloop/go_memoize/background"
+)
+
+const (
+ defaultRefreshInterval = time.Minute
+ defaultRequestTimeout = 2 * time.Second
+)
+
+var ErrMissingSource = errors.New("configsnapshot: missing source")
+
+type Logger interface {
+ Printf(format string, args ...any)
+}
+
+type HTTPClient interface {
+ Do(*http.Request) (*http.Response, error)
+}
+
+type AppConfig struct {
+ Version string
+ FeatureFlags map[string]bool
+ UpdatedAt time.Time
+}
+
+type ConfigSource interface {
+ LoadConfig(context.Context) (AppConfig, error)
+}
+
+type ConfigServiceConfig struct {
+ Source ConfigSource
+ RefreshInterval time.Duration
+ Logger Logger
+}
+
+type ConfigService struct {
+ cancel context.CancelFunc
+ value *background.Value[AppConfig]
+}
+
+func StartConfigService(ctx context.Context, cfg ConfigServiceConfig) (*ConfigService, error) {
+ if cfg.Source == nil {
+ return nil, ErrMissingSource
+ }
+ runCtx, cancel := context.WithCancel(ctx)
+ value, err := background.Keep(runCtx, func(ctx context.Context) (AppConfig, error) {
+ loaded, err := cfg.Source.LoadConfig(ctx)
+ if err != nil {
+ return AppConfig{}, err
+ }
+ return cloneConfig(loaded), nil
+ }, defaultDuration(cfg.RefreshInterval, defaultRefreshInterval),
+ background.OnError[AppConfig](func(err error) {
+ if cfg.Logger != nil {
+ cfg.Logger.Printf("config refresh failed: %v", err)
+ }
+ }),
+ background.OnRefresh[AppConfig](func(AppConfig) {
+ if cfg.Logger != nil {
+ cfg.Logger.Printf("config refreshed")
+ }
+ }),
+ )
+ if err != nil {
+ cancel()
+ return nil, err
+ }
+ return &ConfigService{cancel: cancel, value: value}, nil
+}
+
+func (s *ConfigService) Current() AppConfig {
+ return cloneConfig(s.value.Get())
+}
+
+func (s *ConfigService) Close() {
+ s.cancel()
+}
+
+func cloneConfig(cfg AppConfig) AppConfig {
+ flags := make(map[string]bool, len(cfg.FeatureFlags))
+ for key, value := range cfg.FeatureFlags {
+ flags[key] = value
+ }
+ cfg.FeatureFlags = flags
+ return cfg
+}
+
+func defaultDuration(value, fallback time.Duration) time.Duration {
+ if value == 0 {
+ return fallback
+ }
+ return value
+}
diff --git a/examples/config_snapshot/config_service_test.go b/examples/config_snapshot/config_service_test.go
new file mode 100644
index 0000000..0358146
--- /dev/null
+++ b/examples/config_snapshot/config_service_test.go
@@ -0,0 +1,42 @@
+package configsnapshot
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestConfigServiceLoadsBeeceptorCompanySnapshot(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/companies" {
+ t.Fatalf("path = %q, want /companies", r.URL.Path)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"id":1,"name":"Acme Corp","industry":"Manufacturing"},
+ {"id":2,"name":"Globex","industry":"Logistics"}
+ ]`))
+ }))
+ defer server.Close()
+
+ source, err := NewBeeceptorConfigSource(BeeceptorConfigSourceConfig{BaseURL: server.URL})
+ if err != nil {
+ t.Fatalf("NewBeeceptorConfigSource failed: %v", err)
+ }
+ service, err := StartConfigService(context.Background(), ConfigServiceConfig{Source: source, RefreshInterval: time.Hour})
+ if err != nil {
+ t.Fatalf("StartConfigService failed: %v", err)
+ }
+ defer service.Close()
+
+ cfg := service.Current()
+ if cfg.Version != "beeceptor-companies" || !cfg.FeatureFlags["industry:Manufacturing"] || !cfg.FeatureFlags["industry:Logistics"] {
+ t.Fatalf("unexpected config: %+v", cfg)
+ }
+ cfg.FeatureFlags["industry:Manufacturing"] = false
+ if !service.Current().FeatureFlags["industry:Manufacturing"] {
+ t.Fatal("Current returned mutable shared FeatureFlags map")
+ }
+}
diff --git a/examples/direct_profile_cache/README.md b/examples/direct_profile_cache/README.md
new file mode 100644
index 0000000..839441f
--- /dev/null
+++ b/examples/direct_profile_cache/README.md
@@ -0,0 +1,17 @@
+# Direct Profile Cache
+
+Working direct memoizer example for profiles derived from Beeceptor's sample user API shape: `GET https://fake-json-api.mock.beeceptor.com/users`.
+
+- Uses `MemoizeCtx1E` for `LoadProfile(ctx, profileID int64)`.
+- Omits `WithStore`, so direct memoization uses the default internal `Store[uint64, Profile]`.
+- Still requires `WithTTL`; direct memoizers do not silently choose an expiration policy.
+- Error-returning memoizers cache successful results only.
+- Use this style for simple in-process function memoization when you do not need explicit cache keys or manual invalidation.
+- Read `profile_service.go` first for the memoize package usage.
+- Beeceptor HTTP and JSON parsing live in `beeceptor_repository.go`.
+
+Test it directly:
+
+```sh
+go test ./examples/direct_profile_cache -count=1
+```
diff --git a/examples/direct_profile_cache/beeceptor_repository.go b/examples/direct_profile_cache/beeceptor_repository.go
new file mode 100644
index 0000000..7d46994
--- /dev/null
+++ b/examples/direct_profile_cache/beeceptor_repository.go
@@ -0,0 +1,100 @@
+package directprofilecache
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const BeeceptorUsersURL = "https://fake-json-api.mock.beeceptor.com/users"
+
+var (
+ ErrMissingBaseURL = errors.New("directprofilecache: missing base URL")
+ ErrProfileNotFound = errors.New("directprofilecache: profile not found")
+)
+
+type BeeceptorProfileRepositoryConfig struct {
+ BaseURL string
+ HTTPClient HTTPClient
+ RequestTimeout time.Duration
+}
+
+type BeeceptorProfileRepository struct {
+ baseURL *url.URL
+ client HTTPClient
+ requestTimeout time.Duration
+}
+
+func NewBeeceptorProfileRepository(cfg BeeceptorProfileRepositoryConfig) (*BeeceptorProfileRepository, error) {
+ baseURL, err := parseBaseURL(defaultString(cfg.BaseURL, "https://fake-json-api.mock.beeceptor.com"))
+ if err != nil {
+ return nil, err
+ }
+ client := cfg.HTTPClient
+ if client == nil {
+ client = http.DefaultClient
+ }
+ return &BeeceptorProfileRepository{baseURL: baseURL, client: client, requestTimeout: defaultDuration(cfg.RequestTimeout, defaultRequestTimeout)}, nil
+}
+
+func (r *BeeceptorProfileRepository) LoadProfile(ctx context.Context, profileID int64) (Profile, error) {
+ ctx, cancel := context.WithTimeout(ctx, r.requestTimeout)
+ defer cancel()
+
+ endpoint := r.baseURL.JoinPath("users")
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+ if err != nil {
+ return Profile{}, err
+ }
+ req.Header.Set("Accept", "application/json")
+ res, err := r.client.Do(req)
+ if err != nil {
+ return Profile{}, err
+ }
+ defer func() { _ = res.Body.Close() }()
+ if res.StatusCode != http.StatusOK {
+ return Profile{}, fmt.Errorf("directprofilecache: Beeceptor users status %d", res.StatusCode)
+ }
+
+ var users []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Company string `json:"company"`
+ }
+ if err := json.NewDecoder(res.Body).Decode(&users); err != nil {
+ return Profile{}, err
+ }
+ for _, user := range users {
+ if user.ID == profileID {
+ return Profile{ID: user.ID, DisplayName: user.Name, Email: user.Email, Company: user.Company}, nil
+ }
+ }
+ return Profile{}, ErrProfileNotFound
+}
+
+func parseBaseURL(raw string) (*url.URL, error) {
+ if strings.TrimSpace(raw) == "" {
+ return nil, ErrMissingBaseURL
+ }
+ baseURL, err := url.Parse(raw)
+ if err != nil {
+ return nil, err
+ }
+ if baseURL.Scheme == "" || baseURL.Host == "" {
+ return nil, fmt.Errorf("directprofilecache: invalid base URL %q", raw)
+ }
+ return baseURL, nil
+}
+
+func defaultString(value, fallback string) string {
+ if value == "" {
+ return fallback
+ }
+ return value
+}
diff --git a/examples/direct_profile_cache/profile_service.go b/examples/direct_profile_cache/profile_service.go
new file mode 100644
index 0000000..a18b2d7
--- /dev/null
+++ b/examples/direct_profile_cache/profile_service.go
@@ -0,0 +1,69 @@
+package directprofilecache
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+)
+
+const (
+ defaultProfileTTL = time.Minute
+ defaultRequestTimeout = 2 * time.Second
+)
+
+var ErrMissingRepository = errors.New("directprofilecache: missing repository")
+
+type HTTPClient interface {
+ Do(*http.Request) (*http.Response, error)
+}
+
+type Profile struct {
+ ID int64
+ DisplayName string
+ Email string
+ Company string
+}
+
+type ProfileRepository interface {
+ LoadProfile(context.Context, int64) (Profile, error)
+}
+
+type ProfileServiceConfig struct {
+ Repository ProfileRepository
+ TTL time.Duration
+ Metrics memoize.Metrics
+}
+
+type ProfileService struct {
+ loadProfile func(context.Context, int64) (Profile, error)
+}
+
+func NewProfileService(cfg ProfileServiceConfig) (*ProfileService, error) {
+ if cfg.Repository == nil {
+ return nil, ErrMissingRepository
+ }
+ opts := memoize.Opts().WithTTL(defaultDuration(cfg.TTL, defaultProfileTTL))
+ if cfg.Metrics != nil {
+ opts = opts.WithMetrics(cfg.Metrics)
+ }
+
+ cached, err := memoize.MemoizeCtx1E(cfg.Repository.LoadProfile, opts)
+ if err != nil {
+ return nil, err
+ }
+ return &ProfileService{loadProfile: cached}, nil
+}
+
+func (s *ProfileService) GetProfile(ctx context.Context, profileID int64) (Profile, error) {
+ return s.loadProfile(ctx, profileID)
+}
+
+func defaultDuration(value, fallback time.Duration) time.Duration {
+ if value == 0 {
+ return fallback
+ }
+ return value
+}
diff --git a/examples/direct_profile_cache/profile_service_test.go b/examples/direct_profile_cache/profile_service_test.go
new file mode 100644
index 0000000..26a25b4
--- /dev/null
+++ b/examples/direct_profile_cache/profile_service_test.go
@@ -0,0 +1,50 @@
+package directprofilecache
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestProfileServiceMemoizesBeeceptorUserProfile(t *testing.T) {
+ requests := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests++
+ if r.URL.Path != "/users" {
+ t.Fatalf("path = %q, want /users", r.URL.Path)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"id":7,"name":"Linus Torvalds","email":"linus@example.test","company":"Kernel Labs"}
+ ]`))
+ }))
+ defer server.Close()
+
+ repo, err := NewBeeceptorProfileRepository(BeeceptorProfileRepositoryConfig{BaseURL: server.URL})
+ if err != nil {
+ t.Fatalf("NewBeeceptorProfileRepository failed: %v", err)
+ }
+ service, err := NewProfileService(ProfileServiceConfig{Repository: repo, TTL: time.Minute})
+ if err != nil {
+ t.Fatalf("NewProfileService failed: %v", err)
+ }
+
+ ctx := context.Background()
+ first, err := service.GetProfile(ctx, 7)
+ if err != nil {
+ t.Fatalf("first GetProfile failed: %v", err)
+ }
+ second, err := service.GetProfile(ctx, 7)
+ if err != nil {
+ t.Fatalf("second GetProfile failed: %v", err)
+ }
+
+ if first.DisplayName != "Linus Torvalds" || second != first {
+ t.Fatalf("unexpected cached profile: first=%+v second=%+v", first, second)
+ }
+ if requests != 1 {
+ t.Fatalf("requests = %d, want 1", requests)
+ }
+}
diff --git a/examples/direct_stale_profile_cache/README.md b/examples/direct_stale_profile_cache/README.md
new file mode 100644
index 0000000..ab2cd0f
--- /dev/null
+++ b/examples/direct_stale_profile_cache/README.md
@@ -0,0 +1,25 @@
+# Direct Stale Profile Cache
+
+This example wraps a Beeceptor-shaped profile repository with direct function memoization, stale-while-revalidate, and stale-on-error behavior.
+
+The service keeps the memoize call front and center:
+
+```go
+cached, err := memoize.MemoizeCtx1E(
+ repo.LoadProfile,
+ memoize.Opts().
+ WithTTL(time.Minute).
+ WithStaleTTL(5*time.Minute).
+ KeepStaleOnError(),
+)
+```
+
+Because this is a direct memoizer, `go_memoize` hashes the `int64` profile ID into an internal `uint64` key and uses the default internal `Store[uint64, Profile]`. Use `WithStore` only when you need to supply a custom direct store tier.
+
+`WithTTL(time.Minute)` marks a profile fresh for one minute. `WithStaleTTL(5*time.Minute)` keeps the old value available while a refresh is attempted. `KeepStaleOnError()` lets the service continue returning the stale profile if the upstream Beeceptor-style `/users` request fails during the stale window.
+
+Run the example test with:
+
+```sh
+go test ./examples/direct_stale_profile_cache -count=1
+```
diff --git a/examples/direct_stale_profile_cache/beeceptor_repository.go b/examples/direct_stale_profile_cache/beeceptor_repository.go
new file mode 100644
index 0000000..085121c
--- /dev/null
+++ b/examples/direct_stale_profile_cache/beeceptor_repository.go
@@ -0,0 +1,105 @@
+package directstaleprofilecache
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const defaultBeeceptorBaseURL = "https://fake-json-api.mock.beeceptor.com"
+
+var (
+ ErrMissingBaseURL = errors.New("directstaleprofilecache: missing base URL")
+ ErrProfileNotFound = errors.New("directstaleprofilecache: profile not found")
+)
+
+type BeeceptorProfileRepositoryConfig struct {
+ BaseURL string
+ HTTPClient HTTPClient
+ RequestTimeout time.Duration
+}
+
+type BeeceptorProfileRepository struct {
+ baseURL *url.URL
+ client HTTPClient
+ requestTimeout time.Duration
+}
+
+func NewBeeceptorProfileRepository(cfg BeeceptorProfileRepositoryConfig) (*BeeceptorProfileRepository, error) {
+ baseURL, err := parseBaseURL(defaultString(cfg.BaseURL, defaultBeeceptorBaseURL))
+ if err != nil {
+ return nil, err
+ }
+ client := cfg.HTTPClient
+ if client == nil {
+ client = http.DefaultClient
+ }
+ return &BeeceptorProfileRepository{
+ baseURL: baseURL,
+ client: client,
+ requestTimeout: defaultDuration(cfg.RequestTimeout, defaultRequestTimeout),
+ }, nil
+}
+
+func (r *BeeceptorProfileRepository) LoadProfile(ctx context.Context, profileID int64) (Profile, error) {
+ ctx, cancel := context.WithTimeout(ctx, r.requestTimeout)
+ defer cancel()
+
+ endpoint := r.baseURL.JoinPath("users")
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+ if err != nil {
+ return Profile{}, err
+ }
+ req.Header.Set("Accept", "application/json")
+
+ res, err := r.client.Do(req)
+ if err != nil {
+ return Profile{}, err
+ }
+ defer func() { _ = res.Body.Close() }()
+ if res.StatusCode != http.StatusOK {
+ return Profile{}, fmt.Errorf("directstaleprofilecache: Beeceptor users status %d", res.StatusCode)
+ }
+
+ var users []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Company string `json:"company"`
+ }
+ if err := json.NewDecoder(res.Body).Decode(&users); err != nil {
+ return Profile{}, err
+ }
+ for _, user := range users {
+ if user.ID == profileID {
+ return Profile{ID: user.ID, DisplayName: user.Name, Email: user.Email, Company: user.Company}, nil
+ }
+ }
+ return Profile{}, ErrProfileNotFound
+}
+
+func parseBaseURL(raw string) (*url.URL, error) {
+ if strings.TrimSpace(raw) == "" {
+ return nil, ErrMissingBaseURL
+ }
+ baseURL, err := url.Parse(raw)
+ if err != nil {
+ return nil, err
+ }
+ if baseURL.Scheme == "" || baseURL.Host == "" {
+ return nil, fmt.Errorf("directstaleprofilecache: invalid base URL %q", raw)
+ }
+ return baseURL, nil
+}
+
+func defaultString(value, fallback string) string {
+ if value == "" {
+ return fallback
+ }
+ return value
+}
diff --git a/examples/direct_stale_profile_cache/profile_service.go b/examples/direct_stale_profile_cache/profile_service.go
new file mode 100644
index 0000000..8d010bf
--- /dev/null
+++ b/examples/direct_stale_profile_cache/profile_service.go
@@ -0,0 +1,75 @@
+package directstaleprofilecache
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+)
+
+const (
+ defaultFreshTTL = time.Minute
+ defaultStaleTTL = 5 * time.Minute
+ defaultRequestTimeout = 2 * time.Second
+)
+
+var ErrMissingRepository = errors.New("directstaleprofilecache: missing repository")
+
+type HTTPClient interface {
+ Do(*http.Request) (*http.Response, error)
+}
+
+type Profile struct {
+ ID int64
+ DisplayName string
+ Email string
+ Company string
+}
+
+type ProfileRepository interface {
+ LoadProfile(context.Context, int64) (Profile, error)
+}
+
+type ProfileServiceConfig struct {
+ Repository ProfileRepository
+ FreshTTL time.Duration
+ StaleTTL time.Duration
+ Metrics memoize.Metrics
+}
+
+type ProfileService struct {
+ loadProfile func(context.Context, int64) (Profile, error)
+}
+
+func NewProfileService(cfg ProfileServiceConfig) (*ProfileService, error) {
+ if cfg.Repository == nil {
+ return nil, ErrMissingRepository
+ }
+
+ opts := memoize.Opts().
+ WithTTL(defaultDuration(cfg.FreshTTL, defaultFreshTTL)).
+ WithStaleTTL(defaultDuration(cfg.StaleTTL, defaultStaleTTL)).
+ KeepStaleOnError()
+ if cfg.Metrics != nil {
+ opts = opts.WithMetrics(cfg.Metrics)
+ }
+
+ cached, err := memoize.MemoizeCtx1E(cfg.Repository.LoadProfile, opts)
+ if err != nil {
+ return nil, err
+ }
+ return &ProfileService{loadProfile: cached}, nil
+}
+
+func (s *ProfileService) GetProfile(ctx context.Context, profileID int64) (Profile, error) {
+ return s.loadProfile(ctx, profileID)
+}
+
+func defaultDuration(value, fallback time.Duration) time.Duration {
+ if value == 0 {
+ return fallback
+ }
+ return value
+}
diff --git a/examples/direct_stale_profile_cache/profile_service_test.go b/examples/direct_stale_profile_cache/profile_service_test.go
new file mode 100644
index 0000000..3b1a891
--- /dev/null
+++ b/examples/direct_stale_profile_cache/profile_service_test.go
@@ -0,0 +1,53 @@
+package directstaleprofilecache
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestProfileServiceCachesProfileWithDirectStaleMemoizer(t *testing.T) {
+ requests := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests++
+ if r.URL.Path != "/users" {
+ t.Fatalf("path = %q, want /users", r.URL.Path)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[{"id":11,"name":"Grace Hopper","email":"grace@example.com","company":"Navy"}]`))
+ }))
+ defer server.Close()
+
+ repo, err := NewBeeceptorProfileRepository(BeeceptorProfileRepositoryConfig{BaseURL: server.URL})
+ if err != nil {
+ t.Fatalf("NewBeeceptorProfileRepository failed: %v", err)
+ }
+ service, err := NewProfileService(ProfileServiceConfig{
+ Repository: repo,
+ FreshTTL: time.Minute,
+ StaleTTL: 5 * time.Minute,
+ })
+ if err != nil {
+ t.Fatalf("NewProfileService failed: %v", err)
+ }
+
+ ctx := context.Background()
+ first, err := service.GetProfile(ctx, 11)
+ if err != nil {
+ t.Fatalf("first GetProfile failed: %v", err)
+ }
+ second, err := service.GetProfile(ctx, 11)
+ if err != nil {
+ t.Fatalf("second GetProfile failed: %v", err)
+ }
+
+ want := Profile{ID: 11, DisplayName: "Grace Hopper", Email: "grace@example.com", Company: "Navy"}
+ if first != want || second != first {
+ t.Fatalf("unexpected cached profile: first=%+v second=%+v want=%+v", first, second, want)
+ }
+ if requests != 1 {
+ t.Fatalf("requests = %d, want 1", requests)
+ }
+}
diff --git a/examples/http_user_cache/README.md b/examples/http_user_cache/README.md
new file mode 100644
index 0000000..0366a0a
--- /dev/null
+++ b/examples/http_user_cache/README.md
@@ -0,0 +1,16 @@
+# HTTP User Cache
+
+Working explicit cache example for users loaded from Beeceptor's sample user API shape: `GET https://fake-json-api.mock.beeceptor.com/users`.
+
+- Uses user IDs as explicit cache keys.
+- Uses bounded `memory.New[int64, User]` as LRU storage.
+- Configures `WithTTL`, `WithStaleTTL`, `KeepStaleOnError`, `WithRefreshTimeout`, and optional metrics.
+- Keeps the memoize package usage in `user_service.go`.
+- Keeps Beeceptor HTTP and JSON parsing in `beeceptor_repository.go`.
+- Exposes `Close()` so services can call `cache.Stop()` during shutdown.
+
+Test it directly:
+
+```sh
+go test ./examples/http_user_cache -count=1
+```
diff --git a/examples/http_user_cache/beeceptor_repository.go b/examples/http_user_cache/beeceptor_repository.go
new file mode 100644
index 0000000..83e49ef
--- /dev/null
+++ b/examples/http_user_cache/beeceptor_repository.go
@@ -0,0 +1,114 @@
+package httpusercache
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const BeeceptorUsersURL = "https://fake-json-api.mock.beeceptor.com/users"
+
+var (
+ ErrMissingBaseURL = errors.New("httpusercache: missing base URL")
+ ErrUserNotFound = errors.New("httpusercache: user not found")
+)
+
+type BeeceptorUserRepositoryConfig struct {
+ BaseURL string
+ HTTPClient HTTPClient
+ RequestTimeout time.Duration
+ Logger Logger
+}
+
+type BeeceptorUserRepository struct {
+ baseURL *url.URL
+ client HTTPClient
+ requestTimeout time.Duration
+ logger Logger
+}
+
+func NewBeeceptorUserRepository(cfg BeeceptorUserRepositoryConfig) (*BeeceptorUserRepository, error) {
+ baseURL, err := parseBaseURL(defaultString(cfg.BaseURL, "https://fake-json-api.mock.beeceptor.com"))
+ if err != nil {
+ return nil, err
+ }
+ client := cfg.HTTPClient
+ if client == nil {
+ client = http.DefaultClient
+ }
+ return &BeeceptorUserRepository{
+ baseURL: baseURL,
+ client: client,
+ requestTimeout: defaultDuration(cfg.RequestTimeout, defaultRequestTimeout),
+ logger: cfg.Logger,
+ }, nil
+}
+
+func (r *BeeceptorUserRepository) LoadUser(ctx context.Context, userID int64) (User, error) {
+ ctx, cancel := context.WithTimeout(ctx, r.requestTimeout)
+ defer cancel()
+
+ users, err := r.fetchUsers(ctx)
+ if err != nil {
+ return User{}, err
+ }
+ for _, user := range users {
+ if user.ID == userID {
+ return user, nil
+ }
+ }
+ if r.logger != nil {
+ r.logger.Printf("beeceptor user not found id=%d", userID)
+ }
+ return User{}, ErrUserNotFound
+}
+
+func (r *BeeceptorUserRepository) fetchUsers(ctx context.Context) ([]User, error) {
+ endpoint := r.baseURL.JoinPath("users")
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+
+ res, err := r.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = res.Body.Close() }()
+ if res.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("httpusercache: Beeceptor users status %d", res.StatusCode)
+ }
+
+ var users []User
+ if err := json.NewDecoder(res.Body).Decode(&users); err != nil {
+ return nil, err
+ }
+ return users, nil
+}
+
+func parseBaseURL(raw string) (*url.URL, error) {
+ if strings.TrimSpace(raw) == "" {
+ return nil, ErrMissingBaseURL
+ }
+ baseURL, err := url.Parse(raw)
+ if err != nil {
+ return nil, err
+ }
+ if baseURL.Scheme == "" || baseURL.Host == "" {
+ return nil, fmt.Errorf("httpusercache: invalid base URL %q", raw)
+ }
+ return baseURL, nil
+}
+
+func defaultString(value, fallback string) string {
+ if value == "" {
+ return fallback
+ }
+ return value
+}
diff --git a/examples/http_user_cache/user_service.go b/examples/http_user_cache/user_service.go
new file mode 100644
index 0000000..d5ac4bb
--- /dev/null
+++ b/examples/http_user_cache/user_service.go
@@ -0,0 +1,117 @@
+package httpusercache
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/agkloop/go_memoize/stores/memory"
+)
+
+const (
+ defaultCacheCapacity = 50_000
+ defaultFreshTTL = 30 * time.Second
+ defaultStaleTTL = 2 * time.Minute
+ defaultRequestTimeout = 2 * time.Second
+ defaultRefreshTimeout = 3 * time.Second
+)
+
+var ErrMissingRepository = errors.New("httpusercache: missing repository")
+
+type Logger interface {
+ Printf(format string, args ...any)
+}
+
+type HTTPClient interface {
+ Do(*http.Request) (*http.Response, error)
+}
+
+type User struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Company string `json:"company"`
+}
+
+type UserRepository interface {
+ LoadUser(context.Context, int64) (User, error)
+}
+
+type UserServiceConfig struct {
+ Repository UserRepository
+ BaseURL string
+ HTTPClient HTTPClient
+ CacheCapacity int
+ FreshTTL time.Duration
+ StaleTTL time.Duration
+ RequestTimeout time.Duration
+ RefreshTimeout time.Duration
+ Metrics memoize.Metrics
+ Logger Logger
+}
+
+type UserService struct {
+ repository UserRepository
+ cache *memoize.Cache[int64, User]
+}
+
+func NewUserService(cfg UserServiceConfig) (*UserService, error) {
+ repository := cfg.Repository
+ if repository == nil {
+ created, err := NewBeeceptorUserRepository(BeeceptorUserRepositoryConfig{
+ BaseURL: cfg.BaseURL,
+ HTTPClient: cfg.HTTPClient,
+ RequestTimeout: cfg.RequestTimeout,
+ Logger: cfg.Logger,
+ })
+ if err != nil {
+ return nil, err
+ }
+ repository = created
+ }
+ if repository == nil {
+ return nil, ErrMissingRepository
+ }
+
+ opts := memoize.Opts().
+ WithStore(memory.New[int64, User](defaultInt(cfg.CacheCapacity, defaultCacheCapacity))).
+ WithTTL(defaultDuration(cfg.FreshTTL, defaultFreshTTL)).
+ WithStaleTTL(defaultDuration(cfg.StaleTTL, defaultStaleTTL)).
+ WithRefreshTimeout(defaultDuration(cfg.RefreshTimeout, defaultRefreshTimeout)).
+ KeepStaleOnError()
+ if cfg.Metrics != nil {
+ opts = opts.WithMetrics(cfg.Metrics)
+ }
+
+ cache, err := memoize.New[int64, User](opts)
+ if err != nil {
+ return nil, err
+ }
+ return &UserService{repository: repository, cache: cache}, nil
+}
+
+func (s *UserService) GetUser(ctx context.Context, userID int64) (User, error) {
+ return s.cache.GetOrCompute(ctx, userID, func(ctx context.Context) (User, error) {
+ return s.repository.LoadUser(ctx, userID)
+ })
+}
+
+func (s *UserService) Close() {
+ s.cache.Stop()
+}
+
+func defaultInt(value, fallback int) int {
+ if value == 0 {
+ return fallback
+ }
+ return value
+}
+
+func defaultDuration(value, fallback time.Duration) time.Duration {
+ if value == 0 {
+ return fallback
+ }
+ return value
+}
diff --git a/examples/http_user_cache/user_service_test.go b/examples/http_user_cache/user_service_test.go
new file mode 100644
index 0000000..526deb6
--- /dev/null
+++ b/examples/http_user_cache/user_service_test.go
@@ -0,0 +1,48 @@
+package httpusercache
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestUserServiceCachesBeeceptorUsersEndpoint(t *testing.T) {
+ requests := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests++
+ if r.URL.Path != "/users" {
+ t.Fatalf("path = %q, want /users", r.URL.Path)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"id":1,"name":"Ada Lovelace","email":"ada@example.test","company":"Analytical Engines"},
+ {"id":2,"name":"Grace Hopper","email":"grace@example.test","company":"Compilers Inc"}
+ ]`))
+ }))
+ defer server.Close()
+
+ service, err := NewUserService(UserServiceConfig{BaseURL: server.URL, FreshTTL: time.Minute})
+ if err != nil {
+ t.Fatalf("NewUserService failed: %v", err)
+ }
+ defer service.Close()
+
+ ctx := context.Background()
+ first, err := service.GetUser(ctx, 1)
+ if err != nil {
+ t.Fatalf("first GetUser failed: %v", err)
+ }
+ second, err := service.GetUser(ctx, 1)
+ if err != nil {
+ t.Fatalf("second GetUser failed: %v", err)
+ }
+
+ if first.Name != "Ada Lovelace" || second.Name != first.Name {
+ t.Fatalf("unexpected cached user: first=%+v second=%+v", first, second)
+ }
+ if requests != 1 {
+ t.Fatalf("requests = %d, want 1", requests)
+ }
+}
diff --git a/go.mod b/go.mod
index e0ff4ad..b6f1c7c 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,3 @@
module github.com/agkloop/go_memoize
-go 1.22.5
+go 1.24.0
diff --git a/hashing.go b/hashing.go
index 5832d92..1517ac1 100644
--- a/hashing.go
+++ b/hashing.go
@@ -1,4 +1,4 @@
-package go_memoize
+package memoize
import (
"fmt"
diff --git a/hashing_test.go b/hashing_test.go
index 805e815..da128f7 100644
--- a/hashing_test.go
+++ b/hashing_test.go
@@ -1,4 +1,4 @@
-package go_memoize
+package memoize
import (
"math"
diff --git a/internal/hash/hash.go b/internal/hash/hash.go
new file mode 100644
index 0000000..8155b21
--- /dev/null
+++ b/internal/hash/hash.go
@@ -0,0 +1,73 @@
+package hash
+
+import (
+ "fmt"
+ "math"
+)
+
+const (
+ Offset64 = uint64(14695981039346656037)
+ Prime64 = uint64(1099511628211)
+)
+
+func String(h uint64, key string) uint64 {
+ length := len(key)
+ for i := 0; i < length/4*4; i += 4 {
+ h = (h ^ uint64(key[i])) * Prime64
+ h = (h ^ uint64(key[i+1])) * Prime64
+ h = (h ^ uint64(key[i+2])) * Prime64
+ h = (h ^ uint64(key[i+3])) * Prime64
+ }
+ for i := length / 4 * 4; i < length; i++ {
+ h = (h ^ uint64(key[i])) * Prime64
+ }
+ return h
+}
+
+func Uint(h uint64, key uint64) uint64 {
+ return (h ^ key) * Prime64
+}
+
+func Bool(h uint64, key bool) uint64 {
+ if key {
+ return (h ^ 1) * Prime64
+ }
+ return h * Prime64
+}
+
+func Comparable[K comparable](h uint64, key K) uint64 {
+ switch v := any(key).(type) {
+ case string:
+ return String(h, v)
+ case int:
+ return Uint(h, uint64(v))
+ case int8:
+ return Uint(h, uint64(v))
+ case int16:
+ return Uint(h, uint64(v))
+ case int32:
+ return Uint(h, uint64(v))
+ case int64:
+ return Uint(h, uint64(v))
+ case uint:
+ return Uint(h, uint64(v))
+ case uint8:
+ return Uint(h, uint64(v))
+ case uint16:
+ return Uint(h, uint64(v))
+ case uint32:
+ return Uint(h, uint64(v))
+ case uint64:
+ return Uint(h, v)
+ case uintptr:
+ return Uint(h, uint64(v))
+ case float32:
+ return Uint(h, math.Float64bits(float64(v)))
+ case float64:
+ return Uint(h, math.Float64bits(v))
+ case bool:
+ return Bool(h, v)
+ default:
+ panic(fmt.Sprintf("unsupported type for caching %T", key))
+ }
+}
diff --git a/internal/hash/hash_test.go b/internal/hash/hash_test.go
new file mode 100644
index 0000000..164c396
--- /dev/null
+++ b/internal/hash/hash_test.go
@@ -0,0 +1,26 @@
+package hash
+
+import "testing"
+
+func TestFNVCompatibility(t *testing.T) {
+ tests := []struct {
+ name string
+ got uint64
+ want uint64
+ }{
+ {name: "string", got: String(Offset64, "foo"), want: 15902901984413996407},
+ {name: "uint", got: Uint(Offset64, 42), want: 12638128926439346813},
+ {name: "bool false", got: Bool(Offset64, false), want: 12638153115695167455},
+ {name: "bool true", got: Bool(Offset64, true), want: 12638152016183539244},
+ {name: "comparable string", got: Comparable(Offset64, "foo"), want: 15902901984413996407},
+ {name: "comparable int", got: Comparable(Offset64, 42), want: 12638128926439346813},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.got != tt.want {
+ t.Fatalf("hash = %d, want %d", tt.got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/refreshloop/refreshloop.go b/internal/refreshloop/refreshloop.go
new file mode 100644
index 0000000..7f83582
--- /dev/null
+++ b/internal/refreshloop/refreshloop.go
@@ -0,0 +1,41 @@
+package refreshloop
+
+import (
+ "context"
+ "time"
+)
+
+// Hooks receives refresh outcomes from Run.
+type Hooks[V any] struct {
+ OnValue func(V)
+ OnError func(error)
+}
+
+// Run calls load on each interval tick until ctx is cancelled.
+// Failed loads report OnError and leave the last successful value unchanged.
+func Run[V any](ctx context.Context, interval time.Duration, load func(context.Context) (V, error), hooks Hooks[V]) {
+ if interval <= 0 {
+ return
+ }
+
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ v, err := load(ctx)
+ if err != nil {
+ if hooks.OnError != nil {
+ hooks.OnError(err)
+ }
+ continue
+ }
+ if hooks.OnValue != nil {
+ hooks.OnValue(v)
+ }
+ }
+ }
+}
diff --git a/loader/loader.go b/loader/loader.go
new file mode 100644
index 0000000..42c340f
--- /dev/null
+++ b/loader/loader.go
@@ -0,0 +1,134 @@
+package loader
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/agkloop/go_memoize/internal/refreshloop"
+)
+
+type options[V any] struct {
+ onError func(error)
+}
+
+// Option configures a Loader.
+type Option[V any] func(*options[V])
+
+// WithOnError sets a callback invoked whenever the load function returns an error.
+// If not set, errors are silently ignored (stale value is kept).
+func WithOnError[V any](fn func(error)) Option[V] {
+ return func(o *options[V]) { o.onError = fn }
+}
+
+// Loader runs a load function on a fixed interval and caches the latest result.
+// Value() always returns instantly (after the first successful load).
+type Loader[V any] struct {
+ fn func(context.Context) (V, error)
+ interval time.Duration
+ opts options[V]
+
+ mu sync.RWMutex
+ value V
+ err error
+ hasVal bool
+ ready chan struct{} // closed on first successful load
+ stop chan struct{}
+ stopped chan struct{}
+}
+
+// New creates and starts a Loader. The load function is called immediately,
+// then on every interval tick. Stop must be called to release resources.
+func New[V any](fn func(context.Context) (V, error), interval time.Duration, opts ...Option[V]) *Loader[V] {
+ o := options[V]{}
+ for _, opt := range opts {
+ opt(&o)
+ }
+ l := &Loader[V]{
+ fn: fn,
+ interval: interval,
+ opts: o,
+ ready: make(chan struct{}),
+ stop: make(chan struct{}),
+ stopped: make(chan struct{}),
+ }
+ go l.run()
+ return l
+}
+
+func (l *Loader[V]) run() {
+ defer close(l.stopped)
+ l.load()
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ select {
+ case <-l.stop:
+ cancel()
+ case <-done:
+ }
+ }()
+ defer func() {
+ close(done)
+ cancel()
+ }()
+
+ refreshloop.Run(ctx, l.interval, l.fn, refreshloop.Hooks[V]{
+ OnValue: l.store,
+ OnError: l.storeError,
+ })
+}
+
+func (l *Loader[V]) load() {
+ v, err := l.fn(context.Background())
+ if err != nil {
+ l.storeError(err)
+ return
+ }
+ l.store(v)
+}
+
+func (l *Loader[V]) store(v V) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ l.value = v
+ l.err = nil
+ if !l.hasVal {
+ l.hasVal = true
+ close(l.ready)
+ }
+}
+
+func (l *Loader[V]) storeError(err error) {
+ l.mu.Lock()
+ if !l.hasVal {
+ l.err = err
+ }
+ l.mu.Unlock()
+ if l.opts.onError != nil {
+ l.opts.onError(err)
+ }
+}
+
+// Value returns the latest successfully loaded value.
+// Blocks until the first successful load or ctx is cancelled.
+// After the first success, always returns instantly.
+func (l *Loader[V]) Value(ctx context.Context) (V, error) {
+ select {
+ case <-l.ready:
+ l.mu.RLock()
+ v, err := l.value, l.err
+ l.mu.RUnlock()
+ return v, err
+ case <-ctx.Done():
+ var zero V
+ return zero, ctx.Err()
+ }
+}
+
+// Stop halts the background refresh goroutine and waits for it to exit.
+func (l *Loader[V]) Stop() {
+ close(l.stop)
+ <-l.stopped
+}
diff --git a/loader/loader_test.go b/loader/loader_test.go
new file mode 100644
index 0000000..36b53b2
--- /dev/null
+++ b/loader/loader_test.go
@@ -0,0 +1,180 @@
+package loader_test
+
+import (
+ "context"
+ "errors"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/agkloop/go_memoize/loader"
+)
+
+func TestLoaderValue(t *testing.T) {
+ calls := atomic.Int32{}
+ l := loader.New[string](
+ func(_ context.Context) (string, error) {
+ calls.Add(1)
+ return "hello", nil
+ },
+ 50*time.Millisecond,
+ )
+ defer l.Stop()
+
+ v, err := l.Value(context.Background())
+ if err != nil || v != "hello" {
+ t.Fatalf("expected 'hello', got %q, err=%v", v, err)
+ }
+}
+
+func TestLoaderNonPositiveIntervalDoesNotPanic(t *testing.T) {
+ l := loader.New[int](
+ func(_ context.Context) (int, error) {
+ return 42, nil
+ },
+ 0,
+ )
+ defer l.Stop()
+
+ v, err := l.Value(context.Background())
+ if err != nil || v != 42 {
+ t.Fatalf("expected 42, got %d, err=%v", v, err)
+ }
+ time.Sleep(10 * time.Millisecond)
+}
+
+func TestLoaderRefreshes(t *testing.T) {
+ counter := atomic.Int32{}
+ l := loader.New[int](
+ func(_ context.Context) (int, error) {
+ return int(counter.Add(1)), nil
+ },
+ 30*time.Millisecond,
+ )
+ defer l.Stop()
+
+ // Wait long enough for at least 3 ticks
+ time.Sleep(120 * time.Millisecond)
+ v, _ := l.Value(context.Background())
+ if v < 2 {
+ t.Fatalf("expected at least 2 refreshes, got %d", v)
+ }
+}
+
+func TestLoaderKeepsStaleOnError(t *testing.T) {
+ first := atomic.Bool{}
+ first.Store(true)
+
+ l := loader.New[string](
+ func(_ context.Context) (string, error) {
+ if first.CompareAndSwap(true, false) {
+ return "good", nil
+ }
+ return "", errors.New("transient")
+ },
+ 30*time.Millisecond,
+ )
+ defer l.Stop()
+
+ // First call: get the good value
+ v, err := l.Value(context.Background())
+ if err != nil || v != "good" {
+ t.Fatalf("expected 'good': v=%q err=%v", v, err)
+ }
+
+ // Wait for at least one failing refresh
+ time.Sleep(70 * time.Millisecond)
+
+ // Stale value must still be returned
+ v, err = l.Value(context.Background())
+ if err != nil || v != "good" {
+ t.Fatalf("expected stale 'good' after error: v=%q err=%v", v, err)
+ }
+}
+
+func TestLoaderKeepsStaleOnRefreshErrorAndCallsOnError(t *testing.T) {
+ first := atomic.Bool{}
+ first.Store(true)
+ errored := make(chan error, 1)
+
+ l := loader.New[string](
+ func(_ context.Context) (string, error) {
+ if first.CompareAndSwap(true, false) {
+ return "good", nil
+ }
+ return "", errors.New("transient")
+ },
+ 20*time.Millisecond,
+ loader.WithOnError[string](func(err error) {
+ select {
+ case errored <- err:
+ default:
+ }
+ }),
+ )
+ defer l.Stop()
+
+ v, err := l.Value(context.Background())
+ if err != nil || v != "good" {
+ t.Fatalf("expected 'good': v=%q err=%v", v, err)
+ }
+
+ select {
+ case err := <-errored:
+ if err == nil || err.Error() != "transient" {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("OnError was not called for refresh error")
+ }
+
+ v, err = l.Value(context.Background())
+ if err != nil || v != "good" {
+ t.Fatalf("expected stale 'good' after error: v=%q err=%v", v, err)
+ }
+}
+
+func TestLoaderStop(t *testing.T) {
+ calls := atomic.Int32{}
+ l := loader.New[int](
+ func(_ context.Context) (int, error) {
+ calls.Add(1)
+ return 1, nil
+ },
+ 20*time.Millisecond,
+ )
+ l.Stop()
+ before := calls.Load()
+ time.Sleep(60 * time.Millisecond)
+ after := calls.Load()
+ if after > before+1 { // allow one in-flight call
+ t.Fatalf("loader continued after Stop: before=%d after=%d", before, after)
+ }
+}
+
+func TestLoaderOnError(t *testing.T) {
+ errored := make(chan error, 1)
+ l := loader.New[string](
+ func(_ context.Context) (string, error) {
+ return "", errors.New("boom")
+ },
+ 20*time.Millisecond,
+ loader.WithOnError[string](func(err error) {
+ select {
+ case errored <- err:
+ default:
+ }
+ }),
+ )
+ defer l.Stop()
+
+ // Expect an error to be reported
+ select {
+ case err := <-errored:
+ if err == nil || err.Error() != "boom" {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("OnError was not called within timeout")
+ }
+}
diff --git a/memoize.go b/memoize.go
index 9cf733d..b81748c 100644
--- a/memoize.go
+++ b/memoize.go
@@ -1,232 +1,234 @@
-package go_memoize
+package memoize
-import (
- "time"
-)
+import "context"
-// Memoize returns a memoized version of the compute function with a specified TTL.
-// V is the type of the value returned by the compute function.
-func Memoize[V any](computeFn func() V, ttl time.Duration) func() V {
- cache := NewCacheSized[uint64, V](1, int64(ttl.Seconds()))
+// Memoize returns a memoized version of the compute function.
+func Memoize[V any](computeFn func() V, opts Options) (func() V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func() V {
- return cache.GetOrCompute(0, func() V {
- return computeFn()
+ value, err := cache.GetOrCompute(context.Background(), 0, func(context.Context) (V, error) {
+ return computeFn(), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// Memoize1 returns a memoized version of the compute function with a single key and a specified TTL.
-// K is the type of the key, and V is the type of the value returned by the compute function.
-func Memoize1[K comparable, V any](computeFn func(K) V, ttl time.Duration) func(K) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+// Memoize1 returns a memoized version of the compute function with a single key.
+func Memoize1[K comparable, V any](computeFn func(K) V, opts Options) (func(K) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(k K) V {
- return cache.GetOrCompute(hash1(k), func() V {
- return computeFn(k)
+ value, err := cache.GetOrCompute(context.Background(), hash1(k), func(context.Context) (V, error) {
+ return computeFn(k), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// Memoize2 returns a memoized version of the compute function with two keys and a specified TTL.
-// K1 and K2 are the types of the keys, and V is the type of the value returned by the compute function.
-func Memoize2[K1, K2 comparable, V any](computeFn func(K1, K2) V, ttl time.Duration) func(K1, K2) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+// Memoize2 returns a memoized version of the compute function with two keys.
+func Memoize2[K1, K2 comparable, V any](computeFn func(K1, K2) V, opts Options) (func(K1, K2) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(key1 K1, key2 K2) V {
- return cache.GetOrCompute(hash2(key1, key2), func() V {
- return computeFn(key1, key2)
+ value, err := cache.GetOrCompute(context.Background(), hash2(key1, key2), func(context.Context) (V, error) {
+ return computeFn(key1, key2), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// Memoize3 returns a memoized version of the compute function with three keys and a specified TTL.
-// K1, K2, and K3 are the types of the keys, and V is the type of the value returned by the compute function.
-func Memoize3[K1, K2, K3 comparable, V any](computeFn func(K1, K2, K3) V, ttl time.Duration) func(K1, K2, K3) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+// Memoize3 returns a memoized version of the compute function with three keys.
+func Memoize3[K1, K2, K3 comparable, V any](computeFn func(K1, K2, K3) V, opts Options) (func(K1, K2, K3) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(key1 K1, key2 K2, key3 K3) V {
- return cache.GetOrCompute(hash3(key1, key2, key3), func() V {
- return computeFn(key1, key2, key3)
+ value, err := cache.GetOrCompute(context.Background(), hash3(key1, key2, key3), func(context.Context) (V, error) {
+ return computeFn(key1, key2, key3), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// Memoize4 returns a memoized version of the compute function with four keys and a specified TTL.
-// K1, K2, K3, and K4 are the types of the keys, and V is the type of the value returned by the compute function.
-func Memoize4[K1, K2, K3, K4 comparable, V any](computeFn func(K1, K2, K3, K4) V, ttl time.Duration) func(K1, K2, K3, K4) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+// Memoize4 returns a memoized version of the compute function with four keys.
+func Memoize4[K1, K2, K3, K4 comparable, V any](computeFn func(K1, K2, K3, K4) V, opts Options) (func(K1, K2, K3, K4) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(key1 K1, key2 K2, key3 K3, key4 K4) V {
- return cache.GetOrCompute(hash4(key1, key2, key3, key4), func() V {
- return computeFn(key1, key2, key3, key4)
+ value, err := cache.GetOrCompute(context.Background(), hash4(key1, key2, key3, key4), func(context.Context) (V, error) {
+ return computeFn(key1, key2, key3, key4), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// Memoize5 returns a memoized version of the compute function with five keys and a specified TTL.
-// K1, K2, K3, K4, and K5 are the types of the keys, and V is the type of the value returned by the compute function.
-func Memoize5[K1, K2, K3, K4, K5 comparable, V any](computeFn func(K1, K2, K3, K4, K5) V, ttl time.Duration) func(K1, K2, K3, K4, K5) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+// Memoize5 returns a memoized version of the compute function with five keys.
+func Memoize5[K1, K2, K3, K4, K5 comparable, V any](computeFn func(K1, K2, K3, K4, K5) V, opts Options) (func(K1, K2, K3, K4, K5) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(key1 K1, key2 K2, key3 K3, key4 K4, key5 K5) V {
- return cache.GetOrCompute(hash5(key1, key2, key3, key4, key5), func() V {
- return computeFn(key1, key2, key3, key4, key5)
+ value, err := cache.GetOrCompute(context.Background(), hash5(key1, key2, key3, key4, key5), func(context.Context) (V, error) {
+ return computeFn(key1, key2, key3, key4, key5), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// Memoize6 returns a memoized version of the compute function with six keys and a specified TTL.
-// K1, K2, K3, K4, K5, and K6 are the types of the keys, and V is the type of the value returned by the compute function.
-func Memoize6[K1, K2, K3, K4, K5, K6 comparable, V any](computeFn func(K1, K2, K3, K4, K5, K6) V, ttl time.Duration) func(K1, K2, K3, K4, K5, K6) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+// Memoize6 returns a memoized version of the compute function with six keys.
+func Memoize6[K1, K2, K3, K4, K5, K6 comparable, V any](computeFn func(K1, K2, K3, K4, K5, K6) V, opts Options) (func(K1, K2, K3, K4, K5, K6) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6) V {
- return cache.GetOrCompute(hash6(key1, key2, key3, key4, key5, key6), func() V {
- return computeFn(key1, key2, key3, key4, key5, key6)
+ value, err := cache.GetOrCompute(context.Background(), hash6(key1, key2, key3, key4, key5, key6), func(context.Context) (V, error) {
+ return computeFn(key1, key2, key3, key4, key5, key6), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// Memoize7 returns a memoized version of the compute function with seven keys and a specified TTL.
-// K1, K2, K3, K4, K5, K6, and K7 are the types of the keys, and V is the type of the value returned by the compute function.
-func Memoize7[K1, K2, K3, K4, K5, K6, K7 comparable, V any](computeFn func(K1, K2, K3, K4, K5, K6, K7) V, ttl time.Duration) func(K1, K2, K3, K4, K5, K6, K7) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+// Memoize7 returns a memoized version of the compute function with seven keys.
+func Memoize7[K1, K2, K3, K4, K5, K6, K7 comparable, V any](computeFn func(K1, K2, K3, K4, K5, K6, K7) V, opts Options) (func(K1, K2, K3, K4, K5, K6, K7) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6, key7 K7) V {
- return cache.GetOrCompute(hash7(key1, key2, key3, key4, key5, key6, key7), func() V {
- return computeFn(key1, key2, key3, key4, key5, key6, key7)
+ value, err := cache.GetOrCompute(context.Background(), hash7(key1, key2, key3, key4, key5, key6, key7), func(context.Context) (V, error) {
+ return computeFn(key1, key2, key3, key4, key5, key6, key7), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// --- New variants that return an error and avoid caching when computeFn returns a non-nil error ---
-
// MemoizeE memoizes a function that returns (V, error). Errors are not cached.
-func MemoizeE[V any](computeFn func() (V, error), ttl time.Duration) func() (V, error) {
- cache := NewCacheSized[uint64, V](1, int64(ttl.Seconds()))
- return func() (V, error) {
- // try cached
- if v, ok := cache.Get(0); ok {
- return v, nil
- }
- // compute
- v, err := computeFn()
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(0, v)
- return v, nil
+func MemoizeE[V any](computeFn func() (V, error), opts Options) (func() (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func() (V, error) {
+ return getSetDirect(context.Background(), cache, 0, func() (V, error) { return computeFn() })
+ }, nil
}
-// Memoize1E memoizes a function with 1 arg that returns (V, error). Errors are not cached.
-func Memoize1E[K comparable, V any](computeFn func(K) (V, error), ttl time.Duration) func(K) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(k K) (V, error) {
- key := hash1(k)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(k)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func Memoize1E[K comparable, V any](computeFn func(K) (V, error), opts Options) (func(K) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(k K) (V, error) {
+ return getSetDirect(context.Background(), cache, hash1(k), func() (V, error) { return computeFn(k) })
+ }, nil
}
-// Memoize2E memoizes a function with 2 args that returns (V, error). Errors are not cached.
-func Memoize2E[K1, K2 comparable, V any](computeFn func(K1, K2) (V, error), ttl time.Duration) func(K1, K2) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(key1 K1, key2 K2) (V, error) {
- key := hash2(key1, key2)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(key1, key2)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func Memoize2E[K1, K2 comparable, V any](computeFn func(K1, K2) (V, error), opts Options) (func(K1, K2) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(key1 K1, key2 K2) (V, error) {
+ return getSetDirect(context.Background(), cache, hash2(key1, key2), func() (V, error) { return computeFn(key1, key2) })
+ }, nil
}
-// Memoize3E memoizes a function with 3 args that returns (V, error). Errors are not cached.
-func Memoize3E[K1, K2, K3 comparable, V any](computeFn func(K1, K2, K3) (V, error), ttl time.Duration) func(K1, K2, K3) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(key1 K1, key2 K2, key3 K3) (V, error) {
- key := hash3(key1, key2, key3)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(key1, key2, key3)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func Memoize3E[K1, K2, K3 comparable, V any](computeFn func(K1, K2, K3) (V, error), opts Options) (func(K1, K2, K3) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(key1 K1, key2 K2, key3 K3) (V, error) {
+ return getSetDirect(context.Background(), cache, hash3(key1, key2, key3), func() (V, error) { return computeFn(key1, key2, key3) })
+ }, nil
}
-// Memoize4E memoizes a function with 4 args that returns (V, error). Errors are not cached.
-func Memoize4E[K1, K2, K3, K4 comparable, V any](computeFn func(K1, K2, K3, K4) (V, error), ttl time.Duration) func(K1, K2, K3, K4) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(key1 K1, key2 K2, key3 K3, key4 K4) (V, error) {
- key := hash4(key1, key2, key3, key4)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(key1, key2, key3, key4)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func Memoize4E[K1, K2, K3, K4 comparable, V any](computeFn func(K1, K2, K3, K4) (V, error), opts Options) (func(K1, K2, K3, K4) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(key1 K1, key2 K2, key3 K3, key4 K4) (V, error) {
+ return getSetDirect(context.Background(), cache, hash4(key1, key2, key3, key4), func() (V, error) { return computeFn(key1, key2, key3, key4) })
+ }, nil
}
-// Memoize5E memoizes a function with 5 args that returns (V, error). Errors are not cached.
-func Memoize5E[K1, K2, K3, K4, K5 comparable, V any](computeFn func(K1, K2, K3, K4, K5) (V, error), ttl time.Duration) func(K1, K2, K3, K4, K5) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(key1 K1, key2 K2, key3 K3, key4 K4, key5 K5) (V, error) {
- key := hash5(key1, key2, key3, key4, key5)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(key1, key2, key3, key4, key5)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func Memoize5E[K1, K2, K3, K4, K5 comparable, V any](computeFn func(K1, K2, K3, K4, K5) (V, error), opts Options) (func(K1, K2, K3, K4, K5) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(key1 K1, key2 K2, key3 K3, key4 K4, key5 K5) (V, error) {
+ return getSetDirect(context.Background(), cache, hash5(key1, key2, key3, key4, key5), func() (V, error) { return computeFn(key1, key2, key3, key4, key5) })
+ }, nil
}
-// Memoize6E memoizes a function with 6 args that returns (V, error). Errors are not cached.
-func Memoize6E[K1, K2, K3, K4, K5, K6 comparable, V any](computeFn func(K1, K2, K3, K4, K5, K6) (V, error), ttl time.Duration) func(K1, K2, K3, K4, K5, K6) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6) (V, error) {
- key := hash6(key1, key2, key3, key4, key5, key6)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(key1, key2, key3, key4, key5, key6)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func Memoize6E[K1, K2, K3, K4, K5, K6 comparable, V any](computeFn func(K1, K2, K3, K4, K5, K6) (V, error), opts Options) (func(K1, K2, K3, K4, K5, K6) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6) (V, error) {
+ return getSetDirect(context.Background(), cache, hash6(key1, key2, key3, key4, key5, key6), func() (V, error) { return computeFn(key1, key2, key3, key4, key5, key6) })
+ }, nil
}
-// Memoize7E memoizes a function with 7 args that returns (V, error). Errors are not cached.
-func Memoize7E[K1, K2, K3, K4, K5, K6, K7 comparable, V any](computeFn func(K1, K2, K3, K4, K5, K6, K7) (V, error), ttl time.Duration) func(K1, K2, K3, K4, K5, K6, K7) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6, key7 K7) (V, error) {
- key := hash7(key1, key2, key3, key4, key5, key6, key7)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(key1, key2, key3, key4, key5, key6, key7)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func Memoize7E[K1, K2, K3, K4, K5, K6, K7 comparable, V any](computeFn func(K1, K2, K3, K4, K5, K6, K7) (V, error), opts Options) (func(K1, K2, K3, K4, K5, K6, K7) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6, key7 K7) (V, error) {
+ return getSetDirect(context.Background(), cache, hash7(key1, key2, key3, key4, key5, key6, key7), func() (V, error) { return computeFn(key1, key2, key3, key4, key5, key6, key7) })
+ }, nil
+}
+
+func getSetDirect[V any](ctx context.Context, cache *Cache[uint64, V], key uint64, computeFn func() (V, error)) (V, error) {
+ return cache.GetOrCompute(ctx, key, func(context.Context) (V, error) {
+ return computeFn()
+ })
}
diff --git a/memoize_ctx.go b/memoize_ctx.go
index 65ab1b7..a4dc3be 100644
--- a/memoize_ctx.go
+++ b/memoize_ctx.go
@@ -1,223 +1,219 @@
-package go_memoize
+package memoize
-import (
- "context"
- "time"
-)
+import "context"
-// MemoizeCtx returns a memoized version of the compute function with a specified TTL.
-func MemoizeCtx[V any](computeFn func(context.Context) V, ttl time.Duration) func(context.Context) V {
- cache := NewCacheSized[uint64, V](1, int64(ttl.Seconds()))
+func MemoizeCtx[V any](computeFn func(context.Context) V, opts Options) (func(context.Context) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(ctx context.Context) V {
- return cache.GetOrCompute(0, func() V {
- return computeFn(ctx)
+ value, err := cache.GetOrCompute(ctx, 0, func(context.Context) (V, error) {
+ return computeFn(ctx), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// MemoizeCtx1 returns a memoized version of the compute function with a single key and a specified TTL.
-func MemoizeCtx1[K comparable, V any](computeFn func(context.Context, K) V, ttl time.Duration) func(context.Context, K) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+func MemoizeCtx1[K comparable, V any](computeFn func(context.Context, K) V, opts Options) (func(context.Context, K) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(ctx context.Context, k K) V {
- return cache.GetOrCompute(hash1(k), func() V {
- return computeFn(ctx, k)
+ value, err := cache.GetOrCompute(ctx, hash1(k), func(context.Context) (V, error) {
+ return computeFn(ctx, k), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// MemoizeCtx2 returns a memoized version of the compute function with two keys and a specified TTL.
-func MemoizeCtx2[K1, K2 comparable, V any](computeFn func(context.Context, K1, K2) V, ttl time.Duration) func(context.Context, K1, K2) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+func MemoizeCtx2[K1, K2 comparable, V any](computeFn func(context.Context, K1, K2) V, opts Options) (func(context.Context, K1, K2) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(ctx context.Context, key1 K1, key2 K2) V {
- return cache.GetOrCompute(hash2(key1, key2), func() V {
- return computeFn(ctx, key1, key2)
+ value, err := cache.GetOrCompute(ctx, hash2(key1, key2), func(context.Context) (V, error) {
+ return computeFn(ctx, key1, key2), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// MemoizeCtx3 returns a memoized version of the compute function with three keys and a specified TTL.
-func MemoizeCtx3[K1, K2, K3 comparable, V any](computeFn func(context.Context, K1, K2, K3) V, ttl time.Duration) func(context.Context, K1, K2, K3) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+func MemoizeCtx3[K1, K2, K3 comparable, V any](computeFn func(context.Context, K1, K2, K3) V, opts Options) (func(context.Context, K1, K2, K3) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(ctx context.Context, key1 K1, key2 K2, key3 K3) V {
- return cache.GetOrCompute(hash3(key1, key2, key3), func() V {
- return computeFn(ctx, key1, key2, key3)
+ value, err := cache.GetOrCompute(ctx, hash3(key1, key2, key3), func(context.Context) (V, error) {
+ return computeFn(ctx, key1, key2, key3), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// MemoizeCtx4 returns a memoized version of the compute function with four keys and a specified TTL.
-func MemoizeCtx4[K1, K2, K3, K4 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4) V, ttl time.Duration) func(context.Context, K1, K2, K3, K4) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+func MemoizeCtx4[K1, K2, K3, K4 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4) V, opts Options) (func(context.Context, K1, K2, K3, K4) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4) V {
- return cache.GetOrCompute(hash4(key1, key2, key3, key4), func() V {
- return computeFn(ctx, key1, key2, key3, key4)
+ value, err := cache.GetOrCompute(ctx, hash4(key1, key2, key3, key4), func(context.Context) (V, error) {
+ return computeFn(ctx, key1, key2, key3, key4), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// MemoizeCtx5 returns a memoized version of the compute function with five keys and a specified TTL.
-func MemoizeCtx5[K1, K2, K3, K4, K5 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5) V, ttl time.Duration) func(context.Context, K1, K2, K3, K4, K5) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+func MemoizeCtx5[K1, K2, K3, K4, K5 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5) V, opts Options) (func(context.Context, K1, K2, K3, K4, K5) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4, key5 K5) V {
- return cache.GetOrCompute(hash5(key1, key2, key3, key4, key5), func() V {
- return computeFn(ctx, key1, key2, key3, key4, key5)
+ value, err := cache.GetOrCompute(ctx, hash5(key1, key2, key3, key4, key5), func(context.Context) (V, error) {
+ return computeFn(ctx, key1, key2, key3, key4, key5), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// MemoizeCtx6 returns a memoized version of the compute function with six keys and a specified TTL.
-func MemoizeCtx6[K1, K2, K3, K4, K5, K6 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5, K6) V, ttl time.Duration) func(context.Context, K1, K2, K3, K4, K5, K6) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+func MemoizeCtx6[K1, K2, K3, K4, K5, K6 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5, K6) V, opts Options) (func(context.Context, K1, K2, K3, K4, K5, K6) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6) V {
- return cache.GetOrCompute(hash6(key1, key2, key3, key4, key5, key6), func() V {
- return computeFn(ctx, key1, key2, key3, key4, key5, key6)
+ value, err := cache.GetOrCompute(ctx, hash6(key1, key2, key3, key4, key5, key6), func(context.Context) (V, error) {
+ return computeFn(ctx, key1, key2, key3, key4, key5, key6), nil
})
- }
+ if err != nil {
+ var zero V
+ return zero
+ }
+ return value
+ }, nil
}
-// MemoizeCtx7 returns a memoized version of the compute function with seven keys and a specified TTL.
-func MemoizeCtx7[K1, K2, K3, K4, K5, K6, K7 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5, K6, K7) V, ttl time.Duration) func(context.Context, K1, K2, K3, K4, K5, K6, K7) V {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
+func MemoizeCtx7[K1, K2, K3, K4, K5, K6, K7 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5, K6, K7) V, opts Options) (func(context.Context, K1, K2, K3, K4, K5, K6, K7) V, error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
+ }
return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6, key7 K7) V {
- return cache.GetOrCompute(hash7(key1, key2, key3, key4, key5, key6, key7), func() V {
- return computeFn(ctx, key1, key2, key3, key4, key5, key6, key7)
+ value, err := cache.GetOrCompute(ctx, hash7(key1, key2, key3, key4, key5, key6, key7), func(context.Context) (V, error) {
+ return computeFn(ctx, key1, key2, key3, key4, key5, key6, key7), nil
})
- }
-}
-
-// --- New context-aware variants returning (V, error) that avoid caching errors ---
-
-// MemoizeCtxE memoizes a context-aware function returning (V, error). Errors are not cached.
-func MemoizeCtxE[V any](computeFn func(context.Context) (V, error), ttl time.Duration) func(context.Context) (V, error) {
- cache := NewCacheSized[uint64, V](1, int64(ttl.Seconds()))
- return func(ctx context.Context) (V, error) {
- if v, ok := cache.Get(0); ok {
- return v, nil
- }
- v, err := computeFn(ctx)
if err != nil {
- return zeroValue[V](), err
+ var zero V
+ return zero
}
- cache.Set(0, v)
- return v, nil
+ return value
+ }, nil
+}
+
+func MemoizeCtxE[V any](computeFn func(context.Context) (V, error), opts Options) (func(context.Context) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(ctx context.Context) (V, error) {
+ return getSetDirect(ctx, cache, 0, func() (V, error) { return computeFn(ctx) })
+ }, nil
}
-// MemoizeCtx1E memoizes a context-aware function with 1 arg returning (V, error). Errors are not cached.
-func MemoizeCtx1E[K comparable, V any](computeFn func(context.Context, K) (V, error), ttl time.Duration) func(context.Context, K) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(ctx context.Context, k K) (V, error) {
- key := hash1(k)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(ctx, k)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func MemoizeCtx1E[K comparable, V any](computeFn func(context.Context, K) (V, error), opts Options) (func(context.Context, K) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(ctx context.Context, k K) (V, error) {
+ return getSetDirect(ctx, cache, hash1(k), func() (V, error) { return computeFn(ctx, k) })
+ }, nil
}
-// MemoizeCtx2E memoizes a context-aware function with 2 args returning (V, error). Errors are not cached.
-func MemoizeCtx2E[K1, K2 comparable, V any](computeFn func(context.Context, K1, K2) (V, error), ttl time.Duration) func(context.Context, K1, K2) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(ctx context.Context, key1 K1, key2 K2) (V, error) {
- key := hash2(key1, key2)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(ctx, key1, key2)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func MemoizeCtx2E[K1, K2 comparable, V any](computeFn func(context.Context, K1, K2) (V, error), opts Options) (func(context.Context, K1, K2) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(ctx context.Context, key1 K1, key2 K2) (V, error) {
+ return getSetDirect(ctx, cache, hash2(key1, key2), func() (V, error) { return computeFn(ctx, key1, key2) })
+ }, nil
}
-// MemoizeCtx3E memoizes a context-aware function with 3 args returning (V, error). Errors are not cached.
-func MemoizeCtx3E[K1, K2, K3 comparable, V any](computeFn func(context.Context, K1, K2, K3) (V, error), ttl time.Duration) func(context.Context, K1, K2, K3) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(ctx context.Context, key1 K1, key2 K2, key3 K3) (V, error) {
- key := hash3(key1, key2, key3)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(ctx, key1, key2, key3)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func MemoizeCtx3E[K1, K2, K3 comparable, V any](computeFn func(context.Context, K1, K2, K3) (V, error), opts Options) (func(context.Context, K1, K2, K3) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(ctx context.Context, key1 K1, key2 K2, key3 K3) (V, error) {
+ return getSetDirect(ctx, cache, hash3(key1, key2, key3), func() (V, error) { return computeFn(ctx, key1, key2, key3) })
+ }, nil
}
-// MemoizeCtx4E memoizes a context-aware function with 4 args returning (V, error). Errors are not cached.
-func MemoizeCtx4E[K1, K2, K3, K4 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4) (V, error), ttl time.Duration) func(context.Context, K1, K2, K3, K4) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4) (V, error) {
- key := hash4(key1, key2, key3, key4)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(ctx, key1, key2, key3, key4)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func MemoizeCtx4E[K1, K2, K3, K4 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4) (V, error), opts Options) (func(context.Context, K1, K2, K3, K4) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4) (V, error) {
+ return getSetDirect(ctx, cache, hash4(key1, key2, key3, key4), func() (V, error) { return computeFn(ctx, key1, key2, key3, key4) })
+ }, nil
}
-// MemoizeCtx5E memoizes a context-aware function with 5 args returning (V, error). Errors are not cached.
-func MemoizeCtx5E[K1, K2, K3, K4, K5 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5) (V, error), ttl time.Duration) func(context.Context, K1, K2, K3, K4, K5) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4, key5 K5) (V, error) {
- key := hash5(key1, key2, key3, key4, key5)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(ctx, key1, key2, key3, key4, key5)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func MemoizeCtx5E[K1, K2, K3, K4, K5 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5) (V, error), opts Options) (func(context.Context, K1, K2, K3, K4, K5) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4, key5 K5) (V, error) {
+ return getSetDirect(ctx, cache, hash5(key1, key2, key3, key4, key5), func() (V, error) { return computeFn(ctx, key1, key2, key3, key4, key5) })
+ }, nil
}
-// MemoizeCtx6E memoizes a context-aware function with 6 args returning (V, error). Errors are not cached.
-func MemoizeCtx6E[K1, K2, K3, K4, K5, K6 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5, K6) (V, error), ttl time.Duration) func(context.Context, K1, K2, K3, K4, K5, K6) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6) (V, error) {
- key := hash6(key1, key2, key3, key4, key5, key6)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(ctx, key1, key2, key3, key4, key5, key6)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func MemoizeCtx6E[K1, K2, K3, K4, K5, K6 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5, K6) (V, error), opts Options) (func(context.Context, K1, K2, K3, K4, K5, K6) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6) (V, error) {
+ return getSetDirect(ctx, cache, hash6(key1, key2, key3, key4, key5, key6), func() (V, error) { return computeFn(ctx, key1, key2, key3, key4, key5, key6) })
+ }, nil
}
-// MemoizeCtx7E memoizes a context-aware function with 7 args returning (V, error). Errors are not cached.
-func MemoizeCtx7E[K1, K2, K3, K4, K5, K6, K7 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5, K6, K7) (V, error), ttl time.Duration) func(context.Context, K1, K2, K3, K4, K5, K6, K7) (V, error) {
- cache := NewCache[uint64, V](int64(ttl.Seconds()))
- return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6, key7 K7) (V, error) {
- key := hash7(key1, key2, key3, key4, key5, key6, key7)
- if v, ok := cache.Get(key); ok {
- return v, nil
- }
- v, err := computeFn(ctx, key1, key2, key3, key4, key5, key6, key7)
- if err != nil {
- return zeroValue[V](), err
- }
- cache.Set(key, v)
- return v, nil
+func MemoizeCtx7E[K1, K2, K3, K4, K5, K6, K7 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5, K6, K7) (V, error), opts Options) (func(context.Context, K1, K2, K3, K4, K5, K6, K7) (V, error), error) {
+ cache, err := newDirectCache[V](opts)
+ if err != nil {
+ return nil, err
}
+ return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6, key7 K7) (V, error) {
+ return getSetDirect(ctx, cache, hash7(key1, key2, key3, key4, key5, key6, key7), func() (V, error) { return computeFn(ctx, key1, key2, key3, key4, key5, key6, key7) })
+ }, nil
}
diff --git a/memoize_ctx_test.go b/memoize_ctx_test.go
index 35e06c1..b12f283 100644
--- a/memoize_ctx_test.go
+++ b/memoize_ctx_test.go
@@ -1,4 +1,4 @@
-package go_memoize
+package memoize
import (
"context"
@@ -14,7 +14,7 @@ func TestMemoizeCtx_NoExpiry(t *testing.T) {
count++
return 1
}
- memoizedFn := MemoizeCtx(computeFn, 0)
+ memoizedFn := mustMemoized(MemoizeCtx(computeFn, Opts().NoExpiration()))
memoizedFn(context.Background())
memoizedFn(context.Background())
if count != 1 {
@@ -28,7 +28,7 @@ func TestMemoizeCtx1_NoExpiry(t *testing.T) {
count++
return key * 2
}
- memoizedFn := MemoizeCtx1(computeFn, 0)
+ memoizedFn := mustMemoized(MemoizeCtx1(computeFn, Opts().NoExpiration()))
memoizedFn(context.Background(), 21)
memoizedFn(context.Background(), 21)
if count != 1 {
@@ -42,7 +42,7 @@ func TestMemoizeCtx2_NoExpiry(t *testing.T) {
count++
return key1 + key2
}
- memoizedFn := MemoizeCtx2(computeFn, 0)
+ memoizedFn := mustMemoized(MemoizeCtx2(computeFn, Opts().NoExpiration()))
memoizedFn(context.Background(), 20, 22)
memoizedFn(context.Background(), 20, 22)
if count != 1 {
@@ -56,7 +56,7 @@ func TestMemoizeCtx3_NoExpiry(t *testing.T) {
count++
return key1 + key2 + key3
}
- memoizedFn := MemoizeCtx3(computeFn, 0)
+ memoizedFn := mustMemoized(MemoizeCtx3(computeFn, Opts().NoExpiration()))
memoizedFn(context.Background(), 10, 20, 12)
memoizedFn(context.Background(), 10, 20, 12)
if count != 1 {
@@ -70,7 +70,7 @@ func TestMemoizeCtx4_NoExpiry(t *testing.T) {
count++
return key1 + key2 + key3 + key4
}
- memoizedFn := MemoizeCtx4(computeFn, 0)
+ memoizedFn := mustMemoized(MemoizeCtx4(computeFn, Opts().NoExpiration()))
memoizedFn(context.Background(), 10, 10, 10, 12)
memoizedFn(context.Background(), 10, 10, 10, 12)
if count != 1 {
@@ -84,7 +84,7 @@ func TestMemoizeCtx5_NoExpiry(t *testing.T) {
count++
return key1 + key2 + key3 + key4 + key5
}
- memoizedFn := MemoizeCtx5(computeFn, 0)
+ memoizedFn := mustMemoized(MemoizeCtx5(computeFn, Opts().NoExpiration()))
memoizedFn(context.Background(), 1, 2, 3, 4, 5)
memoizedFn(context.Background(), 1, 2, 3, 4, 5)
if count != 1 {
@@ -98,7 +98,7 @@ func TestMemoizeCtx6_NoExpiry(t *testing.T) {
count++
return key1 + key2 + key3 + key4 + key5 + key6
}
- memoizedFn := MemoizeCtx6(computeFn, 0)
+ memoizedFn := mustMemoized(MemoizeCtx6(computeFn, Opts().NoExpiration()))
memoizedFn(context.Background(), 1, 2, 3, 4, 5, 6)
memoizedFn(context.Background(), 1, 2, 3, 4, 5, 6)
if count != 1 {
@@ -112,7 +112,7 @@ func TestMemoizeCtx7_NoExpiry(t *testing.T) {
count++
return key1 + key2 + key3 + key4 + key5 + key6 + key7
}
- memoizedFn := MemoizeCtx7(computeFn, 0)
+ memoizedFn := mustMemoized(MemoizeCtx7(computeFn, Opts().NoExpiration()))
memoizedFn(context.Background(), 1, 2, 3, 4, 5, 6, 7)
memoizedFn(context.Background(), 1, 2, 3, 4, 5, 6, 7)
if count != 1 {
@@ -126,7 +126,7 @@ func TestMemoizeCtx_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return 1
}
- memoizedFn := MemoizeCtx(computeFn, 10*time.Second)
+ memoizedFn := mustMemoized(MemoizeCtx(computeFn, Opts().WithTTL(10*time.Second)))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
@@ -147,7 +147,7 @@ func TestMemoizeCtx1_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return key * 2
}
- memoizedFn := MemoizeCtx1(computeFn, 1*time.Minute)
+ memoizedFn := mustMemoized(MemoizeCtx1(computeFn, Opts().WithTTL(1*time.Minute)))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
@@ -168,7 +168,7 @@ func TestMemoizeCtx2_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return key1 + key2
}
- memoizedFn := MemoizeCtx2(computeFn, 1*time.Minute)
+ memoizedFn := mustMemoized(MemoizeCtx2(computeFn, Opts().WithTTL(1*time.Minute)))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
@@ -189,7 +189,7 @@ func TestMemoizeCtx3_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return key1 + key2 + key3
}
- memoizedFn := MemoizeCtx3(computeFn, 1*time.Minute)
+ memoizedFn := mustMemoized(MemoizeCtx3(computeFn, Opts().WithTTL(1*time.Minute)))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
@@ -210,7 +210,7 @@ func TestMemoizeCtx4_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return key1 + key2 + key3 + key4
}
- memoizedFn := MemoizeCtx4(computeFn, 1*time.Minute)
+ memoizedFn := mustMemoized(MemoizeCtx4(computeFn, Opts().WithTTL(1*time.Minute)))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
@@ -231,7 +231,7 @@ func TestMemoizeCtx5_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return key1 + key2 + key3 + key4 + key5
}
- memoizedFn := MemoizeCtx5(computeFn, 1*time.Minute)
+ memoizedFn := mustMemoized(MemoizeCtx5(computeFn, Opts().WithTTL(1*time.Minute)))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
@@ -252,7 +252,7 @@ func TestMemoizeCtx6_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return key1 + key2 + key3 + key4 + key5 + key6
}
- memoizedFn := MemoizeCtx6(computeFn, 1*time.Minute)
+ memoizedFn := mustMemoized(MemoizeCtx6(computeFn, Opts().WithTTL(1*time.Minute)))
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i < 10; i++ {
@@ -273,7 +273,7 @@ func TestMemoizeCtx7_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return key1 + key2 + key3 + key4 + key5 + key6 + key7
}
- memoizedFn := MemoizeCtx7(computeFn, 1*time.Minute)
+ memoizedFn := mustMemoized(MemoizeCtx7(computeFn, Opts().WithTTL(1*time.Minute)))
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i < 10; i++ {
diff --git a/memoize_error_test.go b/memoize_error_test.go
index 910506b..8a3a32a 100644
--- a/memoize_error_test.go
+++ b/memoize_error_test.go
@@ -1,4 +1,4 @@
-package go_memoize
+package memoize
import (
"context"
@@ -17,7 +17,7 @@ func TestMemoize1E_DoesNotCacheError(t *testing.T) {
}
return "ok", nil
}
- m := Memoize1E(fn, time.Minute)
+ m := mustMemoized(Memoize1E(fn, Opts().WithTTL(time.Minute)))
// First call should return error and not be cached
if v, err := m(1); err == nil {
@@ -58,7 +58,7 @@ func TestMemoizeE_DoesNotCacheError(t *testing.T) {
}
return "ok", nil
}
- m := MemoizeE(fn, time.Minute)
+ m := mustMemoized(MemoizeE(fn, Opts().WithTTL(time.Minute)))
// First call fails and should not be cached
if v, err := m(); err == nil {
@@ -99,7 +99,7 @@ func TestMemoize2E_DoesNotCacheError(t *testing.T) {
}
return fmt.Sprintf("%d-%s", a, b), nil
}
- m := Memoize2E(fn, time.Minute)
+ m := mustMemoized(Memoize2E(fn, Opts().WithTTL(time.Minute)))
// First call fails and should not be cached
if v, err := m(5, "x"); err == nil {
@@ -140,7 +140,7 @@ func TestMemoizeCtx1E_DoesNotCacheError(t *testing.T) {
}
return "ok", nil
}
- m := MemoizeCtx1E(fn, time.Minute)
+ m := mustMemoized(MemoizeCtx1E(fn, Opts().WithTTL(time.Minute)))
// First call should return error and not be cached
if v, err := m(context.Background(), 42); err == nil {
diff --git a/memoize_test.go b/memoize_test.go
index 569a28f..8b8589c 100644
--- a/memoize_test.go
+++ b/memoize_test.go
@@ -1,19 +1,111 @@
-package go_memoize
+package memoize
import (
+ "context"
+ "errors"
+ "strconv"
"sync"
"sync/atomic"
"testing"
"time"
)
+func mustMemoized[F any](fn F, err error) F {
+ if err != nil {
+ panic(err)
+ }
+ return fn
+}
+
+func TestMemoize2WithOptionsDefaultStoreCaches(t *testing.T) {
+ calls := 0
+ cached, err := Memoize2(func(org string, id int) string {
+ calls++
+ return org + ":" + strconv.Itoa(id)
+ }, Opts().WithTTL(time.Minute))
+ if err != nil {
+ t.Fatalf("Memoize2 returned error: %v", err)
+ }
+ if got := cached("acme", 42); got != "acme:42" {
+ t.Fatalf("first call = %q", got)
+ }
+ if got := cached("acme", 42); got != "acme:42" {
+ t.Fatalf("second call = %q", got)
+ }
+ if calls != 1 {
+ t.Fatalf("calls = %d, want 1", calls)
+ }
+}
+
+func TestMemoize2WithOptionsInvalidTTLReturnsError(t *testing.T) {
+ _, err := Memoize2(func(org string, id int) string { return org }, Opts().WithTTL(0))
+ if !errors.Is(err, ErrInvalidTTL) {
+ t.Fatalf("err = %v, want ErrInvalidTTL", err)
+ }
+}
+
+func TestDirectStoreStoresRawEntries(t *testing.T) {
+ store := newDirectStore[string]()
+ now := time.Now()
+ stale := Stored[string]{Value: "cached", CreatedAt: now.Add(-2 * time.Minute), FreshUntil: now.Add(-time.Minute)}
+ if err := store.Set(context.Background(), 7, stale); err != nil {
+ t.Fatalf("Set returned error: %v", err)
+ }
+ got, ok, err := store.Get(context.Background(), 7)
+ if err != nil {
+ t.Fatalf("Get returned error: %v", err)
+ }
+ if !ok || got.Value != "cached" || !got.FreshUntil.Equal(stale.FreshUntil) {
+ t.Fatalf("got = %#v, ok = %v", got, ok)
+ }
+}
+
+func TestMemoizeCtx1EKeepStaleOnErrorReturnsStaleValue(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0)
+ calls := 0
+ cached, err := MemoizeCtx1E(func(context.Context, int) (string, error) {
+ calls++
+ if calls > 1 {
+ return "", errors.New("upstream unavailable")
+ }
+ return "fresh", nil
+ }, Opts().
+ WithTTL(time.Second).
+ WithStaleTTL(time.Minute).
+ KeepStaleOnError().
+ WithClock(ClockFunc(func() time.Time { return now })))
+ if err != nil {
+ t.Fatalf("MemoizeCtx1E returned error: %v", err)
+ }
+
+ first, err := cached(context.Background(), 42)
+ if err != nil {
+ t.Fatalf("first call returned error: %v", err)
+ }
+ if first != "fresh" {
+ t.Fatalf("first call = %q, want fresh", first)
+ }
+
+ now = now.Add(2 * time.Minute)
+ second, err := cached(context.Background(), 42)
+ if err != nil {
+ t.Fatalf("second call returned error: %v", err)
+ }
+ if second != "fresh" {
+ t.Fatalf("second call = %q, want stale fresh", second)
+ }
+ if calls != 2 {
+ t.Fatalf("calls = %d, want 2", calls)
+ }
+}
+
func TestMemoizeWithTTL(t *testing.T) {
count := 0
computeFn := func() int {
count++
return 1
}
- memoizedFn := Memoize(computeFn, 1*time.Second)
+ memoizedFn := mustMemoized(Memoize(computeFn, Opts().WithTTL(1*time.Second)))
memoizedFn()
memoizedFn()
if count != 1 {
@@ -34,7 +126,7 @@ func TestMemoize1WithTTL(t *testing.T) {
count++
return key * 2
}
- memoizedFn := Memoize1(computeFn, 1*time.Second)
+ memoizedFn := mustMemoized(Memoize1(computeFn, Opts().WithTTL(1*time.Second)))
memoizedFn(21)
memoizedFn(21)
if count != 1 {
@@ -54,7 +146,7 @@ func TestMemoize2WithTTL(t *testing.T) {
count++
return key1 + key2
}
- memoizedFn := Memoize2(computeFn, 1*time.Second)
+ memoizedFn := mustMemoized(Memoize2(computeFn, Opts().WithTTL(1*time.Second)))
memoizedFn(20, 22)
memoizedFn(20, 22)
if count != 1 {
@@ -74,7 +166,7 @@ func TestMemoize3WithTTL(t *testing.T) {
count++
return key1 + key2 + key3
}
- memoizedFn := Memoize3(computeFn, 1*time.Second)
+ memoizedFn := mustMemoized(Memoize3(computeFn, Opts().WithTTL(1*time.Second)))
memoizedFn(10, 20, 12)
memoizedFn(10, 20, 12)
if count != 1 {
@@ -94,7 +186,7 @@ func TestMemoize4WithTTL(t *testing.T) {
count++
return key1 + key2 + key3 + key4
}
- memoizedFn := Memoize4(computeFn, 1*time.Second)
+ memoizedFn := mustMemoized(Memoize4(computeFn, Opts().WithTTL(1*time.Second)))
memoizedFn(10, 10, 10, 12)
memoizedFn(10, 10, 10, 12)
if count != 1 {
@@ -113,7 +205,7 @@ func TestMemoizeWithTTL_NoExpiry(t *testing.T) {
count++
return 1
}
- memoizedFn := Memoize(computeFn, 0)
+ memoizedFn := mustMemoized(Memoize(computeFn, Opts().NoExpiration()))
memoizedFn()
memoizedFn()
if count != 1 {
@@ -127,7 +219,7 @@ func TestMemoize1WithTTL_NoExpiry(t *testing.T) {
count++
return key * 2
}
- memoizedFn := Memoize1(computeFn, 0)
+ memoizedFn := mustMemoized(Memoize1(computeFn, Opts().NoExpiration()))
memoizedFn(21)
memoizedFn(21)
if count != 1 {
@@ -141,7 +233,7 @@ func TestMemoize2WithTTL_NoExpiry(t *testing.T) {
count++
return key1 + key2
}
- memoizedFn := Memoize2(computeFn, 0)
+ memoizedFn := mustMemoized(Memoize2(computeFn, Opts().NoExpiration()))
memoizedFn(20, 22)
memoizedFn(20, 22)
if count != 1 {
@@ -155,7 +247,7 @@ func TestMemoize3WithTTL_NoExpiry(t *testing.T) {
count++
return key1 + key2 + key3
}
- memoizedFn := Memoize3(computeFn, 0)
+ memoizedFn := mustMemoized(Memoize3(computeFn, Opts().NoExpiration()))
memoizedFn(10, 20, 12)
memoizedFn(10, 20, 12)
memoizedFn(10, 20, 12)
@@ -171,7 +263,7 @@ func TestMemoize4WithTTL_NoExpiry(t *testing.T) {
count++
return key1 + key2 + key3 + key4
}
- memoizedFn := Memoize4(computeFn, 0)
+ memoizedFn := mustMemoized(Memoize4(computeFn, Opts().NoExpiration()))
memoizedFn(10, 10, 10, 12)
memoizedFn(10, 10, 10, 12)
memoizedFn(10, 11, 10, 12)
@@ -186,7 +278,7 @@ func TestMemoize5WithTTL(t *testing.T) {
count++
return key1 + key2 + key3 + key4 + key5
}
- memoizedFn := Memoize5(computeFn, 1*time.Second)
+ memoizedFn := mustMemoized(Memoize5(computeFn, Opts().WithTTL(1*time.Second)))
memoizedFn(1, 2, 3, 4, 5)
memoizedFn(1, 2, 3, 4, 5)
if count != 1 {
@@ -206,7 +298,7 @@ func TestMemoize6WithTTL(t *testing.T) {
count++
return key1 + key2 + key3 + key4 + key5 + key6
}
- memoizedFn := Memoize6(computeFn, 1*time.Second)
+ memoizedFn := mustMemoized(Memoize6(computeFn, Opts().WithTTL(1*time.Second)))
memoizedFn(1, 2, 3, 4, 5, 6)
memoizedFn(1, 2, 3, 4, 5, 6)
if count != 1 {
@@ -226,7 +318,7 @@ func TestMemoize7WithTTL(t *testing.T) {
count++
return key1 + key2 + key3 + key4 + key5 + key6 + key7
}
- memoizedFn := Memoize7(computeFn, 1*time.Second)
+ memoizedFn := mustMemoized(Memoize7(computeFn, Opts().WithTTL(1*time.Second)))
memoizedFn(1, 2, 3, 4, 5, 6, 7)
memoizedFn(1, 2, 3, 4, 5, 6, 7)
if count != 1 {
@@ -246,7 +338,7 @@ func TestMemoize5WithTTL_NoExpiry(t *testing.T) {
count++
return key1 + key2 + key3 + key4 + key5
}
- memoizedFn := Memoize5(computeFn, 0)
+ memoizedFn := mustMemoized(Memoize5(computeFn, Opts().NoExpiration()))
memoizedFn(1, 2, 3, 4, 5)
memoizedFn(1, 2, 3, 4, 5)
if count != 1 {
@@ -260,7 +352,7 @@ func TestMemoize6WithTTL_NoExpiry(t *testing.T) {
count++
return key1 + key2 + key3 + key4 + key5 + key6
}
- memoizedFn := Memoize6(computeFn, 0)
+ memoizedFn := mustMemoized(Memoize6(computeFn, Opts().NoExpiration()))
memoizedFn(1, 2, 3, 4, 5, 6)
memoizedFn(1, 2, 3, 4, 5, 6)
if count != 1 {
@@ -274,7 +366,7 @@ func TestMemoize7WithTTL_NoExpiry(t *testing.T) {
count++
return key1 + key2 + key3 + key4 + key5 + key6 + key7
}
- memoizedFn := Memoize7(computeFn, 0)
+ memoizedFn := mustMemoized(Memoize7(computeFn, Opts().NoExpiration()))
memoizedFn(1, 2, 3, 4, 5, 6, 7)
memoizedFn(1, 2, 3, 4, 5, 6, 7)
memoizedFn(1, 2, 3, 4, 5, 44, 77)
@@ -290,7 +382,7 @@ func TestMemoizeWithTTL_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return 1
}
- memoizedFn := Memoize(computeFn, 10*time.Second)
+ memoizedFn := mustMemoized(Memoize(computeFn, Opts().WithTTL(10*time.Second)))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
@@ -311,7 +403,7 @@ func TestMemoize1WithTTL_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return key * 2
}
- memoizedFn := Memoize1(computeFn, 1*time.Minute)
+ memoizedFn := mustMemoized(Memoize1(computeFn, Opts().WithTTL(1*time.Minute)))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
@@ -332,7 +424,7 @@ func TestMemoize2WithTTL_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return key1 + key2
}
- memoizedFn := Memoize2(computeFn, 1*time.Minute)
+ memoizedFn := mustMemoized(Memoize2(computeFn, Opts().WithTTL(1*time.Minute)))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
@@ -353,7 +445,7 @@ func TestMemoize3WithTTL_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return key1 + key2 + key3
}
- memoizedFn := Memoize3(computeFn, 1*time.Minute)
+ memoizedFn := mustMemoized(Memoize3(computeFn, Opts().WithTTL(1*time.Minute)))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
@@ -374,7 +466,7 @@ func TestMemoize4WithTTL_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return key1 + key2 + key3 + key4
}
- memoizedFn := Memoize4(computeFn, 1*time.Minute)
+ memoizedFn := mustMemoized(Memoize4(computeFn, Opts().WithTTL(1*time.Minute)))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
@@ -395,7 +487,7 @@ func TestMemoize5WithTTL_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return key1 + key2 + key3 + key4 + key5
}
- memoizedFn := Memoize5(computeFn, 1*time.Minute)
+ memoizedFn := mustMemoized(Memoize5(computeFn, Opts().WithTTL(1*time.Minute)))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
@@ -416,7 +508,7 @@ func TestMemoize6WithTTL_ConcurrentAccess(t *testing.T) {
atomic.AddInt32(&count, 1)
return key1 + key2 + key3 + key4 + key5 + key6
}
- memoizedFn := Memoize6(computeFn, 1*time.Minute)
+ memoizedFn := mustMemoized(Memoize6(computeFn, Opts().WithTTL(1*time.Minute)))
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i < 10; i++ {
diff --git a/metrics.go b/metrics.go
new file mode 100644
index 0000000..872ba6a
--- /dev/null
+++ b/metrics.go
@@ -0,0 +1,31 @@
+package memoize
+
+import "time"
+
+type MetricEventKind uint8
+
+const (
+ MetricHit MetricEventKind = iota + 1
+ MetricMiss
+ MetricStaleHit
+ MetricRefreshStart
+ MetricRefreshSuccess
+ MetricRefreshError
+ MetricSet
+ MetricDelete
+)
+
+type MetricEvent struct {
+ Kind MetricEventKind
+ Key string
+ Duration time.Duration
+ Err error
+}
+
+type Metrics interface {
+ RecordMetric(MetricEvent)
+}
+
+type noopMetrics struct{}
+
+func (noopMetrics) RecordMetric(MetricEvent) {}
diff --git a/metrics/inmem.go b/metrics/inmem.go
new file mode 100644
index 0000000..677f5b5
--- /dev/null
+++ b/metrics/inmem.go
@@ -0,0 +1,163 @@
+package metrics
+
+import (
+ "sort"
+ "sync"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+)
+
+// CacheStats holds aggregated metrics for a single metric key.
+type CacheStats struct {
+ Hits int64
+ Misses int64
+ StaleHits int64
+ Sets int64
+ Deletes int64
+ RefreshSuccess int64
+ RefreshErrors int64
+ HitRatePercent float64
+ // Latency percentiles (milliseconds), populated if latency samples exist.
+ LatencyP50Ms float64
+ LatencyP95Ms float64
+ LatencyP99Ms float64
+}
+
+type cacheEntry struct {
+ mu sync.Mutex
+ hits int64
+ misses int64
+ staleHits int64
+ sets int64
+ deletes int64
+ refreshSuccess int64
+ refreshErrors int64
+ // latency samples in milliseconds, capped at 1024 (ring buffer).
+ latencies [1024]float64
+ latLen int
+ latHead int
+}
+
+func (e *cacheEntry) addLatency(d time.Duration) {
+ e.mu.Lock()
+ e.latencies[e.latHead] = float64(d.Milliseconds())
+ e.latHead = (e.latHead + 1) % len(e.latencies)
+ if e.latLen < len(e.latencies) {
+ e.latLen++
+ }
+ e.mu.Unlock()
+}
+
+func (e *cacheEntry) percentiles() (p50, p95, p99 float64) {
+ e.mu.Lock()
+ if e.latLen == 0 {
+ e.mu.Unlock()
+ return 0, 0, 0
+ }
+ samples := make([]float64, e.latLen)
+ copy(samples, e.latencies[:e.latLen])
+ e.mu.Unlock()
+ sort.Float64s(samples)
+ idx := func(p float64) float64 {
+ i := int(p * float64(len(samples)-1))
+ return samples[i]
+ }
+ return idx(0.50), idx(0.95), idx(0.99)
+}
+
+// InMemoryMetrics is a thread-safe implementation that accumulates
+// hit/miss/refresh counts and latency percentiles in memory.
+type InMemoryMetrics struct {
+ mu sync.RWMutex
+ caches map[string]*cacheEntry
+}
+
+// NewInMemoryMetrics returns a ready-to-use InMemoryMetrics.
+func NewInMemoryMetrics() *InMemoryMetrics {
+ return &InMemoryMetrics{caches: make(map[string]*cacheEntry)}
+}
+
+func (m *InMemoryMetrics) entry(name string) *cacheEntry {
+ m.mu.RLock()
+ e := m.caches[name]
+ m.mu.RUnlock()
+ if e != nil {
+ return e
+ }
+ m.mu.Lock()
+ if m.caches[name] == nil {
+ m.caches[name] = &cacheEntry{}
+ }
+ e = m.caches[name]
+ m.mu.Unlock()
+ return e
+}
+
+func (m *InMemoryMetrics) RecordMetric(event memoize.MetricEvent) {
+ if event.Kind == memoize.MetricRefreshStart {
+ return
+ }
+ e := m.entry(event.Key)
+ e.mu.Lock()
+ switch event.Kind {
+ case memoize.MetricHit:
+ e.hits++
+ case memoize.MetricMiss:
+ e.misses++
+ case memoize.MetricStaleHit:
+ e.staleHits++
+ case memoize.MetricSet:
+ e.sets++
+ case memoize.MetricDelete:
+ e.deletes++
+ case memoize.MetricRefreshSuccess:
+ e.refreshSuccess++
+ case memoize.MetricRefreshError:
+ e.refreshErrors++
+ }
+ e.mu.Unlock()
+ if event.Kind == memoize.MetricRefreshSuccess {
+ e.addLatency(event.Duration)
+ }
+}
+
+// Stats returns a snapshot of all metrics keyed by MetricEvent.Key.
+func (m *InMemoryMetrics) Stats() map[string]CacheStats {
+ m.mu.RLock()
+ names := make([]string, 0, len(m.caches))
+ for n := range m.caches {
+ names = append(names, n)
+ }
+ m.mu.RUnlock()
+
+ out := make(map[string]CacheStats, len(names))
+ for _, name := range names {
+ e := m.entry(name)
+ e.mu.Lock()
+ cs := CacheStats{
+ Hits: e.hits,
+ Misses: e.misses,
+ StaleHits: e.staleHits,
+ Sets: e.sets,
+ Deletes: e.deletes,
+ RefreshSuccess: e.refreshSuccess,
+ RefreshErrors: e.refreshErrors,
+ }
+ e.mu.Unlock()
+ total := cs.Hits + cs.Misses
+ if total > 0 {
+ cs.HitRatePercent = float64(cs.Hits) / float64(total) * 100
+ }
+ cs.LatencyP50Ms, cs.LatencyP95Ms, cs.LatencyP99Ms = e.percentiles()
+ out[name] = cs
+ }
+ return out
+}
+
+// Reset clears all accumulated metrics.
+func (m *InMemoryMetrics) Reset() {
+ m.mu.Lock()
+ m.caches = make(map[string]*cacheEntry)
+ m.mu.Unlock()
+}
diff --git a/metrics/inmem_test.go b/metrics/inmem_test.go
new file mode 100644
index 0000000..4d22128
--- /dev/null
+++ b/metrics/inmem_test.go
@@ -0,0 +1,76 @@
+package metrics_test
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/agkloop/go_memoize/metrics"
+)
+
+func TestInMemoryMetrics_HitMissCount(t *testing.T) {
+ m := metrics.NewInMemoryMetrics()
+
+ m.RecordMetric(memoize.MetricEvent{Kind: memoize.MetricHit, Key: "users"})
+ m.RecordMetric(memoize.MetricEvent{Kind: memoize.MetricHit, Key: "users"})
+ m.RecordMetric(memoize.MetricEvent{Kind: memoize.MetricMiss, Key: "users"})
+
+ s := m.Stats()
+ uc, ok := s["users"]
+ if !ok {
+ t.Fatal("expected stats for 'users'")
+ }
+ if uc.Hits != 2 {
+ t.Errorf("expected 2 hits, got %d", uc.Hits)
+ }
+ if uc.Misses != 1 {
+ t.Errorf("expected 1 miss, got %d", uc.Misses)
+ }
+ if uc.HitRatePercent < 66.0 || uc.HitRatePercent > 67.0 {
+ t.Errorf("expected hit rate ~66.7%%, got %.2f", uc.HitRatePercent)
+ }
+}
+
+func TestInMemoryMetrics_RefreshCounts(t *testing.T) {
+ m := metrics.NewInMemoryMetrics()
+
+ m.RecordMetric(memoize.MetricEvent{Kind: memoize.MetricRefreshStart, Key: "feed"})
+ m.RecordMetric(memoize.MetricEvent{Kind: memoize.MetricRefreshSuccess, Key: "feed", Duration: 10 * time.Millisecond})
+ m.RecordMetric(memoize.MetricEvent{Kind: memoize.MetricRefreshStart, Key: "feed"})
+ m.RecordMetric(memoize.MetricEvent{Kind: memoize.MetricRefreshError, Key: "feed", Err: errors.New("timeout")})
+
+ s := m.Stats()
+ fc := s["feed"]
+ if fc.RefreshSuccess != 1 {
+ t.Errorf("expected 1 success, got %d", fc.RefreshSuccess)
+ }
+ if fc.RefreshErrors != 1 {
+ t.Errorf("expected 1 error, got %d", fc.RefreshErrors)
+ }
+}
+
+func TestInMemoryMetrics_MultipleCaches(t *testing.T) {
+ m := metrics.NewInMemoryMetrics()
+ m.RecordMetric(memoize.MetricEvent{Kind: memoize.MetricHit, Key: "a"})
+ m.RecordMetric(memoize.MetricEvent{Kind: memoize.MetricHit, Key: "a"})
+ m.RecordMetric(memoize.MetricEvent{Kind: memoize.MetricMiss, Key: "b"})
+
+ s := m.Stats()
+ if _, ok := s["a"]; !ok {
+ t.Fatal("expected stats for 'a'")
+ }
+ if _, ok := s["b"]; !ok {
+ t.Fatal("expected stats for 'b'")
+ }
+}
+
+func TestInMemoryMetrics_Reset(t *testing.T) {
+ m := metrics.NewInMemoryMetrics()
+ m.RecordMetric(memoize.MetricEvent{Kind: memoize.MetricHit, Key: "x"})
+ m.Reset()
+ s := m.Stats()
+ if len(s) != 0 {
+ t.Fatalf("expected empty stats after Reset, got %v", s)
+ }
+}
diff --git a/options.go b/options.go
new file mode 100644
index 0000000..94f4604
--- /dev/null
+++ b/options.go
@@ -0,0 +1,62 @@
+package memoize
+
+import (
+ "sync"
+ "time"
+)
+
+type Cache[K comparable, V any] struct {
+ store Store[K, V]
+ peeker peekingStore[K, V]
+ ttlSet bool
+ ttl time.Duration
+ staleTTL time.Duration
+ noExpiration bool
+ bypass bool
+ keepStaleOnError bool
+ metrics Metrics
+ metricsEnabled bool
+ clock Clock
+ refreshTimeout time.Duration
+ flightMu sync.Mutex
+ flights map[K]*flight[V]
+}
+
+func New[K comparable, V any](opts ...Options) (*Cache[K, V], error) {
+ c := &Cache[K, V]{
+ metrics: noopMetrics{},
+ clock: NewTickerClock(time.Millisecond),
+ refreshTimeout: 30 * time.Second,
+ flights: make(map[K]*flight[V]),
+ }
+ for _, opt := range opts {
+ if err := applyOptions(c, opt); err != nil {
+ return nil, err
+ }
+ }
+ if c.ttlSet && c.ttl <= 0 {
+ return nil, ErrInvalidTTL
+ }
+ if c.staleTTL < 0 {
+ return nil, ErrInvalidStaleTTL
+ }
+ if c.ttl == 0 && !c.noExpiration && !c.bypass {
+ return nil, ErrMissingExpirationPolicy
+ }
+ if c.ttl == 0 && c.staleTTL > 0 {
+ return nil, ErrInvalidStaleTTL
+ }
+ if c.store == nil && !c.bypass {
+ return nil, ErrMissingStore
+ }
+ return c, nil
+}
+
+// Stop releases background resources held by the cache.
+// If the cache uses a TickerClock (the default), Stop shuts down its
+// background goroutine. Safe to call multiple times.
+func (c *Cache[K, V]) Stop() {
+ if tc, ok := c.clock.(*TickerClock); ok {
+ tc.Stop()
+ }
+}
diff --git a/options_test.go b/options_test.go
new file mode 100644
index 0000000..c40dabc
--- /dev/null
+++ b/options_test.go
@@ -0,0 +1,53 @@
+package memoize_test
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/agkloop/go_memoize/stores/memory"
+)
+
+func TestNewRequiresExpirationPolicy(t *testing.T) {
+ _, err := memoize.New[string, string]()
+ if !errors.Is(err, memoize.ErrMissingExpirationPolicy) {
+ t.Fatalf("expected ErrMissingExpirationPolicy, got %v", err)
+ }
+}
+
+func TestNewRejectsNonPositiveTTL(t *testing.T) {
+ _, err := memoize.New[string, string](memoize.Opts().WithTTL(0))
+ if !errors.Is(err, memoize.ErrInvalidTTL) {
+ t.Fatalf("expected ErrInvalidTTL, got %v", err)
+ }
+}
+
+func TestNewRejectsStaleTTLWithoutTTL(t *testing.T) {
+ _, err := memoize.New[string, string](memoize.Opts().NoExpiration().WithStaleTTL(time.Second))
+ if !errors.Is(err, memoize.ErrInvalidStaleTTL) {
+ t.Fatalf("expected ErrInvalidStaleTTL, got %v", err)
+ }
+}
+
+func TestNewRejectsWrongStoreType(t *testing.T) {
+ _, err := memoize.New[string, string](memoize.Opts().WithStore(memory.New[int, string](16)).WithTTL(time.Minute))
+ if !errors.Is(err, memoize.ErrInvalidStore) {
+ t.Fatalf("expected ErrInvalidStore, got %v", err)
+ }
+}
+
+func TestNewRequiresStoreForExplicitCache(t *testing.T) {
+ _, err := memoize.New[string, string](memoize.Opts().WithTTL(time.Minute))
+ if !errors.Is(err, memoize.ErrMissingStore) {
+ t.Fatalf("expected ErrMissingStore, got %v", err)
+ }
+}
+
+func TestNewRejectsTypedNilStore(t *testing.T) {
+ var store *memory.Store[string, string]
+ _, err := memoize.New[string, string](memoize.Opts().WithStore(store).WithTTL(time.Minute))
+ if !errors.Is(err, memoize.ErrInvalidStore) {
+ t.Fatalf("expected ErrInvalidStore, got %v", err)
+ }
+}
diff --git a/scripts/check-docs-skill-sync.sh b/scripts/check-docs-skill-sync.sh
new file mode 100755
index 0000000..aa81bd6
--- /dev/null
+++ b/scripts/check-docs-skill-sync.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env sh
+set -eu
+
+skill_file=".agents/skills/go-memoize-package/SKILL.md"
+mode="${1:-worktree}"
+
+case "$mode" in
+ --staged)
+ changed_files=$(git diff --name-only --cached | sort -u)
+ ;;
+ worktree)
+ changed_files=$(
+ {
+ git diff --name-only --cached
+ git diff --name-only
+ git ls-files --others --exclude-standard
+ } | sort -u
+ )
+ ;;
+ *)
+ echo "usage: $0 [--staged]" >&2
+ exit 2
+ ;;
+esac
+
+docs_changed=$(printf '%s\n' "$changed_files" | awk '
+ /^README\.md$/ { found=1 }
+ /^docs\// && $0 !~ /^docs\/superpowers\// { found=1 }
+ /^examples\// { found=1 }
+ /^adapters\/redis\/examples\// { found=1 }
+ END { if (found) print "yes" }
+')
+
+if [ "$docs_changed" = "yes" ]; then
+ if [ ! -f "$skill_file" ]; then
+ echo "Public docs or examples changed, but $skill_file is missing." >&2
+ exit 1
+ fi
+
+ missing=""
+ for required in \
+ "## Public API Rules" \
+ "## Background And Loader Semantics" \
+ "## Docs Sync Rule" \
+ "background.Value.Get()" \
+ "memoize.New[K,V]" \
+ "RecordMetric(memoize.MetricEvent)"
+ do
+ if ! grep -Fq "$required" "$skill_file"; then
+ missing="${missing}\n- ${required}"
+ fi
+ done
+
+ if [ -n "$missing" ]; then
+ printf '%s\n' "Public docs or examples changed, but $skill_file is missing sync anchors:" >&2
+ printf '%b\n' "$missing" >&2
+ printf '%s\n' "Update the go-memoize-package skill before finishing docs-heavy work." >&2
+ exit 1
+ fi
+fi
+
+exit 0
diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh
new file mode 100755
index 0000000..5059585
--- /dev/null
+++ b/scripts/pre-commit.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env sh
+set -eu
+
+root=$(git rev-parse --show-toplevel)
+cd "$root"
+
+log() {
+ printf '\n==> %s\n' "$1"
+}
+
+go_files=$(
+ for file in $(git ls-files '*.go'); do
+ if [ -f "$file" ]; then
+ printf '%s\n' "$file"
+ fi
+ done
+)
+
+log "docs/skill sync"
+scripts/check-docs-skill-sync.sh --staged
+
+log "gofmt check"
+if [ -n "$go_files" ]; then
+ unformatted=$(gofmt -l $go_files)
+ if [ -n "$unformatted" ]; then
+ printf '%s\n' "$unformatted" >&2
+ printf '%s\n' "Run gofmt on the files above." >&2
+ exit 1
+ fi
+fi
+
+log "public docs/examples legacy import guard"
+if git grep -n -E 'github\.com/agkloop/go_memoize/(v2|helpers)|go_memoize/(v2|helpers)' -- README.md docs examples adapters/redis/examples ':!docs/superpowers'; then
+ printf '%s\n' "Public docs/examples must not use legacy module or helper import paths." >&2
+ exit 1
+fi
+
+log "root go vet"
+go vet ./...
+
+log "root tests"
+go test ./... -count=1
+
+log "redis adapter tests"
+(
+ cd adapters/redis
+ go test ./... -count=1
+)
+
+log "pre-commit checks passed"
diff --git a/scripts/profile.sh b/scripts/profile.sh
new file mode 100755
index 0000000..1140f67
--- /dev/null
+++ b/scripts/profile.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Run all v2 benchmarks with profiling enabled.
+# Profiles land in v2/benchmarks/cpu.prof and v2/benchmarks/mem.prof.
+# Usage: ./scripts/profile.sh [bench-regex]
+
+BENCH="${1:-.}"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+BENCH_DIR="$SCRIPT_DIR/../v2/benchmarks"
+PPROF_PID=""
+PPROF_MEM_PID=""
+
+cleanup() {
+ if [[ -n "$PPROF_PID" ]]; then
+ kill "$PPROF_PID" 2>/dev/null || true
+ fi
+ if [[ -n "$PPROF_MEM_PID" ]]; then
+ kill "$PPROF_MEM_PID" 2>/dev/null || true
+ fi
+}
+
+trap cleanup EXIT INT TERM
+
+echo "==> Running benchmarks matching: $BENCH"
+cd "$BENCH_DIR"
+BENCH_PROFILE=1 go test . \
+ -bench="$BENCH" \
+ -benchmem \
+ -benchtime=10s \
+ -count=1 \
+ -timeout=300s
+
+echo ""
+echo "==> Profiles written:"
+echo " $BENCH_DIR/cpu.prof"
+echo " $BENCH_DIR/mem.prof"
+echo ""
+echo "==> Opening CPU profile (ctrl-C to exit):"
+go tool pprof -http=:6060 "$BENCH_DIR/cpu.prof" &
+PPROF_PID=$!
+
+echo "==> Opening MEM profile on :6061 (ctrl-C to exit):"
+go tool pprof -http=:6061 "$BENCH_DIR/mem.prof" &
+PPROF_MEM_PID=$!
+
+echo ""
+echo "CPU flamegraph: http://localhost:6060/ui/flamegraph"
+echo "MEM flamegraph: http://localhost:6061/ui/flamegraph"
+echo ""
+echo "Press ENTER to stop both pprof servers."
+read -r || true
diff --git a/scripts/verify-ci-local.sh b/scripts/verify-ci-local.sh
new file mode 100755
index 0000000..b93e334
--- /dev/null
+++ b/scripts/verify-ci-local.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env sh
+set -eu
+
+root=$(git rev-parse --show-toplevel)
+cd "$root"
+
+require_optional=${REQUIRE_OPTIONAL_TOOLS:-0}
+
+log() {
+ printf '\n==> %s\n' "$1"
+}
+
+run_optional() {
+ tool=$1
+ shift
+ if command -v "$tool" >/dev/null 2>&1; then
+ "$tool" "$@"
+ return
+ fi
+
+ if [ "$require_optional" = "1" ]; then
+ printf '%s\n' "Required tool not found on PATH: $tool" >&2
+ exit 1
+ fi
+
+ printf '%s\n' "Skipping optional check; $tool is not installed." >&2
+}
+
+check_tidy() {
+ module_dir=$1
+ mod_file=$module_dir/go.mod
+ sum_file=$module_dir/go.sum
+
+ log "go mod tidy check: $module_dir"
+ (
+ cd "$module_dir"
+ go mod tidy
+ )
+ git diff --exit-code -- "$mod_file" "$sum_file"
+}
+
+go_files=$(
+ for file in $(git ls-files '*.go'); do
+ if [ -f "$file" ]; then
+ printf '%s\n' "$file"
+ fi
+ done
+)
+
+log "docs/skill sync"
+scripts/check-docs-skill-sync.sh
+
+log "gofmt check"
+if [ -n "$go_files" ]; then
+ unformatted=$(gofmt -l $go_files)
+ if [ -n "$unformatted" ]; then
+ printf '%s\n' "$unformatted" >&2
+ printf '%s\n' "Run gofmt on the files above." >&2
+ exit 1
+ fi
+fi
+
+log "public docs/examples legacy import guard"
+if git grep -n -E 'github\.com/agkloop/go_memoize/(v2|helpers)|go_memoize/(v2|helpers)' -- README.md docs examples adapters/redis/examples ':!docs/superpowers'; then
+ printf '%s\n' "Public docs/examples must not use legacy module or helper import paths." >&2
+ exit 1
+fi
+
+check_tidy .
+check_tidy adapters/redis
+
+log "root go vet"
+go vet ./...
+
+log "redis adapter go vet"
+(
+ cd adapters/redis
+ go vet ./...
+)
+
+log "root tests"
+go test ./... -count=1
+
+log "root race tests"
+go test ./... -race -count=1
+
+log "redis adapter tests"
+(
+ cd adapters/redis
+ go test ./... -count=1
+)
+
+log "redis adapter race tests"
+(
+ cd adapters/redis
+ go test ./... -race -count=1
+)
+
+log "benchmark smoke"
+go test ./benchmarks/ -bench=. -benchmem -benchtime=100ms -count=1
+
+log "govulncheck root"
+run_optional govulncheck ./...
+
+log "govulncheck redis adapter"
+if command -v govulncheck >/dev/null 2>&1; then
+ (
+ cd adapters/redis
+ govulncheck ./...
+ )
+elif [ "$require_optional" = "1" ]; then
+ printf '%s\n' "Required tool not found on PATH: govulncheck" >&2
+ exit 1
+else
+ printf '%s\n' "Skipping optional check; govulncheck is not installed." >&2
+fi
+
+log "golangci-lint root"
+run_optional golangci-lint run ./...
+
+log "golangci-lint redis adapter"
+if command -v golangci-lint >/dev/null 2>&1; then
+ (
+ cd adapters/redis
+ golangci-lint run ./...
+ )
+elif [ "$require_optional" = "1" ]; then
+ printf '%s\n' "Required tool not found on PATH: golangci-lint" >&2
+ exit 1
+else
+ printf '%s\n' "Skipping optional check; golangci-lint is not installed." >&2
+fi
+
+log "full verification passed"
diff --git a/serializer.go b/serializer.go
new file mode 100644
index 0000000..3462b20
--- /dev/null
+++ b/serializer.go
@@ -0,0 +1,6 @@
+package memoize
+
+type Serializer[V any] interface {
+ Marshal(V) ([]byte, error)
+ Unmarshal([]byte) (V, error)
+}
diff --git a/serializers/func.go b/serializers/func.go
new file mode 100644
index 0000000..d52f768
--- /dev/null
+++ b/serializers/func.go
@@ -0,0 +1,14 @@
+package serializers
+
+type Func[V any] struct {
+ MarshalFunc func(V) ([]byte, error)
+ UnmarshalFunc func([]byte) (V, error)
+}
+
+func (f Func[V]) Marshal(value V) ([]byte, error) {
+ return f.MarshalFunc(value)
+}
+
+func (f Func[V]) Unmarshal(data []byte) (V, error) {
+ return f.UnmarshalFunc(data)
+}
diff --git a/serializers/gob.go b/serializers/gob.go
new file mode 100644
index 0000000..2d036ac
--- /dev/null
+++ b/serializers/gob.go
@@ -0,0 +1,20 @@
+package serializers
+
+import (
+ "bytes"
+ "encoding/gob"
+)
+
+type Gob[V any] struct{}
+
+func (Gob[V]) Marshal(value V) ([]byte, error) {
+ var buf bytes.Buffer
+ err := gob.NewEncoder(&buf).Encode(value)
+ return buf.Bytes(), err
+}
+
+func (Gob[V]) Unmarshal(data []byte) (V, error) {
+ var value V
+ err := gob.NewDecoder(bytes.NewReader(data)).Decode(&value)
+ return value, err
+}
diff --git a/serializers/json.go b/serializers/json.go
new file mode 100644
index 0000000..99eba5d
--- /dev/null
+++ b/serializers/json.go
@@ -0,0 +1,15 @@
+package serializers
+
+import "encoding/json"
+
+type JSON[V any] struct{}
+
+func (JSON[V]) Marshal(value V) ([]byte, error) {
+ return json.Marshal(value)
+}
+
+func (JSON[V]) Unmarshal(data []byte) (V, error) {
+ var value V
+ err := json.Unmarshal(data, &value)
+ return value, err
+}
diff --git a/serializers/serializers_test.go b/serializers/serializers_test.go
new file mode 100644
index 0000000..a6d2f69
--- /dev/null
+++ b/serializers/serializers_test.go
@@ -0,0 +1,38 @@
+package serializers
+
+import "testing"
+
+type sample struct {
+ Name string
+ Age int
+}
+
+func TestJSONRoundTrip(t *testing.T) {
+ serializer := JSON[sample]{}
+ data, err := serializer.Marshal(sample{Name: "Ada", Age: 37})
+ if err != nil {
+ t.Fatalf("marshal failed: %v", err)
+ }
+ got, err := serializer.Unmarshal(data)
+ if err != nil {
+ t.Fatalf("unmarshal failed: %v", err)
+ }
+ if got.Name != "Ada" || got.Age != 37 {
+ t.Fatalf("unexpected value: %#v", got)
+ }
+}
+
+func TestGobRoundTrip(t *testing.T) {
+ serializer := Gob[sample]{}
+ data, err := serializer.Marshal(sample{Name: "Grace", Age: 85})
+ if err != nil {
+ t.Fatalf("marshal failed: %v", err)
+ }
+ got, err := serializer.Unmarshal(data)
+ if err != nil {
+ t.Fatalf("unmarshal failed: %v", err)
+ }
+ if got.Name != "Grace" || got.Age != 85 {
+ t.Fatalf("unexpected value: %#v", got)
+ }
+}
diff --git a/store.go b/store.go
new file mode 100644
index 0000000..973fb0b
--- /dev/null
+++ b/store.go
@@ -0,0 +1,10 @@
+package memoize
+
+import "context"
+
+type Store[K comparable, V any] interface {
+ Get(ctx context.Context, key K) (Stored[V], bool, error)
+ Set(ctx context.Context, key K, value Stored[V]) error
+ Delete(ctx context.Context, key K) error
+ Clear(ctx context.Context) error
+}
diff --git a/stored.go b/stored.go
new file mode 100644
index 0000000..cb50202
--- /dev/null
+++ b/stored.go
@@ -0,0 +1,34 @@
+package memoize
+
+import "time"
+
+type Stored[V any] struct {
+ Value V
+ CreatedAt time.Time
+ FreshUntil time.Time
+ StaleUntil time.Time
+ NoExpire bool
+ Version string
+ Tags []string
+}
+
+type entryState uint8
+
+const (
+ entryExpired entryState = iota
+ entryFresh
+ entryStale
+)
+
+func (s Stored[V]) state(now time.Time) entryState {
+ if s.NoExpire {
+ return entryFresh
+ }
+ if now.Before(s.FreshUntil) || now.Equal(s.FreshUntil) {
+ return entryFresh
+ }
+ if !s.StaleUntil.IsZero() && (now.Before(s.StaleUntil) || now.Equal(s.StaleUntil)) {
+ return entryStale
+ }
+ return entryExpired
+}
diff --git a/stores/chain/store.go b/stores/chain/store.go
new file mode 100644
index 0000000..b59c1af
--- /dev/null
+++ b/stores/chain/store.go
@@ -0,0 +1,68 @@
+package chain
+
+import (
+ "context"
+
+ memoize "github.com/agkloop/go_memoize"
+)
+
+// ChainStore is an ordered sequence of Store[K, V] tiers.
+// On Get: checks tiers in order; on a hit in tier i, backfills tiers 0..i-1.
+// On Set/Delete/Clear: propagates to all tiers.
+type ChainStore[K comparable, V any] struct {
+ tiers []memoize.Store[K, V]
+}
+
+// New creates a ChainStore from the given tiers (L1 first, L2 second, ...).
+// At least two tiers are required.
+func New[K comparable, V any](tiers ...memoize.Store[K, V]) *ChainStore[K, V] {
+ if len(tiers) < 2 {
+ panic("chain.New: at least two tiers required")
+ }
+ return &ChainStore[K, V]{tiers: tiers}
+}
+
+func (c *ChainStore[K, V]) Get(ctx context.Context, key K) (memoize.Stored[V], bool, error) {
+ for i, tier := range c.tiers {
+ val, ok, err := tier.Get(ctx, key)
+ if err != nil {
+ return val, false, err
+ }
+ if ok {
+ // Backfill all higher-priority tiers (indices 0..i-1)
+ for j := 0; j < i; j++ {
+ _ = c.tiers[j].Set(ctx, key, val)
+ }
+ return val, true, nil
+ }
+ }
+ var zero memoize.Stored[V]
+ return zero, false, nil
+}
+
+func (c *ChainStore[K, V]) Set(ctx context.Context, key K, value memoize.Stored[V]) error {
+ for _, tier := range c.tiers {
+ if err := tier.Set(ctx, key, value); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (c *ChainStore[K, V]) Delete(ctx context.Context, key K) error {
+ for _, tier := range c.tiers {
+ if err := tier.Delete(ctx, key); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (c *ChainStore[K, V]) Clear(ctx context.Context) error {
+ for _, tier := range c.tiers {
+ if err := tier.Clear(ctx); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/stores/chain/store_test.go b/stores/chain/store_test.go
new file mode 100644
index 0000000..5a441c2
--- /dev/null
+++ b/stores/chain/store_test.go
@@ -0,0 +1,109 @@
+package chain_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/agkloop/go_memoize/stores/chain"
+ "github.com/agkloop/go_memoize/stores/memory"
+)
+
+func stored(v string, ttl time.Duration) memoize.Stored[string] {
+ now := time.Now()
+ return memoize.Stored[string]{
+ Value: v,
+ CreatedAt: now,
+ FreshUntil: now.Add(ttl),
+ }
+}
+
+func TestChainGet_L1Hit(t *testing.T) {
+ ctx := context.Background()
+ l1 := memory.New[string, string](16)
+ l2 := memory.New[string, string](16)
+ c := chain.New[string, string](l1, l2)
+
+ _ = l1.Set(ctx, "k", stored("from-l1", time.Hour))
+ got, ok, err := c.Get(ctx, "k")
+ if err != nil || !ok || got.Value != "from-l1" {
+ t.Fatalf("expected L1 hit: got=%v ok=%v err=%v", got, ok, err)
+ }
+}
+
+func TestChainGet_L2HitBackfillsL1(t *testing.T) {
+ ctx := context.Background()
+ l1 := memory.New[string, string](16)
+ l2 := memory.New[string, string](16)
+ c := chain.New[string, string](l1, l2)
+
+ _ = l2.Set(ctx, "k", stored("from-l2", time.Hour))
+ got, ok, err := c.Get(ctx, "k")
+ if err != nil || !ok || got.Value != "from-l2" {
+ t.Fatalf("expected L2 hit: got=%v ok=%v err=%v", got, ok, err)
+ }
+ // L1 should now have it
+ l1got, l1ok, _ := l1.Get(ctx, "k")
+ if !l1ok || l1got.Value != "from-l2" {
+ t.Fatal("expected L1 to be backfilled from L2")
+ }
+}
+
+func TestChainGet_Miss(t *testing.T) {
+ ctx := context.Background()
+ c := chain.New[string, string](memory.New[string, string](16), memory.New[string, string](16))
+ _, ok, err := c.Get(ctx, "missing")
+ if err != nil || ok {
+ t.Fatalf("expected miss: ok=%v err=%v", ok, err)
+ }
+}
+
+func TestChainSet_WritesAllTiers(t *testing.T) {
+ ctx := context.Background()
+ l1 := memory.New[string, string](16)
+ l2 := memory.New[string, string](16)
+ c := chain.New[string, string](l1, l2)
+
+ _ = c.Set(ctx, "k", stored("v", time.Hour))
+ if _, ok, _ := l1.Get(ctx, "k"); !ok {
+ t.Fatal("L1 should have the key after Set")
+ }
+ if _, ok, _ := l2.Get(ctx, "k"); !ok {
+ t.Fatal("L2 should have the key after Set")
+ }
+}
+
+func TestChainDelete_AllTiers(t *testing.T) {
+ ctx := context.Background()
+ l1 := memory.New[string, string](16)
+ l2 := memory.New[string, string](16)
+ c := chain.New[string, string](l1, l2)
+
+ _ = c.Set(ctx, "k", stored("v", time.Hour))
+ _ = c.Delete(ctx, "k")
+
+ if _, ok, _ := l1.Get(ctx, "k"); ok {
+ t.Fatal("L1 should not have key after Delete")
+ }
+ if _, ok, _ := l2.Get(ctx, "k"); ok {
+ t.Fatal("L2 should not have key after Delete")
+ }
+}
+
+func TestChainClear_AllTiers(t *testing.T) {
+ ctx := context.Background()
+ l1 := memory.New[string, string](16)
+ l2 := memory.New[string, string](16)
+ c := chain.New[string, string](l1, l2)
+
+ _ = c.Set(ctx, "k", stored("v", time.Hour))
+ _ = c.Clear(ctx)
+
+ if _, ok, _ := l1.Get(ctx, "k"); ok {
+ t.Fatal("L1 should be empty after Clear")
+ }
+ if _, ok, _ := l2.Get(ctx, "k"); ok {
+ t.Fatal("L2 should be empty after Clear")
+ }
+}
diff --git a/stores/local/store.go b/stores/local/store.go
new file mode 100644
index 0000000..eff9c51
--- /dev/null
+++ b/stores/local/store.go
@@ -0,0 +1,102 @@
+package local
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/gob"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ memoize "github.com/agkloop/go_memoize"
+)
+
+// LocalFileStore[V] persists cache entries as Gob-encoded files in a directory.
+// Each key maps to a file named by its SHA-256 hash (hex). Writes are atomic
+// (write temp file → rename). Get returns stored entries for the cache engine
+// to decide freshness and staleness.
+//
+// V must be Gob-encodable.
+type LocalFileStore[V any] struct {
+ dir string
+}
+
+// New creates a LocalFileStore that stores entries in dir.
+func New[V any](dir string) *LocalFileStore[V] {
+ return &LocalFileStore[V]{dir: dir}
+}
+
+type diskEntry[V any] struct {
+ Stored memoize.Stored[V]
+}
+
+func (s *LocalFileStore[V]) path(key string) string {
+ h := sha256.Sum256([]byte(key))
+ return filepath.Join(s.dir, fmt.Sprintf("%x.cache", h))
+}
+
+func (s *LocalFileStore[V]) Get(_ context.Context, key string) (memoize.Stored[V], bool, error) {
+ var zero memoize.Stored[V]
+ data, err := os.ReadFile(s.path(key))
+ if os.IsNotExist(err) {
+ return zero, false, nil
+ }
+ if err != nil {
+ return zero, false, err
+ }
+ var de diskEntry[V]
+ if err := gob.NewDecoder(bytes.NewReader(data)).Decode(&de); err != nil {
+ return zero, false, nil // treat corrupt file as miss
+ }
+ return de.Stored, true, nil
+}
+
+func (s *LocalFileStore[V]) Set(_ context.Context, key string, value memoize.Stored[V]) error {
+ if err := os.MkdirAll(s.dir, 0o755); err != nil {
+ return err
+ }
+ var buf bytes.Buffer
+ if err := gob.NewEncoder(&buf).Encode(diskEntry[V]{Stored: value}); err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(s.dir, ".tmp-")
+ if err != nil {
+ return err
+ }
+ if _, err := tmp.Write(buf.Bytes()); err != nil {
+ _ = tmp.Close()
+ _ = os.Remove(tmp.Name())
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ _ = os.Remove(tmp.Name())
+ return err
+ }
+ return os.Rename(tmp.Name(), s.path(key))
+}
+
+func (s *LocalFileStore[V]) Delete(_ context.Context, key string) error {
+ err := os.Remove(s.path(key))
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+}
+
+func (s *LocalFileStore[V]) Clear(_ context.Context) error {
+ entries, err := os.ReadDir(s.dir)
+ if os.IsNotExist(err) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ _ = os.Remove(filepath.Join(s.dir, e.Name()))
+ }
+ return nil
+}
diff --git a/stores/local/store_test.go b/stores/local/store_test.go
new file mode 100644
index 0000000..5fc8759
--- /dev/null
+++ b/stores/local/store_test.go
@@ -0,0 +1,145 @@
+package local_test
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/agkloop/go_memoize/stores/local"
+)
+
+func stored(v string, ttl time.Duration) memoize.Stored[string] {
+ now := time.Now()
+ s := memoize.Stored[string]{Value: v, CreatedAt: now}
+ if ttl > 0 {
+ s.FreshUntil = now.Add(ttl)
+ } else {
+ s.NoExpire = true
+ }
+ return s
+}
+
+func TestLocalFileStore_SetGet(t *testing.T) {
+ dir := t.TempDir()
+ s := local.New[string](dir)
+ ctx := context.Background()
+
+ _ = s.Set(ctx, "hello", stored("world", time.Hour))
+ got, ok, err := s.Get(ctx, "hello")
+ if err != nil || !ok || got.Value != "world" {
+ t.Fatalf("got=%v ok=%v err=%v", got, ok, err)
+ }
+}
+
+func TestLocalFileStore_Miss(t *testing.T) {
+ dir := t.TempDir()
+ s := local.New[string](dir)
+ ctx := context.Background()
+
+ _, ok, err := s.Get(ctx, "nonexistent")
+ if err != nil || ok {
+ t.Fatalf("expected miss: ok=%v err=%v", ok, err)
+ }
+}
+
+func TestLocalFileStore_Delete(t *testing.T) {
+ dir := t.TempDir()
+ s := local.New[string](dir)
+ ctx := context.Background()
+
+ _ = s.Set(ctx, "k", stored("v", time.Hour))
+ _ = s.Delete(ctx, "k")
+ _, ok, _ := s.Get(ctx, "k")
+ if ok {
+ t.Fatal("expected key to be deleted")
+ }
+}
+
+func TestLocalFileStore_Clear(t *testing.T) {
+ dir := t.TempDir()
+ s := local.New[string](dir)
+ ctx := context.Background()
+
+ _ = s.Set(ctx, "a", stored("1", time.Hour))
+ _ = s.Set(ctx, "b", stored("2", time.Hour))
+ _ = s.Clear(ctx)
+
+ _, okA, _ := s.Get(ctx, "a")
+ _, okB, _ := s.Get(ctx, "b")
+ if okA || okB {
+ t.Fatal("expected store to be empty after Clear")
+ }
+ // Verify no files remain
+ entries, _ := os.ReadDir(dir)
+ if len(entries) != 0 {
+ t.Fatalf("expected 0 files after Clear, got %d", len(entries))
+ }
+}
+
+func TestLocalFileStoreReturnsExpiredEntriesForCacheEngine(t *testing.T) {
+ dir := t.TempDir()
+ s := local.New[string](dir)
+ ctx := context.Background()
+
+ past := time.Now().Add(-time.Hour)
+ expired := memoize.Stored[string]{Value: "old", CreatedAt: past, FreshUntil: past}
+ if err := s.Set(ctx, "expired", expired); err != nil {
+ t.Fatalf("set expired entry: %v", err)
+ }
+
+ got, ok, err := s.Get(ctx, "expired")
+ if err != nil {
+ t.Fatalf("get expired entry: %v", err)
+ }
+ if !ok {
+ t.Fatal("expected expired entry to be returned for cache engine")
+ }
+ if got.Value != expired.Value || !got.FreshUntil.Equal(expired.FreshUntil) {
+ t.Fatalf("got=%+v want=%+v", got, expired)
+ }
+}
+
+func TestLocalFileStoreReturnsStaleEntriesForCacheEngine(t *testing.T) {
+ dir := t.TempDir()
+ s := local.New[string](dir)
+ ctx := context.Background()
+ now := time.Now()
+ entry := memoize.Stored[string]{
+ Value: "stale",
+ CreatedAt: now.Add(-time.Hour),
+ FreshUntil: now.Add(-time.Minute),
+ StaleUntil: now.Add(time.Hour),
+ }
+
+ if err := s.Set(ctx, "stale", entry); err != nil {
+ t.Fatalf("set stale entry: %v", err)
+ }
+ got, ok, err := s.Get(ctx, "stale")
+ if err != nil {
+ t.Fatalf("get stale entry: %v", err)
+ }
+ if !ok {
+ t.Fatal("expected stale entry to be returned for cache engine")
+ }
+ if got.Value != entry.Value || !got.FreshUntil.Equal(entry.FreshUntil) || !got.StaleUntil.Equal(entry.StaleUntil) {
+ t.Fatalf("got=%+v want=%+v", got, entry)
+ }
+}
+
+func TestLocalFileStore_Persistence(t *testing.T) {
+ dir := t.TempDir()
+ ctx := context.Background()
+
+ // Write with one store instance
+ s1 := local.New[string](dir)
+ _ = s1.Set(ctx, "persistent", stored("stays", time.Hour))
+
+ // Read with a new instance pointing at the same dir
+ s2 := local.New[string](dir)
+ got, ok, err := s2.Get(ctx, "persistent")
+ if err != nil || !ok || got.Value != "stays" {
+ t.Fatalf("expected persisted value: got=%v ok=%v err=%v", got, ok, err)
+ }
+}
diff --git a/stores/memory/options.go b/stores/memory/options.go
new file mode 100644
index 0000000..e88abdb
--- /dev/null
+++ b/stores/memory/options.go
@@ -0,0 +1,48 @@
+package memory
+
+type options struct {
+ getRecencySample uint32
+ shards int
+ maxBytes int64 // 0 = unbounded
+}
+
+type Option[K comparable, V any] func(*options)
+
+// WithMaxBytes limits the estimated in-memory footprint of the store to n bytes,
+// evicting the least-recently-used entry whenever a new insert would exceed the limit.
+//
+// The byte cost of each entry is estimated as:
+//
+// unsafe.Sizeof(key) + unsafe.Sizeof(value)
+//
+// This is a shallow, compile-time struct estimate. It counts header sizes of
+// pointer-bearing types (strings, slices) but NOT the heap data they point to.
+// For V=string, the string content bytes are not counted. Plan capacity accordingly.
+//
+// If a single entry's cost exceeds the limit and the store is empty, it is admitted
+// anyway (a store must hold at least one item). In this case usedBytes will exceed maxBytes.
+//
+// n <= 0 is ignored (unbounded).
+func WithMaxBytes[K comparable, V any](n int64) Option[K, V] {
+ return func(o *options) {
+ if n > 0 {
+ o.maxBytes = n
+ }
+ }
+}
+
+// WithGetRecencySample makes Get refresh LRU recency once every n hits.
+// n <= 1 preserves exact LRU behavior by refreshing recency on every hit.
+func WithGetRecencySample[K comparable, V any](n uint32) Option[K, V] {
+ return func(o *options) {
+ o.getRecencySample = n
+ }
+}
+
+// WithShards sets the number of shards used by NewSharded.
+// n must be a positive power of two.
+func WithShards[K comparable, V any](n int) Option[K, V] {
+ return func(o *options) {
+ o.shards = n
+ }
+}
diff --git a/stores/memory/sharded.go b/stores/memory/sharded.go
new file mode 100644
index 0000000..4aa6d50
--- /dev/null
+++ b/stores/memory/sharded.go
@@ -0,0 +1,171 @@
+package memory
+
+import (
+ "context"
+ "runtime"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ internalhash "github.com/agkloop/go_memoize/internal/hash"
+)
+
+// ShardedStore distributes keys across independent Store[K, V] instances
+// using FNV-1a hashing to reduce mutex contention for distributed-key workloads.
+type ShardedStore[K comparable, V any] struct {
+ shards []Store[K, V]
+ mask uint64
+}
+
+// NewSharded creates a sharded store with total item capacity split across shards.
+func NewSharded[K comparable, V any](capacity int, opts ...Option[K, V]) *ShardedStore[K, V] {
+ if capacity <= 0 {
+ panic("memory.NewSharded: capacity must be positive")
+ }
+ o := options{}
+ for _, opt := range opts {
+ opt(&o)
+ }
+ shardCount := o.shards
+ if shardCount == 0 {
+ shardCount = nextPowerOfTwoInt(runtime.GOMAXPROCS(0) * 16)
+ }
+ if shardCount <= 0 || shardCount&(shardCount-1) != 0 {
+ panic("memory.NewSharded: shard count must be a positive power of two")
+ }
+ for shardCount > capacity {
+ shardCount >>= 1
+ }
+ if shardCount == 0 {
+ shardCount = 1
+ }
+
+ shards := make([]Store[K, V], shardCount)
+ for i := range shards {
+ shardCap := capacity / shardCount
+ if i < capacity%shardCount {
+ shardCap++
+ }
+ shardOpts := o
+ shardOpts.shards = 0
+ if o.maxBytes > 0 {
+ shardBytes := o.maxBytes / int64(shardCount)
+ if i < int(o.maxBytes%int64(shardCount)) {
+ shardBytes++
+ }
+ shardOpts.maxBytes = shardBytes
+ }
+ shards[i] = *New[K, V](shardCap, func(opts *options) { *opts = shardOpts })
+ }
+ return &ShardedStore[K, V]{shards: shards, mask: uint64(shardCount - 1)}
+}
+
+func (s *ShardedStore[K, V]) shard(key K) *Store[K, V] {
+ h := shardHash(key)
+ return &s.shards[(h>>16)&s.mask]
+}
+
+func (s *ShardedStore[K, V]) Get(ctx context.Context, key K) (memoize.Stored[V], bool, error) {
+ return s.shard(key).Get(ctx, key)
+}
+
+func (s *ShardedStore[K, V]) Peek(ctx context.Context, key K) (memoize.Stored[V], bool, error) {
+ return s.shard(key).Peek(ctx, key)
+}
+
+func (s *ShardedStore[K, V]) PeekFreshValue(ctx context.Context, key K, now time.Time) (V, bool, error) {
+ return s.shard(key).PeekFreshValue(ctx, key, now)
+}
+
+func (s *ShardedStore[K, V]) Set(ctx context.Context, key K, value memoize.Stored[V]) error {
+ return s.shard(key).Set(ctx, key, value)
+}
+
+func (s *ShardedStore[K, V]) Delete(ctx context.Context, key K) error {
+ return s.shard(key).Delete(ctx, key)
+}
+
+func (s *ShardedStore[K, V]) Clear(ctx context.Context) error {
+ for i := range s.shards {
+ if err := s.shards[i].Clear(ctx); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// Len returns total number of entries across all shards.
+func (s *ShardedStore[K, V]) Len() int {
+ total := 0
+ for i := range s.shards {
+ total += s.shards[i].Len()
+ }
+ return total
+}
+
+func (s *ShardedStore[K, V]) UsedBytes() int64 {
+ var total int64
+ for i := range s.shards {
+ total += s.shards[i].UsedBytes()
+ }
+ return total
+}
+
+func (s *ShardedStore[K, V]) DeleteByTag(ctx context.Context, tag string) error {
+ for i := range s.shards {
+ if err := s.shards[i].DeleteByTag(ctx, tag); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func shardHash[K comparable](key K) uint64 {
+ switch v := any(key).(type) {
+ case string:
+ return internalhash.String(internalhash.Offset64, v)
+ case int:
+ return internalhash.Uint(internalhash.Offset64, uint64(v))
+ case int8:
+ return internalhash.Uint(internalhash.Offset64, uint64(v))
+ case int16:
+ return internalhash.Uint(internalhash.Offset64, uint64(v))
+ case int32:
+ return internalhash.Uint(internalhash.Offset64, uint64(v))
+ case int64:
+ return internalhash.Uint(internalhash.Offset64, uint64(v))
+ case uint:
+ return internalhash.Uint(internalhash.Offset64, uint64(v))
+ case uint8:
+ return internalhash.Uint(internalhash.Offset64, uint64(v))
+ case uint16:
+ return internalhash.Uint(internalhash.Offset64, uint64(v))
+ case uint32:
+ return internalhash.Uint(internalhash.Offset64, uint64(v))
+ case uint64:
+ return internalhash.Uint(internalhash.Offset64, v)
+ case uintptr:
+ return internalhash.Uint(internalhash.Offset64, uint64(v))
+ case bool:
+ return internalhash.Bool(internalhash.Offset64, v)
+ default:
+ panic("memory.NewSharded: unsupported key type")
+ }
+}
+
+func nextPowerOfTwoInt(n int) int {
+ if n <= 1 {
+ return 1
+ }
+ n--
+ n |= n >> 1
+ n |= n >> 2
+ n |= n >> 4
+ n |= n >> 8
+ n |= n >> 16
+ if unsafeIntSize == 64 {
+ n |= n >> 32
+ }
+ return n + 1
+}
+
+const unsafeIntSize = 32 << (^uint(0) >> 63)
diff --git a/stores/memory/sharded_test.go b/stores/memory/sharded_test.go
new file mode 100644
index 0000000..a8c5e69
--- /dev/null
+++ b/stores/memory/sharded_test.go
@@ -0,0 +1,82 @@
+package memory
+
+import (
+ "context"
+ "strconv"
+ "sync"
+ "testing"
+
+ memoize "github.com/agkloop/go_memoize"
+)
+
+func TestShardedStoreBasicOps(t *testing.T) {
+ ctx := context.Background()
+ s := NewSharded[string, string](16, WithShards[string, string](16))
+
+ stored := memoize.Stored[string]{Value: "hello", NoExpire: true}
+ _ = s.Set(ctx, "key", stored)
+ got, ok, err := s.Get(ctx, "key")
+ if err != nil || !ok || got.Value != "hello" {
+ t.Fatalf("got=%v ok=%v err=%v", got, ok, err)
+ }
+ peeked, ok, err := s.Peek(ctx, "key")
+ if err != nil || !ok || peeked.Value != "hello" {
+ t.Fatalf("peeked=%v ok=%v err=%v", peeked, ok, err)
+ }
+
+ _ = s.Delete(ctx, "key")
+ if _, ok, _ := s.Get(ctx, "key"); ok {
+ t.Fatal("expected key to be deleted")
+ }
+}
+
+func TestShardedStoreConcurrentWrites(t *testing.T) {
+ ctx := context.Background()
+ s := NewSharded[string, int](1024, WithShards[string, int](8))
+
+ var wg sync.WaitGroup
+ for i := 0; i < 1000; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ key := strconv.Itoa(i)
+ _ = s.Set(ctx, key, memoize.Stored[int]{Value: i, NoExpire: true})
+ _, _, _ = s.Get(ctx, key)
+ }(i)
+ }
+ wg.Wait()
+}
+
+func TestShardedStoreClear(t *testing.T) {
+ ctx := context.Background()
+ s := NewSharded[string, string](32, WithShards[string, string](4))
+ for i := 0; i < 20; i++ {
+ _ = s.Set(ctx, strconv.Itoa(i), memoize.Stored[string]{Value: "v", NoExpire: true})
+ }
+ _ = s.Clear(ctx)
+ if s.Len() != 0 {
+ t.Fatalf("expected 0 after clear, got %d", s.Len())
+ }
+}
+
+func TestShardedStorePanicsOnBadN(t *testing.T) {
+ defer func() {
+ if r := recover(); r == nil {
+ t.Fatal("expected panic for non-power-of-two n")
+ }
+ }()
+ NewSharded[string, string](16, WithShards[string, string](3)) // not a power of two
+}
+
+func TestShardedStorePanicsOnUnsupportedKeyType(t *testing.T) {
+ type unsupported struct{ value string }
+
+ defer func() {
+ if r := recover(); r == nil {
+ t.Fatal("expected panic for unsupported sharded key type")
+ }
+ }()
+
+ s := NewSharded[unsupported, string](16, WithShards[unsupported, string](4))
+ _ = s.Set(context.Background(), unsupported{value: "key"}, memoize.Stored[string]{Value: "value", NoExpire: true})
+}
diff --git a/stores/memory/single.go b/stores/memory/single.go
new file mode 100644
index 0000000..d683290
--- /dev/null
+++ b/stores/memory/single.go
@@ -0,0 +1,110 @@
+package memory
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "time"
+ "unsafe"
+
+ memoize "github.com/agkloop/go_memoize"
+)
+
+type singleEntry[K comparable, V any] struct {
+ key K
+ value memoize.Stored[V]
+ cost int64
+}
+
+// SingleStore stores one key/value pair with atomic read access.
+// It is intended for one logical cached value where LRU bookkeeping is unnecessary.
+type SingleStore[K comparable, V any] struct {
+ mu sync.Mutex
+ entry atomic.Pointer[singleEntry[K, V]]
+}
+
+func NewSingle[K comparable, V any]() *SingleStore[K, V] {
+ return &SingleStore[K, V]{}
+}
+
+func (s *SingleStore[K, V]) Get(_ context.Context, key K) (memoize.Stored[V], bool, error) {
+ return s.load(key)
+}
+
+func (s *SingleStore[K, V]) Peek(_ context.Context, key K) (memoize.Stored[V], bool, error) {
+ return s.load(key)
+}
+
+func (s *SingleStore[K, V]) PeekFreshValue(_ context.Context, key K, now time.Time) (V, bool, error) {
+ e := s.entry.Load()
+ if e == nil || e.key != key {
+ var zero V
+ return zero, false, nil
+ }
+ entry := &e.value
+ if entry.NoExpire || now.Before(entry.FreshUntil) || now.Equal(entry.FreshUntil) {
+ return entry.Value, true, nil
+ }
+ var zero V
+ return zero, false, nil
+}
+
+func (s *SingleStore[K, V]) load(key K) (memoize.Stored[V], bool, error) {
+ e := s.entry.Load()
+ if e == nil || e.key != key {
+ var zero memoize.Stored[V]
+ return zero, false, nil
+ }
+ return e.value, true, nil
+}
+
+func (s *SingleStore[K, V]) Set(_ context.Context, key K, value memoize.Stored[V]) error {
+ s.mu.Lock()
+ s.entry.Store(&singleEntry[K, V]{key: key, value: value, cost: singleEntryBytes(key, value)})
+ s.mu.Unlock()
+ return nil
+}
+
+func (s *SingleStore[K, V]) Delete(_ context.Context, key K) error {
+ s.mu.Lock()
+ if e := s.entry.Load(); e != nil && e.key == key {
+ s.entry.Store(nil)
+ }
+ s.mu.Unlock()
+ return nil
+}
+
+func (s *SingleStore[K, V]) Clear(context.Context) error {
+ s.mu.Lock()
+ s.entry.Store(nil)
+ s.mu.Unlock()
+ return nil
+}
+
+func (s *SingleStore[K, V]) Len() int {
+ if s.entry.Load() == nil {
+ return 0
+ }
+ return 1
+}
+
+func (s *SingleStore[K, V]) UsedBytes() int64 {
+ e := s.entry.Load()
+ if e == nil {
+ return 0
+ }
+ return e.cost
+}
+
+func (s *SingleStore[K, V]) DeleteByTag(_ context.Context, tag string) error {
+ s.mu.Lock()
+ if e := s.entry.Load(); e != nil && hasTag(e.value.Tags, tag) {
+ s.entry.Store(nil)
+ }
+ s.mu.Unlock()
+ return nil
+}
+
+func singleEntryBytes[K comparable, V any](key K, value memoize.Stored[V]) int64 {
+ return int64(unsafe.Sizeof(key)) + int64(unsafe.Sizeof(value))
+}
diff --git a/stores/memory/single_test.go b/stores/memory/single_test.go
new file mode 100644
index 0000000..44c833e
--- /dev/null
+++ b/stores/memory/single_test.go
@@ -0,0 +1,114 @@
+package memory
+
+import (
+ "context"
+ "strconv"
+ "sync"
+ "testing"
+
+ memoize "github.com/agkloop/go_memoize"
+)
+
+func TestSingleStoreSetGetPeekDeleteClear(t *testing.T) {
+ ctx := context.Background()
+ s := NewSingle[string, string]()
+ entry := memoize.Stored[string]{Value: "value", NoExpire: true}
+
+ if got := s.Len(); got != 0 {
+ t.Fatalf("expected empty store len 0, got %d", got)
+ }
+ if _, ok, err := s.Get(ctx, "key"); err != nil || ok {
+ t.Fatalf("missing key returned ok=%v err=%v", ok, err)
+ }
+ if err := s.Set(ctx, "key", entry); err != nil {
+ t.Fatalf("set failed: %v", err)
+ }
+ got, ok, err := s.Get(ctx, "key")
+ if err != nil || !ok || got.Value != "value" {
+ t.Fatalf("get returned value=%q ok=%v err=%v", got.Value, ok, err)
+ }
+ peeked, ok, err := s.Peek(ctx, "key")
+ if err != nil || !ok || peeked.Value != "value" {
+ t.Fatalf("peek returned value=%q ok=%v err=%v", peeked.Value, ok, err)
+ }
+ if got := s.Len(); got != 1 {
+ t.Fatalf("expected len 1, got %d", got)
+ }
+
+ if err := s.Delete(ctx, "other"); err != nil {
+ t.Fatalf("delete other failed: %v", err)
+ }
+ if _, ok, _ := s.Get(ctx, "key"); !ok {
+ t.Fatal("delete of different key should not remove stored value")
+ }
+ if err := s.Delete(ctx, "key"); err != nil {
+ t.Fatalf("delete key failed: %v", err)
+ }
+ if _, ok, _ := s.Get(ctx, "key"); ok {
+ t.Fatal("deleted key should be absent")
+ }
+
+ _ = s.Set(ctx, "key", entry)
+ if err := s.Clear(ctx); err != nil {
+ t.Fatalf("clear failed: %v", err)
+ }
+ if got := s.Len(); got != 0 {
+ t.Fatalf("expected len 0 after clear, got %d", got)
+ }
+}
+
+func TestSingleStoreReplaceRemovesPreviousKey(t *testing.T) {
+ ctx := context.Background()
+ s := NewSingle[string, string]()
+
+ _ = s.Set(ctx, "a", memoize.Stored[string]{Value: "A", NoExpire: true})
+ _ = s.Set(ctx, "b", memoize.Stored[string]{Value: "B", NoExpire: true})
+
+ if _, ok, _ := s.Get(ctx, "a"); ok {
+ t.Fatal("single store should only retain the latest key")
+ }
+ got, ok, err := s.Get(ctx, "b")
+ if err != nil || !ok || got.Value != "B" {
+ t.Fatalf("expected latest key b, got value=%q ok=%v err=%v", got.Value, ok, err)
+ }
+}
+
+func TestSingleStoreDeleteByTag(t *testing.T) {
+ ctx := context.Background()
+ s := NewSingle[string, string]()
+
+ _ = s.Set(ctx, "key", memoize.Stored[string]{Value: "value", NoExpire: true, Tags: []string{"group"}})
+ if err := s.DeleteByTag(ctx, "other"); err != nil {
+ t.Fatalf("delete by other tag failed: %v", err)
+ }
+ if _, ok, _ := s.Get(ctx, "key"); !ok {
+ t.Fatal("different tag should not delete entry")
+ }
+ if err := s.DeleteByTag(ctx, "group"); err != nil {
+ t.Fatalf("delete by tag failed: %v", err)
+ }
+ if _, ok, _ := s.Get(ctx, "key"); ok {
+ t.Fatal("matching tag should delete entry")
+ }
+}
+
+func TestSingleStoreConcurrentAccess(t *testing.T) {
+ ctx := context.Background()
+ s := NewSingle[string, string]()
+ var wg sync.WaitGroup
+
+ for i := 0; i < 200; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ key := strconv.Itoa(i % 4)
+ _ = s.Set(ctx, key, memoize.Stored[string]{Value: key, NoExpire: true})
+ _, _, _ = s.Get(ctx, key)
+ _, _, _ = s.Peek(ctx, key)
+ if i%17 == 0 {
+ _ = s.Delete(ctx, key)
+ }
+ }(i)
+ }
+ wg.Wait()
+}
diff --git a/stores/memory/store.go b/stores/memory/store.go
new file mode 100644
index 0000000..4ab7ac1
--- /dev/null
+++ b/stores/memory/store.go
@@ -0,0 +1,274 @@
+package memory
+
+import (
+ "context"
+ "math"
+ "sync"
+ "time"
+ "unsafe"
+
+ memoize "github.com/agkloop/go_memoize"
+)
+
+const emptyIndex = uint32(math.MaxUint32)
+
+type element[K comparable, V any] struct {
+ key K
+ value memoize.Stored[V]
+ cost int64
+
+ next uint32
+ prev uint32
+}
+
+type Store[K comparable, V any] struct {
+ mu sync.Mutex
+ index map[K]uint32
+ elements []element[K, V]
+ head uint32
+ len uint32
+ cap uint32
+ usedBytes int64
+ getHits uint64
+ opts options
+}
+
+func New[K comparable, V any](capacity int, opts ...Option[K, V]) *Store[K, V] {
+ if capacity <= 0 {
+ panic("memory.New: capacity must be positive")
+ }
+ o := options{}
+ for _, opt := range opts {
+ opt(&o)
+ }
+ if o.getRecencySample == 0 {
+ o.getRecencySample = 1
+ }
+ cap32 := uint32(capacity)
+ return &Store[K, V]{
+ index: make(map[K]uint32, capacity),
+ elements: make([]element[K, V], cap32),
+ head: emptyIndex,
+ cap: cap32,
+ opts: o,
+ }
+}
+
+func (s *Store[K, V]) Get(_ context.Context, key K) (memoize.Stored[V], bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ pos, ok := s.index[key]
+ if !ok {
+ var zero memoize.Stored[V]
+ return zero, false, nil
+ }
+ if s.refreshOnGet() {
+ s.moveToFront(pos)
+ }
+ return s.elements[pos].value, true, nil
+}
+
+func (s *Store[K, V]) Peek(_ context.Context, key K) (memoize.Stored[V], bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ pos, ok := s.index[key]
+ if !ok {
+ var zero memoize.Stored[V]
+ return zero, false, nil
+ }
+ return s.elements[pos].value, true, nil
+}
+
+func (s *Store[K, V]) PeekFreshValue(_ context.Context, key K, now time.Time) (V, bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ pos, ok := s.index[key]
+ if !ok {
+ var zero V
+ return zero, false, nil
+ }
+ entry := &s.elements[pos].value
+ if entry.NoExpire || now.Before(entry.FreshUntil) || now.Equal(entry.FreshUntil) {
+ return entry.Value, true, nil
+ }
+ var zero V
+ return zero, false, nil
+}
+
+func (s *Store[K, V]) refreshOnGet() bool {
+ if s.opts.getRecencySample <= 1 {
+ return true
+ }
+ s.getHits++
+ return s.getHits%uint64(s.opts.getRecencySample) == 0
+}
+
+func (s *Store[K, V]) Set(_ context.Context, key K, value memoize.Stored[V]) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ cost := entryBytes(key, value)
+ if pos, ok := s.index[key]; ok {
+ s.usedBytes += cost - s.elements[pos].cost
+ s.elements[pos].value = value
+ s.elements[pos].cost = cost
+ s.moveToFront(pos)
+ s.enforceBytes()
+ return nil
+ }
+
+ for s.len == s.cap || (s.opts.maxBytes > 0 && s.usedBytes+cost > s.opts.maxBytes && s.len > 0) {
+ s.removeAt(s.elements[s.head].next)
+ }
+
+ pos := s.len
+ s.len++
+ s.elements[pos] = element[K, V]{key: key, value: value, cost: cost, next: emptyIndex, prev: emptyIndex}
+ s.index[key] = pos
+ s.linkFront(pos)
+ s.usedBytes += cost
+ return nil
+}
+
+func (s *Store[K, V]) enforceBytes() {
+ for s.opts.maxBytes > 0 && s.usedBytes > s.opts.maxBytes && s.len > 1 {
+ s.removeAt(s.elements[s.head].next)
+ }
+}
+
+// entryBytes returns a shallow estimate of the memory cost of one cache entry.
+// Heap allocations inside K or V (e.g. string content, slice backing arrays) are not counted.
+func entryBytes[K comparable, V any](key K, value memoize.Stored[V]) int64 {
+ return int64(unsafe.Sizeof(key)) + int64(unsafe.Sizeof(value))
+}
+
+func (s *Store[K, V]) Delete(_ context.Context, key K) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if pos, ok := s.index[key]; ok {
+ s.removeAt(pos)
+ }
+ return nil
+}
+
+func (s *Store[K, V]) Clear(_ context.Context) error {
+ s.mu.Lock()
+ for i := uint32(0); i < s.len; i++ {
+ s.elements[i] = element[K, V]{}
+ }
+ s.index = make(map[K]uint32, s.cap)
+ s.head = emptyIndex
+ s.len = 0
+ s.usedBytes = 0
+ s.mu.Unlock()
+ return nil
+}
+
+// Len returns the number of items currently in the store.
+func (s *Store[K, V]) Len() int {
+ s.mu.Lock()
+ n := s.len
+ s.mu.Unlock()
+ return int(n)
+}
+
+// UsedBytes returns the current estimated byte usage of the store.
+func (s *Store[K, V]) UsedBytes() int64 {
+ s.mu.Lock()
+ n := s.usedBytes
+ s.mu.Unlock()
+ return n
+}
+
+// DeleteByTag removes all entries that have tag in their Tags slice.
+func (s *Store[K, V]) DeleteByTag(_ context.Context, tag string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for pos := uint32(0); pos < s.len; {
+ if hasTag(s.elements[pos].value.Tags, tag) {
+ s.removeAt(pos)
+ continue
+ }
+ pos++
+ }
+ return nil
+}
+
+func hasTag(tags []string, tag string) bool {
+ for _, t := range tags {
+ if t == tag {
+ return true
+ }
+ }
+ return false
+}
+
+func (s *Store[K, V]) linkFront(pos uint32) {
+ if s.head == emptyIndex {
+ s.head = pos
+ s.elements[pos].next = pos
+ s.elements[pos].prev = pos
+ return
+ }
+ tail := s.elements[s.head].next
+ s.elements[pos].next = tail
+ s.elements[pos].prev = s.head
+ s.elements[tail].prev = pos
+ s.elements[s.head].next = pos
+ s.head = pos
+}
+
+func (s *Store[K, V]) unlinkLRU(pos uint32) {
+ if s.elements[pos].next == pos {
+ s.head = emptyIndex
+ return
+ }
+ next := s.elements[pos].next
+ prev := s.elements[pos].prev
+ s.elements[next].prev = prev
+ s.elements[prev].next = next
+ if s.head == pos {
+ s.head = prev
+ }
+}
+
+func (s *Store[K, V]) moveToFront(pos uint32) {
+ if s.head == pos || s.head == emptyIndex {
+ return
+ }
+ s.unlinkLRU(pos)
+ s.linkFront(pos)
+}
+
+func (s *Store[K, V]) removeAt(pos uint32) {
+ removedKey := s.elements[pos].key
+ s.usedBytes -= s.elements[pos].cost
+ s.unlinkLRU(pos)
+ delete(s.index, removedKey)
+
+ last := s.len - 1
+ s.len--
+ if pos != last {
+ s.elements[pos] = s.elements[last]
+ s.index[s.elements[pos].key] = pos
+ s.repointMoved(last, pos)
+ }
+ s.elements[last] = element[K, V]{}
+}
+
+func (s *Store[K, V]) repointMoved(oldPos, newPos uint32) {
+ moved := &s.elements[newPos]
+ if moved.next == oldPos {
+ moved.next = newPos
+ } else {
+ s.elements[moved.next].prev = newPos
+ }
+ if moved.prev == oldPos {
+ moved.prev = newPos
+ } else {
+ s.elements[moved.prev].next = newPos
+ }
+ if s.head == oldPos {
+ s.head = newPos
+ }
+}
diff --git a/stores/memory/store_test.go b/stores/memory/store_test.go
new file mode 100644
index 0000000..72533ed
--- /dev/null
+++ b/stores/memory/store_test.go
@@ -0,0 +1,318 @@
+package memory
+
+import (
+ "context"
+ "strconv"
+ "sync"
+ "testing"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+)
+
+func TestStoreGetSetDeleteClear(t *testing.T) {
+ ctx := context.Background()
+ store := New[string, string](16)
+ now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
+ entry := memoize.Stored[string]{Value: "value", CreatedAt: now, FreshUntil: now.Add(time.Minute)}
+
+ if _, ok, err := store.Get(ctx, "missing"); err != nil || ok {
+ t.Fatalf("missing key returned ok=%v err=%v", ok, err)
+ }
+ if err := store.Set(ctx, "key", entry); err != nil {
+ t.Fatalf("set failed: %v", err)
+ }
+ got, ok, err := store.Get(ctx, "key")
+ if err != nil || !ok || got.Value != "value" {
+ t.Fatalf("get returned value=%q ok=%v err=%v", got.Value, ok, err)
+ }
+ if err := store.Delete(ctx, "key"); err != nil {
+ t.Fatalf("delete failed: %v", err)
+ }
+ if _, ok, err := store.Get(ctx, "key"); err != nil || ok {
+ t.Fatalf("deleted key returned ok=%v err=%v", ok, err)
+ }
+ if err := store.Set(ctx, "key", entry); err != nil {
+ t.Fatalf("set before clear failed: %v", err)
+ }
+ if err := store.Clear(ctx); err != nil {
+ t.Fatalf("clear failed: %v", err)
+ }
+ if _, ok, err := store.Get(ctx, "key"); err != nil || ok {
+ t.Fatalf("cleared key returned ok=%v err=%v", ok, err)
+ }
+}
+
+func TestLRUEvictsLeastRecentlyUsed(t *testing.T) {
+ ctx := context.Background()
+ s := New[string, string](2)
+
+ stored := func(v string) memoize.Stored[string] {
+ return memoize.Stored[string]{Value: v, NoExpire: true}
+ }
+
+ _ = s.Set(ctx, "a", stored("A"))
+ _ = s.Set(ctx, "b", stored("B"))
+ // Access "a" so "b" becomes LRU
+ _, _, _ = s.Get(ctx, "a")
+ // Insert "c" — should evict "b"
+ _ = s.Set(ctx, "c", stored("C"))
+
+ if _, ok, _ := s.Get(ctx, "b"); ok {
+ t.Fatal("expected 'b' to be evicted")
+ }
+ if _, ok, _ := s.Get(ctx, "a"); !ok {
+ t.Fatal("expected 'a' to survive")
+ }
+ if _, ok, _ := s.Get(ctx, "c"); !ok {
+ t.Fatal("expected 'c' to be present")
+ }
+}
+
+func TestPeekDoesNotAffectRecency(t *testing.T) {
+ ctx := context.Background()
+ s := New[string, string](2)
+ stored := func(v string) memoize.Stored[string] {
+ return memoize.Stored[string]{Value: v, NoExpire: true}
+ }
+
+ _ = s.Set(ctx, "a", stored("A"))
+ _ = s.Set(ctx, "b", stored("B"))
+ got, ok, err := s.Peek(ctx, "a")
+ if err != nil || !ok || got.Value != "A" {
+ t.Fatalf("peek returned value=%q ok=%v err=%v", got.Value, ok, err)
+ }
+ _ = s.Set(ctx, "c", stored("C"))
+
+ if _, ok, _ := s.Get(ctx, "a"); ok {
+ t.Fatal("peek should not refresh recency; expected a to be evicted")
+ }
+ if _, ok, _ := s.Get(ctx, "b"); !ok {
+ t.Fatal("expected b to survive")
+ }
+ if _, ok, _ := s.Get(ctx, "c"); !ok {
+ t.Fatal("expected c to be present")
+ }
+}
+
+func TestGetRecencySampling(t *testing.T) {
+ ctx := context.Background()
+ stored := func(v string) memoize.Stored[string] {
+ return memoize.Stored[string]{Value: v, NoExpire: true}
+ }
+
+ skipped := New[string, string](2, WithGetRecencySample[string, string](3))
+ _ = skipped.Set(ctx, "a", stored("A"))
+ _ = skipped.Set(ctx, "b", stored("B"))
+ _, _, _ = skipped.Get(ctx, "a")
+ _ = skipped.Set(ctx, "c", stored("C"))
+ if _, ok, _ := skipped.Get(ctx, "a"); ok {
+ t.Fatal("first sampled get should not refresh recency; expected a to be evicted")
+ }
+
+ refreshed := New[string, string](2, WithGetRecencySample[string, string](3))
+ _ = refreshed.Set(ctx, "a", stored("A"))
+ _ = refreshed.Set(ctx, "b", stored("B"))
+ for i := 0; i < 3; i++ {
+ _, _, _ = refreshed.Get(ctx, "a")
+ }
+ _ = refreshed.Set(ctx, "c", stored("C"))
+ if _, ok, _ := refreshed.Get(ctx, "a"); !ok {
+ t.Fatal("third sampled get should refresh recency; expected a to survive")
+ }
+ if _, ok, _ := refreshed.Get(ctx, "b"); ok {
+ t.Fatal("expected b to be evicted after sampled refresh of a")
+ }
+}
+
+func TestCapacityFirstConstructorEvictsLeastRecentlyUsed(t *testing.T) {
+ ctx := context.Background()
+ s := New[string, string](2)
+ stored := func(v string) memoize.Stored[string] {
+ return memoize.Stored[string]{Value: v, NoExpire: true}
+ }
+
+ if err := s.Set(ctx, "a", stored("A")); err != nil {
+ t.Fatalf("set a failed: %v", err)
+ }
+ if err := s.Set(ctx, "b", stored("B")); err != nil {
+ t.Fatalf("set b failed: %v", err)
+ }
+ if got := s.Len(); got != 2 {
+ t.Fatalf("expected len 2 before eviction, got %d", got)
+ }
+ if _, ok, _ := s.Get(ctx, "a"); !ok {
+ t.Fatal("expected a before eviction")
+ }
+ if err := s.Set(ctx, "c", stored("C")); err != nil {
+ t.Fatalf("set c failed: %v", err)
+ }
+
+ if got := s.Len(); got != 2 {
+ t.Fatalf("expected len 2 after eviction, got %d", got)
+ }
+ if _, ok, _ := s.Get(ctx, "b"); ok {
+ t.Fatal("expected b to be evicted")
+ }
+ if _, ok, _ := s.Get(ctx, "a"); !ok {
+ t.Fatal("expected a to survive as most recently used")
+ }
+ if _, ok, _ := s.Get(ctx, "c"); !ok {
+ t.Fatal("expected c to be present")
+ }
+}
+
+func TestEntriesSurviveUpdateDeleteAndCompaction(t *testing.T) {
+ ctx := context.Background()
+ s := New[string, string](4)
+ stored := func(v string, tags ...string) memoize.Stored[string] {
+ return memoize.Stored[string]{Value: v, NoExpire: true, Tags: tags}
+ }
+
+ for _, key := range []string{"k3", "k7", "k12", "k16"} {
+ if err := s.Set(ctx, key, stored(key, "group")); err != nil {
+ t.Fatalf("set %s failed: %v", key, err)
+ }
+ }
+ if err := s.Set(ctx, "k7", stored("B2", "group")); err != nil {
+ t.Fatalf("update k7 failed: %v", err)
+ }
+ if got := s.Len(); got != 4 {
+ t.Fatalf("update should not change len, got %d", got)
+ }
+ if err := s.Delete(ctx, "k7"); err != nil {
+ t.Fatalf("delete k7 failed: %v", err)
+ }
+ if err := s.Set(ctx, "k23", stored("e", "other")); err != nil {
+ t.Fatalf("set k23 failed: %v", err)
+ }
+
+ if _, ok, _ := s.Get(ctx, "k7"); ok {
+ t.Fatal("deleted key k7 should be absent")
+ }
+ for _, key := range []string{"k3", "k12", "k16", "k23"} {
+ if _, ok, _ := s.Get(ctx, key); !ok {
+ t.Fatalf("expected key %s to survive collision-chain compaction", key)
+ }
+ }
+ if err := s.DeleteByTag(ctx, "group"); err != nil {
+ t.Fatalf("delete by tag failed: %v", err)
+ }
+ for _, key := range []string{"k3", "k12", "k16"} {
+ if _, ok, _ := s.Get(ctx, key); ok {
+ t.Fatalf("expected tagged key %s to be removed", key)
+ }
+ }
+ if _, ok, _ := s.Get(ctx, "k23"); !ok {
+ t.Fatal("expected differently-tagged key k23 to survive")
+ }
+}
+
+func TestNoEvictionWhenUnbounded(t *testing.T) {
+ ctx := context.Background()
+ s := New[string, string](1000)
+
+ stored := func(v string) memoize.Stored[string] {
+ return memoize.Stored[string]{Value: v, NoExpire: true}
+ }
+ for i := 0; i < 1000; i++ {
+ _ = s.Set(ctx, strconv.Itoa(i), stored(strconv.Itoa(i)))
+ }
+ if _, ok, _ := s.Get(ctx, "0"); !ok {
+ t.Fatal("entry 0 should still exist in unbounded store")
+ }
+}
+
+func TestByteCapacityEvicts(t *testing.T) {
+ ctx := context.Background()
+ stored := func(v string) memoize.Stored[string] {
+ return memoize.Stored[string]{Value: v, NoExpire: true}
+ }
+ entryCost := entryBytes("key1", stored("0123456789"))
+ s := New[string, string](16, WithMaxBytes[string, string](entryCost*2-1))
+
+ _ = s.Set(ctx, "key1", stored("0123456789"))
+ _ = s.Set(ctx, "key2", stored("abcdefghij"))
+ _ = s.Set(ctx, "key3", stored("XXXXXXXXXX"))
+
+ if _, ok, _ := s.Get(ctx, "key1"); ok {
+ t.Fatal("key1 should have been evicted")
+ }
+ if _, ok, _ := s.Get(ctx, "key2"); ok {
+ t.Fatal("key2 should have been evicted")
+ }
+ if _, ok, _ := s.Get(ctx, "key3"); !ok {
+ t.Fatal("key3 should be present")
+ }
+}
+
+func TestUsedBytesAccountingAfterDeleteAndClear(t *testing.T) {
+ ctx := context.Background()
+ s := New[string, string](16)
+
+ stored := func(v string) memoize.Stored[string] {
+ return memoize.Stored[string]{Value: v, NoExpire: true}
+ }
+
+ _ = s.Set(ctx, "k1", stored("v1"))
+ _ = s.Set(ctx, "k2", stored("v2"))
+ before := s.UsedBytes()
+ if before == 0 {
+ t.Fatal("usedBytes should be nonzero after two sets")
+ }
+
+ _ = s.Delete(ctx, "k1")
+ after := s.UsedBytes()
+ if after >= before {
+ t.Fatalf("usedBytes should decrease after delete: before=%d after=%d", before, after)
+ }
+
+ _ = s.Clear(ctx)
+ if s.UsedBytes() != 0 {
+ t.Fatalf("usedBytes should be 0 after clear, got %d", s.UsedBytes())
+ }
+}
+
+func TestDeleteByTag(t *testing.T) {
+ ctx := context.Background()
+ s := New[string, string](16)
+
+ stored := func(v string, tags ...string) memoize.Stored[string] {
+ return memoize.Stored[string]{Value: v, NoExpire: true, Tags: tags}
+ }
+
+ _ = s.Set(ctx, "a", stored("A", "group1"))
+ _ = s.Set(ctx, "b", stored("B", "group1", "group2"))
+ _ = s.Set(ctx, "c", stored("C", "group2"))
+ _ = s.Set(ctx, "d", stored("D")) // no tags
+
+ _ = s.DeleteByTag(ctx, "group1")
+
+ if _, ok, _ := s.Get(ctx, "a"); ok {
+ t.Fatal("'a' should have been invalidated by tag group1")
+ }
+ if _, ok, _ := s.Get(ctx, "b"); ok {
+ t.Fatal("'b' should have been invalidated by tag group1")
+ }
+ if _, ok, _ := s.Get(ctx, "c"); !ok {
+ t.Fatal("'c' should survive (only has group2)")
+ }
+ if _, ok, _ := s.Get(ctx, "d"); !ok {
+ t.Fatal("'d' should survive (no tags)")
+ }
+}
+
+func TestConcurrentGetSet(t *testing.T) {
+ s := New[string, string](10)
+ var wg sync.WaitGroup
+ for i := 0; i < 200; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ key := strconv.Itoa(i % 15)
+ _ = s.Set(context.Background(), key, memoize.Stored[string]{Value: "v", NoExpire: true})
+ _, _, _ = s.Get(context.Background(), key)
+ }(i)
+ }
+ wg.Wait()
+}
diff --git a/tagstore.go b/tagstore.go
new file mode 100644
index 0000000..466c3a9
--- /dev/null
+++ b/tagstore.go
@@ -0,0 +1,11 @@
+package memoize
+
+import "context"
+
+// TaggedStore is an optional extension of Store[K, V] for tag-based invalidation.
+// A Store that supports tags should implement this interface.
+type TaggedStore[K comparable, V any] interface {
+ Store[K, V]
+ // DeleteByTag removes all entries whose Tags slice contains the given tag.
+ DeleteByTag(ctx context.Context, tag string) error
+}
diff --git a/unified_api_test.go b/unified_api_test.go
new file mode 100644
index 0000000..e930805
--- /dev/null
+++ b/unified_api_test.go
@@ -0,0 +1,91 @@
+package memoize_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ memoize "github.com/agkloop/go_memoize"
+ "github.com/agkloop/go_memoize/stores/memory"
+)
+
+func TestUnifiedRootMemoize1UsesDirectOptions(t *testing.T) {
+ calls := 0
+ cached, err := memoize.Memoize1(func(id int) string {
+ calls++
+ return "user"
+ }, memoize.Opts().WithTTL(time.Minute))
+ if err != nil {
+ t.Fatalf("Memoize1 returned error: %v", err)
+ }
+
+ if got := cached(42); got != "user" {
+ t.Fatalf("first call = %q", got)
+ }
+ if got := cached(42); got != "user" {
+ t.Fatalf("second call = %q", got)
+ }
+ if calls != 1 {
+ t.Fatalf("calls = %d, want 1", calls)
+ }
+}
+
+func TestUnifiedRootMemoizeCtx1EUsesDirectOptions(t *testing.T) {
+ calls := 0
+ cached, err := memoize.MemoizeCtx1E(func(ctx context.Context, id int) (string, error) {
+ calls++
+ return "user", ctx.Err()
+ }, memoize.Opts().WithTTL(time.Minute))
+ if err != nil {
+ t.Fatalf("MemoizeCtx1E returned error: %v", err)
+ }
+
+ if got, err := cached(context.Background(), 42); err != nil || got != "user" {
+ t.Fatalf("first call = %q, %v", got, err)
+ }
+ if got, err := cached(context.Background(), 42); err != nil || got != "user" {
+ t.Fatalf("second call = %q, %v", got, err)
+ }
+ if calls != 1 {
+ t.Fatalf("calls = %d, want 1", calls)
+ }
+}
+
+func TestUnifiedRootCacheEngineUsesRootStores(t *testing.T) {
+ cache, err := memoize.New[string, string](
+ memoize.Opts().
+ WithStore(memory.New[string, string](16)).
+ WithTTL(time.Minute),
+ )
+ if err != nil {
+ t.Fatalf("New returned error: %v", err)
+ }
+ defer cache.Stop()
+
+ calls := 0
+ compute := func(context.Context) (string, error) {
+ calls++
+ return "value", nil
+ }
+ if got, err := cache.GetOrCompute(context.Background(), "key", compute); err != nil || got != "value" {
+ t.Fatalf("first GetOrCompute = %q, %v", got, err)
+ }
+ if got, err := cache.GetOrCompute(context.Background(), "key", compute); err != nil || got != "value" {
+ t.Fatalf("second GetOrCompute = %q, %v", got, err)
+ }
+ if calls != 1 {
+ t.Fatalf("calls = %d, want 1", calls)
+ }
+}
+
+func TestUnifiedRootMemoize1UsesNonGenericOpts(t *testing.T) {
+ cached, err := memoize.Memoize1(func(id int) string {
+ return "user"
+ }, memoize.Opts().WithTTL(time.Minute))
+ if err != nil {
+ t.Fatalf("Memoize1 returned error: %v", err)
+ }
+ if got := cached(42); got != "user" {
+ t.Fatalf("cached value = %q", got)
+ }
+}