Skip to content

Commit 67afd04

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
Add pluggable persistence architecture
1 parent 266241a commit 67afd04

37 files changed

+2111
-2987
lines changed

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
.PHONY: test lint bench benchmark clean
22

33
test:
4-
go test -v -race -cover ./...
4+
@echo "Running tests in all modules..."
5+
@find . -name go.mod -execdir go test -v -race -cover ./... \;
56

67
lint:
78
go vet ./...

README.md

Lines changed: 33 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
<br clear="right">
1010

11-
Fast, persistent Go cache with S3-FIFO eviction - better hit rates than LRU, survives restarts with local files or Google Cloud Datastore, zero allocations.
11+
Fast, persistent Go cache with S3-FIFO eviction - better hit rates than LRU, survives restarts with pluggable persistence backends, zero allocations.
1212

1313
## Install
1414

@@ -19,30 +19,42 @@ go get github.com/codeGROOVE-dev/bdcache
1919
## Use
2020

2121
```go
22+
import (
23+
"github.com/codeGROOVE-dev/bdcache"
24+
"github.com/codeGROOVE-dev/bdcache/persist/localfs"
25+
)
26+
2227
// Memory only
23-
cache, err := bdcache.New[string, int](ctx)
24-
if err != nil {
25-
return err
26-
}
28+
cache, _ := bdcache.New[string, int](ctx)
2729
cache.Set(ctx, "answer", 42, 0) // Synchronous: returns after persistence completes
2830
cache.SetAsync(ctx, "answer", 42, 0) // Async: returns immediately, persists in background
29-
val, found, err := cache.Get(ctx, "answer")
30-
31-
// With smart persistence (local files for dev, Google Cloud Datastore for Cloud Run)
32-
cache, err := bdcache.New[string, User](ctx, bdcache.WithBestStore("myapp"))
33-
34-
// With Cloud Datastore persistence and automatic cleanup
35-
cache, err := bdcache.New[string, User](ctx,
36-
bdcache.WithCloudDatastore("myapp"),
37-
bdcache.WithCleanup(24*time.Hour), // Cleanup entries older than 24h
38-
)
31+
val, found, _ := cache.Get(ctx, "answer")
32+
33+
// With local file persistence
34+
p, _ := localfs.New[string, User]("myapp", "")
35+
cache, _ := bdcache.New[string, User](ctx,
36+
bdcache.WithPersistence(p))
37+
38+
// With Valkey/Redis persistence
39+
p, _ := valkey.New[string, User](ctx, "myapp", "localhost:6379")
40+
cache, _ := bdcache.New[string, User](ctx,
41+
bdcache.WithPersistence(p))
42+
43+
// Cloud Run auto-detection (datastore in Cloud Run, localfs elsewhere)
44+
p, _ := cloudrun.New[string, User](ctx, "myapp")
45+
cache, _ := bdcache.New[string, User](ctx,
46+
bdcache.WithPersistence(p))
3947
```
4048

4149
## Features
4250

4351
- **S3-FIFO eviction** - Better than LRU ([learn more](https://s3fifo.com/))
4452
- **Type safe** - Go generics
45-
- **Persistence** - Local files (gob) or Google Cloud Datastore (JSON)
53+
- **Pluggable persistence** - Bring your own database or use built-in backends:
54+
- [`persist/localfs`](persist/localfs) - Local files (gob encoding, zero dependencies)
55+
- [`persist/datastore`](persist/datastore) - Google Cloud Datastore
56+
- [`persist/valkey`](persist/valkey) - Valkey/Redis
57+
- [`persist/cloudrun`](persist/cloudrun) - Auto-detect Cloud Run
4658
- **Graceful degradation** - Cache works even if persistence fails
4759
- **Per-item TTL** - Optional expiration
4860

@@ -73,64 +85,13 @@ Benchmarks on MacBook Pro M4 Max comparing memory-only Get operations:
7385

7486
### Competitive Analysis
7587

76-
Independent benchmark using [scalalang2/go-cache-benchmark](https://github.com/scalalang2/go-cache-benchmark) (500K items, Zipfian distribution):
88+
Independent benchmark using [scalalang2/go-cache-benchmark](https://github.com/scalalang2/go-cache-benchmark) (500K items, Zipfian distribution) shows bdcache consistently ranks top 1-2 for hit rate across all cache sizes:
7789

78-
**Hit Rate Leadership:**
79-
- **0.1% cache size**: bdcache **48.12%** vs SIEVE 47.42%, TinyLFU 47.37%, S3-FIFO 47.16%
80-
- **1% cache size**: bdcache **64.45%** vs TinyLFU 63.94%, Otter 63.60%, S3-FIFO 63.59%, SIEVE 63.33%
81-
- **10% cache size**: bdcache **80.39%** vs TinyLFU 80.43%, Otter 79.86%, S3-FIFO 79.84%
82-
83-
Consistently ranks top 1-2 for hit rate across all cache sizes while maintaining competitive throughput (5-12M QPS). The S3-FIFO implementation prioritizes cache efficiency over raw speed, making bdcache ideal when hit rate matters.
84-
85-
### Detailed Benchmarks
86-
87-
Memory-only operations:
88-
```
89-
BenchmarkCache_Get_Hit-16 56M ops/sec 17.8 ns/op 0 B/op 0 allocs
90-
BenchmarkCache_Set-16 56M ops/sec 17.8 ns/op 0 B/op 0 allocs
91-
```
92-
93-
With file persistence enabled:
94-
```
95-
BenchmarkCache_Get_PersistMemoryHit-16 85M ops/sec 11.8 ns/op 0 B/op 0 allocs
96-
BenchmarkCache_Get_PersistDiskRead-16 73K ops/sec 13.8 µs/op 7921 B/op 178 allocs
97-
BenchmarkCache_Set_WithPersistence-16 9K ops/sec 112.3 µs/op 2383 B/op 36 allocs
98-
```
99-
100-
## Cloud Datastore TTL Setup
101-
102-
When using Google Cloud Datastore persistence, configure native TTL policies for automatic expiration:
103-
104-
### One-time Setup (per database)
105-
106-
```bash
107-
# Enable TTL on the 'expiry' field for CacheEntry kind
108-
gcloud firestore fields ttls update expiry \
109-
--collection-group=CacheEntry \
110-
--enable-ttl \
111-
--database=YOUR_CACHE_ID
112-
```
90+
- **0.1% cache size**: bdcache **48.12%** vs SIEVE 47.42%, TinyLFU 47.37%
91+
- **1% cache size**: bdcache **64.45%** vs TinyLFU 63.94%, Otter 63.60%
92+
- **10% cache size**: bdcache **80.39%** vs TinyLFU 80.43%, Otter 79.86%
11393

114-
**Important:**
115-
- Replace `YOUR_CACHE_ID` with your cache ID (passed to `WithCloudDatastore()`)
116-
- This is a one-time setup per database
117-
- Datastore automatically deletes expired entries within 24 hours
118-
- No indexing needed on the expiry field (prevents hotspots)
119-
120-
### Best Practices
121-
122-
1. **Use Native TTL**: Let Datastore handle expiration automatically
123-
2. **Add Cleanup Fallback**: Use `WithCleanup()` as a safety net:
124-
```go
125-
cache, err := bdcache.New[string, User](ctx,
126-
bdcache.WithCloudDatastore("myapp"),
127-
bdcache.WithCleanup(24*time.Hour), // Safety net for orphaned data
128-
)
129-
```
130-
3. **Set Cleanup MaxAge**: Should match your longest TTL value
131-
4. **Monitor Costs**: TTL deletions count toward entity delete operations
132-
133-
If native TTL is properly configured, `WithCleanup()` will find no entries (fast no-op).
94+
See [benchmarks/](benchmarks/) for detailed methodology and running instructions.
13495

13596
## License
13697

benchmarks/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,18 @@ Demonstrates S3-FIFO's scan resistance with a cherrypicked workload:
7171

7272
This is a **best-case scenario for S3-FIFO**. Many real workloads won't see this dramatic of a difference.
7373

74+
### Independent Hit Rate Benchmark
75+
76+
Using [scalalang2/go-cache-benchmark](https://github.com/scalalang2/go-cache-benchmark) (500K items, Zipfian distribution):
77+
78+
| Cache Size | bdcache | TinyLFU | Otter | S3-FIFO | SIEVE |
79+
|-----------|---------|---------|-------|---------|-------|
80+
| **0.1%** | **48.12%** | 47.37% | - | 47.16% | 47.42% |
81+
| **1%** | **64.45%** | 63.94% | 63.60% | 63.59% | 63.33% |
82+
| **10%** | **80.39%** | 80.43% | 79.86% | 79.84% | - |
83+
84+
bdcache consistently ranks top 1-2 for hit rate while maintaining competitive throughput (5-12M QPS).
85+
7486
### Additional Tests
7587

7688
```bash

cache.go

Lines changed: 56 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package bdcache
33

44
import (
55
"context"
6+
"errors"
67
"fmt"
78
"log/slog"
89
"time"
@@ -17,7 +18,9 @@ type Cache[K comparable, V any] struct {
1718

1819
// New creates a new cache with the given options.
1920
func New[K comparable, V any](ctx context.Context, options ...Option) (*Cache[K, V], error) {
20-
opts := defaultOptions()
21+
opts := &Options{
22+
MemorySize: 10000,
23+
}
2124
for _, opt := range options {
2225
opt(opts)
2326
}
@@ -27,47 +30,45 @@ func New[K comparable, V any](ctx context.Context, options ...Option) (*Cache[K,
2730
opts: opts,
2831
}
2932

30-
// Initialize persistence if configured
31-
if opts.CacheID != "" {
32-
var err error
33-
if opts.UseDatastore {
34-
cache.persist, err = newDatastorePersist[K, V](ctx, opts.CacheID)
33+
// Set persistence from options
34+
if opts.Persister != nil {
35+
p, ok := opts.Persister.(PersistenceLayer[K, V])
36+
if !ok {
37+
return nil, errors.New("invalid persister type")
38+
}
39+
cache.persist = p
40+
slog.Info("initialized cache with persistence")
41+
}
42+
43+
// Run background cleanup if configured
44+
if cache.persist != nil && opts.CleanupEnabled {
45+
//nolint:contextcheck // Background cleanup uses detached context to complete independently
46+
go func() {
47+
// Create detached context with timeout - cleanup should complete independently
48+
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
49+
defer cancel()
50+
51+
deleted, err := cache.persist.Cleanup(bgCtx, opts.CleanupMaxAge)
3552
if err != nil {
36-
slog.Warn("failed to initialize datastore persistence, continuing with memory-only cache",
37-
"error", err, "cache_id", opts.CacheID)
38-
cache.persist = nil
39-
} else {
40-
slog.Info("initialized cache with datastore persistence", "cache_id", opts.CacheID)
53+
slog.Warn("error during cache cleanup", "error", err)
54+
return
4155
}
42-
} else {
43-
cache.persist, err = newFilePersist[K, V](opts.CacheID)
44-
if err != nil {
45-
slog.Warn("failed to initialize file persistence, continuing with memory-only cache",
46-
"error", err, "cache_id", opts.CacheID)
47-
cache.persist = nil
48-
} else {
49-
slog.Info("initialized cache with file persistence", "cache_id", opts.CacheID)
56+
if deleted > 0 {
57+
slog.Info("cache cleanup complete", "deleted", deleted)
5058
}
51-
}
59+
}()
60+
}
5261

53-
// Run background cleanup if configured
54-
if cache.persist != nil && opts.CleanupEnabled {
55-
go func() {
56-
deleted, err := cache.persist.Cleanup(ctx, opts.CleanupMaxAge)
57-
if err != nil {
58-
slog.Warn("error during cache cleanup", "error", err)
59-
return
60-
}
61-
if deleted > 0 {
62-
slog.Info("cache cleanup complete", "deleted", deleted)
63-
}
64-
}()
65-
}
62+
// Warm up cache from persistence if configured
63+
if cache.persist != nil && opts.WarmupLimit > 0 {
64+
//nolint:contextcheck // Background warmup uses detached context to complete independently
65+
go func() {
66+
// Create detached context with timeout - warmup should complete independently
67+
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
68+
defer cancel()
6669

67-
// Warm up cache from persistence if configured
68-
if cache.persist != nil && opts.WarmupLimit > 0 {
69-
go cache.warmup(ctx)
70-
}
70+
cache.warmup(bgCtx)
71+
}()
7172
}
7273

7374
return cache, nil
@@ -138,18 +139,24 @@ func (c *Cache[K, V]) Get(ctx context.Context, key K) (V, bool, error) {
138139
return val, true, nil
139140
}
140141

142+
// calculateExpiry returns the expiry time based on TTL and default TTL.
143+
func (c *Cache[K, V]) calculateExpiry(ttl time.Duration) time.Time {
144+
if ttl > 0 {
145+
return time.Now().Add(ttl)
146+
}
147+
if c.opts.DefaultTTL > 0 {
148+
return time.Now().Add(c.opts.DefaultTTL)
149+
}
150+
return time.Time{}
151+
}
152+
141153
// Set stores a value in the cache with an optional TTL.
142154
// A zero TTL means no expiration (or uses DefaultTTL if configured).
143155
// The value is ALWAYS stored in memory, even if persistence fails.
144156
// Returns an error if the key violates persistence constraints or if persistence fails.
145157
// Even when an error is returned, the value is cached in memory.
146158
func (c *Cache[K, V]) Set(ctx context.Context, key K, value V, ttl time.Duration) error {
147-
var expiry time.Time
148-
if ttl > 0 {
149-
expiry = time.Now().Add(ttl)
150-
} else if c.opts.DefaultTTL > 0 {
151-
expiry = time.Now().Add(c.opts.DefaultTTL)
152-
}
159+
expiry := c.calculateExpiry(ttl)
153160

154161
// Validate key early if persistence is enabled
155162
if c.persist != nil {
@@ -175,12 +182,7 @@ func (c *Cache[K, V]) Set(ctx context.Context, key K, value V, ttl time.Duration
175182
// Key validation and in-memory caching happen synchronously. Persistence errors are logged but not returned.
176183
// Returns an error only for validation failures (e.g., invalid key format).
177184
func (c *Cache[K, V]) SetAsync(ctx context.Context, key K, value V, ttl time.Duration) error {
178-
var expiry time.Time
179-
if ttl > 0 {
180-
expiry = time.Now().Add(ttl)
181-
} else if c.opts.DefaultTTL > 0 {
182-
expiry = time.Now().Add(c.opts.DefaultTTL)
183-
}
185+
expiry := c.calculateExpiry(ttl)
184186

185187
// Validate key early if persistence is enabled (synchronous)
186188
if c.persist != nil {
@@ -195,7 +197,11 @@ func (c *Cache[K, V]) SetAsync(ctx context.Context, key K, value V, ttl time.Dur
195197
// Update persistence asynchronously if available
196198
if c.persist != nil {
197199
go func() {
198-
if err := c.persist.Store(ctx, key, value, expiry); err != nil {
200+
// Derive context with timeout to prevent hanging
201+
storeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
202+
defer cancel()
203+
204+
if err := c.persist.Store(storeCtx, key, value, expiry); err != nil {
199205
slog.Warn("async persistence store failed", "error", err, "key", key)
200206
}
201207
}()

0 commit comments

Comments
 (0)