202 lines
5.3 KiB
Go
202 lines
5.3 KiB
Go
// 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()
|
|
}
|
|
}
|
|
}
|