Skip to content

Commit ae9f861

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
Add cache cleanup
1 parent 50e1014 commit ae9f861

File tree

8 files changed

+187
-42
lines changed

8 files changed

+187
-42
lines changed

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ val, found, err := cache.Get(ctx, "answer")
3131

3232
// With smart persistence (local files for dev, Google Cloud Datastore for Cloud Run)
3333
cache, err := bdcache.New[string, User](ctx, bdcache.WithBestStore("myapp"))
34+
35+
// With Cloud Datastore persistence and automatic cleanup
36+
cache, err := bdcache.New[string, User](ctx,
37+
bdcache.WithCloudDatastore("myapp"),
38+
bdcache.WithCleanup(24*time.Hour), // Cleanup entries older than 24h
39+
)
3440
```
3541

3642
## Features
@@ -92,6 +98,41 @@ BenchmarkCache_Get_PersistDiskRead-16 73K ops/sec 13.8 µs/op 7921 B/o
9298
BenchmarkCache_Set_WithPersistence-16 9K ops/sec 112.3 µs/op 2383 B/op 36 allocs
9399
```
94100

101+
## Cloud Datastore TTL Setup
102+
103+
When using Google Cloud Datastore persistence, configure native TTL policies for automatic expiration:
104+
105+
### One-time Setup (per database)
106+
107+
```bash
108+
# Enable TTL on the 'expiry' field for CacheEntry kind
109+
gcloud firestore fields ttls update expiry \
110+
--collection-group=CacheEntry \
111+
--enable-ttl \
112+
--database=YOUR_CACHE_ID
113+
```
114+
115+
**Important:**
116+
- Replace `YOUR_CACHE_ID` with your cache ID (passed to `WithCloudDatastore()`)
117+
- This is a one-time setup per database
118+
- Datastore automatically deletes expired entries within 24 hours
119+
- No indexing needed on the expiry field (prevents hotspots)
120+
121+
### Best Practices
122+
123+
1. **Use Native TTL**: Let Datastore handle expiration automatically
124+
2. **Add Cleanup Fallback**: Use `WithCleanup()` as a safety net:
125+
```go
126+
cache, err := bdcache.New[string, User](ctx,
127+
bdcache.WithCloudDatastore("myapp"),
128+
bdcache.WithCleanup(24*time.Hour), // Safety net for orphaned data
129+
)
130+
```
131+
3. **Set Cleanup MaxAge**: Should match your longest TTL value
132+
4. **Monitor Costs**: TTL deletions count toward entity delete operations
133+
134+
If native TTL is properly configured, `WithCleanup()` will find no entries (fast no-op).
135+
95136
## License
96137

97138
Apache 2.0

benchmarks/hitrate_comparison_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package benchmarks
22

3-
43
import (
54
"context"
65
"fmt"

cache.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ func New[K comparable, V any](ctx context.Context, options ...Option) (*Cache[K,
5050
}
5151
}
5252

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+
}
66+
5367
// Warm up cache from persistence if configured
5468
if cache.persist != nil && opts.WarmupLimit > 0 {
5569
go cache.warmup(ctx)
@@ -85,22 +99,22 @@ func (c *Cache[K, V]) warmup(ctx context.Context) {
8599

86100
// Get retrieves a value from the cache.
87101
// It first checks the memory cache, then falls back to persistence if available.
88-
func (c *Cache[K, V]) Get(ctx context.Context, key K) (value V, found bool, err error) {
102+
func (c *Cache[K, V]) Get(ctx context.Context, key K) (V, bool, error) {
89103
// Check memory first
90104
if val, ok := c.memory.get(key); ok {
91105
return val, true, nil
92106
}
93107

108+
var zero V
109+
94110
// If no persistence, return miss
95111
if c.persist == nil {
96-
var zero V
97112
return zero, false, nil
98113
}
99114

100115
// Validate key before accessing persistence (security: prevent path traversal)
101116
if err := c.persist.ValidateKey(key); err != nil {
102117
slog.Warn("invalid key for persistence", "error", err, "key", key)
103-
var zero V
104118
return zero, false, nil
105119
}
106120

@@ -109,12 +123,10 @@ func (c *Cache[K, V]) Get(ctx context.Context, key K) (value V, found bool, err
109123
if err != nil {
110124
// Log error but don't fail - graceful degradation
111125
slog.Warn("persistence load failed", "error", err, "key", key)
112-
var zero V
113126
return zero, false, nil
114127
}
115128

116129
if !found {
117-
var zero V
118130
return zero, false, nil
119131
}
120132

cache_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,10 @@ func (e *errorPersist[K, V]) ValidateKey(key K) error {
751751
return nil // Allow all keys for test
752752
}
753753

754+
func (e *errorPersist[K, V]) Cleanup(ctx context.Context, maxAge time.Duration) (int, error) {
755+
return 0, context.DeadlineExceeded
756+
}
757+
754758
func BenchmarkCache_Set_WithPersistence(b *testing.B) {
755759
ctx := context.Background()
756760
cacheID := "bench-persist-" + time.Now().Format("20060102150405")

options.go

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import (
77

88
// Options configures a Cache instance.
99
type Options struct {
10-
CacheID string
11-
MemorySize int
12-
DefaultTTL time.Duration
13-
WarmupLimit int
14-
UseDatastore bool
10+
CacheID string
11+
MemorySize int
12+
DefaultTTL time.Duration
13+
WarmupLimit int
14+
UseDatastore bool
15+
CleanupEnabled bool
16+
CleanupMaxAge time.Duration
1517
}
1618

1719
// Option is a functional option for configuring a Cache.
@@ -55,11 +57,7 @@ func WithCloudDatastore(cacheID string) Option {
5557
func WithBestStore(cacheID string) Option {
5658
return func(o *Options) {
5759
o.CacheID = cacheID
58-
if os.Getenv("K_SERVICE") != "" {
59-
o.UseDatastore = true
60-
} else {
61-
o.UseDatastore = false
62-
}
60+
o.UseDatastore = os.Getenv("K_SERVICE") != ""
6361
}
6462
}
6563

@@ -71,13 +69,20 @@ func WithWarmup(n int) Option {
7169
}
7270
}
7371

72+
// WithCleanup enables background cleanup of expired entries at startup.
73+
// maxAge should be set to your maximum TTL value - entries older than this are deleted.
74+
// This is a safety net for expired data and works alongside native Datastore TTL policies.
75+
// If native TTL is properly configured, this cleanup will be fast (no-op).
76+
func WithCleanup(maxAge time.Duration) Option {
77+
return func(o *Options) {
78+
o.CleanupEnabled = true
79+
o.CleanupMaxAge = maxAge
80+
}
81+
}
82+
7483
// defaultOptions returns the default configuration (memory-only).
7584
func defaultOptions() *Options {
7685
return &Options{
77-
MemorySize: 10000,
78-
DefaultTTL: 0,
79-
CacheID: "",
80-
UseDatastore: false,
81-
WarmupLimit: 0,
86+
MemorySize: 10000,
8287
}
8388
}

persist.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ type PersistenceLayer[K comparable, V any] interface {
3131
// Equivalent to LoadRecent(ctx, 0).
3232
LoadAll(ctx context.Context) (<-chan Entry[K, V], <-chan error)
3333

34+
// Cleanup removes expired entries from persistent storage.
35+
// maxAge specifies how old entries must be before deletion.
36+
// Returns the number of entries deleted and any error.
37+
Cleanup(ctx context.Context, maxAge time.Duration) (int, error)
38+
3439
// Close releases any resources held by the persistence layer.
3540
Close() error
3641
}

persist_datastore.go

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -82,18 +82,9 @@ func (d *datastorePersist[K, V]) Load(ctx context.Context, key K) (V, time.Time,
8282
return zero, time.Time{}, false, fmt.Errorf("datastore get: %w", err)
8383
}
8484

85-
// Check expiration
85+
// Check expiration - return miss but don't delete
86+
// Cleanup is handled by native Datastore TTL or periodic Cleanup() calls
8687
if !entry.Expiry.IsZero() && time.Now().After(entry.Expiry) {
87-
// Delete expired entry asynchronously with timeout
88-
// Note: Using context.Background() intentionally - cleanup should continue even if parent context is cancelled
89-
go func(_ context.Context) {
90-
//nolint:contextcheck // Using Background intentionally for independent cleanup goroutine
91-
cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
92-
defer cancel()
93-
if err := d.client.Delete(cleanupCtx, dsKey); err != nil {
94-
slog.Debug("failed to delete expired entry", "error", err)
95-
}
96-
}(ctx)
9788
return zero, time.Time{}, false, nil
9889
}
9990

@@ -187,17 +178,8 @@ func (d *datastorePersist[K, V]) LoadRecent(ctx context.Context, limit int) (<-c
187178
default:
188179
}
189180

190-
// Clean up expired entries asynchronously with timeout
181+
// Skip expired entries - cleanup is handled by native TTL or periodic Cleanup() calls
191182
if !entry.Expiry.IsZero() && now.After(entry.Expiry) {
192-
// Note: Using context.Background() intentionally - cleanup should continue even if parent context is cancelled
193-
go func(_ context.Context, key *datastore.Key) {
194-
//nolint:contextcheck // Using Background intentionally for independent cleanup goroutine
195-
cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
196-
defer cancel()
197-
if err := d.client.Delete(cleanupCtx, key); err != nil {
198-
slog.Debug("failed to delete expired entry", "error", err)
199-
}
200-
}(ctx, dsKey)
201183
expired++
202184
continue
203185
}
@@ -250,6 +232,37 @@ func (d *datastorePersist[K, V]) LoadAll(ctx context.Context) (<-chan Entry[K, V
250232
return d.LoadRecent(ctx, 0)
251233
}
252234

235+
// Cleanup removes expired entries from Datastore.
236+
// maxAge specifies how old entries must be (based on expiry field) before deletion.
237+
// If native Datastore TTL is properly configured, this will find no entries.
238+
func (d *datastorePersist[K, V]) Cleanup(ctx context.Context, maxAge time.Duration) (int, error) {
239+
cutoff := time.Now().Add(-maxAge)
240+
241+
// Query for entries with expiry before cutoff
242+
// This finds entries that should have expired based on maxAge
243+
query := datastore.NewQuery(d.kind).
244+
Filter("expiry >", time.Time{}).
245+
Filter("expiry <", cutoff).
246+
KeysOnly()
247+
248+
keys, err := d.client.GetAll(ctx, query, nil)
249+
if err != nil {
250+
return 0, fmt.Errorf("query expired entries: %w", err)
251+
}
252+
253+
if len(keys) == 0 {
254+
return 0, nil
255+
}
256+
257+
// Batch delete expired entries
258+
if err := d.client.DeleteMulti(ctx, keys); err != nil {
259+
return 0, fmt.Errorf("delete expired entries: %w", err)
260+
}
261+
262+
slog.Info("cleaned up expired entries", "count", len(keys), "kind", d.kind)
263+
return len(keys), nil
264+
}
265+
253266
// Close releases Datastore client resources.
254267
func (d *datastorePersist[K, V]) Close() error {
255268
return d.client.Close()

persist_file.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,72 @@ func (f *filePersist[K, V]) LoadAll(ctx context.Context) (<-chan Entry[K, V], <-
342342
return f.LoadRecent(ctx, 0)
343343
}
344344

345+
// Cleanup removes expired entries from file storage.
346+
// Walks through all cache files and deletes those with expired timestamps.
347+
func (f *filePersist[K, V]) Cleanup(ctx context.Context, maxAge time.Duration) (int, error) {
348+
cutoff := time.Now().Add(-maxAge)
349+
deleted := 0
350+
351+
entries, err := os.ReadDir(f.dir)
352+
if err != nil {
353+
if os.IsNotExist(err) {
354+
return 0, nil
355+
}
356+
return 0, fmt.Errorf("read cache directory: %w", err)
357+
}
358+
359+
for _, entry := range entries {
360+
// Check context cancellation
361+
select {
362+
case <-ctx.Done():
363+
return deleted, ctx.Err()
364+
default:
365+
}
366+
367+
if entry.IsDir() {
368+
continue
369+
}
370+
371+
filename := filepath.Join(f.dir, entry.Name())
372+
373+
// Read and check expiry
374+
file, err := os.Open(filename)
375+
if err != nil {
376+
if os.IsNotExist(err) {
377+
continue
378+
}
379+
slog.Debug("failed to open file for cleanup", "file", filename, "error", err)
380+
continue
381+
}
382+
383+
var entry Entry[K, V]
384+
decoder := gob.NewDecoder(file)
385+
err = decoder.Decode(&entry)
386+
if closeErr := file.Close(); closeErr != nil {
387+
slog.Debug("failed to close file during cleanup", "file", filename, "error", closeErr)
388+
}
389+
390+
if err != nil {
391+
slog.Debug("failed to decode file for cleanup", "file", filename, "error", err)
392+
continue
393+
}
394+
395+
// Delete if expired
396+
if !entry.Expiry.IsZero() && entry.Expiry.Before(cutoff) {
397+
if err := os.Remove(filename); err != nil && !os.IsNotExist(err) {
398+
slog.Debug("failed to remove expired file", "file", filename, "error", err)
399+
continue
400+
}
401+
deleted++
402+
}
403+
}
404+
405+
if deleted > 0 {
406+
slog.Info("cleaned up expired file entries", "count", deleted, "dir", f.dir)
407+
}
408+
return deleted, nil
409+
}
410+
345411
// Close cleans up resources.
346412
func (*filePersist[K, V]) Close() error {
347413
// No resources to clean up for file-based persistence

0 commit comments

Comments
 (0)