-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontext_cache.go
More file actions
369 lines (347 loc) · 13.1 KB
/
Copy pathcontext_cache.go
File metadata and controls
369 lines (347 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
package router
import (
"encoding/json"
"maps"
"strings"
"sync"
"time"
"github.com/adcontextprotocol/adcp-go/tmproto"
)
// DefaultContextCacheTTL is the router's default cache lifetime for a
// per-provider Context Match response, per spec §Caching. Providers can
// override it via ContextMatchResponse.CacheTTL.
const DefaultContextCacheTTL = 5 * time.Minute
// MaxContextCacheTTL is the schema-enforced ceiling on provider-supplied
// cache_ttl (spec §Caching: "schema-enforced maximum is 86400 seconds").
// The router clamps here as a defense-in-depth in case a future provider
// sends a value that escaped upstream validation.
const MaxContextCacheTTL = 24 * time.Hour
// ContextCacheMetrics is the observability hook for the per-provider
// Context Match cache. Deployments wire this through prommetrics (or
// noop) via WithContextCache. Bounded labels only — providerID is the
// stable configured identifier, not user input.
type ContextCacheMetrics interface {
IncHit(providerID string)
IncMiss(providerID string)
}
// noopContextCacheMetrics is used when the caller does not supply one.
type noopContextCacheMetrics struct{}
func (noopContextCacheMetrics) IncHit(string) {}
func (noopContextCacheMetrics) IncMiss(string) {}
// ContextCache is an in-memory, per-provider cache of Context Match
// responses. Keyed on {property_rid, placement_id, provider_id,
// seller_agent_url, country}.
//
// The spec's recommended cache key at §Caching lists only the first
// three components. The additional seller and country dimensions are
// added because this repository's own targeting engine
// (targeting/engine.go: ActivePackages(ctx, canonicalSeller,
// propertyID, country, placementID, ...)) scopes the active package
// set per seller and per country — different sellers or geos on the
// same placement return different offers. Keying only on placement
// would let one seller's cached offers be served to another seller
// on the same placement during the TTL window, disclosing competitor
// brands, pricing, and creative manifests across tenants.
// seller_agent_url is compared using the AdCP URL canonicalization
// rules (urlcanon.Canonicalize), the same normalization the engine
// applies before its lookup.
//
// Responses are deeply cloned on read so callers can freely mutate
// Offer pointer/slice/map members without corrupting the cached
// entry. (Nested any values inside Signals stay shared — see the
// note on cloneContextResponse.)
//
// Spec cache_ttl semantics (see Put for the enforcement code):
//
// - absent (nil) → router uses its configured default TTL
// - explicit 0 → provider is disabling caching; entry not stored
// - explicit > 0 → override, clamped to MaxContextCacheTTL
//
// The tri-state depends on tmproto.ContextMatchResponse.CacheTTL being
// a pointer type so absent-field is distinguishable from present-zero
// (docs/sdk-typing-policy.md).
//
// The router is stateless and horizontally scaled, so this cache is
// per-instance — restarts clear it and instances behind a load balancer
// each maintain their own view. That matches how the reference agents
// deploy (no shared cache) and keeps the router deployment story simple
// (no Redis dependency).
type ContextCache struct {
mu sync.Mutex
entries map[string]contextCacheEntry
defaultTTL time.Duration
// maxEntries caps the number of live entries; 0 disables the cap.
// When the cap is hit on Put, expired entries are swept; if still
// full, the entry with the oldest insertedAt is evicted.
maxEntries int
metrics ContextCacheMetrics
// now is time.Now in production; tests substitute a clock so
// expiration windows can be exercised without sleeping.
now func() time.Time
}
type contextCacheEntry struct {
response *tmproto.ContextMatchResponse
expiresAt time.Time
insertedAt time.Time
}
// ContextCacheOption configures the cache.
type ContextCacheOption func(*ContextCache)
// WithContextCacheMetrics installs a metrics sink. Without this the
// cache runs with a no-op sink.
func WithContextCacheMetrics(m ContextCacheMetrics) ContextCacheOption {
return func(c *ContextCache) { c.metrics = m }
}
// WithContextCacheMaxEntries caps the number of live entries. Zero or
// negative values disable the cap. On Put once the cap is hit the
// cache sweeps expired entries first, then evicts the oldest insert
// if the cache is still full — bounding memory against a caller that
// varies placement/seller/country to grow the working set forever.
func WithContextCacheMaxEntries(n int) ContextCacheOption {
return func(c *ContextCache) {
if n < 0 {
n = 0
}
c.maxEntries = n
}
}
// NewContextCache builds a cache with the given default TTL applied
// whenever a provider response omits or zeroes cache_ttl. TTLs of zero
// or less collapse to DefaultContextCacheTTL — a caller that truly
// wants caching disabled should skip constructing the cache and not
// wire it into the router (or pass WithContextCache(nil)).
func NewContextCache(defaultTTL time.Duration, opts ...ContextCacheOption) *ContextCache {
if defaultTTL <= 0 {
defaultTTL = DefaultContextCacheTTL
}
c := &ContextCache{
entries: make(map[string]contextCacheEntry),
defaultTTL: defaultTTL,
metrics: noopContextCacheMetrics{},
now: time.Now,
}
for _, o := range opts {
o(c)
}
return c
}
// Get looks up a cached response. Returns (nil, false) on miss or
// expiration; the caller falls back to a live fan-out call. The
// returned response is a defensive copy — callers may overwrite
// RequestID or Signals without corrupting the cached entry.
//
// sellerAgentURL and country participate in the key so a request from
// one seller never returns a response the router cached for another
// seller (see the ContextCache doc for the rationale). Callers should
// pass sellerAgentURL already normalized via urlcanon.Canonicalize —
// the cache does not canonicalize on the hot path.
func (c *ContextCache) Get(propertyRID, placementID, providerID, sellerAgentURL, country string) (*tmproto.ContextMatchResponse, bool) {
if c == nil {
return nil, false
}
key := contextCacheKey(propertyRID, placementID, providerID, sellerAgentURL, country)
c.mu.Lock()
entry, ok := c.entries[key]
if !ok {
c.mu.Unlock()
c.metrics.IncMiss(providerID)
return nil, false
}
if c.now().After(entry.expiresAt) {
delete(c.entries, key)
c.mu.Unlock()
c.metrics.IncMiss(providerID)
return nil, false
}
c.mu.Unlock()
c.metrics.IncHit(providerID)
return cloneContextResponse(entry.response), true
}
// Put stores a response under the spec's canonical cache key. The TTL
// is derived from the response's cache_ttl per spec §Caching:
//
// - cache_ttl absent (nil pointer) → use the cache's configured
// default TTL (5 min out of the box).
// - cache_ttl == 0 → provider is disabling caching
// (e.g. after a targeting-config change). The entry is NOT stored;
// subsequent requests fan out live until the provider raises the
// TTL again.
// - cache_ttl > 0 → override, clamped to
// MaxContextCacheTTL. Clamping happens in seconds first to avoid
// a Duration multiplication overflowing int64 for pathologically
// large values that escaped upstream schema validation.
func (c *ContextCache) Put(propertyRID, placementID, providerID, sellerAgentURL, country string, resp *tmproto.ContextMatchResponse) {
if c == nil || resp == nil {
return
}
ttl := c.defaultTTL
if resp.CacheTTL != nil {
secs := *resp.CacheTTL
switch {
case secs == 0:
// Explicit disable — do not cache.
return
case secs < 0:
// Nonsensical; fall back to the default rather than store
// something with a negative TTL that would collapse to
// already-expired.
default:
maxSecs := int(MaxContextCacheTTL / time.Second)
if secs > maxSecs {
secs = maxSecs
}
ttl = time.Duration(secs) * time.Second
}
}
key := contextCacheKey(propertyRID, placementID, providerID, sellerAgentURL, country)
now := c.now()
c.mu.Lock()
// Bound the map. Only enforce when writing a NEW key — an
// overwrite doesn't grow the set. Sweep expired first (cheap;
// removes stale entries the caller has already forgotten about),
// then evict the oldest insert if still full.
if c.maxEntries > 0 {
if _, existing := c.entries[key]; !existing && len(c.entries) >= c.maxEntries {
c.sweepExpiredLocked(now)
if len(c.entries) >= c.maxEntries {
c.evictOldestLocked()
}
}
}
c.entries[key] = contextCacheEntry{
response: cloneContextResponse(resp),
expiresAt: now.Add(ttl),
insertedAt: now,
}
c.mu.Unlock()
}
// sweepExpiredLocked removes any entry whose TTL has elapsed. Caller
// holds c.mu.
func (c *ContextCache) sweepExpiredLocked(now time.Time) {
for k, e := range c.entries {
if now.After(e.expiresAt) {
delete(c.entries, k)
}
}
}
// evictOldestLocked drops the entry with the oldest insertedAt. O(N)
// but only runs on the cap-hit path, and N is bounded by the
// operator-configured cap. Caller holds c.mu.
func (c *ContextCache) evictOldestLocked() {
var oldestKey string
var oldestAt time.Time
first := true
for k, e := range c.entries {
if first || e.insertedAt.Before(oldestAt) {
oldestKey = k
oldestAt = e.insertedAt
first = false
}
}
if !first {
delete(c.entries, oldestKey)
}
}
// Size returns the number of live entries. Includes entries whose TTL
// has expired but which have not been evicted yet — used mostly by
// tests and operational metrics, not by hot-path logic.
func (c *ContextCache) Size() int {
if c == nil {
return 0
}
c.mu.Lock()
defer c.mu.Unlock()
return len(c.entries)
}
// contextCacheKey assembles the canonical cache key. NUL is used as
// the separator so a component containing "|" or "/" cannot collide
// with it. tmproto request validation rejects control bytes in
// property_rid, placement_id, and seller_agent_url (validateSafeID /
// validateSellerAgentURL); provider_id is bounded by the spec
// (`^[A-Za-z0-9_]+$`, max 64) and only ever populated from
// ProviderSet.ID; country is either empty or an ISO-3166 alpha-2
// pair. No component can carry NUL by construction.
func contextCacheKey(propertyRID, placementID, providerID, sellerAgentURL, country string) string {
var b strings.Builder
b.Grow(len(propertyRID) + len(placementID) + len(providerID) + len(sellerAgentURL) + len(country) + 4)
b.WriteString(propertyRID)
b.WriteByte(0)
b.WriteString(placementID)
b.WriteByte(0)
b.WriteString(providerID)
b.WriteByte(0)
b.WriteString(sellerAgentURL)
b.WriteByte(0)
b.WriteString(country)
return b.String()
}
// cloneContextResponse copies the response deeply enough that the
// merger, and any future seller-agent-stamper or macro-injector code
// path, can freely mutate the returned Offer entries without
// corrupting the cache. tmproto.Offer carries several pointer/slice
// fields that would otherwise be shared:
//
// - SellerAgent, Brand (json.RawMessage — byte slice)
// - CreativeManifest (*json.RawMessage)
// - Price (*OfferPrice)
// - Macros (map[string]string)
//
// The schema doc on tmproto.Offer.SellerAgent explicitly says the
// router MAY stamp that field from a cached package→seller map —
// once that stamp lands, a shallow clone would silently corrupt cache
// entries. Deep-clone here eliminates that failure mode.
//
// Isolation NOT provided for Signals nested values: the top-level
// map[string]any is a fresh allocation, but nested map/slice values
// stay shared with the cached entry. Nothing in the merger mutates
// them today; a general deep-copy of arbitrary any values would need
// a JSON round-trip (types aren't statically knowable). The
// ContextCache docstring calls this out.
func cloneContextResponse(src *tmproto.ContextMatchResponse) *tmproto.ContextMatchResponse {
if src == nil {
return nil
}
dst := *src
// CacheTTL is *int; the shallow struct copy above shares the
// pointer with the cached entry. Give the caller its own
// allocation so `*resp.CacheTTL = 0` on a returned hit cannot
// silently flip the cached entry's disable-caching semantics.
if src.CacheTTL != nil {
v := *src.CacheTTL
dst.CacheTTL = &v
}
if len(src.Offers) > 0 {
dst.Offers = make([]tmproto.Offer, len(src.Offers))
for i := range src.Offers {
dst.Offers[i] = cloneOffer(src.Offers[i])
}
}
if len(src.Signals) > 0 {
dst.Signals = make(map[string]any, len(src.Signals))
maps.Copy(dst.Signals, src.Signals)
}
return &dst
}
// cloneOffer duplicates every pointer/slice/map on Offer so mutation
// through a cache-hit copy cannot reach the cached entry.
func cloneOffer(src tmproto.Offer) tmproto.Offer {
dst := src // scalar fields (PackageID, Summary) copy by value
if src.SellerAgent != nil {
dst.SellerAgent = append(json.RawMessage(nil), src.SellerAgent...)
}
if src.Brand != nil {
dst.Brand = append(json.RawMessage(nil), src.Brand...)
}
if src.Price != nil {
p := *src.Price
dst.Price = &p
}
if src.CreativeManifest != nil {
cm := append(json.RawMessage(nil), *src.CreativeManifest...)
dst.CreativeManifest = &cm
}
if len(src.Macros) > 0 {
dst.Macros = make(map[string]string, len(src.Macros))
maps.Copy(dst.Macros, src.Macros)
}
return dst
}