Skip to content

Commit d0785bd

Browse files
authored
fix: bound tiered etag metadata writes (#381)
Only publish tiered cache ETags when a successful write replaces an existing authoritative object with a different ETag, avoiding unbounded metadata growth for immutable cache keys.
1 parent e40ba0f commit d0785bd

2 files changed

Lines changed: 97 additions & 33 deletions

File tree

internal/cache/tiered.go

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
type Tiered struct {
2424
caches []Cache
2525
metadata *metadatadb.Store
26+
etags *metadatadb.Map[Key, string]
2627
namespace Namespace
2728
}
2829

@@ -40,7 +41,7 @@ func MaybeNewTiered(ctx context.Context, caches []Cache, metadata *metadatadb.St
4041
if metadata == nil {
4142
panic("Tiered cache requires a metadata store")
4243
}
43-
return Tiered{caches: caches, metadata: metadata}
44+
return Tiered{caches: caches, metadata: metadata, etags: tieredETags(metadata, "")}
4445
}
4546

4647
type authoritativeCache struct {
@@ -70,21 +71,27 @@ func (t Tiered) Close() error {
7071

7172
// Create a new object. All underlying caches will be written to in sequence.
7273
func (t Tiered) Create(ctx context.Context, key Key, headers http.Header, ttl time.Duration, opts ...Option) (Writer, error) {
73-
rawETag, _, err := createETag(opts...)
74+
rawETag, quotedETag, err := createETag(opts...)
7475
if err != nil {
7576
return nil, errors.WithStack(err)
7677
}
7778
createOpts := []Option{WithETag(rawETag)}
7879

80+
replaceETag, err := t.replacementETag(ctx, key, quotedETag)
81+
if err != nil {
82+
return nil, err
83+
}
84+
7985
// The first error will cancel all outstanding writes.
8086
ctx, cancel := context.WithCancelCause(ctx)
8187

8288
tw := &tieredWriter{
83-
writers: make([]Writer, len(t.caches)),
84-
cancel: cancel,
85-
etags: t.etags(),
86-
key: key,
87-
rawETag: rawETag,
89+
writers: make([]Writer, len(t.caches)),
90+
cancel: cancel,
91+
etags: t.etags,
92+
key: key,
93+
etag: quotedETag,
94+
replaceETag: replaceETag,
8895
}
8996
// Note: we can't use errgroup here because we do not want to cancel the context on Wait().
9097
wg := sync.WaitGroup{}
@@ -108,6 +115,18 @@ func (t Tiered) Create(ctx context.Context, key Key, headers http.Header, ttl ti
108115
}
109116
}
110117

118+
func (t Tiered) replacementETag(ctx context.Context, key Key, newETag string) (bool, error) {
119+
headers, err := t.caches[len(t.caches)-1].Stat(ctx, key)
120+
switch {
121+
case errors.Is(err, os.ErrNotExist):
122+
return false, nil
123+
case err != nil:
124+
return false, errors.WithStack(err)
125+
default:
126+
return headers.Get(ETagKey) != newETag, nil
127+
}
128+
}
129+
111130
// Delete from all underlying caches. All errors are returned.
112131
func (t Tiered) Delete(ctx context.Context, key Key) error {
113132
wg := sync.WaitGroup{}
@@ -118,7 +137,7 @@ func (t Tiered) Delete(ctx context.Context, key Key) error {
118137
wg.Wait()
119138
err := errors.Join(errs...)
120139
if err == nil {
121-
t.deleteETag(key)
140+
t.etags.Delete(key)
122141
}
123142
return err
124143
}
@@ -304,16 +323,12 @@ func (t Tiered) backfillReader(ctx context.Context, key Key, src io.ReadCloser,
304323

305324
const tieredETagsMap = "cache-etags"
306325

307-
func (t Tiered) etags() *metadatadb.Map[Key, string] {
308-
return metadatadb.NewMap[Key, string](t.metadata.Namespace(string(t.namespace)), tieredETagsMap)
309-
}
310-
311-
func (t Tiered) deleteETag(key Key) {
312-
t.etags().Delete(key)
326+
func tieredETags(metadata *metadatadb.Store, namespace Namespace) *metadatadb.Map[Key, string] {
327+
return metadatadb.NewMap[Key, string](metadata.Namespace(string(namespace)), tieredETagsMap)
313328
}
314329

315330
func (t Tiered) invalidateStale(ctx context.Context, c Cache, key Key, headers http.Header) bool {
316-
want, ok := t.etags().Get(key)
331+
want, ok := t.etags.Get(key)
317332
if !ok || want == headers.Get(ETagKey) {
318333
return false
319334
}
@@ -470,13 +485,14 @@ func (t Tiered) Stats(ctx context.Context) (Stats, error) {
470485
}
471486

472487
type tieredWriter struct {
473-
writers []Writer
474-
cancel context.CancelCauseFunc
475-
etags *metadatadb.Map[Key, string]
476-
key Key
477-
rawETag string
478-
closed bool
479-
aborted bool
488+
writers []Writer
489+
cancel context.CancelCauseFunc
490+
etags *metadatadb.Map[Key, string]
491+
key Key
492+
etag string
493+
replaceETag bool
494+
closed bool
495+
aborted bool
480496
}
481497

482498
var _ Writer = (*tieredWriter)(nil)
@@ -501,12 +517,8 @@ func (t *tieredWriter) Close() error {
501517
}
502518
wg.Wait()
503519
err := errors.Join(errs...)
504-
if err == nil && !t.aborted && t.etags != nil {
505-
quoted, qerr := FormatETag(t.rawETag)
506-
if qerr != nil {
507-
return errors.WithStack(qerr)
508-
}
509-
t.etags.Set(t.key, quoted)
520+
if err == nil && !t.aborted && t.replaceETag {
521+
t.etags.Set(t.key, t.etag)
510522
}
511523
return err
512524
}
@@ -531,7 +543,7 @@ func (t Tiered) Namespace(namespace Namespace) Cache {
531543
for i, c := range t.caches {
532544
namespaced[i] = c.Namespace(namespace)
533545
}
534-
return Tiered{caches: namespaced, metadata: t.metadata, namespace: namespace}
546+
return Tiered{caches: namespaced, metadata: t.metadata, etags: tieredETags(t.metadata, namespace), namespace: namespace}
535547
}
536548

537549
// ListNamespaces returns unique namespaces from all underlying caches.

internal/cache/tiered_test.go

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ func TestTieredRequiresMetadataStore(t *testing.T) {
275275
})
276276
}
277277

278-
func TestTieredCreatePublishesMetadataETag(t *testing.T) {
278+
func TestTieredCreateDoesNotPublishMetadataETagForNewKey(t *testing.T) {
279279
_, ctx := logging.Configure(t.Context(), logging.Config{Level: slog.LevelDebug})
280280
store := newMetadataStore(ctx)
281281
lower, err := cache.NewMemory(ctx, cache.MemoryConfig{LimitMB: 1024, MaxTTL: time.Hour})
@@ -292,9 +292,55 @@ func TestTieredCreatePublishesMetadataETag(t *testing.T) {
292292
assert.NoError(t, err)
293293
assert.NoError(t, w.Close())
294294

295+
_, ok := tieredETags(store, "").Get(key)
296+
assert.False(t, ok)
297+
}
298+
299+
func TestTieredCreatePublishesMetadataETagForReplacement(t *testing.T) {
300+
_, ctx := logging.Configure(t.Context(), logging.Config{Level: slog.LevelDebug})
301+
store := newMetadataStore(ctx)
302+
lower, err := cache.NewMemory(ctx, cache.MemoryConfig{LimitMB: 1024, MaxTTL: time.Hour})
303+
assert.NoError(t, err)
304+
upper, err := cache.NewMemory(ctx, cache.MemoryConfig{LimitMB: 1024, MaxTTL: time.Hour})
305+
assert.NoError(t, err)
306+
tiered := cache.MaybeNewTiered(ctx, []cache.Cache{lower, upper}, store)
307+
defer tiered.Close()
308+
309+
key := cache.NewKey("tiered-replace-metadata-etag")
310+
seedTier(ctx, t, tiered, key, []byte("old"), "old-etag")
311+
312+
w, err := tiered.Create(ctx, key, nil, time.Minute, cache.WithETag("new-etag"))
313+
assert.NoError(t, err)
314+
_, err = w.Write([]byte("new"))
315+
assert.NoError(t, err)
316+
assert.NoError(t, w.Close())
317+
295318
etag, ok := tieredETags(store, "").Get(key)
296319
assert.True(t, ok)
297-
assert.Equal(t, `"metadata-etag"`, etag)
320+
assert.Equal(t, `"new-etag"`, etag)
321+
}
322+
323+
func TestTieredCreateDoesNotPublishMetadataETagForSameETagReplacement(t *testing.T) {
324+
_, ctx := logging.Configure(t.Context(), logging.Config{Level: slog.LevelDebug})
325+
store := newMetadataStore(ctx)
326+
lower, err := cache.NewMemory(ctx, cache.MemoryConfig{LimitMB: 1024, MaxTTL: time.Hour})
327+
assert.NoError(t, err)
328+
upper, err := cache.NewMemory(ctx, cache.MemoryConfig{LimitMB: 1024, MaxTTL: time.Hour})
329+
assert.NoError(t, err)
330+
tiered := cache.MaybeNewTiered(ctx, []cache.Cache{lower, upper}, store)
331+
defer tiered.Close()
332+
333+
key := cache.NewKey("tiered-same-metadata-etag")
334+
seedTier(ctx, t, tiered, key, []byte("old"), "same-etag")
335+
336+
w, err := tiered.Create(ctx, key, nil, time.Minute, cache.WithETag("same-etag"))
337+
assert.NoError(t, err)
338+
_, err = w.Write([]byte("new"))
339+
assert.NoError(t, err)
340+
assert.NoError(t, w.Close())
341+
342+
_, ok := tieredETags(store, "").Get(key)
343+
assert.False(t, ok)
298344
}
299345

300346
func TestTieredAbortDoesNotPublishMetadataETag(t *testing.T) {
@@ -461,9 +507,15 @@ func TestTieredMetadataIsNamespaced(t *testing.T) {
461507

462508
key := cache.NewKey("tiered-namespaced-metadata")
463509
namespaced := tiered.Namespace("alpha")
464-
w, err := namespaced.Create(ctx, key, nil, time.Minute, cache.WithETag("alpha-etag"))
510+
w, err := namespaced.Create(ctx, key, nil, time.Minute, cache.WithETag("old-alpha-etag"))
465511
assert.NoError(t, err)
466-
_, err = w.Write([]byte("content"))
512+
_, err = w.Write([]byte("old"))
513+
assert.NoError(t, err)
514+
assert.NoError(t, w.Close())
515+
516+
w, err = namespaced.Create(ctx, key, nil, time.Minute, cache.WithETag("alpha-etag"))
517+
assert.NoError(t, err)
518+
_, err = w.Write([]byte("new"))
467519
assert.NoError(t, err)
468520
assert.NoError(t, w.Close())
469521

0 commit comments

Comments
 (0)