cart: back projection cache with platform/catalog.ProjectionStore
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s

Consumer side of the catalog-projection seam (C1):
- catalogProjectionCache now wraps ProjectionStore[catalog.Projection] (identity
  transform — the cart's overlay uses ~all fields); delivery handler routes bus
  frames via HandleFrame (snapshot begin|chunk|end + epoch-stamped deltas).
- Per-pod cold-start-ready .bin under CART_PROJECTION_DIR (pod-local, never
  shared) + IsDeleted tombstone passthrough; HTTP fetch demoted to last-resort.
- Tests: test-only Apply shim + mustCache helper keep existing cases green;
  new snapshot-framing integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 21:05:55 +02:00
co-authored by Claude Opus 4.8
parent a3eab70de8
commit d711348863
5 changed files with 177 additions and 93 deletions
+6 -6
View File
@@ -10,7 +10,7 @@ import (
// TestProjectionCache_ApplyUpserts covers the happy path: a batch of N
// projections reaches the cache via Apply, all are visible via Get.
func TestProjectionCache_ApplyUpserts(t *testing.T) {
c := newCatalogProjectionCache()
c := mustCache(t)
updates := []catalog.ProjectionUpdate{
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard"}},
{Projection: catalog.Projection{ID: "id-B", SKU: "B", PriceIncVat: money.Cents(150_00), TaxClass: "reduced"}},
@@ -48,7 +48,7 @@ func TestProjectionCache_ApplyUpserts(t *testing.T) {
// - IsDeleted returns true
// - the same entry continues to count toward Len (size accounting)
func TestProjectionCache_DeleteTombstone(t *testing.T) {
c := newCatalogProjectionCache()
c := mustCache(t)
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00)}},
})
@@ -81,7 +81,7 @@ func TestProjectionCache_DeleteTombstone(t *testing.T) {
// clears the tombstone (live entry replaces it): Get returns the projection,
// IsDeleted flips to false. Mirrors how the live catalog keeps flipping state.
func TestProjectionCache_BusResurrect(t *testing.T) {
c := newCatalogProjectionCache()
c := mustCache(t)
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(50_00)}},
{Projection: catalog.Projection{SKU: "A"}, Deleted: true},
@@ -110,7 +110,7 @@ func TestProjectionCache_BusResurrect(t *testing.T) {
// SKU is a no-op (defensive — bus should never publish "" but cheaper to log
// + drop than to populate an ambiguous cache slot).
func TestProjectionCache_EmptySKUIgnored(t *testing.T) {
c := newCatalogProjectionCache()
c := mustCache(t)
upserts, _ := c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "", PriceIncVat: money.Cents(100_00)}},
})
@@ -125,7 +125,7 @@ func TestProjectionCache_EmptySKUIgnored(t *testing.T) {
// TestProjectionCache_BusRaceSafe runs Apply + Get concurrently under -race
// to lock down the lock order. Run via `go test -race ./cmd/cart/...`.
func TestProjectionCache_BusRaceSafe(t *testing.T) {
c := newCatalogProjectionCache()
c := mustCache(t)
done := make(chan struct{})
go func() {
defer close(done)
@@ -151,7 +151,7 @@ func TestProjectionCache_BusRaceSafe(t *testing.T) {
// the per-batch counters report the right split. Without this the cache could
// drift on count semantics across batched events.
func TestApply_MixedUpsertDelete(t *testing.T) {
c := newCatalogProjectionCache()
c := mustCache(t)
// Order matters: prior in-cache state for A is upserted twice (latest wins),
// B is tombstoned, C cold-upserted, D cold-tombstoned.
updates := []catalog.ProjectionUpdate{