Conversation
7047126 to
573ebdf
Compare
55ba444 to
c73eb72
Compare
|
👍 |
|
👍 |
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. |
…onsistency Signed-off-by: Larry D Almeida <hello@larrydalmeida.com>
Fixed - renamed valkey_miss, valkey_get_error, valkey_set_fallback to l2_miss, l2_get_error, l2_set_fallback for consistency. On the write-through question: yes, a successful Set writes to both L2 and L1. |
…cache other than having the expose interface to make sure it has all its needs. Like this everyone can pass their own implementation Signed-off-by: Sandor Szücs <sandor.szuecs@zalando.de>
Signed-off-by: Sandor Szücs <sandor.szuecs@zalando.de>
Signed-off-by: Sandor Szücs <sandor.szuecs@zalando.de>
…ent if not set Signed-off-by: Sandor Szücs <sandor.szuecs@zalando.de>
Signed-off-by: Sandor Szücs <sandor.szuecs@zalando.de>
…ting the real proxy with the filter in a route Signed-off-by: Sandor Szücs <sandor.szuecs@zalando.de>
Signed-off-by: Sandor Szücs <sandor.szuecs@zalando.de>
Signed-off-by: Sandor Szücs <sandor.szuecs@zalando.de>
Signed-off-by: Sandor Szücs <sandor.szuecs@zalando.de>
Signed-off-by: Sandor Szücs <sandor.szuecs@zalando.de>
|
I refactored a bit the code as I saw in my review. I did a bit more refactoring to drop out Valkey from the ./filter/cache package. Valkey is only used in tests, but not required so library users could use whatever they want as L2 storage as long as they implement From my side we are good to go. |
|
👍 |
1 similar comment
|
👍 |
|
Thanks for this contribution! |
|
|
||
| // testMetrics is a minimal metrics.Metrics stub for testing. | ||
| // Only IncCounter does real work; all other methods are no-ops. | ||
| type testMetrics struct { |
There was a problem hiding this comment.
Shouldn't this use mockMetrics?
see subject follow up on #4033 Signed-off-by: Mustafa Abdelrahman <mustafa.abdelrahman@zalando.de>
Related Issue
Follow up of #3991
Description
Extends the cache() filter with an optional Valkey-backed L2 cache using a client-side consistent hash ring. When Valkey is configured (via
--swarm-valkey-urls,--kubernetes-valkey-service-name, or--swarm-valkey-endpoints-remote-urlwith--enable-swarm), responses are stored in Valkey (L2) with the in-process LRU (L1) serving as a look-aside cache in front of Valkey, warmed lazily on Valkey hits and successful stores.On Valkey write errors, the filter falls back transparently to L1. On Valkey read errors, the request is treated as a cache miss and fetched from origin. Valkey delete errors are best-effort - the local L1 entry is always removed, but other Skipper instances in the fleet retain their own L1 copies until each entry's warmed TTL (bounded by
--cache-l1-ttl) expires naturally.Why? Valkey is a network call - it can fail due to timeouts, connection drops, or shard unavailability. The filter is designed to degrade gracefully rather than return errors to the requester. Without Valkey configuration, the filter operates as a pure in-process LRU cache.
Storage architecture
flowchart TD A[Incoming Request] --> B["Skipper cache() filter"] B --> C{L1 LRUStorage} C -->|l1_hit — fresh hit| S[Entry Served] C -->|miss| D{ValkeyStorage L2} C -->|stale hit — no l1_hit| D D -->|l2_hit — key found| S D -->|valkey_miss — key absent| E[Origin Fetch\nContentful CDN / Pegasus] D -->|valkey_get_error — error / timeout| E E -->|write back: Set warms Valkey + L1<br/>min TTL: cache-l1-ttl vs entry.TTL| SWrite path: successful Valkey Set warms L1 with min(--cache-l1-ttl, entry.TTL) (default 60s). Valkey Get hit warms L1 with min(--cache-l1-ttl, remaining freshness). L1 is also populated on Valkey Set errors (fallback). Set --cache-l1-ttl=0 for write-around.
Read path: L1 checked first. Fresh L1 hit → returns immediately (increments
l1_hit), no Valkey call. Stale L1 hit (past TTL but within the stale retention window) → falls through to Valkey without incrementingl1_hit. L1 miss → Valkey. Valkey hit → incrementsl2_hit, warms L1 withmin(--cache-l1-ttl, remaining freshness)when--cache-l1-ttl > 0, returns entry. Valkey miss (nil) → cold miss (incrementsvalkey_miss). Valkey error → incrementsvalkey_get_error, treated as a cold miss.Caching semantics
Each stored entry has three time zones relative to its
CreatedAttimestamp:[0, TTL)[TTL, TTL + StaleWhileRevalidate)must-revalidate,proxy-revalidate,no-cache,s-maxage) bypass this and force a synchronous origin fetch.[TTL + SWR, TTL + max(SIE, SWR))StaleIfError > StaleWhileRevalidate. Origin is always contacted (coalesce); the stale entry is served as a fallback only if origin returns 5xx.Valkey expiry: entries are stored in Valkey for
TTL + max(StaleIfError, StaleWhileRevalidate), ensuring the key outlives both stale windows.L1 TTL: capped to
min(--cache-l1-ttl, entry.TTL)on the write path, andmin(--cache-l1-ttl, remaining freshness)on the read-promotion path. In both cases L1 never holds an entry beyond Valkey's authoritative freshness window for the fresh portion. L1 warming is skipped forTTL=0entries (no-cache / conditional-revalidation-only entries) to avoid polluting L1 with entries that must not be served directly.StaleIfError scope:
StaleIfErroractivates on thecoalescepath — cold misses and SIE-zone re-hits both go throughcoalesce, which snapshots any eligible stale entry before the origin fetch and serves it on 5xx.StaleIfErrordoes not activate on the background revalidation path (doRevalidate): if the background fetch fails with a transport error, the error is logged and the stored entry is left unchanged. If the background fetch returns a 5xx HTTP response, it may be stored (subject toerrorTTL) —StaleIfErrordoes not apply as a fallback on this path. Valkey errors are treated as cache misses —StaleIfErrordoes not protect against Valkey unavailability.L1 staleness and cross-instance consistency:
ValkeyStorage.Getonly returns an L1 entry if it is still fresh (!IsStale). Entries in the stale-while-revalidate window fall through to Valkey, so a fresher copy written by another instance is not bypassed. Entries in the SIE-only zone are returned from L1 (IsStaleis false outside the SWR window), butfilter.gocallscoalescefor them regardless, which re-reads from Valkey when snapshotting the SIE candidate.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 response stored by pod A lands in the same shard that pod B would read from - cross-pod cache sharing is guaranteed by consistent hashing. However, there is no cross-pod thundering herd protection: if N pods miss simultaneously before any one of them completes the fetch and writes to Valkey, all N will forward to origin. This is most likely at cold start, TTL expiry on high-traffic keys, or during Valkey shard failures.
Observability
hitstalemissl1_hitl1_hitandhit)l2_hit--cache-l1-ttl > 0valkey_missvalkey_get_errorvalkey_set_fallbacklru_oversizedlru_evictionstorage_errorcoalesce_errorreval_errorreval_droppedlru_bytesgauge is updated by a background scraper every 10s instead of only on eviction, so it stays accurate when capacity is not exceeded.Storage
SetandDeleteerrors are now logged atWarninstead of being silently discarded.