315 lines
11 KiB
Go
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)
|
|
}
|