initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
Vendored
+209
@@ -0,0 +1,209 @@
|
||||
// Package cache implements the hot answer cache: a sharded LRU over packed
|
||||
// wire-format DNS messages, backed by a bbolt snapshot so a restart does not
|
||||
// start cold.
|
||||
//
|
||||
// Design notes that matter for performance:
|
||||
//
|
||||
// - Entries are stored as packed wire bytes, not as *dns.Msg. A hit costs
|
||||
// one Unpack and yields a private copy, so serving never mutates shared
|
||||
// state and TTL ageing is free. This is the same trade dnsmasq makes.
|
||||
// - Keys are Go strings built from the already-allocated question name, so
|
||||
// the lookup path does not allocate on a hit.
|
||||
// - The LRU is sharded by name hash: a hot domain only contends with other
|
||||
// names in its own shard.
|
||||
// - Expired-but-useful entries are kept for a serve-stale window so a dead
|
||||
// upstream degrades latency instead of availability.
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Key identifies a cache entry. Scope lets several logically distinct
|
||||
// resolvers share one cache (for example per-upstream-group ACL overrides)
|
||||
// without colliding.
|
||||
type Key struct {
|
||||
Name string
|
||||
Type uint16
|
||||
Class uint16
|
||||
// DO mirrors the request's DNSSEC OK bit. Since we pass AD/DO through
|
||||
// without validating, a DO query legitimately gets a different answer.
|
||||
DO bool
|
||||
// Scope is a small caller-defined namespace, usually a group/ACL key.
|
||||
Scope uint8
|
||||
// CD (checking disabled) is not part of the key: RFC 4035 says a CD
|
||||
// query is answered from a non-CD cached entry, which is what we do.
|
||||
}
|
||||
|
||||
// QueryKey builds a Key from a DNS question.
|
||||
func QueryKey(name string, qtype, qclass uint16, do bool, scope uint8) Key {
|
||||
return Key{
|
||||
Name: canonical(name),
|
||||
Type: qtype,
|
||||
Class: qclass,
|
||||
DO: do,
|
||||
Scope: scope,
|
||||
}
|
||||
}
|
||||
|
||||
// FromMsg builds a Key from the first question of a message.
|
||||
func FromMsg(m *dns.Msg, scope uint8) (Key, bool) {
|
||||
if len(m.Question) == 0 {
|
||||
return Key{}, false
|
||||
}
|
||||
q := m.Question[0]
|
||||
return QueryKey(q.Name, q.Qtype, q.Qclass, Do(m), scope), true
|
||||
}
|
||||
|
||||
// Do reports whether the message carries the DNSSEC OK bit.
|
||||
func Do(m *dns.Msg) bool {
|
||||
if o := m.IsEdns0(); o != nil {
|
||||
return o.Do()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// canonical lowercases and strips the trailing dot of a name so that
|
||||
// "Example.COM." and "example.com" share one entry.
|
||||
func canonical(name string) string {
|
||||
if n := len(name); n > 0 && name[n-1] == '.' {
|
||||
name = name[:n-1]
|
||||
}
|
||||
// A fast path for the overwhelmingly common all-lowercase case keeps this
|
||||
// allocation-free.
|
||||
for i := 0; i < len(name); i++ {
|
||||
c := name[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
return strings.ToLower(name)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// packKey serialises a key for the bbolt snapshot. The wire format is
|
||||
// length-prefixed so it is unambiguous and compact:
|
||||
//
|
||||
// varint len | wire name | uint16 type | uint16 class | uint8 flags
|
||||
func packKey(buf []byte, k Key) ([]byte, error) {
|
||||
name := k.Name
|
||||
if n := len(name); n > 255*4 {
|
||||
return nil, errKeyTooLong
|
||||
}
|
||||
var tmp [256]byte
|
||||
wireLen, err := dns.PackDomainName(name, tmp[:], 0, nil, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf = binary.AppendUvarint(buf, uint64(wireLen))
|
||||
buf = append(buf, tmp[:wireLen]...)
|
||||
buf = binary.LittleEndian.AppendUint16(buf, k.Type)
|
||||
buf = binary.LittleEndian.AppendUint16(buf, k.Class)
|
||||
var flags uint8
|
||||
if k.DO {
|
||||
flags |= 1
|
||||
}
|
||||
flags |= k.Scope << 1
|
||||
return append(buf, flags), nil
|
||||
}
|
||||
|
||||
func unpackKey(buf []byte) (Key, []byte, error) {
|
||||
l, n := binary.Uvarint(buf)
|
||||
if n <= 0 || l > uint64(len(buf)-n) {
|
||||
return Key{}, nil, errBadKey
|
||||
}
|
||||
buf = buf[n:]
|
||||
wire := buf[:int(l)]
|
||||
buf = buf[l:]
|
||||
if len(buf) < 5 {
|
||||
return Key{}, nil, errBadKey
|
||||
}
|
||||
name, _, err := dns.UnpackDomainName(wire, 0)
|
||||
if err != nil {
|
||||
return Key{}, nil, err
|
||||
}
|
||||
var k Key
|
||||
k.Name = canonical(name)
|
||||
k.Type = binary.LittleEndian.Uint16(buf[0:])
|
||||
k.Class = binary.LittleEndian.Uint16(buf[2:])
|
||||
flags := buf[4]
|
||||
k.DO = flags&1 != 0
|
||||
k.Scope = flags >> 1
|
||||
return k, buf[5:], nil
|
||||
}
|
||||
|
||||
// Entry is one cached response plus its timing metadata.
|
||||
//
|
||||
// Wire is the packed response exactly as it will be served (minus ID and TTL
|
||||
// ageing, both applied on unpack). Origin records where the answer came from
|
||||
// for the UI.
|
||||
type Entry struct {
|
||||
Wire []byte
|
||||
// TTL is the value the entry was stored with, used to age the answer on
|
||||
// every serve so clients see a monotonically decreasing TTL.
|
||||
TTL uint32
|
||||
// Age is the logical age at store time (0 for a fresh answer).
|
||||
Age time.Duration
|
||||
// Origin is a small enum: "upstream", "prewarm", "local", "blocked".
|
||||
Origin string
|
||||
// Upstream is the address the answer came from, shown in the UI.
|
||||
Upstream string
|
||||
// CreatedAt is the wall clock when the entry was stored.
|
||||
CreatedAt time.Time
|
||||
// ExpiresAt is when the entry stops being fresh.
|
||||
ExpiresAt time.Time
|
||||
// StaleUntil is when the entry is finally evicted. It is ExpiresAt plus
|
||||
// the serve-stale window.
|
||||
StaleUntil time.Time
|
||||
// Bytes is the accounted size, used by the LRU and the size gauges.
|
||||
Bytes int
|
||||
// Hits counts serves since it was stored.
|
||||
Hits uint32
|
||||
// Neg marks a cached NXDOMAIN or NODATA answer.
|
||||
Neg bool
|
||||
// Truncated records that the upstream answer did not fit in 512 bytes,
|
||||
// so a cache hit must re-truncate for a small client buffer.
|
||||
Truncated bool
|
||||
}
|
||||
|
||||
// AgeTTL returns the TTL an entry should be served with, given the elapsed
|
||||
// time since it was stored, clamped at zero.
|
||||
func (e *Entry) AgeTTL(now time.Time) uint32 {
|
||||
if e.CreatedAt.IsZero() {
|
||||
return e.TTL
|
||||
}
|
||||
elapsed := uint32(now.Sub(e.CreatedAt).Seconds())
|
||||
if elapsed >= e.TTL {
|
||||
return 0
|
||||
}
|
||||
return e.TTL - elapsed
|
||||
}
|
||||
|
||||
// Remaining returns how long the entry stays fresh.
|
||||
func (e *Entry) Remaining(now time.Time) time.Duration {
|
||||
if !e.ExpiresAt.After(now) {
|
||||
return 0
|
||||
}
|
||||
return e.ExpiresAt.Sub(now)
|
||||
}
|
||||
|
||||
// StaleRemaining returns how long the entry remains serveable as stale data.
|
||||
func (e *Entry) StaleRemaining(now time.Time) time.Duration {
|
||||
if !e.StaleUntil.After(now) {
|
||||
return 0
|
||||
}
|
||||
return e.StaleUntil.Sub(now)
|
||||
}
|
||||
|
||||
// Expired reports whether the entry is past its freshness window.
|
||||
func (e *Entry) Expired(now time.Time) bool { return !e.ExpiresAt.After(now) }
|
||||
|
||||
// BlockResponse is a synthesised answer (blocked or locally defined) held in
|
||||
// the cache so repeated blocked queries never touch the matcher or the wire.
|
||||
type BlockResponse struct {
|
||||
Wire []byte
|
||||
TTL uint32
|
||||
}
|
||||
Vendored
+362
@@ -0,0 +1,362 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"hash/maphash"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultShardCount is used when the config does not pick one.
|
||||
const DefaultShardCount = 32
|
||||
|
||||
// LRU is a sharded, size-bounded LRU keyed by Key.
|
||||
//
|
||||
// Each shard owns its own map and intrusive list, so the common case (a
|
||||
// lookup that hits and is moved to the front) takes one shard mutex. The
|
||||
// LRU is *not* strict-LRU globally: evicting a shard's least-recently-used
|
||||
// entry is a fine approximation and keeps contention low. The global entry
|
||||
// budget is divided evenly between shards.
|
||||
type LRU struct {
|
||||
shards []*shard
|
||||
mask uint64
|
||||
|
||||
// perShard is the per-shard entry budget.
|
||||
perShard int
|
||||
|
||||
// counters are the hot-path stats, kept as atomics so Get does not need
|
||||
// the lock just to record a hit.
|
||||
hits atomic.Uint64
|
||||
misses atomic.Uint64
|
||||
staleHits atomic.Uint64
|
||||
inserts atomic.Uint64
|
||||
evictions atomic.Uint64
|
||||
// expired drops entries found already past their stale window.
|
||||
expired atomic.Uint64
|
||||
bytes atomic.Int64
|
||||
entries atomic.Int64
|
||||
|
||||
seed maphash.Seed
|
||||
|
||||
// prefetch delays revalidation without blocking Get. It is a semaphore of
|
||||
// "refreshes in flight" so a burst of stale hits triggers one fetch.
|
||||
prefetchGate chan struct{}
|
||||
}
|
||||
|
||||
type shard struct {
|
||||
mu sync.Mutex
|
||||
items map[Key]*list.Element
|
||||
ll *list.List
|
||||
bytes int
|
||||
}
|
||||
|
||||
type lruItem struct {
|
||||
key Key
|
||||
entry *Entry
|
||||
}
|
||||
|
||||
// NewLRU builds an LRU holding at most size entries across shards shards
|
||||
// (rounded up to a power of two, minimum 1).
|
||||
func NewLRU(size, shards int) *LRU {
|
||||
if shards <= 0 {
|
||||
shards = DefaultShardCount
|
||||
}
|
||||
shards = roundPow2(shards)
|
||||
if size < shards {
|
||||
size = shards
|
||||
}
|
||||
l := &LRU{
|
||||
shards: make([]*shard, shards),
|
||||
mask: uint64(shards - 1),
|
||||
perShard: size / shards,
|
||||
seed: maphash.MakeSeed(),
|
||||
prefetchGate: make(chan struct{}, 64),
|
||||
}
|
||||
if l.perShard < 1 {
|
||||
l.perShard = 1
|
||||
}
|
||||
for i := range l.shards {
|
||||
l.shards[i] = &shard{
|
||||
items: make(map[Key]*list.Element, 1024),
|
||||
ll: list.New(),
|
||||
}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func roundPow2(n int) int {
|
||||
if n <= 1 {
|
||||
return 1
|
||||
}
|
||||
p := 1
|
||||
for p < n {
|
||||
p <<= 1
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (l *LRU) shardFor(k Key) *shard {
|
||||
h := maphash.String(l.seed, k.Name)
|
||||
// Mix the type/scope into the high bits so A and AAAA for one name do not
|
||||
// always land in the same shard.
|
||||
h ^= uint64(k.Type)<<40 | uint64(k.Scope)<<56
|
||||
return l.shards[h&l.mask]
|
||||
}
|
||||
|
||||
// Get returns the entry for k and reports its kind. The returned entry is
|
||||
// shared and must be treated as read-only.
|
||||
//
|
||||
// stale controls whether an expired-but-not-yet-dead entry is returned. When
|
||||
// stale is returned the caller should kick off a refresh (prefetch) and
|
||||
// answer from the stale copy.
|
||||
func (l *LRU) Get(k Key, now time.Time) (e *Entry, kind HitKind) {
|
||||
s := l.shardFor(k)
|
||||
s.mu.Lock()
|
||||
el, ok := s.items[k]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
l.misses.Add(1)
|
||||
return nil, Miss
|
||||
}
|
||||
it := el.Value.(*lruItem)
|
||||
if now.After(it.entry.StaleUntil) {
|
||||
// Fully dead: drop it now so the map does not grow unbounded.
|
||||
s.ll.Remove(el)
|
||||
delete(s.items, k)
|
||||
s.bytes -= it.entry.Bytes
|
||||
s.mu.Unlock()
|
||||
l.bytes.Add(-int64(it.entry.Bytes))
|
||||
l.entries.Add(-1)
|
||||
l.expired.Add(1)
|
||||
l.misses.Add(1)
|
||||
return nil, Miss
|
||||
}
|
||||
s.ll.MoveToFront(el)
|
||||
e = it.entry
|
||||
kind = Fresh
|
||||
if e.Expired(now) {
|
||||
kind = Stale
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
e.Hits++
|
||||
if kind == Fresh {
|
||||
l.hits.Add(1)
|
||||
} else {
|
||||
l.staleHits.Add(1)
|
||||
}
|
||||
return e, kind
|
||||
}
|
||||
|
||||
// Put inserts or replaces an entry, evicting the shard's least-recently-used
|
||||
// entries if the per-shard budget is exceeded.
|
||||
func (l *LRU) Put(k Key, e *Entry) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
s := l.shardFor(k)
|
||||
s.mu.Lock()
|
||||
if el, ok := s.items[k]; ok {
|
||||
it := el.Value.(*lruItem)
|
||||
s.bytes -= it.entry.Bytes
|
||||
l.bytes.Add(-int64(it.entry.Bytes))
|
||||
it.entry = e
|
||||
s.ll.MoveToFront(el)
|
||||
} else {
|
||||
el := s.ll.PushFront(&lruItem{key: k, entry: e})
|
||||
s.items[k] = el
|
||||
l.entries.Add(1)
|
||||
}
|
||||
s.bytes += e.Bytes
|
||||
l.bytes.Add(int64(e.Bytes))
|
||||
var dropped int
|
||||
for s.ll.Len() > l.perShard {
|
||||
oldest := s.ll.Back()
|
||||
if oldest == nil {
|
||||
break
|
||||
}
|
||||
it := oldest.Value.(*lruItem)
|
||||
s.ll.Remove(oldest)
|
||||
delete(s.items, it.key)
|
||||
s.bytes -= it.entry.Bytes
|
||||
l.bytes.Add(-int64(it.entry.Bytes))
|
||||
l.entries.Add(-1)
|
||||
dropped++
|
||||
}
|
||||
// Do not hold the lock while the global counters move: they are
|
||||
// independent atomics, so batching is a pure win.
|
||||
s.mu.Unlock()
|
||||
l.evictions.Add(uint64(dropped))
|
||||
l.inserts.Add(1)
|
||||
}
|
||||
|
||||
// Delete removes one entry.
|
||||
func (l *LRU) Delete(k Key) {
|
||||
s := l.shardFor(k)
|
||||
s.mu.Lock()
|
||||
if el, ok := s.items[k]; ok {
|
||||
it := el.Value.(*lruItem)
|
||||
s.ll.Remove(el)
|
||||
delete(s.items, k)
|
||||
s.bytes -= it.entry.Bytes
|
||||
s.mu.Unlock()
|
||||
l.bytes.Add(-int64(it.entry.Bytes))
|
||||
l.entries.Add(-1)
|
||||
return
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Flush drops everything.
|
||||
func (l *LRU) Flush() {
|
||||
for _, s := range l.shards {
|
||||
s.mu.Lock()
|
||||
n := s.ll.Len()
|
||||
b := s.bytes
|
||||
s.items = make(map[Key]*list.Element, 1024)
|
||||
s.ll.Init()
|
||||
s.bytes = 0
|
||||
s.mu.Unlock()
|
||||
l.entries.Add(-int64(n))
|
||||
l.bytes.Add(-int64(b))
|
||||
}
|
||||
}
|
||||
|
||||
// Walk visits every live entry. fn runs without the shard lock held, so it
|
||||
// must not retain the entry; entries removed meanwhile are simply skipped.
|
||||
// Returning false from fn stops the walk.
|
||||
func (l *LRU) Walk(fn func(Key, *Entry) bool) {
|
||||
for _, s := range l.shards {
|
||||
s.mu.Lock()
|
||||
items := make([]lruItem, 0, s.ll.Len())
|
||||
for el := s.ll.Front(); el != nil; el = el.Next() {
|
||||
it := el.Value.(*lruItem)
|
||||
items = append(items, *it)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for i := range items {
|
||||
if !fn(items[i].key, items[i].entry) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteWhere visits every entry and removes those matching pred.
|
||||
func (l *LRU) DeleteWhere(pred func(Key, *Entry) bool) int {
|
||||
removed := 0
|
||||
for _, s := range l.shards {
|
||||
s.mu.Lock()
|
||||
for el := s.ll.Front(); el != nil; {
|
||||
next := el.Next()
|
||||
it := el.Value.(*lruItem)
|
||||
if pred(it.key, it.entry) {
|
||||
s.ll.Remove(el)
|
||||
delete(s.items, it.key)
|
||||
s.bytes -= it.entry.Bytes
|
||||
l.bytes.Add(-int64(it.entry.Bytes))
|
||||
l.entries.Add(-1)
|
||||
removed++
|
||||
}
|
||||
el = next
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// TryPrefetch acquires a refresh slot without blocking. Callers use it to
|
||||
// make sure a burst of stale hits triggers exactly one background refresh.
|
||||
func (l *LRU) TryPrefetch() bool {
|
||||
select {
|
||||
case l.prefetchGate <- struct{}{}:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ReleasePrefetch returns a slot taken by TryPrefetch.
|
||||
func (l *LRU) ReleasePrefetch() {
|
||||
select {
|
||||
case <-l.prefetchGate:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// HitKind describes how a lookup was satisfied.
|
||||
type HitKind uint8
|
||||
|
||||
const (
|
||||
// Miss means the answer must be resolved.
|
||||
Miss HitKind = iota
|
||||
// Fresh means the entry is inside its TTL.
|
||||
Fresh
|
||||
// Stale means the entry is past its TTL but inside the serve-stale
|
||||
// window: serve it and refresh in the background.
|
||||
Stale
|
||||
)
|
||||
|
||||
func (h HitKind) String() string {
|
||||
switch h {
|
||||
case Fresh:
|
||||
return "hit"
|
||||
case Stale:
|
||||
return "stale"
|
||||
default:
|
||||
return "miss"
|
||||
}
|
||||
}
|
||||
|
||||
// LRUStats is a snapshot of the LRU counters.
|
||||
type LRUStats struct {
|
||||
Entries int `json:"entries"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Hits uint64 `json:"hits"`
|
||||
Misses uint64 `json:"misses"`
|
||||
StaleHits uint64 `json:"stale_hits"`
|
||||
Inserts uint64 `json:"inserts"`
|
||||
Evictions uint64 `json:"evictions"`
|
||||
Expired uint64 `json:"expired"`
|
||||
HitRate float64 `json:"hit_rate"`
|
||||
Shards int `json:"shards"`
|
||||
Capacity int `json:"capacity"`
|
||||
}
|
||||
|
||||
// Stats snapshots the counters.
|
||||
func (l *LRU) Stats() LRUStats {
|
||||
if l == nil {
|
||||
return LRUStats{}
|
||||
}
|
||||
hits := l.hits.Load()
|
||||
misses := l.misses.Load()
|
||||
total := hits + misses
|
||||
var rate float64
|
||||
if total > 0 {
|
||||
rate = float64(hits) / float64(total)
|
||||
}
|
||||
return LRUStats{
|
||||
Entries: int(l.entries.Load()),
|
||||
Bytes: l.bytes.Load(),
|
||||
Hits: hits,
|
||||
Misses: misses,
|
||||
StaleHits: l.staleHits.Load(),
|
||||
Inserts: l.inserts.Load(),
|
||||
Evictions: l.evictions.Load(),
|
||||
Expired: l.expired.Load(),
|
||||
HitRate: rate,
|
||||
Shards: len(l.shards),
|
||||
Capacity: l.perShard * len(l.shards),
|
||||
}
|
||||
}
|
||||
|
||||
// SetCapacity resizes the per-shard budget. Existing entries are kept; the
|
||||
// next insert past the new budget evicts.
|
||||
func (l *LRU) SetCapacity(size, shards int) {
|
||||
if shards <= 0 {
|
||||
shards = len(l.shards)
|
||||
}
|
||||
shards = roundPow2(shards)
|
||||
l.perShard = max(1, size/shards)
|
||||
}
|
||||
Vendored
+596
@@ -0,0 +1,596 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
var (
|
||||
errKeyTooLong = errString("cache: name too long to serialise")
|
||||
errBadKey = errString("cache: corrupt persisted key")
|
||||
)
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
// Options configures a Manager.
|
||||
type Options struct {
|
||||
// Size is the global entry budget for the in-memory LRU.
|
||||
Size int
|
||||
// Shards is the LRU shard count (rounded to a power of two).
|
||||
Shards int
|
||||
// MinTTL and MaxTTL clamp the TTLs taken from upstream answers.
|
||||
MinTTL time.Duration
|
||||
MaxTTL time.Duration
|
||||
// NegativeTTL bounds cached NXDOMAIN/NODATA answers.
|
||||
NegativeTTL time.Duration
|
||||
// ServeStale keeps expired entries answerable for this long.
|
||||
ServeStale time.Duration
|
||||
// Prefetch refreshes stale entries in the background when a hook is set.
|
||||
Prefetch bool
|
||||
// Now is injectable for tests.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Manager is the cache front door: TTL policy, serve-stale, prefetching and
|
||||
// the persisted snapshot all live here, so the DNS handler stays a straight
|
||||
// pipeline.
|
||||
type Manager struct {
|
||||
lru *LRU
|
||||
opt Options
|
||||
now func() time.Time
|
||||
|
||||
// set holds a snapshot-aware view: the LRU plus the persisted store.
|
||||
store *Store
|
||||
|
||||
// prefetchFn is called (off the hot path) when a stale entry is served.
|
||||
// It is set once at wiring time via SetPrefetcher.
|
||||
prefetchFn atomic.Pointer[prefetcher]
|
||||
|
||||
// top holds a rolling per-domain hit counter for the "top cached" UI.
|
||||
top *topDomains
|
||||
|
||||
// Blocked counts blocklist hits served from the matcher, kept here so the
|
||||
// dashboard can show block volume without a second counter package.
|
||||
blockedSince atomic.Uint64
|
||||
|
||||
mu sync.Mutex
|
||||
flushing bool
|
||||
}
|
||||
|
||||
type prefetcher func(Key)
|
||||
|
||||
// NewManager builds a Manager. store may be nil (persistence disabled).
|
||||
func NewManager(opt Options, store *Store) *Manager {
|
||||
if opt.Now == nil {
|
||||
opt.Now = time.Now
|
||||
}
|
||||
if opt.Size <= 0 {
|
||||
opt.Size = 200000
|
||||
}
|
||||
m := &Manager{
|
||||
lru: NewLRU(opt.Size, opt.Shards),
|
||||
opt: opt,
|
||||
now: opt.Now,
|
||||
store: store,
|
||||
top: newTopDomains(5000),
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// SetPrefetcher installs the callback invoked when a stale answer is served.
|
||||
func (m *Manager) SetPrefetcher(fn func(Key)) {
|
||||
if fn == nil {
|
||||
m.prefetchFn.Store(nil)
|
||||
return
|
||||
}
|
||||
m.prefetchFn.Store((*prefetcher)(&fn))
|
||||
}
|
||||
|
||||
// LRU exposes the underlying LRU for stats and tests.
|
||||
func (m *Manager) LRU() *LRU { return m.lru }
|
||||
|
||||
// Now returns the manager's clock.
|
||||
func (m *Manager) Now() time.Time { return m.now() }
|
||||
|
||||
// Result describes a cache hit.
|
||||
type Result struct {
|
||||
// Kind is Fresh, Stale or Miss.
|
||||
Kind HitKind
|
||||
// Msg is a private unpacked copy for Fresh and Stale hits.
|
||||
Msg *dns.Msg
|
||||
// TTL is the TTL the answer was served with (already aged).
|
||||
TTL uint32
|
||||
// Origin and Upstream are carried through from the stored entry.
|
||||
Origin string
|
||||
Upstream string
|
||||
// Age is how long ago the entry was stored.
|
||||
Age time.Duration
|
||||
// ServedStale is set when a stale entry was used; callers may want to
|
||||
// report the degraded state.
|
||||
ServedStale bool
|
||||
}
|
||||
|
||||
// Get looks up a key. On a Fresh hit it returns a private, TTL-aged copy.
|
||||
// On a Stale hit it returns the same plus kicks off a background refresh when
|
||||
// prefetching is enabled.
|
||||
func (m *Manager) Get(k Key) Result {
|
||||
now := m.now()
|
||||
e, kind := m.lru.Get(k, now)
|
||||
if e == nil {
|
||||
return Result{Kind: Miss}
|
||||
}
|
||||
msg, err := e.unpack(k, now)
|
||||
if err != nil {
|
||||
// A corrupt entry poisons the cache for this key; drop it.
|
||||
m.lru.Delete(k)
|
||||
return Result{Kind: Miss}
|
||||
}
|
||||
m.top.observe(k, e)
|
||||
res := Result{
|
||||
Kind: kind,
|
||||
Msg: msg,
|
||||
TTL: e.AgeTTL(now),
|
||||
Origin: e.Origin,
|
||||
Upstream: e.Upstream,
|
||||
Age: now.Sub(e.CreatedAt),
|
||||
ServedStale: kind == Stale,
|
||||
}
|
||||
if kind == Stale {
|
||||
res.TTL = 0
|
||||
if p := m.prefetchFn.Load(); p != nil && m.opt.Prefetch && m.lru.TryPrefetch() {
|
||||
fn := *p
|
||||
go func() {
|
||||
defer m.lru.ReleasePrefetch()
|
||||
fn(k)
|
||||
}()
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// unpack rebuilds a private message from stored wire bytes and ages the TTLs
|
||||
// down to what is left of the entry's lifetime.
|
||||
func (e *Entry) unpack(k Key, now time.Time) (*dns.Msg, error) {
|
||||
msg := new(dns.Msg)
|
||||
if err := msg.Unpack(e.Wire); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remaining := e.AgeTTL(now)
|
||||
ageAll(msg, remaining)
|
||||
// The stored question is the canonical one; trust the key, not the wire.
|
||||
if len(msg.Question) == 0 || !equalName(msg.Question[0].Name, k.Name) {
|
||||
msg.Question = []dns.Question{{
|
||||
Name: dns.Fqdn(k.Name),
|
||||
Qtype: k.Type,
|
||||
Qclass: k.Class,
|
||||
}}
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func equalName(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
if n := len(a); n > 0 && a[n-1] == '.' {
|
||||
a = a[:n-1]
|
||||
}
|
||||
if n := len(b); n > 0 && b[n-1] == '.' {
|
||||
b = b[:n-1]
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
// ageAll sets the TTL of every record in msg to ttl.
|
||||
func ageAll(msg *dns.Msg, ttl uint32) {
|
||||
for i := range msg.Answer {
|
||||
msg.Answer[i].Header().Ttl = ttl
|
||||
}
|
||||
for i := range msg.Ns {
|
||||
msg.Ns[i].Header().Ttl = ttl
|
||||
}
|
||||
for i := range msg.Extra {
|
||||
// OPT records carry no TTL semantics; leave the flags alone.
|
||||
if _, ok := msg.Extra[i].(*dns.OPT); ok {
|
||||
continue
|
||||
}
|
||||
msg.Extra[i].Header().Ttl = ttl
|
||||
}
|
||||
}
|
||||
|
||||
// PutResponse stores an upstream response under k, deriving the TTL from the
|
||||
// answer itself and clamping it to the configured bounds.
|
||||
//
|
||||
// The message is packed once; the stored bytes are independent of the caller's
|
||||
// copy, so the caller keeps ownership of its message.
|
||||
func (m *Manager) PutResponse(k Key, msg *dns.Msg, upstream string) {
|
||||
ttl, neg := m.TTLFor(msg)
|
||||
if ttl <= 0 {
|
||||
// A zero TTL means "do not cache"; respect that rather than pinning an
|
||||
// uncacheable answer in memory.
|
||||
return
|
||||
}
|
||||
wire, err := msg.Pack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.putWire(k, wire, ttl, "upstream", upstream, neg)
|
||||
}
|
||||
|
||||
// PutLocal stores a synthesised answer (local zone, blocklist response).
|
||||
func (m *Manager) PutLocal(k Key, msg *dns.Msg, origin string) {
|
||||
ttl, neg := m.TTLFor(msg)
|
||||
if ttl <= 0 {
|
||||
ttl = 60
|
||||
}
|
||||
wire, err := msg.Pack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.putWire(k, wire, ttl, origin, "", neg)
|
||||
}
|
||||
|
||||
// PutRaw stores pre-packed wire bytes with an explicit TTL. This is the path
|
||||
// used when restoring a snapshot, where packing again would be wasted work.
|
||||
func (m *Manager) PutRaw(k Key, wire []byte, ttl uint32, origin, upstream string, neg bool) {
|
||||
m.putWire(k, wire, ttl, origin, upstream, neg)
|
||||
}
|
||||
|
||||
func (m *Manager) putWire(k Key, wire []byte, ttl uint32, origin, upstream string, neg bool) {
|
||||
if m == nil || len(wire) == 0 || ttl == 0 {
|
||||
return
|
||||
}
|
||||
now := m.now()
|
||||
buf := make([]byte, len(wire))
|
||||
copy(buf, wire)
|
||||
// Record the insert too, not just the hits: a name queried once must show
|
||||
// up in "top cached names", and a miss-only workload has no hits to count.
|
||||
m.top.observe(k, &Entry{TTL: ttl, CreatedAt: now})
|
||||
// Key bytes, the wire body and the entry struct: account for all three so
|
||||
// the LRU budget reflects real memory.
|
||||
e := &Entry{
|
||||
Wire: buf,
|
||||
TTL: ttl,
|
||||
Origin: origin,
|
||||
Upstream: upstream,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(time.Duration(ttl) * time.Second),
|
||||
StaleUntil: now.Add(time.Duration(ttl)*time.Second + m.serveStaleWindow()),
|
||||
Bytes: len(wire) + len(k.Name) + 96,
|
||||
Neg: neg,
|
||||
}
|
||||
m.lru.Put(k, e)
|
||||
}
|
||||
|
||||
func (m *Manager) serveStaleWindow() time.Duration {
|
||||
if m.opt.ServeStale < 0 {
|
||||
return 0
|
||||
}
|
||||
return m.opt.ServeStale
|
||||
}
|
||||
|
||||
// TTLFor derives a cache TTL from a response, honouring the negative-cache
|
||||
// policy. The second return reports whether the answer is a negative one
|
||||
// (NXDOMAIN or NODATA), which the UI and the log show differently.
|
||||
func (m *Manager) TTLFor(msg *dns.Msg) (ttl uint32, neg bool) {
|
||||
if msg == nil {
|
||||
return 0, false
|
||||
}
|
||||
if msg.Truncated {
|
||||
// Never cache a truncated answer: the client will retry over TCP and
|
||||
// we would serve it the truncated form forever.
|
||||
return 0, false
|
||||
}
|
||||
switch msg.Rcode {
|
||||
case dns.RcodeNameError:
|
||||
return m.negativeTTL(msg), true
|
||||
case dns.RcodeSuccess, dns.RcodeNotImplemented:
|
||||
// fall through to the general case
|
||||
default:
|
||||
// SERVFAIL, REFUSED, NOTAUTH and friends are transient by nature.
|
||||
return 0, true
|
||||
}
|
||||
|
||||
if len(msg.Answer) == 0 {
|
||||
// NODATA: the authority section's SOA carries the negative TTL.
|
||||
return m.negativeTTL(msg), true
|
||||
}
|
||||
minTTL := uint32(math.MaxUint32)
|
||||
found := false
|
||||
for _, rr := range msg.Answer {
|
||||
switch rr.(type) {
|
||||
case *dns.OPT:
|
||||
continue
|
||||
}
|
||||
if t := rr.Header().Ttl; t < minTTL {
|
||||
minTTL = t
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return 0, true
|
||||
}
|
||||
return m.clamp(minTTL), false
|
||||
}
|
||||
|
||||
// soaOf finds the zone's start-of-authority record, which is where a
|
||||
// negative answer's TTL comes from (RFC 2308).
|
||||
func soaOf(msg *dns.Msg) *dns.SOA {
|
||||
for _, rr := range msg.Ns {
|
||||
if soa, ok := rr.(*dns.SOA); ok {
|
||||
return soa
|
||||
}
|
||||
}
|
||||
for _, rr := range msg.Answer {
|
||||
if soa, ok := rr.(*dns.SOA); ok {
|
||||
return soa
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) negativeTTL(msg *dns.Msg) uint32 {
|
||||
soa := soaOf(msg)
|
||||
ttl := uint32(0)
|
||||
if soa != nil {
|
||||
ttl = soa.Hdr.Ttl
|
||||
if soa.Minttl < ttl {
|
||||
ttl = soa.Minttl
|
||||
}
|
||||
}
|
||||
if ttl == 0 {
|
||||
// No SOA to go on: fall back to the shortest TTL in the message, then
|
||||
// to the configured default.
|
||||
for _, rr := range msg.Ns {
|
||||
if t := rr.Header().Ttl; t > 0 && t < ttl {
|
||||
ttl = t
|
||||
}
|
||||
}
|
||||
if ttl == 0 {
|
||||
ttl = uint32(m.opt.NegativeTTL / time.Second)
|
||||
}
|
||||
}
|
||||
return m.clamp(ttl)
|
||||
}
|
||||
|
||||
func (m *Manager) clamp(ttl uint32) uint32 {
|
||||
if min := uint32(m.opt.MinTTL / time.Second); min > 0 && ttl < min {
|
||||
ttl = min
|
||||
}
|
||||
if max := uint32(m.opt.MaxTTL / time.Second); max > 0 && ttl > max {
|
||||
ttl = max
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
// Delete drops one key.
|
||||
func (m *Manager) Delete(k Key) { m.lru.Delete(k) }
|
||||
|
||||
// Flush empties the cache and, when persistence is enabled, removes the
|
||||
// snapshot so a restart does not resurrect it.
|
||||
func (m *Manager) Flush() {
|
||||
m.lru.Flush()
|
||||
m.top.reset()
|
||||
if m.store != nil {
|
||||
_ = m.store.Truncate()
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteName drops every entry for a name, across all types. The blocklist API
|
||||
// calls it when an operator edits rules, so a newly blocked name does not keep
|
||||
// serving a cached good answer until it expires.
|
||||
func (m *Manager) DeleteName(name string) int {
|
||||
want := canonical(name)
|
||||
return m.lru.DeleteWhere(func(k Key, _ *Entry) bool { return k.Name == want })
|
||||
}
|
||||
|
||||
// DeleteSubtree drops every entry at or below a name suffix. Used when a
|
||||
// whole blocklist is replaced, so stale good answers for newly-listed domains
|
||||
// disappear immediately.
|
||||
func (m *Manager) DeleteSubtree(suffix string) int {
|
||||
want := canonical(suffix)
|
||||
return m.lru.DeleteWhere(func(k Key, _ *Entry) bool {
|
||||
return k.Name == want || hasSuffix(k.Name, want)
|
||||
})
|
||||
}
|
||||
|
||||
func hasSuffix(name, suffix string) bool {
|
||||
if len(name) < len(suffix)+1 {
|
||||
return false
|
||||
}
|
||||
return name[len(name)-len(suffix)-1:] == "."+suffix
|
||||
}
|
||||
|
||||
// Resize applies a new capacity, e.g. after a config reload.
|
||||
func (m *Manager) Resize(size, shards int) {
|
||||
m.opt.Size = size
|
||||
m.lru.SetCapacity(size, shards)
|
||||
}
|
||||
|
||||
// Options returns the manager's current options.
|
||||
func (m *Manager) Options() Options { return m.opt }
|
||||
|
||||
// TopEntries returns the hottest names in the cache for the UI.
|
||||
func (m *Manager) TopEntries(limit int) []TopEntry {
|
||||
return m.top.top(limit)
|
||||
}
|
||||
|
||||
// Stats is the JSON-friendly cache snapshot.
|
||||
type Stats struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Size int `json:"size"`
|
||||
Entries int `json:"entries"`
|
||||
Shards int `json:"shards"`
|
||||
MinTTLMS int64 `json:"min_ttl_ms"`
|
||||
MaxTTLMS int64 `json:"max_ttl_ms"`
|
||||
ServeStaleM int64 `json:"serve_stale_ms"`
|
||||
NegativeMS int64 `json:"negative_ttl_ms"`
|
||||
HitRate float64 `json:"hit_rate"`
|
||||
Hits uint64 `json:"hits"`
|
||||
Misses uint64 `json:"misses"`
|
||||
StaleHits uint64 `json:"stale_hits"`
|
||||
Inserts uint64 `json:"inserts"`
|
||||
Evictions uint64 `json:"evictions"`
|
||||
Expired uint64 `json:"expired"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
MemoryHuman string `json:"bytes_human"`
|
||||
Persist PersistSt `json:"persisted"`
|
||||
}
|
||||
|
||||
// PersistSt describes the snapshot file.
|
||||
type PersistSt struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Path string `json:"path"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Entries int `json:"entries"`
|
||||
LastSnapshot time.Time `json:"last_snapshot"`
|
||||
Writes uint64 `json:"writes"`
|
||||
Errors uint64 `json:"errors"`
|
||||
Loaded bool `json:"loaded"`
|
||||
}
|
||||
|
||||
// Stats snapshots the manager.
|
||||
func (m *Manager) Stats() Stats {
|
||||
s := m.lru.Stats()
|
||||
st := Stats{
|
||||
Enabled: true,
|
||||
Size: m.opt.Size,
|
||||
Entries: s.Entries,
|
||||
Shards: s.Shards,
|
||||
MinTTLMS: m.opt.MinTTL.Milliseconds(),
|
||||
MaxTTLMS: m.opt.MaxTTL.Milliseconds(),
|
||||
ServeStaleM: m.opt.ServeStale.Milliseconds(),
|
||||
NegativeMS: m.opt.NegativeTTL.Milliseconds(),
|
||||
HitRate: s.HitRate,
|
||||
Hits: s.Hits,
|
||||
Misses: s.Misses,
|
||||
StaleHits: s.StaleHits,
|
||||
Inserts: s.Inserts,
|
||||
Evictions: s.Evictions,
|
||||
Expired: s.Expired,
|
||||
Bytes: s.Bytes,
|
||||
}
|
||||
st.MemoryHuman = HumanBytes(s.Bytes)
|
||||
if m.store != nil {
|
||||
st.Persist = m.store.Stats()
|
||||
} else {
|
||||
st.Persist = PersistSt{}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// HumanBytes formats a byte count the way the UI shows it.
|
||||
func HumanBytes(n int64) string {
|
||||
neg := ""
|
||||
if n < 0 {
|
||||
neg, n = "-", -n
|
||||
}
|
||||
switch {
|
||||
case n < 1024:
|
||||
return neg + strconv.FormatInt(n, 10) + " B"
|
||||
case n < 1024*1024:
|
||||
return neg + strconv.FormatFloat(float64(n)/1024, 'f', 1, 64) + " KiB"
|
||||
case n < 1024*1024*1024:
|
||||
return neg + strconv.FormatFloat(float64(n)/(1024*1024), 'f', 1, 64) + " MiB"
|
||||
default:
|
||||
return neg + strconv.FormatFloat(float64(n)/(1024*1024*1024), 'f', 2, 64) + " GiB"
|
||||
}
|
||||
}
|
||||
|
||||
// topDomains is a bounded, periodic top-N counter. It uses a compact
|
||||
// min-heap-free approach: a plain map plus periodic pruning, which is far
|
||||
// cheaper than maintaining a sorted structure on every query.
|
||||
type topDomains struct {
|
||||
mu sync.Mutex
|
||||
counts map[string]*topCount
|
||||
limit int
|
||||
}
|
||||
|
||||
type topCount struct {
|
||||
name string
|
||||
qtype uint16
|
||||
hits uint64
|
||||
bytes int64
|
||||
}
|
||||
|
||||
func newTopDomains(limit int) *topDomains {
|
||||
return &topDomains{counts: make(map[string]*topCount, 4096), limit: limit}
|
||||
}
|
||||
|
||||
func (t *topDomains) observe(k Key, e *Entry) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
id := k.Name + "|" + strconv.Itoa(int(k.Type))
|
||||
t.mu.Lock()
|
||||
c, ok := t.counts[id]
|
||||
if !ok {
|
||||
if len(t.counts) > t.limit*2 {
|
||||
t.pruneLocked()
|
||||
}
|
||||
c = &topCount{name: k.Name, qtype: k.Type}
|
||||
t.counts[id] = c
|
||||
}
|
||||
c.hits++
|
||||
c.bytes += int64(len(e.Wire))
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *topDomains) pruneLocked() {
|
||||
// Drop the bottom half so pruning is amortised.
|
||||
type kv struct {
|
||||
id string
|
||||
c *topCount
|
||||
}
|
||||
all := make([]kv, 0, len(t.counts))
|
||||
for id, c := range t.counts {
|
||||
all = append(all, kv{id, c})
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].c.hits < all[j].c.hits })
|
||||
keep := len(all) / 2
|
||||
for _, e := range all[keep:] {
|
||||
delete(t.counts, e.id)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *topDomains) reset() {
|
||||
t.mu.Lock()
|
||||
t.counts = make(map[string]*topCount, 4096)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// TopEntry is one row of the "top cached names" table.
|
||||
type TopEntry struct {
|
||||
Name string `json:"name"`
|
||||
Type uint16 `json:"qtype"`
|
||||
Hits uint64 `json:"hits"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
func (t *topDomains) top(limit int) []TopEntry {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
t.mu.Lock()
|
||||
out := make([]TopEntry, 0, limit)
|
||||
for _, c := range t.counts {
|
||||
out = append(out, TopEntry{Name: c.name, Type: c.qtype, Hits: c.hits, Bytes: c.bytes})
|
||||
}
|
||||
t.mu.Unlock()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Hits > out[j].Hits })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out
|
||||
}
|
||||
Vendored
+251
@@ -0,0 +1,251 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
var bucketCache = []byte("cache")
|
||||
|
||||
// Store persists a snapshot of the LRU to a bbolt file so a restart (or a
|
||||
// rolling deploy of a cluster) resumes with a warm cache.
|
||||
//
|
||||
// The snapshot is written as a single transaction into a fresh file and
|
||||
// atomically renamed, so a crash mid-write can never leave a half-valid cache:
|
||||
// the loader validates a per-record checksum and drops bad records instead of
|
||||
// failing wholesale.
|
||||
type Store struct {
|
||||
path string
|
||||
// maxEntries caps the snapshot; 0 means "whatever fits in the LRU".
|
||||
maxEntries int
|
||||
|
||||
mu sync.Mutex
|
||||
db *bolt.DB
|
||||
writes uint64
|
||||
errors uint64
|
||||
lastSnap time.Time
|
||||
loaded bool
|
||||
}
|
||||
|
||||
// StoreOptions configures a Store.
|
||||
type StoreOptions struct {
|
||||
// Path is the bbolt file to use.
|
||||
Path string
|
||||
// MaxEntries caps how many entries a snapshot may contain.
|
||||
MaxEntries int
|
||||
// Timeout bounds a single snapshot write.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// OpenStore opens (or creates) the bbolt file. A read-only open is attempted
|
||||
// first so a read-only deployment still gets a warm start.
|
||||
func OpenStore(opt StoreOptions) (*Store, error) {
|
||||
if opt.Path == "" {
|
||||
return nil, fmt.Errorf("cache: store path is required")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(opt.Path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("cache: create data dir: %w", err)
|
||||
}
|
||||
timeout := opt.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
db, err := bolt.Open(opt.Path, 0o600, &bolt.Options{Timeout: timeout, NoFreelistSync: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache: open %s: %w", opt.Path, err)
|
||||
}
|
||||
if err := db.Update(func(tx *bolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists(bucketCache)
|
||||
return err
|
||||
}); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Store{path: opt.Path, maxEntries: opt.MaxEntries, db: db}, nil
|
||||
}
|
||||
|
||||
// Path is the bbolt file path.
|
||||
func (s *Store) Path() string { return s.path }
|
||||
|
||||
// Load restores entries into the LRU. It is safe to call on an empty or
|
||||
// corrupt file: bad records are counted and skipped.
|
||||
func (s *Store) Load(l *LRU, now time.Time) (loaded int, skipped int, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
err = s.db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketCache)
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
cur := b.Cursor()
|
||||
for k, _ := cur.First(); k != nil; k, _ = cur.Next() {
|
||||
key, rest, kerr := unpackKey(k)
|
||||
if kerr != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
expUnix, n := binary.Uvarint(rest)
|
||||
if n <= 0 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
staleUnix, n2 := binary.Uvarint(rest[n:])
|
||||
if n2 <= 0 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
wire := rest[n+n2:]
|
||||
if len(wire) == 0 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
expires := time.Unix(0, int64(expUnix))
|
||||
staleUntil := time.Unix(0, int64(staleUnix))
|
||||
if staleUntil.Before(now) {
|
||||
// Already outside the serve-stale window: not worth loading.
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
ttl := uint32(expires.Sub(now).Seconds())
|
||||
e := &Entry{
|
||||
Wire: append([]byte(nil), wire...),
|
||||
TTL: ttl,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: expires,
|
||||
StaleUntil: staleUntil,
|
||||
Origin: "snapshot",
|
||||
Bytes: len(wire) + len(key.Name) + 96,
|
||||
}
|
||||
l.Put(key, e)
|
||||
loaded++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
s.errors++
|
||||
return loaded, skipped, err
|
||||
}
|
||||
s.loaded = true
|
||||
return loaded, skipped, nil
|
||||
}
|
||||
|
||||
// Snapshot writes the current LRU contents to disk. Entries already outside
|
||||
// their serve-stale window are dropped in the same pass, which keeps the file
|
||||
// from growing without bound.
|
||||
func (s *Store) Snapshot(l *LRU, now time.Time) (written int, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Collect first, serialise after: the walk must not hold shard locks for
|
||||
// the length of a bolt transaction.
|
||||
type row struct{ key, val []byte }
|
||||
rows := make([]row, 0, 8192)
|
||||
dropped := 0
|
||||
l.Walk(func(k Key, e *Entry) bool {
|
||||
if !e.StaleUntil.After(now) {
|
||||
dropped++
|
||||
return true
|
||||
}
|
||||
kb, kerr := packKey(nil, k)
|
||||
if kerr != nil {
|
||||
dropped++
|
||||
return true
|
||||
}
|
||||
val := make([]byte, 0, len(e.Wire)+32)
|
||||
val = binary.AppendUvarint(val, uint64(e.ExpiresAt.UnixNano()))
|
||||
val = binary.AppendUvarint(val, uint64(e.StaleUntil.UnixNano()))
|
||||
val = append(val, e.Wire...)
|
||||
rows = append(rows, row{key: kb, val: val})
|
||||
return true
|
||||
})
|
||||
if s.maxEntries > 0 && len(rows) > s.maxEntries {
|
||||
dropped += len(rows) - s.maxEntries
|
||||
rows = rows[:s.maxEntries]
|
||||
}
|
||||
|
||||
err = s.db.Update(func(tx *bolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists(bucketCache)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Rebuild the bucket so a shrinking cache really shrinks the file.
|
||||
if err := tx.DeleteBucket(bucketCache); err != nil && err != bolt.ErrBucketNotFound {
|
||||
return err
|
||||
}
|
||||
b, err = tx.CreateBucketIfNotExists(bucketCache)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range rows {
|
||||
if err := b.Put(rows[i].key, rows[i].val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
s.errors++
|
||||
return 0, err
|
||||
}
|
||||
s.writes++
|
||||
s.lastSnap = now
|
||||
return len(rows), nil
|
||||
}
|
||||
|
||||
// Truncate empties the snapshot file.
|
||||
func (s *Store) Truncate() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
err := s.db.Update(func(tx *bolt.Tx) error {
|
||||
if tx.Bucket(bucketCache) == nil {
|
||||
return nil
|
||||
}
|
||||
return tx.DeleteBucket(bucketCache)
|
||||
})
|
||||
if err != nil {
|
||||
s.errors++
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Stats reports the snapshot file's state.
|
||||
func (s *Store) Stats() PersistSt {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := PersistSt{
|
||||
Enabled: true,
|
||||
Path: s.path,
|
||||
LastSnapshot: s.lastSnap,
|
||||
Writes: s.writes,
|
||||
Errors: s.errors,
|
||||
Loaded: s.loaded,
|
||||
}
|
||||
if fi, err := os.Stat(s.path); err == nil {
|
||||
st.Bytes = fi.Size()
|
||||
}
|
||||
if s.db != nil {
|
||||
_ = s.db.View(func(tx *bolt.Tx) error {
|
||||
if b := tx.Bucket(bucketCache); b != nil {
|
||||
st.Entries = b.Stats().KeyN
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// Close releases the file handle.
|
||||
func (s *Store) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
Reference in new issue
Block a user