more platform

This commit is contained in:
mats committed 2026-06-29 12:35:32 +02:00
1 parent 8a71e32149
commit 2a66754f9e
27 files changed
+2435 -540

No files matched your search

+77 -308
View File
@@ -1,330 +1,99 @@
package catalog
// ProjectionStore — the consumer half of the projection seam (see
// docs/catalog-projection.md). A commerce service (cart, checkout, order, UCP)
// feeds it the catalog.projection_published frames it receives off the bus and
// reads catalog facts back by SKU. The store owns a LOCAL read-only projection:
// Projection seam — catalog binding over the generic platform/projection
// package. The value-agnostic machinery (snapshot .bin format, mmap reader,
// race-free SnapshotHolder, the begin→chunk*→end consumer state machine with
// epoch gating + delta overlay) lives in platform/projection. This file is the
// thin catalog-specific binding: it fixes the WIRE type to catalog.Projection
// and supplies the decoder for the catalog wire schema, so cart / checkout /
// order / catalog-emit keep their existing catalog.* API unchanged.
//
// - A full snapshot stream (begin → chunk* → end, one MetaEpoch) is assembled
// into the store's OWN .bin (SnapshotWriter), atomically swapped in as the
// mmap base (SnapshotHolder). The base is the cold-start substrate — it
// survives a restart (reopened from disk), so the consumer serves reads
// immediately without waiting for a bus replay.
// - Per-batch deltas of the SAME epoch are applied to an in-memory overlay on
// top of the base (overlay wins; a delete leaves a tombstone that shadows the
// base and the fallback). The next snapshot folds the overlay into a new base.
//
// Read order on Get: overlay → mmap base → optional caller fallback (e.g. an
// HTTP fetch from the reader) — last-resort only. The access pattern is point
// lookup by SKU; nothing enumerates the catalog.
//
// No shared volume: each consumer builds its own base from the bus. Concurrency:
// HandleFrame is single-writer (drive it from one delivery goroutine); Get is
// safe from many readers.
// See docs/catalog-projection.md for the consumer contract and
// docs/plan-projectionstore-generalization.md for why the split exists.
import (
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.k6n.net/mats/platform/projection"
)
// ProjectionFallback resolves an SKU the local store doesn't have yet (cold
// start before the first snapshot, or an item added since the last base). It
// returns a raw Projection — the store applies the SAME Transform to it, so a
// fallback hit is shaped identically to bus-fed data. Return (_, false) to
// report a genuine miss.
type ProjectionFallback func(sku string) (Projection, bool)
// Generic machinery, re-exported under their historical catalog names. These
// are type aliases — catalog.SnapshotHolder[T] IS projection.SnapshotHolder[T],
// so values flow between the two packages without conversion.
type (
SnapshotWriter[T any] = projection.SnapshotWriter[T]
SnapshotReader[T any] = projection.SnapshotReader[T]
SnapshotHolder[T any] = projection.SnapshotHolder[T]
// ProjectionTransform projects the wire Projection down to the consumer's own
// stored type T. It is MANDATORY: it is the one place a consumer declares which
// catalog facts it actually needs (and any derivation, e.g. tax_class → rate),
// so its local .bin holds only that — smaller, and read-ready with no per-Get
// conversion. Every value entering the store — snapshot, delta, and fallback —
// passes through it exactly once.
type ProjectionTransform[T any] func(Projection) T
// ProjectionStore is a consumer's local, cold-start-ready cache of T, fed the
// catalog.projection_published frames off the bus. The wire type is fixed to
// catalog.Projection; T is the consumer's projected shape.
ProjectionStore[T any] = projection.Store[Projection, T]
// ProjectionStore is a consumer's local, cold-start-ready projection cache of T.
type ProjectionStore[T any] struct {
dir, name string
transform ProjectionTransform[T]
fallback ProjectionFallback
holder *SnapshotHolder[T] // mmap base; atomic-swap + drain-grace
// ProjectionTransform projects the wire Projection down to the consumer's
// stored type T (the one place a consumer declares which catalog facts it
// needs). MANDATORY.
ProjectionTransform[T any] = projection.Transform[Projection, T]
mu sync.RWMutex
epoch int64 // epoch of the live base
overlay map[string]overlayEntry[T] // same-epoch deltas layered on the base
// ProjectionFallback resolves a SKU the local store doesn't have yet. May be nil.
ProjectionFallback = projection.Fallback[Projection]
)
// in-progress snapshot assembly (one epoch at a time)
building *SnapshotWriter[T]
buildingEpoch int64
buildingSeen int
// DefaultDrainGrace is the SnapshotHolder default drain period (re-exported).
const DefaultDrainGrace = projection.DefaultDrainGrace
// Snapshot file errors, re-exported so callers can errors.Is against the
// catalog.* names. Identity is preserved (these ARE the projection package's
// error values).
var (
ErrSnapshotMagic = projection.ErrSnapshotMagic
ErrSnapshotVersion = projection.ErrSnapshotVersion
ErrSnapshotTruncated = projection.ErrSnapshotTruncated
ErrSnapshotBadBlob = projection.ErrSnapshotBadBlob
ErrSnapshotOverflow = projection.ErrSnapshotOverflow
ErrSKUTooLong = projection.ErrSKUTooLong
ErrTenantTooLong = projection.ErrTenantTooLong
)
// NewSnapshotWriter returns an empty snapshot writer for value type T.
func NewSnapshotWriter[T any]() *SnapshotWriter[T] { return projection.NewSnapshotWriter[T]() }
// NewSnapshotReader wraps an already-loaded []byte as a reader of T.
func NewSnapshotReader[T any](data []byte) (*SnapshotReader[T], error) {
return projection.NewSnapshotReader[T](data)
}
type overlayEntry[T any] struct {
val T
deleted bool
// ReadSnapshotFile opens + parses a snapshot file as a reader of T (mmap on Unix).
func ReadSnapshotFile[T any](path string) (*SnapshotReader[T], error) {
return projection.ReadSnapshotFile[T](path)
}
// NewProjectionStore opens a store of T backed by dir/name (its own .bin).
// transform is REQUIRED — it defines the consumer's projection shape (see
// ProjectionTransform). If a base from a previous run is present it is reopened
// immediately (cold-start ready); its epoch is read from the sidecar so
// same-epoch deltas can resume. fallback may be nil. Close() when done.
// NewSnapshotHolder returns a holder of T with the given drain grace.
func NewSnapshotHolder[T any](grace time.Duration) *SnapshotHolder[T] {
return projection.NewSnapshotHolder[T](grace)
}
// NewProjectionStore opens a catalog ProjectionStore backed by dir/name (its own
// .bin). transform is REQUIRED (declares the stored shape); fallback may be nil.
// If a base from a previous run is present it is reopened immediately
// (cold-start ready). Close() when done.
func NewProjectionStore[T any](dir, name string, transform ProjectionTransform[T], fallback ProjectionFallback) (*ProjectionStore[T], error) {
if dir == "" || name == "" {
return nil, fmt.Errorf("catalog: ProjectionStore needs dir and name")
}
if transform == nil {
return nil, fmt.Errorf("catalog: ProjectionStore needs a transform (declare which catalog facts this consumer stores)")
}
s := &ProjectionStore[T]{
dir: dir,
name: name,
transform: transform,
fallback: fallback,
holder: NewSnapshotHolder[T](0),
overlay: make(map[string]overlayEntry[T]),
}
path := filepath.Join(dir, name)
if err := s.holder.Open(path); err == nil {
s.epoch = readEpochSidecar(path)
log.Printf("catalog: ProjectionStore reopened base %s (epoch=%d, %d entries)", path, s.epoch, s.holder.Len())
} else if !os.IsNotExist(err) {
// A corrupt/partial base on disk shouldn't crash boot — start empty and
// wait for the next snapshot; reads fall back meanwhile.
log.Printf("catalog: ProjectionStore ignoring unreadable base %s: %v", path, err)
}
return s, nil
return projection.NewStore[Projection, T](dir, name, decodeProjectionUpdates, transform, fallback)
}
// HandleFrame applies one catalog.projection_published event (snapshot frame or
// delta), routed by its Meta. Drive this from a single delivery goroutine. A
// returned error means the payload was undecodable (the caller may NACK); a
// frame that simply doesn't apply (stale epoch, wrong assembly state) returns
// nil so the delivery is acked.
func (s *ProjectionStore[T]) HandleFrame(meta map[string]string, payload []byte) error {
epoch, _ := strconv.ParseInt(meta[MetaEpoch], 10, 64)
switch meta[MetaSnapshotPhase] {
case SnapshotBegin:
return s.beginSnapshot(epoch, meta[MetaTenant])
case SnapshotChunk:
return s.chunkSnapshot(epoch, payload)
case SnapshotEnd:
return s.endSnapshot(epoch, meta)
case "":
return s.applyDelta(epoch, payload)
default:
return nil // unknown phase — ignore forward-compatibly
}
}
func (s *ProjectionStore[T]) beginSnapshot(epoch int64, tenant string) error {
s.mu.Lock()
defer s.mu.Unlock()
if epoch <= s.epoch {
return nil // we already hold this base or newer
}
// Start (or restart) assembly for this epoch. A new begin supersedes any
// half-built snapshot (e.g. a producer that aborted mid-stream).
s.building = NewSnapshotWriter[T]().WithTenant(tenant)
s.buildingEpoch = epoch
s.buildingSeen = 0
return nil
}
func (s *ProjectionStore[T]) chunkSnapshot(epoch int64, payload []byte) error {
ups, err := DecodeUpdates(payload)
// decodeProjectionUpdates bridges the catalog wire schema ([]ProjectionUpdate —
// a flat Projection plus a `deleted` flag) to the generic projection.Update the
// Store works with. The index key is the Projection's SKU. This is the one
// catalog-specific seam the generic Store can't know: the on-the-wire payload
// shape is a domain decision.
func decodeProjectionUpdates(payload []byte) ([]projection.Update[Projection], error) {
raw, err := DecodeUpdates(payload)
if err != nil {
return fmt.Errorf("catalog: decode snapshot chunk: %w", err)
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.building == nil || epoch != s.buildingEpoch {
return nil // not assembling this epoch
out := make([]projection.Update[Projection], len(raw))
for i, u := range raw {
out[i] = projection.Update[Projection]{Key: u.SKU, Value: u.Projection, Deleted: u.Deleted}
}
for _, u := range ups {
if u.Deleted || u.SKU == "" {
continue // a snapshot carries live items only
}
// Transform applied here — the base stores the consumer's shape, not the
// full Projection.
if err := s.building.Append(u.SKU, s.transform(u.Projection)); err != nil {
log.Printf("catalog: ProjectionStore skip %q in snapshot: %v", u.SKU, err)
continue
}
s.buildingSeen++
}
return nil
}
func (s *ProjectionStore[T]) endSnapshot(epoch int64, meta map[string]string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.building == nil || epoch != s.buildingEpoch {
return nil
}
w := s.building
s.building = nil
// Completeness guard: if begin/end advertised a count and we appended fewer
// (a lost chunk), DON'T swap — keep the old base and wait for the next
// snapshot. Serving an incomplete catalog silently is worse than stale.
if cs := meta[MetaCount]; cs != "" {
if want, perr := strconv.Atoi(cs); perr == nil && want != s.buildingSeen {
log.Printf("catalog: ProjectionStore snapshot epoch=%d incomplete (%d/%d) — discarding, keeping base epoch=%d",
epoch, s.buildingSeen, want, s.epoch)
return nil
}
}
path, err := w.Write(s.dir, s.name)
if err != nil {
return fmt.Errorf("catalog: write snapshot base: %w", err)
}
// Persist the epoch sidecar (atomic) BEFORE swapping the in-memory reader, so
// a reopened store's epoch always matches the .bin already on disk (w.Write
// renamed the new file into place above). Writing it after Reload would leave
// a crash window where the new .bin carries a stale/absent epoch — the store
// would then mislabel its base and drop the current epoch's deltas on restart.
writeEpochSidecar(path, epoch)
if err := s.holder.Reload(path); err != nil {
return fmt.Errorf("catalog: reload snapshot base: %w", err)
}
s.epoch = epoch
s.overlay = make(map[string]overlayEntry[T]) // base now includes everything ≤ epoch
log.Printf("catalog: ProjectionStore snapshot applied — base swapped epoch=%d (%d entries)", epoch, s.buildingSeen)
return nil
}
func (s *ProjectionStore[T]) applyDelta(epoch int64, payload []byte) error {
ups, err := DecodeUpdates(payload)
if err != nil {
return fmt.Errorf("catalog: decode delta: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
switch {
case s.building != nil:
// Under FIFO delivery a delta won't interleave a snapshot stream; if it
// does, the snapshot is authoritative for its epoch, so drop the delta.
return nil
case epoch < s.epoch:
return nil // stale — predates our base
case epoch > s.epoch:
// CONTRACT: a future-epoch delta is DROPPED, not buffered. Under FIFO
// per-queue delivery begin(N) always precedes delta(N), so this only
// happens after a reconnect that missed the whole snapshot — which a
// buffer couldn't reassemble anyway. Recovery is the next snapshot of
// this epoch; the consumer's fallback covers reads until then.
return nil
}
var upserts, deletes int
for _, u := range ups {
if u.SKU == "" {
continue
}
if u.Deleted {
s.overlay[u.SKU] = overlayEntry[T]{deleted: true}
deletes++
continue
}
// Transform applied here too — overlay holds the consumer's shape.
s.overlay[u.SKU] = overlayEntry[T]{val: s.transform(u.Projection)}
upserts++
}
if upserts+deletes > 0 {
log.Printf("catalog: ProjectionStore delta applied epoch=%d (+%d/-%d, overlay=%d)",
epoch, upserts, deletes, len(s.overlay))
}
return nil
}
// Get resolves a value by SKU: overlay (incl. tombstone) → mmap base →
// fallback. A tombstoned SKU returns (zero, false) and never reaches the
// fallback — the catalog explicitly removed it. A fallback hit is run through
// the same Transform, so it's shaped like everything else.
func (s *ProjectionStore[T]) Get(sku string) (T, bool) {
var zero T
s.mu.RLock()
e, ok := s.overlay[sku]
s.mu.RUnlock()
if ok {
if e.deleted {
return zero, false
}
return e.val, true
}
if v, ok := s.holder.Get(sku); ok {
return v, true
}
if s.fallback != nil {
if p, ok := s.fallback(sku); ok {
return s.transform(p), true
}
}
return zero, false
}
// IsDeleted reports whether the overlay holds a tombstone for sku — a delete
// seen on the bus since the current base. Lets a caller skip a downstream fetch
// for an SKU the catalog just removed. After a snapshot folds the delete into a
// new base the SKU is simply absent (IsDeleted → false); "absent" is then the
// caller's fetch/miss path, which a deleted item resolves as a downstream 404.
func (s *ProjectionStore[T]) IsDeleted(sku string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
e, ok := s.overlay[sku]
return ok && e.deleted
}
// Epoch returns the epoch of the current base (0 if none yet).
func (s *ProjectionStore[T]) Epoch() int64 {
s.mu.RLock()
defer s.mu.RUnlock()
return s.epoch
}
// Stats returns observability counters (base size, overlay size, epoch).
func (s *ProjectionStore[T]) Stats() (epoch int64, baseCount, overlayCount int) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.epoch, s.holder.Len(), len(s.overlay)
}
// Close releases the mmap base.
func (s *ProjectionStore[T]) Close() error { return s.holder.Close() }
// ── epoch sidecar ────────────────────────────────────────────────────────────
// The .bin header has no room for the epoch, so it's persisted next to it as
// "<name>.epoch" — read on boot so a reopened base can resume same-epoch deltas.
func epochSidecarPath(binPath string) string { return binPath + ".epoch" }
// writeEpochSidecar writes the epoch atomically (temp+rename) so a crash mid-write
// can't leave a torn file that parses as a bogus epoch — a reader either sees the
// old epoch or the new one, never garbage.
func writeEpochSidecar(binPath string, epoch int64) {
p := epochSidecarPath(binPath)
tmp := p + ".tmp"
if err := os.WriteFile(tmp, []byte(strconv.FormatInt(epoch, 10)), 0o644); err != nil {
log.Printf("catalog: ProjectionStore epoch sidecar write: %v", err)
return
}
if err := os.Rename(tmp, p); err != nil {
log.Printf("catalog: ProjectionStore epoch sidecar rename: %v", err)
_ = os.Remove(tmp)
}
}
func readEpochSidecar(binPath string) int64 {
b, err := os.ReadFile(epochSidecarPath(binPath))
if err != nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
return n
return out, nil
}
+16 -13
View File
@@ -36,6 +36,7 @@ import (
"encoding/json"
"git.k6n.net/mats/platform/money"
"git.k6n.net/mats/platform/projection"
)
// Projection is the published, versioned catalog entry that commerce modules
@@ -82,10 +83,10 @@ import (
// the JSON token. The on-disk snapshot binary header/record layout is
// unchanged \u2014 only the per-blob JSON payload gains a key.
type Projection struct {
ItemID uint32 `json:"item_id,omitempty"`
ID string `json:"id"`
SKU string `json:"sku"`
Title string `json:"title,omitempty"`
ItemID uint32 `json:"item_id,omitempty"`
ID string `json:"id"`
SKU string `json:"sku"`
Title string `json:"title,omitempty"`
PriceIncVat money.Cents `json:"price_inc_vat"`
Currency string `json:"currency,omitempty"`
// TaxClass uses a string identifier (see platform/tax class constants).
@@ -154,7 +155,9 @@ func Decode(raw []byte) (Projection, error) {
// catalog.projection_published carries two channels distinguished only by
// event.Event.Meta. The producer (slask-finder writeradmin) sets these keys and
// the consumer (ProjectionStore, consumer.go) reads them — one definition so the
// two never drift. See docs/catalog-projection.md.
// two never drift. The framing itself is domain-neutral and lives in
// platform/projection; catalog re-exports the constants so the producer/consumer
// keep their catalog.* names. See docs/catalog-projection.md.
//
// - A FULL SNAPSHOT is a framed begin → chunk* → end stream, all tagged with one
// MetaEpoch. begin/end carry MetaCount (total projections) for completeness
@@ -162,13 +165,13 @@ func Decode(raw []byte) (Projection, error) {
// - A DELTA has NO MetaSnapshotPhase key, and MetaEpoch names the snapshot base
// it builds on (consumers ignore a delta older than the base they hold).
const (
MetaSnapshotPhase = "snapshot" // begin|chunk|end; absent ⇒ delta
MetaEpoch = "epoch" // int64 (Unix-ms), monotonic across rebuilds
MetaSeq = "seq" // chunk sequence within a snapshot
MetaCount = "count" // total projections (begin/end only)
MetaTenant = "tenant" // namespace tag
MetaSnapshotPhase = projection.MetaSnapshotPhase // begin|chunk|end; absent ⇒ delta
MetaEpoch = projection.MetaEpoch // int64 (Unix-ms), monotonic across rebuilds
MetaSeq = projection.MetaSeq // chunk sequence within a snapshot
MetaCount = projection.MetaCount // total projections (begin/end only)
MetaTenant = projection.MetaTenant // namespace tag
SnapshotBegin = "begin"
SnapshotChunk = "chunk"
SnapshotEnd = "end"
SnapshotBegin = projection.SnapshotBegin
SnapshotChunk = projection.SnapshotChunk
SnapshotEnd = projection.SnapshotEnd
)
-488
View File
@@ -1,488 +0,0 @@
// Snapshot reader/writer for the published catalog Projection.
//
// Why a snapshot file at all? The bus is the live channel, but it doesn't
// carry the historical population on cold start. A snapshot file gives
// consumers (cart / checkout / order / UCP add-to-cart) a fast mmap-loaded
// cold-start substrate, so the first add-line request doesn't have to wait for
// the bus to replay. The bus then keeps it fresh.
//
// Format v2 (little-endian, fixed-width, mmap-friendly):
//
// header [72 bytes] magic(8) version(4) reserved(4) count(8)
// blobStart(8) recordStart(8) tenant(32, zero-padded)
// blobs variable count sequential JSON-encoded Projection bodies, no padding
// records count*44 each: sku(32, zero-padded) blobOffset(8) blobLength(4);
// sorted by SKU via the single compareSKU total order
//
// Tenant field at offsets 40–71 (zero-padded) names the namespace this
// snapshot covers. Independent SKU schemas can produce Projections into
// different tenant files without conflict. Hot-reload across tenants stays
// safe under SnapshotHolder (snapshot_manager.go).
//
// Sorting + binary search gives O(log N) per-SKU lookup with no on-disk hash
// table and no collisions. SKUs longer than 32 bytes are rejected at write
// time (Cart SKUs in this codebase are well under that).
//
// Memory model: the Reader holds one []byte for the entire file (mmap on
// Unix, ReadFile elsewhere). Per-SKU Get reads at most log2(N) record slots
// (each 44 B) and one JSON blob (~200 B). For 220 k projections the
// resident set is "pages touched this minute", not the whole file.
//
// SAFETY: SnapshotReader alone is NOT safe across hot-reload. During reload,
// in-flight Get() calls on the OLD reader continue to work until disposal
// of the old reader is finalised; calling SnapshotReader.Close() concurrently
// with Get() on the same reader risks unmapping pages-under-read (SIGBUS).
// Use SnapshotHolder (snapshot_manager.go) for any reload-bearing setup —
// it gives you an atomic.Pointer swap with a drain-grace (default 30s) so
// concurrent Gets on the OLD reader finish naturally before munmap fires.
package catalog
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"sort"
"sync"
)
const (
snapshotMagic uint64 = 0x3230564a53435050 // "PCSJ002" v2 (was 0x3130564a53435050 = v1)
snapshotVersion uint32 = 2
maxSKULen = 32
maxTenantLen = 32
headerSize = 8 + 4 + 4 + 8 + 8 + 8 + 32 // 72 bytes
recordSize = 32 + 8 + 4 // 44 bytes per record
)
var (
ErrSnapshotMagic = errors.New("catalog: not a snapshot file (bad magic)")
ErrSnapshotVersion = errors.New("catalog: unsupported snapshot version")
ErrSnapshotTruncated = errors.New("catalog: snapshot truncated")
ErrSnapshotBadBlob = errors.New("catalog: snapshot record points past file")
ErrSnapshotOverflow = errors.New("catalog: snapshot record-section size overflows uint64")
ErrSKUTooLong = fmt.Errorf("catalog: SKU longer than %d bytes", maxSKULen)
ErrTenantTooLong = fmt.Errorf("catalog: tenant name longer than %d bytes", maxTenantLen)
)
// The snapshot is generic over the stored value T (see SnapshotWriter[T] /
// SnapshotReader[T] below). The binary format, sorting, mmap, and bounds-check
// logic is value-agnostic and lives in the unexported raw* layer; the generic
// wrappers only marshal T → blob (write) and blob → T (read). The producer
// stores catalog.Projection; each consumer stores its OWN projected type via a
// transform (see ProjectionStore[T] in consumer.go) — that's the per-consumer
// read-only projection from docs/catalog-projection.md.
// rawSnapshotWriter is the value-agnostic writer: it indexes (sku → blob bytes).
// Single-shot: append() all, then write() once (atomic rename).
type rawSnapshotWriter struct {
tenant string
blobs []byte
records []snapshotRecord
dirty bool
}
// snapshotRecord is the on-disk layout: SKU zero-padded to 32 bytes, then
// an 8-byte offset (from file start) plus a 4-byte length for the JSON blob.
type snapshotRecord struct {
SKU [32]byte
BlobOffset uint64
BlobLength uint32
}
// append adds a pre-encoded blob under sku. ErrSKUTooLong if sku exceeds the
// on-disk width.
func (w *rawSnapshotWriter) append(sku string, blob []byte) error {
if len(sku) > maxSKULen {
return ErrSKUTooLong
}
var rec snapshotRecord
copy(rec.SKU[:], sku)
rec.BlobOffset = uint64(headerSize + len(w.blobs))
rec.BlobLength = uint32(len(blob))
w.blobs = append(w.blobs, blob...)
w.records = append(w.records, rec)
w.dirty = true
return nil
}
// SnapshotWriter[T] is single-shot: Append() all values, then Write() once
// (atomic rename-into-place so readers never see a partial file). T is the
// stored value type (Projection for the producer; a consumer-specific type for
// a ProjectionStore[T]).
type SnapshotWriter[T any] struct {
raw rawSnapshotWriter
}
// NewSnapshotWriter returns an empty writer for value type T. Caller Appends
// every value and calls Write once.
func NewSnapshotWriter[T any]() *SnapshotWriter[T] {
return &SnapshotWriter[T]{}
}
// WithTenant tags this writer with a tenant namespace that lands in the
// snapshot header. Optional — empty tenant gets zero-padded; readers see an
// empty string when the writer was untagged.
func (w *SnapshotWriter[T]) WithTenant(tenant string) *SnapshotWriter[T] {
w.raw.tenant = tenant
return w
}
// Append encodes v and indexes it under sku. Returns ErrSKUTooLong if sku
// exceeds the on-disk width; returns marshal errors as wrapped fmt.Errorf.
// SKU is explicit because T is opaque — the caller knows the key.
func (w *SnapshotWriter[T]) Append(sku string, v T) error {
if len(sku) > maxSKULen {
return ErrSKUTooLong
}
jb, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("catalog: marshal snapshot value %q: %w", sku, err)
}
return w.raw.append(sku, jb)
}
// Len returns how many values have been Append'd.
func (w *SnapshotWriter[T]) Len() int { return len(w.raw.records) }
// Write serialises to dir/name (atomic temp+rename). See rawSnapshotWriter.write.
func (w *SnapshotWriter[T]) Write(dir, name string) (finalPath string, err error) {
return w.raw.write(dir, name)
}
// write serialises to a temp file in dir, fsync's, then renames into the
// final path. Records are sorted by SKU asc before serialisation so the
// reader's binary search works.
func (w *rawSnapshotWriter) write(dir, name string) (finalPath string, err error) {
if w.dirty {
sort.Slice(w.records, func(i, j int) bool {
return compareSKU(w.records[i].SKU[:], w.records[j].SKU[:]) < 0
})
w.dirty = false
}
if len(w.tenant) > maxTenantLen {
err = ErrTenantTooLong
return "", err
}
if err = os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
finalPath = filepath.Join(dir, name)
tmp := finalPath + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return "", err
}
defer func() {
if err != nil {
_ = os.Remove(tmp)
_ = f.Close()
}
}()
// Header: fixed 72 bytes (8 + 4 + 4 + 8 + 8 + 8 + 32).
blobStart := uint64(headerSize)
recordStart := blobStart + uint64(len(w.blobs))
var hdr [headerSize]byte
binary.LittleEndian.PutUint64(hdr[0:8], snapshotMagic)
binary.LittleEndian.PutUint32(hdr[8:12], snapshotVersion)
binary.LittleEndian.PutUint32(hdr[12:16], 0)
binary.LittleEndian.PutUint64(hdr[16:24], uint64(len(w.records)))
binary.LittleEndian.PutUint64(hdr[24:32], blobStart)
binary.LittleEndian.PutUint64(hdr[32:40], recordStart)
copy(hdr[40:40+len(w.tenant)], w.tenant) // rest is zero-pad
if _, werr := f.Write(hdr[:]); werr != nil {
err = werr
return "", err
}
if _, werr := f.Write(w.blobs); werr != nil {
err = werr
return "", err
}
recBytes := make([]byte, recordSize*len(w.records))
for i, r := range w.records {
off := i * recordSize
copy(recBytes[off:off+32], r.SKU[:])
binary.LittleEndian.PutUint64(recBytes[off+32:off+40], r.BlobOffset)
binary.LittleEndian.PutUint32(recBytes[off+40:off+44], r.BlobLength)
}
if _, werr := f.Write(recBytes); werr != nil {
err = werr
return "", err
}
if werr := f.Sync(); werr != nil {
err = werr
return "", err
}
if werr := f.Close(); werr != nil {
err = werr
return "", err
}
if werr := os.Rename(tmp, finalPath); werr != nil {
err = werr
return "", err
}
return finalPath, nil
}
// compareSKU defines the SINGLE TOTAL ORDER for SKUs across both Writer
// (uses it for sort.Slice) and Reader (uses it for binary search). One
// definition; invariant test verifies the on-disk result is sorted under
// it. Source SKUs SHOULD NOT contain embedded null bytes — the padded
// comparator treats them as terminators, which silently truncates an SKU.
// Realistically no SKU has NUL.
func compareSKU(padded, other []byte) int {
// padded is a fixed-size zero-padded slot; trim trailing zeros.
end := len(padded)
for end > 0 && padded[end-1] == 0 {
end--
}
a := padded[:end]
b := other
n := len(a)
if len(b) < n {
n = len(b)
}
for i := 0; i < n; i++ {
if a[i] != b[i] {
if a[i] < b[i] {
return -1
}
return 1
}
}
switch {
case len(a) < len(b):
return -1
case len(a) > len(b):
return 1
default:
return 0
}
}
// compareRecordLookup is compareSKU specialised for Reader lookups so the
// argument order matches the lex order: "padded" (fixed-slot) and "want"
// (query).
//
// compareSKU is symmetric so this is just an alias used by Reader code
// paths to make intent obvious. Any change to the total order here MUST be
// reflected in Writer.sort.Slice and verified by the invariant test.
func compareRecordLookup(padded, want []byte) int {
return compareSKU(padded, want)
}
// rawSnapshotReader is the value-agnostic reader: binary search by SKU, returns
// the raw blob bytes. Concurrency-safe for parallel getRaw; NOT safe racing
// Close with getRaw (see package doc — use SnapshotHolder for reload).
type rawSnapshotReader struct {
data []byte
count uint64
blobStart uint64
recordStart uint64
Tenant string // populated from header at Open time; "" if not set
closeOnce sync.Once
closer func() error // munmap + close on Unix; no-op on ReadFile fallback
}
// SnapshotReader[T] is the typed view over a raw snapshot: Get decodes the blob
// into T. Read-only and concurrency-safe — parallel Get calls are OK. NOT safe
// racing Close concurrently with Get; use SnapshotHolder[T] for reload.
type SnapshotReader[T any] struct {
raw *rawSnapshotReader
}
// ReadSnapshotFile opens + parses a snapshot file as a reader of T. On Unix uses
// mmap; on other platforms reads into memory (functionally identical, higher RSS).
func ReadSnapshotFile[T any](path string) (*SnapshotReader[T], error) {
rr, err := readRawSnapshotFile(path)
if err != nil {
return nil, err
}
return &SnapshotReader[T]{raw: rr}, nil
}
// NewSnapshotReader wraps an already-loaded []byte as a reader of T (use in
// tests; production paths go through ReadSnapshotFile).
func NewSnapshotReader[T any](data []byte) (*SnapshotReader[T], error) {
rr, err := newRawSnapshotReader(data)
if err != nil {
return nil, err
}
return &SnapshotReader[T]{raw: rr}, nil
}
// Get returns the value for sku, or (zero, false) if absent / undecodable.
func (r *SnapshotReader[T]) Get(sku string) (T, bool) {
var zero T
blob, ok := r.raw.getRaw(sku)
if !ok {
return zero, false
}
var v T
if err := json.Unmarshal(blob, &v); err != nil {
return zero, false
}
return v, true
}
// Len returns the number of values in this snapshot.
func (r *SnapshotReader[T]) Len() int { return r.raw.Len() }
// Tenant returns the snapshot's namespace tag ("" if untagged).
func (r *SnapshotReader[T]) Tenant() string { return r.raw.Tenant }
// Close releases the file handle and mmap region. Idempotent.
func (r *SnapshotReader[T]) Close() error { return r.raw.Close() }
// readRawSnapshotFile opens + parses a snapshot file. On Unix uses mmap; on
// other platforms reads into memory (functionally identical, higher RSS).
func readRawSnapshotFile(path string) (*rawSnapshotReader, error) {
data, closer, err := loadSnapshotBytes(path)
if err != nil {
return nil, err
}
r, err := newRawSnapshotReader(data)
if err != nil {
if closer != nil {
_ = closer()
}
return nil, err
}
r.closer = closer
return r, nil
}
func newRawSnapshotReader(data []byte) (*rawSnapshotReader, error) {
if len(data) < headerSize {
return nil, ErrSnapshotTruncated
}
magic := binary.LittleEndian.Uint64(data[0:8])
if magic != snapshotMagic {
return nil, ErrSnapshotMagic
}
version := binary.LittleEndian.Uint32(data[8:12])
if version != snapshotVersion {
return nil, ErrSnapshotVersion
}
count := binary.LittleEndian.Uint64(data[16:24])
blobStart := binary.LittleEndian.Uint64(data[24:32])
recordStart := binary.LittleEndian.Uint64(data[32:40])
if blobStart != uint64(headerSize) {
return nil, fmt.Errorf("catalog: blobStart=%d expected %d", blobStart, headerSize)
}
// Multiplication overflow guard: for a malformed/tampered count, the
// naive int(count)*recordSize in Go silently wraps on overflow,
// making expectedSize negative and bypassing the truncation guard.
// Compute as uint64 with an explicit overflow check.
recordBytes := uint64(recordSize) * count
if count > 0 && recordBytes/uint64(count) != uint64(recordSize) {
return nil, fmt.Errorf("%w: count*recordSize overflows (count=%d)", ErrSnapshotOverflow, count)
}
if recordStart > math.MaxUint64-recordBytes {
return nil, fmt.Errorf("%w: recordStart+records exceeds uint64 (recordStart=%d count=%d)",
ErrSnapshotOverflow, recordStart, count)
}
expectedSize := recordStart + recordBytes
if uint64(len(data)) < expectedSize {
return nil, ErrSnapshotTruncated
}
// Per-record blob-offset bounds check: defends against malformed /
// tampered files where the record section is intact but the blob
// pointer lies past EOF. A linear pass; cheap (44 B × count at open).
//
// Avoid the naive blobOff+blobLen > dataLen check — blobOff can be
// MaxUint64 in tampered input and uint64 addition wraps, falsely
// passing the test. Split the check into overflow-safe parts.
dataLen := uint64(len(data))
for i := uint64(0); i < count; i++ {
recOff := recordStart + i*uint64(recordSize)
blobLen := binary.LittleEndian.Uint32(data[recOff+40 : recOff+44])
blobOff := binary.LittleEndian.Uint64(data[recOff+32 : recOff+40])
if blobOff > dataLen || uint64(blobLen) > dataLen-blobOff {
return nil, fmt.Errorf("%w: record %d blobOff=%d len=%d exceeds file size %d",
ErrSnapshotBadBlob, i, blobOff, blobLen, dataLen)
}
}
// Tenant: read zero-padded bytes at offset 40, trim trailing zeros.
tenantBytes := data[40 : 40+maxTenantLen]
tenantEnd := len(tenantBytes)
for tenantEnd > 0 && tenantBytes[tenantEnd-1] == 0 {
tenantEnd--
}
tenant := string(tenantBytes[:tenantEnd])
return &rawSnapshotReader{
data: data,
count: count,
blobStart: blobStart,
recordStart: recordStart,
Tenant: tenant,
}, nil
}
// Len returns the number of records in this snapshot.
func (r *rawSnapshotReader) Len() int { return int(r.count) }
// Close releases the file handle and mmap region (if any). Idempotent — safe
// to call from multiple goroutines (uses sync.Once under the hood).
//
// SAFETY: this is NOT safe against concurrent getRaw() — see package doc. Use
// SnapshotHolder for any reload-bearing workflow.
func (r *rawSnapshotReader) Close() error {
var err error
r.closeOnce.Do(func() {
if r.closer != nil {
err = r.closer()
}
})
return err
}
// getRaw returns the blob bytes for sku, or (nil, false) if not found. The
// returned slice aliases the mmap region — valid until Close. O(log N) binary
// search on the sorted record section.
func (r *rawSnapshotReader) getRaw(sku string) ([]byte, bool) {
if r.count == 0 || sku == "" {
return nil, false
}
skuBytes := []byte(sku)
if len(skuBytes) > maxSKULen {
return nil, false
}
lo, hi := uint64(0), r.count
for lo < hi {
mid := (lo + hi) / 2
recOff := int(r.recordStart) + int(mid)*recordSize
recSKU := r.data[recOff : recOff+32]
cmp := compareRecordLookup(recSKU, skuBytes)
switch {
case cmp < 0:
lo = mid + 1
case cmp > 0:
hi = mid
default:
blobLen := binary.LittleEndian.Uint32(r.data[recOff+40 : recOff+44])
blobOff := binary.LittleEndian.Uint64(r.data[recOff+32 : recOff+40])
// Per-record bounds check at Open guarantees this slice is in-range.
return r.data[blobOff : blobOff+uint64(blobLen)], true
}
}
return nil, false
}
-28
View File
@@ -1,28 +0,0 @@
// Local helpers for snapshot_test.go — production code does not see them.
package catalog
import (
"os"
)
func os2WriteFile(path string, data []byte, perm os.FileMode) error {
return os.WriteFile(path, data, perm)
}
func os2Stat(path string) (os.FileInfo, error) {
return os.Stat(path)
}
// appendP appends a Projection keyed by its own SKU — the producer/test pattern
// over the now-generic SnapshotWriter[Projection] (Append takes an explicit key).
func appendP(w *SnapshotWriter[Projection], p Projection) error {
return w.Append(p.SKU, p)
}
func repeat32(s string, n int) string {
out := make([]byte, 0, n*len(s))
for i := 0; i < n; i++ {
out = append(out, s...)
}
return string(out)
}
-234
View File
@@ -1,234 +0,0 @@
// SnapshotHolder provides atomic hot-reload for SnapshotReader and solves the
// classic "Get concurrent with Close/munmap" race that would otherwise produce
// uncatchable SIGBUS / SIGSEGV under reload.
//
// Safety is an RWMutex (readMu): Get holds RLock across the load-and-read of a
// reader, and a reader is only Close()d (munmap) under Lock, so a reader can
// never be unmapped while a Get is reading it — regardless of how long that
// Get's goroutine was descheduled. (A drain-grace timer alone, the original
// approach, is only best-effort: a goroutine paused longer than the grace can
// still read freed pages. TestSnapshotHolder_SwapRace reproduces that crash.)
//
// The drain grace is retained as a close-BATCHING optimization: it defers and
// coalesces reader closes onto the reaper tick instead of taking the exclusive
// Lock on every swap. Pattern source: slask-finder's mmap+reload stores.
// See docs/inventory-shape.md 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[T any] struct {
current atomic.Pointer[SnapshotReader[T]]
// readMu makes reclamation race-free: Get holds RLock across the
// Load-and-read of a reader, and a reader is only Close()d (munmap) under
// Lock — so a reader can never be unmapped while a Get is reading it, even
// if that Get's goroutine was descheduled past the drain grace. The grace
// (below) is now only close-BATCHING — defer + coalesce closes off the swap
// path — NOT the safety mechanism it was before (which was best-effort and
// could SIGSEGV under a long enough scheduler pause).
readMu sync.RWMutex
drainsMu sync.Mutex
drains []*pendingClose[T]
drainGrace time.Duration
stopCh chan struct{}
once sync.Once
}
type pendingClose[T any] struct {
r *SnapshotReader[T]
expireAt time.Time
}
// NewSnapshotHolder returns a holder of T 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[T any](grace time.Duration) *SnapshotHolder[T] {
if grace <= 0 {
grace = DefaultDrainGrace
}
h := &SnapshotHolder[T]{
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[T]) Open(path string) error {
r, err := ReadSnapshotFile[T](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[T]) Reload(path string) error {
r, err := ReadSnapshotFile[T](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[T]) Set(r *SnapshotReader[T]) {
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[T]{
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. The RLock is held across the Load AND
// the read so a concurrent reaper can't munmap this reader mid-read.
func (h *SnapshotHolder[T]) Get(sku string) (T, bool) {
h.readMu.RLock()
defer h.readMu.RUnlock()
r := h.current.Load()
if r == nil {
var zero T
return zero, false
}
return r.Get(sku)
}
// Len returns the count of the current reader (0 if empty).
func (h *SnapshotHolder[T]) Len() int {
h.readMu.RLock()
defer h.readMu.RUnlock()
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[T]) Close() error {
var firstErr error
h.once.Do(func() {
close(h.stopCh)
// Close under Lock so no in-flight Get is reading these readers' mmap
// when we munmap them.
h.readMu.Lock()
r := h.current.Swap(nil)
if r != nil {
if err := r.Close(); err != nil {
firstErr = err
}
}
h.readMu.Unlock()
h.drainsMu.Lock()
drained := h.drains
h.drains = nil
h.drainsMu.Unlock()
for _, d := range drained {
h.readMu.Lock()
err := d.r.Close()
h.readMu.Unlock()
if 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[T]) 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[T]) reapNow() {
now := time.Now()
var alive []*pendingClose[T]
var reaped []*pendingClose[T]
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 {
// Lock briefly so the munmap can't race an in-flight Get on this reader.
// Gets that loaded this reader hold RLock and finish before the Lock is
// granted; Gets starting after see the swapped-in reader, never this one.
h.readMu.Lock()
_ = d.r.Close()
h.readMu.Unlock()
}
}
func (h *SnapshotHolder[T]) 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()
}
}
}
-290
View File
@@ -1,290 +0,0 @@
package catalog
import (
"fmt"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"git.k6n.net/mats/platform/money"
)
// TestSnapshotHolder_SwapRace storms the holder: 100 readers Get in tight loops
// while a rotator Reloads a fresh snapshot every 50ms. It asserts the holder's
// safety + liveness invariants under concurrency (run with -race):
//
// - No panic / SIGBUS — completion is the proof the drain grace never unmaps a
// reader while an in-flight Get is reading it.
// - No torn reads — every hit decodes to a valid Projection (SKU matches,
// price > 0). A read of a munmapped page would crash or fail to decode.
// - No gap — since every snapshot version contains every SKU, a Get must never
// miss; misses would mean the holder briefly exposed an empty/nil reader
// mid-swap. (Exact "stale beyond drain grace" timing is intentionally not
// asserted — it's timing-flaky, and the no-torn-read + no-crash checks
// already cover the drain-safety property it stands in for.)
func TestSnapshotHolder_SwapRace(t *testing.T) {
dir := t.TempDir()
const versions = 4
const skuCount = 200
paths := make([]string, versions)
for v := 0; v < versions; v++ {
w := NewSnapshotWriter[Projection]()
for i := 0; i < skuCount; i++ {
sku := fmt.Sprintf("SKU-%03d", i)
// price encodes the version (always > 0) so a torn read is detectable.
if err := w.Append(sku, Projection{ID: "sku:" + sku, SKU: sku, PriceIncVat: money.Cents(int64((v + 1) * 100))}); err != nil {
t.Fatal(err)
}
}
p, err := w.Write(dir, fmt.Sprintf("snap-%d.bin", v))
if err != nil {
t.Fatal(err)
}
paths[v] = p
}
h := NewSnapshotHolder[Projection](100 * time.Millisecond)
if err := h.Open(paths[0]); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = h.Close() })
stop := make(chan struct{})
var reads, misses int64
var torn int32
var wg sync.WaitGroup
for r := 0; r < 100; r++ {
wg.Add(1)
go func(seed int) {
defer wg.Done()
i := seed
for {
select {
case <-stop:
return
default:
}
sku := fmt.Sprintf("SKU-%03d", i%skuCount)
if p, ok := h.Get(sku); ok {
if p.SKU != sku || p.PriceIncVat <= 0 {
atomic.StoreInt32(&torn, 1)
return
}
atomic.AddInt64(&reads, 1)
} else {
atomic.AddInt64(&misses, 1)
}
i++
}
}(r)
}
wg.Add(1)
go func() {
defer wg.Done()
tick := time.NewTicker(50 * time.Millisecond)
defer tick.Stop()
v := 0
for {
select {
case <-stop:
return
case <-tick.C:
v = (v + 1) % versions
if err := h.Reload(paths[v]); err != nil {
atomic.StoreInt32(&torn, 1)
return
}
}
}
}()
time.Sleep(1500 * time.Millisecond)
close(stop)
wg.Wait()
if atomic.LoadInt32(&torn) != 0 {
t.Fatal("torn/garbage read or reload error during swap storm")
}
if m := atomic.LoadInt64(&misses); m != 0 {
t.Fatalf("got %d misses — holder exposed an empty reader mid-swap (every version has every SKU)", m)
}
if atomic.LoadInt64(&reads) == 0 {
t.Fatal("no successful reads — test did not exercise the holder")
}
t.Logf("SwapRace OK: %d reads across %d rotations, 0 torn, 0 miss", atomic.LoadInt64(&reads), versions)
}
// fakeCloser counts how many times it's been called. Used to assert the
// drain-grace semantics of SnapshotHolder.
type fakeCloser struct{ hits int }
func (f *fakeCloser) Close() error { f.hits++; return nil }
// withFakeCloser swaps SnapshotReader's closer for our counter. Returns the
// reader and the counter; tests then call holder.Set(reloaded) and assert
// that close is invoked after the drain grace.
func withFakeCloser(t *testing.T, path string, fc *fakeCloser) *SnapshotReader[Projection] {
t.Helper()
r, err := ReadSnapshotFile[Projection](path)
if err != nil {
t.Fatal(err)
}
// replace real closer with our counter; the test asserts only via fc
r.raw.closer = fc.Close
return r
}
// TestSnapshotHolderReloadDrainsOld — Set swaps the current reader; the
// previous reader is queued. Before grace: still Get-able. After grace:
// reaped and Close fires.
//
// We use a very short grace so the test doesn't sleep long.
func TestSnapshotHolderReloadDrainsOld(t *testing.T) {
dir := t.TempDir()
// 1) Build a valid first snapshot (skus A-1 / A-2).
w := NewSnapshotWriter[Projection]()
if err := appendP(w, makeProjection("A-1", 100)); err != nil {
t.Fatal(err)
}
if err := appendP(w, makeProjection("A-2", 200)); err != nil {
t.Fatal(err)
}
if _, err := w.Write(dir, "first.bin"); err != nil {
t.Fatal(err)
}
firstCloser := &fakeCloser{}
first := withFakeCloser(t, filepath.Join(dir, "first.bin"), firstCloser)
defer first.Close()
// 2) Build a second snapshot (skus B-1).
w2 := NewSnapshotWriter[Projection]()
if err := appendP(w2, makeProjection("B-1", 300)); err != nil {
t.Fatal(err)
}
if _, err := w2.Write(dir, "second.bin"); err != nil {
t.Fatal(err)
}
secondCloser := &fakeCloser{}
second := withFakeCloser(t, filepath.Join(dir, "second.bin"), secondCloser)
defer second.Close()
h := NewSnapshotHolder[Projection](50 * time.Millisecond)
defer h.Close()
h.Set(first)
if h.Len() != 2 {
t.Errorf("Len after first Set: got %d want 2", h.Len())
}
if _, ok := h.Get("A-1"); !ok {
t.Error("A-1 not found in initial state")
}
// 3) Swap to second — first is now queued.
h.Set(second)
if h.Len() != 1 {
t.Errorf("Len after second Set: got %d want 1", h.Len())
}
if firstCloser.hits != 0 {
t.Errorf("firstCloser hits: got %d want 0 (still in drain grace)", firstCloser.hits)
}
if h.DrainCount() != 1 {
t.Errorf("DrainCount: got %d want 1", h.DrainCount())
}
// 4) Wait past grace, force a reap tick.
time.Sleep(80 * time.Millisecond)
h.reapNow()
if firstCloser.hits != 1 {
t.Errorf("firstCloser hits: got %d want 1 (should have reaped)", firstCloser.hits)
}
if h.DrainCount() != 0 {
t.Errorf("DrainCount after reap: got %d want 0", h.DrainCount())
}
}
// TestSnapshotHolderEmptyGet — holder with no current serves empty.
func TestSnapshotHolderEmptyGet(t *testing.T) {
h := NewSnapshotHolder[Projection](100 * time.Millisecond)
defer h.Close()
if _, ok := h.Get("anything"); ok {
t.Error("empty holder should return false")
}
if h.Len() != 0 {
t.Errorf("Len: got %d want 0", h.Len())
}
}
// TestSnapshotHolderCloseImmediatelyReaps — Close with pending drains
// should close them now (no grace wait). Confirms the drain queue is
// drained on Close.
func TestSnapshotHolderCloseImmediatelyReaps(t *testing.T) {
dir := t.TempDir()
w := NewSnapshotWriter[Projection]()
if err := appendP(w, makeProjection("X-1", 1)); err != nil {
t.Fatal(err)
}
if _, err := w.Write(dir, "x.bin"); err != nil {
t.Fatal(err)
}
fc1 := &fakeCloser{}
r1 := withFakeCloser(t, filepath.Join(dir, "x.bin"), fc1)
w2 := NewSnapshotWriter[Projection]()
if err := appendP(w2, makeProjection("Y-1", 2)); err != nil {
t.Fatal(err)
}
if _, err := w2.Write(dir, "y.bin"); err != nil {
t.Fatal(err)
}
fc2 := &fakeCloser{}
r2 := withFakeCloser(t, filepath.Join(dir, "y.bin"), fc2)
h := NewSnapshotHolder[Projection](10 * time.Second) // long grace so reap wouldn't happen on its own
h.Set(r1)
h.Set(r2)
if fc1.hits != 0 || fc2.hits != 0 {
t.Errorf("premature Close hits: fc1=%d fc2=%d", fc1.hits, fc2.hits)
}
if err := h.Close(); err != nil {
t.Errorf("Close: %v", err)
}
if fc1.hits != 1 || fc2.hits != 1 {
t.Errorf("after Close: fc1=%d fc2=%d want 1 each", fc1.hits, fc2.hits)
}
}
// TestSnapshotHolderIdentityReload — Open the same path twice; current reader
// is replaced; DrainCount returns 1 (old queued). Old's data still served
// until grace elapses.
func TestSnapshotHolderIdentityReload(t *testing.T) {
dir := t.TempDir()
w := NewSnapshotWriter[Projection]()
if err := appendP(w, makeProjection("ID-1", 1)); err != nil {
t.Fatal(err)
}
if _, err := w.Write(dir, "id.bin"); err != nil {
t.Fatal(err)
}
h := NewSnapshotHolder[Projection](10 * time.Second)
defer h.Close()
if err := h.Reload(filepath.Join(dir, "id.bin")); err != nil {
t.Fatal(err)
}
if err := h.Reload(filepath.Join(dir, "id.bin")); err != nil {
t.Fatal(err)
}
if h.DrainCount() != 1 {
t.Errorf("DrainCount after two reloads of same path: got %d want 1", h.DrainCount())
}
if _, ok := h.Get("ID-1"); !ok {
t.Error("current reader did not surface ID-1")
}
}
-17
View File
@@ -1,17 +0,0 @@
//go:build !unix
// Non-unix fallback for SnapshotReader: ReadFile into a []byte. Functionally
// identical to the unix mmap path — same reader code, same lookups — just
// pays the full resident-set cost at Open. Production mmap-on-Linux/Darwin;
// this exists for windows / plan9 / wasm builds.
package catalog
import "os"
func loadSnapshotBytes(path string) (data []byte, closer func() error, err error) {
data, err = os.ReadFile(path)
if err != nil {
return nil, nil, err
}
return data, func() error { return nil }, nil
}
-448
View File
@@ -1,448 +0,0 @@
package catalog
import (
"encoding/binary"
"errors"
"fmt"
"math"
"math/rand"
"path/filepath"
"testing"
"time"
"git.k6n.net/mats/platform/money"
)
// TestSnapshotRoundTrip — write 1000 projections in shuffled order, read back
// and verify all 1000 are found at the right SKU.
func TestSnapshotRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "snap.bin")
const n = 1000
w := NewSnapshotWriter[Projection]().WithTenant("default")
// intentionally shuffled input — sort is what reorders at Write.
perm := rand.Perm(n)
for _, i := range perm {
sku := fmt.Sprintf("SKU-%05d", i)
if err := appendP(w, makeProjection(sku, int64(100+(i%97)*100))); err != nil {
t.Fatalf("Append %s: %v", sku, err)
}
}
if w.Len() != n {
t.Fatalf("Len: got %d want %d", w.Len(), n)
}
final, err := w.Write(dir, "snap.bin")
if err != nil {
t.Fatalf("Write: %v", err)
}
if final != path {
t.Errorf("Write returned %q expected %q", final, path)
}
r, err := ReadSnapshotFile[Projection](path)
if err != nil {
t.Fatalf("ReadSnapshotFile: %v", err)
}
defer r.Close()
if r.Len() != n {
t.Errorf("Len: got %d want %d", r.Len(), n)
}
if r.Tenant() != "default" {
t.Errorf("Tenant: got %q want %q", r.Tenant(), "default")
}
// every SKU present and correct
for _, i := range rand.Perm(n)[:50] { // probe 50 random lookups
sku := fmt.Sprintf("SKU-%05d", i)
got, ok := r.Get(sku)
if !ok {
t.Errorf("Get(%q) not found", sku)
continue
}
want := money.Cents(100 + (i%97)*100)
if got.PriceIncVat != want {
t.Errorf("Get(%q) PriceIncVat: got %d want %d", sku, got.PriceIncVat, want)
}
}
// missing SKU
if _, ok := r.Get("NOPE-99999"); ok {
t.Error("Get on missing SKU should return ok=false")
}
}
// TestSnapshotCompareSKUInvariant — verify the on-disk record section is
// sorted under the same compareSKU total order that BinarySearch uses.
// If sort.Slice and Reader.Get ever drift apart, lookups silently miss
// — this test pins that invariant.
func TestSnapshotCompareSKUInvariant(t *testing.T) {
dir := t.TempDir()
w := NewSnapshotWriter[Projection]()
// deliberately inverted order after Append (so sort has work to do)
perm := rand.Perm(500)
for _, i := range perm {
sku := fmt.Sprintf("XX-%05d", i)
if err := appendP(w, makeProjection(sku, int64(i))); err != nil {
t.Fatal(err)
}
}
if _, err := w.Write(dir, "snap.bin"); err != nil {
t.Fatal(err)
}
r, err := ReadSnapshotFile[Projection](filepath.Join(dir, "snap.bin"))
if err != nil {
t.Fatal(err)
}
defer r.Close()
// Walk every record slot, harvest SKU bytes from the pad-then-trim
// form, and assert non-decreasing order under compareSKU.
prevSKU := ""
for i := 0; i < r.Len(); i++ {
recOff := int(r.raw.recordStart) + i*recordSize
raw := r.raw.data[recOff : recOff+32]
// trim trailing zeros
end := len(raw)
for end > 0 && raw[end-1] == 0 {
end--
}
cur := string(raw[:end])
if i > 0 {
if compareSKU(runesToPadded32(prevSKU), []byte(cur)) > 0 {
t.Errorf("record section out of order at i=%d: prev=%q cur=%q", i, prevSKU, cur)
}
}
prevSKU = cur
}
}
// runesToPadded32 zero-pads s into a 32-byte slot for compareSKU in the
// invariant test.
func runesToPadded32(s string) []byte {
out := make([]byte, 32)
copy(out, s)
return out
}
// TestSnapshotTenantRoundTrip — Writer.WithTenant lands in the header and
// Reader surfaces it. Without this, one file per tenant is just a convention
// with no on-disk evidence.
func TestSnapshotTenantRoundTrip(t *testing.T) {
dir := t.TempDir()
w := NewSnapshotWriter[Projection]().WithTenant("bygglagret")
if err := appendP(w, makeProjection("ABC-1", 100)); err != nil {
t.Fatal(err)
}
if _, err := w.Write(dir, "snap.bin"); err != nil {
t.Fatal(err)
}
r, err := ReadSnapshotFile[Projection](filepath.Join(dir, "snap.bin"))
if err != nil {
t.Fatal(err)
}
defer r.Close()
if r.Tenant() != "bygglagret" {
t.Errorf("Tenant: got %q want %q", r.Tenant(), "bygglagret")
}
// Empty tenant writes zero-padded.
w2 := NewSnapshotWriter[Projection]()
if err := appendP(w2, makeProjection("ABC-2", 200)); err != nil {
t.Fatal(err)
}
if _, err := w2.Write(dir, "snap2.bin"); err != nil {
t.Fatal(err)
}
r2, err := ReadSnapshotFile[Projection](filepath.Join(dir, "snap2.bin"))
if err != nil {
t.Fatal(err)
}
defer r2.Close()
if r2.Tenant() != "" {
t.Errorf("Tenant (no tag): got %q want empty", r2.Tenant())
}
}
// TestSnapshotTenantTooLong — over-long tenant name rejected at Write.
func TestSnapshotTenantTooLong(t *testing.T) {
dir := t.TempDir()
w := NewSnapshotWriter[Projection]().WithTenant(string(make([]byte, 33)))
if err := appendP(w, makeProjection("ABC", 1)); err != nil {
t.Fatal(err)
}
if _, err := w.Write(dir, "snap.bin"); err == nil {
t.Error("expected error for over-long tenant")
} else if !errors.Is(err, ErrTenantTooLong) {
t.Errorf("got %v want ErrTenantTooLong", err)
}
}
// TestSnapshotCountOverflowRejected — tampered count that would cause
// recordStart + records*recordSize to overflow uint64 must be rejected
// with ErrSnapshotOverflow (formerly a silent wraparound vulnerability).
func TestSnapshotCountOverflowRejected(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "over.bin")
bs := make([]byte, headerSize)
binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic)
binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion)
binary.LittleEndian.PutUint32(bs[12:16], 0)
// count that overflows when multiplied by recordSize=44
binary.LittleEndian.PutUint64(bs[16:24], math.MaxUint64/uint64(recordSize)+1)
binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize))
binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize))
if err := os2WriteFile(path, bs, 0o644); err != nil {
t.Fatal(err)
}
if _, err := ReadSnapshotFile[Projection](path); !errors.Is(err, ErrSnapshotOverflow) {
t.Errorf("overflow count rejected with %v, want ErrSnapshotOverflow", err)
}
}
// TestSnapshotAtomicSwap — verify the temporary-then-rename discipline never
// leaves a half-written snap.bin visible to readers.
func TestSnapshotAtomicSwap(t *testing.T) {
dir := t.TempDir()
w := NewSnapshotWriter[Projection]()
mustAppend := func(w *SnapshotWriter[Projection], sku string, price int64) {
t.Helper()
if err := appendP(w, makeProjection(sku, price)); err != nil {
t.Fatalf("Append %s: %v", sku, err)
}
}
mustAppend(w, "AAA", 100)
final, err := w.Write(dir, "snap.bin")
if err != nil {
t.Fatal(err)
}
// tmp must not exist anymore
if _, err := os2Stat(final + ".tmp"); err == nil {
t.Errorf(".tmp still present after Write")
}
// replace
w2 := NewSnapshotWriter[Projection]()
mustAppend(w2, "ZZZ", 200)
if _, err := w2.Write(dir, "snap.bin"); err != nil {
t.Fatal(err)
}
r, err := ReadSnapshotFile[Projection](final)
if err != nil {
t.Fatal(err)
}
defer r.Close()
if _, ok := r.Get("ZZZ"); !ok {
t.Error("second-snapshot Get(ZZZ) missing")
}
if _, ok := r.Get("AAA"); ok {
t.Error("first-snapshot residue Get(AAA) found — atomic swap bug")
}
}
// TestSnapshotRejectBadInput — magic, version, truncation, blob-offset OOB.
func TestSnapshotRejectBadInput(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.bin")
// Empty file: truncated.
if err := os2WriteFile(path, []byte{}, 0o644); err != nil {
t.Fatal(err)
}
if _, err := ReadSnapshotFile[Projection](path); !errors.Is(err, ErrSnapshotTruncated) {
t.Errorf("empty file rejected with %v, want ErrSnapshotTruncated", err)
}
// Wrong magic. The fixture must be at least headerSize bytes long
// (v2 header is 72 B), otherwise we'd hit ErrSnapshotTruncated
// before the magic check fires.
wrongMagic := make([]byte, headerSize+1)
for i := range wrongMagic {
wrongMagic[i] = 0xff
}
if err := os2WriteFile(path, wrongMagic, 0o644); err != nil {
t.Fatal(err)
}
if _, err := ReadSnapshotFile[Projection](path); !errors.Is(err, ErrSnapshotMagic) {
t.Errorf("wrong magic rejected with %v, want ErrSnapshotMagic", err)
}
// Truncated mid-record. A valid header claiming 5 records but with no
// record section.
if err := os2WriteFile(path, buildValidHeader(5), 0o644); err != nil {
t.Fatal(err)
}
if _, err := ReadSnapshotFile[Projection](path); !errors.Is(err, ErrSnapshotTruncated) {
t.Errorf("truncated snapshot rejected with %v, want ErrSnapshotTruncated", err)
}
// Tampered blob offsets: valid header, valid record section with a
// blob pointer that lies past EOF. Should reject with ErrSnapshotBadBlob.
tampered := buildTamperedHeader()
if err := os2WriteFile(path, tampered, 0o644); err != nil {
t.Fatal(err)
}
if _, err := ReadSnapshotFile[Projection](path); !errors.Is(err, ErrSnapshotBadBlob) {
t.Errorf("tampered-blob rejected with %v, want ErrSnapshotBadBlob", err)
}
}
// TestSnapshotEmpty — empty (count=0) snapshot is valid; Get returns false.
func TestSnapshotEmpty(t *testing.T) {
dir := t.TempDir()
w := NewSnapshotWriter[Projection]()
if _, err := w.Write(dir, "empty.bin"); err != nil {
t.Fatal(err)
}
r, err := ReadSnapshotFile[Projection](filepath.Join(dir, "empty.bin"))
if err != nil {
t.Fatal(err)
}
defer r.Close()
if r.Len() != 0 {
t.Errorf("Len: got %d want 0", r.Len())
}
if _, ok := r.Get("anything"); ok {
t.Error("empty snapshot should never return ok=true")
}
}
// TestSnapshotSKU32 — SKU exactly 32 chars (the boundary case) must succeed.
func TestSnapshotSKU32(t *testing.T) {
dir := t.TempDir()
sku := "SKU-" + repeat32("X", 32-4) // 4 + 28 = 32 chars
if len(sku) != 32 {
t.Fatalf("test bug: sku length %d", len(sku))
}
w := NewSnapshotWriter[Projection]()
if err := appendP(w, makeProjection(sku, 1)); err != nil {
t.Fatalf("Append 32-char SKU: %v", err)
}
if _, err := w.Write(dir, "snap.bin"); err != nil {
t.Fatal(err)
}
r, err := ReadSnapshotFile[Projection](filepath.Join(dir, "snap.bin"))
if err != nil {
t.Fatal(err)
}
defer r.Close()
if _, ok := r.Get(sku); !ok {
t.Error("32-char SKU Get returned false")
}
}
// TestSnapshotSKU33Rejected — one char over should error at Append.
func TestSnapshotSKU33Rejected(t *testing.T) {
w := NewSnapshotWriter[Projection]()
sku := repeat32("X", 33)
err := appendP(w, makeProjection(sku, 1))
if !errors.Is(err, ErrSKUTooLong) {
t.Errorf("33-char SKU Append: got %v want ErrSKUTooLong", err)
}
}
// TestSnapshotGetSpeed — probe thousands of random SKU lookups to give a
// sense of the binary-search cost (not a benchmark per se).
func TestSnapshotGetSpeed(t *testing.T) {
if testing.Short() {
t.Skip("short mode")
}
dir := t.TempDir()
const n = 5000
w := NewSnapshotWriter[Projection]()
skus := make([]string, n)
for i := 0; i < n; i++ {
skus[i] = fmt.Sprintf("Z%06d", i)
if err := appendP(w, makeProjection(skus[i], int64(i))); err != nil {
t.Fatalf("Append %s: %v", skus[i], err)
}
}
if _, err := w.Write(dir, "snap.bin"); err != nil {
t.Fatal(err)
}
r, err := ReadSnapshotFile[Projection](filepath.Join(dir, "snap.bin"))
if err != nil {
t.Fatal(err)
}
defer r.Close()
start := time.Now()
hits, misses := 0, 0
for _, i := range rand.Perm(n) {
if _, ok := r.Get(skus[i]); ok {
hits++
} else {
misses++
}
}
elapsed := time.Since(start)
t.Logf("100%% hit Get on %d records: %d hits, %d misses, %v (%v/lookup)",
n, hits, misses, elapsed, elapsed/time.Duration(n))
if misses != 0 {
t.Errorf("expected zero misses on full-key set, got %d", misses)
}
}
// buildValidHeader synthesises a header byte slice with the given Count
// and zero-blob / zero-record section, used to exercise the truncation
// branch in newSnapshotReader.
func buildValidHeader(count uint64) []byte {
bs := make([]byte, headerSize)
binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic)
binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion)
binary.LittleEndian.PutUint32(bs[12:16], 0)
binary.LittleEndian.PutUint64(bs[16:24], count)
binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize)) // blobStart
binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize)) // recordStart (truncated)
return bs
}
// buildTamperedHeader returns a buffer with a valid header claiming 1 record
// AND its 44-byte record section, but the record's blob pointer lies past
// EOF. Used to exercise ErrSnapshotBadBlob.
func buildTamperedHeader() []byte {
bs := make([]byte, headerSize+recordSize)
binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic)
binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion)
binary.LittleEndian.PutUint32(bs[12:16], 0)
binary.LittleEndian.PutUint64(bs[16:24], 1)
binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize))
binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize))
binary.LittleEndian.PutUint64(bs[headerSize+32:headerSize+40], ^uint64(0))
binary.LittleEndian.PutUint32(bs[headerSize+40:headerSize+44], ^uint32(0))
return bs
}
// TestSnapshotRejectBadVersion — pin the ErrSnapshotVersion rejection path.
func TestSnapshotRejectBadVersion(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "badver.bin")
bs := make([]byte, headerSize)
binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic)
binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion+1)
binary.LittleEndian.PutUint32(bs[12:16], 0)
binary.LittleEndian.PutUint64(bs[16:24], 0)
binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize))
binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize))
if err := os2WriteFile(path, bs, 0o644); err != nil {
t.Fatal(err)
}
if _, err := ReadSnapshotFile[Projection](path); !errors.Is(err, ErrSnapshotVersion) {
t.Errorf("v2-not-supported header rejected with %v, want ErrSnapshotVersion", err)
}
}
// makeProjection helper repeated here for the new tests.
func makeProjection(sku string, price int64) Projection {
return Projection{
ID: "sku:" + sku,
SKU: sku,
Title: "Item " + sku,
PriceIncVat: money.Cents(price),
Currency: "SEK",
TaxClass: "standard",
UpdatedAt: time.Now().UnixMilli(),
}
}
-61
View File
@@ -1,61 +0,0 @@
//go:build unix
// mmap-on-unix loader for SnapshotReader. The file handle stays alive for
// the lifetime of the mmap region (the kernel needs it pinned). Closer
// munmaps AND closes the fd. Uses MADV_RANDOM because the dominant access
// pattern is binary-search-by-SKU — touching random record slots, not
// streaming the file.
package catalog
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
// loadSnapshotBytes returns the file contents (mmap'd) + a closer that munmaps
// and closes the fd.
func loadSnapshotBytes(path string) (data []byte, closer func() error, err error) {
f, err := os.Open(path)
if err != nil {
return nil, nil, err
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, nil, err
}
size := info.Size()
if size == 0 {
_ = f.Close()
return []byte{}, func() error { return nil }, nil
}
if size < int64(headerSize) {
_ = f.Close()
return nil, nil, ErrSnapshotTruncated
}
data, mmapErr := unix.Mmap(int(f.Fd()), 0, int(size), unix.PROT_READ, unix.MAP_SHARED)
if mmapErr != nil {
_ = f.Close()
return nil, nil, fmt.Errorf("catalog: mmap %s: %w", path, mmapErr)
}
// Binary-search by SKU = random page access; MADV_RANDOM tells the
// kernel NOT to do read-ahead — saves I/O on cold layers.
if maErr := unix.Madvise(data, unix.MADV_RANDOM); maErr != nil {
// non-fatal; degrade gracefully
_ = maErr
}
closer = func() error {
mErr := unix.Munmap(data)
cErr := f.Close()
if mErr != nil {
return mErr
}
return cErr
}
return data, closer, nil
}