// Package main — `catalog-emit` is the producer-side harness for the // platform/catalog wire. It has two modes: // // 1. One-shot (no -rabbit flag): synthesise / load items per tenant, write // one snapshot file per tenant under -out-dir, probe a randomised sample // of SKUs to verify the format round-trips, then exit. Useful for // seed-and-verify runs and CI. // // 2. Watch (-rabbit amqp://...): emit a baseline snapshot per tenant (so the // start state matches what the bus will then incrementally update), bind // to the AMQP topic exchange and consume `catalog.projection_published` // events. Each event carries a `[]ProjectionUpdate` batch (upsert+delete // per SKU). Per-tenant live state is reflected in a per-tenant // SnapshotHolder; reload-on-event uses the same drain-grace atomic-swap // pattern as the consumer side. A periodic probe validates that live Get // traffic continues to hit under concurrent reloads. // // Note on naming: "producer wire-up" here means the harness produces per-tenant // snapshot BIN files autosaved on every bus batch — it does NOT publish // catalog.projection_published events itself (it only consumes them, in the // same role as cart / checkout / order). // // Usage (one-shot): // // catalog-emit \ // -schemas /etc/platform/catalog/schemas.json \ // [-items ./items.json] \ // -out-dir ./catalog \ // -tenants default[,bygglagret] // // Usage (watch): // // catalog-emit \ // -schemas /etc/platform/catalog/schemas.json \ // -rabbit amqp://guest:guest@localhost:5672/ \ // -exchange platform \ // -out-dir ./catalog \ // -tenants default[,bygglagret] // // Exit code 0 = every sampled lookup matched; 2 = bad CLI args; 1 = a probe // miss happened in one-shot mode (in watch mode, misses are reported but the // process keeps running for further evaluation). package main import ( "encoding/json" "flag" "fmt" "math/rand" "os" "os/signal" "path/filepath" "strings" "sync" "sync/atomic" "syscall" "time" "git.k6n.net/mats/platform/catalog" "git.k6n.net/mats/platform/cmd/internal/catalogtest" "git.k6n.net/mats/platform/event" "git.k6n.net/mats/platform/rabbit" amqp "github.com/rabbitmq/amqp091-go" ) // TenantState is one tenant's live projection state + a SnapshotHolder that // hot-reloads a freshly-written snapshot file on every update batch. // // The items map is the live projection set; rebuilding it from a snapshot // is intentionally cheap because the written file IS the on-disk truth // after every rebuild — but the live map is what bus batches merge into. // // Counters are atomic.Uint64 because they are read by the SIGINT shutdown // path concurrently with writes from both the bus-handler goroutine and // the probe goroutine; a mutex-protected uint64 would either contend on // the probe hot path or race with the shutdown summary. type TenantState struct { tenant string schema catalog.Schema mu sync.RWMutex items map[string]catalog.Projection holder *catalog.SnapshotHolder[catalog.Projection] path string // /.bin rebuildCount atomic.Uint64 probeCount atomic.Uint64 probeMisses atomic.Uint64 } // newTenantState wires a brand-new tenant. Caller is responsible for // calling rebuild() (or Open via the initial state pipeline) before any Get. func newTenantState(tenant string, sch catalog.Schema, holder *catalog.SnapshotHolder[catalog.Projection], path string) *TenantState { return &TenantState{ tenant: tenant, schema: sch, items: make(map[string]catalog.Projection, 1024), holder: holder, path: path, } } // rebuild merges batch atomically under the write lock, captures a snapshot // of the items map, releases the lock, and does IO (Write + Reload) without // holding it. The snapshot capture is the only step that touches the items // map; everything after the unlock races freely with concurrent // rebuilds / probes, and drain-grace handles the prior-reader lifecycle. // // Race analysis: between unlock and Write completion, a second event for // the same tenant could mutate items and trigger a parallel rebuild that // races to the disk. Resolve in favour of the second event — its Write // wins via atomic-rename; the first Write either finishes last (writes the // older state, harmless because items already moved) or its tmp file is // overwritten by the second's tmp + rename. The fact that the second's // reload is the latest Set means downstream Get sees the newest state; // the first's Reload, if it lands second, demotes a newer reader to // older — drain-grace then queues the newer one for reap after grace, // and the older one serves drops via atomic.Pointer swap. Both ends are // eventually consistent; not a correctness break, an arrival-ordering race // in IO. For a producer harness verification role this is acceptable; a // high-volume producer should use a per-tenant coalescing channel. func (ts *TenantState) rebuild(batch []catalog.ProjectionUpdate) error { ts.mu.Lock() if batch != nil { for _, u := range batch { if u.Deleted { delete(ts.items, u.SKU) continue } if u.SKU == "" { continue } ts.items[u.SKU] = u.Projection } } snapshot := make([]catalog.Projection, 0, len(ts.items)) for _, p := range ts.items { snapshot = append(snapshot, p) } ts.rebuildCount.Add(1) ts.mu.Unlock() return ts.writeSnapshot(snapshot) } // writeSnapshot emits snapshot to disk (atomic temp+rename) and reloads // the holder. Called WITHOUT holding ts.mu. func (ts *TenantState) writeSnapshot(snap []catalog.Projection) error { w := catalog.NewSnapshotWriter[catalog.Projection]().WithTenant(ts.tenant) for _, p := range snap { if err := w.Append(p.SKU, p); err != nil { return fmt.Errorf("append %s: %w", p.SKU, err) } } out, err := w.Write(filepath.Dir(ts.path), filepath.Base(ts.path)) if err != nil { return fmt.Errorf("snapshot write %s: %w", ts.path, err) } if err := ts.holder.Reload(out); err != nil { return fmt.Errorf("holder reload %s: %w", out, err) } return nil } // upsert inserts or replaces a projection in the live map. Used by the // initial-seed pipeline; the bus-update path goes through rebuild(). func (ts *TenantState) upsert(p catalog.Projection) { ts.mu.Lock() defer ts.mu.Unlock() ts.items[p.SKU] = p } // count returns the current item-set size under read lock. func (ts *TenantState) count() int { ts.mu.RLock() defer ts.mu.RUnlock() return len(ts.items) } // sample uniformly picks up to n SKUs at random from the live map. Used by // the probe paths to validate atomic-reload under drain-grace. func (ts *TenantState) sample(rng *rand.Rand, n int) []string { ts.mu.RLock() defer ts.mu.RUnlock() if len(ts.items) == 0 { return nil } skus := make([]string, 0, len(ts.items)) for sku := range ts.items { skus = append(skus, sku) } if n >= len(skus) { return skus } out := make([]string, 0, n) picked := make(map[int]struct{}, n) for len(out) < n { idx := rng.Intn(len(skus)) if _, dup := picked[idx]; dup { continue } picked[idx] = struct{}{} out = append(out, skus[idx]) } return out } // get is a thin wrapper around holder.Get. Counts probes and misses // atomically — readable from the SIGINT shutdown path without a lock. func (ts *TenantState) get(sku string) (catalog.Projection, bool) { ts.probeCount.Add(1) p, ok := ts.holder.Get(sku) if !ok { ts.probeMisses.Add(1) } return p, ok } func main() { schemasPath := flag.String("schemas", "", "JSON file mapping tenant -> catalog.Schema") itemsPath := flag.String("items", "", "JSON file mapping tenant -> []source-item (optional; loadOrSynthesize falls back to -n synth per tenant if absent)") outDir := flag.String("out-dir", "./out", "output directory; per-tenant files /.bin") sampleCount := flag.Int("sample", 200, "number of random SKUs to verify per probe call") probeTick := flag.Duration("probe-tick", 2*time.Second, "watch mode: probe interval; one-shot ignores it") tenantsCSV := flag.String("tenants", "default", "comma-separated tenant names to operate on") emitN := flag.Int("n", 10000, "synthesised item count per tenant when -items is omitted") seed := flag.Int64("seed", 42, "RNG seed for synthesised items / probe sample") rabbitURL := flag.String("rabbit", "", "if set, watch mode consumes catalog.projection_published from this RabbitMQ URL") exchange := flag.String("exchange", "catalog", "RabbitMQ topic exchange to bind on (default 'catalog' to match the writeradmin event family)") drainGrace := flag.Duration("drain-grace", catalog.DefaultDrainGrace, "SnapshotHolder drain grace (override for tight tests)") flag.Parse() if *schemasPath == "" { fmt.Fprintln(os.Stderr, "ERROR: -schemas is required") flag.Usage() os.Exit(2) } tenants := splitCSV(*tenantsCSV) if len(tenants) == 0 { fail("no tenants parsed from -tenants %q", *tenantsCSV) } reg := catalog.NewRegistry() if err := reg.LoadFile(*schemasPath); err != nil { fail("load schemas: %v", err) } if err := os.MkdirAll(*outDir, 0o755); err != nil { fail("mkdir out-dir %q: %v", *outDir, err) } // Per-tenant state. Built once and shared across seed/bus/probe paths. states := make(map[string]*TenantState, len(tenants)) for _, t := range tenants { sch := reg.SchemaFor(t) if sch.Tenant == "" { sch.Tenant = t } path := filepath.Join(*outDir, t+".bin") holder := catalog.NewSnapshotHolder[catalog.Projection](*drainGrace) states[t] = newTenantState(t, sch, holder, path) } // Register cleanup for both the defer path (graceful shutdown / SIGINT) // and the fail path (mid-run error). Defers run LIFO: rc.Close() (added // later, in watch mode) fires first; this hook runs second. With rc // nil, the extra teardown is harmless. cleanup = func() { for _, ts := range states { _ = ts.holder.Close() } } defer cleanup() // Watch-mode rc is appended after this point; in watch its Close is // added to the defer stack too, separately. // Initial state pipeline: synthesise / load items, Apply per tenant's // registered schema, upsert into each tenant's map, then rebuild() the // snapshots so the SnapshotHolders are populated before any Get. sources, err := catalogtest.LoadOrSynthesize(*itemsPath, tenants, *emitN, *seed, reg, "B") if err != nil { fail("load items: %v", err) } skipped := 0 for _, t := range tenants { sch := reg.SchemaFor(t) ts := states[t] for _, src := range sources[t] { p, err := catalog.Apply(sch, src) if err != nil { fmt.Fprintf(os.Stderr, "skip %s: %v\n", t, err) skipped++ continue } ts.upsert(p) } } for _, t := range tenants { ts := states[t] if err := ts.rebuild(nil); err != nil { fail("initial rebuild %s: %v", t, err) } fmt.Printf("initial %s: %d items -> %s\n", t, ts.holder.Len(), ts.path) } fmt.Printf("seeded %d items across %d tenants, skipped %d\n", sumCounts(states), len(tenants), skipped) watch := *rabbitURL != "" if !watch { if err := probe(states, tenants, *sampleCount, *seed+1); err != nil { fail("probe miss: %v", err) } return } // Watch mode. fmt.Printf("connecting to RabbitMQ: %s (exchange=%s)\n", *rabbitURL, *exchange) rc, err := rabbit.Dial(*rabbitURL, "catalog-emit") if err != nil { fail("rabbit dial: %v", err) } defer rc.Close() pattern := string(event.CatalogProjectionPublished) bindOnce := func() { ch, err := rc.Channel() if err != nil { fmt.Fprintf(os.Stderr, "watch: open channel: %v\n", err) return } if err := rabbit.ListenToPattern(ch, *exchange, pattern, makeHandler(states)); err != nil { fmt.Fprintf(os.Stderr, "watch: bind pattern %q: %v\n", pattern, err) _ = ch.Close() } } rc.NotifyOnReconnect(bindOnce) fmt.Printf("watching %q on exchange %q\n", pattern, *exchange) go probeLoop(states, tenants, *sampleCount, *probeTick) sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) sig := <-sigCh fmt.Printf("received signal %s; shutting down (rebuilds=%d probes=%d misses=%d)\n", sig, sumRebuilds(states), sumProbes(states), sumMisses(states), ) } // routedEvent is the result of routeTenant — `tenant` is the key into the // states map; `fallback` tells the handler it should warn (a real // default-tenant publisher via Meta["tenant"]="default" does NOT set // fallback, so legitimate default traffic stays silent). type routedEvent struct { tenant string fallback bool // no Meta[tenant] and no Source "catalog:" prefix } // makeHandler returns the AMQP delivery handler that consumes // `catalog.projection_published`. It parses the event, picks the tenant // (Meta["tenant"] -> Source[:N] suffix -> "default"), decodes the // ProjectionUpdate batch, and feeds it to the per-tenant rebuild path. // // A deferred recover wraps the body so a panic in any single event // doesn't kill the consumer goroutine; the queue keeps draining on the // next delivery, and the reconnect loop re-binds cleanly on the next // broker hiccup. func makeHandler(states map[string]*TenantState) func(amqp.Delivery) error { return func(d amqp.Delivery) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("handler panic recovered: %v", r) } }() var ev event.Event if err := json.Unmarshal(d.Body, &ev); err != nil { return fmt.Errorf("decode event: %w", err) } r := routeTenant(&ev) if r.fallback { // Loud-warn so a misrouted publisher shows up in ops logs. fmt.Fprintf(os.Stderr, "watch WARN: event id=%s source=%s type=%s — no Meta[%q] and no \"catalog:\" Source prefix — routing to default\n", ev.ID, ev.Source, ev.Type, "tenant") } ts, ok := states[r.tenant] if !ok { // The publisher-decorated tenant exists but isn't in this // harness's -tenants list. The event is silently dropped — a // half-configured test environment would look like a broken // wire. Surface loudly so the misconfig is visible from // stdout. fmt.Fprintf(os.Stderr, "watch WARN: event id=%s source=%s type=%s routed to tenant=%q but this harness only tracks %v — dropping (check -tenants on the harness vs Meta[tenant]/Meta[country] on the publisher)\n", ev.ID, ev.Source, ev.Type, r.tenant, trackedTenants(states)) return nil } updates, err := catalog.DecodeUpdates(ev.Payload) if err != nil { return fmt.Errorf("decode payload as []ProjectionUpdate: %w", err) } if err := ts.rebuild(updates); err != nil { return fmt.Errorf("rebuild %s: %w", r.tenant, err) } return nil } } // routeTenant extracts the tenant for a delivered event. Meta and Source // values are TrimSpace'd so a publisher that emits " default " or // "catalog: bygglagret " still lands in the right bucket. Lookup order: // // 1. Meta["tenant"] — explicit per-event tenant (preferred). // 2. Meta["country"] — compat fallback for finder writeradmin today // (single-tenant-per-country writers). // 3. Source split — "catalog:" → tenant portion. // 4. "default" — final fallback so an undecorated publisher still lands // somewhere the harness can see it. Marked via `fallback: true` so the // caller can log a warning, distinguishing a real default-tenant // publisher from a missing-decoration one. func routeTenant(ev *event.Event) routedEvent { if t, ok := ev.Meta["tenant"]; ok { if t = strings.TrimSpace(t); t != "" { return routedEvent{tenant: t} } } if t, ok := ev.Meta["country"]; ok { // Legacy finder writers (writeradmin today) only know country. Treat // country as tenant in the single-tenant-per-country model; multi- // tenant writers should set Meta["tenant"] explicitly. if t = strings.TrimSpace(t); t != "" { return routedEvent{tenant: t} } } if strings.HasPrefix(ev.Source, "catalog:") { if t := strings.TrimSpace(strings.TrimPrefix(ev.Source, "catalog:")); t != "" { return routedEvent{tenant: t} } } return routedEvent{tenant: "default", fallback: true} } // trackedTenants returns the sorted list of tenant names configured on // this harness. Used in misroute WARN lines so a half-configured test // environment is visible from stdout. func trackedTenants(states map[string]*TenantState) []string { out := make([]string, 0, len(states)) for t := range states { out = append(out, t) } return out } // sumCounts / sumRebuilds / sumProbes / sumMisses aggregate per-tenant // counters for ops visibility at shutdown. Reads are atomic; safe to call // concurrently with rebuild() / get(). func sumCounts(states map[string]*TenantState) int { n := 0 for _, ts := range states { n += ts.count() } return n } func sumRebuilds(states map[string]*TenantState) uint64 { var n uint64 for _, ts := range states { n += ts.rebuildCount.Load() } return n } func sumProbes(states map[string]*TenantState) uint64 { var n uint64 for _, ts := range states { n += ts.probeCount.Load() } return n } func sumMisses(states map[string]*TenantState) uint64 { var n uint64 for _, ts := range states { n += ts.probeMisses.Load() } return n } // probe picks `sampleCount` random SKUs across all tenanted state and // looks them up via the SnapshotHolder. Misses are reported and accumulated; // on a miss, an error is returned so the caller can fail loud (one-shot) // or log (watch). func probe(states map[string]*TenantState, tenants []string, sampleCount int, seed int64) error { rng := rand.New(rand.NewSource(seed)) misses := uint64(0) hits := uint64(0) start := time.Now() for _, t := range tenants { ts := states[t] skus := ts.sample(rng, sampleCount) for _, sku := range skus { if _, ok := ts.get(sku); ok { hits++ } else { fmt.Fprintf(os.Stderr, "MISS: %s/%s\n", t, sku) misses++ } } } elapsed := time.Since(start) total := int(hits + misses) if total == 0 { return nil } fmt.Printf("probe: %d hits, %d misses, %v (%v/lookup)\n", hits, misses, elapsed, elapsed/time.Duration(total)) if misses > 0 { return fmt.Errorf("%d probe misses", misses) } return nil } // probeLoop runs probe() on every tick; logs misses but does NOT exit. It // is the watch-mode heartbeat that validates the holder under live reloads. func probeLoop(states map[string]*TenantState, tenants []string, sampleCount int, tick time.Duration) { t := time.NewTicker(tick) defer t.Stop() seed := int64(1) for range t.C { _ = probe(states, tenants, sampleCount, seed) seed++ } } // splitCSV trims and splits a comma-separated tenant list into a slice. func splitCSV(s string) []string { if s == "" { return nil } out := make([]string, 0, 2) for _, p := range strings.Split(s, ",") { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } // cleanup runs the registered shutdown hooks (holder file mmap unmaps, AMQP // connection close) before exit. main() populates `cleanup` with a function // that closes all per-tenant SnapshotHolders and rc.Close if rc was opened; // fail() invokes the same hook before os.Exit so a mid-run failure does not // leak mmap mappings or AMQP sockets. // // We can't just rely on defers because os.Exit skips them — and the holder // close is the only defer that's registered before several possible fail() // sites (initial rebuild, one-shot probe miss). var cleanup = func() {} // fail prints a FATAL line, runs registered cleanup hooks, then exits 1. func fail(format string, a ...any) { fmt.Fprintf(os.Stderr, "FATAL: "+format+"\n", a...) cleanup() os.Exit(1) }