424 lines
14 KiB
Go
424 lines
14 KiB
Go
// 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
|
||
}
|
||
|
||
|