572 lines
19 KiB
Go
572 lines
19 KiB
Go
// 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
|
|
path string // <out-dir>/<tenant>.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, 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().WithTenant(ts.tenant)
|
|
for _, p := range snap {
|
|
if err := w.Append(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 <out-dir>/<tenant>.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(*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:<x>" 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:<tenant>\" 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>" → 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)
|
|
}
|