11/*
22Copyright 2012 Google Inc.
3+ Copyright 2019-2025 Vimeo Inc.
34
45Licensed under the Apache License, Version 2.0 (the "License");
56you 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.
2542package galaxycache // import "github.com/vimeo/galaxycache"
2643
2744import (
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 {
500566func 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+
8991026func (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