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

+131 -18
View File
@@ -1,11 +1,124 @@
package catalog
import (
"fmt"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"git.k6n.net/mats/platform/money"
)
// TestSnapshotHolder_SwapRace storms the holder: 100 readers Get in tight loops
// while a rotator Reloads a fresh snapshot every 50ms. It asserts the holder's
// safety + liveness invariants under concurrency (run with -race):
//
// - No panic / SIGBUS — completion is the proof the drain grace never unmaps a
// reader while an in-flight Get is reading it.
// - No torn reads — every hit decodes to a valid Projection (SKU matches,
// price > 0). A read of a munmapped page would crash or fail to decode.
// - No gap — since every snapshot version contains every SKU, a Get must never
// miss; misses would mean the holder briefly exposed an empty/nil reader
// mid-swap. (Exact "stale beyond drain grace" timing is intentionally not
// asserted — it's timing-flaky, and the no-torn-read + no-crash checks
// already cover the drain-safety property it stands in for.)
func TestSnapshotHolder_SwapRace(t *testing.T) {
dir := t.TempDir()
const versions = 4
const skuCount = 200
paths := make([]string, versions)
for v := 0; v < versions; v++ {
w := NewSnapshotWriter[Projection]()
for i := 0; i < skuCount; i++ {
sku := fmt.Sprintf("SKU-%03d", i)
// price encodes the version (always > 0) so a torn read is detectable.
if err := w.Append(sku, Projection{ID: "sku:" + sku, SKU: sku, PriceIncVat: money.Cents(int64((v + 1) * 100))}); err != nil {
t.Fatal(err)
}
}
p, err := w.Write(dir, fmt.Sprintf("snap-%d.bin", v))
if err != nil {
t.Fatal(err)
}
paths[v] = p
}
h := NewSnapshotHolder[Projection](100 * time.Millisecond)
if err := h.Open(paths[0]); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = h.Close() })
stop := make(chan struct{})
var reads, misses int64
var torn int32
var wg sync.WaitGroup
for r := 0; r < 100; r++ {
wg.Add(1)
go func(seed int) {
defer wg.Done()
i := seed
for {
select {
case <-stop:
return
default:
}
sku := fmt.Sprintf("SKU-%03d", i%skuCount)
if p, ok := h.Get(sku); ok {
if p.SKU != sku || p.PriceIncVat <= 0 {
atomic.StoreInt32(&torn, 1)
return
}
atomic.AddInt64(&reads, 1)
} else {
atomic.AddInt64(&misses, 1)
}
i++
}
}(r)
}
wg.Add(1)
go func() {
defer wg.Done()
tick := time.NewTicker(50 * time.Millisecond)
defer tick.Stop()
v := 0
for {
select {
case <-stop:
return
case <-tick.C:
v = (v + 1) % versions
if err := h.Reload(paths[v]); err != nil {
atomic.StoreInt32(&torn, 1)
return
}
}
}
}()
time.Sleep(1500 * time.Millisecond)
close(stop)
wg.Wait()
if atomic.LoadInt32(&torn) != 0 {
t.Fatal("torn/garbage read or reload error during swap storm")
}
if m := atomic.LoadInt64(&misses); m != 0 {
t.Fatalf("got %d misses — holder exposed an empty reader mid-swap (every version has every SKU)", m)
}
if atomic.LoadInt64(&reads) == 0 {
t.Fatal("no successful reads — test did not exercise the holder")
}
t.Logf("SwapRace OK: %d reads across %d rotations, 0 torn, 0 miss", atomic.LoadInt64(&reads), versions)
}
// fakeCloser counts how many times it's been called. Used to assert the
// drain-grace semantics of SnapshotHolder.
type fakeCloser struct{ hits int }
@@ -15,14 +128,14 @@ func (f *fakeCloser) Close() error { f.hits++; return nil }
// withFakeCloser swaps SnapshotReader's closer for our counter. Returns the
// reader and the counter; tests then call holder.Set(reloaded) and assert
// that close is invoked after the drain grace.
func withFakeCloser(t *testing.T, path string, fc *fakeCloser) *SnapshotReader {
func withFakeCloser(t *testing.T, path string, fc *fakeCloser) *SnapshotReader[Projection] {
t.Helper()
r, err := ReadSnapshotFile(path)
r, err := ReadSnapshotFile[Projection](path)
if err != nil {
t.Fatal(err)
}
// replace real closer with our counter; the test asserts only via fc
r.closer = fc.Close
r.raw.closer = fc.Close
return r
}
@@ -35,11 +148,11 @@ func TestSnapshotHolderReloadDrainsOld(t *testing.T) {
dir := t.TempDir()
// 1) Build a valid first snapshot (skus A-1 / A-2).
w := NewSnapshotWriter()
if err := w.Append(makeProjection("A-1", 100)); err != nil {
w := NewSnapshotWriter[Projection]()
if err := appendP(w, makeProjection("A-1", 100)); err != nil {
t.Fatal(err)
}
if err := w.Append(makeProjection("A-2", 200)); err != nil {
if err := appendP(w, makeProjection("A-2", 200)); err != nil {
t.Fatal(err)
}
if _, err := w.Write(dir, "first.bin"); err != nil {
@@ -50,8 +163,8 @@ func TestSnapshotHolderReloadDrainsOld(t *testing.T) {
defer first.Close()
// 2) Build a second snapshot (skus B-1).
w2 := NewSnapshotWriter()
if err := w2.Append(makeProjection("B-1", 300)); err != nil {
w2 := NewSnapshotWriter[Projection]()
if err := appendP(w2, makeProjection("B-1", 300)); err != nil {
t.Fatal(err)
}
if _, err := w2.Write(dir, "second.bin"); err != nil {
@@ -61,7 +174,7 @@ func TestSnapshotHolderReloadDrainsOld(t *testing.T) {
second := withFakeCloser(t, filepath.Join(dir, "second.bin"), secondCloser)
defer second.Close()
h := NewSnapshotHolder(50 * time.Millisecond)
h := NewSnapshotHolder[Projection](50 * time.Millisecond)
defer h.Close()
h.Set(first)
if h.Len() != 2 {
@@ -96,7 +209,7 @@ func TestSnapshotHolderReloadDrainsOld(t *testing.T) {
// TestSnapshotHolderEmptyGet — holder with no current serves empty.
func TestSnapshotHolderEmptyGet(t *testing.T) {
h := NewSnapshotHolder(100 * time.Millisecond)
h := NewSnapshotHolder[Projection](100 * time.Millisecond)
defer h.Close()
if _, ok := h.Get("anything"); ok {
t.Error("empty holder should return false")
@@ -112,8 +225,8 @@ func TestSnapshotHolderEmptyGet(t *testing.T) {
func TestSnapshotHolderCloseImmediatelyReaps(t *testing.T) {
dir := t.TempDir()
w := NewSnapshotWriter()
if err := w.Append(makeProjection("X-1", 1)); err != nil {
w := NewSnapshotWriter[Projection]()
if err := appendP(w, makeProjection("X-1", 1)); err != nil {
t.Fatal(err)
}
if _, err := w.Write(dir, "x.bin"); err != nil {
@@ -122,8 +235,8 @@ func TestSnapshotHolderCloseImmediatelyReaps(t *testing.T) {
fc1 := &fakeCloser{}
r1 := withFakeCloser(t, filepath.Join(dir, "x.bin"), fc1)
w2 := NewSnapshotWriter()
if err := w2.Append(makeProjection("Y-1", 2)); err != nil {
w2 := NewSnapshotWriter[Projection]()
if err := appendP(w2, makeProjection("Y-1", 2)); err != nil {
t.Fatal(err)
}
if _, err := w2.Write(dir, "y.bin"); err != nil {
@@ -132,7 +245,7 @@ func TestSnapshotHolderCloseImmediatelyReaps(t *testing.T) {
fc2 := &fakeCloser{}
r2 := withFakeCloser(t, filepath.Join(dir, "y.bin"), fc2)
h := NewSnapshotHolder(10 * time.Second) // long grace so reap wouldn't happen on its own
h := NewSnapshotHolder[Projection](10 * time.Second) // long grace so reap wouldn't happen on its own
h.Set(r1)
h.Set(r2)
@@ -152,15 +265,15 @@ func TestSnapshotHolderCloseImmediatelyReaps(t *testing.T) {
// until grace elapses.
func TestSnapshotHolderIdentityReload(t *testing.T) {
dir := t.TempDir()
w := NewSnapshotWriter()
if err := w.Append(makeProjection("ID-1", 1)); err != nil {
w := NewSnapshotWriter[Projection]()
if err := appendP(w, makeProjection("ID-1", 1)); err != nil {
t.Fatal(err)
}
if _, err := w.Write(dir, "id.bin"); err != nil {
t.Fatal(err)
}
h := NewSnapshotHolder(10 * time.Second)
h := NewSnapshotHolder[Projection](10 * time.Second)
defer h.Close()
if err := h.Reload(filepath.Join(dir, "id.bin")); err != nil {
t.Fatal(err)