Files
platform/catalog/snapshot.go
T
2026-06-28 17:52:49 +02:00

424 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}