Skip to content

Commit e0e3741

Browse files
worstellampagent
andcommitted
fix(cache): make disk ranged reads revision-atomic
Disk.Open opened the file descriptor and read the ETag from BoltDB without synchronisation, while diskWriter.Close renamed the body then wrote metadata as two separate steps. A replacement interleaving those reads returned one revision's bytes with another's ETag. With ParallelGet this passes the chunk-0 consistency check yet splices old and new bytes across later chunks, so per-chunk ETag pinning did not actually bind the served body to the returned metadata. Guard each key's body swap with a striped RWMutex: Open captures the (descriptor, headers) pair under a read lock, and Close/Delete perform the rename/remove plus metadata write under the write lock. os.Open binds the descriptor to a stable inode, so the captured pair stays revision-consistent for the descriptor's lifetime. Closes #348 Co-authored-by: Amp <amp@ampcode.com> Signed-off-by: Elizabeth Worstell <worstell@squareup.com> Amp-Thread-ID: https://ampcode.com/threads/T-019f3d90-8c5a-757c-87ff-308404f3b2bd
1 parent cb44752 commit e0e3741

2 files changed

Lines changed: 154 additions & 7 deletions

File tree

internal/cache/disk.go

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cache
22

33
import (
44
"context"
5+
"hash/fnv"
56
"io"
67
"io/fs"
78
"log/slog"
@@ -10,6 +11,7 @@ import (
1011
"path/filepath"
1112
"sort"
1213
"strconv"
14+
"sync"
1315
"sync/atomic"
1416
"time"
1517

@@ -45,8 +47,17 @@ type Disk struct {
4547
runEviction chan struct{}
4648
stop context.CancelFunc
4749
evictionDone chan struct{}
50+
// locks serialises each key's body swap against reads so Open captures a
51+
// revision-consistent (file descriptor, headers) pair. Striped to keep
52+
// cross-key contention negligible. Shared across namespaced copies (the
53+
// slice header is copied, the mutexes are not).
54+
locks []sync.RWMutex
4855
}
4956

57+
// diskLockStripes bounds the number of per-key locks. A power of two so the
58+
// hash reduction is a mask.
59+
const diskLockStripes = 1024
60+
5061
var _ Cache = (*Disk)(nil)
5162

5263
// NewDisk creates a new disk-based cache instance.
@@ -119,6 +130,7 @@ func NewDisk(ctx context.Context, config DiskConfig) (*Disk, error) {
119130
runEviction: make(chan struct{}),
120131
stop: stop,
121132
evictionDone: make(chan struct{}),
133+
locks: make([]sync.RWMutex, diskLockStripes),
122134
}
123135
disk.size.Store(size)
124136

@@ -127,6 +139,16 @@ func NewDisk(ctx context.Context, config DiskConfig) (*Disk, error) {
127139
return disk, nil
128140
}
129141

142+
// keyLock returns the stripe guarding a given (namespace, key) body/metadata
143+
// pair. It is derived deterministically so every namespaced view of the same
144+
// disk agrees on the same lock.
145+
func (d *Disk) keyLock(namespace Namespace, key Key) *sync.RWMutex {
146+
h := fnv.New32a()
147+
_, _ = h.Write([]byte(namespace))
148+
_, _ = h.Write(key[:])
149+
return &d.locks[h.Sum32()&(diskLockStripes-1)]
150+
}
151+
130152
func (d *Disk) String() string { return "disk:" + d.config.Root }
131153

132154
func (d *Disk) Close() error {
@@ -211,19 +233,24 @@ func (d *Disk) Delete(_ context.Context, key Key) error {
211233
expired = true
212234
}
213235

236+
// Remove the body and its metadata as one critical section, excluding any
237+
// concurrent Open's snapshot capture (#348).
238+
lock := d.keyLock(d.namespace, key)
239+
lock.Lock()
214240
info, err := os.Stat(fullPath)
215241
if err != nil {
242+
lock.Unlock()
216243
return errors.Errorf("failed to stat file: %w", err)
217244
}
218-
219245
if err := os.Remove(fullPath); err != nil {
246+
lock.Unlock()
220247
return errors.Errorf("failed to remove file: %w", err)
221248
}
222-
223-
// Remove metadata
224249
if err := d.db.delete(d.namespace, key); err != nil {
250+
lock.Unlock()
225251
return errors.Errorf("failed to delete TTL metadata: %w", err)
226252
}
253+
lock.Unlock()
227254

228255
d.size.Add(-info.Size())
229256

@@ -275,25 +302,42 @@ func (d *Disk) Open(ctx context.Context, key Key, opts ...Option) (io.ReadCloser
275302
path := d.keyToPath(d.namespace, key)
276303
fullPath := filepath.Join(d.config.Root, path)
277304

305+
// Capture the file descriptor and its metadata under the key's read lock,
306+
// excluding the writer's rename+db.set critical section. os.Open binds the
307+
// descriptor to a specific inode (stable across a later rename), so the
308+
// pair captured here stays revision-consistent for the descriptor's whole
309+
// lifetime — without this, a replacement landing between os.Open and
310+
// getHeaders returns one revision's bytes with another's ETag, silently
311+
// splicing versions across ParallelGet chunks (#348).
312+
lock := d.keyLock(d.namespace, key)
313+
lock.RLock()
278314
f, err := os.Open(fullPath)
279315
if err != nil {
316+
lock.RUnlock()
280317
return nil, nil, errors.Errorf("failed to open file: %w", err)
281318
}
282-
283319
expiresAt, err := d.db.getTTL(d.namespace, key)
284320
if err != nil {
321+
lock.RUnlock()
285322
return nil, nil, errors.Join(err, f.Close())
286323
}
287324

325+
// Delete takes the write lock, so an expired entry must be released before
326+
// cleanup. Checking expiry before reading headers keeps the original
327+
// semantics: an expired entry (even one with bad/missing header metadata)
328+
// is treated as a miss and deleted, rather than surfacing a hard error.
288329
now := time.Now()
289330
if now.After(expiresAt) {
331+
lock.RUnlock()
290332
return nil, nil, errors.Join(fs.ErrNotExist, f.Close(), d.Delete(ctx, key))
291333
}
292334

293335
headers, err := d.db.getHeaders(d.namespace, key)
294336
if err != nil {
337+
lock.RUnlock()
295338
return nil, nil, errors.Join(errors.Errorf("failed to get headers: %w", err), f.Close())
296339
}
340+
lock.RUnlock()
297341

298342
finfo, err := f.Stat()
299343
if err != nil {
@@ -505,18 +549,24 @@ func (w *diskWriter) Close() error {
505549
return errors.Errorf("failed to create directory: %w", err)
506550
}
507551

508-
// Check if we're overwriting an existing file and subtract its size
552+
// Swap the body and publish its metadata as one critical section so a
553+
// concurrent Open never captures the new bytes with the old ETag, or vice
554+
// versa (#348).
555+
lock := w.disk.keyLock(w.namespace, w.key)
556+
lock.Lock()
557+
// Check if we're overwriting an existing file and subtract its size.
509558
if info, err := os.Stat(w.path); err == nil {
510559
w.disk.size.Add(-info.Size())
511560
}
512-
513561
if err := os.Rename(w.tempPath, w.path); err != nil {
562+
lock.Unlock()
514563
return errors.Errorf("failed to rename temp file: %w", err)
515564
}
516-
517565
if err := w.disk.db.set(w.key, w.namespace, w.expiresAt, w.headers); err != nil {
566+
lock.Unlock()
518567
return errors.Join(errors.Errorf("failed to set metadata: %w", err), os.Remove(w.path))
519568
}
569+
lock.Unlock()
520570

521571
w.disk.size.Add(w.size)
522572

internal/cache/disk_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
package cache_test
22

33
import (
4+
"bytes"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"io"
48
"log/slog"
9+
"net/http"
510
"os"
11+
"sync"
612
"testing"
713
"time"
814

915
"github.com/alecthomas/assert/v2"
16+
"github.com/alecthomas/errors"
1017

1118
"github.com/block/cachew/internal/cache"
1219
"github.com/block/cachew/internal/cache/cachetest"
@@ -52,3 +59,93 @@ func TestDiskCacheSoak(t *testing.T) {
5259
TTL: 5 * time.Minute,
5360
})
5461
}
62+
63+
// TestDiskOpenRevisionAtomic asserts that Open never returns one revision's
64+
// bytes paired with another revision's ETag while the same key is concurrently
65+
// replaced (#348). The invariant checked mirrors production: the ETag returned
66+
// with a body must equal the hash of that body. Run with -race for best
67+
// coverage.
68+
func TestDiskOpenRevisionAtomic(t *testing.T) {
69+
dir := t.TempDir()
70+
_, ctx := logging.Configure(t.Context(), logging.Config{Level: slog.LevelError})
71+
c, err := cache.NewDisk(ctx, cache.DiskConfig{Root: dir, MaxTTL: time.Hour})
72+
assert.NoError(t, err)
73+
defer c.Close()
74+
75+
key := cache.NewKey("revision-atomic")
76+
77+
// Each revision's raw ETag is the hash of its body, exactly as the higher
78+
// cache layers derive content-hash ETags. A revision-consistent Open must
79+
// therefore return headers whose ETag re-hashes to the served body.
80+
write := func(rev int) {
81+
body := bytes.Repeat([]byte{byte(rev)}, 4096+rev)
82+
sum := sha256.Sum256(body)
83+
raw := hex.EncodeToString(sum[:])
84+
w, err := c.Create(ctx, key, http.Header{}, time.Hour, cache.WithETag(raw))
85+
if err != nil {
86+
t.Errorf("create: %v", err)
87+
return
88+
}
89+
if _, err := w.Write(body); err != nil {
90+
t.Errorf("write: %v", errors.Join(err, w.Abort(err)))
91+
return
92+
}
93+
if err := w.Close(); err != nil {
94+
t.Errorf("close: %v", err)
95+
}
96+
}
97+
98+
write(0) // seed so readers don't race an empty key
99+
100+
const writers, readsPerReader, readers = 4, 500, 4
101+
var wg sync.WaitGroup
102+
stop := make(chan struct{})
103+
104+
for range writers {
105+
wg.Go(func() {
106+
for rev := 1; ; rev++ {
107+
select {
108+
case <-stop:
109+
return
110+
default:
111+
}
112+
write(rev % 251) // stay in a single byte; distinct bodies
113+
}
114+
})
115+
}
116+
117+
for range readers {
118+
wg.Go(func() {
119+
for range readsPerReader {
120+
r, headers, err := c.Open(ctx, key)
121+
if err != nil {
122+
t.Errorf("open: %v", err)
123+
continue
124+
}
125+
body, err := io.ReadAll(r)
126+
_ = r.Close()
127+
if err != nil {
128+
t.Errorf("read: %v", err)
129+
continue
130+
}
131+
sum := sha256.Sum256(body)
132+
want, err := cache.FormatETag(hex.EncodeToString(sum[:]))
133+
if err != nil {
134+
t.Errorf("format etag: %v", err)
135+
continue
136+
}
137+
if got := headers.Get(cache.ETagKey); got != want {
138+
t.Errorf("etag/body mismatch: header %s does not match body hash %s (spliced revision)", got, want)
139+
}
140+
}
141+
})
142+
}
143+
144+
// Bound the writers so the test terminates even if the readers finish
145+
// first; readers stop on their own after readsPerReader each.
146+
go func() {
147+
time.Sleep(time.Second)
148+
close(stop)
149+
}()
150+
wg.Wait()
151+
}

0 commit comments

Comments
 (0)