Skip to content

Commit b92961c

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
Add compression support
1 parent 75cd27e commit b92961c

24 files changed

+1880
-212
lines changed

Makefile

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ tag:
1111
@echo ""
1212
@echo "Step 1: Update submodule go.mod files to require sfcache $(VERSION)..."
1313
@find . -path ./go.mod -prune -o -name go.mod -print | xargs -I{} sed -i '' 's|github.com/codeGROOVE-dev/sfcache v[^ ]*|github.com/codeGROOVE-dev/sfcache $(VERSION)|' {}
14-
@find . -path ./go.mod -prune -o -name go.mod -print | xargs -I{} sed -i '' 's|github.com/codeGROOVE-dev/sfcache/pkg/persist/localfs v[^ ]*|github.com/codeGROOVE-dev/sfcache/pkg/persist/localfs $(VERSION)|' {}
15-
@find . -path ./go.mod -prune -o -name go.mod -print | xargs -I{} sed -i '' 's|github.com/codeGROOVE-dev/sfcache/pkg/persist/datastore v[^ ]*|github.com/codeGROOVE-dev/sfcache/pkg/persist/datastore $(VERSION)|' {}
14+
@# Update store submodule dependencies (compress must be first as others depend on it)
15+
@find . -path ./go.mod -prune -o -name go.mod -print | xargs -I{} sed -i '' 's|github.com/codeGROOVE-dev/sfcache/pkg/store/compress v[^ ]*|github.com/codeGROOVE-dev/sfcache/pkg/store/compress $(VERSION)|' {}
16+
@find . -path ./go.mod -prune -o -name go.mod -print | xargs -I{} sed -i '' 's|github.com/codeGROOVE-dev/sfcache/pkg/store/localfs v[^ ]*|github.com/codeGROOVE-dev/sfcache/pkg/store/localfs $(VERSION)|' {}
17+
@find . -path ./go.mod -prune -o -name go.mod -print | xargs -I{} sed -i '' 's|github.com/codeGROOVE-dev/sfcache/pkg/store/datastore v[^ ]*|github.com/codeGROOVE-dev/sfcache/pkg/store/datastore $(VERSION)|' {}
18+
@find . -path ./go.mod -prune -o -name go.mod -print | xargs -I{} sed -i '' 's|github.com/codeGROOVE-dev/sfcache/pkg/store/valkey v[^ ]*|github.com/codeGROOVE-dev/sfcache/pkg/store/valkey $(VERSION)|' {}
1619
@echo ""
1720
@echo "Step 2: Commit go.mod changes..."
1821
@git add -A
@@ -21,7 +24,10 @@ tag:
2124
@echo "Step 3: Create and push tags..."
2225
@git tag -a $(VERSION) -m "$(VERSION)" --force
2326
@git push origin $(VERSION) --force
24-
@# Push submodule tags in dependency order (cloudrun depends on datastore and localfs)
27+
@# Push submodule tags in dependency order:
28+
@# - compress first (localfs, datastore, valkey depend on it)
29+
@# - cloudrun last (depends on datastore and localfs)
30+
@# Note: alphabetical sort naturally orders compress before datastore/localfs/valkey
2531
@for mod in $$(find . -name go.mod -not -path "./go.mod" | sort | grep -v cloudrun) $$(find . -name go.mod -path "*/cloudrun/*"); do \
2632
dir=$$(dirname $$mod); \
2733
dir=$${dir#./}; \

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ Designed for persistently caching API requests in an unreliable environment, thi
1919
- **Multi-tier persistent cache (optional)** - Bring your own database or use built-in backends:
2020
- [`pkg/store/cloudrun`](pkg/store/cloudrun) - Automatically select Google Cloud Datastore in Cloud Run, localfs elsewhere
2121
- [`pkg/store/datastore`](pkg/store/datastore) - Google Cloud Datastore
22-
- [`pkg/store/localfs`](pkg/store/localfs) - Local files (gob encoding, zero dependencies)
22+
- [`pkg/store/localfs`](pkg/store/localfs) - Local files (JSON encoding, zero dependencies)
2323
- [`pkg/store/null`](pkg/store/null) - No-op (for testing or TieredCache API compatibility)
2424
- [`pkg/store/valkey`](pkg/store/valkey) - Valkey/Redis
25+
- **Optional compression** - S2 or Zstd for all persistence backends via [`pkg/store/compress`](pkg/store/compress)
2526
- **Per-item TTL** - Optional expiration
2627
- **Thundering herd prevention** - `GetSet` deduplicates concurrent loads for the same key
2728
- **Graceful degradation** - Cache works even if persistence fails
@@ -55,6 +56,14 @@ cache.SetAsync(ctx, "user:123", user) // Don't wait for the key to persist
5556
cache.Store.Len(ctx) // Access persistence layer directly
5657
```
5758

59+
With S2 compression (fast, good ratio):
60+
61+
```go
62+
import "github.com/codeGROOVE-dev/sfcache/pkg/store/compress"
63+
64+
p, _ := localfs.New[string, User]("myapp", "", compress.S2())
65+
```
66+
5867
How about a persistent cache suitable for Cloud Run or local development? This uses Cloud DataStore if available, local files if not:
5968

6069
```go

pkg/store/cloudrun/go.mod

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@ require (
77
github.com/codeGROOVE-dev/sfcache/pkg/store/localfs v1.4.1
88
)
99

10-
require github.com/codeGROOVE-dev/ds9 v0.8.0 // indirect
10+
require (
11+
github.com/codeGROOVE-dev/ds9 v0.8.0 // indirect
12+
github.com/codeGROOVE-dev/sfcache/pkg/store/compress v0.0.0 // indirect
13+
github.com/klauspost/compress v1.18.2 // indirect
14+
)
1115

1216
replace github.com/codeGROOVE-dev/sfcache/pkg/store/datastore => ../datastore
1317

1418
replace github.com/codeGROOVE-dev/sfcache/pkg/store/localfs => ../localfs
19+
20+
replace github.com/codeGROOVE-dev/sfcache/pkg/store/compress => ../compress

pkg/store/cloudrun/go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
github.com/codeGROOVE-dev/ds9 v0.8.0 h1:A23VvL1YzUBZyXNYmF5u0R6nPcxQitPeLo8FFk6OiUs=
22
github.com/codeGROOVE-dev/ds9 v0.8.0/go.mod h1:0UDipxF1DADfqM5GtjefgB2u+EXdDgOKmxVvrSGLHoM=
3+
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
4+
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
5+
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
6+
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=

pkg/store/compress/compress.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Package compress provides compression algorithms for sfcache persistence stores.
2+
package compress
3+
4+
import (
5+
"github.com/klauspost/compress/s2"
6+
"github.com/klauspost/compress/zstd"
7+
)
8+
9+
// Compressor compresses and decompresses data.
10+
type Compressor interface {
11+
Encode(data []byte) ([]byte, error)
12+
Decode(data []byte) ([]byte, error)
13+
Extension() string
14+
}
15+
16+
type none struct{}
17+
18+
// None returns a pass-through compressor (no compression).
19+
func None() Compressor { return none{} }
20+
21+
func (none) Encode(data []byte) ([]byte, error) { return data, nil }
22+
func (none) Decode(data []byte) ([]byte, error) { return data, nil }
23+
func (none) Extension() string { return "" }
24+
25+
type s2c struct{}
26+
27+
// S2 returns a fast compressor using S2 (improved Snappy).
28+
func S2() Compressor { return s2c{} }
29+
30+
func (s2c) Encode(data []byte) ([]byte, error) { return s2.Encode(nil, data), nil }
31+
func (s2c) Decode(data []byte) ([]byte, error) { return s2.Decode(nil, data) }
32+
func (s2c) Extension() string { return ".s" }
33+
34+
type zstdc struct {
35+
enc *zstd.Encoder
36+
dec *zstd.Decoder
37+
}
38+
39+
// Zstd returns a compressor using Zstandard.
40+
// Level: 1 (fastest) to 4 (best compression).
41+
func Zstd(level int) Compressor {
42+
lvl := zstd.SpeedDefault
43+
if level <= 1 {
44+
lvl = zstd.SpeedFastest
45+
} else if level >= 4 {
46+
lvl = zstd.SpeedBestCompression
47+
}
48+
enc, _ := zstd.NewWriter(nil, zstd.WithEncoderLevel(lvl)) //nolint:errcheck // options are valid
49+
dec, _ := zstd.NewReader(nil) //nolint:errcheck // options are valid
50+
return &zstdc{enc: enc, dec: dec}
51+
}
52+
53+
func (z *zstdc) Encode(data []byte) ([]byte, error) { return z.enc.EncodeAll(data, nil), nil }
54+
func (z *zstdc) Decode(data []byte) ([]byte, error) { return z.dec.DecodeAll(data, nil) }
55+
func (*zstdc) Extension() string { return ".z" }
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package compress
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
)
7+
8+
var benchData = []byte(`{"key":"test-key-12345","value":{"name":"benchmark","count":42,"tags":["test","benchmark","compression"],"created":"2024-01-01T00:00:00Z"},"expiry":"2025-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}`)
9+
10+
func BenchmarkCompressors(b *testing.B) {
11+
compressors := []struct {
12+
name string
13+
c Compressor
14+
}{
15+
{"None", None()},
16+
{"S2", S2()},
17+
{"Zstd-1", Zstd(1)},
18+
{"Zstd-4", Zstd(4)},
19+
}
20+
21+
for _, tc := range compressors {
22+
b.Run(tc.name+"/Encode", func(b *testing.B) {
23+
b.SetBytes(int64(len(benchData)))
24+
b.ReportAllocs()
25+
for range b.N {
26+
_, _ = tc.c.Encode(benchData) //nolint:errcheck // benchmark
27+
}
28+
})
29+
30+
// Pre-encode for decode benchmark
31+
encoded, _ := tc.c.Encode(benchData) //nolint:errcheck // setup for benchmark
32+
b.Run(tc.name+"/Decode", func(b *testing.B) {
33+
b.SetBytes(int64(len(encoded)))
34+
b.ReportAllocs()
35+
for range b.N {
36+
_, _ = tc.c.Decode(encoded) //nolint:errcheck // benchmark
37+
}
38+
})
39+
}
40+
}
41+
42+
func TestCompressorsRoundTrip(t *testing.T) {
43+
compressors := []struct {
44+
name string
45+
c Compressor
46+
ext string
47+
}{
48+
{"None", None(), ""},
49+
{"S2", S2(), ".s"},
50+
{"Zstd-1", Zstd(1), ".z"},
51+
{"Zstd-4", Zstd(4), ".z"},
52+
}
53+
54+
for _, tc := range compressors {
55+
t.Run(tc.name, func(t *testing.T) {
56+
encoded, err := tc.c.Encode(benchData)
57+
if err != nil {
58+
t.Fatalf("Encode: %v", err)
59+
}
60+
61+
decoded, err := tc.c.Decode(encoded)
62+
if err != nil {
63+
t.Fatalf("Decode: %v", err)
64+
}
65+
66+
if !bytes.Equal(decoded, benchData) {
67+
t.Errorf("roundtrip failed: got %q, want %q", decoded, benchData)
68+
}
69+
70+
if tc.c.Extension() != tc.ext {
71+
t.Errorf("Extension = %q, want %q", tc.c.Extension(), tc.ext)
72+
}
73+
})
74+
}
75+
}
76+
77+
func TestNoneZeroCopy(t *testing.T) {
78+
c := None()
79+
data := []byte("test data")
80+
81+
encoded, err := c.Encode(data)
82+
if err != nil {
83+
t.Fatalf("Encode: %v", err)
84+
}
85+
if &encoded[0] != &data[0] {
86+
t.Error("None.Encode should return same slice (zero-copy)")
87+
}
88+
89+
decoded, err := c.Decode(data)
90+
if err != nil {
91+
t.Fatalf("Decode: %v", err)
92+
}
93+
if &decoded[0] != &data[0] {
94+
t.Error("None.Decode should return same slice (zero-copy)")
95+
}
96+
}

pkg/store/compress/go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module github.com/codeGROOVE-dev/sfcache/pkg/store/compress
2+
3+
go 1.25.4
4+
5+
require github.com/klauspost/compress v1.18.0

pkg/store/compress/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
2+
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=

pkg/store/datastore/datastore.go

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
ds "github.com/codeGROOVE-dev/ds9/pkg/datastore"
13+
"github.com/codeGROOVE-dev/sfcache/pkg/store/compress"
1314
)
1415

1516
const (
@@ -19,28 +20,30 @@ const (
1920

2021
// Store implements persistence using Google Cloud Datastore.
2122
type Store[K comparable, V any] struct {
22-
client *ds.Client
23-
kind string
23+
client *ds.Client
24+
kind string
25+
compressor compress.Compressor
26+
ext string
2427
}
2528

2629
// ValidateKey checks if a key is valid for Datastore persistence.
2730
// Datastore has stricter key length limits than files.
2831
func (*Store[K, V]) ValidateKey(key K) error {
29-
s := fmt.Sprintf("%v", key)
30-
if len(s) > maxDatastoreKeyLen {
31-
return fmt.Errorf("key too long: %d bytes (max %d for datastore)", len(s), maxDatastoreKeyLen)
32-
}
33-
if s == "" {
32+
k := fmt.Sprintf("%v", key)
33+
if k == "" {
3434
return errors.New("key cannot be empty")
3535
}
36+
if len(k) > maxDatastoreKeyLen {
37+
return fmt.Errorf("key too long: %d bytes (max %d for datastore)", len(k), maxDatastoreKeyLen)
38+
}
3639
return nil
3740
}
3841

3942
// Location returns the Datastore key path for a given cache key.
4043
// Implements the Store interface Location() method.
4144
// Format: "kind/key" (e.g., "CacheEntry/mykey").
4245
func (s *Store[K, V]) Location(key K) string {
43-
return fmt.Sprintf("%s/%v", s.kind, key)
46+
return fmt.Sprintf("%s/%v%s", s.kind, key, s.ext)
4447
}
4548

4649
// entry represents a cache entry in Datastore.
@@ -54,27 +57,30 @@ type entry struct {
5457

5558
// New creates a new Datastore-based persistence layer.
5659
// The cacheID is used as the Datastore database name.
57-
// An empty projectID will be auto-detected from the environment.
58-
func New[K comparable, V any](ctx context.Context, cacheID string) (*Store[K, V], error) {
59-
// Empty project ID lets ds9 auto-detect
60+
// Optional compressor enables compression (default: no compression).
61+
func New[K comparable, V any](ctx context.Context, cacheID string, c ...compress.Compressor) (*Store[K, V], error) {
62+
comp := compress.None()
63+
if len(c) > 0 && c[0] != nil {
64+
comp = c[0]
65+
}
66+
6067
client, err := ds.NewClientWithDatabase(ctx, "", cacheID)
6168
if err != nil {
6269
return nil, fmt.Errorf("create datastore client: %w", err)
6370
}
6471

65-
// Verify connectivity (assert readiness)
66-
// Note: ds9 doesn't expose Ping, but client creation validates connectivity
67-
6872
return &Store[K, V]{
69-
client: client,
70-
kind: datastoreKind,
73+
client: client,
74+
kind: datastoreKind,
75+
compressor: comp,
76+
ext: comp.Extension(),
7177
}, nil
7278
}
7379

7480
// makeKey creates a Datastore key from a cache key.
75-
// We use the string representation directly as the key name.
81+
// We use the string representation directly as the key name, with extension suffix.
7682
func (s *Store[K, V]) makeKey(key K) *ds.Key {
77-
return ds.NameKey(s.kind, fmt.Sprintf("%v", key), nil)
83+
return ds.NameKey(s.kind, fmt.Sprintf("%v%s", key, s.ext), nil)
7884
}
7985

8086
// Get retrieves a value from Datastore.
@@ -98,14 +104,17 @@ func (s *Store[K, V]) Get(ctx context.Context, key K) (value V, expiry time.Time
98104
return zero, time.Time{}, false, nil
99105
}
100106

101-
// Decode from base64
102107
b, err := base64.StdEncoding.DecodeString(e.Value)
103108
if err != nil {
104109
return zero, time.Time{}, false, fmt.Errorf("decode base64: %w", err)
105110
}
106111

107-
// Decode value from JSON
108-
if err := json.Unmarshal(b, &value); err != nil {
112+
jsonData, err := s.compressor.Decode(b)
113+
if err != nil {
114+
return zero, time.Time{}, false, fmt.Errorf("decompress: %w", err)
115+
}
116+
117+
if err := json.Unmarshal(jsonData, &value); err != nil {
109118
return zero, time.Time{}, false, fmt.Errorf("unmarshal value: %w", err)
110119
}
111120

@@ -114,21 +123,23 @@ func (s *Store[K, V]) Get(ctx context.Context, key K) (value V, expiry time.Time
114123

115124
// Set saves a value to Datastore.
116125
func (s *Store[K, V]) Set(ctx context.Context, key K, value V, expiry time.Time) error {
117-
k := s.makeKey(key)
118-
119-
// Encode value as JSON then base64
120-
b, err := json.Marshal(value)
126+
jsonData, err := json.Marshal(value)
121127
if err != nil {
122128
return fmt.Errorf("marshal value: %w", err)
123129
}
124130

131+
data, err := s.compressor.Encode(jsonData)
132+
if err != nil {
133+
return fmt.Errorf("compress: %w", err)
134+
}
135+
125136
e := entry{
126-
Value: base64.StdEncoding.EncodeToString(b),
137+
Value: base64.StdEncoding.EncodeToString(data),
127138
Expiry: expiry,
128139
UpdatedAt: time.Now(),
129140
}
130141

131-
if _, err := s.client.Put(ctx, k, &e); err != nil {
142+
if _, err := s.client.Put(ctx, s.makeKey(key), &e); err != nil {
132143
return fmt.Errorf("datastore put: %w", err)
133144
}
134145

pkg/store/datastore/go.mod

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,11 @@ module github.com/codeGROOVE-dev/sfcache/pkg/store/datastore
22

33
go 1.25.4
44

5-
require github.com/codeGROOVE-dev/ds9 v0.8.0
5+
require (
6+
github.com/codeGROOVE-dev/ds9 v0.8.0
7+
github.com/codeGROOVE-dev/sfcache/pkg/store/compress v0.0.0
8+
)
9+
10+
require github.com/klauspost/compress v1.18.0 // indirect
11+
12+
replace github.com/codeGROOVE-dev/sfcache/pkg/store/compress => ../compress

0 commit comments

Comments
 (0)