This commit is contained in:
mats committed 2026-06-28 17:52:49 +02:00
1 parent e706f7d907
commit 21bcb63440
42 files changed
+5903

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
// 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()
}
}
}