// Package main — `event-emit-publisher` is the test-producer for the // catalog.projection_published wire. It drives the same shape the // slask-finder writeradmin emits, so cmd/catalog-emit (consumer harness) // and go-cart-actor/inventory (consumer bridge) can be exercised // end-to-end without a live writer admin in the loop. // // Two modes: // // 1. Tick (default): every -tick interval, build a batch of -batch-size // upserts (mixed with a small fraction of deletes for the cart bridge to // see), publish one catalog.projection_published event per tenant, // repeat. // 2. One-shot (-oneshot): publish a single batch and exit — useful for CI // and one-line smoke checks. // // Usage: // // event-emit-publisher \ // -schemas /etc/platform/catalog/schemas.json \ // -tenants default,bygglagret[,se] \ // -rabbit amqp://admin:devpass@localhost:5672/ \ // -exchange catalog \ // -batch-size 200 -tick 5s -jitter 1s [-oneshot] package main import ( "flag" "fmt" "math/rand" "os" "os/signal" "strconv" "strings" "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" ) // perTenantPublisher holds the batches+counter for one tenant so we can // emit one event per tenant per tick without rebuilding the schema work. type perTenantPublisher struct { tenant string schema catalog.Schema count atomic.Uint64 // batches published for this tenant } func main() { schemasPath := flag.String("schemas", "", "JSON file mapping tenant -> catalog.Schema") itemsPath := flag.String("items", "", "JSON file mapping tenant -> []source-item (optional; falls back to synthetic items when absent)") tenantsCSV := flag.String("tenants", "default", "comma-separated tenant names (also used as Meta[tenant]") emitN := flag.Int("n", 10000, "synthesised total item count per tenant when -items is omitted") batchSize := flag.Int("batch-size", 100, "items per published batch per tenant") deleteRatio := flag.Float64("delete-ratio", 0.05, "fraction of each batch marked deleted (0..1)") rabbitURL := flag.String("rabbit", "amqp://admin:devpass@localhost:5672/", "RabbitMQ URL") exchange := flag.String("exchange", "catalog", "RabbitMQ topic exchange to publish on (default 'catalog')") tickDuration := flag.Duration("tick", 5*time.Second, "interval between batches (0 disables ticking)") jitter := flag.Duration("jitter", 1*time.Second, "max random delay added on top of tick") seed := flag.Int64("seed", 42, "RNG seed") oneshot := flag.Bool("oneshot", false, "publish a single batch per tenant and exit") maxBatches := flag.Int("max-batches", 0, "stop after this many batches per tenant (0 = unlimited)") eventIDPrefix := flag.String("event-id-prefix", "pub-", "prefix for emitted event IDs") flag.Parse() if *schemasPath == "" { fmt.Fprintln(os.Stderr, "ERROR: -schemas is required") flag.Usage() os.Exit(2) } if *batchSize <= 0 { fail("batch-size must be > 0, got %d", *batchSize) } if *deleteRatio < 0 || *deleteRatio > 1 { fail("delete-ratio must be in [0,1], got %v", *deleteRatio) } 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) } sources, err := catalogtest.LoadOrSynthesize(*itemsPath, tenants, *emitN, *seed, reg, "P") if err != nil { fail("load items: %v", err) } // Build per-tenant schema and pre-applied projections once (the cart // and the harness hot path both want the typed Projection shape). pubs := make([]*perTenantPublisher, 0, len(tenants)) for _, t := range tenants { sch := reg.SchemaFor(t) if sch.Tenant == "" { sch.Tenant = t } if _, ok := sources[t]; !ok { fail("tenant %q has no source items (check -tenants and -items / -n)", t) } pubs = append(pubs, &perTenantPublisher{tenant: t, schema: sch}) } // Dial RabbitMQ. We use the rabbit.Conn wrapper so the publisher can run // across broker hiccups. rc, err := rabbit.Dial(*rabbitURL, "event-emit-publisher") if err != nil { fail("rabbit dial %q: %v", *rabbitURL, err) } // Register cleanup so a later fail() won't leak the AMQP socket, and // also so graceful shutdown closes the connection cleanly. cleanup = func() { _ = rc.Close() } defer cleanup() if err := declareExchange(rc, *exchange); err != nil { fail("declare exchange %q: %v", *exchange, err) } fmt.Printf("publishing to %s on exchange %q (tenants=%v batch=%d tick=%v jitter=%v oneshot=%v)\n", *rabbitURL, *exchange, tenants, *batchSize, *tickDuration, *jitter, *oneshot) rng := rand.New(rand.NewSource(*seed)) publishOnce := func() { for _, pub := range pubs { updates, skipped := buildBatch(pub, sources[pub.tenant], *batchSize, *deleteRatio, rng) ev := buildEvent(pub, updates, *eventIDPrefix) if err := publishEvent(rc, *exchange, ev); err != nil { fmt.Fprintf(os.Stderr, "publish %s: %v\n", pub.tenant, err) continue } n := pub.count.Add(1) fmt.Printf("tenant=%s batch=%d upserts=%d skipped=%d eventID=%s\n", pub.tenant, n, len(updates), skipped, ev.ID) } } if *oneshot { publishOnce() return } if *tickDuration <= 0 { fail("watch-mode requires -tick > 0 (use -oneshot to emit one batch and exit)") } sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigCh fmt.Fprintln(os.Stderr, "received signal; shutting down") // bulk-total counter for the SIGINT summary for _, pub := range pubs { fmt.Printf("summary tenant=%s batches=%d\n", pub.tenant, pub.count.Load()) } os.Exit(0) }() t := time.NewTicker(*tickDuration) defer t.Stop() batches := 0 for { select { case <-t.C: publishOnce() batches++ if *maxBatches > 0 && batches >= *maxBatches { fmt.Printf("reached max-batches=%d per tenant, exiting\n", *maxBatches) return } if *jitter > 0 { // slop the next tick by a small random fraction of the jitter time.Sleep(time.Duration(rng.Int63n(int64(*jitter)))) } } } } // buildBatch picks batchSize items from sources, applies the schema, and // returns a []ProjectionUpdate batch (upserts and deletes). deleteRatio of // items become deletion markers (with the same Schema on apply but // upsert=false) so the cart bridge sees both shapes. // // skipped counts items that failed Apply — leave them out rather than // poisoning the batch (mirrors writeradmin's behavior). func buildBatch(pub *perTenantPublisher, sourceItems []map[string]any, batchSize int, deleteRatio float64, rng *rand.Rand) ([]catalog.ProjectionUpdate, int) { updates := make([]catalog.ProjectionUpdate, 0, batchSize) skipped := 0 n := len(sourceItems) if n == 0 { return updates, 0 } for i := 0; i < batchSize; i++ { idx := rng.Intn(n) src := sourceItems[idx] isDelete := rng.Float64() < deleteRatio if isDelete { sku := catalogtest.StringField(src, pub.schema.SKUField) if sku == "" { skipped++ continue } updates = append(updates, catalog.ProjectionUpdate{ Projection: catalog.Projection{ SKU: sku, ID: pub.schema.IDPrefix + sku, }, Deleted: true, }) continue } p, err := catalog.Apply(pub.schema, src) if err != nil { skipped++ continue } updates = append(updates, catalog.ProjectionUpdate{Projection: p}) } return updates, skipped } // buildEvent wraps a batch of updates into one envelope.Event matching the // publisher conventions used by slask-finder/writeradmin/projection-emit.go. // // Source uses the "catalog:" prefix (consumer's routeTenant splits // on ':'), and Meta carries both "tenant" AND "country" (the latter for // compat with single-tenant-per-country models). Push a stable unique ID // so a replay tool can dedupe. func buildEvent(pub *perTenantPublisher, updates []catalog.ProjectionUpdate, idPrefix string) event.Event { payload, err := catalog.EncodeUpdates(updates) if err != nil { // EncodeUpdates only errors on a fundamentally broken batch; surface loud. fail("encode updates for %s: %v", pub.tenant, err) } return event.Event{ ID: idPrefix + pub.tenant + "-" + strconv.FormatInt(time.Now().UnixNano(), 36), Type: event.CatalogProjectionPublished, Source: "catalog:" + pub.tenant, Subject: pub.tenant, Payload: payload, Time: time.Now(), Meta: map[string]string{ "tenant": pub.tenant, "country": pub.tenant, // single-tenant-per-country model "emitter": "event-emit-publisher", }, } } // publishEvent publishes the envelope via rabbit.PublishEvent (which // opens a channel per call — fine for a low-rate test publisher). Routing // key inside PublishEvent is implied as string(ev.Type); here we want // "catalog.projection_published" which is exactly that. We pass the // underlying *amqp.Connection that rc.Connection() returns. // // Note: PublishEvent transiently opens + closes a channel per publish. // At -tick ≈ 5s that's negligible, but if this CLI is ever pushed to // high rates, swap to a single long-lived channel + Publish(). func publishEvent(rc *rabbit.Conn, exchange string, ev event.Event) error { return rabbit.PublishEvent(rc.Connection(), exchange, ev) } // declareExchange opens a channel and issue-s a topic exchange declare so a // fresh broker has the topology the harness's reader and the cart bridge // expect. Idempotent. func declareExchange(rc *rabbit.Conn, exchange string) error { ch, err := rc.Channel() if err != nil { return fmt.Errorf("open channel: %w", err) } defer ch.Close() if err := ch.ExchangeDeclare(exchange, "topic", true, false, false, false, nil); err != nil { return fmt.Errorf("exchange declare: %w", err) } return nil } // splitCSV trims and splits a comma-separated tenant list. 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 before os.Exit so a mid-run // failure does not leak the AMQP connection (or any other resource // registered later). main() assigns a closure to cleanup that closes the // rabbit.Conn; fail() invokes it before exiting. // // We can't rely solely on defer cleanup() because os.Exit skips defers; the // fail path is reachable from several sites before a defer would normally // fire, plus the connection may be the only system resource the binary // opened. Same pattern as platform/cmd/catalog-emit's cleanup hook. var cleanup = func() {} // fail prints a FATAL line, runs registered cleanup, and exits with code 1. func fail(format string, a ...any) { fmt.Fprintf(os.Stderr, "FATAL: "+format+"\n", a...) cleanup() os.Exit(1) }