From 7d71724a84697bc0e8155b527bdbd0118b876c08 Mon Sep 17 00:00:00 2001 From: mkbeh Date: Mon, 21 Sep 2026 16:13:19 +0300 Subject: [PATCH 1/5] refactor: decouple metrics registration --- README.md | 6 +- cache.go | 33 +++--- cache_test.go | 18 +++ doc.go | 5 +- examples/otel/README.md | 2 +- examples/otel/main.go | 5 +- extra/paceotel/README.md | 12 +- extra/paceotel/cache.go | 7 +- extra/paceotel/cache_test.go | 71 ++++++++++++ extra/paceotel/doc.go | 2 + extra/paceotel/metrics.go | 22 ++-- extra/paceotel/metrics_test.go | 2 + metrics.go | 32 ------ metrics_test.go | 198 --------------------------------- options.go | 18 +-- options_test.go | 11 -- 16 files changed, 146 insertions(+), 298 deletions(-) delete mode 100644 metrics.go delete mode 100644 metrics_test.go diff --git a/README.md b/README.md index f0fed01..17e1151 100644 --- a/README.md +++ b/README.md @@ -225,9 +225,9 @@ _ = stats.ClearedEntryCount // Entries removed by full cache clears Statistics also include load outcomes, shared and superseded loads, deleted and cleared entry counts, cleanup activity, and segment count. -Optional OpenTelemetry metrics are available through [paceotel](./extra/paceotel). OpenTelemetry configuration and -exporter selection remain application concerns, so Prometheus, OTLP, and other exporters can be used without changing -the cache integration. +Optional OpenTelemetry metrics are available through [paceotel](./extra/paceotel) and are registered explicitly for +each cache. OpenTelemetry configuration and exporter selection remain application concerns, so Prometheus, OTLP, and +other exporters can be used without changing the cache integration. For a complete setup, see the [example](./examples/otel). diff --git a/cache.go b/cache.go index 3a0428f..9211b9b 100644 --- a/cache.go +++ b/cache.go @@ -25,6 +25,7 @@ const ( // Cache is safe for concurrent use. A Cache must not be copied after creation. type Cache[K comparable, V any] struct { loader Loader[K, V] + name string store *storage[K, V] states []cacheState[K, V] @@ -44,8 +45,7 @@ type Cache[K comparable, V any] struct { // // Unless overridden by options, New uses the default cache capacity, a single // storage segment, and no time-based expiration. No default loader is -// configured. Metrics are disabled by default, and background cleanup is not -// started automatically. +// configured. Background cleanup is not started automatically. func New[K comparable, V any]( options ...Option, ) (*Cache[K, V], error) { @@ -58,7 +58,7 @@ func New[K comparable, V any]( // exists. Per-call loaders may be supplied through GetOrLoadFunc and // GetOrLoadEntryFunc. Loader must not be nil. // -// Options, metrics, and background cleanup have the same semantics as New. +// Options and background cleanup have the same semantics as New. func NewWithDefaultLoader[K comparable, V any]( loader Loader[K, V], options ...Option, @@ -87,6 +87,7 @@ func newCache[K comparable, V any]( cache := &Cache[K, V]{ loader: loader, + name: settings.name, store: store, states: make([]cacheState[K, V], len(store.segments)), @@ -102,11 +103,18 @@ func newCache[K comparable, V any]( cleanupInterval: settings.cleanupInterval, } - if err := cache.registerMetrics(settings.name, settings.metrics); err != nil { - return nil, fmt.Errorf("pacecache: register metrics: %w", err) + return cache, nil +} + +// Name returns the optional logical name assigned to the cache. +// +// An unnamed, nil, or zero-value Cache returns an empty string. +func (cache *Cache[K, V]) Name() string { + if cache == nil { + return "" } - return cache, nil + return cache.name } // StartCleanup runs periodic expiration cleanup until StopCleanup is called. @@ -170,19 +178,6 @@ func (cache *Cache[K, V]) effectiveTTL(expiration time.Duration) time.Duration { return jitteredTTL(ttl, cache.jitter) } -func (cache *Cache[K, V]) registerMetrics(name string, metrics Metrics) error { - if metrics == nil { - return nil - } - - return metrics.Register( - metricsSource[K, V]{ - name: name, - cache: cache, - }, - ) -} - func (cache *Cache[K, V]) initialized() bool { return cache != nil && cache.store != nil && diff --git a/cache_test.go b/cache_test.go index 402a137..3625d81 100644 --- a/cache_test.go +++ b/cache_test.go @@ -11,6 +11,9 @@ import ( func TestNilCacheIsSafe(t *testing.T) { var cache *Cache[string, int] + if cache.Name() != "" { + t.Fatalf("nil Cache.Name() = %q, want empty", cache.Name()) + } if cache.Exists("key") { t.Fatal("nil Cache.Exists() = true, want false") } @@ -34,6 +37,9 @@ func TestNilCacheIsSafe(t *testing.T) { func TestZeroValueCacheIsSafe(t *testing.T) { var cache Cache[string, int] + if cache.Name() != "" { + t.Fatalf("zero Cache.Name() = %q, want empty", cache.Name()) + } if cache.Exists("key") || cache.RefreshTTL("key") { t.Fatal("zero Cache unexpectedly reports a live key") } @@ -119,6 +125,18 @@ func TestEffectiveTTLAndDeadlineHelpers(t *testing.T) { } } +func TestCacheName(t *testing.T) { + named := mustNewCache[int](t, WithName("users")) + if got := named.Name(); got != "users" { + t.Fatalf("Name() = %q, want users", got) + } + + unnamed := mustNewCache[int](t) + if got := unnamed.Name(); got != "" { + t.Fatalf("unnamed Name() = %q, want empty", got) + } +} + func TestNewEnablesSlidingExpiration(t *testing.T) { cache := mustNewCache[int](t, WithSlidingExpiration()) for index := range cache.store.segments { diff --git a/doc.go b/doc.go index 03c9467..157bad8 100644 --- a/doc.go +++ b/doc.go @@ -13,9 +13,10 @@ // Cache mutations act as publication barriers for concurrent loads, preventing // superseded loader results from overwriting newer cache state. // +// Cache.Name exposes an optional logical cache name configured with WithName. // Cache statistics are collected locally and exposed through Cache.Stats. -// Optional metrics integrations register when a cache is created and observe -// those snapshots without adding telemetry calls to the cache request path. +// Observability integrations can use both without adding telemetry calls to +// the cache request path. // // The cache is local to one application process. It does not provide // distributed cache coherence between application instances. diff --git a/examples/otel/README.md b/examples/otel/README.md index 9e09174..5f70ef5 100644 --- a/examples/otel/README.md +++ b/examples/otel/README.md @@ -8,7 +8,7 @@ OpenTelemetry SDK exporter without changing the cache integration. ## Key Concepts Covered * Configuring OpenTelemetry with a custom `MeterProvider` -* Attaching `paceotel` to a cache through `pacecache.WithMetrics` +* Registering a cache explicitly with `paceotel` * Observing hits, misses, loader outcomes, and deletions * Handling not-found loader results without caching them * Flushing telemetry before a short-lived process exits diff --git a/examples/otel/main.go b/examples/otel/main.go index 7135892..a957b48 100644 --- a/examples/otel/main.go +++ b/examples/otel/main.go @@ -71,12 +71,15 @@ func run(ctx context.Context) error { pacecache.WithName("users"), pacecache.WithMaxEntries(128), pacecache.WithTTL(time.Minute), - pacecache.WithMetrics(metrics), ) if err != nil { return fmt.Errorf("create users cache: %w", err) } + if err := metrics.Register(users); err != nil { + return fmt.Errorf("register users cache metrics: %w", err) + } + // 3. Generate representative cache activity. lookups := []struct { id int64 diff --git a/extra/paceotel/README.md b/extra/paceotel/README.md index c299258..f65ddc0 100644 --- a/extra/paceotel/README.md +++ b/extra/paceotel/README.md @@ -33,11 +33,17 @@ metrics := paceotel.New( ) defer metrics.Unregister() -// Attach metrics when creating the cache. -cache, _ := pacecache.New[string, string]( +cache, err := pacecache.New[string, string]( pacecache.WithName("users"), - pacecache.WithMetrics(metrics), ) +if err != nil { + panic(err) +} + +// Register the cache with the OpenTelemetry integration. +if err := metrics.Register(cache); err != nil { + panic(err) +} ``` diff --git a/extra/paceotel/cache.go b/extra/paceotel/cache.go index 150c4f2..63f7d3c 100644 --- a/extra/paceotel/cache.go +++ b/extra/paceotel/cache.go @@ -405,8 +405,8 @@ func newMetricInstruments(meter metric.Meter) (metricInstruments, error) { return instruments, nil } -func newMetricAttributes(name string) metricAttributes { - var base []attribute.KeyValue +func newMetricAttributes(name string, attributes ...attribute.KeyValue) metricAttributes { + base := slices.Clone(attributes) if name != "" { base = append( @@ -418,7 +418,8 @@ func newMetricAttributes(name string) metricAttributes { option := func(extra ...attribute.KeyValue) metric.ObserveOption { return metric.WithAttributeSet( attribute.NewSet( - slices.Concat(base, extra)...), + slices.Concat(base, extra)..., + ), ) } diff --git a/extra/paceotel/cache_test.go b/extra/paceotel/cache_test.go index f594fcd..afe4df0 100644 --- a/extra/paceotel/cache_test.go +++ b/extra/paceotel/cache_test.go @@ -310,6 +310,77 @@ func TestMetricsCollectsSource(t *testing.T) { ) } +func TestMetricsRegisterAttributes(t *testing.T) { + metrics, reader := newTestMetrics(t) + + if err := metrics.Register( + &metricsSourceStub{ + name: "users", + stats: pacecache.Stats{ + EntryCount: 3, + HitCount: 11, + }, + }, + attribute.String("pacecache.shard", "0"), + ); err != nil { + t.Fatalf("Register() error = %v", err) + } + + collected := collectMetricsByName(t, collectTestMetrics(t, reader)) + + requireInt64GaugePoint( + t, + collected, + wantEntryCountMetricName, + map[string]string{ + wantCacheNameAttribute: "users", + "pacecache.shard": "0", + }, + 3, + ) + requireInt64CounterPoint( + t, + collected, + wantLookupCountMetricName, + map[string]string{ + wantCacheNameAttribute: "users", + wantLookupResultAttribute: "hit", + "pacecache.shard": "0", + }, + 11, + ) +} + +func TestMetricsRegisterAttributesLibraryValuesWin(t *testing.T) { + metrics, reader := newTestMetrics(t) + + if err := metrics.Register( + &metricsSourceStub{ + name: "users", + stats: pacecache.Stats{ + HitCount: 11, + }, + }, + attribute.String(cacheNameAttribute, "overridden"), + attribute.String(lookupResultAttribute, "overridden"), + ); err != nil { + t.Fatalf("Register() error = %v", err) + } + + collected := collectMetricsByName(t, collectTestMetrics(t, reader)) + + requireInt64CounterPoint( + t, + collected, + wantLookupCountMetricName, + map[string]string{ + wantCacheNameAttribute: "users", + wantLookupResultAttribute: "hit", + }, + 11, + ) +} + func TestMetricsCollectsMultipleSources(t *testing.T) { metrics, reader := newTestMetrics(t) diff --git a/extra/paceotel/doc.go b/extra/paceotel/doc.go index 9cfb0a5..bdc1f41 100644 --- a/extra/paceotel/doc.go +++ b/extra/paceotel/doc.go @@ -1,2 +1,4 @@ // Package paceotel provides OpenTelemetry metrics for pacecache. +// +// Cache instances are registered explicitly through Metrics.Register. package paceotel diff --git a/extra/paceotel/metrics.go b/extra/paceotel/metrics.go index 61c231d..1ebc1d0 100644 --- a/extra/paceotel/metrics.go +++ b/extra/paceotel/metrics.go @@ -8,9 +8,16 @@ import ( "github.com/mkbeh/pacecache" "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" ) +// Source exposes the cache identity and statistics required by Metrics. +type Source interface { + Name() string + Stats() pacecache.Stats +} + // Metrics exports pacecache statistics through OpenTelemetry. // // Metrics is safe to reuse across multiple caches. Each registered source must @@ -25,8 +32,6 @@ type Metrics struct { closed bool } -var _ pacecache.Metrics = (*Metrics)(nil) - // New creates an OpenTelemetry metrics implementation. // // By default, metrics use the global OpenTelemetry MeterProvider. The returned @@ -57,8 +62,10 @@ func New(options ...Option) *Metrics { // Register adds one cache metrics source to this OpenTelemetry integration. // The source name must be unique within Metrics. An empty name is allowed, but -// only one unnamed source may be registered. -func (metrics *Metrics) Register(source pacecache.MetricsSource) error { +// only one unnamed source may be registered. Additional attributes are attached +// to every metric observation for the source. paceotel-owned attributes take +// precedence over attributes with the same keys. +func (metrics *Metrics) Register(source Source, attributes ...attribute.KeyValue) error { if metrics == nil { return errors.New("paceotel: metrics is nil") } @@ -91,7 +98,7 @@ func (metrics *Metrics) Register(source pacecache.MetricsSource) error { registration, err := metrics.registerCallback( source, - newMetricAttributes(name), + newMetricAttributes(name, attributes...), ) if err != nil { return err @@ -141,10 +148,7 @@ func (metrics *Metrics) Unregister() error { return nil } -func (metrics *Metrics) registerCallback( - source pacecache.MetricsSource, - attributes metricAttributes, -) (metric.Registration, error) { +func (metrics *Metrics) registerCallback(source Source, attributes metricAttributes) (metric.Registration, error) { instruments := *metrics.instruments registration, err := metrics.meter.RegisterCallback( diff --git a/extra/paceotel/metrics_test.go b/extra/paceotel/metrics_test.go index 303a977..5514582 100644 --- a/extra/paceotel/metrics_test.go +++ b/extra/paceotel/metrics_test.go @@ -17,6 +17,8 @@ import ( "go.opentelemetry.io/otel/sdk/metric/metricdata" ) +var _ Source = (*pacecache.Cache[string, int])(nil) + type metricsSourceStub struct { name string stats pacecache.Stats diff --git a/metrics.go b/metrics.go deleted file mode 100644 index 0a26e27..0000000 --- a/metrics.go +++ /dev/null @@ -1,32 +0,0 @@ -package pacecache - -// MetricsSource exposes cache identity and statistics to a metrics implementation. -type MetricsSource interface { - Name() string - Stats() Stats -} - -// Metrics registers cache statistics with a metrics implementation. -// -// Implementations must be safe to reuse across multiple caches. Register may -// be called concurrently. -// -// If Register returns an error, the implementation must release any resources -// created during the registration attempt. -type Metrics interface { - Register(source MetricsSource) error -} - -// metricsSource exposes only the capabilities required by Metrics. -type metricsSource[K comparable, V any] struct { - name string - cache *Cache[K, V] -} - -func (source metricsSource[K, V]) Name() string { - return source.name -} - -func (source metricsSource[K, V]) Stats() Stats { - return source.cache.Stats() -} diff --git a/metrics_test.go b/metrics_test.go deleted file mode 100644 index 26b5e08..0000000 --- a/metrics_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package pacecache - -import ( - "errors" - "slices" - "sync" - "testing" -) - -type testMetrics struct { - mu sync.Mutex - - registerCalls int - sources []MetricsSource - err error -} - -func (metrics *testMetrics) Register(source MetricsSource) error { - metrics.mu.Lock() - defer metrics.mu.Unlock() - - metrics.registerCalls++ - - if metrics.err != nil { - return metrics.err - } - - metrics.sources = append(metrics.sources, source) - - return nil -} - -func (metrics *testMetrics) snapshot() (int, []MetricsSource) { - metrics.mu.Lock() - defer metrics.mu.Unlock() - - return metrics.registerCalls, slices.Clone(metrics.sources) -} - -func TestMetricsRegistersSource(t *testing.T) { - metrics := &testMetrics{} - - cache, err := New[string, int]( - WithName("users"), - WithMetrics(metrics), - ) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - registerCalls, sources := metrics.snapshot() - if registerCalls != 1 { - t.Fatalf("Register() calls = %d, want 1", registerCalls) - } - if len(sources) != 1 { - t.Fatalf("registered sources = %d, want 1", len(sources)) - } - - source := sources[0] - if source.Name() != "users" { - t.Fatalf("source name = %q, want users", source.Name()) - } - if _, ok := source.(interface{ StartCleanup() }); ok { - t.Fatal("metrics source unexpectedly exposes Cache.StartCleanup") - } - if _, ok := source.(interface{ StopCleanup() }); ok { - t.Fatal("metrics source unexpectedly exposes Cache.StopCleanup") - } - - cache.Set("a", 1, NoExpiration) - if got := source.Stats().EntryCount; got != 1 { - t.Fatalf("source Stats().EntryCount = %d, want 1", got) - } -} - -func TestMetricsRegistersMultipleCaches(t *testing.T) { - metrics := &testMetrics{} - - users, err := New[string, int]( - WithName("users"), - WithMetrics(metrics), - ) - if err != nil { - t.Fatalf("New(users) error = %v", err) - } - - sessions, err := New[string, int]( - WithName("sessions"), - WithMetrics(metrics), - ) - if err != nil { - t.Fatalf("New(sessions) error = %v", err) - } - - users.Set("a", 1, NoExpiration) - sessions.Set("a", 1, NoExpiration) - sessions.Set("b", 2, NoExpiration) - - registerCalls, sources := metrics.snapshot() - if registerCalls != 2 { - t.Fatalf("Register() calls = %d, want 2", registerCalls) - } - if len(sources) != 2 { - t.Fatalf("registered sources = %d, want 2", len(sources)) - } - - byName := make(map[string]MetricsSource, len(sources)) - for _, source := range sources { - if _, ok := source.(interface{ StartCleanup() }); ok { - t.Fatalf( - "metrics source %q unexpectedly exposes Cache.StartCleanup", - source.Name(), - ) - } - if _, ok := source.(interface{ StopCleanup() }); ok { - t.Fatalf( - "metrics source %q unexpectedly exposes Cache.StopCleanup", - source.Name(), - ) - } - - if _, exists := byName[source.Name()]; exists { - t.Fatalf("metrics source %q registered more than once", source.Name()) - } - - byName[source.Name()] = source - } - - usersSource, ok := byName["users"] - if !ok { - t.Fatal("users metrics source not registered") - } - if got := usersSource.Stats().EntryCount; got != 1 { - t.Fatalf("users Stats().EntryCount = %d, want 1", got) - } - - sessionsSource, ok := byName["sessions"] - if !ok { - t.Fatal("sessions metrics source not registered") - } - if got := sessionsSource.Stats().EntryCount; got != 2 { - t.Fatalf("sessions Stats().EntryCount = %d, want 2", got) - } -} - -func TestMetricsRegistersUnnamedSource(t *testing.T) { - metrics := &testMetrics{} - - if _, err := New[string, int](WithMetrics(metrics)); err != nil { - t.Fatalf("New() error = %v", err) - } - - _, sources := metrics.snapshot() - if len(sources) != 1 { - t.Fatalf("registered sources = %d, want 1", len(sources)) - } - if sources[0].Name() != "" { - t.Fatalf("source name = %q, want empty", sources[0].Name()) - } -} - -func TestMetricsRegisterError(t *testing.T) { - sentinel := errors.New("register failed") - metrics := &testMetrics{err: sentinel} - - cache, err := New[string, int]( - WithName("users"), - WithMetrics(metrics), - ) - if cache != nil { - t.Fatal("cache must be nil when metrics registration fails") - } - if !errors.Is(err, sentinel) { - t.Fatalf("error = %v, want wrapped sentinel", err) - } - - registerCalls, sources := metrics.snapshot() - if registerCalls != 1 { - t.Fatalf("Register() calls = %d, want 1", registerCalls) - } - if len(sources) != 0 { - t.Fatalf("registered sources = %d, want 0", len(sources)) - } -} - -func TestMetricsNilIsNoop(t *testing.T) { - cache, err := New[string, int](WithMetrics(nil)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - cache.Set("a", 1, NoExpiration) - - value, found := cache.Get("a") - if !found || value != 1 { - t.Fatalf("Get(a) = %d, %t, want 1, true", value, found) - } -} diff --git a/options.go b/options.go index 9c26a1e..4acc34b 100644 --- a/options.go +++ b/options.go @@ -29,8 +29,6 @@ type settings struct { cleanupInterval time.Duration cleanupBatchSize int cleanupEntryBudget int - - metrics Metrics } func newSettings(options ...Option) (*settings, error) { @@ -66,8 +64,8 @@ func defaultSettings() *settings { // WithName configures an optional logical cache name. // -// Metrics implementations may use the name to distinguish cache instances. -// An empty name leaves the cache unnamed. +// The name can be used to identify the cache in logs, diagnostics, or +// observability integrations. An empty name leaves the cache unnamed. func WithName(name string) Option { return func(settings *settings) error { settings.name = name @@ -222,18 +220,6 @@ func WithCleanupEntryBudget(entries int) Option { } } -// WithMetrics configures optional cache metrics. -// -// The Metrics implementation may be reused by multiple caches. The cache -// does not manage the lifecycle of metrics registrations. -func WithMetrics(metrics Metrics) Option { - return func(settings *settings) error { - settings.metrics = metrics - - return nil - } -} - func (settings *settings) validate() error { if settings.ttl > 0 && settings.ttl > maxDuration-settings.jitter { return errors.New("ttl plus jitter exceeds maximum duration") diff --git a/options_test.go b/options_test.go index 4392b95..fc04a03 100644 --- a/options_test.go +++ b/options_test.go @@ -40,14 +40,9 @@ func TestDefaultSettings(t *testing.T) { if settings.slidingExpiration { t.Fatal("sliding expiration must be disabled by default") } - if settings.metrics != nil { - t.Fatal("metrics must be nil by default") - } } func TestNewSettingsAppliesOptions(t *testing.T) { - metrics := &testMetrics{} - got, err := newSettings( WithName("users"), WithMaxEntries(128), @@ -58,7 +53,6 @@ func TestNewSettingsAppliesOptions(t *testing.T) { WithCleanupInterval(time.Second), WithCleanupBatchSize(1024), WithCleanupEntryBudget(64*1024), - WithMetrics(metrics), ) if err != nil { t.Fatalf("newSettings() error = %v", err) @@ -74,7 +68,6 @@ func TestNewSettingsAppliesOptions(t *testing.T) { cleanupInterval: time.Second, cleanupBatchSize: 1024, cleanupEntryBudget: 64 * 1024, - metrics: metrics, } if *got != want { @@ -162,7 +155,6 @@ func TestSettingsAcceptsBoundaryValues(t *testing.T) { WithMaxEntries(1), WithTTL(NoExpiration), WithJitter(maxDuration), - WithMetrics(nil), ) if err != nil { t.Fatalf("newSettings() error = %v", err) @@ -174,7 +166,4 @@ func TestSettingsAcceptsBoundaryValues(t *testing.T) { if settings.ttl != NoExpiration { t.Fatalf("ttl = %v, want NoExpiration", settings.ttl) } - if settings.metrics != nil { - t.Fatal("metrics must remain nil") - } } From b48d62a434f54c3b42aaced71b26ca1a70876ea8 Mon Sep 17 00:00:00 2001 From: mkbeh Date: Mon, 21 Sep 2026 18:13:25 +0300 Subject: [PATCH 2/5] refactor: simplify cache construction --- README.md | 13 +- benchmarks/performance/hitratio/cmd/main.go | 4 +- .../hitratio/internal/policy/policy.go | 23 +-- .../hitratio/internal/simulator/simulator.go | 17 +-- benchmarks/performance/memory/main.go | 6 +- .../performance/throughput/throughput_test.go | 6 +- cache.go | 31 ++-- cache_test.go | 46 +++--- cleanup_test.go | 44 ++---- delete_test.go | 14 +- errors.go | 2 +- examples/basic/main.go | 6 +- examples/otel/main.go | 5 +- extra/paceotel/README.md | 5 +- load.go | 4 +- load_test.go | 101 +++++-------- lookup_test.go | 15 +- options.go | 133 ++++++----------- options_test.go | 136 +++++++++++------- set_test.go | 28 ++-- stats_test.go | 2 +- 21 files changed, 256 insertions(+), 385 deletions(-) diff --git a/README.md b/README.md index 17e1151..45b1807 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,7 @@ Create a cache with `pacecache.New`: ```go -cache, err := pacecache.New[string, string]() -if err != nil { - panic(err) -} +cache := pacecache.New[string, string]() ``` @@ -62,7 +59,7 @@ deadlines and reduce synchronized expiration bursts. Individual entries can use ```go -cache, _ := pacecache.New[string, string]( +cache := pacecache.New[string, string]( pacecache.WithTTL(5*time.Minute), pacecache.WithJitter(30*time.Second), ) @@ -124,11 +121,11 @@ if found { ``` -If the same loader is reused across calls, configure it once with `NewWithDefaultLoader` and use `GetOrLoad`: +If the same loader is reused across calls, configure it once with `NewWithLoader` and use `GetOrLoad`: ```go -cache, _ := pacecache.NewWithDefaultLoader[string, string]( +cache := pacecache.NewWithLoader[string, string]( func(ctx context.Context, key string) (string, bool, error) { // Fetch data from a database, file, or remote service. return "loaded value", true, nil @@ -156,7 +153,7 @@ Expired entries are removed lazily when encountered. Background cleanup can be s ```go -cache, _ := pacecache.New[string, string]( +cache := pacecache.New[string, string]( pacecache.WithTTL(5*time.Minute), ) diff --git a/benchmarks/performance/hitratio/cmd/main.go b/benchmarks/performance/hitratio/cmd/main.go index 2b7322d..8999e41 100644 --- a/benchmarks/performance/hitratio/cmd/main.go +++ b/benchmarks/performance/hitratio/cmd/main.go @@ -35,9 +35,7 @@ func run(configPath string) error { app := simulator.New(cfg) - if err := app.Simulate(os.Stdout); err != nil { - return fmt.Errorf("simulate trace: %w", err) - } + app.Simulate(os.Stdout) return nil } diff --git a/benchmarks/performance/hitratio/internal/policy/policy.go b/benchmarks/performance/hitratio/internal/policy/policy.go index c26c1b1..3fbfee6 100644 --- a/benchmarks/performance/hitratio/internal/policy/policy.go +++ b/benchmarks/performance/hitratio/internal/policy/policy.go @@ -1,10 +1,6 @@ package policy -import ( - "fmt" - - "github.com/mkbeh/pacecache" -) +import "github.com/mkbeh/pacecache" type Policy struct { cache *pacecache.Cache[uint64, uint64] @@ -13,18 +9,13 @@ type Policy struct { misses uint64 } -func New(capacity int, segments int) (*Policy, error) { - cache, err := pacecache.New[uint64, uint64]( - pacecache.WithMaxEntries(capacity), - pacecache.WithSegmentCount(segments), - ) - if err != nil { - return nil, fmt.Errorf("create cache: %w", err) - } - +func New(capacity int, segments int) *Policy { return &Policy{ - cache: cache, - }, nil + cache: pacecache.New[uint64, uint64]( + pacecache.WithMaxEntries(capacity), + pacecache.WithSegmentCount(segments), + ), + } } func (p *Policy) Record(key uint64) { diff --git a/benchmarks/performance/hitratio/internal/simulator/simulator.go b/benchmarks/performance/hitratio/internal/simulator/simulator.go index 7c483d9..4ea1cfe 100644 --- a/benchmarks/performance/hitratio/internal/simulator/simulator.go +++ b/benchmarks/performance/hitratio/internal/simulator/simulator.go @@ -19,7 +19,7 @@ func New(cfg config.Config) Simulator { } } -func (s Simulator) Simulate(output io.Writer) error { +func (s Simulator) Simulate(output io.Writer) { fmt.Fprintln( output, "trace,capacity,segments,requests,hits,misses,hit_ratio", @@ -28,10 +28,7 @@ func (s Simulator) Simulate(output io.Writer) error { for _, unsignedCapacity := range s.cfg.Capacities { capacity := int(unsignedCapacity) - result, err := s.simulateCapacity(capacity) - if err != nil { - return err - } + result := s.simulateCapacity(capacity) fmt.Fprintf( output, @@ -46,7 +43,6 @@ func (s Simulator) Simulate(output io.Writer) error { ) } - return nil } type result struct { @@ -56,11 +52,8 @@ type result struct { Ratio float64 } -func (s Simulator) simulateCapacity(capacity int) (result, error) { - p, err := policy.New(capacity, s.cfg.Segments) - if err != nil { - return result{}, fmt.Errorf("create policy for capacity %d: %w", capacity, err) - } +func (s Simulator) simulateCapacity(capacity int) result { + p := policy.New(capacity, s.cfg.Segments) generator := trace.NewZipf( s.cfg.Zipf.S, @@ -86,5 +79,5 @@ func (s Simulator) simulateCapacity(capacity int) (result, error) { Hits: hits, Misses: misses, Ratio: p.Ratio(), - }, nil + } } diff --git a/benchmarks/performance/memory/main.go b/benchmarks/performance/memory/main.go index e9a20fd..efbf6ab 100644 --- a/benchmarks/performance/memory/main.go +++ b/benchmarks/performance/memory/main.go @@ -37,15 +37,11 @@ func main() { var before runtime.MemStats runtime.ReadMemStats(&before) - cache, err := pacecache.New[string, string]( + cache := pacecache.New[string, string]( pacecache.WithMaxEntries(*capacity), pacecache.WithSegmentCount(segmentCount), pacecache.WithTTL(expiration), ) - if err != nil { - log.Fatalf("create cache: %v", err) - } - for index := range *capacity { key := keys[index] diff --git a/benchmarks/performance/throughput/throughput_test.go b/benchmarks/performance/throughput/throughput_test.go index 622b0b1..c394226 100644 --- a/benchmarks/performance/throughput/throughput_test.go +++ b/benchmarks/performance/throughput/throughput_test.go @@ -131,14 +131,10 @@ func newThroughputCache( b.Helper() for range throughputPopulationAttempts { - cache, err := pacecache.New[string, string]( + cache := pacecache.New[string, string]( pacecache.WithMaxEntries(maxEntries), pacecache.WithSegmentCount(throughputSegments), ) - if err != nil { - b.Fatalf("create cache: %v", err) - } - for index, key := range data.keys { cache.Set( key, diff --git a/cache.go b/cache.go index 9211b9b..bb6e42a 100644 --- a/cache.go +++ b/cache.go @@ -1,7 +1,7 @@ package pacecache import ( - "fmt" + "math" "math/rand/v2" "sync" "time" @@ -15,6 +15,8 @@ const ( NoExpiration time.Duration = -1 ) +const maxDuration = time.Duration(math.MaxInt64) + // Cache is a bounded in-process cache for keys of type K and values of type V. // // Cache uses exact LRU eviction within each storage segment and TTL expiration. @@ -48,36 +50,29 @@ type Cache[K comparable, V any] struct { // configured. Background cleanup is not started automatically. func New[K comparable, V any]( options ...Option, -) (*Cache[K, V], error) { +) *Cache[K, V] { return newCache[K, V](nil, options...) } -// NewWithDefaultLoader creates a Cache with the given default loader. +// NewWithLoader creates a Cache with the given default loader. // // The loader is used by GetOrLoad and GetOrLoadEntry when no live cache entry // exists. Per-call loaders may be supplied through GetOrLoadFunc and -// GetOrLoadEntryFunc. Loader must not be nil. +// GetOrLoadEntryFunc. A nil loader leaves the cache without a default loader. // // Options and background cleanup have the same semantics as New. -func NewWithDefaultLoader[K comparable, V any]( +func NewWithLoader[K comparable, V any]( loader Loader[K, V], options ...Option, -) (*Cache[K, V], error) { - if loader == nil { - return nil, ErrNoLoader - } - - return newCache[K, V](loader, options...) +) *Cache[K, V] { + return newCache(loader, options...) } func newCache[K comparable, V any]( loader Loader[K, V], options ...Option, -) (*Cache[K, V], error) { - settings, err := newSettings(options...) - if err != nil { - return nil, fmt.Errorf("pacecache: %w", err) - } +) *Cache[K, V] { + settings := newSettings(options...) store := newStorage[K, V]( settings.maxEntries, @@ -85,7 +80,7 @@ func newCache[K comparable, V any]( settings.slidingExpiration, ) - cache := &Cache[K, V]{ + return &Cache[K, V]{ loader: loader, name: settings.name, @@ -102,8 +97,6 @@ func newCache[K comparable, V any]( }, cleanupInterval: settings.cleanupInterval, } - - return cache, nil } // Name returns the optional logical name assigned to the cache. diff --git a/cache_test.go b/cache_test.go index 3625d81..1804d41 100644 --- a/cache_test.go +++ b/cache_test.go @@ -83,7 +83,7 @@ func TestZeroValueCacheLoadReturnsNotInitialized(t *testing.T) { } } -func TestEffectiveTTLAndDeadlineHelpers(t *testing.T) { +func TestCacheEffectiveTTL(t *testing.T) { cache := &Cache[string, int]{ttl: 10 * time.Second} if got := cache.effectiveTTL(DefaultExpiration); got != 10*time.Second { @@ -103,7 +103,9 @@ func TestEffectiveTTLAndDeadlineHelpers(t *testing.T) { t.Fatalf("jittered TTL = %v, want [5s,6s)", got) } } +} +func TestDeadlineAfter(t *testing.T) { if got := deadlineAfter(100, 0); got != 0 { t.Fatalf("deadlineAfter zero = %d, want 0", got) } @@ -116,7 +118,9 @@ func TestEffectiveTTLAndDeadlineHelpers(t *testing.T) { if got := deadlineAfter(int64(maxDuration)-5, 10*time.Nanosecond); got != int64(maxDuration) { t.Fatalf("saturated deadline = %d, want %d", got, int64(maxDuration)) } +} +func TestJitteredTTL(t *testing.T) { if got := jitteredTTL(5*time.Second, 0); got != 5*time.Second { t.Fatalf("jitteredTTL without jitter = %v", got) } @@ -126,19 +130,19 @@ func TestEffectiveTTLAndDeadlineHelpers(t *testing.T) { } func TestCacheName(t *testing.T) { - named := mustNewCache[int](t, WithName("users")) + named := newTestCache[int](WithName("users")) if got := named.Name(); got != "users" { t.Fatalf("Name() = %q, want users", got) } - unnamed := mustNewCache[int](t) + unnamed := newTestCache[int]() if got := unnamed.Name(); got != "" { t.Fatalf("unnamed Name() = %q, want empty", got) } } func TestNewEnablesSlidingExpiration(t *testing.T) { - cache := mustNewCache[int](t, WithSlidingExpiration()) + cache := newTestCache[int](WithSlidingExpiration()) for index := range cache.store.segments { if !cache.store.segments[index].slidingExpiration { t.Fatalf("segment %d sliding expiration disabled", index) @@ -146,13 +150,12 @@ func TestNewEnablesSlidingExpiration(t *testing.T) { } } -func TestNewWrapsConfigurationError(t *testing.T) { - cache, err := New[string, int](WithMaxEntries(2), WithSegmentCount(3)) - if cache != nil { - t.Fatal("cache must be nil for invalid configuration") - } - if err == nil || err.Error() != "pacecache: invalid configuration: segment count must not exceed max entries" { - t.Fatalf("New() error = %v", err) +func TestNewClampsSegmentCountToMaxEntries(t *testing.T) { + cache := New[string, int](WithMaxEntries(2), WithSegmentCount(3)) + + stats := cache.Stats() + if stats.MaxEntries != 2 || stats.SegmentCount != 2 { + t.Fatalf("Stats() = maxEntries:%d segmentCount:%d, want 2/2", stats.MaxEntries, stats.SegmentCount) } } @@ -162,10 +165,7 @@ type testCompositeKey struct { } func TestCacheSupportsInt64Keys(t *testing.T) { - cache, err := New[int64, string](WithMaxEntries(8), WithSegmentCount(2)) - if err != nil { - t.Fatalf("New() error = %v", err) - } + cache := New[int64, string](WithMaxEntries(8), WithSegmentCount(2)) cache.Set(42, "Ada", NoExpiration) @@ -186,10 +186,7 @@ func TestCacheSupportsInt64Keys(t *testing.T) { } func TestCacheSupportsComparableStructKeys(t *testing.T) { - cache, err := New[testCompositeKey, int](WithMaxEntries(8), WithSegmentCount(2)) - if err != nil { - t.Fatalf("New() error = %v", err) - } + cache := New[testCompositeKey, int](WithMaxEntries(8), WithSegmentCount(2)) stored := testCompositeKey{TenantID: 7, UserID: 42} equal := testCompositeKey{TenantID: 7, UserID: 42} @@ -251,15 +248,8 @@ func (ctx *observedWaitContext) cancel() { const testTimeout = 5 * time.Second -func mustNewCache[V any](t *testing.T, options ...Option) *Cache[string, V] { - t.Helper() - - cache, err := New[string, V](options...) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - return cache +func newTestCache[V any](options ...Option) *Cache[string, V] { + return New[string, V](options...) } func requirePanic(t *testing.T, fn func()) { diff --git a/cleanup_test.go b/cleanup_test.go index 66b70d3..16f3626 100644 --- a/cleanup_test.go +++ b/cleanup_test.go @@ -47,25 +47,17 @@ func TestCleanupWorkerEmptyStorage(t *testing.T) { } func TestNewDoesNotStartCleanup(t *testing.T) { - cache, err := New[string, int](WithCleanupInterval(time.Millisecond)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - + cache := New[string, int](WithCleanupInterval(time.Millisecond)) if cache.cleanup != nil { t.Fatal("cleanup unexpectedly running after New") } } func TestStartCleanupRemovesExpiredEntry(t *testing.T) { - cache, err := New[string, int]( + cache := New[string, int]( WithMaxEntries(1), WithCleanupInterval(time.Millisecond), ) - if err != nil { - t.Fatalf("New() error = %v", err) - } - cache.store.enableExpirationIndex(time.Nanosecond) cache.Set("key", 1, time.Millisecond) time.Sleep(2 * time.Millisecond) @@ -95,7 +87,7 @@ func TestStartCleanupRemovesExpiredEntry(t *testing.T) { } func TestStopCleanupWithoutStartIsNoop(t *testing.T) { - cache := mustNewCache[int](t) + cache := newTestCache[int]() done := make(chan struct{}) go func() { @@ -107,11 +99,7 @@ func TestStopCleanupWithoutStartIsNoop(t *testing.T) { } func TestStopCleanupStopsRunningWorker(t *testing.T) { - cache, err := New[string, int](WithCleanupInterval(time.Hour)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - + cache := New[string, int](WithCleanupInterval(time.Hour)) done := startTestCleanup(t, cache) cache.StopCleanup() @@ -121,11 +109,7 @@ func TestStopCleanupStopsRunningWorker(t *testing.T) { } func TestStopCleanupConcurrent(t *testing.T) { - cache, err := New[string, int](WithCleanupInterval(time.Hour)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - + cache := New[string, int](WithCleanupInterval(time.Hour)) done := startTestCleanup(t, cache) const callers = 16 @@ -143,11 +127,7 @@ func TestStopCleanupConcurrent(t *testing.T) { } func TestStartCleanupCanRestart(t *testing.T) { - cache, err := New[string, int](WithCleanupInterval(time.Hour)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - + cache := New[string, int](WithCleanupInterval(time.Hour)) firstDone := startTestCleanup(t, cache) cache.StopCleanup() waitTestSignal(t, firstDone) @@ -158,11 +138,7 @@ func TestStartCleanupCanRestart(t *testing.T) { } func TestStartCleanupReturnsWhenAlreadyRunning(t *testing.T) { - cache, err := New[string, int](WithCleanupInterval(time.Hour)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - + cache := New[string, int](WithCleanupInterval(time.Hour)) firstDone := startTestCleanup(t, cache) secondDone := make(chan struct{}) @@ -283,15 +259,11 @@ func TestCleanupWorkerRunSchedulesContinuationForBacklog(t *testing.T) { } func TestCacheCleanupUsesConfiguredLimits(t *testing.T) { - cache, err := New[string, int]( + cache := New[string, int]( WithCleanupInterval(time.Hour), WithCleanupBatchSize(7), WithCleanupEntryBudget(11), ) - if err != nil { - t.Fatalf("New() error = %v", err) - } - if cache.cleanupInterval != time.Hour { t.Fatalf("cleanup interval = %v, want 1h", cache.cleanupInterval) } diff --git a/delete_test.go b/delete_test.go index cf1ef7e..b9575a8 100644 --- a/delete_test.go +++ b/delete_test.go @@ -9,7 +9,7 @@ import ( ) func TestCacheGetAndDelete(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(1)) cache.Set("key", 42, NoExpiration) before := cache.Stats() @@ -40,7 +40,7 @@ func TestCacheGetAndDelete(t *testing.T) { } func TestCacheGetAndDeleteIsAtomic(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(1)) cache.Set("key", 42, NoExpiration) const callers = 32 @@ -94,7 +94,7 @@ func TestCacheGetAndDeleteIsAtomic(t *testing.T) { } func TestCacheGetAndDeleteExpiredEntry(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(1)) index := cache.store.segmentIndex("expired") cache.store.setAt( @@ -126,7 +126,7 @@ func TestCacheGetAndDeleteExpiredEntry(t *testing.T) { } func TestCacheGetAndDeleteSupersedesInflightLoad(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(1)) started := make(chan struct{}) release := make(chan struct{}) @@ -200,7 +200,7 @@ func TestDeleteMultipleKeysAndDuplicates(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - cache := mustNewCache[int](t, test.options...) + cache := newTestCache[int](test.options...) cache.Set("a", 1, NoExpiration) cache.Set("b", 2, NoExpiration) cache.Set("c", 3, NoExpiration) @@ -224,7 +224,7 @@ func TestDeleteMultipleKeysAndDuplicates(t *testing.T) { } func TestConcurrentMultiKeyDeletionLockOrderDoesNotDeadlock(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(16), WithSegmentCount(8)) + cache := newTestCache[int](WithMaxEntries(16), WithSegmentCount(8)) cache.Set("a", 1, NoExpiration) cache.Set("b", 2, NoExpiration) @@ -243,7 +243,7 @@ func TestConcurrentMultiKeyDeletionLockOrderDoesNotDeadlock(t *testing.T) { } func TestCacheClear(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(2)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(2)) cache.Set("a", 1, NoExpiration) cache.Set("b", 2, NoExpiration) diff --git a/errors.go b/errors.go index eebca47..43b4a5f 100644 --- a/errors.go +++ b/errors.go @@ -8,7 +8,7 @@ var ( ErrNotInitialized = errors.New("pacecache: cache is not initialized") // ErrNoLoader indicates that an operation requires a loader but none is - // available. NewWithDefaultLoader also returns ErrNoLoader when passed a nil loader. + // available. ErrNoLoader = errors.New("pacecache: loader is not configured") // ErrLoadSuperseded indicates that a successful loader result was made stale diff --git a/examples/basic/main.go b/examples/basic/main.go index 3e6e17d..540d3b6 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -35,16 +35,12 @@ func run(ctx context.Context) error { }, } - users, err := pacecache.NewWithDefaultLoader[int64, user]( + users := pacecache.NewWithLoader[int64, user]( repository.find, pacecache.WithMaxEntries(128), pacecache.WithTTL(30*time.Second), pacecache.WithJitter(5*time.Second), ) - if err != nil { - return fmt.Errorf("create users cache: %w", err) - } - // The first lookup loads the user from the underlying repository. first, found, err := users.GetOrLoad(ctx, 42) if err != nil { diff --git a/examples/otel/main.go b/examples/otel/main.go index a957b48..0dff60b 100644 --- a/examples/otel/main.go +++ b/examples/otel/main.go @@ -66,15 +66,12 @@ func run(ctx context.Context) error { }, } - users, err := pacecache.NewWithDefaultLoader[int64, user]( + users := pacecache.NewWithLoader[int64, user]( repository.find, pacecache.WithName("users"), pacecache.WithMaxEntries(128), pacecache.WithTTL(time.Minute), ) - if err != nil { - return fmt.Errorf("create users cache: %w", err) - } if err := metrics.Register(users); err != nil { return fmt.Errorf("register users cache metrics: %w", err) diff --git a/extra/paceotel/README.md b/extra/paceotel/README.md index f65ddc0..24f0ae1 100644 --- a/extra/paceotel/README.md +++ b/extra/paceotel/README.md @@ -33,12 +33,9 @@ metrics := paceotel.New( ) defer metrics.Unregister() -cache, err := pacecache.New[string, string]( +cache := pacecache.New[string, string]( pacecache.WithName("users"), ) -if err != nil { - panic(err) -} // Register the cache with the OpenTelemetry integration. if err := metrics.Register(cache); err != nil { diff --git a/load.go b/load.go index 863e089..6533e79 100644 --- a/load.go +++ b/load.go @@ -24,7 +24,7 @@ type cacheState[K comparable, V any] struct { // default loader. // // On a cache miss, GetOrLoad returns ErrNoLoader when the cache was created -// without a default loader. Use NewWithDefaultLoader to configure one, or +// without a default loader. Use NewWithLoader to configure one, or // GetOrLoadFunc to supply a loader for a specific operation. // // A loader result with found=true is cached using the configured TTL. A result @@ -83,7 +83,7 @@ func (cache *Cache[K, V]) GetOrLoadFunc( // returning the snapshot. // // On a cache miss, GetOrLoadEntry returns ErrNoLoader when the cache was -// created without a default loader. Use NewWithDefaultLoader to configure one, or +// created without a default loader. Use NewWithLoader to configure one, or // GetOrLoadEntryFunc to supply a loader for a specific operation. // // A loader result with found=false is not cached and returns the zero Entry with diff --git a/load_test.go b/load_test.go index 1906b6a..ee0f23e 100644 --- a/load_test.go +++ b/load_test.go @@ -9,13 +9,12 @@ import ( "time" ) -func TestNewWithDefaultLoaderRejectsNilLoader(t *testing.T) { - cache, err := NewWithDefaultLoader[string, int](nil) - if cache != nil { - t.Fatal("cache must be nil for nil default loader") - } - if !errors.Is(err, ErrNoLoader) { - t.Fatalf("NewWithDefaultLoader() error = %v, want ErrNoLoader", err) +func TestNewWithLoaderNilLoaderReturnsErrNoLoaderOnMiss(t *testing.T) { + cache := NewWithLoader[string, int](nil) + + value, found, err := cache.GetOrLoad(context.Background(), "missing") + if value != 0 || found || !errors.Is(err, ErrNoLoader) { + t.Fatalf("GetOrLoad() = (%d, %t, %v), want zero/false/ErrNoLoader", value, found, err) } } @@ -23,7 +22,7 @@ func TestGetOrLoadUsesDefaultLoader(t *testing.T) { var calls atomic.Int64 var loadedKey string - cache, err := NewWithDefaultLoader[string, int]( + cache := NewWithLoader[string, int]( func(_ context.Context, key string) (int, bool, error) { calls.Add(1) loadedKey = key @@ -31,10 +30,6 @@ func TestGetOrLoadUsesDefaultLoader(t *testing.T) { return 42, true, nil }, ) - if err != nil { - t.Fatalf("NewWithDefaultLoader() error = %v", err) - } - for range 2 { value, found, err := cache.GetOrLoad(context.Background(), "key") if err != nil || !found || value != 42 { @@ -51,7 +46,7 @@ func TestGetOrLoadUsesDefaultLoader(t *testing.T) { } func TestGetOrLoadWithoutDefaultLoaderReturnsErrNoLoaderOnMiss(t *testing.T) { - cache := mustNewCache[int](t) + cache := newTestCache[int]() cache.Set("cached", 7, NoExpiration) value, found, err := cache.GetOrLoad(context.Background(), "cached") @@ -65,21 +60,17 @@ func TestGetOrLoadWithoutDefaultLoaderReturnsErrNoLoaderOnMiss(t *testing.T) { } } -func TestGetOrLoadWithOverridesDefaultLoader(t *testing.T) { +func TestGetOrLoadFuncOverridesDefaultLoader(t *testing.T) { var defaultCalls atomic.Int64 var overrideCalls atomic.Int64 - cache, err := NewWithDefaultLoader[string, int]( + cache := NewWithLoader[string, int]( func(context.Context, string) (int, bool, error) { defaultCalls.Add(1) return 1, true, nil }, ) - if err != nil { - t.Fatalf("NewWithDefaultLoader() error = %v", err) - } - value, found, err := cache.GetOrLoadFunc( context.Background(), "override", @@ -103,16 +94,12 @@ func TestGetOrLoadWithOverridesDefaultLoader(t *testing.T) { } func TestGetOrLoadEntryUsesDefaultLoader(t *testing.T) { - cache, err := NewWithDefaultLoader[string, int]( + cache := NewWithLoader[string, int]( func(context.Context, string) (int, bool, error) { return 42, true, nil }, WithTTL(time.Minute), ) - if err != nil { - t.Fatalf("NewWithDefaultLoader() error = %v", err) - } - entry, found, err := cache.GetOrLoadEntry(context.Background(), "key") if err != nil || !found || entry.Value() != 42 || entry.ExpiresAt().IsZero() { t.Fatalf("GetOrLoadEntry() = (%+v, %t, %v), want value=42 with expiration", entry, found, err) @@ -125,7 +112,7 @@ func TestDefaultAndExplicitLoadersShareOneWave(t *testing.T) { var defaultCalls atomic.Int64 var overrideCalls atomic.Int64 - cache, err := NewWithDefaultLoader[string, int]( + cache := NewWithLoader[string, int]( func(context.Context, string) (int, bool, error) { defaultCalls.Add(1) close(started) @@ -136,10 +123,6 @@ func TestDefaultAndExplicitLoadersShareOneWave(t *testing.T) { WithMaxEntries(8), WithSegmentCount(1), ) - if err != nil { - t.Fatalf("NewWithDefaultLoader() error = %v", err) - } - ownerDone := make(chan error, 1) go func() { value, found, err := cache.GetOrLoad(context.Background(), "key") @@ -186,7 +169,7 @@ func TestDefaultAndExplicitLoadersShareOneWave(t *testing.T) { } func TestGetOrLoadCachesPositiveResult(t *testing.T) { - cache := mustNewCache[int](t) + cache := newTestCache[int]() var calls atomic.Int64 loader := func(context.Context, string) (int, bool, error) { @@ -210,7 +193,7 @@ func TestGetOrLoadCachesPositiveResult(t *testing.T) { } func TestGetOrLoadNotFoundIsNotCached(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(1)) var calls atomic.Int64 loader := func(context.Context, string) (int, bool, error) { @@ -238,8 +221,7 @@ func TestGetOrLoadNotFoundIsNotCached(t *testing.T) { } func TestGetOrLoadEntryPositiveAndNotFound(t *testing.T) { - cache := mustNewCache[int]( - t, + cache := newTestCache[int]( WithMaxEntries(8), WithSegmentCount(1), WithTTL(time.Minute), @@ -268,7 +250,7 @@ func TestGetOrLoadEntryPositiveAndNotFound(t *testing.T) { } func TestGetOrLoadErrorsAreNotCached(t *testing.T) { - cache := mustNewCache[int](t) + cache := newTestCache[int]() sentinel := errors.New("load failed") var calls atomic.Int64 @@ -293,7 +275,7 @@ func TestGetOrLoadErrorsAreNotCached(t *testing.T) { } func TestGetOrLoadLoaderPanicPropagatesToCallerAndDoesNotPoisonKey(t *testing.T) { - cache := mustNewCache[int](t) + cache := newTestCache[int]() wantErr := errors.New("boom") var recovered any @@ -335,7 +317,7 @@ func TestGetOrLoadLoaderPanicPropagatesToCallerAndDoesNotPoisonKey(t *testing.T) } func TestGetOrLoadValidatesContextAndLoader(t *testing.T) { - cache := mustNewCache[int](t) + cache := newTestCache[int]() if _, _, err := cache.GetOrLoadFunc(nil, "key", func(context.Context, string) (int, bool, error) { return 1, true, nil @@ -355,7 +337,7 @@ func TestGetOrLoadValidatesContextAndLoader(t *testing.T) { } func TestGetOrLoadCanceledMissDoesNotStartLoader(t *testing.T) { - cache := mustNewCache[int](t) + cache := newTestCache[int]() ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -373,7 +355,7 @@ func TestGetOrLoadCanceledMissDoesNotStartLoader(t *testing.T) { } func TestGetOrLoadCachedHitIgnoresCanceledContext(t *testing.T) { - cache := mustNewCache[int](t) + cache := newTestCache[int]() cache.Set("key", 7, NoExpiration) ctx, cancel := context.WithCancel(context.Background()) @@ -389,7 +371,7 @@ func TestGetOrLoadCachedHitIgnoresCanceledContext(t *testing.T) { } func TestGetOrLoadCoalescesConcurrentMisses(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(32), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(32), WithSegmentCount(1)) started := make(chan struct{}) release := make(chan struct{}) @@ -465,7 +447,7 @@ func TestGetOrLoadCoalescesConcurrentMisses(t *testing.T) { } func TestGetOrLoadWaiterCanCancelWithoutCancelingSharedLoad(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(1)) started := make(chan struct{}) release := make(chan struct{}) @@ -513,8 +495,7 @@ func TestGetOrLoadWaiterCanCancelWithoutCancelingSharedLoad(t *testing.T) { } func TestGetOrLoadAndGetOrLoadEntryShareOneWave(t *testing.T) { - cache := mustNewCache[int]( - t, + cache := newTestCache[int]( WithMaxEntries(8), WithSegmentCount(1), WithTTL(time.Minute), @@ -597,10 +578,7 @@ func TestGetOrLoadAndGetOrLoadEntryShareOneWave(t *testing.T) { } func TestGetOrLoadUsesGenericKeyIdentity(t *testing.T) { - cache, err := New[testCompositeKey, int](WithMaxEntries(8), WithSegmentCount(2)) - if err != nil { - t.Fatalf("New() error = %v", err) - } + cache := New[testCompositeKey, int](WithMaxEntries(8), WithSegmentCount(2)) key := testCompositeKey{TenantID: 7, UserID: 42} equal := testCompositeKey{TenantID: 7, UserID: 42} @@ -632,7 +610,7 @@ type publicationLoadResult[V any] struct { } func TestGetOrLoadSetSameKeySupersedesInflightFoundLoad(t *testing.T) { - cache := newPublicationTestCache(t) + cache := newPublicationTestCache() started, release, result := startPublicationLoad(cache, "key", "old", true, nil) waitTestSignal(t, started) @@ -649,7 +627,7 @@ func TestGetOrLoadSetSameKeySupersedesInflightFoundLoad(t *testing.T) { } func TestGetOrLoadDeleteSameKeySupersedesInflightFoundLoad(t *testing.T) { - cache := newPublicationTestCache(t) + cache := newPublicationTestCache() started, release, result := startPublicationLoad(cache, "key", "old", true, nil) waitTestSignal(t, started) @@ -669,7 +647,7 @@ func TestGetOrLoadDeleteSameKeySupersedesInflightFoundLoad(t *testing.T) { } func TestGetOrLoadSetSameKeySupersedesInflightNotFoundLoad(t *testing.T) { - cache := newPublicationTestCache(t) + cache := newPublicationTestCache() started, release, result := startPublicationLoad(cache, "key", "ignored", false, nil) waitTestSignal(t, started) @@ -686,7 +664,7 @@ func TestGetOrLoadSetSameKeySupersedesInflightNotFoundLoad(t *testing.T) { } func TestGetOrLoadDeleteSameKeySupersedesInflightNotFoundLoad(t *testing.T) { - cache := newPublicationTestCache(t) + cache := newPublicationTestCache() started, release, result := startPublicationLoad(cache, "key", "ignored", false, nil) waitTestSignal(t, started) @@ -703,7 +681,7 @@ func TestGetOrLoadDeleteSameKeySupersedesInflightNotFoundLoad(t *testing.T) { } func TestGetOrLoadLoaderErrorPrecedesSuperseded(t *testing.T) { - cache := newPublicationTestCache(t) + cache := newPublicationTestCache() sentinel := errors.New("loader failed") started, release, result := startPublicationLoad(cache, "key", "ignored", false, sentinel) @@ -723,7 +701,7 @@ func TestGetOrLoadLoaderErrorPrecedesSuperseded(t *testing.T) { } func TestGetOrLoadSetOtherKeySameSegmentDoesNotSupersede(t *testing.T) { - cache := newPublicationTestCache(t) + cache := newPublicationTestCache() started, release, result := startPublicationLoad(cache, "key-1", "loaded", true, nil) waitTestSignal(t, started) @@ -736,7 +714,7 @@ func TestGetOrLoadSetOtherKeySameSegmentDoesNotSupersede(t *testing.T) { } func TestGetOrLoadDeleteOtherKeySameSegmentDoesNotSupersede(t *testing.T) { - cache := newPublicationTestCache(t) + cache := newPublicationTestCache() cache.Set("key-2", "other", NoExpiration) started, release, result := startPublicationLoad(cache, "key-1", "loaded", true, nil) @@ -750,7 +728,7 @@ func TestGetOrLoadDeleteOtherKeySameSegmentDoesNotSupersede(t *testing.T) { } func TestGetOrLoadBatchDeleteOtherKeysSameSegmentDoesNotSupersede(t *testing.T) { - cache := newPublicationTestCache(t) + cache := newPublicationTestCache() cache.Set("key-2", "other-2", NoExpiration) cache.Set("key-3", "other-3", NoExpiration) started, release, result := startPublicationLoad(cache, "key-1", "loaded", true, nil) @@ -764,7 +742,7 @@ func TestGetOrLoadBatchDeleteOtherKeysSameSegmentDoesNotSupersede(t *testing.T) } func TestGetOrLoadClearSupersedesAllInflightLoads(t *testing.T) { - cache := newPublicationTestCache(t) + cache := newPublicationTestCache() firstStarted, firstRelease, firstResult := startPublicationLoad(cache, "first", "one", true, nil) secondStarted, secondRelease, secondResult := startPublicationLoad(cache, "second", "two", true, nil) @@ -788,7 +766,7 @@ func TestGetOrLoadClearSupersedesAllInflightLoads(t *testing.T) { } func TestGetOrLoadEntrySetSameKeyReturnsSupersededZeroEntry(t *testing.T) { - cache := newPublicationTestCache(t) + cache := newPublicationTestCache() started := make(chan struct{}) release := make(chan struct{}) done := make(chan struct { @@ -891,16 +869,9 @@ func assertCacheMiss(t *testing.T, cache *Cache[string, string], key string) { } } -func newPublicationTestCache(t *testing.T) *Cache[string, string] { - t.Helper() - - cache, err := New[string, string]( +func newPublicationTestCache() *Cache[string, string] { + return New[string, string]( WithMaxEntries(64), WithSegmentCount(1), ) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - return cache } diff --git a/lookup_test.go b/lookup_test.go index 0f5b323..f92fa34 100644 --- a/lookup_test.go +++ b/lookup_test.go @@ -6,8 +6,7 @@ import ( ) func TestCacheExists(t *testing.T) { - cache := mustNewCache[int]( - t, + cache := newTestCache[int]( WithMaxEntries(8), WithSegmentCount(1), ) @@ -29,8 +28,7 @@ func TestCacheExists(t *testing.T) { } func TestCacheRefreshTTL(t *testing.T) { - cache := mustNewCache[int]( - t, + cache := newTestCache[int]( WithMaxEntries(8), WithSegmentCount(1), WithTTL(time.Minute), @@ -90,7 +88,7 @@ func TestCacheExistsAndRefreshTTLRemoveExpiredEntries(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(2), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(2), WithSegmentCount(1)) stats := cache.stats.segment(0) deadline := cache.store.now() @@ -123,7 +121,7 @@ func TestCacheExistsAndRefreshTTLRemoveExpiredEntries(t *testing.T) { } func TestCacheExistsAndRefreshTTLDoNotUpdateLRU(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(2), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(2), WithSegmentCount(1)) cache.Set("a", 1, time.Minute) cache.Set("b", 2, time.Minute) @@ -147,7 +145,7 @@ func TestCacheExistsAndRefreshTTLDoNotUpdateLRU(t *testing.T) { } func TestCacheSetGetAndLRU(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(2), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(2), WithSegmentCount(1)) if value, found := cache.Get("missing"); value != 0 || found { t.Fatalf("Get(missing) = (%d, %t), want (0, false)", value, found) @@ -178,8 +176,7 @@ func TestCacheSetGetAndLRU(t *testing.T) { } func TestCacheGetEntry(t *testing.T) { - cache := mustNewCache[int]( - t, + cache := newTestCache[int]( WithMaxEntries(4), WithSegmentCount(1), WithTTL(time.Minute), diff --git a/options.go b/options.go index 4acc34b..03cdf12 100644 --- a/options.go +++ b/options.go @@ -1,20 +1,16 @@ package pacecache import ( - "errors" - "fmt" - "math" "time" ) const ( defaultMaxEntries = 10_000 defaultTTL = NoExpiration - maxDuration = time.Duration(math.MaxInt64) ) -// Option configures a Cache created by New or NewWithDefaultLoader. -type Option func(*settings) error +// Option configures a Cache created by New or NewWithLoader. +type Option func(*settings) type settings struct { name string @@ -31,24 +27,20 @@ type settings struct { cleanupEntryBudget int } -func newSettings(options ...Option) (*settings, error) { +func newSettings(options ...Option) *settings { settings := defaultSettings() - for index, option := range options { - if option == nil { - return nil, fmt.Errorf("option %d is nil", index) - } - - if err := option(settings); err != nil { - return nil, fmt.Errorf("apply option %d: %w", index, err) + for _, option := range options { + if option != nil { + option(settings) } } - if err := settings.validate(); err != nil { - return nil, fmt.Errorf("invalid configuration: %w", err) + if settings.segmentCount > settings.maxEntries { + settings.segmentCount = settings.maxEntries } - return settings, nil + return settings } func defaultSettings() *settings { @@ -67,10 +59,8 @@ func defaultSettings() *settings { // The name can be used to identify the cache in logs, diagnostics, or // observability integrations. An empty name leaves the cache unnamed. func WithName(name string) Option { - return func(settings *settings) error { + return func(settings *settings) { settings.name = name - - return nil } } @@ -79,16 +69,12 @@ func WithName(name string) Option { // With one segment, the full budget is shared by the cache. When multiple // segments are configured, the budget is distributed across them and effective // capacity utilization may be slightly lower because each segment enforces its -// own local budget. +// own local budget. Non-positive values are ignored. func WithMaxEntries(maxEntries int) Option { - return func(settings *settings) error { - if maxEntries <= 0 { - return errors.New("max entries must be positive") + return func(settings *settings) { + if maxEntries > 0 { + settings.maxEntries = maxEntries } - - settings.maxEntries = maxEntries - - return nil } } @@ -97,32 +83,26 @@ func WithMaxEntries(maxEntries int) Option { // The default is one segment. More segments can reduce lock contention under // concurrent access but may reduce effective capacity utilization because each // segment has its own entry budget. Benchmark segment counts against the -// application's actual workload. +// application's actual workload. Non-positive values are ignored. Values above +// the configured entry budget are clamped to that budget. func WithSegmentCount(count int) Option { - return func(settings *settings) error { - if count <= 0 { - return errors.New("segment count must be positive") + return func(settings *settings) { + if count > 0 { + settings.segmentCount = count } - - settings.segmentCount = count - - return nil } } // WithTTL configures the default lifetime of cache entries. // // A positive TTL enables time-based expiration. NoExpiration disables -// time-based expiration for entries using the default expiration. +// time-based expiration for entries using the default expiration. Other +// non-positive values are ignored. func WithTTL(ttl time.Duration) Option { - return func(settings *settings) error { - if ttl <= 0 && ttl != NoExpiration { - return errors.New("ttl must be positive or NoExpiration") + return func(settings *settings) { + if ttl == NoExpiration || ttl > 0 { + settings.ttl = ttl } - - settings.ttl = ttl - - return nil } } @@ -131,16 +111,13 @@ func WithTTL(ttl time.Duration) Option { // Jitter adds a random duration smaller than the configured value when an // expiring entry is stored, reducing synchronized expiration. With sliding // expiration, the resulting effective TTL is reused on every refresh instead of -// selecting another jitter value. Zero disables jitter. +// selecting another jitter value. Zero disables jitter. Negative values are +// ignored. func WithJitter(jitter time.Duration) Option { - return func(settings *settings) error { - if jitter < 0 { - return errors.New("jitter must not be negative") + return func(settings *settings) { + if jitter >= 0 { + settings.jitter = jitter } - - settings.jitter = jitter - - return nil } } @@ -153,10 +130,8 @@ func WithJitter(jitter time.Duration) Option { // jitter is selected once when the entry is stored and reused by subsequent // refreshes. Entries using NoExpiration are not refreshed. func WithSlidingExpiration() Option { - return func(settings *settings) error { + return func(settings *settings) { settings.slidingExpiration = true - - return nil } } @@ -167,16 +142,13 @@ func WithSlidingExpiration() Option { // logical TTL precision or the internal expiration bucket resolution. // // Background cleanup must be started explicitly with StartCleanup. Manual -// cleanup through Cache.DeleteExpired is always available. +// cleanup through Cache.DeleteExpired is always available. Non-positive values +// are ignored. func WithCleanupInterval(interval time.Duration) Option { - return func(settings *settings) error { - if interval <= 0 { - return errors.New("cleanup interval must be positive") + return func(settings *settings) { + if interval > 0 { + settings.cleanupInterval = interval } - - settings.cleanupInterval = interval - - return nil } } @@ -187,15 +159,12 @@ func WithCleanupInterval(interval time.Duration) Option { // can increase cleanup throughput but may hold a segment lock for longer. // Values larger than a segment or the remaining cleanup budget are safe and // are naturally limited by the available work. The default is 256. +// Non-positive values are ignored. func WithCleanupBatchSize(size int) Option { - return func(settings *settings) error { - if size <= 0 { - return errors.New("cleanup batch size must be positive") + return func(settings *settings) { + if size > 0 { + settings.cleanupBatchSize = size } - - settings.cleanupBatchSize = size - - return nil } } @@ -207,27 +176,11 @@ func WithCleanupBatchSize(size int) Option { // cleanup is additionally bounded by an internal time budget. Manual cleanup // yields cooperatively after exhausting the entry budget and continues until // all entries due at the start of the call are drained. Values larger than the -// cache size are safe. The default is 16384. +// cache size are safe. The default is 16384. Non-positive values are ignored. func WithCleanupEntryBudget(entries int) Option { - return func(settings *settings) error { - if entries <= 0 { - return errors.New("cleanup entry budget must be positive") + return func(settings *settings) { + if entries > 0 { + settings.cleanupEntryBudget = entries } - - settings.cleanupEntryBudget = entries - - return nil } } - -func (settings *settings) validate() error { - if settings.ttl > 0 && settings.ttl > maxDuration-settings.jitter { - return errors.New("ttl plus jitter exceeds maximum duration") - } - - if settings.segmentCount > settings.maxEntries { - return errors.New("segment count must not exceed max entries") - } - - return nil -} diff --git a/options_test.go b/options_test.go index fc04a03..0b348ab 100644 --- a/options_test.go +++ b/options_test.go @@ -1,7 +1,6 @@ package pacecache import ( - "errors" "testing" "time" ) @@ -15,8 +14,12 @@ func TestDefaultSettings(t *testing.T) { if settings.maxEntries != defaultMaxEntries { t.Fatalf("maxEntries = %d, want %d", settings.maxEntries, defaultMaxEntries) } - if settings.segmentCount != 1 { - t.Fatalf("segmentCount = %d, want 1", settings.segmentCount) + if settings.segmentCount != defaultStorageSegmentCount { + t.Fatalf( + "segmentCount = %d, want %d", + settings.segmentCount, + defaultStorageSegmentCount, + ) } if settings.ttl != defaultTTL { t.Fatalf("ttl = %v, want %v", settings.ttl, defaultTTL) @@ -43,7 +46,7 @@ func TestDefaultSettings(t *testing.T) { } func TestNewSettingsAppliesOptions(t *testing.T) { - got, err := newSettings( + got := newSettings( WithName("users"), WithMaxEntries(128), WithSegmentCount(8), @@ -54,9 +57,6 @@ func TestNewSettingsAppliesOptions(t *testing.T) { WithCleanupBatchSize(1024), WithCleanupEntryBudget(64*1024), ) - if err != nil { - t.Fatalf("newSettings() error = %v", err) - } want := settings{ name: "users", @@ -75,74 +75,98 @@ func TestNewSettingsAppliesOptions(t *testing.T) { } } -func TestNewSettingsRejectsNilOption(t *testing.T) { - _, err := newSettings(nil) - const want = "option 0 is nil" - if err == nil || err.Error() != want { - t.Fatalf("error = %v, want %q", err, want) +func TestNewSettingsIgnoresNilOption(t *testing.T) { + got := newSettings(nil) + want := defaultSettings() + + if *got != *want { + t.Fatalf("newSettings(nil) = %+v, want %+v", *got, *want) } } -func TestNewSettingsWrapsOptionError(t *testing.T) { - sentinel := errors.New("sentinel") - option := func(*settings) error { return sentinel } +func TestNewSettingsIgnoresInvalidOptionValues(t *testing.T) { + settings := newSettings( + WithMaxEntries(128), + WithMaxEntries(0), + WithSegmentCount(8), + WithSegmentCount(-1), + WithTTL(time.Minute), + WithTTL(0), + WithJitter(time.Second), + WithJitter(-1), + WithCleanupInterval(time.Second), + WithCleanupInterval(0), + WithCleanupBatchSize(1024), + WithCleanupBatchSize(0), + WithCleanupEntryBudget(4096), + WithCleanupEntryBudget(-1), + ) - _, err := newSettings(option) - if !errors.Is(err, sentinel) { - t.Fatalf("error = %v, want wrapped sentinel", err) + if settings.maxEntries != 128 { + t.Fatalf("maxEntries = %d, want 128", settings.maxEntries) + } + if settings.segmentCount != 8 { + t.Fatalf("segmentCount = %d, want 8", settings.segmentCount) + } + if settings.ttl != time.Minute { + t.Fatalf("ttl = %v, want 1m", settings.ttl) + } + if settings.jitter != time.Second { + t.Fatalf("jitter = %v, want 1s", settings.jitter) + } + if settings.cleanupInterval != time.Second { + t.Fatalf("cleanupInterval = %v, want 1s", settings.cleanupInterval) + } + if settings.cleanupBatchSize != 1024 { + t.Fatalf("cleanupBatchSize = %d, want 1024", settings.cleanupBatchSize) + } + if settings.cleanupEntryBudget != 4096 { + t.Fatalf("cleanupEntryBudget = %d, want 4096", settings.cleanupEntryBudget) } } -func TestSettingsValidation(t *testing.T) { +func TestNewSettingsClampsSegmentCountToMaxEntries(t *testing.T) { tests := []struct { name string options []Option - want string }{ - {name: "max entries zero", options: []Option{WithMaxEntries(0)}, want: "apply option 0: max entries must be positive"}, - {name: "max entries negative", options: []Option{WithMaxEntries(-1)}, want: "apply option 0: max entries must be positive"}, - {name: "segment count zero", options: []Option{WithSegmentCount(0)}, want: "apply option 0: segment count must be positive"}, - {name: "segment count negative", options: []Option{WithSegmentCount(-1)}, want: "apply option 0: segment count must be positive"}, - {name: "ttl zero", options: []Option{WithTTL(0)}, want: "apply option 0: ttl must be positive or NoExpiration"}, - {name: "ttl invalid negative", options: []Option{WithTTL(-2)}, want: "apply option 0: ttl must be positive or NoExpiration"}, - {name: "negative jitter", options: []Option{WithJitter(-1)}, want: "apply option 0: jitter must not be negative"}, - {name: "cleanup interval zero", options: []Option{WithCleanupInterval(0)}, want: "apply option 0: cleanup interval must be positive"}, - {name: "cleanup interval negative", options: []Option{WithCleanupInterval(-1)}, want: "apply option 0: cleanup interval must be positive"}, - {name: "cleanup batch size zero", options: []Option{WithCleanupBatchSize(0)}, want: "apply option 0: cleanup batch size must be positive"}, - {name: "cleanup batch size negative", options: []Option{WithCleanupBatchSize(-1)}, want: "apply option 0: cleanup batch size must be positive"}, - {name: "cleanup entry budget zero", options: []Option{WithCleanupEntryBudget(0)}, want: "apply option 0: cleanup entry budget must be positive"}, - {name: "cleanup entry budget negative", options: []Option{WithCleanupEntryBudget(-1)}, want: "apply option 0: cleanup entry budget must be positive"}, - {name: "segments exceed max entries", options: []Option{WithMaxEntries(2), WithSegmentCount(3)}, want: "invalid configuration: segment count must not exceed max entries"}, { - name: "ttl plus jitter overflow", + name: "max entries first", options: []Option{ - WithTTL(maxDuration), - WithJitter(time.Nanosecond), + WithMaxEntries(2), + WithSegmentCount(3), + }, + }, + { + name: "segment count first", + options: []Option{ + WithSegmentCount(3), + WithMaxEntries(2), }, - want: "invalid configuration: ttl plus jitter exceeds maximum duration", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, err := newSettings(test.options...) - if err == nil || err.Error() != test.want { - t.Fatalf("error = %v, want %q", err, test.want) + settings := newSettings(test.options...) + if settings.maxEntries != 2 || settings.segmentCount != 2 { + t.Fatalf( + "settings = maxEntries:%d segmentCount:%d, want 2/2", + settings.maxEntries, + settings.segmentCount, + ) } }) } } func TestSettingsAcceptsIndependentCleanupLimits(t *testing.T) { - settings, err := newSettings( + settings := newSettings( WithMaxEntries(4), WithSegmentCount(1), WithCleanupBatchSize(10_000), WithCleanupEntryBudget(3), ) - if err != nil { - t.Fatalf("newSettings() error = %v", err) - } if settings.cleanupBatchSize != 10_000 || settings.cleanupEntryBudget != 3 { t.Fatalf("cleanup limits = %d/%d, want 10000/3", settings.cleanupBatchSize, settings.cleanupEntryBudget) @@ -150,15 +174,12 @@ func TestSettingsAcceptsIndependentCleanupLimits(t *testing.T) { } func TestSettingsAcceptsBoundaryValues(t *testing.T) { - settings, err := newSettings( + settings := newSettings( WithName(""), WithMaxEntries(1), WithTTL(NoExpiration), WithJitter(maxDuration), ) - if err != nil { - t.Fatalf("newSettings() error = %v", err) - } if settings.name != "" { t.Fatalf("name = %q, want empty", settings.name) @@ -166,4 +187,21 @@ func TestSettingsAcceptsBoundaryValues(t *testing.T) { if settings.ttl != NoExpiration { t.Fatalf("ttl = %v, want NoExpiration", settings.ttl) } + if settings.jitter != maxDuration { + t.Fatalf("jitter = %v, want maxDuration", settings.jitter) + } +} + +func TestNewSettingsAcceptsMaximumTTLAndJitter(t *testing.T) { + settings := newSettings( + WithTTL(maxDuration), + WithJitter(maxDuration), + ) + + if settings.ttl != maxDuration { + t.Fatalf("ttl = %v, want maxDuration", settings.ttl) + } + if settings.jitter != maxDuration { + t.Fatalf("jitter = %v, want maxDuration", settings.jitter) + } } diff --git a/set_test.go b/set_test.go index 0002476..0744302 100644 --- a/set_test.go +++ b/set_test.go @@ -9,7 +9,7 @@ import ( ) func TestCacheSetOverwritesExistingValue(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(4), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(4), WithSegmentCount(1)) cache.Set("key", 1, time.Minute) cache.Set("key", 2, NoExpiration) @@ -24,8 +24,7 @@ func TestCacheSetOverwritesExistingValue(t *testing.T) { } func TestCacheStoresTTLPolicyForDefaultCustomAndNoExpiration(t *testing.T) { - cache := mustNewCache[int]( - t, + cache := newTestCache[int]( WithMaxEntries(8), WithSegmentCount(1), WithTTL(10*time.Second), @@ -58,8 +57,7 @@ func TestCacheStoresTTLPolicyForDefaultCustomAndNoExpiration(t *testing.T) { } func TestCacheGetOrSet(t *testing.T) { - cache := mustNewCache[int]( - t, + cache := newTestCache[int]( WithMaxEntries(8), WithSegmentCount(1), WithTTL(10*time.Second), @@ -116,8 +114,7 @@ func TestCacheGetOrSet(t *testing.T) { } func TestCacheGetOrSetExpirationPolicies(t *testing.T) { - cache := mustNewCache[int]( - t, + cache := newTestCache[int]( WithMaxEntries(8), WithSegmentCount(1), WithTTL(10*time.Second), @@ -157,7 +154,7 @@ func TestCacheGetOrSetExpirationPolicies(t *testing.T) { } func TestCacheGetOrSetUsesNoExpirationByDefault(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(1)) value, found := cache.GetOrSet("key", 42, DefaultExpiration) if found || value != 42 { @@ -174,7 +171,7 @@ func TestCacheGetOrSetUsesNoExpirationByDefault(t *testing.T) { } func TestCacheGetOrSetExpiredEntry(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(1)) index := cache.store.segmentIndex("key") cache.store.setAt( @@ -208,7 +205,7 @@ func TestCacheGetOrSetExpiredEntry(t *testing.T) { } func TestCacheGetOrSetIsAtomic(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(1)) const callers = 32 @@ -285,7 +282,7 @@ func TestCacheGetOrSetIsAtomic(t *testing.T) { } func TestCacheGetOrSetSupersedesInflightLoad(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(1)) started := make(chan struct{}) release := make(chan struct{}) @@ -342,8 +339,7 @@ func TestZeroValueCacheGetOrSet(t *testing.T) { } func TestCacheGetOrSetEntry(t *testing.T) { - cache := mustNewCache[int]( - t, + cache := newTestCache[int]( WithMaxEntries(8), WithSegmentCount(1), WithTTL(10*time.Second), @@ -392,7 +388,7 @@ func TestCacheGetOrSetEntry(t *testing.T) { } func TestCacheGetOrSetEntryNoExpirationMetadata(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(1)) entry, found := cache.GetOrSetEntry("key", 42, DefaultExpiration) if found || entry.Value() != 42 { @@ -408,7 +404,7 @@ func TestCacheGetOrSetEntryNoExpirationMetadata(t *testing.T) { } func TestCacheGetOrSetEntryIsAtomic(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(1)) const callers = 32 @@ -483,7 +479,7 @@ func TestCacheGetOrSetEntryIsAtomic(t *testing.T) { } func TestCacheGetOrSetEntrySupersedesInflightLoad(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(8), WithSegmentCount(1)) + cache := newTestCache[int](WithMaxEntries(8), WithSegmentCount(1)) started := make(chan struct{}) release := make(chan struct{}) diff --git a/stats_test.go b/stats_test.go index 59a9af7..d503a2a 100644 --- a/stats_test.go +++ b/stats_test.go @@ -139,7 +139,7 @@ func TestStatsRecordHelpers(t *testing.T) { } func TestStatsConcurrentWithCacheOperations(t *testing.T) { - cache := mustNewCache[int](t, WithMaxEntries(128), WithSegmentCount(8)) + cache := newTestCache[int](WithMaxEntries(128), WithSegmentCount(8)) var group sync.WaitGroup for worker := range 8 { From bbceef39ae0575f9808c8d4fcdcfae87dae7a289 Mon Sep 17 00:00:00 2001 From: mkbeh Date: Mon, 21 Sep 2026 18:59:31 +0300 Subject: [PATCH 3/5] docs: simplify loader examples --- README.md | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 45b1807..ccfaa32 100644 --- a/README.md +++ b/README.md @@ -111,13 +111,7 @@ loader := pacecache.Loader[string, string](func(ctx context.Context, key string) // Return the cached value or invoke the loader on a miss. value, found, err := cache.GetOrLoadFunc(ctx, "key", loader) -if err != nil { - panic(err) -} - -if found { - fmt.Println("retrieved value:", value) // loaded key -} +fmt.Println("retrieved value:", value) // loaded value ``` @@ -135,13 +129,7 @@ cache := pacecache.NewWithLoader[string, string]( // Return the cached value or invoke the configured loader on a miss. value, found, err := cache.GetOrLoad(ctx, "key") -if err != nil { - panic(err) -} - -if found { - fmt.Println("retrieved value:", value) // loaded key -} +fmt.Println("retrieved value:", value) // loaded value ``` From a6edb3cc699da7ac8687b000ce8fd92a4e1a4bb4 Mon Sep 17 00:00:00 2001 From: mkbeh Date: Mon, 21 Sep 2026 19:03:04 +0300 Subject: [PATCH 4/5] docs: simplify examples --- examples/basic/main.go | 3 +-- examples/otel/main.go | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/basic/main.go b/examples/basic/main.go index 540d3b6..9469fe0 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -37,10 +37,9 @@ func run(ctx context.Context) error { users := pacecache.NewWithLoader[int64, user]( repository.find, - pacecache.WithMaxEntries(128), pacecache.WithTTL(30*time.Second), - pacecache.WithJitter(5*time.Second), ) + // The first lookup loads the user from the underlying repository. first, found, err := users.GetOrLoad(ctx, 42) if err != nil { diff --git a/examples/otel/main.go b/examples/otel/main.go index 0dff60b..fe51d9c 100644 --- a/examples/otel/main.go +++ b/examples/otel/main.go @@ -69,7 +69,6 @@ func run(ctx context.Context) error { users := pacecache.NewWithLoader[int64, user]( repository.find, pacecache.WithName("users"), - pacecache.WithMaxEntries(128), pacecache.WithTTL(time.Minute), ) From f341be0de2d0d8706035a627075199f9da5f20d6 Mon Sep 17 00:00:00 2001 From: mkbeh Date: Mon, 21 Sep 2026 19:09:15 +0300 Subject: [PATCH 5/5] docs: prepare v1.4.0 changelog --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d3fd8..3fb3720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to this project will be documented in this file. +## v1.4.0 + +This release simplifies cache construction and decouples the core cache API from optional metrics integrations. + +### Added + +* **Cache Name:** Added `Cache.Name` for retrieving the optional name configured through `WithName`. + +### Changed + +* **Constructors:** `New` and `NewWithLoader` now return a cache directly without an error. `NewWithDefaultLoader` was + renamed to `NewWithLoader`. +* **Options:** Options are now infallible. Invalid values leave the current setting unchanged, while segment counts + exceeding the entry budget are capped at the configured maximum. +* **Loader Configuration:** `NewWithLoader` now accepts a nil loader. `GetOrLoad` and `GetOrLoadEntry` return + `ErrNoLoader` on a cache miss when no loader is configured. +* **Metrics Integration:** Metrics registration is now handled explicitly by integrations through `Cache.Name` and + `Cache.Stats`. + +### Removed + +* **Core Metrics API:** Removed `WithMetrics`, `Metrics`, and `MetricsSource` from the core package. + +## extra/paceotel/v1.4.0 + +This release makes OpenTelemetry registration explicit and independent from the core cache lifecycle. + +### Added + +* **Source Interface:** Added `Source` for exposing the cache name and statistics required for metrics collection. +* **Registration Attributes:** `Metrics.Register` now accepts additional OpenTelemetry attributes for each source. + +### Changed + +* **Metrics Registration:** Cache sources are now registered explicitly through `Metrics.Register` instead of + `pacecache.WithMetrics`. +* **Core Dependency:** Updated `github.com/mkbeh/pacecache` to v1.4.0. + ## v1.3.0 This release simplifies cache construction, makes background cleanup lifecycle explicit, and streamlines metrics