M1 work package: zonecache
Branch worktree-wf_0eb072cf-675-3. Covers docs/architecture.md §3.1 (zonecache row), §4.4 (cold start without the apiserver), §4.5, the §5.2 readiness probe, and decisions D7, D33 and D78.
What was built
internal/zonecache is the always-on runnable that turns store watches into serving snapshots and keeps local.db current.
- Watches. One
Store.Watchper kudurru.io kind (DefaultKinds()reads them from the v1alpha1 scheme, so a new kind is picked up without touching zonecache). It follows the store's Watch contract: initialAddedevents, then aBookmark, then live events. When a watch channel closes, the kind is re-listed with a new Watch. Objects missing from the new list are dropped when its Bookmark arrives, andkudurru_store_watch_resyncs_total{kind}goes up by one.Watcherrors are retried with backoff from 100 ms to 5 s.store.ErrClosedends that kind's watch. - Indexed object set (
ObjectSet). Immutable. Lookups by key, sorted per-kind lists, a label index (WithLabel,Selectwith equality selectors), DNSRecordSets grouped byspec.zoneRef.name(RecordSetsForZone,ZoneRefs), and typed accessors (Zones,Zone,RecordSets,Policies,ClientGroups,BlockLists). It also carries the compiled blocking lists (Lists(), set throughCache.SetLists), the build time (Now()), per-kind change flags (Changed(kind),ListsChanged()) and per-kind resourceVersions. Kinds that did not change share their index with the previous set, so editing one zone re-indexes only DNSZone. - Snapshots. After a change, a 50 ms window collects further changes. Then the builders run in registration order and one
*Snapshotis published through anatomic.Pointer. The query path callsCache.Snapshot(): one atomic load, no lock, no allocation (9.3 ns including aViewlookup). RecordexpiresAtvalues schedule a rebuild when they pass, so builders that honour expiry (§4.2) see the change without a store event. - Builder isolation. If a builder returns an error or panics (the panic is recovered), its previous view stays in service. The other builders are unaffected. The failure raises
Degraded{BuildFailed, builder}and incrementskudurru_zonecache_build_errors_total{builder}. The condition clears on that builder's next successful build. - Sync state and readiness.
State()returnsUnsynced,FromSnapshotorLive.Synced()is true once every kind has delivered its initial list and Bookmark.Ready() (bool, []string)matcheshealth.ReadyFuncand implements the §5.2 rule: ready when live, or when serving a snapshot younger thanMaxAge. When the Cache is ready, the strings list any active Degraded conditions so/healthzshows them. When it is not ready, they say why ("waiting for the initial sync of DNSZone, Policy", "local.db holds no snapshot yet", "serving a local.db snapshot 25h0m0s old, older than maxAge 24h0m0s").Initialized()is closed once the Cache has something to serve: the store synced orlocal.dbwas loaded. - Cold start (§4.4). With
Options.LocalDBset:- The live set and per-kind resourceVersions are written to
local.dbafter every successful sync or re-sync, then at most everyPersistInterval(30 s) when something changed, and once more on shutdown. - If the store has not synced within
StartupTimeout(15 s),local.dbis loaded and served. State isFromSnapshot, withDegraded{ServingFromSnapshot},kudurru_store_snapshot_serving 1andkudurru_store_snapshot_age_seconds. - If the snapshot is older than
MaxAge(24 h), or crosses it while being served (a timer republishes the snapshot),Snapshot.Staleis set,Snapshot.Authoritative()returns false andReady()fails. - When the store syncs, the live set replaces the snapshot. Nothing is written back while the snapshot is being served.
- Unsynced with no snapshot: not ready, and
Authoritative()is false.
- The live set and per-kind resourceVersions are written to
local.dbmoved frominternal/store/kube/localdbtointernal/store/localdb. zonecache is driver-independent and is the only writer, so it made sense to move it. The kube package now imports the new path (three files). Table names and the on-disk schema are unchanged. A newSnapshotStore.Touch(ctx, t)updateswritten_atwithout rewriting any objects (see "Surprises").
The Builder contract
type Builder interface {
Name() string // stable, unique; keys the view and the metric label
Build(objects ObjectSet) (any, error) // returns an immutable view
}
func NewBuilder(name string, build func(ObjectSet) (any, error)) Builder
func View[T any](s *Snapshot, name string) (T, bool)
Registerrejects duplicate and empty names. You can register builders before or afterStart. Registering one triggers a rebuild.Buildruns on the Cache's single rebuild goroutine, never at the same time as itself or another builder. It runs once per published snapshot: after the debounce window that follows a store change, onSetLists, when the source switches (Unsynced, FromSnapshot, Live), when a record'sexpiresAtpasses, when a builder is registered, and when a served snapshot crossesMaxAge.- The
ObjectSetand every object reachable from it are shared and immutable.Buildmust not modify them, but it may keep references to them. Returned slices are clipped, soappendcopies. - The returned value goes to every reader exactly as returned. It must be safe for concurrent reads and must never be modified afterwards (copy-on-write). To reuse unchanged parts of the previous view, check
objects.Changed(kind)andobjects.ListsChanged(). On the first build and on a source switch, both report true for everything. - Use
objects.Now()for time comparisons such asexpiresAt, not the wall clock. That way every view in a snapshot agrees. - Error or panic: the previous view is kept and
Degraded{BuildFailed}is raised. A builder that has never succeeded has no view (Viewreturnsok=false). Buildshould take milliseconds. It delays the builders after it and the publication of the snapshot. It must not block on the Cache.
Wiring (cmd/kudurru serve)
// kube mode only: Docker mode has state.db locally and passes LocalDB nil (§4.5).
db, err := localdb.OpenSnapshotStore(ctx, filepath.Join(cfg.Node.DataDir, localdb.FileName))
if err != nil { return err } // ErrSchemaTooNew: refuse to start (§4.3)
defer db.Close() // after zc.Start has returned
zc, err := zonecache.New(st, zonecache.Options{
LocalDB: db, // nil in Docker/sqlite mode
StartupTimeout: 15 * time.Second, // store.kube.startupTimeout (no config key yet)
MaxAge: 24 * time.Hour, // store.kube.snapshot.maxAge (no config key yet)
Registerer: reg,
Logger: logger,
})
if err != nil { return err }
_ = zc.Register(zoneBuilder) // from internal/zone (parallel package)
_ = zc.Register(policyIndexBuilder) // from the policy index
checker.Register("zonecache", zc.Ready)
run("zonecache", zc.Start) // blocks until ctx ends; final local.db write
// startup probe: SetStarted only after <-zc.Initialized() (and listeners bound)
// list refresher: zc.SetLists(blockingSnapshot) after every blocking.Rebuild
Query path (the authoritative stage):
snap := zc.Snapshot() // once per query
if inPrimaryZone && !snap.Authoritative() { return SERVFAIL } // Unsynced, or snapshot older than maxAge
zones, ok := zonecache.View[*zone.Set](snap, "zones")
Notes:
Cacheimplements controller-runtime'sLeaderElectionRunnable(NeedLeaderElection() == false) andStart(ctx) error, so the future manager can run it unchanged.- With zonecache running, the kube driver's
WaitForSync,MaterializedSnapshotandPersistSnapshotsare no longer needed on the serve path. zonecache waits on its own Bookmarks and writes its own set. TheirStartupTimeoutoptions are independent. Snapshot.StateandAuthoritative()lagCache.State()by one debounce window. The query path should use the snapshot.
Public API summary
New(store.Store, Options) (*Cache, error),DefaultKinds() []store.Kind, theDefault*constants (Debounce 50 ms, PersistInterval 30 s, RefreshInterval 5 min, StartupTimeout 15 s, MaxAge 24 h)(*Cache):Register(Builder) error,SetLists(*blocking.Snapshot),OnPublish(func(*Snapshot)),Start(ctx) error,Snapshot() *Snapshot,State() State,Synced() bool,Ready() (bool, []string),Initialized() <-chan struct{},Degraded() []Condition,NeedLeaderElection() boolSnapshot:Generation,State,Objects ObjectSet,BuiltAt,SnapshotWrittenAt,Stale,View(name),Authoritative(),Degraded()ObjectSet:Kinds,Len,Count,Get,List,WithLabel,Select,Zones,Zone,RecordSets,RecordSetsForZone,ZoneRefs,Policies,ClientGroups,BlockLists,Lists,Now,Changed,ListsChanged,ResourceVersionCondition{Reason, Builder, Message, Since}with reasonsServingFromSnapshot,SnapshotStale,BuildFailed,SnapshotWriteFailed- Metrics:
kudurru_store_watch_resyncs_total{kind},kudurru_zonecache_rebuilds_total,kudurru_zonecache_rebuild_duration_seconds,kudurru_zonecache_build_errors_total{builder},kudurru_zonecache_objects{kind},kudurru_store_snapshot_serving,kudurru_store_snapshot_age_seconds(age of the served snapshot, 0 when live),kudurru_store_snapshot_writes_total{result},kudurru_store_snapshot_last_write_timestamp_seconds
Measurements
Shared 8-CPU sandbox, go test without -race unless noted (TestRebuild10k, benchmarks in scale_test.go). The 10k set is 50 zones, 9,500 record sets with two labels each, 200 policies, 150 client groups and 100 block lists.
| What | Result |
|---|---|
| 10k objects, start to first Live snapshot (includes the 50 ms debounce) | 65 ms (187 ms with -race) |
| 10k objects, one record-set change until it is visible | 61 ms (50 ms of that is debounce) |
Full index build, 10k objects (BenchmarkIndexFull10k) | 7.4 ms, 2.4 MB, 2,464 allocs |
Re-index after one zone edit (BenchmarkIndexOneZone10k) | 5.2 µs, 11 allocs |
Query path: Snapshot() + Authoritative() + View | 9.3 ns, 0 allocs |
local.db write, 10k objects | 136 ms (on the persist goroutine, off the rebuild path) |
local.db load, 10k objects | 54 ms |
envtest: restarted replica, apiserver partitioned, StartupTimeout 1 s | fell back to local.db after 1.02 s |
| envtest: partition healed until swapped to live | 392 ms |
go test -race ./internal/zonecache/ | 12-20 s (the envtest case is about 7 s) |
Tests
- Memory driver: live sync and every
ObjectSetaccessor; live update, add and delete; unchanged kinds share their index; debounce (a burst is one rebuild, two spaced changes are two, nothing visible before the window ends); builder error and panic isolation and recovery; a builder that never succeeds; late registration plusSetLists; a rebuild triggered byexpiresAt. - Fake store (unbounded watch buffers, held Bookmarks, forced channel close, events that bypass watchers): re-list after a watch closes, including a delete and an add the watch never reported;
Watcherrors retried; deletes during the initial list; persist, then cold start, then swap; stale snapshot; a snapshot crossingMaxAgewhile served; unsynced with an emptylocal.dband with nolocal.db; timestamp refresh; final write on shutdown; write failure raisingSnapshotWriteFailedwithout costing readiness; 10k timing. - Kube driver on envtest (
TestKubeColdStart, skipped whenKUBEBUILDER_ASSETSis unset orKUDURRU_SKIP_ENVTESTis set): a live Cache writeslocal.dbwith every object, every kind's resourceVersion and the kube-system UID. A restarted replica behind a failing RoundTripper falls back afterStartupTimeoutand builds its views from the snapshot, with the age gauge set. A write is made while it is partitioned. When the partition heals, the replica swaps to the live set, including that write, and persists it.
Surprises
- A quiet cluster would age its own snapshot past
maxAge. Iflocal.dbis written only when something changes, a cluster with no edits for a day has a snapshot more than 24 h old, and a pod restart would then answer SERVFAIL for primary zones. That defeats D7 on exactly the homelab installs it targets. zonecache therefore refresheswritten_at(SnapshotStore.Touch) everyRefreshInterval(5 min) while live and unchanged. The age then means "time since the set was last known current".- Caveat: the kube driver keeps serving from informer memory during an apiserver outage and gives no "connected" signal, so the Cache stays
Liveand keeps refreshing. The age after a restart therefore measures time since the pod last ran, not time since it last reached the apiserver. See follow-ups.
- Caveat: the kube driver keeps serving from informer memory during an apiserver outage and gives no "connected" signal, so the Cache stays
- The memory driver deadlocks
Watchwith more than 256 objects. It sends the initialAddedevents into a 256-slot channel while holding its mutex, before returning the watcher. Live events beyond the buffer are silently dropped for slow watchers. The zonecache tests use a fake store for the 10k and resync cases instead of changing a shared package. metav1.Timeserialises with second precision, soexpiresAtvalues written through any store lose sub-second precision. That matters only for tests.- The kube driver already had
MaterializedSnapshotandPersistSnapshots, which write from the informer cache. zonecache writes its own object set instead, so the snapshot and the views come from the same events and the logic works with any driver. The kube helpers remain for the driver's own tests.
Follow-ups
- Config keys (owner of
internal/config):store.kube.startupTimeout(15 s) andstore.kube.snapshot.maxAge(24 h) are named in §4.4 but not inschema.go. Wire them intozonecache.Options. - Store connectivity signal: expose the kube driver's last successful contact (for example
store.Info().LastContact, or aConnected()method) so zonecache stops refreshing the snapshot timestamp during an apiserver outage. - Memory driver: deliver initial
Watchevents from a goroutine and stop dropping events for slow watchers, or document both limits onmemory.Store. /healthzsnapshotAge: §5.2 listssnapshotAgein the roll-up.health.Checker's info map is static. A dynamic info hook (or zonecache's ready message, which already contains the age) would fill it.- The cold-start check does not compare cluster IDs (the snapshot records the kube-system UID, but the apiserver is unreachable at that point). A
local.dbcopied from another cluster would be served until the store syncs. - §5.2 startup probe:
serveshould gateSetStartedon<-zc.Initialized(). That change is incmd/, which this package does not touch.
Recommended changes to docs/architecture.md
- §4.4: "writes ... after every successful sync and then at most every 30 s when changed" should add: "and refreshes the snapshot timestamp every 5 min while unchanged, so
snapshot_agemeasures time since the set was last known current". Also definekudurru_store_snapshot_age_secondsas 0 while serving live objects; theKudurruSnapshotStalealert depends on this. - §4.4: the startup timeout applies to every driver that is given a
local.db, not only the kube driver's informers. zonecache measures it against its own watch Bookmarks. - §4.5 / §12 layout:
local.dbcode lives ininternal/store/localdb. It is the natural single owner oflocal.dbmigrations, which the kube spike asked for in its recommendation 7. - §3.1 zonecache row: mention the
Builderseam (internal/zonecache.Builder) as the plug-in point for the zone trees, the policy index and later views.