// SnapshotHolder provides atomic hot-reload for SnapshotReader with drain // grace — solves the classic "Get concurrent with Close/munmap" race that // would otherwise produce uncatchable SIGBUS / SIGSEGV under reload. // // Pattern source: slask-finder's `RawItemsStore` and similar mmap+reload // stores, which delay unmap behind a configurable drain-grace timer so any // in-flight Get on the previously-current reader completes before the // underlying pages become unmapped. See docs/inventory-shape.md & the // finder WARP doc on mmap reload for the prior art. // // Use: // // holder := catalog.NewSnapshotHolder(30 * time.Second) // drain grace // if err := holder.Open("/var/lib/catalog/bygglagret.bin"); err != nil { ... } // // later, when a fresh snapshot lands: // if err := holder.Reload("/var/lib/catalog/bygglagret.bin"); err != nil { ... } // // pulls the projection; always hits the current reader: // p, ok := holder.Get(sku) // // holder.Get / Len are safe for parallel use. Open and Reload are also // safe for parallel use. Close stops the reaper goroutine and shuts the // current reader + drains down. package catalog import ( "sync" "sync/atomic" "time" ) // DefaultDrainGrace is the default drain period for SnapshotHolder. Finder's // analogous reload path uses ~30s; we mirror that value. const DefaultDrainGrace = 30 * time.Second // SnapshotHolder swaps a SnapshotReader under load without unmapping pages // out from under in-flight Get()s. Set retains the previous reader behind a // drain timer; after the timer elapses the reaper goroutine closes the old // reader (and runs its munmap). type SnapshotHolder struct { current atomic.Pointer[SnapshotReader] drainsMu sync.Mutex drains []*pendingClose drainGrace time.Duration stopCh chan struct{} once sync.Once } type pendingClose struct { r *SnapshotReader expireAt time.Time } // NewSnapshotHolder returns a holder with the given drain grace (the time // the previous reader lives behind Set before being closed). Zero or // negative grace falls back to DefaultDrainGrace. func NewSnapshotHolder(grace time.Duration) *SnapshotHolder { if grace <= 0 { grace = DefaultDrainGrace } h := &SnapshotHolder{ drainGrace: grace, stopCh: make(chan struct{}), } go h.sweepLoop() return h } // Open is shorthand for Set(ReadSnapshotFile(path)). Errors leave the // holder's current reader unchanged. func (h *SnapshotHolder) Open(path string) error { r, err := ReadSnapshotFile(path) if err != nil { return err } h.Set(r) return nil } // Reload atomically swaps the current reader for one freshly opened from // path. The old reader (if any) is queued for close after the drain grace // elapses; in-flight Get(s) on the old reader continue to work. func (h *SnapshotHolder) Reload(path string) error { r, err := ReadSnapshotFile(path) if err != nil { return err } h.Set(r) return nil } // Set replaces the current reader with r. The previous reader is queued // for close after the drain grace elapses. r may be nil to clear. func (h *SnapshotHolder) Set(r *SnapshotReader) { if r == nil { h.current.Store(nil) return } old := h.current.Swap(r) if old == nil { return } h.drainsMu.Lock() h.drains = append(h.drains, &pendingClose{ r: old, expireAt: time.Now().Add(h.drainGrace), }) h.drainsMu.Unlock() } // Get is the concurrent-safe shim over the current reader's Get. Returns // (zero, false) when the holder is empty. func (h *SnapshotHolder) Get(sku string) (Projection, bool) { r := h.current.Load() if r == nil { return Projection{}, false } return r.Get(sku) } // Len returns the count of the current reader (0 if empty). func (h *SnapshotHolder) Len() int { r := h.current.Load() if r == nil { return 0 } return r.Len() } // Close stops the reaper goroutine and closes the current reader + any // pending drains without waiting for the grace period. Safe to call // multiple times. func (h *SnapshotHolder) Close() error { var firstErr error h.once.Do(func() { close(h.stopCh) r := h.current.Swap(nil) if r != nil { if err := r.Close(); err != nil { firstErr = err } } h.drainsMu.Lock() drained := h.drains h.drains = nil h.drainsMu.Unlock() for _, d := range drained { if err := d.r.Close(); err != nil && firstErr == nil { firstErr = err } } }) return firstErr } // DrainCount returns the number of readers currently in the drain queue. // Useful for tests and operational metrics. func (h *SnapshotHolder) DrainCount() int { h.drainsMu.Lock() defer h.drainsMu.Unlock() return len(h.drains) } // reapNow scans the drain queue and Closes any readers whose grace has // elapsed. Called by the sweeper goroutine on a tick. func (h *SnapshotHolder) reapNow() { now := time.Now() var alive []*pendingClose var reaped []*pendingClose h.drainsMu.Lock() for _, d := range h.drains { if !d.expireAt.After(now) { reaped = append(reaped, d) } else { alive = append(alive, d) } } h.drains = alive h.drainsMu.Unlock() for _, d := range reaped { _ = d.r.Close() } } func (h *SnapshotHolder) sweepLoop() { interval := h.drainGrace / 4 if interval < 10*time.Millisecond { interval = 10 * time.Millisecond } t := time.NewTicker(interval) defer t.Stop() for { select { case <-h.stopCh: return case <-t.C: h.reapNow() } } }