- Generic SnapshotWriter[T]/SnapshotReader[T] over a value-agnostic raw layer; SnapshotHolder[T] now RWMutex-guards munmap vs in-flight Get (race-free swap, TestSnapshotHolder_SwapRace) — the drain grace is now just close-batching. - ProjectionStore[T]: snapshot begin|chunk|end assembly → atomic swap, epoch-gated delta overlay, tombstones, atomic epoch sidecar (ordered before the swap), cold-start reopen, mandatory ProjectionTransform[T]. Future-epoch deltas are dropped by contract (recover via next snapshot), not buffered. - Shared bus-framing constants so producer + consumer agree. - Bump slask-finder / go-cart-actor / backoffice pointers; refresh status.md + catalog-projection.md to the finished state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
489 lines
16 KiB
Go
489 lines
16 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)
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
|