Skip to content

Fetchers 2.0: Account fetchers - #4895

Open
karwaankit32 wants to merge 5 commits into
prebid:masterfrom
karwaankit32:fetchers-2.0-impl
Open

Fetchers 2.0: Account fetchers#4895
karwaankit32 wants to merge 5 commits into
prebid:masterfrom
karwaankit32:fetchers-2.0-impl

Conversation

@karwaankit32

@karwaankit32 karwaankit32 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Adding support for new fetchers 2.0 Account fetchers

Design - #4860

Validation Summary

Validation was performed in a non-production Kubernetes canary environment using both filesystem-backed and HTTP-backed account sources.

Design Reference

#4860

Fetchers 2.0 caches the final typed *config.Account instead of caching raw JSON bytes. This avoids repeated account defaults merge, JSON unmarshal, DSA unpacking, derived GDPR map creation, and IP masking defaults on account cache hits.

Metrics Used

Metric Meaning
prebid_server_cachekit_cache_result{result="miss",subsystem="account"} Fetchers 2.0 had to load or reload an account
prebid_server_cachekit_cache_result{result="hit",subsystem="account"} Fetchers 2.0 served typed cached account
prebid_server_cachekit_cache_result{result="negative",subsystem="account"} Fetchers 2.0 served cached missing or malformed account verdict
prebid_server_cachekit_backend_fetch{result="ok",subsystem="account"} source returned valid account data
prebid_server_cachekit_backend_fetch{result="notfound",subsystem="account"} source returned definitive missing account
prebid_server_cachekit_backend_fetch{result="error",subsystem="account"} source or transform failed
prebid_server_account_cache_performance{cache_result="hit/miss"} legacy account byte-cache activity

Compatibility and Filesystem Source Tests

Area Configuration Test User Result Metrics / Proof Result
Backward compatibility Fetchers 2.0 unset Known account through /cookie_sync HTTP 200; account config applied no cachekit metrics; legacy cache active Passed
Backward compatibility accounts.v2_enabled=false Known account through /cookie_sync HTTP 200 legacy cache miss then hit; cachekit absent Passed
Filesystem lazy load lru, refresh=ttl, negative off known account twice HTTP 200 both backend ok +1, miss +1, hit +1 Passed
Filesystem TTL reload TTL 30s, serve_stale=false known account, wait past TTL, same account HTTP 200 backend ok increased again; miss increased Passed
Filesystem missing, no negative negative off same missing account twice HTTP 200 with defaults notfound +2, no negative metric Passed
Filesystem negative cache negative on same missing account twice HTTP 200 with defaults first notfound; second negative hit Passed
Filesystem negative TTL negative TTL 10s missing, missing, wait, missing HTTP 200 negative before TTL; backend notfound after TTL Passed
Filesystem no refresh refresh=none known account, wait past TTL, same account HTTP 200 backend ok stayed 1; second call hit Passed
Filesystem no positive cache cache.type=none, negative off valid twice, missing twice HTTP 200 valid backend ok +2; missing notfound +2 Passed
Filesystem no positive cache + negative cache.type=none, negative on missing account twice HTTP 200 first notfound; second negative hit Passed
Filesystem serve stale serve_stale=true warm account, wait TTL, same account HTTP 200 fast stale hit; background backend refresh Passed
Filesystem preload refresh=preload first known account request after restart HTTP 200 preload backend ok; first request hit Passed
Filesystem LRU eviction max_entries=1 account A, account B, account A HTTP 200 backend ok +3; all misses Passed

HTTP Source Tests

Area Configuration Test User Result Metrics / Proof Result
HTTP valid account mock HTTP API, refresh=ttl known account twice HTTP 200 both first backend ok; second hit Passed
HTTP missing account account omitted from response, negative on same missing account twice HTTP 200 with defaults first notfound; second negative hit Passed
HTTP null account account value is null, negative on same account twice HTTP 200 with defaults first notfound; second negative hit Passed
HTTP 404 HTTP 404, negative on same account twice HTTP 200 with defaults first notfound; second negative; mock called once Passed
HTTP 500 HTTP 500 same account three times HTTP 200 with defaults backend error +3; not cached Passed
HTTP bad JSON invalid JSON body same account three times HTTP 200 with defaults backend error +3; not cached Passed
HTTP malformed account invalid account shape same account twice HTTP 400 first backend error; second negative Passed
HTTP bad field type invalid account field type same account twice HTTP 400 first backend error; second negative Passed
HTTP slow account API sleeps 2s then returns valid account same account twice first slow, second fast backend ok +1; cache hit +1 Passed
HTTP unavailable endpoint endpoint on closed port same account twice timeout/no body backend error +2; not cached Passed
HTTP-only preload refresh=preload, HTTP source only startup then known account startup OK with warning; request OK no real preload; lazy load worked Passed

Endpoint Coverage

Endpoint Account Source Test User Result Metrics / Proof Result
/cookie_sync filesystem known account with account-level cookie sync config HTTP 200 v2 metrics moved; legacy cache zero Passed
/cookie_sync HTTP known account from mock HTTP API HTTP 200 v2 metrics moved; legacy cache zero Passed
/openrtb2/auction filesystem auction with parent account HTTP 200 with seatbid v2 backend ok/miss; legacy cache zero Passed
/openrtb2/auction HTTP auction with parent account from mock API HTTP 200 with seatbid v2 backend ok/miss; legacy cache zero Passed

Stress and Thundering Herd Tests

Stress Area Configuration Scale Expected Proof Result
Invalid ID memory bound negative max entries 100 1000 unique invalid IDs per pod memory stays bounded; older negatives evict memory stayed around 184-188 Mi; old ID queried backend again Passed
Valid account coalescing same cold valid account 100 parallel requests one backend fetch backend ok +1, cache hit +99 Passed
Invalid account coalescing same cold invalid account 100 parallel requests one backend notfound backend notfound +1, negative +99 Passed
HTTP slow valid herd mock API sleeps 2s 1000 parallel requests one upstream call MOCK_SLOW_DELTA=1, backend ok +1 Passed
HTTP missing herd same missing account 1000 parallel requests one upstream call, rest negative MOCK_ABSENT_DELTA=1, negative +997 Passed
Serve stale protection warm cache, backend slow 500, serve_stale=true request after TTL user gets stale account fast user returned in ~0.5ms; backend error later Passed

Notes

Topic Observation
HTTP-only preload Does not warm accounts because the HTTP account endpoint is by-ID only; startup logs warning and lazy-loads
Cold cache + dead HTTP endpoint Request waits for backend timeout, then falls back to account defaults
Warm cache + serve_stale=true Request returns last good typed account immediately; failed refresh happens in background
HTTP 404 Now classified as notfound so it can be negative-cached
Legacy cache bypass During Fetchers 2.0 enabled tests, prebid_server_account_cache_performance stayed zero

// contributes nothing to bulk cache preloading and reports no error. Accounts still
// load lazily on demand via FetchAccount.
func (fetcher *HttpFetcher) FetchAllAccounts(ctx context.Context) (map[string]json.RawMessage, []error) {
logger.Warnf("http_fetcher: bulk account preload is not supported by the by-id HTTP endpoint; accounts will load lazily on demand")

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 implement this in a follow up PR.

Comment thread account/cachekit.go Outdated
var preload cachekit.BulkSource[string]
switch cfg.Refresh {
case "", config.RefreshTTL:
// serve-stale via ttl; nothing to preload.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

serve-stale default is documented inconsistently and contradicts the actual behavior.

In stored_requests.go line 93-95 (the Refresh field) it is stated that serve-stale is on by default — "ttl" is described as "(serve-stale + background refresh, the default)" — and line 89-91 (the TTLSeconds field) reinforces it: "the value is still served (stale) while it refreshes, so reads never block on the backend." The same claim is repeated in cachekit.go line 85: "ttl (serve-stale + background revalidation)".

But in stored_requests.go line 100-104 (the ServeStale field) it says serve-stale is off by default — "Opt-in; defaults to off, which expires the entry and reloads it synchronously on the next read (classic TTL cache)."

The code confirms the "off" version is what actually runs: serve-stale is gated solely by f.serveStale in cachekit.go line 128-140, and cachekit.go line 86-109 never sets ServeStale for refresh: ttl — it only adjusts effectiveTTL/preload. The test TestGetExpiresAndReloadsByDefault in cachekit_test.go line 109-126 proves it: "Past TTL with serve-stale off: the read reloads synchronously."

So we need to fix this because the two field comments describe opposite defaults, and the one that operators will read to enable refresh: ttl (lines 89-95) promises a non-blocking stale read that the default does not deliver — under load, past TTL the read blocks on a synchronous backend reload, which is a surprising latency cliff. Either make RefreshTTL actually enable serve-stale (set serveStale = true for the ttl/preload cases in NewCacheKitAccountFetcher), or correct the comments at lines 89-95 and cachekit.go line 85 to state that ttl is a classic blocking-reload cache and serve-stale requires serve_stale: true.

Suggested fix (align behavior to the docs) in NewCacheKitAccountFetcher:



effectiveTTL := cfg.TTL()
serveStale := cfg.ServeStale
switch cfg.Refresh {
case "", config.RefreshTTL:
    serveStale = true // ttl mode == stale-while-revalidate, per its documented contract
case config.RefreshNone:
    effectiveTTL = 0
case config.RefreshPreload:
    serveStale = true
    // ...preload wiring...
}
// pass ServeStale: serveStale into cachekit.New(...)

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 catching this, serveStale enabled by default

Comment thread cachekit/revalidate.go
// negative-cached); on any error (transient or a newly-malformed value) the last
// good value keeps being served and a backoff is recorded.
func (f *Fetcher[K, V]) revalidate(ctx context.Context, key K) {
start := f.clock.Now()

@anishb-ms anishb-ms Aug 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Background revalidation runs with context.Background() and no deadline. If source.Fetch hangs, finish is never called, the per-key inFlight slot stays claimed forever, and that key can never revalidate again (it keeps serving stale under serve-stale). We're implicitly relying on the HTTP client having its own timeout.

Suggested fix — add a bounded context (ideally operator-configurable via CacheKitConfig):

func (f *Fetcher[K, V]) revalidate(ctx context.Context, key K) {
    ctx, cancel := context.WithTimeout(ctx, f.revalTimeout) // e.g. default 10s
    defer cancel()
    ...
}

With a deadline, a wedged backend hits the timeout → finish(key, true) → the slot releases after revalidateBackoff, so the key recovers instead of being pinned.

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 catching this. Added a timeout post which the marker is cleared so that it can be retried again later

Comment thread cachekit/revalidate.go
st.inFlight = true
r.state[key] = st
return true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

revalidator.state grows with one-off failures:

On failure the key is retained (revalidate.go:39-47); it's only removed on a later successful finish. A key that fails once and is never requested again lingers. Bounded for accounts, but the package targets larger key spaces (GVL/stored data). Minimal opportunistic cleanup — drop an expired-backoff entry when we next see it, and note the intent:

func (r *revalidator[K]) begin(key K) bool {
    r.mu.Lock()
    defer r.mu.Unlock()
    st, ok := r.state[key]
    if ok && !st.inFlight && !st.failedAt.IsZero() &&
        !r.clock.Now().Before(st.failedAt.Add(r.backoff)) {
        // Backoff elapsed; forget the stale failure record before re-claiming so
        // one-off failures for keys that are never revisited don't accumulate.
        delete(r.state, key)
        st = revalState{}
    }
    if st.inFlight || (!st.failedAt.IsZero() && r.clock.Now().Before(st.failedAt.Add(r.backoff))) {
        return false
    }
    st.inFlight = true
    r.state[key] = st
    return true
}

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.

Addressed with opportunistic pruning in begin(): before admitting a new revalidation, we remove any non-in-flight failure records whose backoff has elapsed, so one-off failed keys don’t linger indefinitely.

@bsardo bsardo changed the title Adding support for new fetchers 2.0 Account fetchers Fetchers 2.0: Account fetchers Aug 18, 2026
Comment thread cachekit/cache.go
@@ -0,0 +1,74 @@
package cachekit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Curious why you've chosen the name cachekit. I recommend the name fetcher instead for better discoverability.

Comment thread cachekit/cachekit.go
@@ -0,0 +1,213 @@
// Package cachekit is a small, generic read-through fetching engine shared by
// Prebid Server subsystems (accounts today; GVL / stored data / currency later).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This doesn't read cleanly. We don't typically refer to different parts of the app as subsystems - perhaps packages. This also references a current state of implementation (accounts today, .. later) which will quickly become stale.

Recommend adjusting the wording and moving this out to a readme.md file in the package instead of a comment here for easier discoverability.

Comment thread cachekit/cache.go
// NoCache is a pass-through cache: every Get is a miss and Save is a no-op. Paired
// with the engine's single-flight coalescing it yields "always fetch, still
// deduplicate" behaviour for direct-source / live tenants.
type NoCache[K comparable, V any] struct{}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nitpick: Please name NilCache to keep with current naming structure.

Comment thread cachekit/cache.go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please break out each cache (LRUCache an NilCache) into it's own file. A cache folder like we have for Fetcher 1.0 (stored_requests) could work nicely.

Comment thread cachekit/cache.go
}

// NewLRUCache builds an LRU cache holding up to maxEntries values.
func NewLRUCache[K comparable, V any](maxEntries int, clk clock.Clock) (*LRUCache[K, V], error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you use the timeutil.Time interface used throughout this repo instead of clock.Clock?

Comment thread cachekit/cachekit.go
group singleflight.Group
reval *revalidator[K]
revalTimeout time.Duration
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is very close to the ideal Fetcher definition I envisioned. I'm ok with two mutually exclusive sources depending on type configuration, but is there a way to combine them into a single Source which can either be preload/refresh or per item?

I think ttl belongs to the Cache and NegativeStore is either a detail of the Cache or a wrap layer around a Cache. I don't understand what serveStale is doing. Clock (please use timeutil instead) is a detail of the Cache and Refresh job on top of the BulkSource.

Consider renaming reval -> validator as reval can be confused with reveal. Why is there a revalTimeout?

What is a group?

Comment thread cachekit/cachekit.go
p.Clock = clock.New()
}
if p.Metrics == nil {
p.Metrics = noopRecorder{}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why does it default to real clock but a no-op metrics? Consider making these required.

Comment thread cachekit/cachekit.go
if p.Metrics == nil {
p.Metrics = noopRecorder{}
}
if p.RevalidateTimeout <= 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't understand based on the name what a RevalidateTimeout controls.

Comment thread cachekit/cachekit.go
}
f.cache.Save(key, v, f.ttl)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please add context to the metrics that is operation is for the Start operation.

Comment thread cachekit/cachekit.go
for key, bytes := range raw {
v, err := f.transform(key, bytes)
if err != nil {
continue // skip malformed entries; they surface on demand

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How do they surface? A transform failure is a big problem.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants