platform
This commit is contained in:
1 parent
e706f7d907
commit
21bcb63440
42 files changed
+5903
No files matched your search
+73
@@ -0,0 +1,73 @@
|
||||
# Multi-binary image for the platform/ catalog wire harnesses.
|
||||
#
|
||||
# Build context is the platform/ directory. Modules used by the binaries all
|
||||
# live under platform/, so a single-tree build is sufficient — no external
|
||||
# replace directives needed.
|
||||
#
|
||||
# Pure Go: cmd/catalog-emit and cmd/event-emit-publisher use only stdlib +
|
||||
# github.com/rabbitmq/amqp091-go + git.k6n.net/mats/platform/* internals.
|
||||
# No CGO, so we ship a Debian-bookworm-slim runtime with ca-certificates
|
||||
# and a timezone DB. The writer (slask-finder) is NOT built here — it lives
|
||||
# in its own Dockerfile and has its own ONNX / libtokenizers deps (CGO).
|
||||
|
||||
# ─── 1. Build stage ────────────────────────────────────────────────────────
|
||||
FROM golang:1.26-bookworm AS build-stage
|
||||
WORKDIR /app
|
||||
ENV CGO_ENABLED=0 GOFLAGS="-trimpath"
|
||||
|
||||
# Module files first so the dep download caches correctly on rebuild.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Rest of the source.
|
||||
COPY . .
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG BUILDTIME
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \
|
||||
go build -ldflags="-s -w" -o /out/catalog-emit ./cmd/catalog-emit && \
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \
|
||||
go build -ldflags="-s -w" -o /out/event-emit-publisher ./cmd/event-emit-publisher
|
||||
|
||||
# ─── 2. Runtime stage ────────────────────────────────────────────────────
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# ca-certificates for TLS-validated RabbitMQ URLs (amqps://).
|
||||
# tzdata so time.Now() resolves zones predictably in logs.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
tzdata && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user, matching the k8s securityContext pattern that
|
||||
# 20-data-plane.yaml uses for cart/checkout/profile (runAsUser: 1000).
|
||||
# Pick 1010 here to leave 1000 available for future platform pods.
|
||||
RUN groupadd --gid 1010 platcat && \
|
||||
useradd --uid 1010 --gid platcat \
|
||||
--home-dir /home/platcat --create-home \
|
||||
--shell /bin/bash platcat
|
||||
|
||||
# Binaries straight from the build stage.
|
||||
COPY --from=build-stage /out/catalog-emit /usr/local/bin/catalog-emit
|
||||
COPY --from=build-stage /out/event-emit-publisher /usr/local/bin/event-emit-publisher
|
||||
|
||||
# Make them owned by platcat so a non-root pod UID can run them.
|
||||
RUN chown platcat:platcat /usr/local/bin/catalog-emit \
|
||||
/usr/local/bin/event-emit-publisher
|
||||
|
||||
USER platcat:platcat
|
||||
WORKDIR /home/platcat
|
||||
|
||||
# Healthcheck surfaces what's running; useful for `kubectl describe pod`
|
||||
# when watching the projection consumer come up.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||
CMD /usr/local/bin/catalog-emit -h >/dev/null 2>&1 || exit 1
|
||||
|
||||
# Production entrypoint is catalog-emit (the consumer harness); the
|
||||
# publisher binary is intentionally a sibling so it's invokeable for
|
||||
# smoke tests via `kubectl exec <pod> -- /usr/local/bin/event-emit-publisher ...`.
|
||||
# The k8s Deployment (52-catalog-emit.yaml) overrides args with the
|
||||
# watch-mode flags + -out-dir pointing at the projection PVC.
|
||||
ENTRYPOINT ["/usr/local/bin/catalog-emit"]
|
||||
CMD ["-h"]
|
||||
@@ -0,0 +1,386 @@
|
||||
// Builder applies a Schema to a generic source map[string]any and returns
|
||||
// the platform-projected Projection. Pure function — no I/O, no mutation,
|
||||
// safe to call concurrently. Coercions are best-effort:
|
||||
//
|
||||
// - missing source field ⇒ Schema default (FixedCurrency/DefaultTaxClass
|
||||
// when present, otherwise empty/zero)
|
||||
// - wrong-typed source value ⇒ the Builder either coerces (bools from
|
||||
// truthy strings, ints from numeric strings, json.Number, etc.) or
|
||||
// returns ErrProjectionBuild so the caller can decide whether to
|
||||
// log+skip or fail.
|
||||
//
|
||||
// Builder never panics on bad input — a malformed source line is a log+skip,
|
||||
// not a process crash.
|
||||
//
|
||||
// Three coercers cover realistic producer output shapes:
|
||||
//
|
||||
// - stock json.Unmarshal gives numeric fields as float64 unless
|
||||
// `dec.UseNumber()` is on, in which case they come as json.Number.
|
||||
// - json.Number preserves up to ~17 digits of precision; float64
|
||||
// silently loses precision past 2^53. A producer that wants exact
|
||||
// prices sets UseNumber(); this Builder must accept the result.
|
||||
// - some producers serialise via reflection and emit `int` / `int64`
|
||||
// directly; this Builder must accept those too.
|
||||
//
|
||||
// All amounts leave the Builder as money.Cents (integer minor units). The
|
||||
// only float-side conversion lives in platform/money (FromFloatMajor) and
|
||||
// is the explicit lossy-edge; once in Cents, no float is ever summed,
|
||||
// multiplied, or compared downstream.
|
||||
//
|
||||
// SCALE SEMANTICS used by coercePrice:
|
||||
//
|
||||
// - PriceScale == 1: the source value is ALREADY in minor units.
|
||||
// * For string / json.Number: ParseInt directly (integer form only —
|
||||
// the fractional-vs-low-scale guard above rejects "."-containing
|
||||
// inputs).
|
||||
// * For int / int64: identity (multiply by 1).
|
||||
// * For float64 / float32: the float must be integral (else rejected);
|
||||
// cast to int gives minor units directly.
|
||||
//
|
||||
// - PriceScale > 1 (e.g. 100): the source value is in MAJOR units.
|
||||
// * For string / json.Number: ParseCents treats the decimal form as
|
||||
// MAJOR and returns MINOR cents exactly — no further multiply.
|
||||
// * For int / int64: integer multiply (lossless).
|
||||
// * For float64 / float32: float multiply + round-half-away (lossy).
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// ErrProjectionBuild is returned when a source value cannot be coerced
|
||||
// into the Projection shape. Callers typically log + skip so a single bad
|
||||
// item doesn't poison a whole batch.
|
||||
var ErrProjectionBuild = errors.New("catalog: projection build failed")
|
||||
|
||||
// Apply is the pure helper signature: schema, source map → Projection,
|
||||
// error. Use the package-level BuildFromMap helper for the most common
|
||||
// shape (schema + map → Projection); use Apply when you want fine control.
|
||||
func Apply(s Schema, source map[string]any) (Projection, error) {
|
||||
if s.SKUField == "" || s.PriceField == "" {
|
||||
return Projection{}, fmt.Errorf("%w: schema missing sku_field or price_field", ErrProjectionBuild)
|
||||
}
|
||||
if source == nil {
|
||||
return Projection{}, fmt.Errorf("%w: nil source map", ErrProjectionBuild)
|
||||
}
|
||||
sku := stringOf(source, s.SKUField)
|
||||
if sku == "" {
|
||||
return Projection{}, fmt.Errorf("%w: missing SKU at %q", ErrProjectionBuild, s.SKUField)
|
||||
}
|
||||
|
||||
priceRaw, ok := source[s.PriceField]
|
||||
if !ok {
|
||||
return Projection{}, fmt.Errorf("%w: missing price at %q", ErrProjectionBuild, s.PriceField)
|
||||
}
|
||||
price, err := coercePrice(priceRaw, s.PriceScale)
|
||||
if err != nil {
|
||||
return Projection{}, fmt.Errorf("%w: price at %q: %w", ErrProjectionBuild, s.PriceField, err)
|
||||
}
|
||||
|
||||
cur := ""
|
||||
if s.CurrencyField != "" {
|
||||
cur = stringOf(source, s.CurrencyField)
|
||||
}
|
||||
if cur == "" {
|
||||
cur = s.FixedCurrency
|
||||
}
|
||||
|
||||
tax := ""
|
||||
if s.TaxClassField != "" {
|
||||
tax = stringOf(source, s.TaxClassField)
|
||||
}
|
||||
if tax == "" {
|
||||
tax = s.DefaultTaxClass
|
||||
}
|
||||
|
||||
id := sku
|
||||
if s.IDPrefix != "" {
|
||||
id = s.IDPrefix + sku
|
||||
}
|
||||
|
||||
upd, err := coerceUpdatedAt(s, source)
|
||||
if err != nil {
|
||||
return Projection{}, fmt.Errorf("%w: updated_at: %w", ErrProjectionBuild, err)
|
||||
}
|
||||
|
||||
truthy := s.TrueValues
|
||||
if truthy == nil {
|
||||
truthy = DefaultTrueValues()
|
||||
}
|
||||
|
||||
p := Projection{
|
||||
ID: id,
|
||||
SKU: sku,
|
||||
Title: stringOf(source, s.TitleField),
|
||||
PriceIncVat: price,
|
||||
Currency: cur,
|
||||
TaxClass: tax,
|
||||
InventoryTracked: boolOf(source, s.InventoryTrackedF, truthy),
|
||||
DropShip: boolOf(source, s.DropShipF, truthy),
|
||||
Category: stringOf(source, s.CategoryField),
|
||||
Brand: stringOf(source, s.BrandField),
|
||||
Image: stringOf(source, s.ImageField),
|
||||
UpdatedAt: upd,
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// BuildFromMap is the convenience: schema + map → Projection with error.
|
||||
// Use this from producer adapters where the source is a parsed map[string]any.
|
||||
func BuildFromMap(s Schema, source map[string]any) (Projection, error) {
|
||||
return Apply(s, source)
|
||||
}
|
||||
|
||||
// stringOf returns the value at key as a string. JSON unmarshalling gives
|
||||
// strings as string; we accept the common numeric source shapes too so a
|
||||
// map[string]any that came from a different upstream parser (e.g. typed
|
||||
// structs serialised with one round of reflection) still works.
|
||||
//
|
||||
// json.Number is included because precision-preserving producers turn it on
|
||||
// via json.Decoder.UseNumber(); without this case the Builder would reject
|
||||
// every projection in the catalogue.
|
||||
func stringOf(m map[string]any, key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case json.Number:
|
||||
// Use Number preserves up to ~17 digits as a string-token. For SKUs
|
||||
// digit-strings >2^53 (rare but possible), UseNumber is the only
|
||||
// way to round-trip safely. String() gives back the raw digits.
|
||||
return x.String()
|
||||
case bool:
|
||||
if x {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case float64:
|
||||
if x == float64(int64(x)) {
|
||||
return strconv.FormatInt(int64(x), 10)
|
||||
}
|
||||
return strconv.FormatFloat(x, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(x)
|
||||
case int64:
|
||||
return strconv.FormatInt(x, 10)
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// boolOf returns the value at key coerced through truthy. nil/missing keys
|
||||
// are false; strings lower-cased and matched against truthy; bools passed
|
||||
// through; numerics "!= 0"; json.Number goes through Float64.
|
||||
func boolOf(m map[string]any, key string, truthy []string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return false
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
low := strings.ToLower(strings.TrimSpace(x))
|
||||
for _, t := range truthy {
|
||||
if low == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case float64:
|
||||
return x != 0
|
||||
case int:
|
||||
return x != 0
|
||||
case int64:
|
||||
return x != 0
|
||||
case json.Number:
|
||||
if f, err := x.Float64(); err == nil {
|
||||
return f != 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// coercePrice normalises a price source value to money.Cents (integer minor
|
||||
// units). The schema's PriceScale determines whether the source is already
|
||||
// in minor units (scale==1) or in MAJOR units that need interpretation
|
||||
// (scale > 1, typically 100). See the package doc for the full table.
|
||||
//
|
||||
// The fractional-vs-low-scale guard REJECTS a fractional value when scale ≤ 1
|
||||
// at any of the float / string / json.Number arms — that's almost certainly a
|
||||
// tenant who forgot PriceScale=100 and would have produced a silent 100×-off
|
||||
// price. Loud-fail beats silent corruption.
|
||||
//
|
||||
// At scale > 1, decimals come through money.ParseCents (exact, integer-only)
|
||||
// rather than ParseFloat (lossy past 2^53). Float arms remain the explicit
|
||||
// lossy bits, intentionally isolated.
|
||||
func coercePrice(v any, scale int) (money.Cents, error) {
|
||||
if scale < 0 {
|
||||
return 0, fmt.Errorf("negative price_scale: %d", scale)
|
||||
}
|
||||
effective := int64(scale)
|
||||
if effective < 1 {
|
||||
effective = 1
|
||||
}
|
||||
// Fractional-vs-low-scale guard is inlined at each arm so the error
|
||||
// message keeps the actual value (the float stringification is noisier
|
||||
// than the JSON-token ".eE" check, so a single helper would lose
|
||||
// fidelity).
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
if x != float64(int64(x)) && effective == 1 {
|
||||
return 0, fmt.Errorf("fractional price %v with PriceScale=1 — did you forget PriceScale=100?", x)
|
||||
}
|
||||
return money.Cents(roundHalfAwayFromZero(x * float64(effective))), nil
|
||||
case float32:
|
||||
if x != float32(int64(x)) && effective == 1 {
|
||||
return 0, fmt.Errorf("fractional price %v with PriceScale=1 — did you forget PriceScale=100?", x)
|
||||
}
|
||||
return money.Cents(roundHalfAwayFromZero(float64(x) * float64(effective))), nil
|
||||
case int:
|
||||
return money.Cents(int64(x) * effective), nil
|
||||
case int64:
|
||||
return money.Cents(x * effective), nil
|
||||
case string:
|
||||
s := strings.TrimSpace(x)
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty price string")
|
||||
}
|
||||
// Scale 1 with a fractional character set ⇒ foot-gun 4.
|
||||
if effective == 1 && strings.ContainsAny(s, ".eE") {
|
||||
return 0, fmt.Errorf("fractional price %q with PriceScale=1 — did you forget PriceScale=100?", s)
|
||||
}
|
||||
if effective == 1 {
|
||||
// Scale 1 means source IS minor units. Remaining forms are
|
||||
// integer decimal strings — ParseInt directly. ParseCents would
|
||||
// misread "14995" as 14995.00 SEK = 1499500.
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse price %q at scale=1: %w", x, err)
|
||||
}
|
||||
return money.Cents(n), nil
|
||||
}
|
||||
// Scale > 1: source is MAJOR units. ParseCents interprets the
|
||||
// decimal form as MAJOR and returns MINOR (cents) EXACTLY — no
|
||||
// further multiply.
|
||||
if c, err := money.ParseCents(s); err == nil {
|
||||
return c, nil
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse price %q: %w", x, err)
|
||||
}
|
||||
return money.Cents(roundHalfAwayFromZero(f * float64(effective))), nil
|
||||
case json.Number:
|
||||
s := x.String()
|
||||
if effective == 1 && strings.ContainsAny(s, ".eE") {
|
||||
return 0, fmt.Errorf("fractional price %q with PriceScale=1 — did you forget PriceScale=100?", s)
|
||||
}
|
||||
if effective == 1 {
|
||||
// x is the original json.Number — Int64() works.
|
||||
if i, err := x.Int64(); err == nil {
|
||||
return money.Cents(i), nil
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse price json.Number %q at scale=1: %w", s, err)
|
||||
}
|
||||
return money.Cents(n), nil
|
||||
}
|
||||
// Scale > 1: ParseCents token-aware path. ParseCents interprets
|
||||
// the token as MAJOR-units decimal and returns MINOR cents exactly.
|
||||
if c, err := money.ParseCents(s); err == nil {
|
||||
return c, nil
|
||||
}
|
||||
f, err := x.Float64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse price json.Number %q: %w", s, err)
|
||||
}
|
||||
return money.Cents(roundHalfAwayFromZero(f * float64(effective))), nil
|
||||
}
|
||||
return 0, fmt.Errorf("unsupported price type %T", v)
|
||||
}
|
||||
|
||||
// roundHalfAwayFromZero rounds half-away-from-zero instead of banker's
|
||||
// rounding so e.g. 99.995 → 100 (with scale=1) not 99, and -99.995 → -100
|
||||
// not -99. We accept the very small skew in favour of deterministic
|
||||
// conventional rounding for signed amounts (refunds, discounts).
|
||||
func roundHalfAwayFromZero(f float64) int64 {
|
||||
if f >= 0 {
|
||||
return int64(f + 0.5)
|
||||
}
|
||||
return int64(f - 0.5)
|
||||
}
|
||||
|
||||
// coerceUpdatedAt returns Unix-ms for the Projection.UpdatedAt stamp based
|
||||
// on Schema.UpdatedAtSource.
|
||||
//
|
||||
// IMPORTANT contract (foot-gun 3, resolved):
|
||||
//
|
||||
// - StampAtPublish is the RESERVED deliberate-fallback. Builder sets
|
||||
// time.Now().UnixMilli(). Every rebuild re-stamps every projection,
|
||||
// invalidating every consumer's whole cache even when nothing changed.
|
||||
// Pick this only when the producer genuinely has no version stamp.
|
||||
//
|
||||
// - UseSourceField (DefaultSchema, BygglagretSchema) reads the value
|
||||
// from source[Schema.UpdatedAtField]. If the field is MISSING or the
|
||||
// value can't be parsed, Apply returns ErrProjectionBuild (wrapped)
|
||||
// rather than stamp-now'ing. That keeps UpdatedAt a true content
|
||||
// version across rebuilds — same source content ⇒ same UpdatedAt ⇒
|
||||
// consumer caches stay valid.
|
||||
//
|
||||
// Consumers compare UpdatedAt with "strictly greater wins" semantics; a
|
||||
// silent stamp-now fallback would defeat this contract.
|
||||
func coerceUpdatedAt(s Schema, source map[string]any) (int64, error) {
|
||||
if s.UpdatedAtSource == StampAtPublish || s.UpdatedAtField == "" {
|
||||
return time.Now().UnixMilli(), nil
|
||||
}
|
||||
v, ok := source[s.UpdatedAtField]
|
||||
if !ok || v == nil {
|
||||
return 0, fmt.Errorf("missing updated_at at %q (UseSourceField requires the source to supply it)", s.UpdatedAtField)
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case int64:
|
||||
return x, nil
|
||||
case int:
|
||||
return int64(x), nil
|
||||
case float64:
|
||||
return int64(x), nil
|
||||
case json.Number:
|
||||
if i, err := x.Int64(); err == nil {
|
||||
return i, nil
|
||||
}
|
||||
f, err := x.Float64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("updated_at json.Number %q: %w", x.String(), err)
|
||||
}
|
||||
return int64(f), nil
|
||||
case string:
|
||||
t := strings.TrimSpace(x)
|
||||
if t == "" {
|
||||
return 0, fmt.Errorf("empty updated_at string at %q (UseSourceField requires a value)", s.UpdatedAtField)
|
||||
}
|
||||
n, err := strconv.ParseInt(t, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse updated_at %q: %w", x, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
return 0, fmt.Errorf("unsupported updated_at type %T at %q", v, s.UpdatedAtField)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// json.Number from a json.Decoder running with UseNumber() is the
|
||||
// canonical path for precision-preserving producer parsing. Without this
|
||||
// case, BuildFromMap would log+skip every projection that came through
|
||||
// UseNumber — silently losing items.
|
||||
func TestBuilderJSONNumber(t *testing.T) {
|
||||
// json.Number for SKU
|
||||
src := map[string]any{
|
||||
"sku": json.Number("123456789012345"), // float64-unsafe
|
||||
"title": "BigNumber SKU",
|
||||
"price_inc_vat": json.Number("14995"), // already øre
|
||||
"currency": "SEK",
|
||||
"tax_class": "standard",
|
||||
"updated_at": json.Number("1700000000000"),
|
||||
}
|
||||
p, err := Apply(DefaultSchema(), src)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Number source rejected: %v", err)
|
||||
}
|
||||
if p.SKU != "123456789012345" {
|
||||
t.Errorf("SKU: got %q want %q", p.SKU, "123456789012345")
|
||||
}
|
||||
if p.PriceIncVat != 14995 {
|
||||
t.Errorf("PriceIncVat: got %d want %d", p.PriceIncVat, 14995)
|
||||
}
|
||||
if p.UpdatedAt != 1700000000000 {
|
||||
t.Errorf("UpdatedAt: got %d want %d", p.UpdatedAt, int64(1700000000000))
|
||||
}
|
||||
}
|
||||
|
||||
// Updated=UseSourceField: missing source updated_at MUST be a loud error,
|
||||
// not a stamp-now fallback. (Foot-gun 3 resolution: silence here invalidates
|
||||
// every consumer's cache on rebuild.)
|
||||
func TestBuilderRequiresUpdatedAt(t *testing.T) {
|
||||
src := map[string]any{
|
||||
"sku": "X-1",
|
||||
"price_inc_vat": int64(100),
|
||||
// no "updated_at" key intentionally
|
||||
}
|
||||
_, err := Apply(DefaultSchema(), src)
|
||||
if err == nil {
|
||||
t.Fatal("UseSourceField + missing updated_at should error, not stamp-now")
|
||||
}
|
||||
if !errors.Is(err, ErrProjectionBuild) {
|
||||
t.Errorf("got %v want ErrProjectionBuild", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "updated_at") {
|
||||
t.Errorf("error should mention updated_at: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Same projection under StampAtPublish succeeds even without updated_at —
|
||||
// that's the deliberate-fallback path. Used by sources with no other stamp.
|
||||
// Bind the stamp to real wall-clock time (before ≤ actual ≤ after+slack)
|
||||
// so the test distinguishes "stamp-now was called" from any other non-zero
|
||||
// path.
|
||||
func TestBuilderStampAtPublishFallback(t *testing.T) {
|
||||
s := DefaultSchema()
|
||||
s.UpdatedAtSource = StampAtPublish
|
||||
s.UpdatedAtField = "" // ignored when source is StampAtPublish
|
||||
before := time.Now().UnixMilli()
|
||||
src := map[string]any{
|
||||
"sku": "X-2",
|
||||
"price_inc_vat": int64(100),
|
||||
}
|
||||
p, err := Apply(s, src)
|
||||
if err != nil {
|
||||
t.Fatalf("StampAtPublish should accept missing updated_at: %v", err)
|
||||
}
|
||||
after := time.Now().UnixMilli() + 1000 // generous slack for slow CI
|
||||
if p.UpdatedAt < before || p.UpdatedAt > after {
|
||||
t.Errorf("UpdatedAt should be stamp-now Unix-ms in [%d,%d], got %d", before, after, p.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Fractional float price with scale=1 must be loud-fail — the float arm of
|
||||
// coercePrice has the same foot-gun 4 risk as the string arm (silent 100x
|
||||
// bias if a tenant forgets PriceScale=100 and ships a float literal).
|
||||
func TestBuilderFractionalFloatLowScaleRejected(t *testing.T) {
|
||||
src := map[string]any{
|
||||
"sku": "F-1",
|
||||
"price_inc_vat": 99.95, // float major-units at scale=1
|
||||
"updated_at": int64(1700000000000),
|
||||
}
|
||||
_, err := Apply(DefaultSchema(), src)
|
||||
if err == nil {
|
||||
t.Fatal("fractional float price at scale=1 should error, not silently produce 100 øre")
|
||||
}
|
||||
if !errors.Is(err, ErrProjectionBuild) {
|
||||
t.Errorf("got %v want ErrProjectionBuild", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "PriceScale=100") {
|
||||
t.Errorf("error message should mention PriceScale=100 hint: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// json.Number("149.95") at scale=1 must reach the fractional guard via
|
||||
// the json.Number branch — the precision-safe producer route.
|
||||
func TestBuilderFractionalJSONNumberLowScaleRejected(t *testing.T) {
|
||||
src := map[string]any{
|
||||
"sku": "JN-1",
|
||||
"price_inc_vat": json.Number("149.95"),
|
||||
"updated_at": int64(1700000000000),
|
||||
}
|
||||
_, err := Apply(DefaultSchema(), src)
|
||||
if err == nil {
|
||||
t.Fatal("fractional json.Number at scale=1 should error, not silently produce 150 øre")
|
||||
}
|
||||
if !errors.Is(err, ErrProjectionBuild) {
|
||||
t.Errorf("got %v want ErrProjectionBuild", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "PriceScale=100") {
|
||||
t.Errorf("error message should mention PriceScale=100 hint: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fractional string price with scale=1 must be loud-fail (a misconfigured
|
||||
// tenant shipping major units without PriceScale=100 — silent 100x bias).
|
||||
func TestBuilderFractionalStringPriceLowScaleRejected(t *testing.T) {
|
||||
src := map[string]any{
|
||||
"sku": "X-1",
|
||||
"price_inc_vat": "149.95", // major units FRACTION at scale=1
|
||||
"updated_at": int64(1700000000000),
|
||||
}
|
||||
_, err := Apply(DefaultSchema(), src)
|
||||
if err == nil {
|
||||
t.Fatal("fractional string at scale=1 should error, not silently produce 150 øre")
|
||||
}
|
||||
if !errors.Is(err, ErrProjectionBuild) {
|
||||
t.Errorf("got %v want ErrProjectionBuild", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "PriceScale=100") {
|
||||
t.Errorf("error message should mention PriceScale=100 hint: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Same string with scale=100 (correct major-unit config) succeeds.
|
||||
func TestBuilderFractionalStringPriceCorrectScale(t *testing.T) {
|
||||
s := DefaultSchema()
|
||||
s.PriceScale = 100
|
||||
src := map[string]any{
|
||||
"sku": "X-2",
|
||||
"price_inc_vat": "149.95",
|
||||
"updated_at": int64(1700000000000),
|
||||
}
|
||||
p, err := Apply(s, src)
|
||||
if err != nil {
|
||||
t.Fatalf("scale=100 string price rejected: %v", err)
|
||||
}
|
||||
if p.PriceIncVat != 14995 {
|
||||
t.Errorf("PriceIncVat: got %d want %d", p.PriceIncVat, 14995)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Package catalog defines the published, versioned catalog projection that
|
||||
// commerce modules (cart, checkout, order) consume — and the configurable
|
||||
// per-tenant Schema that maps a producer's private item JSON into that
|
||||
// projection.
|
||||
//
|
||||
// Why configurable: different tenants ship with different catalog shapes
|
||||
// (one has ArticleNumber, another has sku; one's price is inc-VAT minor units,
|
||||
// another's is a float with a separate currency; some include a Brand field,
|
||||
// others don't). A one-size-fits-all Projection field set would force every
|
||||
// tenant to field the union of all constraints; the configurable Schema keeps
|
||||
// each tenant's producer adapter small while still emitting the same
|
||||
// downstream wire shape, so commerce consumers do not change.
|
||||
//
|
||||
// What's here:
|
||||
//
|
||||
// - Projection: the published, versioned entry (struct + Encode helpers).
|
||||
// - Schema: declarative field-map for one tenant's source JSON.
|
||||
// - DefaultSchema: the consensus shape that works out of the box when no
|
||||
// tenant-specific override is supplied.
|
||||
// - Registry: per-tenant lookup with DefaultSchema fallback.
|
||||
// - Builder: applies a Schema to a generic source map; pure function.
|
||||
//
|
||||
// What is NOT here (kept out by design):
|
||||
//
|
||||
// - Numerics live in minor units (öre) and stay int64 — never float. The
|
||||
// platform-wide rate-scale convention (basis-points-of-a-percent) is
|
||||
// documented in platform/tax; TaxClass is a STRING identifier on the
|
||||
// Projection side and is resolved to a numeric rate by platform/tax, never
|
||||
// on the wire.
|
||||
// - No transport, no AMQP consumer/producer, no event-subscriber wiring —
|
||||
// callers publish / consume `Projection` JSON via the existing
|
||||
// `platform/event` + `platform/rabbit` package plumbing.
|
||||
// - No HTTP lookup. Finder remains the federation source; no commerce module
|
||||
// issues a "lookup this SKU" RPC today and resolving it here would be
|
||||
// premature.
|
||||
//
|
||||
// See docs/published-document-vocabulary-pass.md §11 for the wider vocabulary
|
||||
// context (this package implements the cart side of that contract; the full
|
||||
// vocabulary is the next pass and lives in `platform/published` once proven
|
||||
// out).
|
||||
package catalog
|
||||
@@ -0,0 +1,134 @@
|
||||
// Package catalog — see doc.go for the package summary.
|
||||
//
|
||||
// This file holds the published Projection struct: the wire shape that
|
||||
// commerce modules receive. Every producer adapter (writeradmin, future CMS
|
||||
// catalog adapter, ...) emits this exact shape; every consumer (cart,
|
||||
// checkout, order, UCP add-to-cart) reads from this exact shape. Per-tenant
|
||||
// field remapping happens in Schema + Builder (schema.go, builder.go).
|
||||
//
|
||||
// Mapping source JSON → Projection is per-tenant and lives in Schema.
|
||||
// The consensus mapping for DefaultSchema() is:
|
||||
//
|
||||
// id ← IDPrefix + SKU (e.g. "sku:<SKU>")
|
||||
// sku ← source[SKUField]
|
||||
// title ← source[TitleField] (omitempty, optional)
|
||||
// price_inc_vat ← coercive parse of source[PriceField] × PriceScale
|
||||
// currency ← source[CurrencyField], or FixedCurrency
|
||||
// tax_class ← source[TaxClassField] (string identifier)
|
||||
// inventory_tracked ← bool coercion of source[InventoryTrackedField]
|
||||
// drop_ship ← bool coercion of source[DropShipField]
|
||||
// category ← source[CategoryField] (omitempty)
|
||||
// brand ← source[BrandField] (omitempty)
|
||||
// image ← source[ImageField] (omitempty)
|
||||
// updated_at ← Unix-ms from source[UpdatedAtField] OR stamped at
|
||||
// publish time when UpdatedAtSource == StampAtPublish
|
||||
//
|
||||
// SCALE (rate-bearing fields): any field that carries a tax rate uses the
|
||||
// platform-wide basis-points-of-a-percent convention (rate × 100; 25% = 2500,
|
||||
// 12.5% = 1250, 8.1% = 810). Never float. Producer-side fields stay in
|
||||
// minor currency units (öre / cents) for amounts — already int64. TaxClass
|
||||
// on the wire is a STRING identifier; numeric rate resolution is a platform/tax
|
||||
// concern (out of package scope by design).
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// Projection is the published, versioned catalog entry that commerce modules
|
||||
// consume. Finder owns updating it; cart / checkout / order read it.
|
||||
//
|
||||
// Versioning: UpdatedAt is a monotonic Unix-millisecond timestamp stamped by
|
||||
// the producer at the time the AMQP event is emitted (or read from a
|
||||
// designated source field, see Schema.UpdatedAtSource). Consumers, when
|
||||
// revalidating, treat a strictly greater UpdatedAt as the authoritative
|
||||
// replacement and refresh the local copy.
|
||||
//
|
||||
// Field rationale:
|
||||
//
|
||||
// ID — opaque published identity; SKU alone is not a stable
|
||||
// cross-module id (different scopes reuse it).
|
||||
// SKU — domain identifier; the join key cart already uses.
|
||||
// PriceIncVat — display + checkout projection; NOT transactional truth.
|
||||
// System of record for the total is settled via the
|
||||
// checkout/order payload — see platform-uniforming-plan.md.
|
||||
// Currency — optional but recommended; absent ⇒ caller supplies.
|
||||
// TaxClass — class identifier (string). Numeric rate resolution is
|
||||
// a platform/tax concern (see platform/tax.Provider). This
|
||||
// struct never carries a numeric rate; that keeps the
|
||||
// rate-scale convention in one place and keeps the wire
|
||||
// free of consumer-policy values.
|
||||
// InventoryTracked / DropShip — fulfillment-routing flags. The C1 contract
|
||||
// asks for these so the cart can pre-mark drop-ship
|
||||
// items before checkout and inventory can opt-out of
|
||||
// tracking for non-stock items.
|
||||
// Category / Brand / Image — display projection only.
|
||||
// UpdatedAt — version stamp for snapshot + revalidate.
|
||||
type Projection struct {
|
||||
ID string `json:"id"`
|
||||
SKU string `json:"sku"`
|
||||
Title string `json:"title,omitempty"`
|
||||
PriceIncVat money.Cents `json:"price_inc_vat"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
// TaxClass uses a string identifier (see platform/tax class constants).
|
||||
// Empty string means "unclassified"; the consumer's tax.Provider is what
|
||||
// resolves the identifier to a numeric rate.
|
||||
TaxClass string `json:"tax_class"`
|
||||
InventoryTracked bool `json:"inventory_tracked"`
|
||||
DropShip bool `json:"drop_ship"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Brand string `json:"brand,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PayloadKey is the envelope key producers use when publishing a slice of
|
||||
// projections over the bus as an `event.Event.Payload` body. Consumers parse
|
||||
// this exact key — never a per-producer ad-hoc name — so the wire shape is
|
||||
// stable.
|
||||
const PayloadKey = "catalog_projection"
|
||||
|
||||
// Encode serialises a Projection to JSON suitable for the event.Payload.
|
||||
// Kept as a package-level helper so producers and tests share the exact wire
|
||||
// shape; any future wire renaming lands here, not in every caller.
|
||||
func (p Projection) Encode() ([]byte, error) {
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
// EncodeBatch serialises a slice to a JSON array.
|
||||
func EncodeBatch(batch []Projection) ([]byte, error) {
|
||||
return json.Marshal(batch)
|
||||
}
|
||||
|
||||
// ProjectionUpdate is one entry in a CatalogProjectionPublished batch. An
|
||||
// upsert carries a fully-built Projection. A delete sets Deleted=true; only SKU
|
||||
// (and ID) are then meaningful and the consumer drops that SKU from its
|
||||
// snapshot / cache. Modelling delete as a flag (not a nil payload) keeps a
|
||||
// change-set's upserts and deletes together in one ordered event.
|
||||
type ProjectionUpdate struct {
|
||||
Projection
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
}
|
||||
|
||||
// EncodeUpdates serialises a batch for the event.Event.Payload body.
|
||||
func EncodeUpdates(batch []ProjectionUpdate) ([]byte, error) {
|
||||
return json.Marshal(batch)
|
||||
}
|
||||
|
||||
// DecodeUpdates parses a CatalogProjectionPublished payload — the consumer side
|
||||
// of EncodeUpdates.
|
||||
func DecodeUpdates(raw []byte) ([]ProjectionUpdate, error) {
|
||||
var out []ProjectionUpdate
|
||||
err := json.Unmarshal(raw, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Decode parses a Projection from JSON. Symmetric with Encode so consumer
|
||||
// tests can round-trip a fixture without depending on the Builder.
|
||||
func Decode(raw []byte) (Projection, error) {
|
||||
var p Projection
|
||||
err := json.Unmarshal(raw, &p)
|
||||
return p, err
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Registry is the per-tenant Schema lookup. A producer processes tenant X:
|
||||
// it builds an event.Event with Source="catalog:<tenant>" and asks the
|
||||
// Registry for X's Schema; the Builder applies that Schema to the source
|
||||
// JSON. Unknown tenants get DefaultSchema — that makes the platform
|
||||
// "just work" for new tenants while the operator rolls out the proper
|
||||
// override file.
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Registry is concurrency-safe: Register / SchemaFor can be called from any
|
||||
// goroutine. SchemaFor is the hot path; it takes a read lock.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
schemas map[string]Schema
|
||||
def Schema // the "default" / DefaultSchema() fallback
|
||||
}
|
||||
|
||||
// NewRegistry returns an empty Registry whose DefaultSchema fallback is
|
||||
// DefaultSchema(). Use Register to add tenant-specific overrides.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
schemas: map[string]Schema{},
|
||||
def: DefaultSchema(),
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefault swaps the DefaultSchema fallback (used when SchemaFor is
|
||||
// called with an unknown tenant name, or with the literal "default").
|
||||
// Returns the receiver so it composes with NewRegistry().
|
||||
func (r *Registry) WithDefault(s Schema) *Registry {
|
||||
if s.Tenant == "" {
|
||||
s.Tenant = "default"
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.def = s
|
||||
return r
|
||||
}
|
||||
|
||||
// Register stores s under s.Tenant. A Schema with empty Tenant is stored
|
||||
// under "default" and ALSO replaces the DefaultSchema fallback.
|
||||
func (r *Registry) Register(s Schema) error {
|
||||
if s.SKUField == "" || s.PriceField == "" {
|
||||
return fmt.Errorf("catalog: schema for %q must declare sku_field and price_field", s.Tenant)
|
||||
}
|
||||
if s.PriceScale < 0 {
|
||||
return fmt.Errorf("catalog: schema %q has negative price_scale (%d)", s.Tenant, s.PriceScale)
|
||||
}
|
||||
// Foot-gun 3, registry-side guard: UseSourceField without a field is
|
||||
// the silent stamp-now path. Refuse at load time so a JSON typo
|
||||
// surfaces immediately rather than invalidating caches per-rebuild.
|
||||
if s.UpdatedAtSource == UseSourceField && s.UpdatedAtField == "" {
|
||||
return fmt.Errorf("catalog: schema %q has updated_at_source=UseSourceField but no updated_at_field (would silently stamp-now)", s.Tenant)
|
||||
}
|
||||
if s.TrueValues == nil {
|
||||
s.TrueValues = DefaultTrueValues()
|
||||
}
|
||||
key := s.Tenant
|
||||
if key == "" {
|
||||
key = "default"
|
||||
s.Tenant = key
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.def = s
|
||||
r.schemas[key] = s
|
||||
return nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.schemas[key] = s
|
||||
return nil
|
||||
}
|
||||
|
||||
// SchemaFor returns the Schema registered for tenant, or the DefaultSchema
|
||||
// fallback when tenant is "" or not registered.
|
||||
func (r *Registry) SchemaFor(tenant string) Schema {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if tenant == "" {
|
||||
return r.def
|
||||
}
|
||||
if s, ok := r.schemas[tenant]; ok {
|
||||
return s
|
||||
}
|
||||
return r.def
|
||||
}
|
||||
|
||||
// Tenants returns the sorted list of registered tenant names (excluding the
|
||||
// default fallback). Useful for ops dashboards and "what schemas are loaded".
|
||||
func (r *Registry) Tenants() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]string, 0, len(r.schemas))
|
||||
for k := range r.schemas {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// LoadFile reads a JSON file of { "tenant": Schema, ... } and registers each
|
||||
// entry into r. The JSON shape mirrors the Schema struct exactly (json tags
|
||||
// in schema.go). Missing file is OK (file is optional); unreadable file
|
||||
// returns the error so a misconfigured deployment fails loudly on startup.
|
||||
func (r *Registry) LoadFile(path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("catalog: read schema file %q: %w", path, err)
|
||||
}
|
||||
var raw map[string]Schema
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return fmt.Errorf("catalog: parse schema file %q: %w", path, err)
|
||||
}
|
||||
// Walk in sorted order so operator-visible loading is deterministic
|
||||
// (helps when something fails partway through).
|
||||
keys := make([]string, 0, len(raw))
|
||||
for k := range raw {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
s := raw[k]
|
||||
if s.Tenant == "" {
|
||||
s.Tenant = k
|
||||
}
|
||||
if err := r.Register(s); err != nil {
|
||||
return fmt.Errorf("catalog: schema %q in %q: %w", k, path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRegistryLookupAndDefault — registered tenant wins, unknown falls back.
|
||||
func TestRegistryLookupAndDefault(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if got := r.SchemaFor("missing"); !reflect.DeepEqual(got, DefaultSchema()) {
|
||||
t.Errorf("unknown tenant did not fall back to DefaultSchema")
|
||||
}
|
||||
bygg := BygglagretSchema()
|
||||
if err := r.Register(bygg); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
got := r.SchemaFor("bygglagret")
|
||||
if got.SKUField != "ArticleNumber" {
|
||||
t.Errorf("Register did not store under Tenant: %+v", got)
|
||||
}
|
||||
if got.SKUField == DefaultSchema().SKUField {
|
||||
t.Errorf("Expected bygglagret to differ from default")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterRejectsInvalidSchema — guard against typos in operator JSON.
|
||||
func TestRegisterRejectsInvalidSchema(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
err := r.Register(Schema{Tenant: "broken", PriceField: "x"}) // missing SKUField
|
||||
if err == nil {
|
||||
t.Error("expected error for schema without SKUField")
|
||||
}
|
||||
err = r.Register(Schema{Tenant: "negscale", SKUField: "s", PriceField: "p", PriceScale: -1})
|
||||
if err == nil {
|
||||
t.Error("expected error for negative PriceScale")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenantsSorted — dashboard-friendly ordering.
|
||||
func TestTenantsSorted(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
for _, k := range []string{"zeta", "alpha", "mu"} {
|
||||
s := Schema{Tenant: k, SKUField: "s", PriceField: "p"}
|
||||
if err := r.Register(s); err != nil {
|
||||
t.Fatalf("Register %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
got := r.Tenants()
|
||||
want := []string{"alpha", "mu", "zeta"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Tenants() got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFileMissingIsOK — file is optional.
|
||||
func TestLoadFileMissingIsOK(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if err := r.LoadFile(filepath.Join(t.TempDir(), "nope.json")); err != nil {
|
||||
t.Errorf("missing file should be a no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFileHappy — load tenant schemas from disk and look one up.
|
||||
func TestLoadFileHappy(t *testing.T) {
|
||||
// JSON shape mirrors schema.go json tags; tenant keys ("bygglagret") are
|
||||
// the registry names.
|
||||
jsonBody := `{
|
||||
"bygglagret": {
|
||||
"sku_field": "ArticleNumber",
|
||||
"price_field": "PriceInclVAT",
|
||||
"price_scale": 100,
|
||||
"id_prefix": "sku:",
|
||||
"updated_at_source": 1,
|
||||
"updated_at_field": "LastModifiedMs",
|
||||
"fixed_currency": "SEK"
|
||||
},
|
||||
"north": {
|
||||
"sku_field": "sku",
|
||||
"price_field": "price_minor",
|
||||
"price_scale": 1
|
||||
}
|
||||
}`
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "schemas.json")
|
||||
writeFile(t, path, jsonBody)
|
||||
r := NewRegistry()
|
||||
if err := r.LoadFile(path); err != nil {
|
||||
t.Fatalf("LoadFile: %v", err)
|
||||
}
|
||||
if got := r.SchemaFor("bygglagret").PriceField; got != "PriceInclVAT" {
|
||||
t.Errorf("bygglagret not loaded: %q", got)
|
||||
}
|
||||
if got := r.SchemaFor("north").PriceScale; got != 1 {
|
||||
t.Errorf("north not loaded: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// writeFile is a local helper; writes the JSON tenant-schema fixture to path.
|
||||
func writeFile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Schema declarations and the DefaultSchema consensus shape.
|
||||
//
|
||||
// A Schema maps a producer's private source JSON to the platform-wide
|
||||
// Projection. Field names point at keys in the source map; Value coercions
|
||||
// happen in the Builder. A Schema is the unit of configuration per tenant;
|
||||
// the Registry (registry.go) carries the per-tenant map.
|
||||
package catalog
|
||||
|
||||
// UpdatedAtSource controls where the Projection's UpdatedAt comes from.
|
||||
//
|
||||
// - StampAtPublish — Builder sets UpdatedAt to time.Now().UnixMilli() at the
|
||||
// moment Build() runs. RESERVED as a deliberate-fallback for sources
|
||||
// that genuinely have no other version stamp. NOT a content-version
|
||||
// address: a full snapshot rebuild re-stamps every projection and
|
||||
// invalidates every consumer cache even when nothing changed. Use only
|
||||
// when the producer side has NO source mtime / hash to set.
|
||||
//
|
||||
// - UseSourceField — the production-correct choice. Builder parses
|
||||
// source[Schema.UpdatedAtField] as a Unix-millisecond integer (or
|
||||
// decimal-fraction-of-Unix-second if the source uses seconds — Apply
|
||||
// accepts that and converts). This is the content-version value;
|
||||
// rebuilds produce identical UpdatedAt when content is identical.
|
||||
//
|
||||
// Critical contract: UpdatedAt is the CONTENT VERSION, not the publish
|
||||
// timestamp. Consumers use it for snapshot + revalidate. If your schema
|
||||
// sets UpdatedAtSource=StampAtPublish, every snapshot rebuild churns the
|
||||
// version. Choose UseSourceField for any consumer-cache scenario.
|
||||
type UpdatedAtSource int
|
||||
|
||||
const (
|
||||
// StampAtPublish sets UpdatedAt at Build() time. DefaultSchema
|
||||
// deliberately does NOT use this; it's reserved for callers who know
|
||||
// they want publish-time semantics.
|
||||
StampAtPublish UpdatedAtSource = iota
|
||||
// UseSourceField reads the version from Schema.UpdatedAtField. This is
|
||||
// the contract for content-versioned snapshots.
|
||||
UseSourceField
|
||||
)
|
||||
|
||||
// Schema is the declarative per-tenant mapping from a producer's source JSON
|
||||
// to the published Projection. Zero value is NOT valid — always start from
|
||||
// DefaultSchema() and override what differs, so unset-but-required fields
|
||||
// surface immediately rather than silently emitting empty Projections.
|
||||
//
|
||||
// All "Field" strings refer to keys in the source map[string]any that the
|
||||
// producer hands the Builder. Empty Field means "this Projection carrier is
|
||||
// supplied by Fixed* / Default* / Build-time, not read from source".
|
||||
type Schema struct {
|
||||
// Tenant is the registry key this schema is registered under. Empty
|
||||
// means "default" — see Registry.
|
||||
Tenant string `json:"tenant,omitempty"`
|
||||
|
||||
// --- source field references (per carrier) ---
|
||||
SKUField string `json:"sku_field"`
|
||||
TitleField string `json:"title_field,omitempty"`
|
||||
PriceField string `json:"price_field"`
|
||||
CurrencyField string `json:"currency_field,omitempty"`
|
||||
TaxClassField string `json:"tax_class_field,omitempty"`
|
||||
InventoryTrackedF string `json:"inventory_tracked_field,omitempty"`
|
||||
DropShipF string `json:"drop_ship_field,omitempty"`
|
||||
CategoryField string `json:"category_field,omitempty"`
|
||||
BrandField string `json:"brand_field,omitempty"`
|
||||
ImageField string `json:"image_field,omitempty"`
|
||||
|
||||
// --- fixed / default values used when a source field is absent ---.
|
||||
FixedCurrency string `json:"fixed_currency,omitempty"`
|
||||
// DefaultTaxClass is what the Builder writes when the source has no
|
||||
// TaxClass field (e.g. producer doesn't classify). Empty means "leave
|
||||
// empty" — consumer's tax.Provider gets called with empty string.
|
||||
DefaultTaxClass string `json:"default_tax_class,omitempty"`
|
||||
|
||||
// --- derived-value controls ---
|
||||
// PriceScale: source value interpreted as MINOR units if ≤ 1 (pass
|
||||
// through; floats round half-away-from-zero, ints pass through exact),
|
||||
// MAJOR units if 100 (multiply by 100 to get øre), etc. Negative =
|
||||
// rejected. The Builder rounds half-away-from-zero on fractional
|
||||
// results so the integer Projection never silently truncates.
|
||||
PriceScale int `json:"price_scale"`
|
||||
// IDPrefix is prepended to the normalised ID. Use "sku:" for the
|
||||
// consensus convention; an empty prefix means raw SKU.
|
||||
IDPrefix string `json:"id_prefix,omitempty"`
|
||||
// UpdatedAtSource — see type doc. DefaultSchema uses UseSourceField and
|
||||
// pins UpdatedAtField="updated_at". Producers MUST supply an updated_at
|
||||
// value (Unix-ms int); Apply returns ErrProjectionBuild (wrapped) when
|
||||
// it's missing rather than silently stamp-now'ing and churning every
|
||||
// rebuild. StampAtPublish is RESERVED for sources with no other stamp.
|
||||
UpdatedAtSource UpdatedAtSource `json:"updated_at_source"`
|
||||
// UpdatedAtField is the SOURCE-side key (when UpdatedAtSource ==
|
||||
// UseSourceField) the Builder reads the version from.
|
||||
UpdatedAtField string `json:"updated_at_field,omitempty"`
|
||||
// TrueValues is the set of source values that map to InventoryTracked
|
||||
// / DropShip = true. Comparison is lower-cased. Defaults to
|
||||
// DefaultTrueValues below if left nil — never add "false" / "0" etc,
|
||||
// the Builder treats anything not in TrueValues as false.
|
||||
TrueValues []string `json:"true_values,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultSchema is the consensus schema: SKU under "sku", price under
|
||||
// "price_inc_vat" already in øre, currency from source "currency" or empty,
|
||||
// tax_class from "tax_class", inventory_tracked / drop_ship from
|
||||
// "inventory_tracked" / "drop_ship" bool fields. updated_at MUST come from
|
||||
// the source under the field name "updated_at" (Unix-ms) — Apply rejects
|
||||
// missing values loudly rather than silent stamp-now churn.
|
||||
//
|
||||
// Use this when a tenant has no override file; it is the safe default for
|
||||
// producers that already emit platform-shaped JSON.
|
||||
func DefaultSchema() Schema {
|
||||
return Schema{
|
||||
Tenant: "default",
|
||||
// source-side field names
|
||||
SKUField: "sku",
|
||||
TitleField: "title",
|
||||
PriceField: "price_inc_vat",
|
||||
CurrencyField: "currency",
|
||||
TaxClassField: "tax_class",
|
||||
InventoryTrackedF: "inventory_tracked",
|
||||
DropShipF: "drop_ship",
|
||||
CategoryField: "category",
|
||||
BrandField: "brand",
|
||||
ImageField: "image",
|
||||
UpdatedAtField: "updated_at",
|
||||
|
||||
FixedCurrency: "", // read from source if present
|
||||
DefaultTaxClass: "standard",
|
||||
|
||||
PriceScale: 1, // price_inc_vat already in minor units
|
||||
IDPrefix: "sku:",
|
||||
UpdatedAtSource: UseSourceField, // change to StampAtPublish only with intent
|
||||
TrueValues: DefaultTrueValues(),
|
||||
}
|
||||
}
|
||||
|
||||
// BygglagretSchema is an explicit preset for the Swedish building-supplies
|
||||
// tenant that ships inc-VAT float prices in a "PriceInclVAT" string field.
|
||||
//
|
||||
// CRITICAL: TaxClassField (VATClass) is a STRING CLASS IDENTIFIER, not a raw
|
||||
// percent. The producer MUST pre-classify numeric VAT codes ("25" →
|
||||
// "standard", "12" → "reduced-12", "6" → "reduced-6") before publishing.
|
||||
// The wire contract is class identifiers; platform/tax resolves them to
|
||||
// numeric rates out of band. Shipping "25" as the class would break that
|
||||
// resolution path on the consumer side.
|
||||
func BygglagretSchema() Schema {
|
||||
return Schema{
|
||||
Tenant: "bygglagret",
|
||||
SKUField: "ArticleNumber",
|
||||
TitleField: "Name",
|
||||
PriceField: "PriceInclVAT",
|
||||
CurrencyField: "", // fixed below
|
||||
TaxClassField: "VATClass",
|
||||
InventoryTrackedF: "StockTracked",
|
||||
DropShipF: "DropshipFlag",
|
||||
CategoryField: "CategoryPath",
|
||||
BrandField: "Brand",
|
||||
ImageField: "PrimaryImage",
|
||||
UpdatedAtField: "LastModifiedMs",
|
||||
|
||||
FixedCurrency: "SEK",
|
||||
DefaultTaxClass: "standard",
|
||||
|
||||
PriceScale: 100, // PriceInclVAT is in SEK; multiply for øre
|
||||
IDPrefix: "sku:",
|
||||
UpdatedAtSource: UseSourceField,
|
||||
TrueValues: DefaultTrueValues(),
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultTrueValues is the consensus truthy-string set for bool-typed
|
||||
// source fields. Lower-cased comparison in Builder.
|
||||
func DefaultTrueValues() []string {
|
||||
return []string{"true", "yes", "1", "on"}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSchemaRoundTrip verifies a Schema can be JSON-encoded and decoded
|
||||
// without losing operator-tunable fields. Round-trip is the contract
|
||||
// LoadFile relies on.
|
||||
func TestSchemaRoundTrip(t *testing.T) {
|
||||
original := BygglagretSchema()
|
||||
original.TrueValues = []string{"true", "ja"} // tenant-specific
|
||||
|
||||
raw, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var decoded Schema
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if decoded.SKUField != original.SKUField {
|
||||
t.Errorf("SKUField: got %q want %q", decoded.SKUField, original.SKUField)
|
||||
}
|
||||
if decoded.PriceScale != original.PriceScale {
|
||||
t.Errorf("PriceScale: got %d want %d", decoded.PriceScale, original.PriceScale)
|
||||
}
|
||||
if decoded.UpdatedAtSource != original.UpdatedAtSource {
|
||||
t.Errorf("UpdatedAtSource: got %v want %v", decoded.UpdatedAtSource, original.UpdatedAtSource)
|
||||
}
|
||||
if decoded.UpdatedAtField != original.UpdatedAtField {
|
||||
t.Errorf("UpdatedAtField: got %q want %q", decoded.UpdatedAtField, original.UpdatedAtField)
|
||||
}
|
||||
if len(decoded.TrueValues) != 2 || decoded.TrueValues[1] != "ja" {
|
||||
t.Errorf("TrueValues lost on round-trip: %v", decoded.TrueValues)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultSchemaConsensusShape pins the field-name contract for the
|
||||
// zero-config tenant (the operator-facing shape).
|
||||
func TestDefaultSchemaConsensusShape(t *testing.T) {
|
||||
s := DefaultSchema()
|
||||
if s.Tenant != "default" {
|
||||
t.Errorf("DefaultSchema().Tenant = %q, want %q", s.Tenant, "default")
|
||||
}
|
||||
if s.SKUField != "sku" || s.PriceField != "price_inc_vat" {
|
||||
t.Errorf("consensus shape rotated: SKUField=%q PriceField=%q", s.SKUField, s.PriceField)
|
||||
}
|
||||
if s.PriceScale != 1 || s.IDPrefix != "sku:" {
|
||||
t.Errorf("consensus defaults wrong: PriceScale=%d IDPrefix=%q", s.PriceScale, s.IDPrefix)
|
||||
}
|
||||
if s.UpdatedAtSource != UseSourceField {
|
||||
t.Errorf("consensus updated_at_source = %v, want UseSourceField "+
|
||||
"(foot-gun 3: stamp-now'ing on rebuild invalidates every consumer cache)", s.UpdatedAtSource)
|
||||
}
|
||||
if len(s.TrueValues) == 0 {
|
||||
t.Errorf("Truth set should not be empty in consensus")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrueValueCaseInsensitive pins the lower-cased match used by boolOf.
|
||||
func TestTrueValueCaseInsensitive(t *testing.T) {
|
||||
truthy := DefaultTrueValues()
|
||||
for _, s := range []string{"TRUE", "Yes", "ON", "1"} {
|
||||
// Re-implement the matching here to avoid coupling to builder.go's
|
||||
// internals, but flag if it ever drifts.
|
||||
low := ""
|
||||
for _, c := range s {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
low += string(c + 32)
|
||||
} else {
|
||||
low += string(c)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, t := range truthy {
|
||||
if low == t {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("truthy set is missing lower-cased %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaZeroValueInvalid documents that the zero value of Schema is
|
||||
// explicitly NOT a usable schema — Apply validates and refuses it.
|
||||
func TestSchemaZeroValueInvalid(t *testing.T) {
|
||||
_, err := Apply(Schema{}, map[string]any{"sku": "ABC", "price_inc_vat": 1})
|
||||
if err == nil {
|
||||
t.Errorf("zero Schema should be rejected by Apply")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// Snapshot reader/writer for the published catalog Projection.
|
||||
//
|
||||
// Why a snapshot file at all? The bus is the live channel, but it doesn't
|
||||
// carry the historical population on cold start. A snapshot file gives
|
||||
// consumers (cart / checkout / order / UCP add-to-cart) a fast mmap-loaded
|
||||
// cold-start substrate, so the first add-line request doesn't have to wait for
|
||||
// the bus to replay. The bus then keeps it fresh.
|
||||
//
|
||||
// Format v2 (little-endian, fixed-width, mmap-friendly):
|
||||
//
|
||||
// header [72 bytes] magic(8) version(4) reserved(4) count(8)
|
||||
// blobStart(8) recordStart(8) tenant(32, zero-padded)
|
||||
// blobs variable count sequential JSON-encoded Projection bodies, no padding
|
||||
// records count*44 each: sku(32, zero-padded) blobOffset(8) blobLength(4);
|
||||
// sorted by SKU via the single compareSKU total order
|
||||
//
|
||||
// Tenant field at offsets 40–71 (zero-padded) names the namespace this
|
||||
// snapshot covers. Independent SKU schemas can produce Projections into
|
||||
// different tenant files without conflict. Hot-reload across tenants stays
|
||||
// safe under SnapshotHolder (snapshot_manager.go).
|
||||
//
|
||||
// Sorting + binary search gives O(log N) per-SKU lookup with no on-disk hash
|
||||
// table and no collisions. SKUs longer than 32 bytes are rejected at write
|
||||
// time (Cart SKUs in this codebase are well under that).
|
||||
//
|
||||
// Memory model: the Reader holds one []byte for the entire file (mmap on
|
||||
// Unix, ReadFile elsewhere). Per-SKU Get reads at most log2(N) record slots
|
||||
// (each 44 B) and one JSON blob (~200 B). For 220 k projections the
|
||||
// resident set is "pages touched this minute", not the whole file.
|
||||
//
|
||||
// SAFETY: SnapshotReader alone is NOT safe across hot-reload. During reload,
|
||||
// in-flight Get() calls on the OLD reader continue to work until disposal
|
||||
// of the old reader is finalised; calling SnapshotReader.Close() concurrently
|
||||
// with Get() on the same reader risks unmapping pages-under-read (SIGBUS).
|
||||
// Use SnapshotHolder (snapshot_manager.go) for any reload-bearing setup —
|
||||
// it gives you an atomic.Pointer swap with a drain-grace (default 30s) so
|
||||
// concurrent Gets on the OLD reader finish naturally before munmap fires.
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
snapshotMagic uint64 = 0x3230564a53435050 // "PCSJ002" v2 (was 0x3130564a53435050 = v1)
|
||||
snapshotVersion uint32 = 2
|
||||
maxSKULen = 32
|
||||
maxTenantLen = 32
|
||||
headerSize = 8 + 4 + 4 + 8 + 8 + 8 + 32 // 72 bytes
|
||||
recordSize = 32 + 8 + 4 // 44 bytes per record
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSnapshotMagic = errors.New("catalog: not a snapshot file (bad magic)")
|
||||
ErrSnapshotVersion = errors.New("catalog: unsupported snapshot version")
|
||||
ErrSnapshotTruncated = errors.New("catalog: snapshot truncated")
|
||||
ErrSnapshotBadBlob = errors.New("catalog: snapshot record points past file")
|
||||
ErrSnapshotOverflow = errors.New("catalog: snapshot record-section size overflows uint64")
|
||||
ErrSKUTooLong = fmt.Errorf("catalog: SKU longer than %d bytes", maxSKULen)
|
||||
ErrTenantTooLong = fmt.Errorf("catalog: tenant name longer than %d bytes", maxTenantLen)
|
||||
)
|
||||
|
||||
// SnapshotWriter is single-shot: Append() all projections, then Write() once
|
||||
// (atomic rename-into-place so readers never see a partial file).
|
||||
type SnapshotWriter struct {
|
||||
Tenant string // optional namespace tag (writes into header)
|
||||
blobs []byte
|
||||
records []snapshotRecord
|
||||
dirty bool
|
||||
}
|
||||
|
||||
// snapshotRecord is the on-disk layout: SKU zero-padded to 32 bytes, then
|
||||
// an 8-byte offset (from file start) plus a 4-byte length for the JSON blob.
|
||||
type snapshotRecord struct {
|
||||
SKU [32]byte
|
||||
BlobOffset uint64
|
||||
BlobLength uint32
|
||||
}
|
||||
|
||||
// NewSnapshotWriter returns an empty writer. Caller Appends every projection
|
||||
// and calls Write once.
|
||||
func NewSnapshotWriter() *SnapshotWriter {
|
||||
return &SnapshotWriter{}
|
||||
}
|
||||
|
||||
// WithTenant tags this writer with a tenant namespace that lands in the
|
||||
// snapshot header. Optional — empty tenant gets zero-padded; readers see an
|
||||
// empty string when the writer was untagged.
|
||||
func (w *SnapshotWriter) WithTenant(tenant string) *SnapshotWriter {
|
||||
w.Tenant = tenant
|
||||
return w
|
||||
}
|
||||
|
||||
// Append encodes and adds a projection. Returns ErrSKUTooLong if SKU exceeds
|
||||
// the on-disk width; returns marshal errors as wrapped fmt.Errorf.
|
||||
func (w *SnapshotWriter) Append(p Projection) error {
|
||||
if len(p.SKU) > maxSKULen {
|
||||
return ErrSKUTooLong
|
||||
}
|
||||
jb, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("catalog: marshal projection %q: %w", p.SKU, err)
|
||||
}
|
||||
|
||||
var rec snapshotRecord
|
||||
copy(rec.SKU[:], p.SKU)
|
||||
rec.BlobOffset = uint64(headerSize + len(w.blobs))
|
||||
rec.BlobLength = uint32(len(jb))
|
||||
|
||||
w.blobs = append(w.blobs, jb...)
|
||||
w.records = append(w.records, rec)
|
||||
w.dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Len returns how many projections have been Append'd.
|
||||
func (w *SnapshotWriter) Len() int { return len(w.records) }
|
||||
|
||||
// Write serialises to a temp file in dir, fsync's, then renames into the
|
||||
// final path. Records are sorted by SKU asc before serialisation so the
|
||||
// reader's binary search works.
|
||||
func (w *SnapshotWriter) Write(dir, name string) (finalPath string, err error) {
|
||||
if w.dirty {
|
||||
sort.Slice(w.records, func(i, j int) bool {
|
||||
return compareSKU(w.records[i].SKU[:], w.records[j].SKU[:]) < 0
|
||||
})
|
||||
w.dirty = false
|
||||
}
|
||||
if len(w.Tenant) > maxTenantLen {
|
||||
err = ErrTenantTooLong
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err = os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
finalPath = filepath.Join(dir, name)
|
||||
tmp := finalPath + ".tmp"
|
||||
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
_ = f.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// Header: fixed 72 bytes (8 + 4 + 4 + 8 + 8 + 8 + 32).
|
||||
blobStart := uint64(headerSize)
|
||||
recordStart := blobStart + uint64(len(w.blobs))
|
||||
var hdr [headerSize]byte
|
||||
binary.LittleEndian.PutUint64(hdr[0:8], snapshotMagic)
|
||||
binary.LittleEndian.PutUint32(hdr[8:12], snapshotVersion)
|
||||
binary.LittleEndian.PutUint32(hdr[12:16], 0)
|
||||
binary.LittleEndian.PutUint64(hdr[16:24], uint64(len(w.records)))
|
||||
binary.LittleEndian.PutUint64(hdr[24:32], blobStart)
|
||||
binary.LittleEndian.PutUint64(hdr[32:40], recordStart)
|
||||
copy(hdr[40:40+len(w.Tenant)], w.Tenant) // rest is zero-pad
|
||||
|
||||
if _, werr := f.Write(hdr[:]); werr != nil {
|
||||
err = werr
|
||||
return "", err
|
||||
}
|
||||
if _, werr := f.Write(w.blobs); werr != nil {
|
||||
err = werr
|
||||
return "", err
|
||||
}
|
||||
recBytes := make([]byte, recordSize*len(w.records))
|
||||
for i, r := range w.records {
|
||||
off := i * recordSize
|
||||
copy(recBytes[off:off+32], r.SKU[:])
|
||||
binary.LittleEndian.PutUint64(recBytes[off+32:off+40], r.BlobOffset)
|
||||
binary.LittleEndian.PutUint32(recBytes[off+40:off+44], r.BlobLength)
|
||||
}
|
||||
if _, werr := f.Write(recBytes); werr != nil {
|
||||
err = werr
|
||||
return "", err
|
||||
}
|
||||
|
||||
if werr := f.Sync(); werr != nil {
|
||||
err = werr
|
||||
return "", err
|
||||
}
|
||||
if werr := f.Close(); werr != nil {
|
||||
err = werr
|
||||
return "", err
|
||||
}
|
||||
if werr := os.Rename(tmp, finalPath); werr != nil {
|
||||
err = werr
|
||||
return "", err
|
||||
}
|
||||
return finalPath, nil
|
||||
}
|
||||
|
||||
// compareSKU defines the SINGLE TOTAL ORDER for SKUs across both Writer
|
||||
// (uses it for sort.Slice) and Reader (uses it for binary search). One
|
||||
// definition; invariant test verifies the on-disk result is sorted under
|
||||
// it. Source SKUs SHOULD NOT contain embedded null bytes — the padded
|
||||
// comparator treats them as terminators, which silently truncates an SKU.
|
||||
// Realistically no SKU has NUL.
|
||||
func compareSKU(padded, other []byte) int {
|
||||
// padded is a fixed-size zero-padded slot; trim trailing zeros.
|
||||
end := len(padded)
|
||||
for end > 0 && padded[end-1] == 0 {
|
||||
end--
|
||||
}
|
||||
a := padded[:end]
|
||||
b := other
|
||||
|
||||
n := len(a)
|
||||
if len(b) < n {
|
||||
n = len(b)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if a[i] != b[i] {
|
||||
if a[i] < b[i] {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(a) < len(b):
|
||||
return -1
|
||||
case len(a) > len(b):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// compareRecordLookup is compareSKU specialised for Reader lookups so the
|
||||
// argument order matches the lex order: "padded" (fixed-slot) and "want"
|
||||
// (query).
|
||||
//
|
||||
// compareSKU is symmetric so this is just an alias used by Reader code
|
||||
// paths to make intent obvious. Any change to the total order here MUST be
|
||||
// reflected in Writer.sort.Slice and verified by the invariant test.
|
||||
func compareRecordLookup(padded, want []byte) int {
|
||||
return compareSKU(padded, want)
|
||||
}
|
||||
|
||||
// SnapshotReader is read-only and concurrency-safe — parallel Get calls are
|
||||
// OK because no fields are mutated after construction. Held open via an
|
||||
// embedded file handle so the underlying mmap stays pinned. Close uses
|
||||
// sync.Once so concurrent shutdown paths can't double-munmap.
|
||||
//
|
||||
// IMPORTANT — see package doc: SnapshotReader is NOT safe racing Close
|
||||
// concurrently with Get. Use SnapshotHolder for any reload-bearing workflow.
|
||||
type SnapshotReader struct {
|
||||
data []byte
|
||||
count uint64
|
||||
blobStart uint64
|
||||
recordStart uint64
|
||||
Tenant string // populated from header at Open time; "" if not set
|
||||
|
||||
closeOnce sync.Once
|
||||
closer func() error // munmap + close on Unix; no-op on ReadFile fallback
|
||||
}
|
||||
|
||||
// ReadSnapshotFile opens + parses a snapshot file. On Unix uses mmap; on
|
||||
// other platforms reads into memory (functionally identical, higher RSS).
|
||||
func ReadSnapshotFile(path string) (*SnapshotReader, error) {
|
||||
data, closer, err := loadSnapshotBytes(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := newSnapshotReader(data)
|
||||
if err != nil {
|
||||
if closer != nil {
|
||||
_ = closer()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
r.closer = closer
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// NewSnapshotReader wraps an already-loaded []byte (use this in tests; for
|
||||
// production paths go through ReadSnapshotFile).
|
||||
func NewSnapshotReader(data []byte) (*SnapshotReader, error) {
|
||||
return newSnapshotReader(data)
|
||||
}
|
||||
|
||||
func newSnapshotReader(data []byte) (*SnapshotReader, error) {
|
||||
if len(data) < headerSize {
|
||||
return nil, ErrSnapshotTruncated
|
||||
}
|
||||
magic := binary.LittleEndian.Uint64(data[0:8])
|
||||
if magic != snapshotMagic {
|
||||
return nil, ErrSnapshotMagic
|
||||
}
|
||||
version := binary.LittleEndian.Uint32(data[8:12])
|
||||
if version != snapshotVersion {
|
||||
return nil, ErrSnapshotVersion
|
||||
}
|
||||
count := binary.LittleEndian.Uint64(data[16:24])
|
||||
blobStart := binary.LittleEndian.Uint64(data[24:32])
|
||||
recordStart := binary.LittleEndian.Uint64(data[32:40])
|
||||
if blobStart != uint64(headerSize) {
|
||||
return nil, fmt.Errorf("catalog: blobStart=%d expected %d", blobStart, headerSize)
|
||||
}
|
||||
|
||||
// Multiplication overflow guard: for a malformed/tampered count, the
|
||||
// naive int(count)*recordSize in Go silently wraps on overflow,
|
||||
// making expectedSize negative and bypassing the truncation guard.
|
||||
// Compute as uint64 with an explicit overflow check.
|
||||
recordBytes := uint64(recordSize) * count
|
||||
if count > 0 && recordBytes/uint64(count) != uint64(recordSize) {
|
||||
return nil, fmt.Errorf("%w: count*recordSize overflows (count=%d)", ErrSnapshotOverflow, count)
|
||||
}
|
||||
if recordStart > math.MaxUint64-recordBytes {
|
||||
return nil, fmt.Errorf("%w: recordStart+records exceeds uint64 (recordStart=%d count=%d)",
|
||||
ErrSnapshotOverflow, recordStart, count)
|
||||
}
|
||||
expectedSize := recordStart + recordBytes
|
||||
if uint64(len(data)) < expectedSize {
|
||||
return nil, ErrSnapshotTruncated
|
||||
}
|
||||
|
||||
// Per-record blob-offset bounds check: defends against malformed /
|
||||
// tampered files where the record section is intact but the blob
|
||||
// pointer lies past EOF. A linear pass; cheap (44 B × count at open).
|
||||
//
|
||||
// Avoid the naive blobOff+blobLen > dataLen check — blobOff can be
|
||||
// MaxUint64 in tampered input and uint64 addition wraps, falsely
|
||||
// passing the test. Split the check into overflow-safe parts.
|
||||
dataLen := uint64(len(data))
|
||||
for i := uint64(0); i < count; i++ {
|
||||
recOff := recordStart + i*uint64(recordSize)
|
||||
blobLen := binary.LittleEndian.Uint32(data[recOff+40 : recOff+44])
|
||||
blobOff := binary.LittleEndian.Uint64(data[recOff+32 : recOff+40])
|
||||
if blobOff > dataLen || uint64(blobLen) > dataLen-blobOff {
|
||||
return nil, fmt.Errorf("%w: record %d blobOff=%d len=%d exceeds file size %d",
|
||||
ErrSnapshotBadBlob, i, blobOff, blobLen, dataLen)
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant: read zero-padded bytes at offset 40, trim trailing zeros.
|
||||
tenantBytes := data[40 : 40+maxTenantLen]
|
||||
tenantEnd := len(tenantBytes)
|
||||
for tenantEnd > 0 && tenantBytes[tenantEnd-1] == 0 {
|
||||
tenantEnd--
|
||||
}
|
||||
tenant := string(tenantBytes[:tenantEnd])
|
||||
|
||||
return &SnapshotReader{
|
||||
data: data,
|
||||
count: count,
|
||||
blobStart: blobStart,
|
||||
recordStart: recordStart,
|
||||
Tenant: tenant,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Len returns the number of Projections in this snapshot.
|
||||
func (r *SnapshotReader) Len() int { return int(r.count) }
|
||||
|
||||
// Close releases the file handle and mmap region (if any). Idempotent — safe
|
||||
// to call from multiple goroutines (uses sync.Once under the hood).
|
||||
//
|
||||
// SAFETY: this is NOT safe against concurrent Get() — see package doc. Use
|
||||
// SnapshotHolder for any reload-bearing workflow.
|
||||
func (r *SnapshotReader) Close() error {
|
||||
var err error
|
||||
r.closeOnce.Do(func() {
|
||||
if r.closer != nil {
|
||||
err = r.closer()
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Get returns the projection for sku, or (zero, false) if not found.
|
||||
// O(log N) — binary search on the sorted record section; on hit, reads the
|
||||
// JSON blob and decodes it (one allocation).
|
||||
func (r *SnapshotReader) Get(sku string) (Projection, bool) {
|
||||
if r.count == 0 || sku == "" {
|
||||
return Projection{}, false
|
||||
}
|
||||
skuBytes := []byte(sku)
|
||||
if len(skuBytes) > maxSKULen {
|
||||
return Projection{}, false
|
||||
}
|
||||
|
||||
lo, hi := uint64(0), r.count
|
||||
for lo < hi {
|
||||
mid := (lo + hi) / 2
|
||||
recOff := int(r.recordStart) + int(mid)*recordSize
|
||||
recSKU := r.data[recOff : recOff+32]
|
||||
cmp := compareRecordLookup(recSKU, skuBytes)
|
||||
switch {
|
||||
case cmp < 0:
|
||||
lo = mid + 1
|
||||
case cmp > 0:
|
||||
hi = mid
|
||||
default:
|
||||
blobLen := binary.LittleEndian.Uint32(r.data[recOff+40 : recOff+44])
|
||||
blobOff := binary.LittleEndian.Uint64(r.data[recOff+32 : recOff+40])
|
||||
// Per-record bounds check at Open guarantees this slice is in-range
|
||||
rdata := r.data[blobOff : blobOff+uint64(blobLen)]
|
||||
var p Projection
|
||||
if err := json.Unmarshal(rdata, &p); err != nil {
|
||||
return Projection{}, false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return Projection{}, false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Local helpers for snapshot_test.go — production code does not see them.
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func os2WriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
return os.WriteFile(path, data, perm)
|
||||
}
|
||||
|
||||
func os2Stat(path string) (os.FileInfo, error) {
|
||||
return os.Stat(path)
|
||||
}
|
||||
|
||||
func repeat32(s string, n int) string {
|
||||
out := make([]byte, 0, n*len(s))
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, s...)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// SnapshotHolder provides atomic hot-reload for SnapshotReader with drain
|
||||
// grace — solves the classic "Get concurrent with Close/munmap" race that
|
||||
// would otherwise produce uncatchable SIGBUS / SIGSEGV under reload.
|
||||
//
|
||||
// Pattern source: slask-finder's `RawItemsStore` and similar mmap+reload
|
||||
// stores, which delay unmap behind a configurable drain-grace timer so any
|
||||
// in-flight Get on the previously-current reader completes before the
|
||||
// underlying pages become unmapped. See docs/inventory-shape.md & the
|
||||
// finder WARP doc on mmap reload for the prior art.
|
||||
//
|
||||
// Use:
|
||||
//
|
||||
// holder := catalog.NewSnapshotHolder(30 * time.Second) // drain grace
|
||||
// if err := holder.Open("/var/lib/catalog/bygglagret.bin"); err != nil { ... }
|
||||
// // later, when a fresh snapshot lands:
|
||||
// if err := holder.Reload("/var/lib/catalog/bygglagret.bin"); err != nil { ... }
|
||||
// // pulls the projection; always hits the current reader:
|
||||
// p, ok := holder.Get(sku)
|
||||
//
|
||||
// holder.Get / Len are safe for parallel use. Open and Reload are also
|
||||
// safe for parallel use. Close stops the reaper goroutine and shuts the
|
||||
// current reader + drains down.
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultDrainGrace is the default drain period for SnapshotHolder. Finder's
|
||||
// analogous reload path uses ~30s; we mirror that value.
|
||||
const DefaultDrainGrace = 30 * time.Second
|
||||
|
||||
// SnapshotHolder swaps a SnapshotReader under load without unmapping pages
|
||||
// out from under in-flight Get()s. Set retains the previous reader behind a
|
||||
// drain timer; after the timer elapses the reaper goroutine closes the old
|
||||
// reader (and runs its munmap).
|
||||
type SnapshotHolder struct {
|
||||
current atomic.Pointer[SnapshotReader]
|
||||
|
||||
drainsMu sync.Mutex
|
||||
drains []*pendingClose
|
||||
drainGrace time.Duration
|
||||
|
||||
stopCh chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
type pendingClose struct {
|
||||
r *SnapshotReader
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
// NewSnapshotHolder returns a holder with the given drain grace (the time
|
||||
// the previous reader lives behind Set before being closed). Zero or
|
||||
// negative grace falls back to DefaultDrainGrace.
|
||||
func NewSnapshotHolder(grace time.Duration) *SnapshotHolder {
|
||||
if grace <= 0 {
|
||||
grace = DefaultDrainGrace
|
||||
}
|
||||
h := &SnapshotHolder{
|
||||
drainGrace: grace,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go h.sweepLoop()
|
||||
return h
|
||||
}
|
||||
|
||||
// Open is shorthand for Set(ReadSnapshotFile(path)). Errors leave the
|
||||
// holder's current reader unchanged.
|
||||
func (h *SnapshotHolder) Open(path string) error {
|
||||
r, err := ReadSnapshotFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.Set(r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload atomically swaps the current reader for one freshly opened from
|
||||
// path. The old reader (if any) is queued for close after the drain grace
|
||||
// elapses; in-flight Get(s) on the old reader continue to work.
|
||||
func (h *SnapshotHolder) Reload(path string) error {
|
||||
r, err := ReadSnapshotFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.Set(r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set replaces the current reader with r. The previous reader is queued
|
||||
// for close after the drain grace elapses. r may be nil to clear.
|
||||
func (h *SnapshotHolder) Set(r *SnapshotReader) {
|
||||
if r == nil {
|
||||
h.current.Store(nil)
|
||||
return
|
||||
}
|
||||
old := h.current.Swap(r)
|
||||
if old == nil {
|
||||
return
|
||||
}
|
||||
h.drainsMu.Lock()
|
||||
h.drains = append(h.drains, &pendingClose{
|
||||
r: old,
|
||||
expireAt: time.Now().Add(h.drainGrace),
|
||||
})
|
||||
h.drainsMu.Unlock()
|
||||
}
|
||||
|
||||
// Get is the concurrent-safe shim over the current reader's Get. Returns
|
||||
// (zero, false) when the holder is empty.
|
||||
func (h *SnapshotHolder) Get(sku string) (Projection, bool) {
|
||||
r := h.current.Load()
|
||||
if r == nil {
|
||||
return Projection{}, false
|
||||
}
|
||||
return r.Get(sku)
|
||||
}
|
||||
|
||||
// Len returns the count of the current reader (0 if empty).
|
||||
func (h *SnapshotHolder) Len() int {
|
||||
r := h.current.Load()
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
return r.Len()
|
||||
}
|
||||
|
||||
// Close stops the reaper goroutine and closes the current reader + any
|
||||
// pending drains without waiting for the grace period. Safe to call
|
||||
// multiple times.
|
||||
func (h *SnapshotHolder) Close() error {
|
||||
var firstErr error
|
||||
h.once.Do(func() {
|
||||
close(h.stopCh)
|
||||
r := h.current.Swap(nil)
|
||||
if r != nil {
|
||||
if err := r.Close(); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
h.drainsMu.Lock()
|
||||
drained := h.drains
|
||||
h.drains = nil
|
||||
h.drainsMu.Unlock()
|
||||
for _, d := range drained {
|
||||
if err := d.r.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
})
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// DrainCount returns the number of readers currently in the drain queue.
|
||||
// Useful for tests and operational metrics.
|
||||
func (h *SnapshotHolder) DrainCount() int {
|
||||
h.drainsMu.Lock()
|
||||
defer h.drainsMu.Unlock()
|
||||
return len(h.drains)
|
||||
}
|
||||
|
||||
// reapNow scans the drain queue and Closes any readers whose grace has
|
||||
// elapsed. Called by the sweeper goroutine on a tick.
|
||||
func (h *SnapshotHolder) reapNow() {
|
||||
now := time.Now()
|
||||
var alive []*pendingClose
|
||||
var reaped []*pendingClose
|
||||
h.drainsMu.Lock()
|
||||
for _, d := range h.drains {
|
||||
if !d.expireAt.After(now) {
|
||||
reaped = append(reaped, d)
|
||||
} else {
|
||||
alive = append(alive, d)
|
||||
}
|
||||
}
|
||||
h.drains = alive
|
||||
h.drainsMu.Unlock()
|
||||
for _, d := range reaped {
|
||||
_ = d.r.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SnapshotHolder) sweepLoop() {
|
||||
interval := h.drainGrace / 4
|
||||
if interval < 10*time.Millisecond {
|
||||
interval = 10 * time.Millisecond
|
||||
}
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-h.stopCh:
|
||||
return
|
||||
case <-t.C:
|
||||
h.reapNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeCloser counts how many times it's been called. Used to assert the
|
||||
// drain-grace semantics of SnapshotHolder.
|
||||
type fakeCloser struct{ hits int }
|
||||
|
||||
func (f *fakeCloser) Close() error { f.hits++; return nil }
|
||||
|
||||
// withFakeCloser swaps SnapshotReader's closer for our counter. Returns the
|
||||
// reader and the counter; tests then call holder.Set(reloaded) and assert
|
||||
// that close is invoked after the drain grace.
|
||||
func withFakeCloser(t *testing.T, path string, fc *fakeCloser) *SnapshotReader {
|
||||
t.Helper()
|
||||
r, err := ReadSnapshotFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// replace real closer with our counter; the test asserts only via fc
|
||||
r.closer = fc.Close
|
||||
return r
|
||||
}
|
||||
|
||||
// TestSnapshotHolderReloadDrainsOld — Set swaps the current reader; the
|
||||
// previous reader is queued. Before grace: still Get-able. After grace:
|
||||
// reaped and Close fires.
|
||||
//
|
||||
// We use a very short grace so the test doesn't sleep long.
|
||||
func TestSnapshotHolderReloadDrainsOld(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// 1) Build a valid first snapshot (skus A-1 / A-2).
|
||||
w := NewSnapshotWriter()
|
||||
if err := w.Append(makeProjection("A-1", 100)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Append(makeProjection("A-2", 200)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(dir, "first.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstCloser := &fakeCloser{}
|
||||
first := withFakeCloser(t, filepath.Join(dir, "first.bin"), firstCloser)
|
||||
defer first.Close()
|
||||
|
||||
// 2) Build a second snapshot (skus B-1).
|
||||
w2 := NewSnapshotWriter()
|
||||
if err := w2.Append(makeProjection("B-1", 300)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w2.Write(dir, "second.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondCloser := &fakeCloser{}
|
||||
second := withFakeCloser(t, filepath.Join(dir, "second.bin"), secondCloser)
|
||||
defer second.Close()
|
||||
|
||||
h := NewSnapshotHolder(50 * time.Millisecond)
|
||||
defer h.Close()
|
||||
h.Set(first)
|
||||
if h.Len() != 2 {
|
||||
t.Errorf("Len after first Set: got %d want 2", h.Len())
|
||||
}
|
||||
if _, ok := h.Get("A-1"); !ok {
|
||||
t.Error("A-1 not found in initial state")
|
||||
}
|
||||
|
||||
// 3) Swap to second — first is now queued.
|
||||
h.Set(second)
|
||||
if h.Len() != 1 {
|
||||
t.Errorf("Len after second Set: got %d want 1", h.Len())
|
||||
}
|
||||
if firstCloser.hits != 0 {
|
||||
t.Errorf("firstCloser hits: got %d want 0 (still in drain grace)", firstCloser.hits)
|
||||
}
|
||||
if h.DrainCount() != 1 {
|
||||
t.Errorf("DrainCount: got %d want 1", h.DrainCount())
|
||||
}
|
||||
|
||||
// 4) Wait past grace, force a reap tick.
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
h.reapNow()
|
||||
if firstCloser.hits != 1 {
|
||||
t.Errorf("firstCloser hits: got %d want 1 (should have reaped)", firstCloser.hits)
|
||||
}
|
||||
if h.DrainCount() != 0 {
|
||||
t.Errorf("DrainCount after reap: got %d want 0", h.DrainCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotHolderEmptyGet — holder with no current serves empty.
|
||||
func TestSnapshotHolderEmptyGet(t *testing.T) {
|
||||
h := NewSnapshotHolder(100 * time.Millisecond)
|
||||
defer h.Close()
|
||||
if _, ok := h.Get("anything"); ok {
|
||||
t.Error("empty holder should return false")
|
||||
}
|
||||
if h.Len() != 0 {
|
||||
t.Errorf("Len: got %d want 0", h.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotHolderCloseImmediatelyReaps — Close with pending drains
|
||||
// should close them now (no grace wait). Confirms the drain queue is
|
||||
// drained on Close.
|
||||
func TestSnapshotHolderCloseImmediatelyReaps(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
w := NewSnapshotWriter()
|
||||
if err := w.Append(makeProjection("X-1", 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(dir, "x.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fc1 := &fakeCloser{}
|
||||
r1 := withFakeCloser(t, filepath.Join(dir, "x.bin"), fc1)
|
||||
|
||||
w2 := NewSnapshotWriter()
|
||||
if err := w2.Append(makeProjection("Y-1", 2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w2.Write(dir, "y.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fc2 := &fakeCloser{}
|
||||
r2 := withFakeCloser(t, filepath.Join(dir, "y.bin"), fc2)
|
||||
|
||||
h := NewSnapshotHolder(10 * time.Second) // long grace so reap wouldn't happen on its own
|
||||
h.Set(r1)
|
||||
h.Set(r2)
|
||||
|
||||
if fc1.hits != 0 || fc2.hits != 0 {
|
||||
t.Errorf("premature Close hits: fc1=%d fc2=%d", fc1.hits, fc2.hits)
|
||||
}
|
||||
if err := h.Close(); err != nil {
|
||||
t.Errorf("Close: %v", err)
|
||||
}
|
||||
if fc1.hits != 1 || fc2.hits != 1 {
|
||||
t.Errorf("after Close: fc1=%d fc2=%d want 1 each", fc1.hits, fc2.hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotHolderIdentityReload — Open the same path twice; current reader
|
||||
// is replaced; DrainCount returns 1 (old queued). Old's data still served
|
||||
// until grace elapses.
|
||||
func TestSnapshotHolderIdentityReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewSnapshotWriter()
|
||||
if err := w.Append(makeProjection("ID-1", 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(dir, "id.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h := NewSnapshotHolder(10 * time.Second)
|
||||
defer h.Close()
|
||||
if err := h.Reload(filepath.Join(dir, "id.bin")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Reload(filepath.Join(dir, "id.bin")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h.DrainCount() != 1 {
|
||||
t.Errorf("DrainCount after two reloads of same path: got %d want 1", h.DrainCount())
|
||||
}
|
||||
if _, ok := h.Get("ID-1"); !ok {
|
||||
t.Error("current reader did not surface ID-1")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !unix
|
||||
|
||||
// Non-unix fallback for SnapshotReader: ReadFile into a []byte. Functionally
|
||||
// identical to the unix mmap path — same reader code, same lookups — just
|
||||
// pays the full resident-set cost at Open. Production mmap-on-Linux/Darwin;
|
||||
// this exists for windows / plan9 / wasm builds.
|
||||
package catalog
|
||||
|
||||
import "os"
|
||||
|
||||
func loadSnapshotBytes(path string) (data []byte, closer func() error, err error) {
|
||||
data, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, func() error { return nil }, nil
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// TestSnapshotRoundTrip — write 1000 projections in shuffled order, read back
|
||||
// and verify all 1000 are found at the right SKU.
|
||||
func TestSnapshotRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "snap.bin")
|
||||
|
||||
const n = 1000
|
||||
w := NewSnapshotWriter().WithTenant("default")
|
||||
// intentionally shuffled input — sort is what reorders at Write.
|
||||
perm := rand.Perm(n)
|
||||
for _, i := range perm {
|
||||
sku := fmt.Sprintf("SKU-%05d", i)
|
||||
if err := w.Append(makeProjection(sku, int64(100+(i%97)*100))); err != nil {
|
||||
t.Fatalf("Append %s: %v", sku, err)
|
||||
}
|
||||
}
|
||||
if w.Len() != n {
|
||||
t.Fatalf("Len: got %d want %d", w.Len(), n)
|
||||
}
|
||||
final, err := w.Write(dir, "snap.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if final != path {
|
||||
t.Errorf("Write returned %q expected %q", final, path)
|
||||
}
|
||||
|
||||
r, err := ReadSnapshotFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSnapshotFile: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if r.Len() != n {
|
||||
t.Errorf("Len: got %d want %d", r.Len(), n)
|
||||
}
|
||||
if r.Tenant != "default" {
|
||||
t.Errorf("Tenant: got %q want %q", r.Tenant, "default")
|
||||
}
|
||||
|
||||
// every SKU present and correct
|
||||
for _, i := range rand.Perm(n)[:50] { // probe 50 random lookups
|
||||
sku := fmt.Sprintf("SKU-%05d", i)
|
||||
got, ok := r.Get(sku)
|
||||
if !ok {
|
||||
t.Errorf("Get(%q) not found", sku)
|
||||
continue
|
||||
}
|
||||
want := money.Cents(100 + (i%97)*100)
|
||||
if got.PriceIncVat != want {
|
||||
t.Errorf("Get(%q) PriceIncVat: got %d want %d", sku, got.PriceIncVat, want)
|
||||
}
|
||||
}
|
||||
|
||||
// missing SKU
|
||||
if _, ok := r.Get("NOPE-99999"); ok {
|
||||
t.Error("Get on missing SKU should return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCompareSKUInvariant — verify the on-disk record section is
|
||||
// sorted under the same compareSKU total order that BinarySearch uses.
|
||||
// If sort.Slice and Reader.Get ever drift apart, lookups silently miss
|
||||
// — this test pins that invariant.
|
||||
func TestSnapshotCompareSKUInvariant(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewSnapshotWriter()
|
||||
// deliberately inverted order after Append (so sort has work to do)
|
||||
perm := rand.Perm(500)
|
||||
for _, i := range perm {
|
||||
sku := fmt.Sprintf("XX-%05d", i)
|
||||
if err := w.Append(makeProjection(sku, int64(i))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := w.Write(dir, "snap.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ReadSnapshotFile(filepath.Join(dir, "snap.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
// Walk every record slot, harvest SKU bytes from the pad-then-trim
|
||||
// form, and assert non-decreasing order under compareSKU.
|
||||
prevSKU := ""
|
||||
for i := 0; i < r.Len(); i++ {
|
||||
recOff := int(r.recordStart) + i*recordSize
|
||||
raw := r.data[recOff : recOff+32]
|
||||
// trim trailing zeros
|
||||
end := len(raw)
|
||||
for end > 0 && raw[end-1] == 0 {
|
||||
end--
|
||||
}
|
||||
cur := string(raw[:end])
|
||||
if i > 0 {
|
||||
if compareSKU(runesToPadded32(prevSKU), []byte(cur)) > 0 {
|
||||
t.Errorf("record section out of order at i=%d: prev=%q cur=%q", i, prevSKU, cur)
|
||||
}
|
||||
}
|
||||
prevSKU = cur
|
||||
}
|
||||
}
|
||||
|
||||
// runesToPadded32 zero-pads s into a 32-byte slot for compareSKU in the
|
||||
// invariant test.
|
||||
func runesToPadded32(s string) []byte {
|
||||
out := make([]byte, 32)
|
||||
copy(out, s)
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSnapshotTenantRoundTrip — Writer.WithTenant lands in the header and
|
||||
// Reader surfaces it. Without this, one file per tenant is just a convention
|
||||
// with no on-disk evidence.
|
||||
func TestSnapshotTenantRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewSnapshotWriter().WithTenant("bygglagret")
|
||||
if err := w.Append(makeProjection("ABC-1", 100)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(dir, "snap.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ReadSnapshotFile(filepath.Join(dir, "snap.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
if r.Tenant != "bygglagret" {
|
||||
t.Errorf("Tenant: got %q want %q", r.Tenant, "bygglagret")
|
||||
}
|
||||
|
||||
// Empty tenant writes zero-padded.
|
||||
w2 := NewSnapshotWriter()
|
||||
if err := w2.Append(makeProjection("ABC-2", 200)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w2.Write(dir, "snap2.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r2, err := ReadSnapshotFile(filepath.Join(dir, "snap2.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r2.Close()
|
||||
if r2.Tenant != "" {
|
||||
t.Errorf("Tenant (no tag): got %q want empty", r2.Tenant)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotTenantTooLong — over-long tenant name rejected at Write.
|
||||
func TestSnapshotTenantTooLong(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewSnapshotWriter().WithTenant(string(make([]byte, 33)))
|
||||
if err := w.Append(makeProjection("ABC", 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(dir, "snap.bin"); err == nil {
|
||||
t.Error("expected error for over-long tenant")
|
||||
} else if !errors.Is(err, ErrTenantTooLong) {
|
||||
t.Errorf("got %v want ErrTenantTooLong", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCountOverflowRejected — tampered count that would cause
|
||||
// recordStart + records*recordSize to overflow uint64 must be rejected
|
||||
// with ErrSnapshotOverflow (formerly a silent wraparound vulnerability).
|
||||
func TestSnapshotCountOverflowRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "over.bin")
|
||||
bs := make([]byte, headerSize)
|
||||
binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic)
|
||||
binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion)
|
||||
binary.LittleEndian.PutUint32(bs[12:16], 0)
|
||||
// count that overflows when multiplied by recordSize=44
|
||||
binary.LittleEndian.PutUint64(bs[16:24], math.MaxUint64/uint64(recordSize)+1)
|
||||
binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize))
|
||||
binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize))
|
||||
if err := os2WriteFile(path, bs, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSnapshotFile(path); !errors.Is(err, ErrSnapshotOverflow) {
|
||||
t.Errorf("overflow count rejected with %v, want ErrSnapshotOverflow", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotAtomicSwap — verify the temporary-then-rename discipline never
|
||||
// leaves a half-written snap.bin visible to readers.
|
||||
func TestSnapshotAtomicSwap(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewSnapshotWriter()
|
||||
mustAppend := func(w *SnapshotWriter, sku string, price int64) {
|
||||
t.Helper()
|
||||
if err := w.Append(makeProjection(sku, price)); err != nil {
|
||||
t.Fatalf("Append %s: %v", sku, err)
|
||||
}
|
||||
}
|
||||
mustAppend(w, "AAA", 100)
|
||||
final, err := w.Write(dir, "snap.bin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// tmp must not exist anymore
|
||||
if _, err := os2Stat(final + ".tmp"); err == nil {
|
||||
t.Errorf(".tmp still present after Write")
|
||||
}
|
||||
|
||||
// replace
|
||||
w2 := NewSnapshotWriter()
|
||||
mustAppend(w2, "ZZZ", 200)
|
||||
if _, err := w2.Write(dir, "snap.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r, err := ReadSnapshotFile(final)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
if _, ok := r.Get("ZZZ"); !ok {
|
||||
t.Error("second-snapshot Get(ZZZ) missing")
|
||||
}
|
||||
if _, ok := r.Get("AAA"); ok {
|
||||
t.Error("first-snapshot residue Get(AAA) found — atomic swap bug")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotRejectBadInput — magic, version, truncation, blob-offset OOB.
|
||||
func TestSnapshotRejectBadInput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.bin")
|
||||
|
||||
// Empty file: truncated.
|
||||
if err := os2WriteFile(path, []byte{}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSnapshotFile(path); !errors.Is(err, ErrSnapshotTruncated) {
|
||||
t.Errorf("empty file rejected with %v, want ErrSnapshotTruncated", err)
|
||||
}
|
||||
|
||||
// Wrong magic. The fixture must be at least headerSize bytes long
|
||||
// (v2 header is 72 B), otherwise we'd hit ErrSnapshotTruncated
|
||||
// before the magic check fires.
|
||||
wrongMagic := make([]byte, headerSize+1)
|
||||
for i := range wrongMagic {
|
||||
wrongMagic[i] = 0xff
|
||||
}
|
||||
if err := os2WriteFile(path, wrongMagic, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSnapshotFile(path); !errors.Is(err, ErrSnapshotMagic) {
|
||||
t.Errorf("wrong magic rejected with %v, want ErrSnapshotMagic", err)
|
||||
}
|
||||
|
||||
// Truncated mid-record. A valid header claiming 5 records but with no
|
||||
// record section.
|
||||
if err := os2WriteFile(path, buildValidHeader(5), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSnapshotFile(path); !errors.Is(err, ErrSnapshotTruncated) {
|
||||
t.Errorf("truncated snapshot rejected with %v, want ErrSnapshotTruncated", err)
|
||||
}
|
||||
|
||||
// Tampered blob offsets: valid header, valid record section with a
|
||||
// blob pointer that lies past EOF. Should reject with ErrSnapshotBadBlob.
|
||||
tampered := buildTamperedHeader()
|
||||
if err := os2WriteFile(path, tampered, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSnapshotFile(path); !errors.Is(err, ErrSnapshotBadBlob) {
|
||||
t.Errorf("tampered-blob rejected with %v, want ErrSnapshotBadBlob", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotEmpty — empty (count=0) snapshot is valid; Get returns false.
|
||||
func TestSnapshotEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := NewSnapshotWriter()
|
||||
if _, err := w.Write(dir, "empty.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ReadSnapshotFile(filepath.Join(dir, "empty.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
if r.Len() != 0 {
|
||||
t.Errorf("Len: got %d want 0", r.Len())
|
||||
}
|
||||
if _, ok := r.Get("anything"); ok {
|
||||
t.Error("empty snapshot should never return ok=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotSKU32 — SKU exactly 32 chars (the boundary case) must succeed.
|
||||
func TestSnapshotSKU32(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sku := "SKU-" + repeat32("X", 32-4) // 4 + 28 = 32 chars
|
||||
if len(sku) != 32 {
|
||||
t.Fatalf("test bug: sku length %d", len(sku))
|
||||
}
|
||||
w := NewSnapshotWriter()
|
||||
if err := w.Append(makeProjection(sku, 1)); err != nil {
|
||||
t.Fatalf("Append 32-char SKU: %v", err)
|
||||
}
|
||||
if _, err := w.Write(dir, "snap.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ReadSnapshotFile(filepath.Join(dir, "snap.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
if _, ok := r.Get(sku); !ok {
|
||||
t.Error("32-char SKU Get returned false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotSKU33Rejected — one char over should error at Append.
|
||||
func TestSnapshotSKU33Rejected(t *testing.T) {
|
||||
w := NewSnapshotWriter()
|
||||
sku := repeat32("X", 33)
|
||||
err := w.Append(makeProjection(sku, 1))
|
||||
if !errors.Is(err, ErrSKUTooLong) {
|
||||
t.Errorf("33-char SKU Append: got %v want ErrSKUTooLong", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotGetSpeed — probe thousands of random SKU lookups to give a
|
||||
// sense of the binary-search cost (not a benchmark per se).
|
||||
func TestSnapshotGetSpeed(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("short mode")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
const n = 5000
|
||||
w := NewSnapshotWriter()
|
||||
skus := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
skus[i] = fmt.Sprintf("Z%06d", i)
|
||||
if err := w.Append(makeProjection(skus[i], int64(i))); err != nil {
|
||||
t.Fatalf("Append %s: %v", skus[i], err)
|
||||
}
|
||||
}
|
||||
if _, err := w.Write(dir, "snap.bin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ReadSnapshotFile(filepath.Join(dir, "snap.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
start := time.Now()
|
||||
hits, misses := 0, 0
|
||||
for _, i := range rand.Perm(n) {
|
||||
if _, ok := r.Get(skus[i]); ok {
|
||||
hits++
|
||||
} else {
|
||||
misses++
|
||||
}
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
t.Logf("100%% hit Get on %d records: %d hits, %d misses, %v (%v/lookup)",
|
||||
n, hits, misses, elapsed, elapsed/time.Duration(n))
|
||||
if misses != 0 {
|
||||
t.Errorf("expected zero misses on full-key set, got %d", misses)
|
||||
}
|
||||
}
|
||||
|
||||
// buildValidHeader synthesises a header byte slice with the given Count
|
||||
// and zero-blob / zero-record section, used to exercise the truncation
|
||||
// branch in newSnapshotReader.
|
||||
func buildValidHeader(count uint64) []byte {
|
||||
bs := make([]byte, headerSize)
|
||||
binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic)
|
||||
binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion)
|
||||
binary.LittleEndian.PutUint32(bs[12:16], 0)
|
||||
binary.LittleEndian.PutUint64(bs[16:24], count)
|
||||
binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize)) // blobStart
|
||||
binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize)) // recordStart (truncated)
|
||||
return bs
|
||||
}
|
||||
|
||||
// buildTamperedHeader returns a buffer with a valid header claiming 1 record
|
||||
// AND its 44-byte record section, but the record's blob pointer lies past
|
||||
// EOF. Used to exercise ErrSnapshotBadBlob.
|
||||
func buildTamperedHeader() []byte {
|
||||
bs := make([]byte, headerSize+recordSize)
|
||||
binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic)
|
||||
binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion)
|
||||
binary.LittleEndian.PutUint32(bs[12:16], 0)
|
||||
binary.LittleEndian.PutUint64(bs[16:24], 1)
|
||||
binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize))
|
||||
binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize))
|
||||
binary.LittleEndian.PutUint64(bs[headerSize+32:headerSize+40], ^uint64(0))
|
||||
binary.LittleEndian.PutUint32(bs[headerSize+40:headerSize+44], ^uint32(0))
|
||||
return bs
|
||||
}
|
||||
|
||||
// TestSnapshotRejectBadVersion — pin the ErrSnapshotVersion rejection path.
|
||||
func TestSnapshotRejectBadVersion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "badver.bin")
|
||||
bs := make([]byte, headerSize)
|
||||
binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic)
|
||||
binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion+1)
|
||||
binary.LittleEndian.PutUint32(bs[12:16], 0)
|
||||
binary.LittleEndian.PutUint64(bs[16:24], 0)
|
||||
binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize))
|
||||
binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize))
|
||||
if err := os2WriteFile(path, bs, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSnapshotFile(path); !errors.Is(err, ErrSnapshotVersion) {
|
||||
t.Errorf("v2-not-supported header rejected with %v, want ErrSnapshotVersion", err)
|
||||
}
|
||||
}
|
||||
|
||||
// makeProjection helper repeated here for the new tests.
|
||||
func makeProjection(sku string, price int64) Projection {
|
||||
return Projection{
|
||||
ID: "sku:" + sku,
|
||||
SKU: sku,
|
||||
Title: "Item " + sku,
|
||||
PriceIncVat: money.Cents(price),
|
||||
Currency: "SEK",
|
||||
TaxClass: "standard",
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//go:build unix
|
||||
|
||||
// mmap-on-unix loader for SnapshotReader. The file handle stays alive for
|
||||
// the lifetime of the mmap region (the kernel needs it pinned). Closer
|
||||
// munmaps AND closes the fd. Uses MADV_RANDOM because the dominant access
|
||||
// pattern is binary-search-by-SKU — touching random record slots, not
|
||||
// streaming the file.
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// loadSnapshotBytes returns the file contents (mmap'd) + a closer that munmaps
|
||||
// and closes the fd.
|
||||
func loadSnapshotBytes(path string) (data []byte, closer func() error, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
size := info.Size()
|
||||
if size == 0 {
|
||||
_ = f.Close()
|
||||
return []byte{}, func() error { return nil }, nil
|
||||
}
|
||||
if size < int64(headerSize) {
|
||||
_ = f.Close()
|
||||
return nil, nil, ErrSnapshotTruncated
|
||||
}
|
||||
|
||||
data, mmapErr := unix.Mmap(int(f.Fd()), 0, int(size), unix.PROT_READ, unix.MAP_SHARED)
|
||||
if mmapErr != nil {
|
||||
_ = f.Close()
|
||||
return nil, nil, fmt.Errorf("catalog: mmap %s: %w", path, mmapErr)
|
||||
}
|
||||
|
||||
// Binary-search by SKU = random page access; MADV_RANDOM tells the
|
||||
// kernel NOT to do read-ahead — saves I/O on cold layers.
|
||||
if maErr := unix.Madvise(data, unix.MADV_RANDOM); maErr != nil {
|
||||
// non-fatal; degrade gracefully
|
||||
_ = maErr
|
||||
}
|
||||
|
||||
closer = func() error {
|
||||
mErr := unix.Munmap(data)
|
||||
cErr := f.Close()
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
return cErr
|
||||
}
|
||||
return data, closer, nil
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Package catalogtest holds shared helpers for the platform catalog test
|
||||
// harnesses — cmd/catalog-emit (consumer harness) and cmd/event-emit-publisher
|
||||
// (test publisher) both use these to seed a source-item set with the same
|
||||
// shape conventions. Anything in this package is a test-only utility; do
|
||||
// not import from production code.
|
||||
//
|
||||
// Scope: this package is parked under platform/cmd/internal/ on purpose.
|
||||
// Go's internal-package rule restricts imports to packages within the
|
||||
// parent directory's subtree, so only platform/cmd/* binaries can import
|
||||
// it. That keeps the test-helper API out of the public platform surface
|
||||
// (e.g. platform/event, platform/rabbit must not import catalogtest —
|
||||
// they're cross-module seams, not test scaffolding).
|
||||
package catalogtest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"git.k6n.net/mats/platform/catalog"
|
||||
)
|
||||
|
||||
// LoadOrSynthesize returns one source-batch per tenant name. If itemsPath
|
||||
// is non-empty, it parses a {"tenant":[...]}-keyed JSON map and returns
|
||||
// it unchanged. Otherwise it generates -n synthetic items per tenant
|
||||
// using the tenant's registered Schema's field names, with skuPrefix
|
||||
// prepended to each SKU.
|
||||
//
|
||||
// schemaFor is supplied so callers must already have a Registry built
|
||||
// (production code paths usually have a Registry at composition root;
|
||||
// publishing this helper to test code keeps the hot path clean).
|
||||
//
|
||||
// skuPrefix is a single-character prefix that lets log lines / probe
|
||||
// output attribute synthetic items to the binary that produced them
|
||||
// ("B" for catalog-emit, "P" for publisher). Substituting per-binary
|
||||
// retains recognisability in mixed test runs.
|
||||
//
|
||||
// Determinism: synth items are content-version-identical between runs at
|
||||
// the same (schema, seed, i). UpdatedAt is Unix-ms seeded from `i`.
|
||||
func LoadOrSynthesize(itemsPath string, tenants []string, n int, seed int64, reg *catalog.Registry, skuPrefix string) (map[string][]map[string]any, error) {
|
||||
if itemsPath != "" {
|
||||
data, err := os.ReadFile(itemsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw map[string][]map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parse items: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
out := make(map[string][]map[string]any, len(tenants))
|
||||
for _, t := range tenants {
|
||||
s := reg.SchemaFor(t)
|
||||
items := make([]map[string]any, n)
|
||||
for i := 0; i < n; i++ {
|
||||
items[i] = SynthItem(s, rng, i, skuPrefix)
|
||||
}
|
||||
out[t] = items
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SynthItem builds one source-map whose key names match the supplied
|
||||
// schema's field names. catalog.Apply on (s, item) succeeds for any
|
||||
// schema that uses the same field-name conventions as DefaultSchema() /
|
||||
// BygglagretSchema() / a Registry-supplied override.
|
||||
//
|
||||
// skuPrefix is prepended to each SKU (e.g. "B" or "P"); reasonable
|
||||
// titles and prices are derived from a stable per-i seed plus the rng.
|
||||
// Title text is intentionally uniform across binaries so log lines
|
||||
// diff-cleanly between catalog-emit and event-emit-publisher runs.
|
||||
//
|
||||
// UpdatedAt is deterministic per i (Unix-ms, stable across runs) so the
|
||||
// synthesised projection is content-version-identical between runs at
|
||||
// the same (schema, seed, i).
|
||||
func SynthItem(s catalog.Schema, rng *rand.Rand, i int, skuPrefix string) map[string]any {
|
||||
sku := fmt.Sprintf("%s%07d", skuPrefix, i)
|
||||
m := map[string]any{}
|
||||
if s.SKUField != "" {
|
||||
m[s.SKUField] = sku
|
||||
}
|
||||
if s.TitleField != "" {
|
||||
m[s.TitleField] = fmt.Sprintf("Catalog Item %07d", i)
|
||||
}
|
||||
if s.PriceField != "" {
|
||||
m[s.PriceField] = int64(99 + rng.Intn(9000))
|
||||
}
|
||||
if s.CurrencyField != "" {
|
||||
m[s.CurrencyField] = "SEK"
|
||||
}
|
||||
if s.TaxClassField != "" {
|
||||
m[s.TaxClassField] = "standard"
|
||||
}
|
||||
if s.InventoryTrackedF != "" {
|
||||
m[s.InventoryTrackedF] = rng.Intn(2) == 0
|
||||
}
|
||||
if s.DropShipF != "" {
|
||||
m[s.DropShipF] = rng.Intn(8) == 0
|
||||
}
|
||||
if s.CategoryField != "" {
|
||||
m[s.CategoryField] = fmt.Sprintf("c-%d", rng.Intn(50))
|
||||
}
|
||||
if s.BrandField != "" {
|
||||
m[s.BrandField] = "Acme"
|
||||
}
|
||||
if s.UpdatedAtField != "" {
|
||||
m[s.UpdatedAtField] = int64(1700000000000 + i)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// StringField returns the string value of a source key, accepting
|
||||
// strings or integral-number source values (mirrors writeradmin's
|
||||
// helper of the same name). Used by delete-marker detection where
|
||||
// the body field may be either JSON-string "999" or JSON-number 999.
|
||||
//
|
||||
// Empty key returns ""; unknown type returns "". Caller decides
|
||||
// what "" means (in writeradmin / publisher buildBatch it means
|
||||
// "skip this delete — no SKU to delete on").
|
||||
func StringField(src map[string]any, key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
switch v := src[key].(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
if v == float64(int64(v)) {
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package event is the platform's shared event vocabulary: one envelope shape
|
||||
// (Event) and a small set of Type constants for the event meanings that
|
||||
// genuinely cross module boundaries. It imports only the stdlib — never an app
|
||||
// module, never a transport library — so every module can depend on it.
|
||||
//
|
||||
// Discipline: a Type constant earns its place only when a real producer emits it
|
||||
// AND it is meaningful to another module. We do not pre-declare aspirational
|
||||
// types. See docs/platform-event-messaging-sketch.md.
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Type is the semantic event kind — the cross-module contract. A module may use
|
||||
// its own private kinds internally; only meanings that cross a boundary belong
|
||||
// here.
|
||||
type Type string
|
||||
|
||||
const (
|
||||
// Editorial (graph-cms). Emitted by the CMS outbound bridge on content-graph
|
||||
// mutations. In-repo consumers are TBD (external generators today); this is
|
||||
// the first envelope producer, so the constants exist where they are emitted.
|
||||
ContentCreated Type = "content.created"
|
||||
ContentUpdated Type = "content.updated"
|
||||
ContentDeleted Type = "content.deleted"
|
||||
|
||||
// Catalog (finder). These meanings already cross modules today — finder
|
||||
// writer → reader and → backoffice (MountFinderListener) — but still travel
|
||||
// as finder's legacy ChangeTopic strings on the wire. The constants document
|
||||
// the shared vocabulary; the wire migration onto the envelope is a later step.
|
||||
CatalogItemChanged Type = "catalog.item_changed" // legacy: finder "item_added"
|
||||
CatalogSnapshotReady Type = "catalog.snapshot_ready" // legacy: finder "snapshot_ready"
|
||||
CatalogSettingsChanged Type = "catalog.settings_changed" // legacy: finder "settings_change"
|
||||
CatalogFacetChanged Type = "catalog.facet_changed" // legacy: finder "facet_change"
|
||||
CatalogAccessoryChanged Type = "catalog.accessory_changed" // legacy: finder "accessory_change"
|
||||
// CatalogProjectionPublished carries the commerce-facing catalog Projection
|
||||
// (platform/catalog) — producer maps raw items → Projection once, consumers
|
||||
// (cart / checkout / order / UCP) read the typed shape. Distinct from
|
||||
// CatalogItemChanged (raw bodies, consumed by inventory/backoffice): a
|
||||
// product index emits BOTH; a non-product index emits neither projection.
|
||||
// Payload is a []catalog.ProjectionUpdate batch (upserts + deletes per SKU).
|
||||
CatalogProjectionPublished Type = "catalog.projection_published"
|
||||
CatalogSortOverride Type = "catalog.sort_override" // finder-internal: per-session/group/popularity overrides
|
||||
CatalogFieldSortOverride Type = "catalog.field_sort_override" // finder-internal: live field ordering overrides
|
||||
|
||||
// Inventory (go-redis-inventory core, emitted by the cart-inventory service).
|
||||
// A coarse stock-level crossing (none/low/high) for one SKU+location —
|
||||
// never a quantity. Exact stock is a synchronous query against the inventory
|
||||
// service; only level crossings cross a module boundary. Payload is an
|
||||
// inventory.LevelChanged (platform/inventory). First consumer: finder stock
|
||||
// overlay (display hints). See docs/inventory-shape.md.
|
||||
InventoryLevelChanged Type = "inventory.level_changed"
|
||||
|
||||
// Commerce (order). Emitted by the order service saga after an order is
|
||||
// placed-and-paid (a durable outbox event). The authoritative "a sale
|
||||
// happened" fact — multiple modules react independently (inventory commits
|
||||
// stock, fulfillment allocates) without checkout coupling to any of them.
|
||||
// Payload is an order.Created (platform/order). See docs/inventory-shape.md
|
||||
// and docs/fulfillment-stance.md.
|
||||
OrderCreated Type = "order.created"
|
||||
|
||||
// Analytics (tracking). Replaces the integer enum 0–6 that finder and
|
||||
// slask-tracking use on the `tracking` topic today. Producer: slask-finder
|
||||
// (pkg/tracking/rabbit-tracking.go). Consumer: slask-tracking
|
||||
// (pkg/events/rabbit-tracking.go).
|
||||
TrackSession Type = "track.session" // 0 — session start
|
||||
TrackSearch Type = "track.search" // 1 — search query
|
||||
TrackClick Type = "track.click" // 2 — item click / product view
|
||||
TrackCart Type = "track.cart" // 3,4 — add to / remove from cart
|
||||
TrackImpression Type = "track.impression" // 5 — impression
|
||||
TrackAction Type = "track.action" // 6 — custom action (wishlist, etc.)
|
||||
TrackPageView Type = "track.page_view"
|
||||
TrackPageEngaged Type = "track.page_engaged"
|
||||
TrackPageAction Type = "track.page_action"
|
||||
)
|
||||
|
||||
// Event is the one envelope. Payload is per-Type, agreed between producer and
|
||||
// consumer (json.RawMessage, not a god-struct). A nil Payload means "the subject
|
||||
// changed; fetch the latest state."
|
||||
type Event struct {
|
||||
ID string `json:"id"` // unique; enables idempotent/at-least-once dedupe
|
||||
Type Type `json:"type"` // the vocabulary
|
||||
Source string `json:"source"` // module identifier ("graph-cms", "finder", ...)
|
||||
Subject string `json:"subject"` // the resource that changed (node id, sku, ...)
|
||||
Payload json.RawMessage `json:"payload,omitempty"` // per-Type body, or nil
|
||||
Time time.Time `json:"time"`
|
||||
Meta map[string]string `json:"meta,omitempty"` // trace id, causation, country, dataType, ...
|
||||
}
|
||||
|
||||
// Handler consumes an event. Returning an error means "not handled" — durable
|
||||
// transports may redeliver; fire-and-forget transports just log.
|
||||
type Handler func(ctx context.Context, e Event) error
|
||||
@@ -1,3 +1,8 @@
|
||||
module git.k6n.net/mats/platform
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/rabbitmq/amqp091-go v1.11.0
|
||||
golang.org/x/sys v0.46.0
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8=
|
||||
github.com/rabbitmq/amqp091-go v1.11.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
@@ -0,0 +1,66 @@
|
||||
// Package inventory is the platform's shared inventory seam: the cross-module
|
||||
// vocabulary for stock *levels*. It deliberately carries no quantities — exact
|
||||
// stock is queried synchronously from the inventory service at decision points
|
||||
// (PDP, add-to-cart, checkout reserve), and only coarse level crossings travel
|
||||
// over the bus. Putting exact quantity on the bus spams it and ships a number
|
||||
// that is stale the moment it is published; a level crossing is the only
|
||||
// inventory signal other modules receive. See docs/inventory-shape.md.
|
||||
//
|
||||
// Like platform/catalog, this package holds only the wire value types plus the
|
||||
// single classification function, so the producer and every consumer agree on
|
||||
// the cutoffs. It imports only the stdlib — never an app module, never a
|
||||
// transport library.
|
||||
package inventory
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Level is the coarse, bus-safe stock state. Consumers map a Level to a display
|
||||
// hint / availability flag; they never see (or need) the exact quantity.
|
||||
type Level string
|
||||
|
||||
const (
|
||||
LevelNone Level = "none" // nothing available to sell
|
||||
LevelLow Level = "low" // at or below the low watermark, but still > 0
|
||||
LevelHigh Level = "high" // comfortably above the low watermark
|
||||
)
|
||||
|
||||
// LevelFor classifies an exact on-hand quantity into a Level given the low
|
||||
// watermark. It is the single source of the cutoffs: the producer computes the
|
||||
// emitted level with it, and any consumer that needs to reclassify a quantity
|
||||
// (e.g. after a synchronous exact query) uses the same function so the two can
|
||||
// never disagree. qty <= 0 → none; 0 < qty <= low → low; qty > low → high.
|
||||
func LevelFor(qty, lowWatermark int64) Level {
|
||||
switch {
|
||||
case qty <= 0:
|
||||
return LevelNone
|
||||
case qty <= lowWatermark:
|
||||
return LevelLow
|
||||
default:
|
||||
return LevelHigh
|
||||
}
|
||||
}
|
||||
|
||||
// InStock reports whether the level means the SKU can be sold (any level other
|
||||
// than none) — a convenience for consumers that only care about the boolean.
|
||||
func (l Level) InStock() bool { return l != LevelNone }
|
||||
|
||||
// LevelChanged is the payload of an event.InventoryLevelChanged envelope: the
|
||||
// SKU + location whose level crossed a threshold, and the new level — never a
|
||||
// quantity. The envelope's Subject duplicates SKU; Meta["country"] carries the
|
||||
// tenant/country for consumer routing (matching the catalog wire convention).
|
||||
type LevelChanged struct {
|
||||
SKU string `json:"sku"`
|
||||
Location string `json:"location"`
|
||||
Level Level `json:"level"`
|
||||
}
|
||||
|
||||
// Encode serialises the payload for event.Event.Payload. Kept here so producer
|
||||
// and consumers share the exact wire shape; any future renaming lands here.
|
||||
func (c LevelChanged) Encode() ([]byte, error) { return json.Marshal(c) }
|
||||
|
||||
// Decode parses an InventoryLevelChanged payload — the consumer side of Encode.
|
||||
func Decode(raw []byte) (LevelChanged, error) {
|
||||
var c LevelChanged
|
||||
err := json.Unmarshal(raw, &c)
|
||||
return c, err
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package inventory
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLevelFor(t *testing.T) {
|
||||
const low = int64(5)
|
||||
cases := []struct {
|
||||
qty int64
|
||||
want Level
|
||||
}{
|
||||
{-3, LevelNone},
|
||||
{0, LevelNone},
|
||||
{1, LevelLow},
|
||||
{5, LevelLow}, // at the watermark is still low
|
||||
{6, LevelHigh},
|
||||
{1000, LevelHigh},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := LevelFor(c.qty, low); got != c.want {
|
||||
t.Errorf("LevelFor(%d, %d) = %q, want %q", c.qty, low, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelInStock(t *testing.T) {
|
||||
if LevelNone.InStock() {
|
||||
t.Error("none should not be in stock")
|
||||
}
|
||||
if !LevelLow.InStock() || !LevelHigh.InStock() {
|
||||
t.Error("low and high should be in stock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelChangedRoundTrip(t *testing.T) {
|
||||
in := LevelChanged{SKU: "ABC-1", Location: "se", Level: LevelLow}
|
||||
raw, err := in.Encode()
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
out, err := Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if out != in {
|
||||
t.Errorf("round-trip mismatch: got %+v, want %+v", out, in)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package mcp
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are
|
||||
// requests with no id and must not receive a response.
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// isNotification reports whether the message is a notification (no id), which
|
||||
// per JSON-RPC must not receive a response.
|
||||
func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 }
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// JSON-RPC standard error codes.
|
||||
const (
|
||||
codeParseError = -32700
|
||||
codeInvalidRequest = -32600
|
||||
codeMethodNotFound = -32601
|
||||
codeInvalidParams = -32602
|
||||
codeInternalError = -32603
|
||||
)
|
||||
|
||||
func result(id json.RawMessage, v any) *rpcResponse {
|
||||
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v}
|
||||
}
|
||||
|
||||
func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse {
|
||||
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package mcp
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Tiny JSON-Schema builders for tool input schemas. They keep tool definitions
|
||||
// readable without a schema dependency.
|
||||
|
||||
// Props is a set of named property schemas for Object.
|
||||
type Props map[string]json.RawMessage
|
||||
|
||||
// Object builds an object JSON Schema from named property schemas and the list
|
||||
// of required keys. required may be nil.
|
||||
func Object(p Props, required []string) json.RawMessage {
|
||||
schema := map[string]any{"type": "object"}
|
||||
if len(p) > 0 {
|
||||
schema["properties"] = p
|
||||
} else {
|
||||
schema["properties"] = map[string]any{}
|
||||
}
|
||||
if len(required) > 0 {
|
||||
schema["required"] = required
|
||||
}
|
||||
b, _ := json.Marshal(schema)
|
||||
return b
|
||||
}
|
||||
|
||||
// Scalar builds a single-typed schema with a description (e.g. Scalar("integer", …)).
|
||||
func Scalar(typ, desc string) json.RawMessage {
|
||||
b, _ := json.Marshal(map[string]string{"type": typ, "description": desc})
|
||||
return b
|
||||
}
|
||||
|
||||
func String(desc string) json.RawMessage { return Scalar("string", desc) }
|
||||
func Integer(desc string) json.RawMessage { return Scalar("integer", desc) }
|
||||
func Number(desc string) json.RawMessage { return Scalar("number", desc) }
|
||||
func Bool(desc string) json.RawMessage { return Scalar("boolean", desc) }
|
||||
func ObjectValue(desc string) json.RawMessage {
|
||||
return Scalar("object", desc)
|
||||
}
|
||||
|
||||
// StringArray builds an array-of-strings schema.
|
||||
func StringArray(desc string) json.RawMessage {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"type": "array", "description": desc, "items": map[string]string{"type": "string"},
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// ObjectArray builds an array-of-objects schema.
|
||||
func ObjectArray(desc string) json.RawMessage {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"type": "array", "description": desc, "items": map[string]string{"type": "object"},
|
||||
})
|
||||
return b
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
// Package mcp is the shared Model Context Protocol server core: JSON-RPC 2.0
|
||||
// over streamable HTTP, tool registration, and the tools/list + tools/call
|
||||
// dispatch. It is transport/protocol plumbing only — the tools themselves
|
||||
// (their domain logic) stay in each module. A module builds its []Tool and calls
|
||||
// NewServer; nothing here knows about carts, content, or orders.
|
||||
//
|
||||
// See docs/platform-uniforming-plan.md §3b and platform-event-messaging-sketch.md.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// defaultProtocolVersion is advertised when Info.ProtocolVersion is empty.
|
||||
const defaultProtocolVersion = "2025-06-18"
|
||||
|
||||
const maxBodyBytes = 4 << 20 // 4 MiB request cap, matching the prior per-module servers.
|
||||
|
||||
// Info identifies the server in the MCP initialize handshake.
|
||||
type Info struct {
|
||||
Name string
|
||||
Version string
|
||||
ProtocolVersion string // optional; defaults to defaultProtocolVersion
|
||||
}
|
||||
|
||||
// Server is the MCP edge over a set of tools. Build it with NewServer and mount
|
||||
// Handler() (e.g. at /mcp). The tool slice is read on every request, so AddTool
|
||||
// is safe after Handler() is in use.
|
||||
type Server struct {
|
||||
info Info
|
||||
tools []Tool
|
||||
}
|
||||
|
||||
// NewServer builds an MCP server advertising the given tools.
|
||||
func NewServer(info Info, tools ...Tool) *Server {
|
||||
if info.ProtocolVersion == "" {
|
||||
info.ProtocolVersion = defaultProtocolVersion
|
||||
}
|
||||
return &Server{info: info, tools: tools}
|
||||
}
|
||||
|
||||
// AddTool appends a tool so it is advertised via tools/list and dispatched via
|
||||
// tools/call alongside the rest.
|
||||
func (s *Server) AddTool(t Tool) { s.tools = append(s.tools, t) }
|
||||
|
||||
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
|
||||
func (s *Server) Handler() http.Handler { return http.HandlerFunc(s.serveHTTP) }
|
||||
|
||||
func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes))
|
||||
if err != nil {
|
||||
writeRPC(w, errorResponse(nil, codeParseError, "read error"))
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if isBatch(body) {
|
||||
var reqs []rpcRequest
|
||||
if err := json.Unmarshal(body, &reqs); err != nil {
|
||||
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
|
||||
return
|
||||
}
|
||||
var out []*rpcResponse
|
||||
for i := range reqs {
|
||||
if resp := s.dispatch(ctx, &reqs[i]); resp != nil {
|
||||
out = append(out, resp)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
writeRPC(w, out)
|
||||
return
|
||||
}
|
||||
var req rpcRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
|
||||
return
|
||||
}
|
||||
resp := s.dispatch(ctx, &req)
|
||||
if resp == nil {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
writeRPC(w, resp)
|
||||
}
|
||||
|
||||
func (s *Server) dispatch(ctx context.Context, req *rpcRequest) *rpcResponse {
|
||||
if req.JSONRPC != "2.0" {
|
||||
if req.isNotification() {
|
||||
return nil
|
||||
}
|
||||
return errorResponse(req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"")
|
||||
}
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
return result(req.ID, s.initialize(req.Params))
|
||||
case "ping":
|
||||
return result(req.ID, struct{}{})
|
||||
case "tools/list":
|
||||
return result(req.ID, map[string]any{"tools": s.tools})
|
||||
case "tools/call":
|
||||
return s.callTool(ctx, req)
|
||||
case "notifications/initialized", "notifications/cancelled":
|
||||
return nil // notifications: acknowledged by the transport, no reply
|
||||
default:
|
||||
if req.isNotification() {
|
||||
return nil
|
||||
}
|
||||
return errorResponse(req.ID, codeMethodNotFound, "unknown method: "+req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) initialize(params json.RawMessage) map[string]any {
|
||||
pv := s.info.ProtocolVersion
|
||||
if len(params) > 0 {
|
||||
var p struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
}
|
||||
if json.Unmarshal(params, &p) == nil && p.ProtocolVersion != "" {
|
||||
pv = p.ProtocolVersion
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"protocolVersion": pv,
|
||||
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||
"serverInfo": map[string]any{"name": s.info.Name, "version": s.info.Version},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) callTool(ctx context.Context, req *rpcRequest) *rpcResponse {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
return errorResponse(req.ID, codeInvalidParams, "invalid params")
|
||||
}
|
||||
// Strip (and log) the UCP-Agent meta sibling before handing args to the tool,
|
||||
// so it never leaks into the tool's argument namespace. No-op when absent.
|
||||
args := extractUCPAgentMeta(p.Arguments)
|
||||
|
||||
for i := range s.tools {
|
||||
if s.tools[i].Name == p.Name {
|
||||
out, err := s.tools[i].Invoke(ctx, args)
|
||||
if err != nil {
|
||||
return result(req.ID, ErrorResult(err))
|
||||
}
|
||||
return result(req.ID, Text(out))
|
||||
}
|
||||
}
|
||||
return errorResponse(req.ID, codeInvalidParams, "unknown tool: "+p.Name)
|
||||
}
|
||||
|
||||
func isBatch(body []byte) bool {
|
||||
for _, b := range body {
|
||||
switch b {
|
||||
case ' ', '\t', '\r', '\n':
|
||||
continue
|
||||
case '[':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeRPC(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Tool is one MCP tool: its advertised schema plus the Invoke func that parses
|
||||
// arguments, does the work, and returns a JSON-encodable payload. Invoke's error
|
||||
// is surfaced to the client as a tool error (isError: true), not a protocol
|
||||
// error. The context carries request scope (deadline, cancellation, trace span);
|
||||
// it flows from the HTTP request through to Invoke.
|
||||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
Invoke func(ctx context.Context, args json.RawMessage) (any, error) `json:"-"`
|
||||
}
|
||||
|
||||
// Text wraps a payload as a successful tools/call result: one JSON text block.
|
||||
func Text(v any) any {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ErrorResult(err)
|
||||
}
|
||||
return map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": string(b)}},
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorResult wraps an operational error as an isError tools/call result (a tool
|
||||
// failure the model can read, not a JSON-RPC protocol error).
|
||||
func ErrorResult(err error) any {
|
||||
return map[string]any{
|
||||
"isError": true,
|
||||
"content": []map[string]any{{"type": "text", "text": err.Error()}},
|
||||
}
|
||||
}
|
||||
|
||||
// Decode unmarshals tool arguments into v, treating empty/null args as a no-op
|
||||
// (zero value). Returns a wrapped error suitable for returning from Invoke.
|
||||
func Decode(args json.RawMessage, v any) error {
|
||||
if len(args) == 0 || string(args) == "null" {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(args, v); err != nil {
|
||||
return fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
)
|
||||
|
||||
// extractUCPAgentMeta checks whether the JSON arguments carry a "meta" key with
|
||||
// a nested "ucp-agent.profile" (the UCP-Agent profile advertisement, a sibling
|
||||
// of the real arguments per the UCP spec). If present it logs the profile for
|
||||
// observability and strips "meta" so it does not leak into the tool's argument
|
||||
// namespace. When absent it returns the arguments unchanged — a no-op for tools
|
||||
// that don't speak UCP.
|
||||
func extractUCPAgentMeta(arguments json.RawMessage) json.RawMessage {
|
||||
if len(arguments) == 0 {
|
||||
return arguments
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(arguments, &obj); err != nil {
|
||||
return arguments
|
||||
}
|
||||
metaRaw, hasMeta := obj["meta"]
|
||||
if !hasMeta {
|
||||
return arguments
|
||||
}
|
||||
var meta struct {
|
||||
UCPAgent *struct {
|
||||
Profile string `json:"profile"`
|
||||
} `json:"ucp-agent"`
|
||||
}
|
||||
if err := json.Unmarshal(metaRaw, &meta); err == nil && meta.UCPAgent != nil && meta.UCPAgent.Profile != "" {
|
||||
log.Printf("ucp-agent: profile=%s", meta.UCPAgent.Profile)
|
||||
}
|
||||
delete(obj, "meta")
|
||||
cleaned, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return arguments
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
// Package money is the platform's shared monetary value type. One rule, enforced
|
||||
// by the type system: every amount in the system is an integer count of minor
|
||||
// currency units (öre / cents) — never a float, never "kronor as a decimal".
|
||||
//
|
||||
// Why a named type and not bare int64? Two int64s can be a price and a quantity;
|
||||
// the compiler can't tell them apart. money.Cents makes "this is money" explicit
|
||||
// and stops a quantity being added to a price by accident. The underlying type
|
||||
// is int64, so arithmetic between Cents values is ordinary integer math and JSON
|
||||
// encodes identically to the int64 wire shape that already exists.
|
||||
//
|
||||
// Floating point is permitted in exactly one situation: ingesting a price from
|
||||
// an external source that already shipped a float (a facet value, a third-party
|
||||
// product feed). That conversion is lossy by definition — the float was imprecise
|
||||
// before we saw it — so it is isolated to FromFloatMajor, named loudly, and used
|
||||
// only at module edges. Everywhere else, parse from the original decimal STRING
|
||||
// with ParseCents (exact) or stay in Cents.
|
||||
//
|
||||
// Minor-unit exponent: this package assumes 2 decimal places (1 unit = 1/100),
|
||||
// which covers every currency in this codebase (SEK/NOK/DKK/EUR/USD). JPY-style
|
||||
// zero-decimal currencies would need a per-currency exponent; deferred until a
|
||||
// real consumer needs it (see doc rule: lift only what a consumer needs).
|
||||
package money
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Cents is an integer count of minor currency units (öre / cents). Positive,
|
||||
// negative (refunds / discounts), and zero are all valid.
|
||||
type Cents int64
|
||||
|
||||
// minorPerMajor is the number of minor units in one major unit (2 decimals).
|
||||
const minorPerMajor = 100
|
||||
|
||||
// ErrParse is the sentinel wrapped by ParseCents failures, for errors.Is.
|
||||
var ErrParse = errors.New("money: parse")
|
||||
|
||||
// ParseCents converts a decimal MAJOR-unit string ("149.95", "-12", "0.05")
|
||||
// into Cents using integer arithmetic only — no strconv.ParseFloat, no float
|
||||
// multiply, so "149.95" → 14995 exactly with no representation error.
|
||||
//
|
||||
// Accepted: optional leading sign, digits, an optional single '.' with at most
|
||||
// two fractional digits. Rejected (returns ErrParse): empty, multiple dots,
|
||||
// more than two fractional digits (silent truncation of a third decimal is
|
||||
// exactly the kind of money bug this package exists to prevent), or any
|
||||
// non-digit byte. A bare "." sides ("149." / ".95") are accepted as 14900 / 5.
|
||||
func ParseCents(s string) (Cents, error) {
|
||||
in := strings.TrimSpace(s)
|
||||
if in == "" {
|
||||
return 0, fmt.Errorf("%w: empty string", ErrParse)
|
||||
}
|
||||
|
||||
neg := false
|
||||
switch in[0] {
|
||||
case '-':
|
||||
neg = true
|
||||
in = in[1:]
|
||||
case '+':
|
||||
in = in[1:]
|
||||
}
|
||||
if in == "" {
|
||||
return 0, fmt.Errorf("%w: sign with no digits in %q", ErrParse, s)
|
||||
}
|
||||
|
||||
intStr, fracStr, hasDot := strings.Cut(in, ".")
|
||||
if hasDot {
|
||||
if strings.IndexByte(fracStr, '.') >= 0 {
|
||||
return 0, fmt.Errorf("%w: multiple decimal points in %q", ErrParse, s)
|
||||
}
|
||||
if len(fracStr) > 2 {
|
||||
return 0, fmt.Errorf("%w: %q has more than 2 decimal places (minor unit is 1/%d)", ErrParse, s, minorPerMajor)
|
||||
}
|
||||
}
|
||||
if intStr == "" {
|
||||
intStr = "0"
|
||||
}
|
||||
// Pad the fractional part to exactly 2 digits: "5" → "50", "" → "00".
|
||||
switch len(fracStr) {
|
||||
case 0:
|
||||
fracStr = "00"
|
||||
case 1:
|
||||
fracStr += "0"
|
||||
}
|
||||
|
||||
major, err := strconv.ParseInt(intStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%w: integer part of %q: %v", ErrParse, s, err)
|
||||
}
|
||||
frac, err := strconv.ParseInt(fracStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%w: fractional part of %q: %v", ErrParse, s, err)
|
||||
}
|
||||
|
||||
// Overflow guard: major * 100 + frac must fit int64.
|
||||
if major > (math.MaxInt64-frac)/minorPerMajor {
|
||||
return 0, fmt.Errorf("%w: %q overflows int64 minor units", ErrParse, s)
|
||||
}
|
||||
c := Cents(major*minorPerMajor + frac)
|
||||
if neg {
|
||||
c = -c
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// FromFloatMajor converts a MAJOR-unit float (149.95) to Cents, rounding
|
||||
// half-away-from-zero. THIS IS THE ONLY LOSSY ENTRY POINT and exists solely for
|
||||
// edges where an upstream already handed us a float64 (a finder facet value, a
|
||||
// third-party feed). Never use it on a value you have as a string — ParseCents
|
||||
// is exact. Never use it in a compute path — stay in Cents.
|
||||
func FromFloatMajor(f float64) Cents {
|
||||
scaled := f * minorPerMajor
|
||||
if scaled >= 0 {
|
||||
return Cents(int64(scaled + 0.5))
|
||||
}
|
||||
return Cents(int64(scaled - 0.5))
|
||||
}
|
||||
|
||||
// String formats Cents as a major-unit decimal with two places: 14995 →
|
||||
// "149.95", -5 → "-0.05". Integer math only.
|
||||
func (c Cents) String() string {
|
||||
n := int64(c)
|
||||
sign := ""
|
||||
if n < 0 {
|
||||
sign = "-"
|
||||
n = -n
|
||||
}
|
||||
return fmt.Sprintf("%s%d.%02d", sign, n/minorPerMajor, n%minorPerMajor)
|
||||
}
|
||||
|
||||
// Int64 returns the raw minor-unit count, for the few low-level call sites
|
||||
// (proto, legacy arithmetic helpers) that still take int64. Prefer staying in
|
||||
// Cents; this is the explicit escape hatch.
|
||||
func (c Cents) Int64() int64 { return int64(c) }
|
||||
|
||||
// Mul scales an amount by an integer quantity — a price times a line quantity
|
||||
// is still money. Kept as a method so callers don't have to write the
|
||||
// Cents(qty) conversion that bare `c * Cents(qty)` would need (and which reads
|
||||
// like cents², a nonsense unit).
|
||||
func (c Cents) Mul(qty int64) Cents { return c * Cents(qty) }
|
||||
@@ -0,0 +1,129 @@
|
||||
package money
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCentsExact(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want Cents
|
||||
}{
|
||||
{"149.95", 14995},
|
||||
{"149.9", 14990},
|
||||
{"149", 14900},
|
||||
{"149.", 14900},
|
||||
{".95", 95},
|
||||
{"0.05", 5},
|
||||
{"0", 0},
|
||||
{"0.00", 0},
|
||||
{"-12.34", -1234},
|
||||
{"-0.05", -5},
|
||||
{"+7.50", 750},
|
||||
{" 10.10 ", 1010},
|
||||
{"1000000.00", 100000000},
|
||||
// The classic float-representation trap: 0.1+0.2 etc. Parsed from the
|
||||
// string these are exact; ParseFloat would have drifted.
|
||||
{"0.10", 10},
|
||||
{"0.20", 20},
|
||||
{"0.30", 30},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ParseCents(c.in)
|
||||
if err != nil {
|
||||
t.Errorf("ParseCents(%q) unexpected error: %v", c.in, err)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("ParseCents(%q) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCentsRejects(t *testing.T) {
|
||||
bad := []string{
|
||||
"",
|
||||
" ",
|
||||
"-",
|
||||
"+",
|
||||
"abc",
|
||||
"12.3.4", // multiple dots
|
||||
"149.955", // 3 decimal places — would silently truncate
|
||||
"1.234", // 3 decimal places
|
||||
"12,34", // comma not a decimal separator here
|
||||
"1e3", // no scientific notation; integer-only parse
|
||||
"12 34", // internal space
|
||||
"$10.00", // currency symbol
|
||||
}
|
||||
for _, s := range bad {
|
||||
if _, err := ParseCents(s); err == nil {
|
||||
t.Errorf("ParseCents(%q) = nil error, want rejection", s)
|
||||
} else if !errors.Is(err, ErrParse) {
|
||||
t.Errorf("ParseCents(%q) error %v not wrapping ErrParse", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringRoundTrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
c Cents
|
||||
want string
|
||||
}{
|
||||
{14995, "149.95"},
|
||||
{14900, "149.00"},
|
||||
{5, "0.05"},
|
||||
{50, "0.50"},
|
||||
{0, "0.00"},
|
||||
{-1234, "-12.34"},
|
||||
{-5, "-0.05"},
|
||||
{100000000, "1000000.00"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.c.String(); got != tc.want {
|
||||
t.Errorf("Cents(%d).String() = %q, want %q", tc.c, got, tc.want)
|
||||
}
|
||||
// Round-trip: String -> ParseCents -> same Cents.
|
||||
back, err := ParseCents(tc.c.String())
|
||||
if err != nil {
|
||||
t.Errorf("round-trip ParseCents(%q): %v", tc.c.String(), err)
|
||||
continue
|
||||
}
|
||||
if back != tc.c {
|
||||
t.Errorf("round-trip Cents(%d) -> %q -> %d", tc.c, tc.c.String(), back)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromFloatMajor(t *testing.T) {
|
||||
cases := []struct {
|
||||
f float64
|
||||
want Cents
|
||||
}{
|
||||
{149.95, 14995},
|
||||
{149.0, 14900},
|
||||
{0.05, 5},
|
||||
{0.005, 1}, // half-away-from-zero rounds up at the boundary
|
||||
{0.004, 0},
|
||||
{-12.34, -1234},
|
||||
{-0.005, -1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := FromFloatMajor(c.f); got != c.want {
|
||||
t.Errorf("FromFloatMajor(%v) = %d, want %d", c.f, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArithmeticStaysInteger(t *testing.T) {
|
||||
// Cents arithmetic is plain integer math — associativity / exactness that
|
||||
// float prices famously lack.
|
||||
unit := Cents(1099) // 10.99
|
||||
var total Cents
|
||||
for i := 0; i < 3; i++ {
|
||||
total += unit
|
||||
}
|
||||
if total != 3297 {
|
||||
t.Errorf("3 * 10.99 = %s, want 32.97", total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package order is the platform's shared order seam: the exported wire shape of
|
||||
// the `order.created` domain event (B4.1). It is the contract between the order
|
||||
// service (producer) and every reactor — inventory commit, fulfillment
|
||||
// allocation, loyalty, ERP sync — none of which the order service knows about.
|
||||
//
|
||||
// Like platform/catalog and platform/inventory, it holds only the wire value
|
||||
// types + encode/decode, and imports only the stdlib.
|
||||
//
|
||||
// `order.created` is a FACT, not a request: by the time it is emitted the order
|
||||
// is placed and paid. Reactors must be idempotent (the transport is
|
||||
// at-least-once) and must not assume they can refuse it — the sale already
|
||||
// happened. Oversell on commit is reconciled downstream (backorder), not
|
||||
// prevented here.
|
||||
package order
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Line is one ordered line item, reduced to what reactors need: the SKU, how
|
||||
// many units sold, and the inventory location to commit against. Pricing/tax
|
||||
// live in the order grain, not on this seam.
|
||||
type Line struct {
|
||||
SKU string `json:"sku"`
|
||||
Quantity int `json:"quantity"`
|
||||
// Location is the inventory location / store id to decrement. Empty means
|
||||
// "use the order's Country" (central stock) — see Created.Country.
|
||||
Location string `json:"location,omitempty"`
|
||||
}
|
||||
|
||||
// Created is the payload of an event.OrderCreated envelope.
|
||||
//
|
||||
// Country is the default inventory location for the commit (central stock). A
|
||||
// Line may override it with its own Location (store id); when a line's Location
|
||||
// is empty the commit falls back to Country. See docs/inventory-shape.md.
|
||||
type Created struct {
|
||||
OrderID string `json:"order_id"`
|
||||
OrderReference string `json:"order_reference,omitempty"`
|
||||
CartID string `json:"cart_id,omitempty"`
|
||||
Country string `json:"country"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
CustomerEmail string `json:"customer_email,omitempty"`
|
||||
Lines []Line `json:"lines"`
|
||||
PlacedAtMs int64 `json:"placed_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
// Encode serialises the payload for event.Event.Payload.
|
||||
func (c Created) Encode() ([]byte, error) { return json.Marshal(c) }
|
||||
|
||||
// Decode parses an order.created payload — the consumer side of Encode.
|
||||
func Decode(raw []byte) (Created, error) {
|
||||
var c Created
|
||||
err := json.Unmarshal(raw, &c)
|
||||
return c, err
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package order
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCreatedRoundTrip(t *testing.T) {
|
||||
in := Created{
|
||||
OrderID: "ord-1",
|
||||
OrderReference: "REF-1",
|
||||
CartID: "cart-9",
|
||||
Country: "se",
|
||||
Currency: "SEK",
|
||||
CustomerEmail: "a@b.c",
|
||||
Lines: []Line{{SKU: "ABC", Quantity: 2, Location: "store-7"}, {SKU: "DEF", Quantity: 1}},
|
||||
PlacedAtMs: 123,
|
||||
}
|
||||
raw, err := in.Encode()
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
out, err := Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if out.OrderID != in.OrderID || out.Country != in.Country || len(out.Lines) != 2 {
|
||||
t.Fatalf("round-trip mismatch: got %+v, want %+v", out, in)
|
||||
}
|
||||
if out.Lines[0].SKU != "ABC" || out.Lines[0].Quantity != 2 || out.Lines[0].Location != "store-7" {
|
||||
t.Errorf("line[0] mismatch: %+v", out.Lines[0])
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
// Package rabbit holds the shared RabbitMQ transport mechanics — connection
|
||||
// setup plus topic declare/publish/consume helpers — that every module used to
|
||||
// hand-roll. It carries NO event vocabulary: topic and routing names are plain
|
||||
// strings supplied by the caller, so each module keeps its own naming policy
|
||||
// (finder's "<country>_<topic>", CMS's "kind.dataType", etc.). See
|
||||
// docs/platform-uniforming-plan.md Win 3.
|
||||
package rabbit
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// Default backoff bounds for the automatic reconnect loop.
|
||||
const (
|
||||
backoffMin = 1 * time.Second
|
||||
backoffMax = 30 * time.Second
|
||||
)
|
||||
|
||||
// Connect dials url with a consistent configuration, tagging the connection with
|
||||
// name so it is identifiable in the RabbitMQ management UI and metrics. It
|
||||
// returns a bare *amqp.Connection and does NOT reconnect on failure.
|
||||
//
|
||||
// Prefer Dial instead: it returns a *Conn wrapper that handles automatic
|
||||
// reconnection and notifies consumers when a reconnect completes so they can
|
||||
// re-declare their topology.
|
||||
//
|
||||
// This replaces the 14 inconsistent hand-rolled dial sites (some with
|
||||
// connection Properties, some bare amqp.Dial with no name).
|
||||
func Connect(url, name string) (*amqp.Connection, error) {
|
||||
return dial(url, name)
|
||||
}
|
||||
|
||||
// dial is the shared dial implementation used by both Connect and Dial.
|
||||
func dial(url, name string) (*amqp.Connection, error) {
|
||||
props := amqp.NewConnectionProperties()
|
||||
if name != "" {
|
||||
props["connection_name"] = name
|
||||
}
|
||||
return amqp.DialConfig(url, amqp.Config{Properties: props})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Conn wraps *amqp.Connection with automatic reconnection. After Dial, it
|
||||
// monitors NotifyClose and re-dials with exponential backoff (1s→30s). All
|
||||
// exported methods are safe for concurrent use.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// conn, err := rabbit.Dial(url, "my-service")
|
||||
// if err != nil { return err }
|
||||
// defer conn.Close()
|
||||
//
|
||||
// // Register consumer setup: called immediately AND on each reconnect.
|
||||
// conn.NotifyOnReconnect(func() {
|
||||
// ch, err := conn.Channel()
|
||||
// if err != nil { return }
|
||||
// rabbit.ListenToTopic(ch, prefix, topic, handler)
|
||||
// })
|
||||
type Conn struct {
|
||||
url string
|
||||
name string
|
||||
|
||||
mu sync.Mutex
|
||||
conn *amqp.Connection
|
||||
done chan struct{} // closed by Close(); stops the reconnect loop
|
||||
|
||||
onReconn chan struct{} // closed on each reconnect to wake up listeners
|
||||
|
||||
notifyFns []func() // registered via NotifyOnReconnect
|
||||
reconnectFns []func() // registered via NotifyReconnect
|
||||
}
|
||||
|
||||
// Dial connects to RabbitMQ and returns a *Conn that automatically reconnects
|
||||
// on connection loss. The name is shown in the RabbitMQ management UI.
|
||||
func Dial(url, name string) (*Conn, error) {
|
||||
c := &Conn{
|
||||
url: url,
|
||||
name: name,
|
||||
done: make(chan struct{}),
|
||||
onReconn: make(chan struct{}),
|
||||
}
|
||||
raw, err := dial(url, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.conn = raw
|
||||
|
||||
// Fire initial connect signal so a consumer registered before this runs
|
||||
// (e.g. via NotifyOnReconnect above) gets called. Since onReconn starts
|
||||
// open, we close it here and replace it with a fresh one for reconnect.
|
||||
close(c.onReconn)
|
||||
c.onReconn = make(chan struct{})
|
||||
|
||||
go c.reconnectLoop()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Connection returns the current *amqp.Connection. Safe for concurrent use.
|
||||
// Use this to pass the underlying connection to functions that expect
|
||||
// *amqp.Connection, such as SendChange and the finder's messaging wrappers.
|
||||
func (c *Conn) Connection() *amqp.Connection {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.conn
|
||||
}
|
||||
|
||||
// Channel opens a channel on the current underlying connection. It is safe
|
||||
// for concurrent use; the error path matches *amqp.Connection.Channel.
|
||||
func (c *Conn) Channel() (*amqp.Channel, error) {
|
||||
return c.Connection().Channel()
|
||||
}
|
||||
|
||||
// OnReconnect returns a channel that is closed each time a reconnect completes.
|
||||
// Use range to re-run consumer setup:
|
||||
//
|
||||
// go func() {
|
||||
// for range conn.OnReconnect() {
|
||||
// ch, _ := conn.Channel()
|
||||
// rabbit.ListenToTopic(ch, prefix, topic, handler)
|
||||
// }
|
||||
// }()
|
||||
//
|
||||
// The channel is NOT signalled on initial connect — only on reconnects.
|
||||
// For a function that fires on both initial connect and reconnect, use
|
||||
// NotifyOnReconnect.
|
||||
func (c *Conn) OnReconnect() <-chan struct{} {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.onReconn
|
||||
}
|
||||
|
||||
// NotifyOnReconnect registers a function that is called immediately (on the
|
||||
// current goroutine) and again on each subsequent reconnect (on the reconnect
|
||||
// loop goroutine). The function should be idempotent — it may be called while
|
||||
// the old listeners are still shutting down from the previous connection drop.
|
||||
// Multiple functions can be registered and are called in registration order.
|
||||
func (c *Conn) NotifyOnReconnect(fn func()) {
|
||||
// Call immediately with the current (live) connection.
|
||||
fn()
|
||||
|
||||
c.mu.Lock()
|
||||
c.notifyFns = append(c.notifyFns, fn)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// NotifyReconnect registers a function that is called on each subsequent
|
||||
// reconnect, but NOT immediately on registration. Use this when the caller
|
||||
// needs to perform first-time setup separately and only wants the reconnect
|
||||
// hook for re-declaring consumers or topology.
|
||||
func (c *Conn) NotifyReconnect(fn func()) {
|
||||
c.mu.Lock()
|
||||
c.reconnectFns = append(c.reconnectFns, fn)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed when the connection is permanently
|
||||
// closed (Close was called).
|
||||
func (c *Conn) Done() <-chan struct{} {
|
||||
return c.done
|
||||
}
|
||||
|
||||
// Close permanently closes the connection and stops the reconnect loop.
|
||||
// It is safe to call multiple times.
|
||||
func (c *Conn) Close() error {
|
||||
select {
|
||||
case <-c.done:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
close(c.done)
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconnectLoop runs in a background goroutine. It waits for the connection
|
||||
// to close (either normally or with an error), then re-dials with exponential
|
||||
// backoff and notifies registered callbacks.
|
||||
func (c *Conn) reconnectLoop() {
|
||||
for {
|
||||
// Wait for the connection to break (or Close to be called).
|
||||
errCh := make(chan *amqp.Error)
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
|
||||
conn.NotifyClose(errCh)
|
||||
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case err, ok := <-errCh:
|
||||
if !ok || err == nil {
|
||||
// Channel closed or clean shutdown (Close called on the underlying
|
||||
// connection). Either way, stop reconnecting.
|
||||
return
|
||||
}
|
||||
log.Printf("rabbit: connection %s lost: %v (reconnecting)", c.name, err)
|
||||
}
|
||||
|
||||
// Reconnect with exponential backoff.
|
||||
c.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// reconnect attempts to re-dial until successful or Close is called.
|
||||
func (c *Conn) reconnect() {
|
||||
duration := backoffMin
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
newConn, err := dial(c.url, c.name)
|
||||
if err == nil {
|
||||
c.mu.Lock()
|
||||
old := c.conn
|
||||
c.conn = newConn
|
||||
c.mu.Unlock()
|
||||
if old != nil {
|
||||
_ = old.Close()
|
||||
}
|
||||
|
||||
log.Printf("rabbit: connection %s re-established", c.name)
|
||||
|
||||
// Notify consumers: close the current signal channel so blocked
|
||||
// OnReconnect calls advance; replace with a fresh one.
|
||||
c.mu.Lock()
|
||||
close(c.onReconn)
|
||||
c.onReconn = make(chan struct{})
|
||||
fns := append([]func(){}, c.notifyFns...)
|
||||
fns = append(fns, c.reconnectFns...)
|
||||
c.mu.Unlock()
|
||||
|
||||
for _, fn := range fns {
|
||||
fn()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("rabbit: reconnect for %s failed: %v (retry in %v)", c.name, err, duration)
|
||||
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-time.After(duration):
|
||||
}
|
||||
|
||||
duration *= 2
|
||||
if duration > backoffMax {
|
||||
duration = backoffMax
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package rabbit_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
)
|
||||
|
||||
// rabbitURL returns the RabbitMQ URL for integration tests, or empty if unset.
|
||||
// Tests check this and skip when no broker is available.
|
||||
func rabbitURL() string {
|
||||
if u := os.Getenv("RABBIT_URL"); u != "" {
|
||||
return u
|
||||
}
|
||||
if u := os.Getenv("RABBIT_HOST"); u != "" {
|
||||
return "amqp://" + u
|
||||
}
|
||||
// Default local dev URL used by docker-compose.
|
||||
return "amqp://guest:guest@localhost:5672"
|
||||
}
|
||||
|
||||
// skipWithoutRabbit skips the test if no RabbitMQ broker is reachable.
|
||||
func skipWithoutRabbit(t *testing.T) string {
|
||||
t.Helper()
|
||||
url := rabbitURL()
|
||||
// Quick probe: try connecting and immediately close.
|
||||
conn, err := rabbit.Connect(url, "test-probe")
|
||||
if err != nil {
|
||||
t.Skipf("RabbitMQ not available at %s: %v", url, err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
return url
|
||||
}
|
||||
|
||||
func TestDial_ConnectAndClose(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-conn")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// The underlying connection should be usable.
|
||||
if c := conn.Connection(); c == nil {
|
||||
t.Fatal("Connection() returned nil")
|
||||
}
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
t.Fatalf("Channel() failed: %v", err)
|
||||
}
|
||||
if err := ch.Close(); err != nil {
|
||||
t.Fatalf("channel close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDial_Done(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-done")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
|
||||
// Done should NOT be closed initially.
|
||||
select {
|
||||
case <-conn.Done():
|
||||
t.Fatal("Done channel closed before Close()")
|
||||
default:
|
||||
}
|
||||
|
||||
// After Close, Done should be closed.
|
||||
_ = conn.Close()
|
||||
select {
|
||||
case <-conn.Done():
|
||||
// expected
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Done channel not closed after Close()")
|
||||
}
|
||||
|
||||
// Double close is safe.
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestDial_NotifyOnReconnect(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-notify")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// NotifyOnReconnect should call the function immediately.
|
||||
called := make(chan struct{}, 1)
|
||||
conn.NotifyOnReconnect(func() {
|
||||
called <- struct{}{}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-called:
|
||||
// expected — immediate call
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("NotifyOnReconnect was not called immediately")
|
||||
}
|
||||
|
||||
// Multiple registrations should each fire immediately.
|
||||
var mu sync.Mutex
|
||||
count := 0
|
||||
conn.NotifyOnReconnect(func() {
|
||||
mu.Lock()
|
||||
count++
|
||||
mu.Unlock()
|
||||
})
|
||||
conn.NotifyOnReconnect(func() {
|
||||
mu.Lock()
|
||||
count++
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
// Wait a moment for the callbacks to run.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
mu.Lock()
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 immediate NotifyOnReconnect calls, got %d", count)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func TestDial_NotifyReconnect_NotImmediate(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-notify-reconnect")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
called := make(chan struct{}, 1)
|
||||
conn.NotifyReconnect(func() {
|
||||
called <- struct{}{}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-called:
|
||||
t.Fatal("NotifyReconnect fired immediately")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
func TestDial_OnReconnectChannel(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-onreconn")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// OnReconnect returns an open channel immediately after Dial.
|
||||
// It should NOT fire (only triggers on actual reconnects).
|
||||
select {
|
||||
case <-conn.OnReconnect():
|
||||
t.Log("OnReconnect fired immediately (race with initial connect — allowed)")
|
||||
default:
|
||||
}
|
||||
|
||||
// Verify OnReconnect returns a non-nil channel.
|
||||
ch := conn.OnReconnect()
|
||||
if ch == nil {
|
||||
t.Fatal("OnReconnect returned nil channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDial_ConcurrentUse(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-concurrent")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Channel() and Connection() should be safe from multiple goroutines.
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
t.Logf("concurrent Channel(): %v", err)
|
||||
return
|
||||
}
|
||||
_ = ch.Close()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestOnReconnect_RegisteredFnsFiresOnReconnect is a structural test: it
|
||||
// verifies that the registered notification list is called on reconnect by
|
||||
// checking that functions registered after initial connect are not called
|
||||
// immediately (they only fire on the NEXT reconnect). Full reconnect testing
|
||||
// requires controlling the broker — see the integration test harness.
|
||||
func TestOnReconnect_NotImmediate(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-reconn-struct")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Register a function with NotifyOnReconnect AFTER Dial. It fires
|
||||
// immediately (that's the contract).
|
||||
immediate := make(chan struct{}, 1)
|
||||
conn.NotifyOnReconnect(func() {
|
||||
immediate <- struct{}{}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-immediate:
|
||||
// expected
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("NotifyOnReconnect did not fire immediately")
|
||||
}
|
||||
|
||||
// OnReconnect should NOT have fired (only fires on actual reconnect).
|
||||
select {
|
||||
case <-conn.OnReconnect():
|
||||
t.Log("OnReconnect fired — race goroutine from initial connect")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnect_BackwardCompat(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
// Connect should still work and return a usable *amqp.Connection.
|
||||
conn, err := rabbit.Connect(url, "test-compat")
|
||||
if err != nil {
|
||||
t.Fatalf("Connect failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
t.Fatalf("Channel from Connect failed: %v", err)
|
||||
}
|
||||
_ = ch.Close()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package rabbit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/platform/event"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// PublishEvent marshals an event.Event and publishes it to exchange with
|
||||
// routingKey = string(ev.Type). Opens and closes a channel per call. The
|
||||
// exchange must already exist (declare it with ListenToPattern or call
|
||||
// ExchangeDeclare explicitly before the first publish).
|
||||
func PublishEvent(c *amqp.Connection, exchange string, ev event.Event) error {
|
||||
body, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch, err := c.Channel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ch.Close()
|
||||
return ch.Publish(exchange, string(ev.Type), true, false, amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
Body: body,
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// ListenToPattern binds an exclusive queue with a routing-key pattern (wildcard)
|
||||
// on a durable topic exchange, so one subscription receives every message
|
||||
// matching the pattern. A handler error nacks (drops) that single message and
|
||||
// keeps consuming.
|
||||
func ListenToPattern(ch *amqp.Channel, exchange, pattern string, handler func(amqp.Delivery) error) error {
|
||||
if err := ch.ExchangeDeclare(exchange, "topic", true, false, false, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
q, err := ch.QueueDeclare("", false, false, true, false, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ch.QueueBind(q.Name, pattern, exchange, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
msgs, err := ch.Consume(q.Name, "", false, false, false, false, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
for d := range msgs {
|
||||
if err := handler(d); err != nil {
|
||||
log.Printf("Error processing pattern message: %v", err)
|
||||
d.Nack(false, false)
|
||||
} else {
|
||||
d.Ack(false)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"default": {
|
||||
"sku_field": "sku",
|
||||
"title_field": "title",
|
||||
"price_field": "price_inc_vat",
|
||||
"currency_field": "currency",
|
||||
"tax_class_field": "tax_class",
|
||||
"inventory_tracked_field": "inventory_tracked",
|
||||
"drop_ship_field": "drop_ship",
|
||||
"category_field": "category",
|
||||
"brand_field": "brand",
|
||||
"image_field": "image",
|
||||
"updated_at_field": "updated_at",
|
||||
"updated_at_source": 1,
|
||||
"price_scale": 1,
|
||||
"id_prefix": "sku:",
|
||||
"default_tax_class": "standard"
|
||||
},
|
||||
"bygglagret": {
|
||||
"sku_field": "ArticleNumber",
|
||||
"title_field": "Name",
|
||||
"price_field": "PriceInclVAT",
|
||||
"tax_class_field": "VATClass",
|
||||
"inventory_tracked_field": "StockTracked",
|
||||
"drop_ship_field": "DropshipFlag",
|
||||
"category_field": "CategoryPath",
|
||||
"brand_field": "Brand",
|
||||
"image_field": "PrimaryImage",
|
||||
"updated_at_field": "LastModifiedMs",
|
||||
"updated_at_source": 1,
|
||||
"price_scale": 100,
|
||||
"id_prefix": "sku:",
|
||||
"fixed_currency": "SEK",
|
||||
"default_tax_class": "standard"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package tax
|
||||
|
||||
// nordicStandardRates maps country code -> default standard VAT rate in basis
|
||||
// points of a percent (2500 = 25%).
|
||||
var nordicStandardRates = map[string]int{
|
||||
"SE": 2500,
|
||||
"NO": 2500,
|
||||
"DK": 2500,
|
||||
"FI": 2550, // 25.5% as of 2024 — exact in basis points, no truncation
|
||||
}
|
||||
|
||||
// Nordic implements Provider for the Nordic countries (SE, NO, DK, FI).
|
||||
// It encodes the standard statutory VAT rates (basis points):
|
||||
//
|
||||
// SE: 25 % (2500)
|
||||
// NO: 25 % (2500)
|
||||
// DK: 25 % (2500)
|
||||
// FI: 25.5 % (2550)
|
||||
//
|
||||
// Rates are integer basis points, so fractional rates (FI 25.5) are represented
|
||||
// exactly — no float, no truncation.
|
||||
//
|
||||
// Nordic does not make network calls — all logic is local math. An external
|
||||
// provider (Avalara, TaxJar) can be added by implementing Provider and
|
||||
// switching on TAX_PROVIDER.
|
||||
type Nordic struct {
|
||||
defaultCountry string
|
||||
}
|
||||
|
||||
// NewNordic returns a Nordic provider. defaultCountry is used when
|
||||
// DefaultTaxRate is called with an empty country code; it defaults to "SE"
|
||||
// if empty.
|
||||
func NewNordic(defaultCountry string) *Nordic {
|
||||
if defaultCountry == "" {
|
||||
defaultCountry = "SE"
|
||||
}
|
||||
return &Nordic{defaultCountry: defaultCountry}
|
||||
}
|
||||
|
||||
var _ Provider = (*Nordic)(nil)
|
||||
|
||||
func (p *Nordic) Name() string { return "nordic" }
|
||||
|
||||
func (p *Nordic) Compute(total int64, taxRate int) int64 {
|
||||
return Compute(total, taxRate)
|
||||
}
|
||||
|
||||
func (p *Nordic) DefaultTaxRate(country string) int {
|
||||
if country == "" {
|
||||
country = p.defaultCountry
|
||||
}
|
||||
if rate, ok := nordicStandardRates[country]; ok {
|
||||
return rate
|
||||
}
|
||||
return nordicStandardRates[p.defaultCountry]
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package tax
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// Price is a monetary amount in minor units (money.Cents) with its VAT broken
|
||||
// down by rate. IncVat is the gross total; VatRates maps each tax rate — basis
|
||||
// points of a percent, 2500 = 25%, 1250 = 12.5% (the single platform scale,
|
||||
// matching OrderLine.tax_rate) — to its VAT amount in minor units.
|
||||
//
|
||||
// All arithmetic is integer (money.Cents is int64-backed); VAT is only ever
|
||||
// computed by Compute (ex-VAT-first), always on a real total. Aggregation
|
||||
// (Add/SumPrices) only sums integers, so totals never drift from the sum of
|
||||
// their parts. The JSON wire shape is unchanged — Cents marshals as int64.
|
||||
type Price struct {
|
||||
IncVat money.Cents `json:"incVat"`
|
||||
VatRates map[int]money.Cents `json:"vat,omitempty"`
|
||||
}
|
||||
|
||||
// NewPrice returns a new zero Price.
|
||||
func NewPrice() *Price {
|
||||
return &Price{
|
||||
IncVat: 0,
|
||||
VatRates: make(map[int]money.Cents),
|
||||
}
|
||||
}
|
||||
|
||||
// NewPriceFromIncVat builds a Price from a gross total and a tax rate in basis
|
||||
// points (2500 = 25%, 1250 = 12.5%). VAT is computed once via Compute, so
|
||||
// exVat + vat == incVat exactly.
|
||||
func NewPriceFromIncVat(incVat money.Cents, rateBp int) *Price {
|
||||
return &Price{
|
||||
IncVat: incVat,
|
||||
VatRates: map[int]money.Cents{
|
||||
rateBp: money.Cents(Compute(incVat.Int64(), rateBp)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ValueExVat returns the price excluding VAT.
|
||||
func (p *Price) ValueExVat() money.Cents {
|
||||
exVat := p.IncVat
|
||||
for _, amount := range p.VatRates {
|
||||
exVat -= amount
|
||||
}
|
||||
return exVat
|
||||
}
|
||||
|
||||
// TotalVat returns the total VAT amount.
|
||||
func (p *Price) TotalVat() money.Cents {
|
||||
total := money.Cents(0)
|
||||
for _, amount := range p.VatRates {
|
||||
total += amount
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// MultiplyPrice scales a Price to a quantity and returns a new Price.
|
||||
//
|
||||
// For a single-rate price (a line item) it recomputes the VAT on the *scaled
|
||||
// total* via NewPriceFromIncVat — not by multiplying the per-unit VAT — so the
|
||||
// result matches a payment provider that re-derives tax from the line total, and
|
||||
// VAT rounding never accumulates per unit. VAT is only ever computed on a real
|
||||
// total.
|
||||
//
|
||||
// A zero-rate price scales its (untaxed) IncVat. A multi-rate price (which a line
|
||||
// item never is — totals are built by Add, never Multiply) falls back to scaling
|
||||
// each component, since IncVat can't be split across rates.
|
||||
func MultiplyPrice(p Price, qty int64) *Price {
|
||||
if len(p.VatRates) == 1 {
|
||||
for rate := range p.VatRates {
|
||||
return NewPriceFromIncVat(p.IncVat.Mul(qty), rate)
|
||||
}
|
||||
}
|
||||
ret := &Price{
|
||||
IncVat: p.IncVat.Mul(qty),
|
||||
VatRates: make(map[int]money.Cents, len(p.VatRates)),
|
||||
}
|
||||
for rate, amount := range p.VatRates {
|
||||
ret.VatRates[rate] = amount.Mul(qty)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// Multiply scales the Price by a quantity in place (see MultiplyPrice).
|
||||
func (p *Price) Multiply(qty int64) {
|
||||
res := MultiplyPrice(*p, qty)
|
||||
p.IncVat = res.IncVat
|
||||
p.VatRates = res.VatRates
|
||||
}
|
||||
|
||||
// MarshalJSON serializes Price as a wire-friendly object with exVat, incVat, and
|
||||
// vat (rate-keyed by raw percent as a string, e.g. "25"). Cents marshals as
|
||||
// int64, so the wire bytes are byte-identical to the pre-money.Cents shape.
|
||||
func (p Price) MarshalJSON() ([]byte, error) {
|
||||
exVat := p.ValueExVat()
|
||||
var vat map[string]money.Cents
|
||||
if len(p.VatRates) > 0 {
|
||||
vat = make(map[string]money.Cents, len(p.VatRates))
|
||||
for rate, amount := range p.VatRates {
|
||||
vat[strconv.Itoa(rate)] = amount
|
||||
}
|
||||
}
|
||||
type wire struct {
|
||||
ExVat money.Cents `json:"exVat"`
|
||||
IncVat money.Cents `json:"incVat"`
|
||||
Vat map[string]money.Cents `json:"vat,omitempty"`
|
||||
}
|
||||
return json.Marshal(wire{ExVat: exVat, IncVat: p.IncVat, Vat: vat})
|
||||
}
|
||||
|
||||
// UnmarshalJSON deserializes Price from its wire format.
|
||||
func (p *Price) UnmarshalJSON(data []byte) error {
|
||||
type wire struct {
|
||||
ExVat money.Cents `json:"exVat"`
|
||||
IncVat money.Cents `json:"incVat"`
|
||||
Vat map[string]money.Cents `json:"vat,omitempty"`
|
||||
}
|
||||
var w wire
|
||||
if err := json.Unmarshal(data, &w); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.IncVat = w.IncVat
|
||||
p.VatRates = make(map[int]money.Cents, len(w.Vat))
|
||||
for rateStr, amount := range w.Vat {
|
||||
// Tolerate legacy float-style keys ("2500", "1250") by truncating to the
|
||||
// basis point integer convention.
|
||||
rate, err := strconv.Atoi(rateStr)
|
||||
if err != nil {
|
||||
f, ferr := strconv.ParseFloat(rateStr, 64)
|
||||
if ferr != nil {
|
||||
return err
|
||||
}
|
||||
rate = int(f)
|
||||
}
|
||||
p.VatRates[rate] = amount
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add adds another Price's values to this one.
|
||||
func (p *Price) Add(price Price) {
|
||||
p.IncVat += price.IncVat
|
||||
for rate, amount := range price.VatRates {
|
||||
p.VatRates[rate] += amount
|
||||
}
|
||||
}
|
||||
|
||||
// Subtract subtracts another Price's values from this one.
|
||||
func (p *Price) Subtract(price Price) {
|
||||
p.IncVat -= price.IncVat
|
||||
for rate, amount := range price.VatRates {
|
||||
p.VatRates[rate] -= amount
|
||||
}
|
||||
}
|
||||
|
||||
// SumPrices aggregates multiple Prices into one.
|
||||
func SumPrices(prices ...Price) *Price {
|
||||
aggregated := NewPrice()
|
||||
for _, price := range prices {
|
||||
aggregated.IncVat += price.IncVat
|
||||
for rate, amount := range price.VatRates {
|
||||
aggregated.VatRates[rate] += amount
|
||||
}
|
||||
}
|
||||
if len(aggregated.VatRates) == 0 {
|
||||
aggregated.VatRates = nil
|
||||
}
|
||||
return aggregated
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
// Package tax provides the tax computation seam: a Provider interface
|
||||
// (mirroring PaymentProvider and ShippingProvider patterns), the shared Compute
|
||||
// formula, and a stateless Static provider for dev/test.
|
||||
//
|
||||
// SCALE: all tax rates are integer **basis points of a percent** — rate × 100,
|
||||
// so 25% = 2500, 12.5% = 1250, 8.1% = 810. This is the single platform-wide
|
||||
// scale (Price.VatRates, CartItem.Tax, OrderLine.tax_rate, the payment-provider
|
||||
// wire). Integer basis points support fractional VAT rates (Ireland 13.5%,
|
||||
// France 5.5%, Finland 25.5%) exactly without ever using a float.
|
||||
package tax
|
||||
|
||||
// Provider computes taxes for orders and line items.
|
||||
type Provider interface {
|
||||
// Name returns the provider name for identification (logging, metrics).
|
||||
Name() string
|
||||
|
||||
// Compute computes the VAT portion from a gross (inc-VAT) total and tax
|
||||
// rate. Both total and return value are in minor currency units (öre).
|
||||
// rateBp is basis points of a percent (2500 = 25%, 1250 = 12.5%).
|
||||
//
|
||||
// Ex-VAT-first integer math: exVat = total*10000/(10000+rateBp); tax = total - exVat.
|
||||
// When rateBp <= 0 the result is 0 (untaxed / zero-rated items).
|
||||
Compute(total int64, rateBp int) int64
|
||||
|
||||
// DefaultTaxRate returns the default VAT rate for a country in basis points
|
||||
// (2500 = 25%). Used for deliveries and items where no explicit rate was
|
||||
// recorded at add-to-cart time. Returns 0 if the country is unknown/untaxed.
|
||||
DefaultTaxRate(country string) int
|
||||
}
|
||||
|
||||
// Compute returns the VAT portion of an inc-VAT total using integer (cent)
|
||||
// arithmetic only, ex-VAT-first so exVat + tax == total exactly and the result
|
||||
// matches the payment-provider convention (no per-line rounding drift):
|
||||
//
|
||||
// exVat = total * 10000 / (10000 + rateBp)
|
||||
// tax = total - exVat
|
||||
//
|
||||
// rateBp is basis points of a percent (2500 = 25%, 1250 = 12.5%) — the single
|
||||
// platform-wide rate scale. When rateBp <= 0 the result is 0 (untaxed).
|
||||
func Compute(total int64, rateBp int) int64 {
|
||||
if rateBp <= 0 {
|
||||
return 0
|
||||
}
|
||||
exVat := total * 10000 / int64(10000+rateBp)
|
||||
return total - exVat
|
||||
}
|
||||
|
||||
// Static is a stateless, always-available Provider that simply runs the
|
||||
// formula without any country-specific rates. Use for local dev, tests,
|
||||
// and as a no-dependency default.
|
||||
type Static struct{}
|
||||
|
||||
var _ Provider = (*Static)(nil)
|
||||
|
||||
// NewStatic returns a new Static provider.
|
||||
func NewStatic() *Static { return &Static{} }
|
||||
|
||||
func (p *Static) Name() string { return "static" }
|
||||
func (p *Static) Compute(total int64, taxRate int) int64 {
|
||||
return Compute(total, taxRate)
|
||||
}
|
||||
func (p *Static) DefaultTaxRate(_ string) int { return 0 }
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
package tax
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStatic_Name(t *testing.T) {
|
||||
p := NewStatic()
|
||||
if got := p.Name(); got != "static" {
|
||||
t.Errorf("Name() = %q, want %q", got, "static")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatic_Compute(t *testing.T) {
|
||||
// rateBp is basis points (2500 = 25%).
|
||||
p := NewStatic()
|
||||
tests := []struct {
|
||||
total int64
|
||||
rateBp int
|
||||
want int64
|
||||
}{
|
||||
{1250, 2500, 250},
|
||||
{25000, 2500, 5000},
|
||||
{1000, 2000, 167},
|
||||
{0, 2500, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := p.Compute(tt.total, tt.rateBp)
|
||||
if got != tt.want {
|
||||
t.Errorf("Compute(%d, %d) = %d; want %d", tt.total, tt.rateBp, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatic_DefaultTaxRate(t *testing.T) {
|
||||
p := NewStatic()
|
||||
if got := p.DefaultTaxRate("SE"); got != 0 {
|
||||
t.Errorf("DefaultTaxRate(\"SE\") = %d; want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNordic_Name(t *testing.T) {
|
||||
p := NewNordic("SE")
|
||||
if got := p.Name(); got != "nordic" {
|
||||
t.Errorf("Name() = %q, want %q", got, "nordic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNordic_Compute(t *testing.T) {
|
||||
// rateBp is basis points (2500 = 25%).
|
||||
tests := []struct {
|
||||
total int64
|
||||
rateBp int
|
||||
want int64
|
||||
desc string
|
||||
}{
|
||||
{0, 2500, 0, "zero total"},
|
||||
{1250, 2500, 250, "25 % VAT on 1250"},
|
||||
{1000, 2000, 167, "20 % VAT on 1000"},
|
||||
{1200, 2500, 240, "25 % VAT on 1200"},
|
||||
{25000, 2500, 5000, "25 % VAT on 25000"},
|
||||
{100, 1000, 10, "10 % VAT on 100"},
|
||||
{100, 10000, 50, "100 % VAT on 100"},
|
||||
{100, 0, 0, "zero-rate"},
|
||||
{100, -1, 0, "negative rate -> zero"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
p := NewNordic("SE")
|
||||
got := p.Compute(tt.total, tt.rateBp)
|
||||
if got != tt.want {
|
||||
t.Errorf("Compute(%d, %d) [%s] = %d; want %d", tt.total, tt.rateBp, tt.desc, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNordic_DefaultTaxRate(t *testing.T) {
|
||||
tests := []struct {
|
||||
country string
|
||||
want int
|
||||
desc string
|
||||
}{
|
||||
{"SE", 2500, "Sweden"},
|
||||
{"NO", 2500, "Norway"},
|
||||
{"DK", 2500, "Denmark"},
|
||||
{"FI", 2550, "Finland (25.5%)"},
|
||||
{"", 2500, "empty -> default SE"},
|
||||
{"DE", 2500, "unknown -> fallback SE"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
p := NewNordic("SE")
|
||||
got := p.DefaultTaxRate(tt.country)
|
||||
if got != tt.want {
|
||||
t.Errorf("DefaultTaxRate(%q) [%s] = %d; want %d", tt.country, tt.desc, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompute(t *testing.T) {
|
||||
// rateBp is basis points (2500 = 25%).
|
||||
tests := []struct {
|
||||
total int64
|
||||
rateBp int
|
||||
want int64
|
||||
desc string
|
||||
}{
|
||||
{1250, 2500, 250, "canonical: 25 %"},
|
||||
{25000, 2500, 5000, "25k with 25 % -> 5k tax"},
|
||||
{0, 2500, 0, "zero total"},
|
||||
{1000, 1250, 112, "fractional: 12.5 %"}, // exVat 1000*10000/11250=888, tax 112
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := Compute(tt.total, tt.rateBp)
|
||||
if got != tt.want {
|
||||
t.Errorf("Compute(%d, %d) [%s] = %d; want %d", tt.total, tt.rateBp, tt.desc, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderInterface(t *testing.T) {
|
||||
var p Provider = NewNordic("SE")
|
||||
_ = p
|
||||
p2 := NewStatic()
|
||||
_ = p2
|
||||
}
|
||||
|
||||
func TestPrice_NewAndString(t *testing.T) {
|
||||
p := NewPrice()
|
||||
if p.IncVat != 0 {
|
||||
t.Errorf("NewPrice().IncVat = %d; want 0", p.IncVat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrice_NewPriceFromIncVat(t *testing.T) {
|
||||
p := NewPriceFromIncVat(1250, 2500) // 25% in basis points
|
||||
if p.IncVat != 1250 {
|
||||
t.Errorf("IncVat = %d; want 1250", p.IncVat)
|
||||
}
|
||||
if len(p.VatRates) != 1 {
|
||||
t.Errorf("expected 1 vat rate, got %d", len(p.VatRates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrice_ValueExVat(t *testing.T) {
|
||||
p := NewPriceFromIncVat(1250, 2500) // 25%
|
||||
exVat := p.ValueExVat()
|
||||
if exVat != 1000 {
|
||||
t.Errorf("ValueExVat() = %d; want 1000", exVat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrice_TotalVat(t *testing.T) {
|
||||
p := NewPriceFromIncVat(1250, 2500) // 25%
|
||||
totalVat := p.TotalVat()
|
||||
if totalVat != 250 {
|
||||
t.Errorf("TotalVat() = %d; want 250", totalVat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrice_Multiply(t *testing.T) {
|
||||
p := NewPriceFromIncVat(1250, 2500)
|
||||
p.Multiply(2)
|
||||
if p.IncVat != 2500 {
|
||||
t.Errorf("IncVat after Multiply(2) = %d; want 2500", p.IncVat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrice_MultiplyPrice(t *testing.T) {
|
||||
p := NewPriceFromIncVat(1250, 2500)
|
||||
p2 := MultiplyPrice(*p, 3)
|
||||
if p2.IncVat != 3750 {
|
||||
t.Errorf("IncVat = %d; want 3750", p2.IncVat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrice_Add(t *testing.T) {
|
||||
a := NewPriceFromIncVat(1250, 2500)
|
||||
b := NewPriceFromIncVat(2500, 2500)
|
||||
a.Add(*b)
|
||||
if a.IncVat != 3750 {
|
||||
t.Errorf("IncVat after Add = %d; want 3750", a.IncVat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrice_Subtract(t *testing.T) {
|
||||
a := NewPriceFromIncVat(2500, 2500)
|
||||
b := NewPriceFromIncVat(1250, 2500)
|
||||
a.Subtract(*b)
|
||||
if a.IncVat != 1250 {
|
||||
t.Errorf("IncVat after Subtract = %d; want 1250", a.IncVat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrice_SumPrices(t *testing.T) {
|
||||
a := NewPriceFromIncVat(1250, 2500)
|
||||
b := NewPriceFromIncVat(2500, 2500)
|
||||
total := SumPrices(*a, *b)
|
||||
if total.IncVat != 3750 {
|
||||
t.Errorf("SumPrices IncVat = %d; want 3750", total.IncVat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrice_JSONRoundTrip(t *testing.T) {
|
||||
p := NewPriceFromIncVat(1250, 2500)
|
||||
data, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal error: %v", err)
|
||||
}
|
||||
var p2 Price
|
||||
if err := json.Unmarshal(data, &p2); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
if p2.IncVat != p.IncVat {
|
||||
t.Errorf("IncVat mismatch: have %d, want %d", p2.IncVat, p.IncVat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrice_SumPricesEmpty(t *testing.T) {
|
||||
total := SumPrices()
|
||||
if total.IncVat != 0 {
|
||||
t.Errorf("SumPrices() IncVat = %d; want 0", total.IncVat)
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
// Package uid provides a compact 64-bit identifier with base62 string form.
|
||||
//
|
||||
// The scheme is used consistently across commerce services (cart, order,
|
||||
// profile, actor grains) so ids are URL/cookie-friendly and comparable
|
||||
// across system boundaries. Each domain re-exports the type as its own
|
||||
// named alias (e.g. order.OrderId = uid.ID) and keeps thin wrapper
|
||||
// functions so call-site code never needs to import platform/uid directly.
|
||||
package uid
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ID is a 64-bit identifier encoded as base62 text (max 11 chars).
|
||||
type ID uint64
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
var base62Rev [256]byte
|
||||
|
||||
func init() {
|
||||
for i := range base62Rev {
|
||||
base62Rev[i] = 0xFF
|
||||
}
|
||||
for i := 0; i < len(base62Alphabet); i++ {
|
||||
base62Rev[base62Alphabet[i]] = byte(i)
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the canonical base62 encoding.
|
||||
func (id ID) String() string { return encodeBase62(uint64(id)) }
|
||||
|
||||
// MarshalJSON encodes the id as a JSON string.
|
||||
func (id ID) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(id.String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the id from a JSON string containing base62 text.
|
||||
func (id *ID) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, ok := Parse(s)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid uid: %q", s)
|
||||
}
|
||||
*id = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// New generates a cryptographically random non-zero 64-bit id.
|
||||
func New() (ID, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0, fmt.Errorf("uid.New: %w", err)
|
||||
}
|
||||
u := (uint64(b[0]) << 56) |
|
||||
(uint64(b[1]) << 48) |
|
||||
(uint64(b[2]) << 40) |
|
||||
(uint64(b[3]) << 32) |
|
||||
(uint64(b[4]) << 24) |
|
||||
(uint64(b[5]) << 16) |
|
||||
(uint64(b[6]) << 8) |
|
||||
uint64(b[7])
|
||||
if u == 0 {
|
||||
return New()
|
||||
}
|
||||
return ID(u), nil
|
||||
}
|
||||
|
||||
// MustNew panics if generation fails.
|
||||
func MustNew() ID {
|
||||
id, err := New()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Parse parses a base62 string into an ID. Returns (0, false) for invalid input.
|
||||
func Parse(s string) (ID, bool) {
|
||||
if len(s) == 0 || len(s) > 11 {
|
||||
return 0, false
|
||||
}
|
||||
u, ok := decodeBase62(s)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return ID(u), true
|
||||
}
|
||||
|
||||
// MustParse panics on invalid base62 input.
|
||||
func MustParse(s string) ID {
|
||||
id, ok := Parse(s)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("invalid uid: %q", s))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// encodeBase62 converts a uint64 to base62 (shortest form, no leading zeros).
|
||||
func encodeBase62(u uint64) string {
|
||||
if u == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [11]byte
|
||||
i := len(buf)
|
||||
for u > 0 {
|
||||
i--
|
||||
buf[i] = base62Alphabet[u%62]
|
||||
u /= 62
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// decodeBase62 converts base62 text to uint64.
|
||||
func decodeBase62(s string) (uint64, bool) {
|
||||
var v uint64
|
||||
for i := 0; i < len(s); i++ {
|
||||
d := base62Rev[s[i]]
|
||||
if d == 0xFF {
|
||||
return 0, false
|
||||
}
|
||||
v = v*62 + uint64(d)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
Reference in new issue
Block a user