Skip to content

Commit 21d3218

Browse files
authored
cache: the Store admission/serve sidecar seam (RPZ phase 4 prerequisite) (#601)
* cache: the sidecar seam — policy state beside entries, judged at the byte serve The admission/serve seam RPZ's response-IP phase builds on, policy-free by itself (docs/rpz-design.md §5.6 item 6). Three pieces: - CacheEntry carries an atomic sidecar pointer beside its immutable payload, the same shape as the prefetch claim: opaque policy state a wired evaluator computed from the entry's own stored records. nil is load-bearing — unknown, never clean. - Admission: a single evaluator wired into the Store stamps every entry at every door — the SetFromResponse funnel, the prefetch CAS replacement, and the compatibility Set (the audit found three doors where the design counted on the writer alone). An entry that predates the wiring is evaluated and stamped by the first decoded serve. - Serve: a WireHitGate is consulted before every record-bearing byte serve — the wire-born exact hit, the Msg-born inline byte path, and the composed chase, which shows the gate one sidecar per segment in chain order (a whole-chain verdict on the alias would go stale when the target refreshed under it). A false verdict declines to the decoded path; composite denial classes carry no records and are not gated. Wired through Setup's marker pattern (SidecarPolicyProvider -> SidecarPolicySetter), first provider wins. Unwired, the seam is one nil field check per hit: the existing zero-alloc hit-class pins run unchanged, and every stamp and gate call is mutation-pinned by the seam tests. * review: a three-way verdict — pure judgment, restamp on the decoded serve, counting after the commit Both findings land on the same joint and one contract change closes them: the gate's boolean became a verdict, and accounting left it entirely. - Judge{WireHit,WireChase} return Serve, Decode, or Restamp, and are pure decisions over the sidecars. Decode means policy wants the full message and the sidecar itself was fine; Restamp means the sidecar is unusable — unevaluated, or stamped under a generation the gate no longer accepts. The decoded serve acts on Restamp by re-evaluating the entry's records and CASing over the judged pointer, stale and nil alike, so a policy reload no longer strands live entries on the decoded path until eviction. The verdict is judged once per hit, before the byte/decoded split, internal serves included — the Msg-path chase reaches its segments through internal sub-queries, which is where a stale segment gets its restamp. - Count{WireHit,WireChase} fire once per byte-served hit, after the transport accepted the bytes — past the last point a serve can still fall back to the decoded path (writer readiness, chain mismatch, build, the fallback sentinel) and be counted a second time by the policy writer there. A transport failure after the commit counts, matching the response-counts-as-written parity the serve paths already keep. Every placement is pinned: stale-restamp against a nil-only CAS, fallback-after-approval counting nothing on both the Msg-born and wire-born paths, committed serves counting exactly once on both, and the chase count with its segments. * cache: build the chase sidecar slice only when a gate is wired Moving the segment array out of the gate branch for the commit-time count made it visible to escape analysis through the interface call, so every ungated chase paid a heap allocation the hit-class pins forbid — CI caught it. The slice is now made on the gated branch only and stays in scope for the count; an ungated chase touches nothing. * cache: the chase sidecars travel by value, and a pin holds the gated path to the ungated cost The gated chase still allocated: the slice handed to JudgeWireChase escaped through the interface call, so wiring the gate put a heap allocation on every cache-contained chase hit — the zero-allocation hit contract holds with policy live, not only without it. The chain is now a bounded value type (SidecarChain, capacity asserted against the chase depth at compile time) passed by value through Judge and Count, which closes the slice-escape class in the type system rather than in review. The new pin closes the blind spot the finding named: the allocation CI never wires a gate, so a baseline-relative AllocsPerRun test serves the exact and chase hits against two caches — one bare, one behind an allocation-free Serve gate — and refuses any gated surplus. Verified to fire: an escaping allocation planted on the gated branch reads 3.0 against a 2.0 baseline.
1 parent 5fa514e commit 21d3218

9 files changed

Lines changed: 928 additions & 0 deletions

File tree

middleware/autowire_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,43 @@ func TestPutChain_NilSafe(t *testing.T) {
193193
Setup(&config.Config{})
194194
GlobalPipeline().PutChain(nil) // nil chain must not panic either
195195
}
196+
197+
// sidecarPolicyHandler implements SidecarPolicyProvider.
198+
type sidecarPolicyHandler struct{ n string }
199+
200+
func (h *sidecarPolicyHandler) Name() string { return h.n }
201+
func (h *sidecarPolicyHandler) ServeDNS(ctx context.Context, ch *Chain) { ch.Next(ctx) }
202+
func (h *sidecarPolicyHandler) SidecarEvaluator() SidecarEvaluator {
203+
return func(*dns.Msg) *Sidecar { return nil }
204+
}
205+
func (h *sidecarPolicyHandler) WireHitGate() WireHitGate { return nil }
206+
207+
// sidecarSetterHandler implements SidecarPolicySetter.
208+
type sidecarSetterHandler struct {
209+
n string
210+
got SidecarPolicyProvider
211+
}
212+
213+
func (h *sidecarSetterHandler) Name() string { return h.n }
214+
func (h *sidecarSetterHandler) ServeDNS(ctx context.Context, ch *Chain) { ch.Next(ctx) }
215+
func (h *sidecarSetterHandler) SetSidecarPolicy(p SidecarPolicyProvider) { h.got = p }
216+
217+
// TestAutoWire_SidecarPolicy pins the seam's wiring: the provider is
218+
// discovered and injected into the setter before the pipeline publishes,
219+
// and with no provider registered the setter is never called.
220+
func TestAutoWire_SidecarPolicy(t *testing.T) {
221+
provider := &sidecarPolicyHandler{n: "policy"}
222+
setter := &sidecarSetterHandler{n: "cache"}
223+
p := &Pipeline{handlers: []Handler{provider, setter}}
224+
p.autoWire()
225+
if setter.got != SidecarPolicyProvider(provider) {
226+
t.Fatal("the sidecar policy provider was not wired into the setter")
227+
}
228+
229+
lone := &sidecarSetterHandler{n: "cache"}
230+
p2 := &Pipeline{handlers: []Handler{lone}}
231+
p2.autoWire()
232+
if lone.got != nil {
233+
t.Fatal("a setter was called with no provider registered")
234+
}
235+
}

middleware/cache/cache.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ type Cache struct {
121121
// get a slot is a cache miss, never a client-visible error.
122122
dnssecCryptoLimiter middleware.DNSSECCryptoLimiter
123123

124+
// wireHitGate is the serve half of the sidecar seam: consulted before
125+
// any record-bearing byte serve with the sidecar(s) the reply would be
126+
// built from. Wired at Setup alongside the store's evaluator; nil (no
127+
// policy middleware registered) costs one field check per hit.
128+
wireHitGate middleware.WireHitGate
129+
124130
config CacheConfig
125131
metrics *CacheMetrics
126132

@@ -1324,6 +1330,12 @@ func (c *Cache) serveHitFromWire(
13241330
// lives inside, past the chase's own decline gates.
13251331
return c.serveChaseHit(ctx, ch, entry, capability, leaser, spent)
13261332
}
1333+
gate := c.wireHitGate
1334+
sc := entry.sidecar.Load()
1335+
if gate != nil && gate.JudgeWireHit(sc) != middleware.WireHitServe {
1336+
wireSkipPolicy.Inc()
1337+
return false
1338+
}
13271339
if mismatch := entry.wireChainMismatch(capability); mismatch != nil {
13281340
mismatch.Inc()
13291341
return false
@@ -1349,6 +1361,9 @@ func (c *Cache) serveHitFromWire(
13491361
}
13501362
switch err := leaser.CommitWire(body, info); {
13511363
case err == nil:
1364+
if gate != nil {
1365+
gate.CountWireHit(sc)
1366+
}
13521367
boundRequestToEntryLifetime(ctx, entry)
13531368
c.metrics.Hit()
13541369
wireFastServed.Inc()
@@ -1360,6 +1375,9 @@ func (c *Cache) serveHitFromWire(
13601375
default:
13611376
// Transport-level failure after commit: the bytes left the
13621377
// process; the response counts as written (Msg-path parity).
1378+
if gate != nil {
1379+
gate.CountWireHit(sc)
1380+
}
13631381
boundRequestToEntryLifetime(ctx, entry)
13641382
c.metrics.Hit()
13651383
ch.Cancel()
@@ -1457,6 +1475,24 @@ func (c *Cache) handleCacheHit(
14571475
}
14581476
}
14591477

1478+
// The policy verdict is judged once per hit, before the byte/decoded
1479+
// split, because both halves read it: the byte path serves only on
1480+
// WireHitServe, and the decoded fallback restamps on WireHitRestamp.
1481+
// It is judged for internal serves too — the verdict is a pure
1482+
// function of the stored state, and the Msg-path chase reaches its
1483+
// segments through internal sub-queries, which is where a stale
1484+
// segment gets its restamp.
1485+
policyVerdict := middleware.WireHitServe
1486+
var policySC *middleware.Sidecar
1487+
if g := c.wireHitGate; g != nil {
1488+
policySC = entry.sidecar.Load()
1489+
policyVerdict = g.JudgeWireHit(policySC)
1490+
} else if c.store.sidecarEvaluator != nil && entry.sidecar.Load() == nil {
1491+
// An evaluator without a gate still owes pre-wiring entries
1492+
// their stamp.
1493+
policyVerdict = middleware.WireHitRestamp
1494+
}
1495+
14601496
// Byte fast path: serve the stored wire directly. The entry-local gate
14611497
// runs first, then the writer chain's preflight — both allocation-free
14621498
// and together complete, so a request bound for the Msg path never
@@ -1470,6 +1506,9 @@ func (c *Cache) handleCacheHit(
14701506
if !entry.wireEligibleFor(req) {
14711507
return wireSkipEntry
14721508
}
1509+
if policyVerdict != middleware.WireHitServe {
1510+
return wireSkipPolicy
1511+
}
14731512
ww, ok := w.(middleware.WireWriter)
14741513
if !ok {
14751514
return wireSkipWriter
@@ -1487,6 +1526,9 @@ func (c *Cache) handleCacheHit(
14871526
}
14881527
switch err := ww.WriteWire(body, info); {
14891528
case err == nil:
1529+
if g := c.wireHitGate; g != nil {
1530+
g.CountWireHit(policySC)
1531+
}
14901532
// The answer is out, so bind the request tree to this entry's
14911533
// lifetime. The byte path is normally reached only by external
14921534
// clients, whose responses nothing derives from — but Queryer is
@@ -1510,6 +1552,9 @@ func (c *Cache) handleCacheHit(
15101552
// still gets this entry's answer: it binds like any other
15111553
// terminal outcome. Only a fallback stays unbound, because
15121554
// there the message path binds instead.
1555+
if g := c.wireHitGate; g != nil {
1556+
g.CountWireHit(policySC)
1557+
}
15131558
boundRequestToEntryLifetime(ctx, entry)
15141559
ch.Cancel()
15151560
served = true
@@ -1529,6 +1574,21 @@ func (c *Cache) handleCacheHit(
15291574
return false
15301575
}
15311576

1577+
// The decoded serve is where an unusable sidecar gets replaced: the
1578+
// gate judged Restamp — unevaluated, or stamped under a generation it
1579+
// no longer accepts — and this message, TTL-adjusted but
1580+
// record-identical to the stored truth, before the chase below
1581+
// appends other entries' records, is exactly what the admission
1582+
// evaluator would have seen. The CAS is against the judged pointer,
1583+
// stale or nil alike, so the entry rejoins the byte path instead of
1584+
// decoding until eviction; losing the CAS means someone else already
1585+
// restamped the same records.
1586+
if policyVerdict == middleware.WireHitRestamp {
1587+
if ev := c.store.sidecarEvaluator; ev != nil {
1588+
entry.CompareAndStampSidecar(policySC, ev(msg))
1589+
}
1590+
}
1591+
15321592
// The message materialized, so the entry was live: bind the request tree
15331593
// to its lifetime before the chase below can derive anything from it.
15341594
boundRequestToEntryLifetime(ctx, entry)
@@ -1649,10 +1709,23 @@ func (c *Cache) Set(key uint64, msg *dns.Msg) {
16491709
if ttl > 0 {
16501710
entry.origTTL = uint32(ttl.Seconds())
16511711
}
1712+
c.store.stampSidecar(entry, filtered)
16521713

16531714
c.store.SetEntryWithKey(key, entry, mt)
16541715
}
16551716

1717+
// SetSidecarPolicy wires both halves of the sidecar seam
1718+
// (middleware.SidecarPolicySetter, injected by Setup before the pipeline
1719+
// publishes): the evaluator stamps entries at every admission door, the
1720+
// gate judges record-bearing byte serves.
1721+
func (c *Cache) SetSidecarPolicy(p middleware.SidecarPolicyProvider) {
1722+
if p == nil {
1723+
return
1724+
}
1725+
c.store.SetSidecarEvaluator(p.SidecarEvaluator())
1726+
c.wireHitGate = p.WireHitGate()
1727+
}
1728+
16561729
// (*Cache).Stats stats returns cache statistics.
16571730
func (c *Cache) Stats() map[string]any {
16581731
hits, misses, evictions, prefetches := c.metrics.Stats()

middleware/cache/entry_wire_chase.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ import (
3131
// chase re-resolves and re-admits it — self-healing at the cost of one
3232
// decoded serve.
3333

34+
// The chase depth must fit the sidecar chain the gate receives; a
35+
// negative constant here fails the build.
36+
const _ = uint(middleware.SidecarChainCap - maxWireChaseHops)
37+
3438
const (
3539
// maxWireChaseHops mirrors the Msg-path chase depth.
3640
maxWireChaseHops = 10
@@ -73,6 +77,25 @@ func (c *Cache) serveChaseHit(
7377
return false
7478
}
7579

80+
// The reply would compose every segment's records, so the gate sees
81+
// every segment's sidecar, in chain order — the per-segment principle:
82+
// a whole-chain verdict on the alias would go stale the moment a
83+
// target entry refreshed under it. The chain travels by value: a
84+
// slice here escapes through the interface call and puts a heap
85+
// allocation on every gated chase hit, which the zero-allocation hit
86+
// contract forbids with the gate wired no less than without it.
87+
gate := c.wireHitGate
88+
var chain middleware.SidecarChain
89+
if gate != nil {
90+
for i := range n {
91+
chain.Append(segs[i].entry.sidecar.Load())
92+
}
93+
if gate.JudgeWireChase(chain) != middleware.WireHitServe {
94+
wireSkipPolicy.Inc()
95+
return false
96+
}
97+
}
98+
7699
size := wire.HeaderLen + (ch.Request.WireQuestionEnd() - wire.HeaderLen)
77100
for i := range n {
78101
size += len(segs[i].body) + segs[i].anCount*wireChaseHeadroom
@@ -106,6 +129,9 @@ func (c *Cache) serveChaseHit(
106129

107130
switch err := leaser.CommitWire(body, info); {
108131
case err == nil:
132+
if gate != nil {
133+
gate.CountWireChase(chain)
134+
}
109135
// The reply contains every segment's records, so the request
110136
// inherits every segment's cache-lifetime bound — the wire twin of
111137
// the Msg chase's lineage inheritance.
@@ -120,6 +146,9 @@ func (c *Cache) serveChaseHit(
120146
wireFastFallback.Inc()
121147
return false
122148
default:
149+
if gate != nil {
150+
gate.CountWireChase(chain)
151+
}
123152
for i := range n {
124153
boundRequestToEntryLifetime(ctx, segs[i].entry)
125154
}

middleware/cache/prometheus.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ var (
6161
wireSkipEntry = wireFastPath.Register("skip_entry")
6262
wireSkipWriter = wireFastPath.Register("skip_writer")
6363
wireSkipDNSSEC = wireFastPath.Register("skip_dnssec")
64+
wireSkipPolicy = wireFastPath.Register("skip_policy")
6465
wireSkipSize = wireFastPath.Register("skip_size")
6566
wireSkipBuild = wireFastPath.Register("skip_build")
6667
wireSkipChase = wireFastPath.Register("skip_chase")

0 commit comments

Comments
 (0)