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

+423
View File
@@ -0,0 +1,423 @@
// 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)
)
// SnapshotWriter is single-shot: Append() all projections, then Write() once
// (atomic rename-into-place so readers never see a partial file).
type SnapshotWriter struct {
Tenant string // optional namespace tag (writes into header)
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
}
// NewSnapshotWriter returns an empty writer. Caller Appends every projection
// and calls Write once.
func NewSnapshotWriter() *SnapshotWriter {
return &SnapshotWriter{}
}
// 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) WithTenant(tenant string) *SnapshotWriter {
w.Tenant = tenant
return w
}
// Append encodes and adds a projection. Returns ErrSKUTooLong if SKU exceeds
// the on-disk width; returns marshal errors as wrapped fmt.Errorf.
func (w *SnapshotWriter) Append(p Projection) error {
if len(p.SKU) > maxSKULen {
return ErrSKUTooLong
}
jb, err := json.Marshal(p)
if err != nil {
return fmt.Errorf("catalog: marshal projection %q: %w", p.SKU, err)
}
var rec snapshotRecord
copy(rec.SKU[:], p.SKU)
rec.BlobOffset = uint64(headerSize + len(w.blobs))
rec.BlobLength = uint32(len(jb))
w.blobs = append(w.blobs, jb...)
w.records = append(w.records, rec)
w.dirty = true
return nil
}
// Len returns how many projections have been Append'd.
func (w *SnapshotWriter) Len() int { return len(w.records) }
// 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 *SnapshotWriter) 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)
}
// SnapshotReader is read-only and concurrency-safe — parallel Get calls are
// OK because no fields are mutated after construction. Held open via an
// embedded file handle so the underlying mmap stays pinned. Close uses
// sync.Once so concurrent shutdown paths can't double-munmap.
//
// IMPORTANT — see package doc: SnapshotReader is NOT safe racing Close
// concurrently with Get. Use SnapshotHolder for any reload-bearing workflow.
type SnapshotReader 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
}
// ReadSnapshotFile opens + parses a snapshot file. On Unix uses mmap; on
// other platforms reads into memory (functionally identical, higher RSS).
func ReadSnapshotFile(path string) (*SnapshotReader, error) {
data, closer, err := loadSnapshotBytes(path)
if err != nil {
return nil, err
}
r, err := newSnapshotReader(data)
if err != nil {
if closer != nil {
_ = closer()
}
return nil, err
}
r.closer = closer
return r, nil
}
// NewSnapshotReader wraps an already-loaded []byte (use this in tests; for
// production paths go through ReadSnapshotFile).
func NewSnapshotReader(data []byte) (*SnapshotReader, error) {
return newSnapshotReader(data)
}
func newSnapshotReader(data []byte) (*SnapshotReader, 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 &SnapshotReader{
data: data,
count: count,
blobStart: blobStart,
recordStart: recordStart,
Tenant: tenant,
}, nil
}
// Len returns the number of Projections in this snapshot.
func (r *SnapshotReader) 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 Get() — see package doc. Use
// SnapshotHolder for any reload-bearing workflow.
func (r *SnapshotReader) Close() error {
var err error
r.closeOnce.Do(func() {
if r.closer != nil {
err = r.closer()
}
})
return err
}
// Get returns the projection for sku, or (zero, false) if not found.
// O(log N) — binary search on the sorted record section; on hit, reads the
// JSON blob and decodes it (one allocation).
func (r *SnapshotReader) Get(sku string) (Projection, bool) {
if r.count == 0 || sku == "" {
return Projection{}, false
}
skuBytes := []byte(sku)
if len(skuBytes) > maxSKULen {
return Projection{}, 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
rdata := r.data[blobOff : blobOff+uint64(blobLen)]
var p Projection
if err := json.Unmarshal(rdata, &p); err != nil {
return Projection{}, false
}
return p, true
}
}
return Projection{}, false
}