Files
platform/cmd/event-emit-publisher/main.go
T
2026-06-28 17:52:49 +02:00

315 lines
11 KiB
Go

// 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:<tenant>" 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)
}