Skip to content

feat: L2 Cache with Valkey Client Side Hash Ring#4033

Open
larry-dalmeida wants to merge 40 commits into
zalando:masterfrom
larry-dalmeida:feat/cache-client-side-valkey-hash-ring
Open

feat: L2 Cache with Valkey Client Side Hash Ring#4033
larry-dalmeida wants to merge 40 commits into
zalando:masterfrom
larry-dalmeida:feat/cache-client-side-valkey-hash-ring

Conversation

@larry-dalmeida

@larry-dalmeida larry-dalmeida commented May 26, 2026

Copy link
Copy Markdown
Contributor

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-urls is 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| S
Loading

Write path: successful Valkey Set warms L1 with min(--cache-l1-ttl, entry.TTL) (default 60 s). L1 is also populated on Valkey errors (fallback). Set --cache-l1-ttl=0 for 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 --> V3
Loading

All 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

Counter Meaning
l1_hit L1 returned a warm entry; Valkey not consulted
valkey_miss Key absent in Valkey (clean miss)
valkey_get_fallback Valkey error on Get; L1 consulted
valkey_set_fallback Valkey error on Set; L1 written

lru_bytes gauge 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 Set and Delete errors are now logged at Warn instead of being silently discarded.

@larry-dalmeida
larry-dalmeida force-pushed the feat/cache-client-side-valkey-hash-ring branch 2 times, most recently from 7047126 to 573ebdf Compare May 27, 2026 04:40
@larry-dalmeida larry-dalmeida changed the title Add L2 Cache with Valkey Client Side Hash Ring feat: Add L2 Cache with Valkey Client Side Hash Ring May 27, 2026
@larry-dalmeida larry-dalmeida changed the title feat: Add L2 Cache with Valkey Client Side Hash Ring feat: L2 Cache with Valkey Client Side Hash Ring May 27, 2026
@larry-dalmeida
larry-dalmeida force-pushed the feat/cache-client-side-valkey-hash-ring branch from 55ba444 to c73eb72 Compare May 27, 2026 09:55
@larry-dalmeida
larry-dalmeida marked this pull request as ready for review May 28, 2026 07:56
@szuecs szuecs added the architectural all changes in the hot path, big changes in the control plane, control flow changes in filters label May 29, 2026
Comment thread skipper.go Outdated
// 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. :)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please merge larry-dalmeida#2 , if you agree that this makes sense

@szuecs

szuecs commented Jun 1, 2026

Copy link
Copy Markdown
Member

👍

@szuecs
szuecs requested review from MustafaSaber and a4180p June 1, 2026 20:02
@larry-dalmeida

Copy link
Copy Markdown
Contributor Author

👍

@a4180p

a4180p commented Jun 2, 2026

Copy link
Copy Markdown
Member

Write path: successful Valkey Set does not warm L1 (write-around). L1 is only populated on Valkey errors.
Read path: Valkey miss → nil (clean miss, no L1 consulted). Valkey error → L1 consulted as fallback.

That actually implies that l1 cache will be empty in case if Set was successfull and only Get fails.
Also, this model means that cache will be slower - there is no fast l1 layer, positive path always reads and writes from/to network storage.
Is that desired behaviour?
I would expect a small write/read-through L1 cache to serve as a fast local layer for the common case, but I may be missing some context around the intended use case.

@szuecs

szuecs commented Jun 2, 2026

Copy link
Copy Markdown
Member

Write path: successful Valkey Set does not warm L1 (write-around). L1 is only populated on Valkey errors.
Read path: Valkey miss → nil (clean miss, no L1 consulted). Valkey error → L1 consulted as fallback.

That actually implies that l1 cache will be empty in case if Set was successful and only Get fails. Also, this model means that cache will be slower - there is no fast l1 layer, positive path always reads and writes from/to network storage. Is that desired behaviour? I would expect a small write/read-through L1 cache to serve as a fast local layer for the common case, but I may be missing some context around the intended use case.

Good point, I wondered about this and missed to ask the question.
Normally (for example CPU) you would check L1 cache and if you get a cache miss, you check L2 cache and if you have a hit you will save in L1 and respond.

Comment thread skipper.go Outdated
@larry-dalmeida

larry-dalmeida commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

@a4180p @szuecs @MustafaSaber

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 hierarchy

works 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 consistency

Without 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 goal

If L1 is warmed on a successful Valkey Set (write-through):

  1. A pod serves future requests from its local LRU, bypassing Valkey.
  2. Valkey Delete or TTL-based expiry does not reach L1 - the pod continues serving stale content until L1's own TTL expires.
  3. There is no invalidation channel. Adding one (e.g. Valkey pub/sub) would require every pod to subscribe, handle reconnects, and accept a bounded staleness window on subscriber lag.

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 L1

A Valkey miss (nil, no error) means the key is genuinely absent - no pod has fetched and stored it yet, or the TTL expired. Consulting L1 on a clean miss would serve stale content beyond the intended TTL. The filter must go to origin.

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 alternatives

Write-through with TTL-bounded staleness

Warm L1 on every successful Set, accept that L1 entries can be served up to their TTL after a Valkey Delete. For content that is never explicitly invalidated (only TTL-expired), this is semantically equivalent to the current design - and would reduce Valkey read load.

Not chosen because explicit Delete is on the roadmap (cache invalidation on content publish events). Once that lands, write-through without an invalidation channel produces observable stale responses.

Write-through + Valkey pub/sub invalidation

Warm 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.

@szuecs

szuecs commented Jun 2, 2026

Copy link
Copy Markdown
Member

Considered alternatives

Write-through with TTL-bounded staleness

Warm L1 on every successful Set, accept that L1 entries can be served up to their TTL after a Valkey Delete. For content that is never explicitly invalidated (only TTL-expired), this is semantically equivalent to the current design - and would reduce Valkey read load.

Not chosen because explicit Delete is on the roadmap (cache invalidation on content publish events). Once that lands, write-through without an invalidation channel produces observable stale responses.

We could have L1 entry TTL of a fixed acceptable amount of time.
We have for example token result caching in tokeninfo filters set to 60s and it offloaded 50% of total CPU time and latency dropped from 10-20ms to <1ms.
Given our experience I would be in favour of having a fixed TTL of 60s for L1 cache in case there is an L2 cache setup configured.

@larry-dalmeida

larry-dalmeida commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Given our experience I would be in favour of having a fixed TTL of 60s for L1 cache in case there is an L2 cache setup configured.

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.
A fixed short TTL (e.g. 60 s) on L1 should result in:

  • staleness is bounded and operator-visible - it's a deliberate configuration choice, not a silent race
  • the Delete path already calls l1.Delete unconditionally, so explicit invalidations would propagate correctly
  • no invalidation channel needed

Trade-off is: cache filter TTL must be meaningfully longer than the L1 TTL for the L1 layer to be useful.
For short-lived entries (< 60 s) the L1 TTL would need to be proportionally smaller, so it is likely to be configurable rather than hard-coded.

For now I will proceed with setting 60s as fixed TTL as a start.

@larry-dalmeida
larry-dalmeida marked this pull request as draft June 2, 2026 14:21
@a4180p

a4180p commented Jun 2, 2026

Copy link
Copy Markdown
Member

@larry-dalmeida

The intent was to keep Valkey authoritative for the lifetime of every entry.

After every successfull read from L1 you can call EXPIRE to valkey comand to:

  • prolongate TTL of the record if it exists
  • get a signal if it not exists and cleanup the record in L1

You can also consider using GETEX instead of GET if you wan TTLs to be updated on read ops.

@szuecs

szuecs commented Jun 2, 2026

Copy link
Copy Markdown
Member

@larry-dalmeida

The intent was to keep Valkey authoritative for the lifetime of every entry.

After every successfull read from L1 you can call EXPIRE to valkey comand to:

This you can't really do because l2 cache is shared by all skipper instances
In the rate limit cases we set Expiry time on create entry. I think it's the way to go for expiration by time.

@larry-dalmeida

larry-dalmeida commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

@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.

@larry-dalmeida

Copy link
Copy Markdown
Contributor Author

Thanks for your patience folks, our 🔥 are out, resuming this today.

@larry-dalmeida
larry-dalmeida force-pushed the feat/cache-client-side-valkey-hash-ring branch 2 times, most recently from 7d1af73 to c1fb3cd Compare June 18, 2026 07:19
@larry-dalmeida

Copy link
Copy Markdown
Contributor Author

Update: fixing final failing tests. PR will be ready for review shortly.
@MustafaSaber @a4180p @szuecs

@larry-dalmeida
larry-dalmeida force-pushed the feat/cache-client-side-valkey-hash-ring branch from 1071146 to 8760752 Compare June 22, 2026 06:52
@larry-dalmeida
larry-dalmeida marked this pull request as ready for review June 22, 2026 11:55
@larry-dalmeida

Copy link
Copy Markdown
Contributor Author

@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>
@larry-dalmeida
larry-dalmeida force-pushed the feat/cache-client-side-valkey-hash-ring branch from 54f559c to 2b4ed9b Compare June 30, 2026 03:54
@larry-dalmeida
larry-dalmeida marked this pull request as ready for review June 30, 2026 04:29
@larry-dalmeida

Copy link
Copy Markdown
Contributor Author

@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.

@szuecs

szuecs commented Jun 30, 2026

Copy link
Copy Markdown
Member

@larry-dalmeida can you check the picture in the description if this is up to date?
I think the l2 cache miss and then fallback to l1 makes no sense if we have already checked l1 first.
So on l2 cache miss we should populate all caches with the value.

Comment thread filters/cache/filter.go Outdated
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread filters/cache/filter.go Outdated
// 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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here it would be interesting to tell in the comment why do we need this, because it's not obvious for the reader.

Comment thread filters/cache/filter.go Outdated
func (s *cacheSpec) Close() {
select {
case <-s.lruBytesDone:
// Already closed; prevent panic on double-close.

@szuecs szuecs Jun 30, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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...
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! will address that.

Comment thread filters/cache/filter.go Outdated
defer s.bgWg.Done()
for job := range s.revalJobs {
if job.filter != nil {
job.filter.doRevalidate(job.key, job.req)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will add a metric

Comment thread filters/cache/filter.go Outdated
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add support for QUERY, it should be small effort.

Comment thread filters/cache/filter.go
metrics metrics.Metrics
rfcMode bool
coldSF singleflight.Group // cold-miss coalescing
revalSF singleflight.Group // coalesces concurrent background revalidations per key

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@larry-dalmeida larry-dalmeida Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread filters/cache/filter.go

// Close is a no-op on individual filter instances; the real Close is on cacheSpec.
// This exists for test compatibility.
func (f *cacheFilter) Close() {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO(szuecs): check if we need this for tests or if this can be cleaned up

Comment thread filters/cache/filter.go
// 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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a test case that rebuilds the routing tree while having a queue of jobs that is being worked on?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, good call. I will add that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, now verifies spec-level worker continues draining jobs when CreateFilter is called again (rebuild), and registry returns same instance.

Comment thread filters/cache/filter.go
}
}

func (s *cacheSpec) CreateFilter(args []interface{}) (filters.Filter, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@larry-dalmeida larry-dalmeida Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread filters/cache/valkey_storage.go Outdated
}

func (s *ValkeyStorage) Delete(ctx context.Context, key string) error {
// ValkeyRingClient exposes no DEL; use EXPIRE key -1 (immediate deletion per Valkey docs).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds like a hack, maybe better to expose a DEL command in the ValkeyRingClient.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@larry-dalmeida
larry-dalmeida marked this pull request as draft July 3, 2026 05:56
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>
@larry-dalmeida
larry-dalmeida marked this pull request as ready for review July 9, 2026 07:34
Comment on lines +1845 to +1848
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that contradicts the PR description now.
Please check the description, including the diagramm if it matches implementation.

Comment on lines +1860 to +1863
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this part. Why the valkey is mentioned in this note?

Comment thread filters/cache/filter.go
}

// coalesce gates concurrent cold misses for the same key behind a single upstream
// fetch, preventing thundering herd. All waiters receive the same response.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread filters/cache/filter.go
}
select {
case f.revalJobs <- revalJob{key: key, req: cloned}:
case f.revalJobs <- job:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what will happen if this call happens simultaniously with Close?

Comment on lines +65 to +69
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pr description says

automatic fallback to the in-process LRU (L1) on any Valkey error

Comment thread filters/cache/filter.go
cloned := orig.Clone(context.Background())
var bodySnapshot []byte
if orig.Method == "QUERY" && orig.Body != nil && orig.Body != http.NoBody {
bodySnapshot, _ = io.ReadAll(orig.Body)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we ignore error here?

Comment thread filters/cache/filter.go
// 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the problem is that all other skipper instances will not delete the value from l1

Comment thread ratelimit/redis.go
}

const (
redisMetricsPrefix = "swarm.redis."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we move this and similar part in valkey.go to the separate PR to make this one a bit smaller?

Comment thread config/config.go
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)")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't be there a --cache-l2-ttl also?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

architectural all changes in the hot path, big changes in the control plane, control flow changes in filters documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants