feat: L2 Cache with Valkey Client Side Hash Ring#4033
Conversation
7047126 to
573ebdf
Compare
55ba444 to
c73eb72
Compare
| // Shallow copy so NewValkeyRingClient can mutate opt.Addrs without | ||
| // racing against the ratelimit registry's copy of the same pointer. | ||
| cacheValkeyOpts := *valkeyOptions | ||
| cacheValkeyRing, err = skpnet.NewValkeyRingClient(&cacheValkeyOpts) |
There was a problem hiding this comment.
Why not passing the same client?
I know that you can not know this, but in the end we can refactor code that I want to move around a bit and some I want to delete later, so we can make this nice. :)
There was a problem hiding this comment.
Please merge larry-dalmeida#2 , if you agree that this makes sense
|
👍 |
|
👍 |
That actually implies that l1 cache will be empty in case if Set was successfull and only Get fails. |
Good point, I wondered about this and missed to ask the question. |
|
Intended behavior: L1 is a degraded-mode fallback only, not hot cache layer path. Happy path always reads from and writes to L2. L1 is only populated when a valkey operation fails. CPI L1/L2 cache hierarchyworks because 1) L2 is on-chip with 5-30ns latency. L1 saves nanoseconds, not milliseconds. 2) Cache coherence is handled at hardware level (MESI protocol) - when core writes to L1, hardware broadcasts invalidation to other core’s L1 automatically. This does not transfer here: L1 is an LRU per Skipper pod - there can be n pods, each with own private LRU. No hardware coherence protocol Valkey round trip is ~0.5-1ms over local cluster network - latency is higher than L1 cache but problem being solved is not sub-ms latency but rather cross-pod cache sharing. Primary goal: cross-pod consistencyWithout L2, every pod maintains an independent LRU. A cold miss on pod A fetches from origin, pod B's LRU is empty and fetches independently. Under load (e.g. a popular campaign going live), this produces an N-way thundering herd - one upstream fetch per pod, not one per cluster. Valkey solves this because the consistent-hash ring maps a given cache key to the same shard regardless of which pod is making the request. A cold-miss coalesced by pod A writes to Valkey shard S. Pod B's next request for the same key hits shard S directly - no upstream fetch. Why warming L1 on write undermines this goalIf L1 is warmed on a successful Valkey
The tradeoff is: faster reads on the hot path vs. stale content served after invalidation, with non-trivial invalidation infrastructure. Why Valkey-miss does not consult L1A Valkey miss ( L1 is only consulted when Valkey returns an error, because in that case we have no authoritative answer. Serving a potentially-stale L1 entry is preferable to a 5xx. Considered alternativesWrite-through with TTL-bounded stalenessWarm L1 on every successful Not chosen because explicit Write-through + Valkey pub/sub invalidationWarm L1, subscribe each pod to a Valkey pub/sub channel for invalidation events. This matches the CPU L1/L2 mental model most closely. Not chosen for this PR. The complexity cost is high (subscribe lifecycle, reconnect handling, message delivery guarantees, lag-bounded staleness), and the latency benefit does not yet justify it. This is the natural next step if Valkey read latency becomes a bottleneck. |
We could have L1 entry TTL of a fixed acceptable amount of time. |
Good point. tokeninfo data is a strong precedent. The write-around choice was conservative: L1 and Valkey TTLs are independent, and if a Valkey entry expires or gets evicted, an L1 entry with a longer TTL would silently serve stale content with no signal. The intent was to keep Valkey authoritative for the lifetime of every entry. That said, your proposal sidesteps the problem cleanly.
Trade-off is: cache filter TTL must be meaningfully longer than the L1 TTL for the L1 layer to be useful. For now I will proceed with setting 60s as fixed TTL as a start. |
After every successfull read from L1 you can call EXPIRE to valkey comand to:
You can also consider using GETEX instead of GET if you wan TTLs to be updated on read ops. |
This you can't really do because l2 cache is shared by all skipper instances |
|
@szuecs @a4180p @MustafaSaber thanks a lot for the feedback and patience 🧡 I will review it thoroughly and get back to you with concrete proposal. Currently I am busy with a business critical reliability related topic but I will resume this on Monday 15th June. |
|
Thanks for your patience folks, our 🔥 are out, resuming this today. |
7d1af73 to
c1fb3cd
Compare
|
Update: fixing final failing tests. PR will be ready for review shortly. |
1071146 to
8760752
Compare
|
@szuecs ran all tests locally - all pass locally. Let me know if you have concerns. |
The len(results)==0 check was placed after the error-checking for-loop, making it read as dead code. Reorder to guard-first for clarity: validate non-empty results, then iterate to check for errors from the SET and EXPIRE commands, then return success. Signed-off-by: larry-dalmeida <hello@larrydalmeida.com>
…evalJob Replace the func() closure field on revalJob with a *cacheFilter pointer and call job.filter.doRevalidate(job.key, job.req) directly in the worker. The closure was only binding key and req, which revalJob already carries as fields, making the indirection unnecessary. Also fixes two missing spec.Close() calls in TestCreateFilter_InvalidArgs and TestCacheFilter_SharedStorage_RouteIsolation that caused goroutine leaks. Signed-off-by: larry-dalmeida <hello@larrydalmeida.com>
54f559c to
2b4ed9b
Compare
|
@szuecs @MustafaSaber @a4180p I've updated to reflect the feedback. Please review when you have a moment. FYI - I've tested the L1 cache on staging successfully, today will beta test on production. Will repeat the same with L2 cache included one we've rolled out skipper updates. |
|
@larry-dalmeida can you check the picture in the description if this is up to date? |
| ListenAddr string // Skipper's own listener address (e.g., ":9090") | ||
| NetOpts skpnet.Options // network options for revalidation requests | ||
| ValkeyRing *skpnet.ValkeyRingClient // optional L2 cache backend; nil = LRU only | ||
| L1TTL time.Duration // max TTL to use when warming L1 from Valkey writes |
There was a problem hiding this comment.
If L2 cache is disable this comment is not true. As far as I remember this is also not optional so we should have a better comment to describe the behaviour or drop all the comments.
| // Options configures the cache filter. All fields are required unless stated otherwise. | ||
| type Options struct { | ||
| MaxBytes int64 // in-memory storage budget for the LRU | ||
| ListenAddr string // Skipper's own listener address (e.g., ":9090") |
There was a problem hiding this comment.
here it would be interesting to tell in the comment why do we need this, because it's not obvious for the reader.
| func (s *cacheSpec) Close() { | ||
| select { | ||
| case <-s.lruBytesDone: | ||
| // Already closed; prevent panic on double-close. |
There was a problem hiding this comment.
Right now we have a race condition within this function if 2 goroutines enter the function at the same time, both can enter "default" branch leading to double close panics.
I think you can fix the guard by using a sync.Once and do:
s.once.Do(func() {
..here close all things...
})
There was a problem hiding this comment.
Good catch! will address that.
| defer s.bgWg.Done() | ||
| for job := range s.revalJobs { | ||
| if job.filter != nil { | ||
| job.filter.doRevalidate(job.key, job.req) |
There was a problem hiding this comment.
We should measure here how long the job runs and expose a metric, because if we have let's say 10k filter instances that do revalJobs it likely gets slow, because we depend on the origin to fetch the data and it depends also on the data size. It's unclear to me how it will behave so measuring is likely a must.
There was a problem hiding this comment.
Will add a metric
| key string | ||
| req *http.Request // pre-cloned, safe to use after the originating request ends | ||
| key string | ||
| req *http.Request // pre-cloned, safe to use after the originating request ends |
There was a problem hiding this comment.
Interesting would be in the comment if the Body is also copied or you should not use the Body at all. Maybe it's also never having a Body, because of GET and HEAD only, but what if we have QUERY, that is already specified as RFC?
There was a problem hiding this comment.
I will add support for QUERY, it should be small effort.
| metrics metrics.Metrics | ||
| rfcMode bool | ||
| coldSF singleflight.Group // cold-miss coalescing | ||
| revalSF singleflight.Group // coalesces concurrent background revalidations per key |
There was a problem hiding this comment.
Do you think we need to de-duplicate also in the job runner at spec level?
The problem is that multiple filter instances can create the same reval job even if we guard it with a singleflight.Group here.
There was a problem hiding this comment.
Not needed (cache key includes routeID -> distinct namespaces). But the registry improves it: routes with identical config now share one cacheFilter and same revalSF, automatically deduplicating revalidation.
|
|
||
| // Close is a no-op on individual filter instances; the real Close is on cacheSpec. | ||
| // This exists for test compatibility. | ||
| func (f *cacheFilter) Close() {} |
There was a problem hiding this comment.
TODO(szuecs): check if we need this for tests or if this can be cleaned up
| // revalidationWorker is the single background goroutine (spec-level, shared across | ||
| // all filter instances) that processes revalidation jobs sequentially. It calls | ||
| // the per-instance doRevalidate method to respect each route's configuration. | ||
| func (s *cacheSpec) revalidationWorker() { |
There was a problem hiding this comment.
Do we have a test case that rebuilds the routing tree while having a queue of jobs that is being worked on?
There was a problem hiding this comment.
no, good call. I will add that.
There was a problem hiding this comment.
Added, now verifies spec-level worker continues draining jobs when CreateFilter is called again (rebuild), and registry returns same instance.
| } | ||
| } | ||
|
|
||
| func (s *cacheSpec) CreateFilter(args []interface{}) (filters.Filter, error) { |
There was a problem hiding this comment.
Because we moved the revaljob queue worker to the spec, we need to make sure we do not destroy filter instances and create new ones, because we would loose all state that we have in the cache filter instance.
We have the same problem in fifo and lifo filters, which was solved by creating a separate package and a registry, because we needed more than just that.
I would propose to solve the problem by caching the filter instance in the spec.
type filterCacheKey struct {
// store parsed arguments here
}
type cacheSpec struct {
...
muFilter sync.Mutex
filters map[filterCacheKey]*cacheFilter
}
In CreateFilter you will parse all args and build the filterCacheKey to lookup from spec.filters. If we have a cacheFilter we return it from here. If we do not have one we create a new one and store it in the spec.filters map.
We guard in CreateFilter by spec.muFilter.Lock the whole process from before filters lookup until after we set the new created cacheFilter instance into the spec.filters map.
Interestingly we will make the whole thing more efficient at scale. If you think about 10k routes with the cache filter, we might have 90% duplicated configuration and we will then share the filter instance across all of them, so the revalidation will be also de-duplicated across different routes because we have the same filter instance and use the same singleflight group.
Why this works: https://go.dev/play/p/TWm1WhBMSdo
There was a problem hiding this comment.
Thanks for the examples and comments. I've added filterCacheKey registry to cacheSpec. Routes with identical config now return the same cacheFilter pointer, preserving singleflight state across route updates and deduplicating revalidation across routes.
| } | ||
| s.metrics.IncCounter("valkey_get_fallback") | ||
| log.WithError(err).Warn("cache: valkey Get failed, falling back to L1") | ||
| return s.l1.Get(ctx, key) |
There was a problem hiding this comment.
We checked already in line 49 if we have a l1 cache hit, therefore this will never work or we hope to get a race condition and another call populated the cache.
Is this done by intention?
There was a problem hiding this comment.
yes, intentional - there's a valid race window between the two L1 reads.
Line 49 checks L1 and misses. Line 54 then issues a blocking network call to Valkey. During that round-trip, a concurrent Set call can reach line 97 and warm L1. If Valkey then returns an error (ex: network call fails), line 62 catches the now-warm L1 entry rather than returning a cold miss. Do you think this is overkill?
| } | ||
|
|
||
| func (s *ValkeyStorage) Delete(ctx context.Context, key string) error { | ||
| // ValkeyRingClient exposes no DEL; use EXPIRE key -1 (immediate deletion per Valkey docs). |
There was a problem hiding this comment.
It sounds like a hack, maybe better to expose a DEL command in the ValkeyRingClient.
There was a problem hiding this comment.
Added proper Del() method to ValkeyRingClient. Updated ValkeyStorage.Delete() to use Del instead of EXPIRE key -1 workaround.
Signed-off-by: larry-dalmeida <hello@larrydalmeida.com>
Signed-off-by: larry-dalmeida <hello@larrydalmeida.com>
Signed-off-by: larry-dalmeida <hello@larrydalmeida.com>
Signed-off-by: larry-dalmeida <hello@larrydalmeida.com>
Signed-off-by: larry-dalmeida <hello@larrydalmeida.com>
Signed-off-by: larry-dalmeida <hello@larrydalmeida.com>
…ute updates Routes with identical cache filter configuration now share a single cacheFilter instance, preserving singleflight state (coldSF, revalSF) across route updates. This also deduplicates revalidation across routes with identical configuration. The registry is keyed by (ttl, errorTTL, swrWindow, staleIfError, sorted keyHeaders, rfcMode). keyHeaders are sorted to normalize order-independent configurations like 'X-Foo,X-Bar' vs 'X-Bar,X-Foo'. Filter instances are stored in cacheSpec.filters and accessed under cacheSpec.muFilter lock. Lookup-or-create happens entirely within CreateFilter; no PostProcessor stage is needed since filter config is the complete identity. Fixes PR zalando#4033 comment r3501729935 (instance loss on route update). Signed-off-by: larry-dalmeida <hello@larrydalmeida.com>
Move struct declarations to their conventional position before the functions that use them. This improves code readability without changing behavior. Also add TestCacheSpec_FilterRegistry_InFlightJobsSurviveRebuild to directly verify szuecs' question on comment r3501723301: does the spec-level worker continue draining revalidation jobs when CreateFilter is called again (simulating a route rebuild)? Answer: yes. The worker is independent of CreateFilter calls; it drains revalJobs continuously. The registry ensures the same cacheFilter instance is returned on rebuild, so the job queue remains intact. Signed-off-by: larry-dalmeida <hello@larrydalmeida.com>
Replace the EXPIRE key -1 workaround in ValkeyStorage.Delete() with a proper DEL command. This eliminates the 'hack' comment that szuecs flagged in comment r3501815470. Changes: - Add Del() method to valkeyRing (net/valkey.go) - Add Del() method to ValkeyRingClient (net/valkey.go) - Add Del() to the valkeyClient interface (filters/cache/valkey_storage.go) - Update Delete() to use Del instead of Expire key -1 (filters/cache/valkey_storage.go) - Add Del() to stubValkeyClient test stub (filters/cache/valkey_storage_test.go) This makes the code more idiomatic and removes the complexity of the EXPIRE key -1 semantics workaround. Signed-off-by: larry-dalmeida <hello@larrydalmeida.com>
The upstream metrics.Metrics interface now includes MeasureBackendZone, which was added after this branch diverged. Add a no-op stub to testMetrics to satisfy the interface and fix 'make vet' failures across all test suites. Signed-off-by: larry-dalmeida <hello@larrydalmeida.com>
| By default entries are stored in an in-process LRU (L1) local to each Skipper process. | ||
| When `--swarm-valkey-urls` is configured, Valkey becomes the primary shared | ||
| store (L2) accessible by all Skipper instances via a client-side consistent hash ring. Every | ||
| read checks L1 first; an L1 hit returns without contacting Valkey. |
There was a problem hiding this comment.
I think that contradicts the PR description now.
Please check the description, including the diagramm if it matches implementation.
| The in-process LRU (L1) is shared across all `cache()` filter instances in the same process. | ||
| When Valkey is configured it acts as a shared store (L2). The L1 storage budget is divided evenly | ||
| across 256 internal shards; a single entry larger than one shard's budget is | ||
| dropped with a warning log. |
There was a problem hiding this comment.
I don't understand this part. Why the valkey is mentioned in this note?
| } | ||
|
|
||
| // coalesce gates concurrent cold misses for the same key behind a single upstream | ||
| // fetch, preventing thundering herd. All waiters receive the same response. |
There was a problem hiding this comment.
I think it will be beneficial to mention that thundering herd pervention is limited only to one skipper node.
If you have a fleet of N instances - your backend could receive up to N simultanious requests.
(or you need to implement distributed lock on top of valkey, in that case this comments will be true)
| } | ||
| select { | ||
| case f.revalJobs <- revalJob{key: key, req: cloned}: | ||
| case f.revalJobs <- job: |
There was a problem hiding this comment.
what will happen if this call happens simultaniously with Close?
| var e Entry | ||
| if err := json.Unmarshal([]byte(data), &e); err != nil { | ||
| return nil, fmt.Errorf("cache: decode valkey entry: %w", err) | ||
| } | ||
| return &e, nil |
There was a problem hiding this comment.
pr description says
automatic fallback to the in-process LRU (L1) on any Valkey error
| cloned := orig.Clone(context.Background()) | ||
| var bodySnapshot []byte | ||
| if orig.Method == "QUERY" && orig.Body != nil && orig.Body != http.NoBody { | ||
| bodySnapshot, _ = io.ReadAll(orig.Body) |
| // QUERY carries semantics in its body; include it in the key so different | ||
| // queries to the same URL produce distinct cache entries. | ||
| if r.Method == "QUERY" && r.Body != nil && r.Body != http.NoBody { | ||
| body, err := io.ReadAll(r.Body) |
There was a problem hiding this comment.
if err != nil we don't add body to the key, but continue to process.
is that desired behaviour?
| } | ||
|
|
||
| func (s *ValkeyStorage) Delete(ctx context.Context, key string) error { | ||
| // Valkey errors are best-effort — L1 delete always runs. |
There was a problem hiding this comment.
the problem is that all other skipper instances will not delete the value from l1
| } | ||
|
|
||
| const ( | ||
| redisMetricsPrefix = "swarm.redis." |
There was a problem hiding this comment.
can we move this and similar part in valkey.go to the separate PR to make this one a bit smaller?
| flag.StringVar(&cfg.SwarmStaticOther, "swarm-static-other", "", "set static swarm all nodes, for example 127.0.0.1:9002,127.0.0.1:9003") | ||
|
|
||
| // cache | ||
| flag.DurationVar(&cfg.CacheL1TTL, "cache-l1-ttl", 60*time.Second, "maximum TTL for write-through L1 warming in the cache() filter when Valkey is configured; set to 0 to disable (write-around)") |
There was a problem hiding this comment.
shouldn't be there a --cache-l2-ttl also?
Related Issue
#3991
Description
Extends the
cache()filter with an optional Valkey-backed L2 cache using a client-side consistent hash ring.When
--swarm-valkey-urlsis configured, responses are stored in Valkey (L2) with automatic fallback to the in-process LRU (L1) on any Valkey error.Storage architecture
flowchart TD A[Incoming Request] --> B["Skipper cache() filter"] B --> C{L1 LRUStorage} C -->|l1_hit| S[Entry Served] C -->|miss| D{ValkeyStorage L2} D -->|valkey_miss — key absent| E[Origin Fetch\nContentful CDN / Pegasus] D -->|valkey_get_fallback — error / timeout| C2{L1 LRUStorage fallback} C2 -->|hit| S C2 -->|miss| E E -->|write back: Set warms Valkey + L1<br/>min TTL: cache-l1-ttl vs entry.TTL| SWrite path: successful Valkey
Setwarms L1 withmin(--cache-l1-ttl, entry.TTL)(default 60 s). L1 is also populated on Valkey errors (fallback). Set--cache-l1-ttl=0for write-around.Read path: L1 checked first - hit returns immediately (increments
l1_hit), no Valkey call. L1 miss → Valkey. Valkey miss (nil) → cold miss. Valkey error →valkey_get_fallback, L1 consulted as fallback.Valkey ring topology
flowchart LR subgraph Pods P1[Pod A] P2[Pod B] P3[Pod C] end subgraph Ring["Valkey Hash Ring (client-side)"] direction LR V1[(Shard 0)] V2[(Shard 1)] V3[(Shard 2)] end P1 -- "key → consistent hash → same shard" --> V1 P2 -- "same key → same shard" --> V1 P3 --> V2 P1 --> V3All pods share the same ring, so a cold-miss coalesced by pod A lands in the same shard that pod B would read from - no thundering herd across pods.
Observability
l1_hitvalkey_missvalkey_get_fallbackvalkey_set_fallbacklru_bytesgauge is now updated by a background scraper every 10 s instead of only on eviction, so it stays accurate when capacity is not exceeded.Storage
SetandDeleteerrors are now logged atWarninstead of being silently discarded.