Skip to content

Commit 0a10226

Browse files
committed
auto-ttl support: Add galaxy-level TTL config
Directly support jittering the TTL/expiry to make it easy to do the right thing. (bound the expiration as necessary)
1 parent 6459d31 commit 0a10226

File tree

5 files changed

+406
-7
lines changed

5 files changed

+406
-7
lines changed

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# galaxycache
22

3-
[![Build Status](https://travis-ci.org/vimeo/galaxycache.svg?branch=master)](https://travis-ci.org/vimeo/galaxycache)
3+
[![Build Status](https://github.com/vimeo/galaxycache/actions/workflows/go.yml/badge.svg)](https://github.com/vimeo/galaxycache/actions/workflows/go.yml)
4+
[![Go Reference](https://pkg.go.dev/badge/github.com/vimeo/galaxycache.svg)](https://pkg.go.dev/github.com/vimeo/galaxycache)
45

5-
galaxycache is a caching and cache-filling library, adapted from groupcache, intended as a
6+
Galaxycache is a caching and cache-filling library, adapted from groupcache, intended as a
67
replacement for memcached in many cases.
78

8-
For API docs and examples, see http://godoc.org/github.com/vimeo/galaxycache
9+
For API docs and examples, see https://pkg.go.dev/github.com/vimeo/galaxycache
910

1011
## Quick Start
1112

@@ -87,6 +88,10 @@ A [`Galaxy`] is a grouping of keys based on a category determined by the user. F
8788

8889
Each [`Galaxy`] contains its own cache space. The cache is immutable; all cache population and eviction is handled by internal logic.
8990

91+
### TTL/Expiry
92+
93+
Values within a [`Galaxy`] may have an expiration time. This can either be returned directly by a BackendGetterWithInfo implementation, or set via the [`WithGetTTL`] option when constructing the [`Galaxy`].
94+
9095
### Maincache vs Hotcache
9196

9297
The cache within each galaxy is divided into a "maincache" and a "hotcache".
@@ -164,4 +169,5 @@ Use the golang-nuts mailing list for any discussion or questions.
164169
[`RemoteFetcher`]:https://godoc.org/github.com/vimeo/galaxycache#RemoteFetcher
165170
[`BackendGetter`]:https://godoc.org/github.com/vimeo/galaxycache#BackendGetter
166171
[`ShouldPromote.Interface`]:https://godoc.org/github.com/vimeo/galaxycache/promoter#Interface
172+
[`WithGetTTL`]: https://pkg.go.dev/github.com/vimeo/galaxycache#WithGetTTL
167173

galaxycache.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/*
22
Copyright 2012 Google Inc.
3+
Copyright 2019-2025 Vimeo Inc.
34
45
Licensed under the Apache License, Version 2.0 (the "License");
56
you may not use this file except in compliance with the License.
@@ -22,12 +23,29 @@ limitations under the License.
2223
// or finally gets the data. In the common case, many concurrent
2324
// cache misses across a set of peers for the same key result in just
2425
// one cache fill.
26+
//
27+
// In most cases, one will construct a [Universe] with [NewUniverse], and then
28+
// construct a [Galaxy] with [Universe.NewGalaxy].
29+
//
30+
// # Expiration/TTL
31+
//
32+
// [Galaxy] implementations support the concept of a value's expiration time.
33+
// This may either be set by providing an [BackendGetterWithInfo]
34+
// implementation to [Universe.NewGalaxyWithBackendInfo] which returns a
35+
// non-zero [BackendGetInfo].Expiration.
36+
//
37+
// Additionally, [Universe.NewGalaxy] and [Universe.NewGalaxyWithBackendInfo] may take
38+
// [WithGetTTL] and [WithPeekTTL] as arguments to provide default expiration-times.
39+
// [WithPeekTTL] only applies to values that are pulled from peers via
40+
// [RemoteFetcher].Peek and [RemoteFetcherWithInfo].PeekWithInfo
41+
// requests.
2542
package galaxycache // import "github.com/vimeo/galaxycache"
2643

2744
import (
2845
"context"
2946
"errors"
3047
"fmt"
48+
"math/rand/v2"
3149
"strconv"
3250
"sync"
3351
"time"
@@ -231,6 +249,14 @@ func (universe *Universe) NewGalaxyWithBackendInfo(name string, cacheBytes int64
231249
for _, opt := range opts {
232250
opt.apply(&gOpts)
233251
}
252+
if gOpts.peekTTL.entryMaxTTL == 0 {
253+
// Default the peek TTL if the get TTL is unset
254+
// most of the time you don't want the peek TTL to be set if
255+
// the get TTL isn't, but, if the content of a galaxy
256+
// changes/gets more info, it can be useful to bound how long
257+
// data can bounce round without being re-hydrated anew.
258+
gOpts.peekTTL = gOpts.getTTL
259+
}
234260
g := &Galaxy{
235261
name: name,
236262
parent: universe,
@@ -414,6 +440,30 @@ type PeekPeerCfg struct {
414440
// Ranges transfered to this instance from peers that scale-down will
415441
// send Peek requests to those dying peers until it starts erroring.
416442
WarmTime time.Duration `dialsdesc:"time after Universe creation at which one should consider the cache warm and to stop making Peek requests to peers"`
443+
444+
// PeekedValueMaxTTL and PeekedValueTTLJitter allow one to specify a maximum Time To Live (TTL) for
445+
// the values in this Galaxy pulled from peers via Peek requests.
446+
//
447+
// Jitter may be 0 to always set the expiration to be exactly maxTTL time in
448+
// the future.
449+
//
450+
// Expiration times of values with an expiration earlier than maxTTL in the
451+
// future are left alone no matter the source. (e.g. the value may have
452+
// originally had a TTL in the same range, but have been sitting in the cache
453+
// for some time.
454+
//
455+
// Setting a non-zero Jitter will randomly pick an expiry between maxTTL-Jitter
456+
// and maxTTL in the future. When set appropriately, this can be leveraged to
457+
// prevent values populated at about the same time from expiring
458+
// simultaneously and causing a burst in activity while rehydrating values.
459+
// When used, Jitter values should be large enough that over a reasonable
460+
// number of maxTTL intervals, keys that are continually accessed will be
461+
// spread their expiration across the entire interval.
462+
//
463+
// Negative TTLs and jitter values are silently ignored, and jitter values that
464+
// are greater than maxTTL will be capped at maxTTL.
465+
PeekedValueMaxTTL time.Duration `dialsdesc:"max time in the future to allow a value pulled in via a Peek request have their expiration"`
466+
PeekedValueTTLJitter time.Duration `dialsdesc:"max time to reduce the "`
417467
}
418468

419469
// Verify implements the [github.com/vimeo/dials.VerifiedConfig] interface
@@ -424,6 +474,14 @@ func (p *PeekPeerCfg) Verify() error {
424474
if p.WarmTime < 0 {
425475
return fmt.Errorf("WarmTime must be non-negative; got %s", p.WarmTime)
426476
}
477+
478+
if p.PeekedValueMaxTTL < 0 {
479+
return fmt.Errorf("PeekedValueMaxTTL should either be zero to disable, or positive; got %s", p.PeekedValueMaxTTL)
480+
}
481+
if p.PeekedValueTTLJitter < 0 {
482+
return fmt.Errorf("PeekedValueTTLJitter should either be zero to disable jittering, or positive; got %s", p.PeekedValueTTLJitter)
483+
}
484+
427485
return nil
428486
}
429487

@@ -440,6 +498,14 @@ type galaxyOpts struct {
440498
maxCandidates int
441499
clock clocks.Clock
442500
resetIdleStatsAge time.Duration
501+
// parameters for capping the TTL on gets and peeks
502+
getTTL ttlJitter
503+
// peeks may be set separately to trigger delayed reloading around version
504+
// upgrades (to prevent old versions of cache-values from persisting
505+
// indefinitely -- may be O(days) with substantial jitter)
506+
//
507+
// Defaults to matching getTTL
508+
peekTTL ttlJitter
443509

444510
peekPeer *PeekPeerCfg
445511
}
@@ -500,6 +566,44 @@ func WithIdleStatsAgeResetWindow(age time.Duration) GalaxyOption {
500566
func WithPreviousPeerPeeking(cfg PeekPeerCfg) GalaxyOption {
501567
return newFuncGalaxyOption(func(g *galaxyOpts) {
502568
g.peekPeer = &cfg
569+
g.peekTTL = newTTLJitter(cfg.PeekedValueMaxTTL, cfg.PeekedValueTTLJitter)
570+
})
571+
}
572+
573+
func newTTLJitter(maxTTL, jitter time.Duration) ttlJitter {
574+
if maxTTL <= 0 {
575+
return ttlJitter{}
576+
}
577+
return ttlJitter{
578+
entryMaxTTL: maxTTL,
579+
entryTTLJitter: min(max(jitter, 0), maxTTL),
580+
}
581+
}
582+
583+
// WithGetTTL allows the client to specify a maximum Time To Live (TTL) for the
584+
// values in this galaxy, with an optional jitter.
585+
//
586+
// Jitter may be 0 to always set the expiration to be exactly maxTTL time in
587+
// the future.
588+
//
589+
// Expiration times of values with an expiration earlier than maxTTL in the
590+
// future are left alone no matter the source. (e.g. the value may have
591+
// originally had a TTL in the same range, but have been sitting in the cache
592+
// for some time.
593+
//
594+
// Setting a non-zero Jitter will randomly pick an expiry between maxTTL-Jitter
595+
// and maxTTL in the future. When set appropriately, this can be leveraged to
596+
// prevent values populated at about the same time from expiring
597+
// simultaneously and causing a burst in activity while rehydrating values.
598+
// When used, Jitter values should be large enough that over a reasonable
599+
// number of maxTTL intervals, keys that are continually accessed will be
600+
// spread their expiration across the entire interval.
601+
//
602+
// Negative TTLs and jitter values are silently ignored, and jitter values that
603+
// are greater than maxTTL will be capped at maxTTL.
604+
func WithGetTTL(maxTTL, jitter time.Duration) GalaxyOption {
605+
return newFuncGalaxyOption(func(g *galaxyOpts) {
606+
g.getTTL = newTTLJitter(maxTTL, jitter)
503607
})
504608
}
505609

@@ -896,6 +1000,29 @@ func (g *Galaxy) load(ctx context.Context, opts loadOpts, key string, dest Codec
8961000
return
8971001
}
8981002

1003+
type ttlJitter struct {
1004+
// if entryMaxTTL > 0, we'll cap the expiry at now + entryMaxTTL - math.Int64N(entryTTLJitter)
1005+
entryMaxTTL, entryTTLJitter time.Duration
1006+
}
1007+
1008+
func (g ttlJitter) capExpiry(clk clocks.Clock, bgInfo *BackendGetInfo) {
1009+
// initial common-case: no TTL set
1010+
if g.entryMaxTTL <= 0 {
1011+
return
1012+
}
1013+
now := clk.Now()
1014+
// We're done if the expiration is already closer than ttl + maxJitter
1015+
if !bgInfo.Expiration.IsZero() && bgInfo.Expiration.Sub(now) <= (g.entryMaxTTL+g.entryTTLJitter) {
1016+
return
1017+
}
1018+
// Either there's no expiration, or it's too far in the future so we need to adjust it downward.
1019+
newCapInterval := g.entryMaxTTL
1020+
if g.entryTTLJitter > 0 {
1021+
newCapInterval -= time.Duration(rand.Int64N(int64(g.entryTTLJitter)))
1022+
}
1023+
bgInfo.Expiration = now.Add(newCapInterval)
1024+
}
1025+
8991026
func (g *Galaxy) getLocally(ctx context.Context, key string, dest Codec) ([]byte, BackendGetInfo, error) {
9001027
startTime := time.Now()
9011028
defer func() {
@@ -906,6 +1033,7 @@ func (g *Galaxy) getLocally(ctx context.Context, key string, dest Codec) ([]byte
9061033
return nil, BackendGetInfo{}, err
9071034
}
9081035
mar, marErr := dest.MarshalBinary()
1036+
g.opts.getTTL.capExpiry(g.clock, &bgInfo)
9091037
return mar, bgInfo, marErr
9101038
}
9111039

@@ -946,6 +1074,7 @@ func (g *Galaxy) peekPeer(ctx context.Context, key string) (valWithStat, Backend
9461074
// inserted into the main cache.
9471075

9481076
value := g.newValWithStat(peekVal, nil)
1077+
g.opts.peekTTL.capExpiry(g.clock, &bgInfo)
9491078
g.populateCache(ctx, key, value, &g.mainCache, bgInfo)
9501079

9511080
return value, bgInfo, nil
@@ -978,6 +1107,7 @@ func (g *Galaxy) getFromPeer(ctx context.Context, peer RemoteFetcherWithInfo, ke
9781107
if g.opts.promoter.ShouldPromote(key, value.data, stats) {
9791108
g.populateCache(ctx, key, value, &g.hotCache, bgInfo)
9801109
}
1110+
g.opts.getTTL.capExpiry(g.clock, &bgInfo)
9811111
return value, bgInfo, nil
9821112
}
9831113

0 commit comments

Comments
 (0)