platform/catalog: generic ProjectionStore[T] consumer + race-free holder; finish C1

- 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>
This commit is contained in:
matsandClaude Opus 4.8 committed 2026-06-28 21:07:39 +02:00
1 parent 5a3ffcb6bc
commit 8b454bccac
8 files changed
+959 -170

No files matched your search

+225
View File
@@ -0,0 +1,225 @@
package catalog
import (
"strconv"
"testing"
"git.k6n.net/mats/platform/money"
)
func snapMeta(phase string, epoch int64, seq, count int) map[string]string {
m := map[string]string{MetaEpoch: strconv.FormatInt(epoch, 10), MetaTenant: "SE"}
if phase != "" {
m[MetaSnapshotPhase] = phase
m[MetaSeq] = strconv.Itoa(seq)
}
if phase == SnapshotBegin || phase == SnapshotEnd {
m[MetaCount] = strconv.Itoa(count)
}
return m
}
func mustEnc(t *testing.T, ups []ProjectionUpdate) []byte {
t.Helper()
b, err := EncodeUpdates(ups)
if err != nil {
t.Fatalf("encode: %v", err)
}
return b
}
// identity is the transform used by tests: store the full Projection (T = Projection).
func identity(p Projection) Projection { return p }
// emitSnapshot drives a full begin→chunk→end stream of projs at epoch.
func emitSnapshot(t *testing.T, s *ProjectionStore[Projection], epoch int64, projs []Projection) {
t.Helper()
if err := s.HandleFrame(snapMeta(SnapshotBegin, epoch, 0, len(projs)), mustEnc(t, nil)); err != nil {
t.Fatalf("begin: %v", err)
}
ups := make([]ProjectionUpdate, len(projs))
for i, p := range projs {
ups[i] = ProjectionUpdate{Projection: p}
}
if err := s.HandleFrame(snapMeta(SnapshotChunk, epoch, 0, 0), mustEnc(t, ups)); err != nil {
t.Fatalf("chunk: %v", err)
}
if err := s.HandleFrame(snapMeta(SnapshotEnd, epoch, 1, len(projs)), mustEnc(t, nil)); err != nil {
t.Fatalf("end: %v", err)
}
}
func proj(sku string, price money.Cents) Projection {
return Projection{ID: "sku:" + sku, SKU: sku, PriceIncVat: price}
}
func newStore(t *testing.T, fb ProjectionFallback) *ProjectionStore[Projection] {
t.Helper()
s, err := NewProjectionStore(t.TempDir(), "consumer.bin", identity, fb)
if err != nil {
t.Fatalf("new store: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestProjectionStore_SnapshotThenGet(t *testing.T) {
s := newStore(t, nil)
emitSnapshot(t, s, 100, []Projection{proj("A", 100_00), proj("B", 150_00)})
if got, ok := s.Get("A"); !ok || got.PriceIncVat != 100_00 {
t.Fatalf("A = %+v ok=%v, want 10000", got, ok)
}
if e, base, ov := s.Stats(); e != 100 || base != 2 || ov != 0 {
t.Fatalf("stats = epoch %d base %d overlay %d, want 100/2/0", e, base, ov)
}
}
func TestProjectionStore_DeltaOverlayWinsOverBase(t *testing.T) {
s := newStore(t, nil)
emitSnapshot(t, s, 100, []Projection{proj("A", 100_00)})
// Same-epoch delta with a fresher price overrides the base.
if err := s.HandleFrame(snapMeta("", 100, 0, 0), mustEnc(t, []ProjectionUpdate{{Projection: proj("A", 80_00)}})); err != nil {
t.Fatalf("delta: %v", err)
}
if got, _ := s.Get("A"); got.PriceIncVat != 80_00 {
t.Fatalf("A after delta = %d, want 8000", got.PriceIncVat)
}
}
func TestProjectionStore_DeltaTombstoneShadowsBaseAndFallback(t *testing.T) {
fbHits := 0
s := newStore(t, func(string) (Projection, bool) { fbHits++; return proj("A", 1), true })
emitSnapshot(t, s, 100, []Projection{proj("A", 100_00)})
if err := s.HandleFrame(snapMeta("", 100, 0, 0), mustEnc(t, []ProjectionUpdate{{Projection: Projection{SKU: "A"}, Deleted: true}})); err != nil {
t.Fatalf("delete: %v", err)
}
if got, ok := s.Get("A"); ok {
t.Fatalf("A after tombstone = %+v, want miss (no resurrect, no fallback)", got)
}
if fbHits != 0 {
t.Fatalf("fallback hit %d times for a tombstoned SKU, want 0", fbHits)
}
}
func TestProjectionStore_FallbackOnMiss(t *testing.T) {
s := newStore(t, func(sku string) (Projection, bool) {
if sku == "Z" {
return proj("Z", 42), true
}
return Projection{}, false
})
emitSnapshot(t, s, 100, []Projection{proj("A", 100_00)})
if got, ok := s.Get("Z"); !ok || got.PriceIncVat != 42 {
t.Fatalf("Z = %+v ok=%v, want fallback 42", got, ok)
}
if _, ok := s.Get("nope"); ok {
t.Fatalf("unknown SKU should miss")
}
}
func TestProjectionStore_StaleEpochIgnored(t *testing.T) {
s := newStore(t, nil)
emitSnapshot(t, s, 200, []Projection{proj("A", 100_00)})
// An older snapshot must not replace the newer base.
emitSnapshot(t, s, 100, []Projection{proj("A", 999_00), proj("OLD", 1)})
if got, _ := s.Get("A"); got.PriceIncVat != 100_00 {
t.Fatalf("A = %d, want base epoch 200 price 10000 (stale snapshot ignored)", got.PriceIncVat)
}
if _, ok := s.Get("OLD"); ok {
t.Fatalf("OLD from a stale snapshot should not be present")
}
// An older-epoch delta is ignored too.
_ = s.HandleFrame(snapMeta("", 100, 0, 0), mustEnc(t, []ProjectionUpdate{{Projection: proj("A", 1)}}))
if got, _ := s.Get("A"); got.PriceIncVat != 100_00 {
t.Fatalf("A after stale delta = %d, want 10000", got.PriceIncVat)
}
}
// A future-epoch delta is DROPPED, by contract. Under FIFO per-queue delivery a
// delta never legitimately precedes its snapshot (the producer emits begin(N)
// before any delta(N)); the only way to see one is a reconnect that missed the
// whole snapshot, which a buffer couldn't reassemble anyway. Recovery is the next
// snapshot of that epoch (and the consumer's fallback in the meantime). See
// docs/catalog-projection.md.
func TestProjectionStore_FutureEpochDeltaDropped(t *testing.T) {
s := newStore(t, nil)
emitSnapshot(t, s, 100, []Projection{proj("A", 100_00)})
// A delta ahead of our base (gap) is dropped; only a matching snapshot applies it.
_ = s.HandleFrame(snapMeta("", 200, 0, 0), mustEnc(t, []ProjectionUpdate{{Projection: proj("A", 7)}}))
if got, _ := s.Get("A"); got.PriceIncVat != 100_00 {
t.Fatalf("A after future delta = %d, want 10000 (gap delta dropped)", got.PriceIncVat)
}
}
func TestProjectionStore_IncompleteSnapshotDiscarded(t *testing.T) {
s := newStore(t, nil)
emitSnapshot(t, s, 100, []Projection{proj("A", 100_00)})
// begin says 3, but only 1 chunked → end must discard and keep the old base.
if err := s.HandleFrame(snapMeta(SnapshotBegin, 200, 0, 3), mustEnc(t, nil)); err != nil {
t.Fatal(err)
}
if err := s.HandleFrame(snapMeta(SnapshotChunk, 200, 0, 0), mustEnc(t, []ProjectionUpdate{{Projection: proj("B", 5)}})); err != nil {
t.Fatal(err)
}
if err := s.HandleFrame(snapMeta(SnapshotEnd, 200, 1, 3), mustEnc(t, nil)); err != nil {
t.Fatal(err)
}
if e := s.Epoch(); e != 100 {
t.Fatalf("epoch = %d, want 100 (incomplete snapshot discarded)", e)
}
if _, ok := s.Get("B"); ok {
t.Fatalf("B from an incomplete snapshot must not be visible")
}
if got, _ := s.Get("A"); got.PriceIncVat != 100_00 {
t.Fatalf("A = %d, want old base intact", got.PriceIncVat)
}
}
func TestProjectionStore_ReopenFromDiskColdStart(t *testing.T) {
dir := t.TempDir()
s1, err := NewProjectionStore(dir, "consumer.bin", identity, nil)
if err != nil {
t.Fatal(err)
}
emitSnapshot(t, s1, 500, []Projection{proj("A", 100_00), proj("B", 150_00)})
_ = s1.Close()
// A fresh store on the same dir must serve immediately from the persisted base
// and resume same-epoch deltas (epoch read from the sidecar).
s2, err := NewProjectionStore(dir, "consumer.bin", identity, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s2.Close() })
if e := s2.Epoch(); e != 500 {
t.Fatalf("reopened epoch = %d, want 500 (from sidecar)", e)
}
if got, ok := s2.Get("A"); !ok || got.PriceIncVat != 100_00 {
t.Fatalf("A after reopen = %+v ok=%v, want 10000", got, ok)
}
if err := s2.HandleFrame(snapMeta("", 500, 0, 0), mustEnc(t, []ProjectionUpdate{{Projection: proj("A", 90_00)}})); err != nil {
t.Fatalf("post-reopen delta: %v", err)
}
if got, _ := s2.Get("A"); got.PriceIncVat != 90_00 {
t.Fatalf("A after post-reopen delta = %d, want 9000", got.PriceIncVat)
}
}
func TestProjectionStore_BadPayloadErrors(t *testing.T) {
s := newStore(t, nil)
if err := s.HandleFrame(snapMeta(SnapshotChunk, 1, 0, 0), []byte("{not json")); err == nil {
t.Fatal("chunk with bad payload should error (caller may NACK)")
}
if err := s.HandleFrame(snapMeta("", 1, 0, 0), []byte("{not json")); err == nil {
t.Fatal("delta with bad payload should error")
}
}