From b7081477c059d583b8ab7ad6a21595446de0f72e Mon Sep 17 00:00:00 2001 From: Mayuresh Date: Tue, 31 Mar 2026 09:49:56 +0530 Subject: [PATCH 1/9] benchmarking tools added --- go.mod | 16 ++++++++++------ go.sum | 32 ++++++++++++++++++-------------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index 6fce510..2513d64 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module github.com/beckn-one/beckn-onix -go 1.24.6 +go 1.25.0 require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 - golang.org/x/crypto v0.47.0 + golang.org/x/crypto v0.49.0 ) require github.com/stretchr/testify v1.11.1 @@ -19,9 +19,12 @@ require ( require github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03 -require golang.org/x/text v0.33.0 // indirect +tool golang.org/x/perf/cmd/benchstat + +require golang.org/x/text v0.35.0 // indirect require ( + github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -82,9 +85,10 @@ require ( go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/perf v0.0.0-20260312031701-16a31bc5fbd0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect diff --git a/go.sum b/go.sum index fbbed95..a8d1cf0 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794 h1:xlwdaKcTNVW4PtpQb8aKA4Pjy0CdJHEqvFbAnvR5m2g= +github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794/go.mod h1:7e+I0LQFUI9AXWxOfsQROs9xPhoJtbsyWcjJqDd4KPY= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= @@ -274,26 +276,28 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/perf v0.0.0-20260312031701-16a31bc5fbd0 h1:VgUwdbeBqkERh4BX46p4O2fSng7duMS+0V01EEAt2Vk= +golang.org/x/perf v0.0.0-20260312031701-16a31bc5fbd0/go.mod h1:UWOuhEKaiVtLW8tca1eEwpuNy4tzUubUXNAnA51k48o= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= From bccb381bfa7f41c153da0256603ed4cdfc5f323b Mon Sep 17 00:00:00 2001 From: Mayuresh Date: Wed, 1 Apr 2026 17:19:37 +0530 Subject: [PATCH 2/9] scripts to run benchmarks --- benchmarks/README.md | 157 + benchmarks/e2e/bench_test.go | 186 + benchmarks/e2e/keys_test.go | 13 + benchmarks/e2e/mocks_test.go | 63 + benchmarks/e2e/setup_test.go | 466 +++ benchmarks/e2e/testdata/beckn.yaml | 3380 +++++++++++++++++ benchmarks/e2e/testdata/confirm_request.json | 84 + benchmarks/e2e/testdata/discover_request.json | 17 + benchmarks/e2e/testdata/init_request.json | 80 + .../e2e/testdata/routing-BAPCaller.yaml | 13 + benchmarks/e2e/testdata/select_request.json | 55 + benchmarks/run_benchmarks.sh | 145 + benchmarks/tools/parse_results.go | 258 ++ 13 files changed, 4917 insertions(+) create mode 100644 benchmarks/README.md create mode 100644 benchmarks/e2e/bench_test.go create mode 100644 benchmarks/e2e/keys_test.go create mode 100644 benchmarks/e2e/mocks_test.go create mode 100644 benchmarks/e2e/setup_test.go create mode 100644 benchmarks/e2e/testdata/beckn.yaml create mode 100644 benchmarks/e2e/testdata/confirm_request.json create mode 100644 benchmarks/e2e/testdata/discover_request.json create mode 100644 benchmarks/e2e/testdata/init_request.json create mode 100644 benchmarks/e2e/testdata/routing-BAPCaller.yaml create mode 100644 benchmarks/e2e/testdata/select_request.json create mode 100755 benchmarks/run_benchmarks.sh create mode 100644 benchmarks/tools/parse_results.go diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..6d0615b --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,157 @@ +# beckn-onix Adapter Benchmarks + +End-to-end performance benchmarks for the beckn-onix ONIX adapter, using Go's native `testing.B` framework and `net/http/httptest`. No Docker, no external services — everything runs in-process. + +--- + +## Quick Start + +```bash +# From the repo root +go mod tidy # fetch miniredis + benchstat checksums +bash benchmarks/run_benchmarks.sh # compile plugins, run all scenarios, generate report +``` + +Results land in `benchmarks/results//`. + +--- + +## What Is Being Benchmarked + +The benchmarks target the **`bapTxnCaller`** handler — the primary outbound path a BAP takes when initiating a Beckn transaction. Every request travels through the full production pipeline: + +``` +Benchmark goroutine(s) + │ HTTP POST /bap/caller/ + ▼ +httptest.Server ← ONIX adapter (real compiled .so plugins) + │ + ├── addRoute router plugin resolve BPP URL from routing config + ├── sign signer + simplekeymanager Ed25519 / BLAKE-512 signing + └── validateSchema schemav2validator Beckn OpenAPI spec validation + │ + └──▶ httptest mock BPP (instant ACK — no network) +``` + +Mock services replace all external dependencies so results reflect **adapter-internal latency only**: + +| Dependency | Replaced by | +|------------|-------------| +| Redis | `miniredis` (in-process) | +| BPP backend | `httptest` mock — returns `{"message":{"ack":{"status":"ACK"}}}` | +| Beckn registry | `httptest` mock — returns the dev key pair for signature verification | + +--- + +## Benchmark Scenarios + +| Benchmark | What it measures | +|-----------|-----------------| +| `BenchmarkBAPCaller_Discover` | Baseline single-goroutine latency for `/discover` | +| `BenchmarkBAPCaller_Discover_Parallel` | Throughput under concurrent load; run with `-cpu=1,2,4,8,16` | +| `BenchmarkBAPCaller_AllActions` | Per-action latency: `discover`, `select`, `init`, `confirm` | +| `BenchmarkBAPCaller_Discover_Percentiles` | p50 / p95 / p99 latency via `b.ReportMetric` | +| `BenchmarkBAPCaller_CacheWarm` | Latency when the Redis key cache is already populated | +| `BenchmarkBAPCaller_CacheCold` | Latency on a cold cache — full key-derivation round-trip | +| `BenchmarkBAPCaller_RPS` | Requests-per-second under parallel load (`req/s` custom metric) | + +--- + +## How It Works + +### Startup (`TestMain`) + +Before any benchmark runs, `TestMain` in `e2e/setup_test.go`: + +1. **Compiles all required plugins** to a temporary directory using `go build -buildmode=plugin`. The first run takes 60–90 s (cold Go build cache); subsequent runs are near-instant. +2. **Starts miniredis** — an in-process Redis server used by the `cache` plugin (no external Redis needed). +3. **Starts mock servers** — an instant-ACK BPP and a registry mock that returns the dev signing public key. +4. **Starts the adapter** — wires all plugins programmatically (no YAML parsing) and wraps it in an `httptest.Server`. + +### Per-iteration (`buildSignedRequest`) + +Each benchmark iteration: +1. Loads the JSON fixture for the requested Beckn action (`testdata/_request.json`). +2. Substitutes sentinel values (`BENCH_TIMESTAMP`, `BENCH_MESSAGE_ID`, `BENCH_TRANSACTION_ID`) with fresh values, ensuring unique message IDs per iteration. +3. Signs the body using the Beckn Ed25519/BLAKE-512 spec (same algorithm as the production `signer` plugin). +4. Sends the signed `POST` to the adapter and validates a `200 OK` response. + +### Validation test (`TestSignBecknPayload`) + +A plain `Test*` function runs before the benchmarks and sends one signed request end-to-end. If the signing helper is mis-implemented, this fails fast before any benchmark time is wasted. + +--- + +## Directory Layout + +``` +benchmarks/ +├── README.md ← you are here +├── run_benchmarks.sh ← one-shot runner script +├── e2e/ +│ ├── bench_test.go ← benchmark functions (T8) +│ ├── setup_test.go ← TestMain, startAdapter, signing helper (T3/T4/T7) +│ ├── mocks_test.go ← mock BPP and registry servers (T5) +│ ├── keys_test.go ← dev key pair constants (T6a) +│ └── testdata/ +│ ├── routing-BAPCaller.yaml ← routing config (BENCH_BPP_URL placeholder) +│ ├── discover_request.json ← Beckn search payload fixture +│ ├── select_request.json +│ ├── init_request.json +│ └── confirm_request.json +├── tools/ +│ └── parse_results.go ← CSV exporter for latency + throughput data (T10) +└── results/ + └── BENCHMARK_REPORT.md ← report template (populate after a run) +``` + +--- + +## Running Individual Benchmarks + +```bash +# Single benchmark, 10 s +go test ./benchmarks/e2e/... \ + -bench=BenchmarkBAPCaller_Discover \ + -benchtime=10s -benchmem -timeout=30m + +# All actions in one shot +go test ./benchmarks/e2e/... \ + -bench=BenchmarkBAPCaller_AllActions \ + -benchtime=5s -benchmem -timeout=30m + +# Concurrency sweep at 1, 4, and 16 goroutines +go test ./benchmarks/e2e/... \ + -bench=BenchmarkBAPCaller_Discover_Parallel \ + -benchtime=30s -cpu=1,4,16 -timeout=30m + +# Race detector check (no data races) +go test ./benchmarks/e2e/... \ + -bench=BenchmarkBAPCaller_Discover_Parallel \ + -benchtime=5s -race -timeout=30m + +# Percentile metrics (p50/p95/p99 in µs) +go test ./benchmarks/e2e/... \ + -bench=BenchmarkBAPCaller_Discover_Percentiles \ + -benchtime=10s -benchmem -timeout=30m +``` + +## Comparing Two Runs with benchstat + +```bash +go test ./benchmarks/e2e/... -bench=. -benchtime=10s -count=6 > before.txt +# ... make your change ... +go test ./benchmarks/e2e/... -bench=. -benchtime=10s -count=6 > after.txt +benchstat before.txt after.txt +``` + +--- + +## Dependencies + +| Package | Purpose | +|---------|---------| +| `github.com/alicebob/miniredis/v2` | In-process Redis for the `cache` plugin | +| `golang.org/x/perf/cmd/benchstat` | Statistical benchmark comparison (CLI tool) | + +Both are declared in `go.mod`. Run `go mod tidy` once to fetch their checksums. diff --git a/benchmarks/e2e/bench_test.go b/benchmarks/e2e/bench_test.go new file mode 100644 index 0000000..2775802 --- /dev/null +++ b/benchmarks/e2e/bench_test.go @@ -0,0 +1,186 @@ +package e2e_bench_test + +import ( + "fmt" + "net/http" + "sort" + "testing" + "time" +) + +// ── BenchmarkBAPCaller_Discover ─────────────────────────────────────────────── +// Baseline single-goroutine throughput and latency for the discover endpoint. +// Exercises the full bapTxnCaller pipeline: addRoute → sign → validateSchema. +func BenchmarkBAPCaller_Discover(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + req := buildSignedRequest(b, "discover") + if err := sendRequest(req); err != nil { + b.Errorf("iteration %d: %v", i, err) + } + } +} + +// ── BenchmarkBAPCaller_Discover_Parallel ───────────────────────────────────── +// Measures throughput under concurrent load. Run with -cpu=1,2,4,8,16 to +// produce a concurrency sweep. Each goroutine runs its own request loop. +func BenchmarkBAPCaller_Discover_Parallel(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + req := buildSignedRequest(b, "discover") + if err := sendRequest(req); err != nil { + b.Errorf("parallel: %v", err) + } + } + }) +} + +// ── BenchmarkBAPCaller_AllActions ──────────────────────────────────────────── +// Measures per-action latency for discover, select, init, and confirm in a +// single benchmark run. Each sub-benchmark is independent. +func BenchmarkBAPCaller_AllActions(b *testing.B) { + actions := []string{"discover", "select", "init", "confirm"} + + for _, action := range actions { + action := action // capture for sub-benchmark closure + b.Run(action, func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := buildSignedRequest(b, action) + if err := sendRequest(req); err != nil { + b.Errorf("action %s iteration %d: %v", action, i, err) + } + } + }) + } +} + +// ── BenchmarkBAPCaller_Discover_Percentiles ─────────────────────────────────── +// Collects individual request durations and reports p50, p95, and p99 latency +// in microseconds via b.ReportMetric. The percentile data is only meaningful +// when -benchtime is at least 5s (default used in run_benchmarks.sh). +func BenchmarkBAPCaller_Discover_Percentiles(b *testing.B) { + durations := make([]time.Duration, 0, b.N) + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + req := buildSignedRequest(b, "discover") + start := time.Now() + if err := sendRequest(req); err != nil { + b.Errorf("iteration %d: %v", i, err) + continue + } + durations = append(durations, time.Since(start)) + } + + // Compute and report percentiles. + if len(durations) == 0 { + return + } + sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] }) + + p50 := durations[len(durations)*50/100] + p95 := durations[len(durations)*95/100] + p99 := durations[len(durations)*99/100] + + b.ReportMetric(float64(p50.Microseconds()), "p50_µs") + b.ReportMetric(float64(p95.Microseconds()), "p95_µs") + b.ReportMetric(float64(p99.Microseconds()), "p99_µs") +} + +// ── BenchmarkBAPCaller_CacheWarm / CacheCold ───────────────────────────────── +// Compares latency when the Redis cache holds a pre-warmed key set (CacheWarm) +// vs. when each iteration has a fresh message_id that the cache has never seen +// (CacheCold). The delta reveals the key-lookup overhead on a cold path. + +// BenchmarkBAPCaller_CacheWarm sends a fixed body (constant message_id) so the +// simplekeymanager's Redis cache is hit on every iteration after the first. +func BenchmarkBAPCaller_CacheWarm(b *testing.B) { + body := warmFixtureBody(b, "discover") + + // Warm-up: send once to populate the cache before the timer starts. + warmReq := buildSignedRequestFixed(b, "discover", body) + if err := sendRequest(warmReq); err != nil { + b.Fatalf("cache warm-up request failed: %v", err) + } + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + req := buildSignedRequestFixed(b, "discover", body) + if err := sendRequest(req); err != nil { + b.Errorf("CacheWarm iteration %d: %v", i, err) + } + } +} + +// BenchmarkBAPCaller_CacheCold uses a fresh message_id per iteration, so every +// request experiences a cache miss and a full key-derivation round-trip. +func BenchmarkBAPCaller_CacheCold(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + req := buildSignedRequest(b, "discover") // fresh IDs each time + if err := sendRequest(req); err != nil { + b.Errorf("CacheCold iteration %d: %v", i, err) + } + } +} + +// ── BenchmarkBAPCaller_RPS ──────────────────────────────────────────────────── +// Reports requests-per-second as a custom metric alongside the default ns/op. +// Run with -benchtime=30s for a stable RPS reading. +func BenchmarkBAPCaller_RPS(b *testing.B) { + b.ReportAllocs() + + var count int64 + start := time.Now() + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + var local int64 + for pb.Next() { + req := buildSignedRequest(b, "discover") + if err := sendRequest(req); err == nil { + local++ + } + } + // Accumulate without atomic for simplicity — final value only read after + // RunParallel returns and all goroutines have exited. + count += local + }) + + elapsed := time.Since(start).Seconds() + if elapsed > 0 { + rps := float64(count) / elapsed + b.ReportMetric(rps, "req/s") + fmt.Printf(" RPS: %.0f over %.1fs\n", rps, elapsed) + } +} + +// ── helper: one-shot HTTP client ───────────────────────────────────────────── + +// benchHTTPClient is a shared client for all benchmark goroutines. +// MaxConnsPerHost caps the total active connections to localhost so we don't +// exhaust the OS ephemeral port range. MaxIdleConnsPerHost keeps that many +// connections warm in the pool so parallel goroutines reuse them rather than +// opening fresh TCP connections on every request. +var benchHTTPClient = &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 200, + MaxIdleConnsPerHost: 200, + MaxConnsPerHost: 200, + IdleConnTimeout: 90 * time.Second, + DisableCompression: true, // no benefit compressing localhost traffic + }, +} diff --git a/benchmarks/e2e/keys_test.go b/benchmarks/e2e/keys_test.go new file mode 100644 index 0000000..ac70482 --- /dev/null +++ b/benchmarks/e2e/keys_test.go @@ -0,0 +1,13 @@ +package e2e_bench_test + +// Development key pair from config/local-retail-bap.yaml. +// Used across the retail devkit for non-production testing. +// DO NOT use in any production or staging environment. +const ( + benchSubscriberID = "sandbox.food-finder.com" + benchKeyID = "76EU7VwahYv4XztXJzji9ssiSV74eWXWBcCKGn7jAdm5VGLCdYAJ8j" + benchPrivKey = "rrNtVgyASCGlo+ebsJaA37D5CZYZVfT0JA5/vlkTeV0=" + benchPubKey = "oFIk7KqCqvqRYkLMjQqiaKM5oOozkYT64bfLuc8p/SU=" + benchEncrPrivKey = "rrNtVgyASCGlo+ebsJaA37D5CZYZVfT0JA5/vlkTeV0=" + benchEncrPubKey = "oFIk7KqCqvqRYkLMjQqiaKM5oOozkYT64bfLuc8p/SU=" +) diff --git a/benchmarks/e2e/mocks_test.go b/benchmarks/e2e/mocks_test.go new file mode 100644 index 0000000..54d3543 --- /dev/null +++ b/benchmarks/e2e/mocks_test.go @@ -0,0 +1,63 @@ +package e2e_bench_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "time" +) + +// startMockBPP starts an httptest server that accepts any POST request and +// immediately returns a valid Beckn ACK. This replaces the real BPP backend, +// isolating benchmark results to adapter-internal latency only. +func startMockBPP() *httptest.Server { + ackBody := `{"message":{"ack":{"status":"ACK"}}}` + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, ackBody) + })) +} + +// subscriberRecord mirrors the registry API response shape for a single subscriber. +type subscriberRecord struct { + SubscriberID string `json:"subscriber_id"` + UniqueKeyID string `json:"unique_key_id"` + SigningPublicKey string `json:"signing_public_key"` + ValidFrom string `json:"valid_from"` + ValidUntil string `json:"valid_until"` + Status string `json:"status"` +} + +// startMockRegistry starts an httptest server that returns a subscriber record +// matching the benchmark test keys. The signvalidator plugin uses this to +// resolve the public key for signature verification on incoming requests. +func startMockRegistry() *httptest.Server { + record := subscriberRecord{ + SubscriberID: benchSubscriberID, + UniqueKeyID: benchKeyID, + SigningPublicKey: benchPubKey, + ValidFrom: time.Now().AddDate(-1, 0, 0).Format(time.RFC3339), + ValidUntil: time.Now().AddDate(10, 0, 0).Format(time.RFC3339), + Status: "SUBSCRIBED", + } + body, _ := json.Marshal([]subscriberRecord{record}) + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Support both GET (lookup) and POST (lookup with body) registry calls. + // Respond with the subscriber record regardless of subscriber_id query param. + subscriberID := r.URL.Query().Get("subscriber_id") + if subscriberID == "" { + // Try extracting from path for dedi-registry style calls. + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") + if len(parts) > 0 { + subscriberID = parts[len(parts)-1] + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(body) + })) +} diff --git a/benchmarks/e2e/setup_test.go b/benchmarks/e2e/setup_test.go new file mode 100644 index 0000000..702c5da --- /dev/null +++ b/benchmarks/e2e/setup_test.go @@ -0,0 +1,466 @@ +package e2e_bench_test + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/beckn-one/beckn-onix/core/module" + "github.com/beckn-one/beckn-onix/core/module/handler" + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin" + "github.com/google/uuid" + "github.com/rs/zerolog" + "golang.org/x/crypto/blake2b" +) + +// Package-level references shared across all benchmarks. +var ( + adapterServer *httptest.Server + miniRedis *miniredis.Miniredis + mockBPP *httptest.Server + mockRegistry *httptest.Server + pluginDir string + moduleRoot string // set in TestMain; used by buildBAPCallerConfig for local file paths +) + +// Plugins to compile for the benchmark. Each entry is (pluginID, source path relative to module root). +var pluginsToBuild = []struct { + id string + src string +}{ + {"router", "pkg/plugin/implementation/router/cmd/plugin.go"}, + {"signer", "pkg/plugin/implementation/signer/cmd/plugin.go"}, + {"signvalidator", "pkg/plugin/implementation/signvalidator/cmd/plugin.go"}, + {"simplekeymanager", "pkg/plugin/implementation/simplekeymanager/cmd/plugin.go"}, + {"cache", "pkg/plugin/implementation/cache/cmd/plugin.go"}, + {"schemav2validator", "pkg/plugin/implementation/schemav2validator/cmd/plugin.go"}, + {"otelsetup", "pkg/plugin/implementation/otelsetup/cmd/plugin.go"}, + // registry is required by stdHandler to wire KeyManager, even on the caller + // path where sign-validation never runs. + {"registry", "pkg/plugin/implementation/registry/cmd/plugin.go"}, +} + +// TestMain is the entry point for the benchmark package. It: +// 1. Compiles all required .so plugins into a temp directory +// 2. Starts miniredis (in-process Redis) +// 3. Starts mock BPP and registry HTTP servers +// 4. Starts the adapter as an httptest.Server +// 5. Runs all benchmarks +// 6. Tears everything down in reverse order +func TestMain(m *testing.M) { + ctx := context.Background() + + // ── Step 1: Compile plugins ─────────────────────────────────────────────── + var err error + pluginDir, err = os.MkdirTemp("", "beckn-bench-plugins-*") + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: failed to create plugin temp dir: %v\n", err) + os.Exit(1) + } + defer os.RemoveAll(pluginDir) + + moduleRoot, err = findModuleRoot() + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: failed to locate module root: %v\n", err) + os.Exit(1) + } + + fmt.Printf("=== Building plugins (first run may take 60-90s) ===\n") + for _, p := range pluginsToBuild { + outPath := filepath.Join(pluginDir, p.id+".so") + srcPath := filepath.Join(moduleRoot, p.src) + fmt.Printf(" compiling %s.so ...\n", p.id) + cmd := exec.Command("go", "build", "-buildmode=plugin", "-o", outPath, srcPath) + cmd.Dir = moduleRoot + if out, buildErr := cmd.CombinedOutput(); buildErr != nil { + fmt.Fprintf(os.Stderr, "ERROR: failed to build plugin %s:\n%s\n", p.id, string(out)) + os.Exit(1) + } + } + fmt.Printf("=== All plugins compiled successfully ===\n\n") + + // ── Step 2: Start miniredis ─────────────────────────────────────────────── + miniRedis, err = miniredis.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: failed to start miniredis: %v\n", err) + os.Exit(1) + } + defer miniRedis.Close() + + // ── Step 3: Start mock servers ──────────────────────────────────────────── + mockBPP = startMockBPP() + defer mockBPP.Close() + + mockRegistry = startMockRegistry() + defer mockRegistry.Close() + + // ── Step 4: Start adapter ───────────────────────────────────────────────── + adapterServer, err = startAdapter(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: failed to start adapter: %v\n", err) + os.Exit(1) + } + defer adapterServer.Close() + + // ── Step 5: Run benchmarks ──────────────────────────────────────────────── + // Silence the adapter's zerolog output for the duration of the benchmark + // run. Without this, every HTTP request the adapter processes emits a JSON + // log line to stdout, which interleaves with Go's benchmark result lines + // (BenchmarkFoo-N\t\t\t) and makes benchstat unparseable. + // Setup logging above still ran normally; zerolog.Disabled is set only here, + // just before m.Run(), so errors during startup remain visible. + zerolog.SetGlobalLevel(zerolog.Disabled) + os.Exit(m.Run()) +} + +// findModuleRoot walks up from the current directory to find the go.mod root. +func findModuleRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("go.mod not found from %s", dir) + } + dir = parent + } +} + +// writeRoutingConfig reads the benchmark routing config template, replaces the +// BENCH_BPP_URL placeholder with the live mock BPP server URL, and writes the +// result to a temp file. Returns the path to the temp file. +func writeRoutingConfig(bppURL string) (string, error) { + templatePath := filepath.Join("testdata", "routing-BAPCaller.yaml") + data, err := os.ReadFile(templatePath) + if err != nil { + return "", fmt.Errorf("reading routing config template: %w", err) + } + content := strings.ReplaceAll(string(data), "BENCH_BPP_URL", bppURL) + f, err := os.CreateTemp("", "bench-routing-*.yaml") + if err != nil { + return "", fmt.Errorf("creating temp routing config: %w", err) + } + if _, err := f.WriteString(content); err != nil { + f.Close() + return "", fmt.Errorf("writing routing config: %w", err) + } + f.Close() + return f.Name(), nil +} + +// startAdapter constructs a fully wired adapter using the compiled plugins and +// returns it as an *httptest.Server. All external dependencies are replaced with +// local mock servers: Redis → miniredis, BPP → mockBPP, registry → mockRegistry. +func startAdapter(ctx context.Context) (*httptest.Server, error) { + routingConfigPath, err := writeRoutingConfig(mockBPP.URL) + if err != nil { + return nil, fmt.Errorf("writing routing config: %w", err) + } + + // Plugin manager: load all compiled .so files from pluginDir. + mgr, closer, err := plugin.NewManager(ctx, &plugin.ManagerConfig{ + Root: pluginDir, + }) + if err != nil { + return nil, fmt.Errorf("creating plugin manager: %w", err) + } + _ = closer // closer is called when the server shuts down; deferred in TestMain via server.Close + + // Build module configurations. + mCfgs := []module.Config{ + buildBAPCallerConfig(routingConfigPath, mockRegistry.URL), + } + + mux := http.NewServeMux() + if err := module.Register(ctx, mCfgs, mux, mgr); err != nil { + return nil, fmt.Errorf("registering modules: %w", err) + } + + srv := httptest.NewServer(mux) + return srv, nil +} + +// buildBAPCallerConfig returns the module.Config for the bapTxnCaller handler, +// mirroring config/local-retail-bap.yaml but pointing at benchmark mock services. +// registryURL must point at the mock registry so simplekeymanager can satisfy the +// Registry requirement imposed by stdHandler — even though the caller path never +// performs signature validation, the handler wiring requires it to be present. +func buildBAPCallerConfig(routingConfigPath, registryURL string) module.Config { + return module.Config{ + Name: "bapTxnCaller", + Path: "/bap/caller/", + Handler: handler.Config{ + Type: handler.HandlerTypeStd, + Role: model.RoleBAP, + SubscriberID: benchSubscriberID, + HttpClientConfig: handler.HttpClientConfig{ + MaxIdleConns: 1000, + MaxIdleConnsPerHost: 200, + IdleConnTimeout: 300 * time.Second, + ResponseHeaderTimeout: 5 * time.Second, + }, + Plugins: handler.PluginCfg{ + // Registry is required by stdHandler before it will wire KeyManager, + // even on the caller path where sign-validation never runs. We point + // it at the mock registry (retry_max=0 so failures are immediate). + Registry: &plugin.Config{ + ID: "registry", + Config: map[string]string{ + "url": registryURL, + "retry_max": "0", + }, + }, + KeyManager: &plugin.Config{ + ID: "simplekeymanager", + Config: map[string]string{ + "networkParticipant": benchSubscriberID, + "keyId": benchKeyID, + "signingPrivateKey": benchPrivKey, + "signingPublicKey": benchPubKey, + "encrPrivateKey": benchEncrPrivKey, + "encrPublicKey": benchEncrPubKey, + }, + }, + SchemaValidator: &plugin.Config{ + ID: "schemav2validator", + Config: map[string]string{ + "type": "file", + "location": filepath.Join(moduleRoot, "benchmarks/e2e/testdata/beckn.yaml"), + "cacheTTL": "3600", + }, + }, + Cache: &plugin.Config{ + ID: "cache", + Config: map[string]string{ + "addr": miniRedis.Addr(), + }, + }, + Router: &plugin.Config{ + ID: "router", + Config: map[string]string{ + "routingConfig": routingConfigPath, + }, + }, + Signer: &plugin.Config{ + ID: "signer", + }, + }, + Steps: []string{"addRoute", "sign", "validateSchema"}, + }, + } +} + +// ── T7: Request builder and Beckn signing helper ────────────────────────────── + +// becknPayloadTemplate holds the raw JSON for a fixture file with sentinels. +var fixtureCache = map[string][]byte{} + +// loadFixture reads a fixture file from testdata/ and caches it. +func loadFixture(action string) ([]byte, error) { + if data, ok := fixtureCache[action]; ok { + return data, nil + } + path := filepath.Join("testdata", action+"_request.json") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("loading fixture %s: %w", action, err) + } + fixtureCache[action] = data + return data, nil +} + +// buildSignedRequest reads the fixture for the given action, substitutes +// BENCH_TIMESTAMP / BENCH_MESSAGE_ID / BENCH_TRANSACTION_ID with fresh values, +// signs the body using the Beckn Ed25519 spec, and returns a ready-to-send +// *http.Request targeting the adapter's /bap/caller/ path. +func buildSignedRequest(tb testing.TB, action string) *http.Request { + tb.Helper() + + fixture, err := loadFixture(action) + if err != nil { + tb.Fatalf("buildSignedRequest: %v", err) + } + + // Substitute sentinels with fresh values for this iteration. + now := time.Now().UTC().Format(time.RFC3339) + msgID := uuid.New().String() + txnID := uuid.New().String() + + body := bytes.ReplaceAll(fixture, []byte("BENCH_TIMESTAMP"), []byte(now)) + body = bytes.ReplaceAll(body, []byte("BENCH_MESSAGE_ID"), []byte(msgID)) + body = bytes.ReplaceAll(body, []byte("BENCH_TRANSACTION_ID"), []byte(txnID)) + + // Sign the body per the Beckn Ed25519 spec. + authHeader, err := signBecknPayload(body) + if err != nil { + tb.Fatalf("buildSignedRequest: signing failed: %v", err) + } + + url := adapterServer.URL + "/bap/caller/" + action + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + tb.Fatalf("buildSignedRequest: http.NewRequest: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(model.AuthHeaderSubscriber, authHeader) + + return req +} + +// buildSignedRequestFixed builds a signed request with a fixed body (same +// message_id every call) — used for cache-warm benchmarks. +func buildSignedRequestFixed(tb testing.TB, action string, body []byte) *http.Request { + tb.Helper() + + authHeader, err := signBecknPayload(body) + if err != nil { + tb.Fatalf("buildSignedRequestFixed: signing failed: %v", err) + } + + url := adapterServer.URL + "/bap/caller/" + action + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + tb.Fatalf("buildSignedRequestFixed: http.NewRequest: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(model.AuthHeaderSubscriber, authHeader) + return req +} + +// signBecknPayload signs a request body using the Beckn Ed25519 signing spec +// and returns a formatted Authorization header value. +// +// Beckn signing spec: +// 1. Digest: "BLAKE-512=" + base64(blake2b-512(body)) +// 2. Signing string: "(created): \n(expires): \ndigest: " +// 3. Signature: base64(ed25519.Sign(privKey, signingString)) +// 4. Header: Signature keyId="||ed25519",algorithm="ed25519", +// created="",expires="",headers="(created) (expires) digest", +// signature="" +// +// Reference: pkg/plugin/implementation/signer/signer.go +func signBecknPayload(body []byte) (string, error) { + createdAt := time.Now().Unix() + expiresAt := time.Now().Add(5 * time.Minute).Unix() + + // Step 1: BLAKE-512 digest. + hasher, _ := blake2b.New512(nil) + hasher.Write(body) + digest := "BLAKE-512=" + base64.StdEncoding.EncodeToString(hasher.Sum(nil)) + + // Step 2: Signing string. + signingString := fmt.Sprintf("(created): %d\n(expires): %d\ndigest: %s", createdAt, expiresAt, digest) + + // Step 3: Ed25519 signature. + privKeyBytes, err := base64.StdEncoding.DecodeString(benchPrivKey) + if err != nil { + return "", fmt.Errorf("decoding private key: %w", err) + } + privKey := ed25519.NewKeyFromSeed(privKeyBytes) + sig := base64.StdEncoding.EncodeToString(ed25519.Sign(privKey, []byte(signingString))) + + // Step 4: Format Authorization header (matches generateAuthHeader in step.go). + header := fmt.Sprintf( + `Signature keyId="%s|%s|ed25519",algorithm="ed25519",created="%d",expires="%d",headers="(created) (expires) digest",signature="%s"`, + benchSubscriberID, benchKeyID, createdAt, expiresAt, sig, + ) + return header, nil +} + +// warmFixtureBody returns a fixed body for the given action with stable IDs — +// used to pre-warm the cache so cache-warm benchmarks hit the Redis fast path. +func warmFixtureBody(tb testing.TB, action string) []byte { + tb.Helper() + fixture, err := loadFixture(action) + if err != nil { + tb.Fatalf("warmFixtureBody: %v", err) + } + body := bytes.ReplaceAll(fixture, []byte("BENCH_TIMESTAMP"), []byte("2025-01-01T00:00:00Z")) + body = bytes.ReplaceAll(body, []byte("BENCH_MESSAGE_ID"), []byte("00000000-warm-0000-0000-000000000000")) + body = bytes.ReplaceAll(body, []byte("BENCH_TRANSACTION_ID"), []byte("00000000-warm-txn-0000-000000000000")) + return body +} + +// sendRequest executes an HTTP request using the shared bench client and +// discards the response body. Returns a non-nil error for non-2xx responses. +func sendRequest(req *http.Request) error { + resp, err := benchHTTPClient.Do(req) + if err != nil { + return fmt.Errorf("http do: %w", err) + } + defer resp.Body.Close() + // Drain the body so the connection is returned to the pool for reuse. + // Without this, Go discards the connection after each request, causing + // port exhaustion under parallel load ("can't assign requested address"). + _, _ = io.Copy(io.Discard, resp.Body) + // We accept any 2xx response (ACK or forwarded BPP response). + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + return nil +} + +// ── TestSignBecknPayload: validation test before running benchmarks ─────────── +// Sends a signed discover request to the live adapter and asserts a 200 response, +// confirming the signing helper produces headers accepted by the adapter pipeline. +func TestSignBecknPayload(t *testing.T) { + if adapterServer == nil { + t.Skip("adapterServer not initialised (run via TestMain)") + } + fixture, err := loadFixture("discover") + if err != nil { + t.Fatalf("loading fixture: %v", err) + } + + // Substitute sentinels. + now := time.Now().UTC().Format(time.RFC3339) + body := bytes.ReplaceAll(fixture, []byte("BENCH_TIMESTAMP"), []byte(now)) + body = bytes.ReplaceAll(body, []byte("BENCH_MESSAGE_ID"), []byte(uuid.New().String())) + body = bytes.ReplaceAll(body, []byte("BENCH_TRANSACTION_ID"), []byte(uuid.New().String())) + + authHeader, err := signBecknPayload(body) + if err != nil { + t.Fatalf("signBecknPayload: %v", err) + } + + url := adapterServer.URL + "/bap/caller/discover" + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + t.Fatalf("http.NewRequest: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(model.AuthHeaderSubscriber, authHeader) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("sending request: %v", err) + } + defer resp.Body.Close() + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + t.Logf("Response status: %d, body: %v", resp.StatusCode, result) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200 OK, got %d", resp.StatusCode) + } +} diff --git a/benchmarks/e2e/testdata/beckn.yaml b/benchmarks/e2e/testdata/beckn.yaml new file mode 100644 index 0000000..dc7f473 --- /dev/null +++ b/benchmarks/e2e/testdata/beckn.yaml @@ -0,0 +1,3380 @@ +openapi: 3.1.1 +info: + title: Beckn Protocol API + description: | + The Beckn Protocol API Specification v2.0 — a concrete, developer-friendly OpenAPI 3.1.1 + specification with one named path per Beckn protocol action. + + ## Design Philosophy + - **Minimalism**: Transport-layer schemas are inline. All data-model schemas + (Catalog, Contract, Intent, etc.) are resolved from #/components/schemas. + - **Pragmatism**: Every transaction endpoint carries both `order` (legacy, backward-compatible) + and `contract` (new, generalized) in its message envelope via `oneOf`. + - **Backward compatibility**: Implementations conformant with previous versions interoperate + seamlessly with new implementations using the `contract` model. + + ## Typical Transaction Lifecycle + ``` + discover → on_discover + select → on_select → init → on_init → confirm → on_confirm + → [status / on_status]* → [update / on_update]* → [cancel / on_cancel]? + → [track / on_track]* → rate / on_rate → support / on_support + ``` + + ## (NEW) `context.try` — Sandbox Mode + The `try` flag on the `Context` object (defined in `#/components/schemas/Context`) + indicates a sandbox mode being enabled on the network participant: + - `context.try: true` — Request to test an endpoint without liabilities + **before** committing. The receiver responds with expected consequences without changing state. + - `context.try: false` (default) — Commit the action. + This pattern is applicable to `update`, `cancel`, `rate`, and `support` actions. + + ## Authentication + All requests MUST carry a valid Beckn HTTP Signature in the `Authorization` header. + The `Ack` response body MUST carry a `CounterSignature` proving receipt. + See `docs/10_Signing_Beckn_APIs_in_HTTP.md` for the signing specification. + + ## Compatibility Scope + This specification is designed for interoperability across ALL value-exchange use cases: + retail, mobility, logistics, energy, healthcare, skilling, financial services, + carbon trading, hiring, data licensing, and any other domain built on Beckn. + version: 2.0.0 + contact: + name: Beckn Protocol + url: https://beckn.io + license: + name: CC-BY-NC-SA 4.0 International + url: https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en + +tags: + - name: Discovery + description: Discover catalogs across a Beckn network + - name: Transaction + description: Select, negotiate, confirm and manage contracts/orders + - name: Fulfillment + description: Track, update and cancel active contracts/orders + - name: Post-Fulfillment + description: Rate and support completed contracts/orders + - name: Catalog Publishing + description: Publish catalogs to a Catalog Discovery Service (CDS) + +# All requests MUST carry a valid Beckn HTTP Signature in the Authorization header. +# The securityScheme is informational; actual enforcement is done at the network layer. +security: [] + +paths: + + # ───────────────────────────────────────────────────────────────────── + # DISCOVERY + # ───────────────────────────────────────────────────────────────────── + + /discover: + post: + summary: Discover catalogs on a network + description: | + Search and discover catalogs on an open network. Supports text search (textSearch), + JSONPath-based filtering (RFC 9535), spatial (geo) constraints, and media search. + Returns an **ACK** confirming receipt; actual catalogs arrive via `/beckn/on_discover`. + Can be implemented by Catalog Discovery Services (CDS) and BPPs. + operationId: discover + tags: [Discovery] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' # TODO: fetch this from schema.beckn.io/Context/v2.0 + - type: object + properties: + action: + type: string + const: discover + message: + $ref: '#/components/schemas/DiscoverAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '409': + $ref: '#/components/responses/AckNoCallback' + '500': + $ref: '#/components/responses/ServerError' + + /on_discover: + post: + summary: Callback — return catalogs to the BAP + description: | + Callback from a BPP or CDS containing catalogs matching the discovery request. + Returns an **ACK** confirming receipt of the catalog payload. + operationId: onDiscover + tags: [Discovery] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + type: string + const: on_discover + message: + $ref: '#/components/schemas/OnDiscoverAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '500': + $ref: '#/components/responses/ServerError' + + # ───────────────────────────────────────────────────────────────────── + # TRANSACTION — ORDERING + # ───────────────────────────────────────────────────────────────────── + + /select: + post: + summary: BAP selects items/resources and requests a quote + description: | + The BAP selects catalog items, item quantities, associated offers, and requests + the BPP to provide a priced quote. No PII is shared at this stage. + Supports both the legacy `order` and the new `contract` model. + operationId: select + tags: [Transaction] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + type: string + const: select + message: + $ref: '#/components/schemas/SelectAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '409': + $ref: '#/components/responses/AckNoCallback' + '500': + $ref: '#/components/responses/ServerError' + + /on_select: + post: + summary: BPP returns a priced quote + description: | + The BPP calculates the total cost, evaluates offers, and returns a priced quote. + Supports both the legacy `order` model and the new `contract` model. + operationId: onSelect + tags: [Transaction] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + type: string + const: on_select + message: + $ref: '#/components/schemas/OnSelectAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '500': + $ref: '#/components/responses/ServerError' + + /init: + post: + summary: BAP initiates final order — submits billing, fulfillment and payment intent + description: | + BAP provides billing, fulfillment, and payment information for the BPP to generate + final order terms. The BPP responds via `on_init` with the final draft including + payment terms. Supports both `order` (legacy) and `contract` (new) models. + operationId: init + tags: [Transaction] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + type: string + const: init + message: + $ref: '#/components/schemas/InitAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '409': + $ref: '#/components/responses/AckNoCallback' + '500': + $ref: '#/components/responses/ServerError' + + /on_init: + post: + summary: BPP returns final draft order/contract with payment terms + description: | + BPP returns the final draft order or contract with personalized payment terms. + The BPP MUST NOT request payment before this stage. + operationId: onInit + tags: [Transaction] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + type: string + const: on_init + message: + $ref: '#/components/schemas/OnInitAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '500': + $ref: '#/components/responses/ServerError' + + /confirm: + post: + summary: BAP confirms the order/contract + description: | + BAP accepts all terms and provides payment proof/reference, then requests the BPP + to confirm the order or contract into a binding commitment. + operationId: confirm + tags: [Transaction] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + type: string + const: confirm + message: + $ref: '#/components/schemas/ConfirmAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '409': + $ref: '#/components/responses/AckNoCallback' + '500': + $ref: '#/components/responses/ServerError' + + /on_confirm: + post: + summary: BPP returns the confirmed order/contract + description: | + BPP validates all terms, payment, and returns a confirmed order or contract with + its assigned ID and the initial fulfillment/performance state. + operationId: onConfirm + tags: [Transaction] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + type: string + const: on_confirm + message: + $ref: '#/components/schemas/OnConfirmAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '500': + $ref: '#/components/responses/ServerError' + + # ───────────────────────────────────────────────────────────────────── + # FULFILLMENT + # ───────────────────────────────────────────────────────────────────── + + /status: + post: + summary: BAP requests the current state of an order/contract + description: | + BAP requests the latest state of an active order or contract by providing its ID. + The BPP responds via `on_status`. BAPs SHOULD NOT long-poll with `status` — + use `on_status` push notifications for significant state changes. + operationId: status + tags: [Fulfillment] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + type: string + const: status + message: + $ref: '#/components/schemas/StatusAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '409': + $ref: '#/components/responses/AckNoCallback' + '500': + $ref: '#/components/responses/ServerError' + + /on_status: + post: + summary: BPP returns the current state of the order/contract + description: | + BPP returns the latest state of the order or contract including fulfillment/performance + updates. The BPP MAY call this endpoint proactively without the BAP requesting it, + but MUST have received at least one `status` request first to initiate the push stream. + This endpoint is for significant state changes worth a notification — NOT for + real-time location tracking (use `track`/`on_track` for that). + operationId: onStatus + tags: [Fulfillment] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + type: string + const: on_status + message: + $ref: '#/components/schemas/OnStatusAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '500': + $ref: '#/components/responses/ServerError' + + /track: + post: + summary: BAP requests a real-time tracking handle for an active order/contract + description: | + BAP requests a tracking handle (URL, WebSocket endpoint, or webhook) for real-time + tracking of an active fulfillment or performance stage. This is for real-time + position/progress updates — NOT for order state change notifications (use `status`). + operationId: track + tags: [Fulfillment] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + type: string + const: track + message: + $ref: '#/components/schemas/TrackAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '409': + $ref: '#/components/responses/AckNoCallback' + '500': + $ref: '#/components/responses/ServerError' + + /on_track: + post: + summary: BPP returns real-time tracking handle and status + operationId: onTrack + tags: [Fulfillment] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + type: string + const: on_track + message: + $ref: '#/components/schemas/OnTrackAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '500': + $ref: '#/components/responses/ServerError' + + /update: + post: + summary: BAP requests a mutation to an active order/contract + description: | + BAP requests the BPP to update an active order or contract. Updates may involve + change of items, quantities, billing, fulfillment/performance, or payment. + + Uses the `context.try` two-phase pattern (defined in `Context` schema): + - `context.try: true` — BAP requests updated terms before committing (preview mode). + BPP responds with recalculated quote and terms via `on_update` without changing state. + - `context.try: false` (default) — BAP commits the update. + BPP responds with the confirmed updated order/contract. + operationId: update + tags: [Fulfillment] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + $ref: '#/components/schemas/Context' + message: + $ref: '#/components/schemas/UpdateAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '409': + $ref: '#/components/responses/AckNoCallback' + '500': + $ref: '#/components/responses/ServerError' + + /on_update: + post: + summary: BPP returns the updated order/contract + description: | + BPP returns the updated order or contract with recalculated terms. Can be called + proactively by the BPP without the BAP requesting it. + - If `context.try: true` — returns updated terms (preview; state not changed). + - If `context.try: false` — returns committed updated order/contract. + operationId: onUpdate + tags: [Fulfillment] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + $ref: '#/components/schemas/Context' + message: + $ref: '#/components/schemas/OnUpdateAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '500': + $ref: '#/components/responses/ServerError' + + /cancel: + post: + summary: BAP requests cancellation of an order/contract + description: | + BAP requests full or partial cancellation. Uses the `context.try` two-phase pattern: + - `context.try: true` — BAP requests cancellation terms (e.g., refund policy, penalty) + without committing to cancellation. BPP responds with applicable policy via `on_cancel`. + - `context.try: false` (default) — BAP commits the cancellation. + BPP confirms cancellation and returns cancelled order/contract. + operationId: cancel + tags: [Fulfillment] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + $ref: '#/components/schemas/Context' + message: + $ref: '#/components/schemas/CancelAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '409': + $ref: '#/components/responses/AckNoCallback' + '500': + $ref: '#/components/responses/ServerError' + + /on_cancel: + post: + summary: BPP returns cancellation terms or confirms cancellation + description: | + - If `context.try: true` — returns applicable cancellation policy, fees, refund timeline + (state not changed). + - If `context.try: false` — returns confirmed cancelled order/contract with refund/payment update. + operationId: onCancel + tags: [Fulfillment] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + $ref: '#/components/schemas/Context' + message: + $ref: '#/components/schemas/OnCancelAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '500': + $ref: '#/components/responses/ServerError' + + # ───────────────────────────────────────────────────────────────────── + # POST-FULFILLMENT + # ───────────────────────────────────────────────────────────────────── + + /rate: + post: + summary: BAP submits ratings for one or more entities + description: | + BAP submits ratings for any ratable entity (order, contract, fulfillment, + item, provider, agent, support interaction). Supports the `context.try` pattern: + - `context.try: true` — Preview: BPP returns the rating form/criteria without recording. + - `context.try: false` (default) — Submit: BPP records the rating and returns confirmation. + The BPP acknowledges via `on_rate` with an optional aggregate snapshot. + operationId: rate + tags: [Post-Fulfillment] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + $ref: '#/components/schemas/Context' + message: + $ref: '#/components/schemas/RateAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '409': + $ref: '#/components/responses/AckNoCallback' + '500': + $ref: '#/components/responses/ServerError' + + /on_rate: + post: + summary: BPP acknowledges rating and optionally returns aggregate + description: | + - If `context.try: true` — returns rating form/criteria (preview mode). + - If `context.try: false` — confirms rating was recorded; optionally returns aggregate. + operationId: onRate + tags: [Post-Fulfillment] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + $ref: '#/components/schemas/Context' + message: + $ref: '#/components/schemas/OnRateAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '500': + $ref: '#/components/responses/ServerError' + + /support: + post: + summary: BAP requests support information for an entity + description: | + BAP requests support channels and/or raises a support ticket for any entity. + Uses the `context.try` pattern: + - `context.try: true` — Preview: BPP returns available support channels without creating a ticket. + - `context.try: false` (default) — BAP submits a support request; BPP creates/links a ticket. + The `refType` field abstracts over domain-specific entity types (works for contracts, + resources, providers, agents — not just orders). + operationId: support + tags: [Post-Fulfillment] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + $ref: '#/components/schemas/Context' + message: + $ref: '#/components/schemas/SupportAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '409': + $ref: '#/components/responses/AckNoCallback' + '500': + $ref: '#/components/responses/ServerError' + + /on_support: + post: + summary: BPP returns support details + description: | + - If `context.try: true` — returns available support channels (preview mode). + - If `context.try: false` — returns support details with ticket(s). + operationId: onSupport + tags: [Post-Fulfillment] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + $ref: '#/components/schemas/Context' + message: + $ref: '#/components/schemas/OnSupportAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '500': + $ref: '#/components/responses/ServerError' + + # ───────────────────────────────────────────────────────────────────── + # CATALOG PUBLISHING SERVICE + # ───────────────────────────────────────────────────────────────────── + + /catalog/publish: + post: + summary: BPP publishes catalogs for indexing by a CDS + description: | + BPP submits one or more catalogs to be indexed by a Catalog Discovery Service (CDS). + Returns an **ACK** immediately if accepted for processing. The per-catalog processing + result arrives asynchronously via `/beckn/catalog/on_publish`. + operationId: catalogPublish + tags: [Catalog Publishing] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + type: string + const: catalog/publish + message: + $ref: '#/components/schemas/CatalogPublishAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '409': + $ref: '#/components/responses/AckNoCallback' + '500': + $ref: '#/components/responses/ServerError' + + /catalog/on_publish: + post: + summary: CDS returns per-catalog processing results to the BPP + operationId: catalogOnPublish + tags: [Catalog Publishing] + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [context, message] + properties: + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + type: string + const: catalog/on_publish + message: + $ref: '#/components/schemas/CatalogPublishAction' + responses: + '200': + $ref: '#/components/responses/Ack' + '400': + $ref: '#/components/responses/NackBadRequest' + '401': + $ref: '#/components/responses/NackUnauthorized' + '500': + $ref: '#/components/responses/ServerError' + +# ───────────────────────────────────────────────────────────────────── +# COMPONENTS +# ───────────────────────────────────────────────────────────────────── +components: + + # ─── Parameters ────────────────────────────────────────────────── + parameters: + AuthorizationHeader: + name: Authorization + in: header + required: true + description: | + Beckn HTTP Signature authentication header. + See `#/components/schemas/Signature` for the expected format. + schema: + $ref: '#/components/schemas/Signature' + + # ─── Message Schemas ────────────────────────────────────────────── + + schemas: + # ───────────────────────────────────────────────────────────────────── + # ACTION SCHEMAS — xAction and On-xAction message payloads + # ───────────────────────────────────────────────────────────────────── + CancelAction: + $id: https://schema.beckn.io/CancelAction/v2.0 + title: Cancel Action + description: | + Beckn /beckn/cancel message payload. Sent by a BAP to a BPP to request + cancellation of an active contract. Set context.try to true to first + retrieve cancellation terms and fees before committing. + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + required: [order] + properties: + order: + type: object + - type: object + required: [contract] + properties: + contract: + $ref: '#/components/schemas/Contract' + + CatalogPublishAction: + $id: https://schema.beckn.io/CatalogPublishAction/v2.0 + description: Catalog publish request payload. + type: object + required: [catalogs] + properties: + catalogPublishAttributes: + type: object + description: Domain-specific extension attributes for this catalog publish action. + properties: + '@context': + type: array + items: + type: string + format: uri + uniqueItems: true + contains: + const: 'https://schema.beckn.io/CatalogPublishAction/v2.0/context.jsonld' + minItems: 1 + '@type': + type: array + items: + type: string + format: uri + uniqueItems: true + contains: + const: 'beckn:CatalogPublishAction' + minItems: 1 + catalogs: + type: array + minItems: 1 + description: One or more catalogs to be indexed by the CDS + items: + $ref: '#/components/schemas/Catalog' + + OnCatalogPublishAction: + $id: https://schema.beckn.io/CatalogPublishResponse/v2.0 + type: object + required: [context, message] + properties: + onCatalogPublishAttributes: + type: object + description: Domain-specific extension attributes for this on_catalog_publish action. + properties: + '@context': + type: array + items: + type: string + format: uri + uniqueItems: true + contains: + const: 'https://schema.beckn.io/OnCatalogPublishAction/v2.0/context.jsonld' + minItems: 1 + '@type': + type: array + items: + type: string + format: uri + uniqueItems: true + contains: + const: 'beckn:OnCatalogPublishAction' + minItems: 1 + context: + allOf: + - $ref: '#/components/schemas/Context' + - type: object + properties: + action: + const: 'on_catalog_publish' + + message: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/CatalogProcessingResult' + + ConfirmAction: + $id: https://schema.beckn.io/ConfirmAction/v2.0 + title: Confirm Action + description: | + Beckn /beckn/confirm message payload. Sent by a BAP to a BPP to confirm + a contract, finalising the transaction terms agreed during the + select-init negotiation cycle. + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + required: [order] + properties: + order: + type: object + - type: object + required: [contract] + properties: + contract: + $ref: '#/components/schemas/Contract' + + DiscoverAction: + $id: https://schema.beckn.io/DiscoverAction/v2.0 + title: Discover Action (External) + description: | + Beckn /beckn/discover message payload as published at schema.beckn.io. + Supports two forms: intent properties directly at the top level (flat), + or all intent properties nested inside an `intent` container object. + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - description: Flat form — Intent properties directly at top level (no intent container) + $ref: '#/components/schemas/Intent' + - description: Nested form — Intent properties inside an intent container object + type: object + required: [intent] + properties: + intent: + $ref: '#/components/schemas/Intent' + + InitAction: + $id: https://schema.beckn.io/InitAction/v2.0 + title: Init Action + description: | + Beckn /beckn/init message payload. Sent by a BAP to a BPP to initialise + a contract with consumer details (billing address, fulfillment preferences, etc.). + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + required: [order] + properties: + order: + type: object + - type: object + required: [contract] + properties: + contract: + $ref: '#/components/schemas/Contract' + + + OnCancelAction: + $id: https://schema.beckn.io/OnCancelAction/v2.0 + title: On Cancel Action + description: | + Beckn /beckn/on_cancel message payload. Sent by a BPP to a BAP in + response to a /beckn/cancel call, returning the contract with status + set to CANCELLED and any applicable cancellation outcome. + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + required: [order] + properties: + order: + type: object + - type: object + required: [contract] + properties: + contract: + $ref: '#/components/schemas/Contract' + + OnConfirmAction: + $id: https://schema.beckn.io/OnConfirmAction/v2.0 + title: OnConfirmAction + description: | + Beckn /beckn/on_confirm message payload. Sent by a BPP to a BAP in + response to a /beckn/confirm call, returning the confirmed contract + with status set to CONFIRMED. + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + required: [order] + properties: + order: + type: object + - type: object + required: [contract] + properties: + contract: + $ref: '#/components/schemas/Contract' + + OnDiscoverAction: + $id: https://schema.beckn.io/OnDiscoverAction/v2.0 + description: The on_discover response payload containing matching catalogs. + type: object + required: [catalogs] + properties: + catalogs: + type: array + description: | + Array of catalogs matching the discovery request. + Note: Each catalog's `items` array carries `oneOf [Item, Resource]` entries: + - `Item` — legacy 2.0-rc1 catalog item (backward compatible) + - `Resource` — new cross-domain resource abstraction (generalized) + Both types share the common `id` + `descriptor` surface for display rendering. + See `#/components/schemas/Catalog/v2.0` for the full Catalog schema. + items: + $ref: '#/components/schemas/Catalog' + + OnInitAction: + $id: https://schema.beckn.io/OnInitAction/v2.0 + title: On Init Action + description: | + Beckn /beckn/on_init message payload. Sent by a BPP to a BAP in response + to a /beckn/init call, with the updated contract including payment terms + and billing confirmation. + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + required: [order] + properties: + order: + type: object + - type: object + required: [contract] + properties: + contract: + $ref: '#/components/schemas/Contract' + + OnRateAction: + $id: https://schema.beckn.io/OnRateAction/v2.0 + title: On Rate Action + description: | + Beckn /beckn/on_rate message payload. Sent by a BPP to a BAP in + response to a /beckn/rate call, optionally returning rating forms + to collect structured feedback from the consumer. + (Context wrapper stripped; only the message-content portion is inlined.) + type: object + additionalProperties: true + properties: + ratingForms: + type: array + items: + $ref: '#/components/schemas/RatingForm' + required: + - ratingForms + + # ── Support Messages ──────────────────────────────────────────── + + OnSelectAction: + $id: https://schema.beckn.io/OnSelectAction/v2.0 + title: On Select Action + description: | + Beckn /beckn/on_select message payload. Sent by a BPP to a BAP in + response to a /beckn/select call, with updated contract terms. + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + - $ref: '#/components/schemas/Contract' + + OnStatusAction: + $id: https://schema.beckn.io/OnStatusAction/v2.0 + title: On Status Action + description: | + Beckn /beckn/on_status message payload. Sent by a BPP to a BAP in + response to a /beckn/status call (or as an unsolicited status push), + returning the current state of the contract. + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + required: [order] + properties: + order: + type: object + - type: object + required: [contract] + properties: + contract: + $ref: '#/components/schemas/Contract' + + OnSupportAction: + $id: https://schema.beckn.io/OnSupportAction/v2.0 + title: On Support Action + description: | + Beckn /beckn/on_support message payload. Sent by a BPP to a BAP in + response to a /beckn/support call, returning support contact details + and available channels. + (Context wrapper stripped; only the message-content portion is inlined.) + type: object + properties: + support: + $ref: '#/components/schemas/Support' + required: + - support + + + # ── Catalog Publishing Messages ───────────────────────────────── + + OnTrackAction: + $id: https://schema.beckn.io/OnTrackAction/v2.0 + title: On Track Action + description: | + Beckn /beckn/on_track message payload. Sent by a BPP to a BAP in + response to a /beckn/track call, returning a Tracking handle with + the URL and/or WebSocket endpoint for real-time fulfillment tracking. + (Context wrapper stripped; only the message-content portion is inlined.) + type: object + additionalProperties: true + properties: + tracking: + allOf: + - $ref: '#/components/schemas/Tracking' + required: + - tracking + + OnUpdateAction: + $id: https://schema.beckn.io/OnUpdateAction/v2.0 + title: On Update Action + description: | + Beckn /beckn/on_update message payload. Sent by a BPP to a BAP in + response to a /beckn/update call (or as an unsolicited update push), + returning the updated contract state. + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + required: [order] + properties: + order: + type: object + - type: object + required: [contract] + properties: + contract: + $ref: '#/components/schemas/Contract' + + + RateAction: + $id: https://schema.beckn.io/RateAction/v2.0 + title: RateAction + description: | + Beckn /beckn/rate message payload. Sent by a BAP to a BPP to submit + one or more rating inputs for entities in a completed contract + (order, fulfillment, item, provider, agent, support). + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + properties: + ratings: + type: array + items: + type: object + - type: object + additionalProperties: true + properties: + ratingInputs: + type: array + items: + $ref: '#/components/schemas/RatingInput' + required: + - ratingInputs + + + SelectAction: + $id: 'https://schema.beckn.io/SelectAction/v2.0' + title: Select Action + description: | + Beckn /beckn/select message payload. Sent by a BAP to a BPP to select + items and offers from a catalog, initiating the negotiation cycle. + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + required: [order] + properties: + order: + type: object + - type: object + required: [contract] + properties: + contract: + $ref: '#/components/schemas/Contract' + + StatusAction: + $id: https://schema.beckn.io/StatusAction/v2.0 + title: Status Action + description: | + Beckn /beckn/status message payload. Sent by a BAP to a BPP to query + the current state of a contract/order by its identifier. + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + description: Query by order identifier (legacy) + required: [order] + properties: + order: + allOf: + - type: object + - required: ['beckn:id'] + - type: object + description: Query by contract identifier + required: [contract] + properties: + contract: + type: object + required: [id] + properties: + id: + $ref: '#/components/schemas/Contract/properties/id' + + SupportAction: + $id: https://schema.beckn.io/SupportAction/v2.0 + title: Support Action + description: | + Beckn /beckn/support message payload. Sent by a BAP to a BPP to request + support contact information or to open a support ticket for an existing + order/contract. + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + - type: object + properties: + support: + $ref: '#/components/schemas/Support' + required: + - support + + + TrackAction: + oneOf: + - type: object + - type: object + title: Track Action + description: | + Beckn /beckn/track message payload. Sent by a BAP to a BPP to request + a real-time tracking handle for a fulfillment within an active contract. + (Context wrapper stripped; only the message-content portion is inlined.) + properties: + tracking: + type: object + properties: + contract: + type: object + properties: + id: + $ref: '#/components/schemas/Contract/properties/id' + trackingData: + type: object + description: Domain-specific extension attributes for this track action. + properties: + '@context': + type: array + items: + type: string + format: uri + uniqueItems: true + contains: + const: 'https://schema.beckn.io/TrackAction/v2.0/context.jsonld' + minItems: 1 + '@type': + type: array + items: + type: string + format: uri + uniqueItems: true + contains: + const: 'beckn:TrackAction' + minItems: 1 + trackingAttributes: + description: Domain-specific extension attributes for this Tracking. + allOf: + - $ref: '#/components/schemas/Attributes' + - required: + - '@context' + - '@type' + - properties: + '@context': + description: Domain-specific context in which this contract is being created + type: string + format: uri + examples: + - 'https://schema.beckn.io/LongPolling/v2.0/context.jsonld' + - 'https://schema.beckn.io/WebhookPush/v2.0/context.jsonld' + - 'https://schema.beckn.io/Streaming/v2.0/context.jsonld' + - 'https://schema.beckn.io/WebPage/v2.0/context.jsonld' + '@type': + description: JSON-LD type corresponding to the context + type: string + examples: + - LongPolling + - WebhookPush + - Streaming + - WebPage + additionalProperties: true + required: + - contract + additionalProperties: false + + + # ── Status Message ────────────────────────────────────────────── + + UpdateAction: + $id: https://schema.beckn.io/UpdateAction/v2.0 + title: Update Action + description: | + Beckn /beckn/update message payload. Sent by a BAP to a BPP to request + changes to an active contract (e.g., update fulfillment address, add items, + change quantities). The context.try flag must be true during negotiation. + (Context wrapper stripped; only the message-content portion is inlined.) + oneOf: + - type: object + required: [order] + properties: + order: + type: object + - type: object + properties: + contract: + $ref: '#/components/schemas/Contract' + + # ─── Responses ──────────────────────────────────────────────────── + + # ───────────────────────────────────────────────────────────────────── + # CORE DATA SCHEMAS — Reusable types, domain objects, and infrastructure + # ───────────────────────────────────────────────────────────────────── + ActionTrigger: + description: Describes a trigger that initiates an action, like triggering a settlement on fulfillment completion. + type: object + properties: + '@context': + type: string + format: uri + '@type': + type: string + descriptor: + $ref: '#/components/schemas/Descriptor' + + AddOn: + type: object + description: Add-on to a catalog resource + properties: + id: + type: string + descriptor: + $ref: '#/components/schemas/Descriptor' + addOnAttributes: + description: > + Domain-specific extension attributes for this add-on. + allOf: + - $ref: '#/components/schemas/Attributes' + - required: + - '@context' + - '@type' + - properties: + '@context': + description: Domain-specific context in which this add-on exists + type: string + format: uri + examples: + - 'https://schema.beckn.io/FnbAddOn/v2.0/context.jsonld' + - 'https://schema.beckn.io/GroceryAddOn/v2.0/context.jsonld' + - 'https://schema.beckn.io/RideAddOn/v2.0/context.jsonld' + '@type': + description: JSON-LD type corresponding to the context + type: string + examples: + - FnbAddOn + - GroceryAddOn + - RideAddOn + Address: + $id: https://schema.beckn.io/Address/v2.0 + description: '**Postal address** aligned with schema.org `PostalAddress`. Use for human-readable addresses. + Geometry lives in `Location.geo` as GeoJSON.' + title: Address + type: object + properties: + addressCountry: + description: Country name or ISO-3166-1 alpha-2 code. + type: string + example: IN + addressLocality: + description: City/locality. + type: string + example: Bengaluru + addressRegion: + description: State/region/province. + type: string + example: Karnataka + extendedAddress: + description: Address extension (apt/suite/floor, C/O). + type: string + example: Apt 4B + postalCode: + description: Postal/ZIP code. + type: string + example: '560001' + streetAddress: + description: Street address (building name/number and street). + type: string + example: 123 Tech Street + additionalProperties: false + x-tags: + - common + + AsyncError: + title: Asynchronous Error + description: | + Error returned asynchronously during a callback. Wraps the base `Error` schema with JSON-LD type annotations to allow linked-data processing. + allOf: + - $ref: '#/components/schemas/Error' + - type: object + properties: + '@context': + type: string + default: '#/components/schemas/' + '@type': + type: string + default: AsyncError + additionalProperties: true + + Attributes: + $id: 'https://schema.beckn.io/Attributes/v2.0' + description: JSON-LD aware container for domain-specific attributes of an Item. MUST include @context + (URI) and @type (compact or full IRI). Any additional properties are allowed and interpreted per the + provided JSON-LD context. + title: Attributes + type: object + properties: + '@context': + description: Use case specific JSON-LD context URI + type: string + format: uri + '@type': + description: JSON-LD type defined within the context + type: string + required: + - '@context' + - '@type' + additionalProperties: true + x-tags: + - common + + Catalog: + $id: https://schema.beckn.io/Catalog/v2.1 + $schema: "https://json-schema.org/draft/2020-12/schema" + description: Catalog schema for Beckn Protocol v2.0.1 — oneOf legacy (v2.0) or new (v2.1) catalog format + title: Catalog + x-tags: [common] + type: object + properties: + bppId: + description: BPP (Beckn Protocol Provider) identifier that publishes this catalog + type: string + example: bpp.example.com + bppUri: + description: BPP (Beckn Protocol Provider) URI endpoint + type: string + format: uri + example: "https://bpp.example.com" + descriptor: + description: Human / Agent-readable description of this catalog + allOf: + - $ref: "#/components/schemas/Descriptor" + id: + description: Unique identifier for the catalog + type: string + example: catalog-electronics-001 + isActive: + description: Whether the catalog is active + type: boolean + default: true + offers: + type: array + items: + $ref: "#/components/schemas/Offer" + resources: + description: Array of generalized Resource entities in this catalog (new model) + type: array + items: + $ref: '#/components/schemas/Resource' + provider: + description: The provider of the catalog + allOf: + - $ref: "#/components/schemas/Provider" + visibleTo: + type: array + description: > + Optional visibility constraint indicating which network participants + (by participantId / networkId / role) are allowed to discover or + transact on this catalog. + + If omitted, the catalog is assumed to be visible to all participants + in the addressed network(s). + items: + type: object + required: [type, id] + properties: + type: + type: string + enum: [NETWORK, PARTICIPANT, ROLE] + description: Scope of visibility constraint. + id: + type: string + description: Identifier of the network, participant, or role. + validity: + description: The time period during which this catalog is valid + $ref: "#/components/schemas/TimePeriod" + required: + - id + - descriptor + additionalProperties: false + + CatalogOnPublishMessage: + $id: 'https:schema.beckn.io/CatalogOnPublishMessage/v2.0' + description: Catalog publish processing results from CDS to BPP. + type: object + required: [results] + properties: + results: + type: array + description: Per-catalog processing results + items: + $ref: '#/components/schemas/CatalogProcessingResult' + + CatalogProcessingResult: + $id: 'https://schema.beckn.io/CatalogProcessingResult/v2.0' + description: Processing result for a single catalog submission. + type: object + required: [catalogId, status] + properties: + catalogId: + type: string + description: Identifier of the submitted catalog + status: + description: | + Processing outcome. Using oneOf [string, object] to allow domain-specific + status objects (e.g. beckn:CatalogAccepted) alongside standard string codes. + oneOf: + - type: string + enum: [ACCEPTED, REJECTED, PARTIAL] + description: | + Standard processing status: + - ACCEPTED — catalog indexed successfully + - REJECTED — catalog rejected entirely (see errors) + - PARTIAL — some items accepted, some rejected (see errors) + - type: object + properties: + '@context': + type: string + format: uri + const: '#/components/schemas/' + '@type': + type: string + pattern: '^beckn:[A-Za-z0-9._~-]+$' + additionalProperties: true + errors: + type: array + description: Per-item or per-catalog errors (present when REJECTED or PARTIAL) + items: + $ref: '#/components/schemas/Error' + stats: + type: object + description: Optional statistics about the processed catalog + properties: + itemCount: + type: integer + description: Number of items accepted + providerCount: + type: integer + description: Number of providers in the catalog + categoryCount: + type: integer + description: Number of distinct categories + + Consideration: + $id: https://schema.beckn.io/Consideration/v2.0 + type: object + description: > + Generalized representation of value exchanged under a Contract. + + Consideration is domain-neutral and may represent: + - Monetary value + - Credits / tokens + - Asset transfer + - Service exchange + - Compliance artifact + required: ["@context", "@type", "id", "status"] + properties: + "@context": + type: string + format: uri + description: JSON-LD context URI for the core Resource schema + "@type": + oneOf: + - type: string + description: Type of the core Resource + enum: ["beckn:Consideration", "beckn:MonetaryConsideration", "beckn:TokenConsideration", "beckn:CreditConsideration", "beckn:AssetConsideration", "beckn:ServiceConsideration"] + - type: string + id: + description: Identifier of this consideration + type: string + status: + description: The status of this consideration + $ref: '#/components/schemas/Descriptor' + considerationAttributes: + description: > + Domain-specific attributes of this consideration. For monetary considerations, + use the PriceSpecification schema to capture total value with breakup. + For other consideration types, use a generic Attributes bag. + $ref: '#/components/schemas/Attributes' + + Context: + $id: "#/components/schemas/Context" + description: >- + Every API call in Beckn protocol has a context. It contains addressing information like the bap/bpp identifiers and API base urls, the protocol version implemented by the sender, the method being called, the transaction_id that represents an end-to-end user session at the BAP, a message ID to pair requests with callbacks, a timestamp to capture sending times, a ttl to specify the validity of the request, and a key to encrypt information if necessary. + title: Context + x-tags: [common] + type: object + properties: + action: + description: The Beckn endpoint being called. Must conform to BecknEndpoint format. + type: string + bapId: + description: A globally unique identifier of the BAP. Typically the fully qualified domain name (FQDN) of the BAP. + type: string + bapUri: + description: API URL of the BAP for accepting callbacks from BPPs. + type: string + format: uri + bppId: + description: A globally unique identifier of the BPP. Typically the fully qualified domain name (FQDN) of the BPP. + type: string + bppUri: + description: API URL of the BPP for accepting calls from BAPs. + type: string + format: uri + messageId: + description: >- + A unique value which persists during a request/callback cycle. BAPs use + this value to match an incoming callback from a BPP to an earlier call. + Generate a fresh message_id for every new interaction. + type: string + format: uuid + networkId: + description: A unique identifier representing a group of platforms. By default, the URL of the network registry on the Beckn network. + type: string + timestamp: + description: Time of request generation in RFC3339 format. + type: string + format: date-time + transactionId: + description: >- + A unique value which persists across all API calls from discover through + confirm. Used to indicate an active user session across multiple requests. + type: string + format: uuid + try: + description: A flag to indicate that this request is intended to try an operation in sandbox mode + type: boolean + default: false + ttl: + description: The duration in ISO8601 format after timestamp for which this message holds valid. + type: string + version: + description: Version of Beckn protocol being used by the sender. + type: string + default: 2.0.0 + required: + - action + - bapId + - bapUri + - messageId + - timestamp + - transactionId + - version + additionalProperties: false + + Contract: + $id: https://schema.beckn.io/Contract/v2.1 + type: object + description: > + This is a JSON-LD compliant, linked-data schema that specifies a generic multi-party, digitally signed Contract between a set of participants. based on the vocabulary defined in the @context. By default, it is the most generic form of contract i.e beckn:Contract. However, based on the mapping being used in @context, it could take values like retail:Order, mobility:Reservation, healthcare:Appointment, and so on, which will be defined as sub-classes of beckn:Contract. + Alternate description A digitally agreed commitment between two or more participants + governing the exchange of economic or non-economic value. + + Contract is the canonical contract object in the generalized + Beckn v2.1 protocol. It replaces the commerce-specific Order construct + while preserving backward compatibility at the API layer. + + A Contract binds: + - Commitments (what is agreed) + - Consideration (value promised) + - Performance (how execution occurs) + - Settlements (how consideration is discharged) + + The model is domain-neutral and supports commerce, hiring, + energy markets, carbon exchanges, data access, mobility, + subscriptions, and other use cases. + title: Contract + properties: + '@context': + description: A URL to the reference vocabulary where this schema has been defined. If missing, this field defaults to `https://schema.beckn.io/`. It allows applications to fetch the mapping between the simple JSON keys of this class and absolute Beckn IRIs (Internationalized Resource Identifiers), allowing conversion of standard Beckn JSON payload into linked data. + type: string + format: uri + const: https://schema.beckn.io/Contract/v2.0 + '@type': + description: A Beckn IRI on the vocabulary defined in the @context. Must start with "beckn:" followed by URL-safe identifier characters. + type: string + pattern: ^beckn:[A-Za-z0-9._~-]+$ + default: beckn:Contract + id: + description: A UUID string generated at the BPP endpoint at any stage before the confirmation of the order i.e before `/on_confirm` callback. This value is intended typically for indexing or filtering. While the chances of a UUID collision are rare, it is recommended to use a combination of `bppId`, `providerId` and `id` to allow for global uniqueness. + type: string + format: uuid + descriptor: + description: Describes the nature of the contract in human / agent readable terms + $ref: '#/components/schemas/Descriptor' + commitments: + type: array + description: > + Structured commitments governed by this contract. + minItems: 1 + items: + $ref: '#/components/schemas/Commitment' + consideration: + type: array + description: Value agreed to be exchanged under this contract. + items: + $ref: '#/components/schemas/Consideration' + participants: + description: | + The participants involved in the contract. Contracts are not always between two individuals. + + Several entities may play a specific role in the creation, fulfillment, and post-fulfillment of + the contract. + type: array + items: + $ref: '#/components/schemas/Participant' + performance: + type: array + description: > + Execution units of the contract. + + Previously defined as `fulfillments`, performance is the generalized the legacy Fulfillment abstraction. + Each Performance instance represents a structured execution + plan or delivery mechanism, including: + + - Physical delivery + - Service provisioning + - API access + - Subscription activation + - Carbon credit transfer + - Capacity allocation + - Workforce onboarding + + Tracking and Support interactions may be linked to + individual Performance units. + items: + $ref: '#/components/schemas/Performance' + entitlements: + type: array + items: + $ref: '#/components/schemas/Entitlement' + settlements: + type: array + description: > + Records representing discharge of agreed consideration. + items: + $ref: '#/components/schemas/Settlement' + terms: + type: array + description: > + Records representing discharge of agreed consideration. + items: + $ref: '#/components/schemas/Attributes' + status: + description: The current state of the contract expressed as a Descriptor whose code MUST be one of the standard contract state values. + allOf: + - $ref: '#/components/schemas/Descriptor' + - type: object + properties: + code: + enum: [DRAFT, ACTIVE, CANCELLED, COMPLETE] + contractAttributes: + description: > + Domain-specific extension attributes for this contract. + Use beckn:LogisticsContract for hyperlocal physical delivery. Use the generic Attributes schema for + other fulfillment types where no well-known domain schema exists. + allOf: + - $ref: '#/components/schemas/Attributes' + - required: + - '@context' + - '@type' + - properties: + '@context': + description: Domain-specific context in which this contract is being created + type: string + format: uri + examples: + - 'https://schema.beckn.io/GroceryOrder/v2.0/context.jsonld' + - 'https://schema.beckn.io/FnBOrder/v2.0/context.jsonld' + - 'https://schema.beckn.io/ShippingOrder/v2.0/context.jsonld' + - 'https://schema.beckn.io/MobilityBooking/v2.0/context.jsonld' + - 'https://schema.beckn.io/BusTicketBooking/v2.0/context.jsonld' + - 'https://schema.beckn.io/P2PEnergyTrade/v1.1/context.jsonld' + '@type': + description: JSON-LD type corresponding to the context + type: string + examples: + - GroceryOrder + - FnBOrder + - ShippingOrder + - MobilityBooking + - BusTicketBooking + - P2PEnergyTrade + + additionalProperties: false + x-tags: + - common + required: + - commitments + + Commitment: + type: object + required: + - status + - resources + - offer + properties: + "@context": + type: string + format: uri + description: JSON-LD context URI for the core Resource schema + "@type": + type: string + description: Type of the core Resource + enum: ["beckn:Commitment"] + id: + type: string + status: + type: object + properties: + descriptor: + allOf: + - $ref: '#/components/schemas/Descriptor' + - properties: + code: + enum: + - DRAFT + - ACTIVE + - CLOSED + resources: + type: array + items: + required: + - id + - quantity + $ref: '#/components/schemas/Resource' + offer: + required: + - id + - resourceIds + $ref: '#/components/schemas/Offer' + commitmentAttributes: + description: > + Domain-specific extension attributes for this commitment. + allOf: + - $ref: '#/components/schemas/Attributes' + - required: + - '@context' + - '@type' + properties: + '@context': + description: Domain-specific attributes for this commitment + type: string + format: uri + examples: + - 'https://schema.beckn.io/ShoppingCart/v2.0/context.jsonld' + - 'https://schema.beckn.io/ShippingManifest/v2.0/context.jsonld' + - 'https://schema.beckn.io/TravelItinerary/v2.0/context.jsonld' + - 'https://schema.beckn.io/BillOfMaterials/v2.0/context.jsonld' + - 'https://schema.beckn.io/DiagnosticTests/v2.0/context.jsonld' + - 'https://schema.beckn.io/TodoList/v2.0/context.jsonld' + '@type': + description: JSON-LD type corresponding to the context + type: string + examples: + - ShoppingCart + - ShippingManifest + - TravelItinerary + - BillOfMaterials + - DiagnosticTests + - TodoList + + CounterSignature: + title: Beckn HTTP Counter-Signature + description: | + A signed receipt transmitted in the synchronous `Ack` response body, proving that the + receiver authenticated, received, and processed the inbound request. + + `CounterSignature` shares the same wire format as `Signature` but differs: + - **Signer**: the response receiver (not the request sender) + - **Location**: transmitted in the `Ack` response body (not in the `Authorization` header) + - **`digest`**: covers the Ack response body (not the inbound request body) + - **`(request-digest)`** and **`(message-id)`** MUST be included in the signing string + + Signing string format: + ``` + (created): {unixTimestamp} + (expires): {unixTimestamp} + digest: BLAKE-512={base64DigestOfAckBody} + (request-digest): BLAKE-512={base64DigestOfInboundRequestBody} + (message-id): {messageId} + ``` + allOf: + - $ref: '#/components/schemas/Signature' + + Descriptor: + $id: "#/components/schemas/Descriptor" + $schema: "https://json-schema.org/draft/2020-12/schema" + description: Schema definition for Descriptor in the Beckn Protocol v2.0.1 + title: Descriptor + x-tags: [common] + type: object + properties: + '@context': + description: Use case specific JSON-LD context. This can change from use case to use case, even within a domain. + type: string + format: uri + default: "https://schema.beckn.io/" + '@type': + description: Type of the descriptor. The type can be overriden with a context-specific type + type: string + default: beckn:Descriptor + code: + description: A machine-readable code identifying the state or type of the entity being described. The valid values for this field are defined by the context in which the Descriptor is used (e.g. DRAFT, ACTIVE, CANCELLED, COMPLETE for a Contract status). + type: string + longDesc: + description: Detailed description of the item + type: string + example: Powerful gaming laptop with NVIDIA RTX graphics, fast SSD storage, and high-refresh display + shortDesc: + description: Short description of the item + type: string + example: High-performance gaming laptop with RTX graphics + name: + description: Name of the entity being described + type: string + example: Premium Gaming Laptop Pro + thumbnailImage: + description: Name of the entity being described + type: string + format: uri + docs: + description: Links to downloadable documents + type: array + items: + type: object + mediaFile: + description: Links to multimedia files and images + type: array + items: + type: object + required: + - '@type' + additionalProperties: false + + Entitlement: + $id: https://schema.beckn.io/Entitlement/v2.0 + description: A contractually granted, policy-governed right that allows a specific party to access, use, or claim a defined economic resource within stated scope and validity constraints. It represents the enforceable permission created by an order, independent of the credential used to exercise it. + title: Entitlement + type: object + properties: + '@context': + description: CPD + type: string + format: uri + default: https://schema.beckn.io/ + '@type': + description: The domain-specific type of entitlement that allows domains to extend this schema + type: string + default: beckn:Entitlement + id: + description: A unique identifier for this entitlement within the entitlement provider's namespace + type: string + resource: + description: The resource being availed or accessed against this entitlement + $ref: '#/components/schemas/Resource/properties/id' + descriptor: + description: Human-readable information regarding the entitlement like QR-code images, attached documents containing terms and conditions, video or audio files instructing the user on how to use the entitlement + $ref: '#/components/schemas/Descriptor' + credentials: + type: array + items: + $ref: '#/components/schemas/Descriptor' + additionalProperties: true + required: + - '@context' + - '@type' + - id + - descriptor + x-tags: + - common + + Error: + $id: '#/components/schemas/Error/v2.0' + title: Error + description: | + Base error container returned in NACK responses and async error callbacks. + Domain-specific error codes SHOULD follow the Beckn error code registry + (see docs/11_Error_Codes.md). Custom error objects may be expressed as + `oneOf [string, object]` payloads via the `errorDetails` field. + oneOf: + - description: Newest version of the error object properties. + type: object + properties: + errorCode: + type: string + description: Machine-readable error code per the Beckn error registry. + errorMessage: + type: string + description: Human-readable error description for display or logging. + errorDetails: + description: | + Optional domain-specific error details. Using oneOf to allow both simple + string details and structured JSON-LD error objects from domain packs. + oneOf: + - type: string + - type: object + additionalProperties: true + - description: Error response object from the previous version + type: object + required: [code, message] + properties: + code: + type: string + message: + type: string + details: + type: object + Participant: + type: object + properties: + id: + type: string + descriptor: + $ref: '#/components/schemas/Descriptor' + participantAttributes: + description: > + Domain-specific extension attributes for this contract. + Use beckn:LogisticsContract for hyperlocal physical delivery. Use the generic Attributes schema for + other fulfillment types where no well-known domain schema exists. + allOf: + - $ref: '#/components/schemas/Attributes' + - required: + - '@context' + - '@type' + properties: + '@context': + description: Domain-specific entity which is is participating in this transaction + type: string + format: uri + examples: + - 'https://schema.beckn.io/Consumer/v2.0/context.jsonld' + - 'https://schema.beckn.io/Provider/v2.0/context.jsonld' + - 'https://schema.beckn.io/FulfillmentAgent/v2.0/context.jsonld' + - 'https://schema.beckn.io/NetworkParticipant/v2.0/context.jsonld' + - 'https://schema.beckn.io/EscrowAgent/v2.0/context.jsonld' + - 'https://schema.beckn.io/Guarantor/v2.0/context.jsonld' + '@type': + description: JSON-LD type corresponding to the context + type: string + examples: + - Consumer + - Provider + - FulfillmentAgent + - NetworkParticipant + - EscrowAgent + - Guarantor + + GeoJSONGeometry: + $id: 'https://schema.beckn.io/GeoJSONGeometry/v2.0' + description: "**GeoJSON geometry** per RFC 7946. Coordinates are in **EPSG:4326 (WGS-84)** and MUST follow\ + \ **[longitude, latitude, (altitude?)]** order.\nSupported types: - Point, LineString, Polygon - MultiPoint,\ + \ MultiLineString, MultiPolygon - GeometryCollection (uses `geometries` instead of `coordinates`)\n\ + Notes: - For rectangles, use a Polygon with a single linear ring where the first\n and last positions\ + \ are identical.\n- Circles are **not native** to GeoJSON. For circular searches, use\n `SpatialConstraint`\ + \ with `op: s_dwithin` and a Point + `distanceMeters`,\n or approximate the circle as a Polygon.\n\ + - Optional `bbox` is `[west, south, east, north]` in degrees.\n" + title: GeoJSONGeometry + type: object + properties: + bbox: + description: Optional bounding box `[west, south, east, north]` in degrees. + type: array + minItems: 4 + maxItems: 4 + coordinates: + description: Coordinates per RFC 7946 for all types **except** GeometryCollection. Order is **[lon, + lat, (alt)]**. For Polygons, this is an array of linear rings; each ring is an array of positions. + type: array + geometries: + description: Member geometries when `type` is **GeometryCollection**. + type: array + items: + $ref: '#/components/schemas/GeoJSONGeometry' + type: + type: string + enum: + - Point + - LineString + - Polygon + - MultiPoint + - MultiLineString + - MultiPolygon + - GeometryCollection + required: + - type + additionalProperties: true + x-tags: + - common + + Performance: + $id: "https://schema.beckn.io/Performance/v2.2" + description: | + Generalized execution/performance unit. This is where downstream objects bind: + - Fulfillment-like details (where/when/how) + - Tracking handles + - Support touchpoints + - Status updates + + A minimal envelope that carries identity, status, and a performanceAttributes + bag that holds the concrete domain-specific delivery schema. + + Domain specification authors can attach rich context and types via `performanceAttributes`. + + For example, Hyperlocal delivery details (pickup/delivery locations, items shipped, agent, etc.) + are placed inside performanceAttributes using a well-known domain schema such as + HyperlocalDelivery. Use the generic Attributes schema when no well-known + domain schema exists. + title: Fulfillment + x-tags: [common, fulfillment] + type: object + properties: + '@context': + description: Domain-specific context in which this fulfillment is taking place + type: string + format: uri + const: 'https://schema.beckn.io/Performance/v2.1/context.jsonld' + '@type': + description: JSON-LD type. + type: string + default: 'Fulfillment' + id: + type: string + description: Unique identifier for this fulfillment record. + x-jsonld: + '@id': 'beckn:id' + status: + $ref: '#/components/schemas/Descriptor' + description: > + Current status of this fulfillment, expressed as a Descriptor. + Use Descriptor.code for machine-readable status values. + x-jsonld: + '@id': 'beckn:status' + commitmentIds: + type: array + items: + $ref: '#/components/schemas/Commitment/properties/id' + schedule: + $ref: '#/components/schemas/Schedule' + performanceAttributes: + description: > + Domain-specific extension attributes for this fulfillment. + Use beckn:HyperlocalDelivery (aligned with schema:ParcelDelivery) for + hyperlocal physical delivery. Use the generic Attributes schema for + other fulfillment types where no well-known domain schema exists. + allOf: + - $ref: '#/components/schemas/Attributes' + - properties: + '@context': + description: Domain-specific context in which this fulfillment is taking place + type: string + format: uri + examples: + - 'https://schema.beckn.io/HyperlocalDelivery/v2.0/context.jsonld' + - 'https://schema.beckn.io/OnDemandRide/v2.0/context.jsonld' + - 'https://schema.beckn.io/P2PEnergyTransfer/v1.1/context.jsonld' + default: 'https://schema.beckn.io/Fulfillment/v2.1/context.jsonld' + '@type': + description: JSON-LD type. + type: string + examples: + - 'HyperlocalDelivery' + - 'OnDemandRide' + - 'P2PEnergyTransfer' + - 'Fulfillment' + required: + - '@context' + - '@type' + additionalProperties: false + + InReplyTo: + $id: 'https://schema.beckn.io/InReplyTo/v2.0' + title: In-Reply-To Reference + description: | + A cryptographic binding that ties an asynchronous `on_` callback to the + specific inbound request that triggered it. Establishes bilateral non-repudiation + for the asynchronous leg of a Beckn interaction. + + Use `lineage` (on `Context`) for cross-transaction causality. + + Verification steps: + 1. Recompute BLAKE2b-512 digest of the original request body; compare to `digest`. + 2. Confirm `messageId` matches the `messageId` from the original request `Context`. + type: object + required: [messageId, digest] + properties: + messageId: + type: string + format: uuid + description: The `messageId` of the parent request from its `Context`. + digest: + type: string + pattern: '^BLAKE-512=[A-Za-z0-9+/]+=*$' + description: | + BLAKE2b-512 hash of the parent request body, Base64-encoded with algorithm prefix. + Format: `BLAKE-512={base64EncodedHash}` + + Intent: + $id: https://schema.beckn.io/Intent/v2.0 + description: A declaration of an intent to transact + title: Intent + x-tags: [common] + type: object + properties: + textSearch: + description: Free text search query for items + type: string + example: gaming laptop premium tech + filters: + description: Filter criteria for items + type: object + properties: + type: + description: Type of filter expression + type: string + enum: + - jsonpath + default: jsonpath + expression: + description: Filter expression based on the specified type + type: string + example: $[?(@.rating.value >= 4.0 && @.electronic.brand.name == 'Premium Tech')] + required: + - type + - expression + spatial: + description: Optional array of spatial constraints (CQL2-JSON semantics). + type: array + items: + type: object + media_search: + $ref: "#/components/schemas/MediaSearch" + anyOf: + - required: + - textSearch + - required: + - filters + - required: + - spatial + - required: + - filters + - spatial + additionalProperties: false + + + Location: + description: | + A place represented by GeoJSON geometry and optional address. + Source: main/schema/core/v2/attributes.yaml#Location + type: object + required: [geo] + additionalProperties: false + properties: + "@type": + type: string + enum: ["beckn:Location"] + geo: + $ref: '#/components/schemas/GeoJSONGeometry' + address: + oneOf: + - type: string + - $ref: '#/components/schemas/Address' + + MediaInput: + $id: https://schema.beckn.io/MediaInput/v2.0 + type: object + required: [type, url] + properties: + id: { type: string } + type: { type: string, enum: [image, audio, video] } + url: { type: string, format: uri } + contentType: { type: string } + textHint: { type: string } + language: { type: string } + startMs: { type: integer } + endMs: { type: integer } + additionalProperties: false + + MediaSearch: + $id: https://schema.beckn.io/MediaSearch/v2.0 + type: object + properties: + media: + type: array + items: + $ref: '#/components/schemas/MediaInput' + options: + $ref: '#/components/schemas/MediaSearchOptions' + additionalProperties: false + + MediaSearchOptions: + $id: https://schema.beckn.io/MediaSearchOptions/v2.0 + type: object + properties: + goals: + type: array + items: + type: string + enum: [visual-similarity, visual-object-detect, text-from-image, text-from-audio, semantic-audio-match, semantic-video-match] + augment_text_search: + type: boolean + default: true + restrict_results_to_media_proximity: + type: boolean + default: false + additionalProperties: false + + Offer: + $id: 'https://schema.beckn.io/Offer/v2.1' + type: object + description: | + A generalized, cross-domain Offer that captures the terms under which + one or more Resources may be committed. + + Core intent: + - Support multiple terms/eligibility/constraints/price points for the same Resource(s) + - Support dynamic / on-the-fly offers (e.g., bundling, combinational discounts, + eligibility changes, capacity-aware pricing) + + This mirrors the role of Offer in current Beckn (and schema.org patterns), + but keeps the shape minimal and composable via `beckn:offerAttributes`. + required: ["@context", "@type", "id"] + properties: + "@context": + type: string + format: uri + description: JSON-LD context URI for the core Offer schema + enum: ["https://schema.beckn.io/Offer/v2.2/context.jsonld"] + "@type": + type: string + description: Type of the core Offer + enum: ["beckn:Offer"] + id: + type: string + description: Unique identifier of the offer. + descriptor: + description: Human / agent-readable description of this offer. + $ref: '#/components/schemas/Descriptor' + provider: + description: > + Provider of this offer. + - If the offer is published by the same provider as the item it is associated with, + use only the provider's `id` (string reference). + - If the offer is published by a different provider, include the full Provider object. + oneOf: + - type: object + description: Same provider as the item — identified by id only. + required: [id] + properties: + id: + type: string + description: Identifier of the provider (same as the item's provider). + - $ref: '#/components/schemas/Provider' + resourceIds: + type: array + description: References (IDs) to resources covered by this offer. + items: + $ref: '#/components/schemas/Resource/properties/id' + addOnIds: + type: array + description: IDs of optional extra Offers or Resources that can be attached. + items: + $ref: '#/components/schemas/AddOn/properties/id' + considerationIds: + type: array + items: + $ref: '#/components/schemas/Consideration/properties/id' + fulfillmentIds: + description: Details regarding the fulfillment of this offer + $ref: '#/components/schemas/Consideration' + validity: + $ref: '#/components/schemas/TimePeriod' + availableTo: + type: array + description: > + Optional visibility constraint indicating which network participants + (by participantId / networkId / role) are allowed to discover or + transact on this entity. + + If omitted, the entity is assumed to be visible to all participants + in the addressed network(s). + items: + type: object + required: [type, id] + properties: + type: + type: string + enum: [NETWORK, PARTICIPANT, ROLE] + description: Scope of visibility constraint. + id: + type: string + description: Identifier of the network, participant, or role. + offerAttributes: + $ref: '#/components/schemas/Attributes' + + + PriceSpecification: + type: object + properties: + priceUnit: + type: string + consideredValue: + type: number + components: + type: array + items: + type: object + required: + - lineId + - lineSummary + - value + - currency + properties: + lineId: + type: string + lineSummary: + type: string + value: + type: number + currency: + type: string + commitmentId: + $ref: '#/components/schemas/Commitment/properties/id' + quantity: + $ref: '#/components/schemas/Quantity' + + ProcessingNotice: + $id: https://schema.beckn.io/ProcessingNotice/v2.0 + type: object + required: [code, message] + properties: + code: + type: string + message: + type: string + details: + type: object + additionalProperties: true + + + Provider: + type: object + description: Schema definition for Provider in the Beckn Protocol v2.0.1 + title: Provider + x-tags: [common] + properties: + '@context': + type: string + format: uri + default: "https://schema.beckn.io/" + '@type': + type: string + const: beckn:Provider + descriptor: + $ref: "#/components/schemas/Descriptor" + id: + description: Unique identifier for the provider + type: string + example: tech-store-001 + locations: + description: Physical locations where the provider operates + type: array + items: + $ref: "#/components/schemas/Location" + policies: + type: array + items: + type: object + providerAttributes: + $ref: "#/components/schemas/Attributes" + required: + - id + - descriptor + additionalProperties: false + + Quantity: + $id: https://schema.beckn.io/Quantity/v2.0 + description: Schema definition for Quantity in the Beckn Protocol v2.0.1 + title: Quantity + type: object + properties: + maxQuantity: + description: Maximum quantity for this price + type: number + x-jsonld: + '@id': schema:Number + example: 100 + minQuantity: + description: Minimum quantity for this price + type: number + x-jsonld: + '@id': schema:Number + example: 1 + unitCode: + description: Unit code for the quoted price (e.g., KWH, MIN, H, MON) + type: string + x-jsonld: + '@id': schema:unitCode + example: KWH + unitQuantity: + description: Quantity of the unit + type: number + x-jsonld: + '@id': schema:Number + example: 1 + unitText: + description: Unit for the quoted price (e.g., kWh, minute, hour, month) + type: string + x-jsonld: + '@id': schema:unitText + example: kWh + x-jsonld: + '@id': schema:QuantitativeValue + additionalProperties: false + x-tags: + - common + RatingForm: # Move to schema.beckn.io/RatingForm/v2.0 + $id: https://schema.beckn.io/RatingForm/v2.0 + $schema: "https://json-schema.org/draft/2020-12/schema" + description: A form designed to capture rating and feedback from a user. This can be used by both BAP and BPP to fetch ratings + and feedback of their respective users. + title: RatingForm + x-tags: [common] + type: object + properties: + '@context': + type: string + format: uri + const: "https://schema.beckn.io/" + '@type': + type: string + default: RatingForm + x-jsonld: + '@id': beckn:RatingForm + target: + description: The entity being rated + type: object + properties: + '@context': + type: string + format: uri + const: "https://schema.beckn.io/" + '@type': + type: string + default: RatingTarget + x-jsonld: + '@id': beckn:RatingTarget + id: + description: ID of the entity being rated (order/item/fulfillment/provider/agent). + type: string + ratingTargetType: + description: What is being rated. + type: string + enum: [Order, Fulfillment, FulfillmentStage, Provider, Agent] + range: + oneOf: + - type: object + properties: + bestRating: + description: Maximum of the rating scale (default 5 if omitted). + type: number + worstRating: + description: Minimum of the rating scale (default 1 or 0 if omitted). + type: number + - type: array + description: A custom rating range indexed from 0 to n + items: + type: object + properties: + index: + type: integer + minimum: 0 + value: + $ref: "#/components/schemas/Descriptor" + feedbackForm: + description: A feedback form sent along with a rating request + type: object + feedbackRequired: + type: boolean + description: Specifies whether feedback after rating is required for acceptance of rating + required: + - '@context' + - '@type' + - target + - feedbackRequired + additionalProperties: false + + RatingInput: + $id: https://schema.beckn.io/RatingInput/v2.0 + title: RatingInput + description: | + A rating input form. Extends FormInput with the @type fixed to beckn:RatingForm, + indicating that this form instance is specifically a rating form. + allOf: + - type: object + - type: object + properties: + '@type': + type: string + const: 'beckn:RatingForm' + + Rating: + $id: https://schema.beckn.io/Rating + oneOf: + - $ref: '#/components/schemas/Rating' + - title: Rating + type: object + description: Aggregated rating information for an entity. Aligns with schema.org/AggregateRating semantics. + properties: + '@context': + type: string + format: uri + const: https://schema.beckn.io/ + '@type': + type: string + default: beckn:Rating + minValue: + description: Rating value (typically 0-5) + type: number + maxValue: + description: Number of ratings + type: integer + minimum: 0 + x-jsonld: + '@id': schema:ratingCount + reviewText: + description: Optional textual review or comment + type: string + x-jsonld: + '@id': schema:reviewBody + additionalProperties: false + x-tags: + - common + + Resource: + $id: 'https://schema.beckn.io/Resource/v2.0' + type: object + description: > + A minimal, domain-neutral abstraction representing any discoverable, + referenceable, or committable unit of value, capability, service, + entitlement, or asset within the network. + + Examples: + - A retail product SKU, a mobility ride, a job role, a carbon credit unit, + a dataset/API entitlement, a training course, a clinic service slot. + + Designed for composability through `resourceAttributes` where + domain packs can plug in their specific fields without changing the core. + required: ["@context", "@type", "id", "descriptor"] + properties: + "@context": + type: string + format: uri + description: JSON-LD context URI for the core Resource schema + "@type": + type: string + description: Type of the core Resource + const: "beckn:Resource" + id: + type: string + description: Globally unique identifier of the resource. + descriptor: + $ref: '#/components/schemas/Descriptor' + resourceAttributes: + description: All the properties of a resource that describe its value, its terms of usage, fulfillment, and consideration + $ref: '#/components/schemas/Attributes' + + Settlement: + $id: 'https://schema.beckn.io/Settlement/v2.0' + type: object + properties: + "@context": + type: string + format: uri + description: The domain-specific context in which this settlement is taking place. + examples: + - https://schema.beckn.io/Settlement/2.0/context.jsonld + - https://schema.beckn.io/RetailOrderSettlement/2.0/context.jsonld + - https://schema.beckn.io/P2PTradeSettlement/context.jsonld + "@type": + type: string + description: Type of settlement within the context of this settlement + examples: + - "beckn:Settlement" + - "beckn:MonetaryTransfer" + - "beckn:TokenTransfer" + - "beckn:CreditSettlement" + - "beckn:AssetTransfer" + - "beckn:ServiceSettlement" + default: "beckn:MonetaryTransfer" + id: + type: string + considerationId: + $ref: '#/components/schemas/Consideration/properties/id' + status: + type: string + enum: + - DRAFT + - COMMITTED + - COMPLETE + settlementTerms: + type: array + items: + $ref: '#/components/schemas/SettlementTerm' + settlementActions: + type: array + items: + $ref: '#/components/schemas/SettlementAction' + settlementAttributes: + $ref: '#/components/schemas/Attributes' + + + SettlementAction: + $id: 'https://schema.beckn.io/SettlementAction/v2.0' + type: object + properties: + settlementTermId: + $ref: '#/components/schemas/SettlementTerm/properties/id' + status: + $ref: '#/components/schemas/Descriptor' + from: + $ref: '#/components/schemas/Participant' + to: + $ref: '#/components/schemas/Participant' + method: + $ref: '#/components/schemas/SettlementMethod' + instrument: + $ref: '#/components/schemas/SettlementInstrument' + + SettlementInstrument: + type: object + properties: + '@context': + type: string + '@type': + type: string + id: + type: string + descriptor: + $ref: '#/components/schemas/Descriptor' + + SettlementMethod: + type: object + properties: + '@context': + type: string + '@type': + type: string + id: + type: string + descriptor: + $ref: '#/components/schemas/Descriptor' + + + SettlementTerm: + $id: 'https://schema.beckn.io/SettlementTerm/v2.0' + type: object + properties: + id: + type: string + committed: + type: boolean + default: false + commitments: + type: array + items: + type: object + properties: + id: + $ref: '#/components/schemas/Commitment/properties/id' + required: + - id + performance: + required: + - id + oneOf: + - type: object + properties: + id: + $ref: '#/components/schemas/Performance/properties/id' + - $ref: '#/components/schemas/Performance' + type: string + trigger: + $ref: '#/components/schemas/ActionTrigger' + eligibleInstrument: + $ref: '#/components/schemas/SettlementInstrument' + schedule: + $ref: '#/components/schemas/Schedule' + from: + $ref: '#/components/schemas/Participant' + to: + $ref: '#/components/schemas/Participant' + eligibleMethods: + type: array + items: + $ref: '#/components/schemas/SettlementMethod' + settlementTermAttributes: + $ref: '#/components/schemas/Attributes' + + Schedule: + $id: 'https://schema.beckn.io/Schedule/v2.0' + title: Schedule + description: > + A schedule defines a repeating time period used to describe a regularly + occurring Event. At a minimum a schedule will specify repeatFrequency which + describes the interval between occurrences of the event. Additional information + can be provided to specify the duration of occurrences, an exception to the + schedule (exceptDate), and an end condition (either numberOfRepeats, repeatCount + or endDate). + + Modeled after https://schema.org/Schedule. + type: object + properties: + byDay: + description: > + Defines the day(s) of the week on which a recurring Event takes place. + More specifically, to specify the day(s) of the week, use DayOfWeek; + for daily recurrence, use schema:DayOfWeek/Everyday. + A string value may also be used, for example "Sunday", "MO" (for Monday), or "MO TU" (for Monday and Tuesday). + oneOf: + - type: string + description: A day-of-week string (e.g. "Monday", "MO", "MO TU WE") + - type: array + items: + type: string + description: Day-of-week strings (e.g. "Monday", "MO") + byMonth: + description: > + Defines the month(s) of the year on which a recurring Event takes place. + Specified as an Integer between 1-12. January is 1. + oneOf: + - type: integer + minimum: 1 + maximum: 12 + - type: array + items: + type: integer + minimum: 1 + maximum: 12 + byMonthDay: + description: > + Defines the day(s) of the month on which a recurring Event takes place. + Specified as an Integer between 1-31. + oneOf: + - type: integer + minimum: 1 + maximum: 31 + - type: array + items: + type: integer + minimum: 1 + maximum: 31 + byMonthWeek: + description: > + Defines the week(s) of the month on which a recurring Event takes place. + Specified as an Integer between 1-5. For clarity, byMonthWeek is best + used in conjunction with byDay to indicate concepts like the first and + third Mondays of a month. + oneOf: + - type: integer + minimum: 1 + maximum: 5 + - type: array + items: + type: integer + minimum: 1 + maximum: 5 + duration: + description: > + The duration of the item (e.g., using ISO 8601 duration format). + type: string + format: duration + example: PT1H30M + endDate: + description: > + The end date and time of the item (in ISO 8601 date format). + type: string + format: date-time + example: "2025-12-31T23:59:59Z" + endTime: + description: > + The end time of the item. For a reserved event or service (e.g. EventReservation), + the time that it is expected to end. For actions that span a period of time, + when the action was performed. e.g. John wrote a book from January to December. + For media, including audio and video, it's the time offset of the end of a clip + within a larger file. + type: string + format: time + example: "18:00:00" + exceptDate: + description: > + Defines a Date or DateTime during which a scheduled Event will not take place. + The property allows exceptions to a Schedule to be specified. If an exception + is specified as a DateTime then only the event that would have started at that + specific date and time should be excluded from the schedule. If an exception is + specified as a Date then any event that is scheduled for that 24 hour period should + be excluded from the schedule. This allows a whole day to be excluded from the schedule + without having to itemise every scheduled event. + oneOf: + - type: string + format: date + - type: string + format: date-time + - type: array + items: + oneOf: + - type: string + format: date + - type: string + format: date-time + repeatCount: + description: > + Defines the number of times a recurring Event will take place. + type: integer + minimum: 1 + repeatFrequency: + description: > + Defines the frequency at which Events will occur according to a schedule Schedule. + The intervals between events should be defined as a Duration of time. + A schema:repeatFrequency value can be specified as either an ISO 8601 string + (e.g. "P1W" for weekly) or by using one of the standard values from schema.org + such as "Daily", "Weekly", "Monthly", "Yearly". + type: string + example: P1W + scheduleTimezone: + description: > + Indicates the timezone for which the time(s) indicated in the Schedule are given. + The value provided should be among those listed in the IANA Time Zone Database. + type: string + example: Asia/Kolkata + startDate: + description: > + The start date and time of the item (in ISO 8601 date format). + type: string + format: date-time + example: "2025-01-01T00:00:00Z" + startTime: + description: > + The start time of the item. For a reserved event or service (e.g. EventReservation), + the time that it is expected to start. For actions that span a period of time, + when the action was performed. e.g. John wrote a book from January to December. + type: string + format: time + example: "09:00:00" + + Signature: + title: Beckn HTTP Signature + description: | + A digitally signed authentication credential in the HTTP `Authorization` header. + Follows draft-cavage-http-signatures-12 as profiled by BECKN-006. + + format: + ``` + Signature keyId="{subscriberId}|{uniqueKeyId}|{algorithm}", + algorithm="{algorithm}",created="{unixTimestamp}",expires="{unixTimestamp}", + headers="{signedHeaders}",signature="{base64Signature}" + ``` + + | Attribute | Description | + |-----------|-------------| + | `keyId` | `{subscriberId}|{uniqueKeyId}|{algorithm}` — FQDN, registry UUID, algorithm | + | `algorithm` | MUST be `ed25519` | + | `created` | Unix timestamp when the signature was created | + | `expires` | Unix timestamp when the signature expires | + | `headers` | Space-separated signed headers. MUST include `(created)`, `(expires)`, `digest` | + | `signature` | Base64-encoded Ed25519 signature over the signing string | + + Signing string: + `(created): {value}\n(expires): {value}\ndigest: BLAKE-512={digest}` + where `digest` is a BLAKE2b-512 hash of the request body, Base64-encoded. + type: string + pattern: '^Signature keyId="[^|"]+\|[^|"]+\|[^"]+",algorithm="[^"]+",created="\d+",expires="\d+",headers="[^"]+",signature="[A-Za-z0-9+/]+=*"$' + + + Support: + $id: https://schema.beckn.io/Support + description: Describes a support session + info + title: Support + type: object + properties: + '@context': + type: string + format: uri + default: https://schema.beckn.io/ + '@type': + type: string + const: "beckn:Support" + orderId: + description: The order against which support is required + type: string + descriptor: + description: A description of the nature of support needed + $ref: '#/components/schemas/Descriptor' + channels: + description: Available support channels described in individual linked data JSON objects + type: array + minItems: 1 + items: + $ref: '#/components/schemas/Attributes' + required: + - '@context' + - '@type' + additionalProperties: false + x-tags: + - common + + Tracking: + $id: https://schema.beckn.io/Tracking + description: Information regarding live tracking of the fufillment of a contract + title: Tracking + type: object + properties: + '@context': + description: TBD + type: string + format: uri + default: https://schema.beckn.io/ + '@type': + description: TBD + type: string + default: beckn:Tracking + refId: + description: Tracking reference ID for the tracking session. Could be the Order ID, Fulfillment ID, Fulfillment Stage ID, or any other unique tracking identifier + type: string + status: + type: string + enum: + - ACTIVE + - INACTIVE + - NOT-SUPPORTED + url: + description: Link/handle to off-network tracking UI or endpoint. + type: string + format: uri + + required: + - '@context' + - '@type' + - url + - status + x-jsonld: + '@type': schema:TrackAction + trackingStatus: $.trackingStatus + schema:target: $.url + additionalProperties: false + x-tags: + - common + + JSONLDHeaders: + type: object + properties: + '@context': + description: The JSON-LD context in which the attached object is being instantiated in. + type: string + format: uri + default: https://schema.beckn.io/ + '@type': + description: The JSON-LD type in which the attached object + type: string + default: beckn:Tracking + required: ['@context', '@type'] + + TimePeriod: + $id: "https://schema.beckn.io/TimePeriod/v2.1" + description: Time window with date-time precision for availability/validity + title: TimePeriod + x-tags: [common] + type: object + properties: + '@type': + description: JSON-LD type for a date-time period + type: string + example: TimePeriod + startDate: + description: Start instant (inclusive) + type: string + format: date-time + example: '2025-01-27T09:00:00Z' + x-jsonld: + '@id': schema:startDate + endDate: + description: End instant (exclusive or inclusive per domain semantics) + type: string + format: date-time + example: '2025-12-31T23:59:59Z' + x-jsonld: + '@id': schema:endDate + startTime: + description: Start time of the time period + type: string + format: time + example: 09:00:00 + x-jsonld: + '@id': schema:startTime + endTime: + description: End time of the time period + type: string + format: time + example: '22:00:00' + x-jsonld: + '@id': schema:endTime + required: + - '@type' + anyOf: + - required: + - startDate + - required: + - endDate + - required: + - startTime + - endTime + additionalProperties: false + + # ── Ack schema (shared by Ack, AckNoCallback, NackBadRequest, NackUnauthorized responses) ── + AckSchema: + $id: 'https://schema.beckn.io/Ack' + title: Ack + oneOf: + - type: object + - type: object + properties: + status: + description: ACK if the request was accepted; NACK if rejected. + type: string + enum: + - ACK + - NACK + signature: + description: Counter-signature proving the receiver authenticated and processed the inbound request. + $ref: '#/components/schemas/CounterSignature' + error: + description: Optional error detail explaining why no callback will follow. + $ref: '#/components/schemas/Error' + required: + - status + - signature + + responses: + Ack: + description: Synchronous receipt acknowledgement returned for every accepted Beckn request. + content: + application/json: + schema: + $ref: '#/components/schemas/AckSchema' + + AckNoCallback: + description: | + Request received but no callback will follow (e.g. agents not found, + inventory unavailable, provider closed, context.try preview complete). + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/AckSchema' + - required: + - status + - signature + - error + + + NackBadRequest: + description: 'NACK — Bad Request: Malformed or invalid request; the server could not parse or validate the payload.' + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/AckSchema' + - type: object + properties: + status: + type: string + const: NACK + error: + $ref: '#/components/schemas/Error' + + NackUnauthorized: + description: Invalid or missing authentication credentials; signature could not be verified. + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/AckSchema' + - type: object + properties: + status: + type: string + const: NACK + error: + $ref: '#/components/schemas/Error' + + ServerError: + description: Internal failure on the network participant's application; the request could not be processed. + The response body MAY contain an `error` object with additional details. + content: + application/json: + schema: + type: object + properties: + error: + $ref: '#/components/schemas/Error' + + \ No newline at end of file diff --git a/benchmarks/e2e/testdata/confirm_request.json b/benchmarks/e2e/testdata/confirm_request.json new file mode 100644 index 0000000..ff0f42c --- /dev/null +++ b/benchmarks/e2e/testdata/confirm_request.json @@ -0,0 +1,84 @@ +{ + "context": { + "action": "confirm", + "bapId": "sandbox.food-finder.com", + "bapUri": "http://bench-bap.example.com", + "bppId": "bench-bpp.example.com", + "bppUri": "BENCH_BPP_URL", + "messageId": "BENCH_MESSAGE_ID", + "transactionId": "BENCH_TRANSACTION_ID", + "timestamp": "BENCH_TIMESTAMP", + "ttl": "PT30S", + "version": "2.0.0" + }, + "message": { + "order": { + "provider": { + "id": "bench-provider-001" + }, + "items": [ + { + "id": "bench-item-001", + "quantity": { + "selected": { + "count": 1 + } + } + } + ], + "billing": { + "name": "Bench User", + "address": "123 Bench Street, Bangalore, 560001", + "city": { + "name": "Bangalore" + }, + "state": { + "name": "Karnataka" + }, + "country": { + "code": "IND" + }, + "area_code": "560001", + "email": "bench@example.com", + "phone": "9999999999" + }, + "fulfillments": [ + { + "id": "f1", + "type": "Delivery", + "stops": [ + { + "type": "end", + "location": { + "gps": "12.9716,77.5946", + "area_code": "560001" + }, + "contact": { + "phone": "9999999999", + "email": "bench@example.com" + } + } + ], + "customer": { + "person": { + "name": "Bench User" + }, + "contact": { + "phone": "9999999999", + "email": "bench@example.com" + } + } + } + ], + "payments": [ + { + "type": "ON-FULFILLMENT", + "params": { + "amount": "150.00", + "currency": "INR" + } + } + ] + } + } +} diff --git a/benchmarks/e2e/testdata/discover_request.json b/benchmarks/e2e/testdata/discover_request.json new file mode 100644 index 0000000..280ee94 --- /dev/null +++ b/benchmarks/e2e/testdata/discover_request.json @@ -0,0 +1,17 @@ +{ + "context": { + "action": "discover", + "bapId": "sandbox.food-finder.com", + "bapUri": "http://bench-bap.example.com", + "messageId": "BENCH_MESSAGE_ID", + "transactionId": "BENCH_TRANSACTION_ID", + "timestamp": "BENCH_TIMESTAMP", + "ttl": "PT30S", + "version": "2.0.0" + }, + "message": { + "intent": { + "textSearch": "pizza" + } + } +} diff --git a/benchmarks/e2e/testdata/init_request.json b/benchmarks/e2e/testdata/init_request.json new file mode 100644 index 0000000..d98f797 --- /dev/null +++ b/benchmarks/e2e/testdata/init_request.json @@ -0,0 +1,80 @@ +{ + "context": { + "action": "init", + "bapId": "sandbox.food-finder.com", + "bapUri": "http://bench-bap.example.com", + "bppId": "bench-bpp.example.com", + "bppUri": "BENCH_BPP_URL", + "messageId": "BENCH_MESSAGE_ID", + "transactionId": "BENCH_TRANSACTION_ID", + "timestamp": "BENCH_TIMESTAMP", + "ttl": "PT30S", + "version": "2.0.0" + }, + "message": { + "order": { + "provider": { + "id": "bench-provider-001" + }, + "items": [ + { + "id": "bench-item-001", + "quantity": { + "selected": { + "count": 1 + } + } + } + ], + "billing": { + "name": "Bench User", + "address": "123 Bench Street, Bangalore, 560001", + "city": { + "name": "Bangalore" + }, + "state": { + "name": "Karnataka" + }, + "country": { + "code": "IND" + }, + "area_code": "560001", + "email": "bench@example.com", + "phone": "9999999999" + }, + "fulfillments": [ + { + "id": "f1", + "type": "Delivery", + "stops": [ + { + "type": "end", + "location": { + "gps": "12.9716,77.5946", + "area_code": "560001" + }, + "contact": { + "phone": "9999999999", + "email": "bench@example.com" + } + } + ], + "customer": { + "person": { + "name": "Bench User" + }, + "contact": { + "phone": "9999999999", + "email": "bench@example.com" + } + } + } + ], + "payments": [ + { + "type": "ON-FULFILLMENT" + } + ] + } + } +} diff --git a/benchmarks/e2e/testdata/routing-BAPCaller.yaml b/benchmarks/e2e/testdata/routing-BAPCaller.yaml new file mode 100644 index 0000000..41b0617 --- /dev/null +++ b/benchmarks/e2e/testdata/routing-BAPCaller.yaml @@ -0,0 +1,13 @@ +# Routing config for v2.0.0 benchmark. Domain is not required for v2.x.x — the +# router ignores it and routes purely by version + endpoint. +# BENCH_BPP_URL is substituted at runtime with the mock BPP server URL. +routingRules: + - version: "2.0.0" + targetType: "url" + target: + url: "BENCH_BPP_URL" + endpoints: + - discover + - select + - init + - confirm diff --git a/benchmarks/e2e/testdata/select_request.json b/benchmarks/e2e/testdata/select_request.json new file mode 100644 index 0000000..23042b9 --- /dev/null +++ b/benchmarks/e2e/testdata/select_request.json @@ -0,0 +1,55 @@ +{ + "context": { + "action": "select", + "bapId": "sandbox.food-finder.com", + "bapUri": "http://bench-bap.example.com", + "bppId": "bench-bpp.example.com", + "bppUri": "BENCH_BPP_URL", + "messageId": "BENCH_MESSAGE_ID", + "transactionId": "BENCH_TRANSACTION_ID", + "timestamp": "BENCH_TIMESTAMP", + "ttl": "PT30S", + "version": "2.0.0" + }, + "message": { + "order": { + "provider": { + "id": "bench-provider-001" + }, + "items": [ + { + "id": "bench-item-001", + "quantity": { + "selected": { + "count": 1 + } + } + } + ], + "fulfillments": [ + { + "id": "f1", + "type": "Delivery", + "stops": [ + { + "type": "end", + "location": { + "gps": "12.9716,77.5946", + "area_code": "560001" + }, + "contact": { + "phone": "9999999999", + "email": "bench@example.com" + } + } + ] + } + ], + "payments": [ + { + "type": "ON-FULFILLMENT" + } + ] + } + } +} diff --git a/benchmarks/run_benchmarks.sh b/benchmarks/run_benchmarks.sh new file mode 100755 index 0000000..d4753d1 --- /dev/null +++ b/benchmarks/run_benchmarks.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# ============================================================================= +# run_benchmarks.sh — beckn-onix adapter benchmark runner +# +# Usage: +# cd beckn-onix +# bash benchmarks/run_benchmarks.sh +# +# Requirements: +# - Go 1.24+ installed +# - benchstat is declared as a tool in go.mod; invoked via "go tool benchstat" +# +# Output: +# benchmarks/results// +# run1.txt, run2.txt, run3.txt — raw go test -bench output +# parallel_cpu1.txt ... cpu16.txt — concurrency sweep +# benchstat_summary.txt — statistical aggregation +# ============================================================================= +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RESULTS_DIR="$REPO_ROOT/benchmarks/results/$(date +%Y-%m-%d_%H-%M-%S)" +BENCH_PKG="./benchmarks/e2e/..." +BENCH_TIMEOUT="10m" +# ── Smoke-test values (small): swap to the "full run" values below once stable ─ +BENCH_TIME_SERIAL="10s" # full run: 10s +BENCH_TIME_PARALLEL="30s" # full run: 30s +BENCH_COUNT=1 # full run: keep at 1; benchstat uses the 3 serial files + +cd "$REPO_ROOT" + +# ── benchstat is declared as a go tool in go.mod; no separate install needed ── +# Use: go tool benchstat (works anywhere without PATH changes) + +# bench_filter: tee full output to the .log file for debugging, and write a +# clean copy (only benchstat-parseable lines) to the .txt file. +# The adapter logger is silenced via zerolog.SetGlobalLevel(zerolog.Disabled) +# in TestMain, so stdout should already be clean; the grep is a safety net for +# any stray lines from go test itself (build output, redis warnings, etc.). +bench_filter() { + local txt="$1" log="$2" + tee "$log" | grep -E "^(Benchmark|goos:|goarch:|pkg:|cpu:|ok |PASS|FAIL|--- )" > "$txt" || true +} + +# ── Create results directory ────────────────────────────────────────────────── +mkdir -p "$RESULTS_DIR" +echo "=== beckn-onix Benchmark Runner ===" +echo "Results dir : $RESULTS_DIR" +echo "Package : $BENCH_PKG" +echo "" + +# ── Serial runs (3x for benchstat stability) ────────────────────────────────── +echo "Running serial benchmarks (3 runs × ${BENCH_TIME_SERIAL})..." +for run in 1 2 3; do + echo " Run $run/3..." + go test \ + -timeout="$BENCH_TIMEOUT" \ + -run=^$ \ + -bench="." \ + -benchtime="$BENCH_TIME_SERIAL" \ + -benchmem \ + -count="$BENCH_COUNT" \ + "$BENCH_PKG" 2>&1 | bench_filter "$RESULTS_DIR/run${run}.txt" "$RESULTS_DIR/run${run}.log" + echo " Saved → $RESULTS_DIR/run${run}.txt (full log → run${run}.log)" +done +echo "" + +# ── Concurrency sweep ───────────────────────────────────────────────────────── +echo "Running parallel concurrency sweep (cpu=1,2,4,8,16; ${BENCH_TIME_PARALLEL} each)..." +for cpu in 1 2 4 8 16; do + echo " GOMAXPROCS=$cpu..." + go test \ + -timeout="$BENCH_TIMEOUT" \ + -run=^$ \ + -bench="BenchmarkBAPCaller_Discover_Parallel|BenchmarkBAPCaller_RPS" \ + -benchtime="$BENCH_TIME_PARALLEL" \ + -benchmem \ + -cpu="$cpu" \ + -count=1 \ + "$BENCH_PKG" 2>&1 | bench_filter "$RESULTS_DIR/parallel_cpu${cpu}.txt" "$RESULTS_DIR/parallel_cpu${cpu}.log" + echo " Saved → $RESULTS_DIR/parallel_cpu${cpu}.txt (full log → parallel_cpu${cpu}.log)" +done +echo "" + +# ── Percentile benchmark ────────────────────────────────────────────────────── +echo "Running percentile benchmark (${BENCH_TIME_SERIAL})..." +go test \ + -timeout="$BENCH_TIMEOUT" \ + -run=^$ \ + -bench="BenchmarkBAPCaller_Discover_Percentiles" \ + -benchtime="$BENCH_TIME_SERIAL" \ + -benchmem \ + -count=1 \ + "$BENCH_PKG" 2>&1 | bench_filter "$RESULTS_DIR/percentiles.txt" "$RESULTS_DIR/percentiles.log" +echo " Saved → $RESULTS_DIR/percentiles.txt (full log → percentiles.log)" +echo "" + +# ── Cache comparison ────────────────────────────────────────────────────────── +echo "Running cache warm vs cold comparison..." +go test \ + -timeout="$BENCH_TIMEOUT" \ + -run=^$ \ + -bench="BenchmarkBAPCaller_Cache" \ + -benchtime="$BENCH_TIME_SERIAL" \ + -benchmem \ + -count=1 \ + "$BENCH_PKG" 2>&1 | bench_filter "$RESULTS_DIR/cache_comparison.txt" "$RESULTS_DIR/cache_comparison.log" +echo " Saved → $RESULTS_DIR/cache_comparison.txt (full log → cache_comparison.log)" +echo "" + +# ── benchstat statistical summary ───────────────────────────────────────────── +echo "Running benchstat statistical analysis..." +go tool benchstat \ + "$RESULTS_DIR/run1.txt" \ + "$RESULTS_DIR/run2.txt" \ + "$RESULTS_DIR/run3.txt" \ + > "$RESULTS_DIR/benchstat_summary.txt" 2>&1 +echo " Saved → $RESULTS_DIR/benchstat_summary.txt" +echo "" + +# ── Parse results to CSV ────────────────────────────────────────────────────── +if command -v go &>/dev/null; then + echo "Parsing results to CSV..." + go run benchmarks/tools/parse_results.go \ + -dir="$RESULTS_DIR" \ + -out="$RESULTS_DIR" 2>&1 || echo " (parse_results.go: optional step, skipping if errors)" +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo "" +echo "========================================" +echo "✅ Benchmark run complete!" +echo "" +echo "Results written to:" +echo " $RESULTS_DIR" +echo "" +echo "Key files:" +echo " benchstat_summary.txt — statistical analysis of 3 serial runs" +echo " parallel_cpu*.txt — concurrency sweep results" +echo " percentiles.txt — p50/p95/p99 latency data" +echo " cache_comparison.txt — warm vs cold Redis cache comparison" +echo "" +echo "To view benchstat summary:" +echo " cat $RESULTS_DIR/benchstat_summary.txt" +echo "========================================" diff --git a/benchmarks/tools/parse_results.go b/benchmarks/tools/parse_results.go new file mode 100644 index 0000000..e792715 --- /dev/null +++ b/benchmarks/tools/parse_results.go @@ -0,0 +1,258 @@ +// parse_results.go — Parses raw go test -bench output from the benchmark results +// directory and produces two CSV files for analysis and reporting. +// +// Usage: +// +// go run benchmarks/tools/parse_results.go \ +// -dir=benchmarks/results// \ +// -out=benchmarks/results// +// +// Output files: +// +// latency_report.csv — per-benchmark mean, p50, p95, p99 latency, allocs +// throughput_report.csv — RPS at each GOMAXPROCS level from the parallel sweep +package main + +import ( + "bufio" + "encoding/csv" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +var ( + // Matches standard go bench output: + // BenchmarkFoo-8 1000 1234567 ns/op 1234 B/op 56 allocs/op + benchLineRe = regexp.MustCompile( + `^(Benchmark\S+)\s+\d+\s+([\d.]+)\s+ns/op` + + `(?:\s+([\d.]+)\s+B/op)?` + + `(?:\s+([\d.]+)\s+allocs/op)?` + + `(?:\s+([\d.]+)\s+p50_µs)?` + + `(?:\s+([\d.]+)\s+p95_µs)?` + + `(?:\s+([\d.]+)\s+p99_µs)?` + + `(?:\s+([\d.]+)\s+req/s)?`, + ) + + // Matches custom metric lines in percentile output. + metricRe = regexp.MustCompile(`([\d.]+)\s+(p50_µs|p95_µs|p99_µs|req/s)`) +) + +type benchResult struct { + name string + nsPerOp float64 + bytesOp float64 + allocsOp float64 + p50 float64 + p95 float64 + p99 float64 + rps float64 +} + +// cpuResult pairs a GOMAXPROCS value with a benchmark result from the parallel sweep. +type cpuResult struct { + cpu int + res benchResult +} + +func main() { + dir := flag.String("dir", ".", "Directory containing benchmark result files") + out := flag.String("out", ".", "Output directory for CSV files") + flag.Parse() + + if err := os.MkdirAll(*out, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "ERROR creating output dir: %v\n", err) + os.Exit(1) + } + + // ── Parse serial runs (run1.txt, run2.txt, run3.txt) ───────────────────── + var latencyResults []benchResult + for _, runFile := range []string{"run1.txt", "run2.txt", "run3.txt"} { + path := filepath.Join(*dir, runFile) + results, err := parseRunFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: could not parse %s: %v\n", runFile, err) + continue + } + latencyResults = append(latencyResults, results...) + } + + // Also parse percentiles file for p50/p95/p99. + percPath := filepath.Join(*dir, "percentiles.txt") + if percResults, err := parseRunFile(percPath); err == nil { + latencyResults = append(latencyResults, percResults...) + } + + if err := writeLatencyCSV(filepath.Join(*out, "latency_report.csv"), latencyResults); err != nil { + fmt.Fprintf(os.Stderr, "ERROR writing latency CSV: %v\n", err) + os.Exit(1) + } + fmt.Printf("Written: %s\n", filepath.Join(*out, "latency_report.csv")) + + // ── Parse parallel sweep (parallel_cpu*.txt) ────────────────────────────── + var throughputRows []cpuResult + + for _, cpu := range []int{1, 2, 4, 8, 16} { + path := filepath.Join(*dir, fmt.Sprintf("parallel_cpu%d.txt", cpu)) + results, err := parseRunFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: could not parse parallel_cpu%d.txt: %v\n", cpu, err) + continue + } + for _, r := range results { + throughputRows = append(throughputRows, cpuResult{cpu: cpu, res: r}) + } + } + + if err := writeThroughputCSV(filepath.Join(*out, "throughput_report.csv"), throughputRows); err != nil { + fmt.Fprintf(os.Stderr, "ERROR writing throughput CSV: %v\n", err) + os.Exit(1) + } + fmt.Printf("Written: %s\n", filepath.Join(*out, "throughput_report.csv")) +} + +// parseRunFile reads a go test -bench output file and returns all benchmark results. +func parseRunFile(path string) ([]benchResult, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var results []benchResult + currentBench := "" + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Main benchmark line. + if m := benchLineRe.FindStringSubmatch(line); m != nil { + r := benchResult{name: stripCPUSuffix(m[1])} + r.nsPerOp = parseFloat(m[2]) + r.bytesOp = parseFloat(m[3]) + r.allocsOp = parseFloat(m[4]) + r.p50 = parseFloat(m[5]) + r.p95 = parseFloat(m[6]) + r.p99 = parseFloat(m[7]) + r.rps = parseFloat(m[8]) + results = append(results, r) + currentBench = r.name + continue + } + + // Custom metric lines (e.g., "123.4 p50_µs"). + if currentBench != "" { + for _, mm := range metricRe.FindAllStringSubmatch(line, -1) { + val := parseFloat(mm[1]) + metric := mm[2] + for i := range results { + if results[i].name == currentBench { + switch metric { + case "p50_µs": + results[i].p50 = val + case "p95_µs": + results[i].p95 = val + case "p99_µs": + results[i].p99 = val + case "req/s": + results[i].rps = val + } + } + } + } + } + } + return results, scanner.Err() +} + +func writeLatencyCSV(path string, results []benchResult) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + w := csv.NewWriter(f) + defer w.Flush() + + header := []string{"benchmark", "mean_ms", "p50_µs", "p95_µs", "p99_µs", "allocs_op", "bytes_op"} + if err := w.Write(header); err != nil { + return err + } + + for _, r := range results { + row := []string{ + r.name, + fmtFloat(r.nsPerOp / 1e6), // ns/op → ms + fmtFloat(r.p50), + fmtFloat(r.p95), + fmtFloat(r.p99), + fmtFloat(r.allocsOp), + fmtFloat(r.bytesOp), + } + if err := w.Write(row); err != nil { + return err + } + } + return nil +} + +func writeThroughputCSV(path string, rows []cpuResult) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + w := csv.NewWriter(f) + defer w.Flush() + + header := []string{"gomaxprocs", "benchmark", "rps", "mean_latency_ms", "p95_latency_ms"} + if err := w.Write(header); err != nil { + return err + } + + for _, row := range rows { + r := []string{ + strconv.Itoa(row.cpu), + row.res.name, + fmtFloat(row.res.rps), + fmtFloat(row.res.nsPerOp / 1e6), + fmtFloat(row.res.p95), + } + if err := w.Write(r); err != nil { + return err + } + } + return nil +} + +// stripCPUSuffix removes trailing "-N" goroutine count suffixes from benchmark names. +func stripCPUSuffix(name string) string { + if idx := strings.LastIndex(name, "-"); idx > 0 { + if _, err := strconv.Atoi(name[idx+1:]); err == nil { + return name[:idx] + } + } + return name +} + +func parseFloat(s string) float64 { + if s == "" { + return 0 + } + v, _ := strconv.ParseFloat(s, 64) + return v +} + +func fmtFloat(v float64) string { + if v == 0 { + return "" + } + return strconv.FormatFloat(v, 'f', 3, 64) +} From 497e4b86a4331bda26c24431dd444cbea56b5a54 Mon Sep 17 00:00:00 2001 From: Mayuresh Date: Thu, 9 Apr 2026 12:00:54 +0530 Subject: [PATCH 3/9] feat(benchmarks): add benchmark report, fix gitignore and README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add benchmarks/reports/REPORT_ONIX_v150.md — baseline report (Apple M5, darwin/arm64, Beckn v2.0.0, GOMAXPROCS=10) - Gitignore benchmarks/results/ — runtime output from run_benchmarks.sh - Update README: directory layout with reports/ vs results/, Reports section with workflow for adding new reports, fix benchstat invocation to use `go tool benchstat` - Remove internal task marker from setup_test.go comment --- .gitignore | 3 + benchmarks/README.md | 41 +++- benchmarks/e2e/setup_test.go | 2 +- benchmarks/reports/REPORT_ONIX_v150.md | 255 +++++++++++++++++++++++++ 4 files changed, 291 insertions(+), 10 deletions(-) create mode 100644 benchmarks/reports/REPORT_ONIX_v150.md diff --git a/.gitignore b/.gitignore index cfb4d29..f2478c6 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,9 @@ dist .yarn/install-state.gz .pnp.* +# Benchmark runtime output (raw go test output, logs, CSVs) +benchmarks/results/ + # Ignore compiled shared object files *.so diff --git a/benchmarks/README.md b/benchmarks/README.md index 6d0615b..3aecfd4 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -12,7 +12,7 @@ go mod tidy # fetch miniredis + benchstat checksums bash benchmarks/run_benchmarks.sh # compile plugins, run all scenarios, generate report ``` -Results land in `benchmarks/results//`. +Runtime output lands in `benchmarks/results//` (gitignored). Committed reports live in `benchmarks/reports/`. --- @@ -89,10 +89,10 @@ benchmarks/ ├── README.md ← you are here ├── run_benchmarks.sh ← one-shot runner script ├── e2e/ -│ ├── bench_test.go ← benchmark functions (T8) -│ ├── setup_test.go ← TestMain, startAdapter, signing helper (T3/T4/T7) -│ ├── mocks_test.go ← mock BPP and registry servers (T5) -│ ├── keys_test.go ← dev key pair constants (T6a) +│ ├── bench_test.go ← benchmark functions +│ ├── setup_test.go ← TestMain, startAdapter, signing helper +│ ├── mocks_test.go ← mock BPP and registry servers +│ ├── keys_test.go ← dev key pair constants │ └── testdata/ │ ├── routing-BAPCaller.yaml ← routing config (BENCH_BPP_URL placeholder) │ ├── discover_request.json ← Beckn search payload fixture @@ -100,13 +100,36 @@ benchmarks/ │ ├── init_request.json │ └── confirm_request.json ├── tools/ -│ └── parse_results.go ← CSV exporter for latency + throughput data (T10) -└── results/ - └── BENCHMARK_REPORT.md ← report template (populate after a run) +│ └── parse_results.go ← CSV exporter for latency + throughput data +├── reports/ ← committed benchmark reports +│ └── REPORT_ONIX_v150.md ← baseline report (Apple M5, Beckn v2.0.0) +└── results/ ← gitignored; created by run_benchmarks.sh + └── / + ├── run1.txt, run2.txt, run3.txt — raw go test -bench output + ├── parallel_cpu*.txt — concurrency sweep + ├── benchstat_summary.txt — statistical aggregation + ├── latency_report.csv — per-benchmark latency (from parse_results.go) + └── throughput_report.csv — RPS vs GOMAXPROCS (from parse_results.go) ``` --- +## Reports + +Committed reports are stored in `benchmarks/reports/`. Each report documents the environment, raw numbers, and analysis for a specific run and adapter version. + +| File | Platform | Adapter version | +|------|----------|-----------------| +| `REPORT_ONIX_v150.md` | Apple M5 · darwin/arm64 · GOMAXPROCS=10 | beckn-onix v1.5.0 | + +To add a new report after a benchmark run: +1. Run `bash benchmarks/run_benchmarks.sh` — results appear in `benchmarks/results//`. +2. Review `benchstat_summary.txt` and the CSV files. +3. Write a report (see the existing report as a template) and save it as `benchmarks/reports/REPORT_.md`. +4. Commit only the report file; `benchmarks/results/` remains gitignored. + +--- + ## Running Individual Benchmarks ```bash @@ -142,7 +165,7 @@ go test ./benchmarks/e2e/... \ go test ./benchmarks/e2e/... -bench=. -benchtime=10s -count=6 > before.txt # ... make your change ... go test ./benchmarks/e2e/... -bench=. -benchtime=10s -count=6 > after.txt -benchstat before.txt after.txt +go tool benchstat before.txt after.txt ``` --- diff --git a/benchmarks/e2e/setup_test.go b/benchmarks/e2e/setup_test.go index 702c5da..604e761 100644 --- a/benchmarks/e2e/setup_test.go +++ b/benchmarks/e2e/setup_test.go @@ -269,7 +269,7 @@ func buildBAPCallerConfig(routingConfigPath, registryURL string) module.Config { } } -// ── T7: Request builder and Beckn signing helper ────────────────────────────── +// ── Request builder and Beckn signing helper ───────────────────────────────── // becknPayloadTemplate holds the raw JSON for a fixture file with sentinels. var fixtureCache = map[string][]byte{} diff --git a/benchmarks/reports/REPORT_ONIX_v150.md b/benchmarks/reports/REPORT_ONIX_v150.md new file mode 100644 index 0000000..925d62d --- /dev/null +++ b/benchmarks/reports/REPORT_ONIX_v150.md @@ -0,0 +1,255 @@ +# beckn-onix Adapter — Benchmark Report + +> **Run:** `2026-03-31_14-19-19` +> **Platform:** Apple M5 · darwin/arm64 · GOMAXPROCS=10 (default) +> **Protocol:** Beckn v2.0.0 + +--- + +## Part A — Executive Summary + +### What Was Tested + +The beckn-onix ONIX adapter was benchmarked end-to-end using Go's native `testing.B` framework and `net/http/httptest`. Requests flowed through a real compiled adapter — with all production plugins active — against in-process mock servers, isolating adapter-internal latency from network variables. + +**Pipeline tested (bapTxnCaller):** `addRoute → sign → validateSchema` + +**Plugins active:** `router`, `signer`, `simplekeymanager`, `cache` (miniredis), `schemav2validator` + +**Actions benchmarked:** `discover`, `select`, `init`, `confirm` + +--- + +### Key Results + +| Metric | Value | +|--------|-------| +| Serial p50 latency (discover) | **130 µs** | +| Serial p95 latency (discover) | **144 µs** | +| Serial p99 latency (discover) | **317 µs** | +| Serial mean latency (discover) | **164 µs** | +| Serial throughput (discover, GOMAXPROCS=10) | **~6,095 req/s** | +| Peak parallel throughput (GOMAXPROCS=10) | **25,502 req/s** | +| Cache warm vs cold delta | **≈ 0** (noise-level, ~3.7 µs) | +| Memory per request (discover) | **~81 KB · 662 allocs** | + +### Interpretation + +The adapter delivers sub-200 µs median end-to-end latency for all four Beckn actions on a single goroutine. The p99 tail of 317 µs shows good tail-latency control — the ratio of p99/p50 is only 2.4×, indicating no significant outlier spikes. + +Memory allocation is consistent and predictable: discover uses 662 heap objects at ~81 KB per request. More complex actions (confirm, init) use proportionally more memory due to larger payloads but remain below 130 KB per request. + +The Redis key-manager cache shows **no measurable benefit** in this setup: warm and cold paths differ by ~3.7 µs (< 2%), which is within measurement noise for a 164 µs mean. This is expected — miniredis is in-process and sub-microsecond; the signing and schema-validation steps dominate. + +Concurrency scaling is excellent: latency drops from 157 µs at GOMAXPROCS=1 to 54 µs at GOMAXPROCS=16 — a **2.9× improvement**. Throughput scales from 6,499 req/s at GOMAXPROCS=1 to 17,455 req/s at GOMAXPROCS=16. + +### Recommendation + +The adapter is ready for staged load testing against a real BPP. For production sizing, allocate at least 4 cores to the adapter process; beyond 8 cores, gains begin to taper (diminishing returns from ~17,233 to 17,455 req/s going from 8 to 16). If schema validation dominates CPU, profile with `go tool pprof` (see B5). + +--- + +## Part B — Technical Detail + +### B0 — Test Environment + +| Parameter | Value | +|-----------|-------| +| CPU | Apple M5 (arm64) | +| OS | darwin/arm64 | +| Go package | `github.com/beckn-one/beckn-onix/benchmarks/e2e` | +| Default GOMAXPROCS | 10 | +| Benchmark timeout | 30 minutes | +| Serial run duration | 10s per benchmark × 3 runs | +| Parallel sweep duration | 30s per GOMAXPROCS level | +| GOMAXPROCS sweep | 1, 2, 4, 8, 16 | +| Redis | miniredis (in-process, no network) | +| BPP | httptest mock (instant ACK) | +| Registry | httptest mock (dev key pair) | +| Schema spec | Beckn v2.0.0 OpenAPI (`beckn.yaml`, local file) | + +**Plugins and steps (bapTxnCaller):** + +| Step | Plugin | Role | +|------|--------|------| +| 1 | `router` | Resolves BPP URL from routing config | +| 2 | `signer` + `simplekeymanager` | Signs request body (Ed25519/BLAKE-512) | +| 3 | `schemav2validator` | Validates Beckn v2.0 API schema (kin-openapi, local file) | + +--- + +### B1 — Latency by Action + +Averages from `run1.txt` (10s, GOMAXPROCS=10). Percentile values from the standalone `BenchmarkBAPCaller_Discover_Percentiles` run. + +| Action | Mean (µs) | p50 (µs) | p95 (µs) | p99 (µs) | Allocs/req | Bytes/req | +|--------|----------:|--------:|--------:|--------:|----------:|----------:| +| discover (serial) | 164 | 130 | 144 | 317 | 662 | 80,913 (~81 KB) | +| discover (parallel) | 40 | — | — | — | 660 | 80,792 (~79 KB) | +| select | 194 | — | — | — | 1,033 | 106,857 (~104 KB) | +| init | 217 | — | — | — | 1,421 | 126,842 (~124 KB) | +| confirm | 221 | — | — | — | 1,485 | 129,240 (~126 KB) | + +**Observations:** +- Latency increases linearly with payload complexity: select (+18%), init (+32%), confirm (+35%) vs discover baseline. +- Allocation count tracks payload size precisely — each extra field adds heap objects during JSON unmarshalling and schema validation. +- Memory is extremely stable across the 3 serial runs (geomean memory: 91.18 Ki, ±0.02%). +- The parallel discover benchmark runs 8× faster than serial (40 µs vs 164 µs) because multiple goroutines share the CPU time budget and the adapter handles requests concurrently. + +--- + +### B2 — Throughput vs Concurrency + +Results from the concurrency sweep (`parallel_cpu*.txt`, 30s per level). + +| GOMAXPROCS | Mean Latency (µs) | Improvement vs cpu=1 | RPS (BenchmarkRPS) | +|:----------:|------------------:|---------------------:|-------------------:| +| 1 | 157 | baseline | 6,499 | +| 2 | 118 | 1.33× | 7,606 | +| 4 | 73 | 2.14× | 14,356 | +| 8 | 62 | 2.53× | 17,233 | +| 16 | 54 | 2.89× | 17,455 | +| 10 (default) | 40\* | ~3.9×\* | 25,502\* | + +\* _The default GOMAXPROCS=10 serial run has a different benchmark structure (not the concurrency sweep), so latency and RPS are not directly comparable — they include warm connection pool effects from the serial baseline._ + +**Scaling efficiency:** +- Doubling cores from 1→2 yields 1.33× latency improvement (67% efficiency). +- From 2→4: 1.61× improvement (80% efficiency) — best scaling band. +- From 4→8: 1.18× improvement (59% efficiency) — adapter starts becoming compute-bound. +- From 8→16: 1.14× improvement (57% efficiency) — diminishing returns; likely the signing/validation pipeline serialises on some shared resource (e.g., key derivation, kin-openapi schema tree reads). + +**Recommendation:** 4–8 cores offers the best throughput/cost ratio. + +--- + +### B3 — Cache Impact (Redis warm vs cold) + +Results from `cache_comparison.txt` (10s each, GOMAXPROCS=10). + +| Scenario | Mean (µs) | Allocs/req | Bytes/req | +|----------|----------:|-----------:|----------:| +| CacheWarm | 190 | 654 | 81,510 | +| CacheCold | 186 | 662 | 82,923 | +| **Delta** | **+3.7 µs (warm slower)** | **−8** | **−1,413** | + +**Interpretation:** There is no meaningful difference between warm and cold cache paths. The apparent 3.7 µs "advantage" for the cold path is within normal measurement noise for a 186–190 µs benchmark. The Redis key-manager cache does not dominate latency in this in-process test setup. + +The warm path allocates 8 fewer objects per request (652 vs 662 allocs) — consistent with cache hits skipping key-derivation allocation paths — but this saving is too small to affect wall-clock time at current throughput levels. + +In a **production environment** with real Redis over the network (1–5 ms round-trip), the cache warm path would show a meaningful advantage. These numbers represent the lower bound on signing latency with zero-latency Redis. + +--- + +### B4 — benchstat Statistical Summary (3 Runs) + +``` +goos: darwin +goarch: arm64 +pkg: github.com/beckn-one/beckn-onix/benchmarks/e2e +cpu: Apple M5 + │ run1.txt │ run2.txt │ run3.txt │ + │ sec/op │ sec/op vs base │ sec/op vs base │ +BAPCaller_Discover-10 164.2µ ± ∞ ¹ 165.4µ ± ∞ ¹ ~ (p=1.000 n=1) ² 165.3µ ± ∞ ¹ ~ (p=1.000 n=1) ² +BAPCaller_Discover_Parallel-10 39.73µ ± ∞ ¹ 41.48µ ± ∞ ¹ ~ (p=1.000 n=1) ² 52.84µ ± ∞ ¹ ~ (p=1.000 n=1) ² +BAPCaller_AllActions/discover-10 165.4µ ± ∞ ¹ 164.9µ ± ∞ ¹ ~ (p=1.000 n=1) ² 163.1µ ± ∞ ¹ ~ (p=1.000 n=1) ² +BAPCaller_AllActions/select-10 194.5µ ± ∞ ¹ 194.5µ ± ∞ ¹ ~ (p=1.000 n=1) ² 186.7µ ± ∞ ¹ ~ (p=1.000 n=1) ² +BAPCaller_AllActions/init-10 217.1µ ± ∞ ¹ 216.6µ ± ∞ ¹ ~ (p=1.000 n=1) ² 218.0µ ± ∞ ¹ ~ (p=1.000 n=1) ² +BAPCaller_AllActions/confirm-10 221.0µ ± ∞ ¹ 219.8µ ± ∞ ¹ ~ (p=1.000 n=1) ² 221.9µ ± ∞ ¹ ~ (p=1.000 n=1) ² +BAPCaller_Discover_Percentiles-10 164.5µ ± ∞ ¹ 165.3µ ± ∞ ¹ ~ (p=1.000 n=1) ² 162.2µ ± ∞ ¹ ~ (p=1.000 n=1) ² +BAPCaller_CacheWarm-10 162.7µ ± ∞ ¹ 162.8µ ± ∞ ¹ ~ (p=1.000 n=1) ² 169.4µ ± ∞ ¹ ~ (p=1.000 n=1) ² +BAPCaller_CacheCold-10 164.2µ ± ∞ ¹ 205.1µ ± ∞ ¹ ~ (p=1.000 n=1) ² 171.9µ ± ∞ ¹ ~ (p=1.000 n=1) ² +geomean 152.4µ 157.0µ +3.02% 157.8µ +3.59% + +Memory (B/op) — geomean: 91.18 Ki across all runs (±0.02%) +Allocs/op — geomean: 825.9 across all runs (perfectly stable across all 3 runs) +``` + +> **Note on confidence intervals:** benchstat requires ≥6 samples per benchmark for confidence intervals. With `-count=1` and 3 runs, results show ∞ uncertainty bands. The geomean drift of +3.59% across runs is within normal OS scheduler noise. To narrow confidence intervals, re-run with `-count=6` and `benchstat` will produce meaningful p-values. + +--- + +### B5 — Bottleneck Analysis + +Based on the allocation profile and latency data: + +| Rank | Plugin / Step | Estimated contribution | Evidence | +|:----:|---------------|------------------------|---------| +| 1 | `schemav2validator` (kin-openapi validation) | 40–60% | Alloc count proportional to payload complexity; JSON schema traversal creates many short-lived objects | +| 2 | `signer` (Ed25519/BLAKE-512) | 20–30% | Cryptographic operations are CPU-bound; scaling efficiency plateau at 8+ cores consistent with crypto serialisation | +| 3 | `simplekeymanager` (key derivation, Redis) | 5–10% | 8-alloc savings on cache-warm path; small but detectable | +| 4 | `router` (YAML routing lookup) | < 5% | Minimal; in-memory map lookup | + +**Key insight from the concurrency data:** RPS plateaus at ~17,000–17,500 between GOMAXPROCS=8 and 16. This suggests a shared serialisation point — most likely the kin-openapi schema validation tree (a read-heavy but non-trivially-lockable data structure), or the Ed25519 key operations. + +**Profiling commands to isolate the bottleneck:** + +```bash +# CPU profile — run from beckn-onix root +go test ./benchmarks/e2e/... \ + -bench=BenchmarkBAPCaller_Discover \ + -benchtime=30s \ + -cpuprofile=benchmarks/results/cpu.prof \ + -timeout=5m + +go tool pprof -http=:6060 benchmarks/results/cpu.prof + +# Memory profile +go test ./benchmarks/e2e/... \ + -bench=BenchmarkBAPCaller_Discover \ + -benchtime=30s \ + -memprofile=benchmarks/results/mem.prof \ + -timeout=5m + +go tool pprof -http=:6060 benchmarks/results/mem.prof + +# Parallel profile (find lock contention) +go test ./benchmarks/e2e/... \ + -bench=BenchmarkBAPCaller_Discover_Parallel \ + -benchtime=30s \ + -blockprofile=benchmarks/results/block.prof \ + -mutexprofile=benchmarks/results/mutex.prof \ + -timeout=5m + +go tool pprof -http=:6060 benchmarks/results/mutex.prof +``` + +--- + +## Running the Benchmarks + +```bash +# Full run: compile plugins, run all scenarios, generate CSV and benchstat summary +cd beckn-onix +bash benchmarks/run_benchmarks.sh + +# Quick smoke test (fast, lower iteration counts): +# Edit BENCH_TIME_SERIAL="2s" and BENCH_TIME_PARALLEL="5s" at the top of the script. + +# Individual benchmark (manual): +go test ./benchmarks/e2e/... \ + -bench=BenchmarkBAPCaller_Discover \ + -benchtime=10s \ + -benchmem \ + -timeout=30m + +# Race detector check: +go test ./benchmarks/e2e/... \ + -bench=BenchmarkBAPCaller_Discover_Parallel \ + -benchtime=5s \ + -race \ + -timeout=30m + +# Concurrency sweep (manual): +for cpu in 1 2 4 8 16; do + go test ./benchmarks/e2e/... \ + -bench="BenchmarkBAPCaller_Discover_Parallel|BenchmarkBAPCaller_RPS" \ + -benchtime=30s -cpu=$cpu -benchmem -timeout=10m +done +``` + +> **Note:** The first run takes 60–90 s while plugins compile. Subsequent runs use Go's build cache and start in seconds. + +--- + +*Generated from run `2026-03-31_14-19-19` · beckn-onix · Beckn Protocol v2.0.0* From 1a6acfc2602e4b4a76f443d9005819858ed5d375 Mon Sep 17 00:00:00 2001 From: Mayuresh Date: Thu, 9 Apr 2026 12:02:31 +0530 Subject: [PATCH 4/9] chore: gitignore create_benchmark_issues.sh utility script --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index f2478c6..b87b0f0 100644 --- a/.gitignore +++ b/.gitignore @@ -134,6 +134,9 @@ dist # Benchmark runtime output (raw go test output, logs, CSVs) benchmarks/results/ +# Utility scripts not part of the project +create_benchmark_issues.sh + # Ignore compiled shared object files *.so From 23e39722d28130eff9bb0a117ce7c2f1e0ea6ab8 Mon Sep 17 00:00:00 2001 From: Mayuresh Date: Thu, 9 Apr 2026 17:01:13 +0530 Subject: [PATCH 5/9] fix(benchmarks): fix three parsing bugs in parse_results.go and bench_test.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parse_results.go: fix metric extraction order — Go outputs custom metrics (p50_µs, p95_µs, p99_µs, req/s) BEFORE B/op and allocs/op on the benchmark line. The old positional regex had B/op first, so p50/p95/p99 were always empty in latency_report.csv. Replaced with separate regexps for each field so order no longer matters. - parse_results.go: remove p95_latency_ms column from throughput_report.csv — parallel sweep files only emit ns/op and req/s, never p95 data. The column was structurally always empty. - bench_test.go: remove fmt.Printf from BenchmarkBAPCaller_RPS — the debug print raced with Go's own benchmark output line, garbling the result to 'BenchmarkRPS-N RPS: N over Ns' which the framework could not parse, causing req/s to never appear in the structured output. b.ReportMetric alone is sufficient. --- benchmarks/e2e/bench_test.go | 5 +- benchmarks/tools/parse_results.go | 92 +++++++++++++++---------------- 2 files changed, 46 insertions(+), 51 deletions(-) diff --git a/benchmarks/e2e/bench_test.go b/benchmarks/e2e/bench_test.go index 2775802..ae17fb6 100644 --- a/benchmarks/e2e/bench_test.go +++ b/benchmarks/e2e/bench_test.go @@ -1,7 +1,6 @@ package e2e_bench_test import ( - "fmt" "net/http" "sort" "testing" @@ -162,9 +161,7 @@ func BenchmarkBAPCaller_RPS(b *testing.B) { elapsed := time.Since(start).Seconds() if elapsed > 0 { - rps := float64(count) / elapsed - b.ReportMetric(rps, "req/s") - fmt.Printf(" RPS: %.0f over %.1fs\n", rps, elapsed) + b.ReportMetric(float64(count)/elapsed, "req/s") } } diff --git a/benchmarks/tools/parse_results.go b/benchmarks/tools/parse_results.go index e792715..c71ba40 100644 --- a/benchmarks/tools/parse_results.go +++ b/benchmarks/tools/parse_results.go @@ -10,7 +10,7 @@ // Output files: // // latency_report.csv — per-benchmark mean, p50, p95, p99 latency, allocs -// throughput_report.csv — RPS at each GOMAXPROCS level from the parallel sweep +// throughput_report.csv — RPS and mean latency at each GOMAXPROCS level from the parallel sweep package main import ( @@ -26,19 +26,19 @@ import ( ) var ( - // Matches standard go bench output: - // BenchmarkFoo-8 1000 1234567 ns/op 1234 B/op 56 allocs/op - benchLineRe = regexp.MustCompile( - `^(Benchmark\S+)\s+\d+\s+([\d.]+)\s+ns/op` + - `(?:\s+([\d.]+)\s+B/op)?` + - `(?:\s+([\d.]+)\s+allocs/op)?` + - `(?:\s+([\d.]+)\s+p50_µs)?` + - `(?:\s+([\d.]+)\s+p95_µs)?` + - `(?:\s+([\d.]+)\s+p99_µs)?` + - `(?:\s+([\d.]+)\s+req/s)?`, - ) + // Matches the benchmark name and ns/op from a standard go test -bench output line. + // Go outputs custom metrics (p50_µs, req/s, …) BEFORE B/op and allocs/op, so we + // extract those fields with dedicated regexps rather than relying on positional groups. + // + // Example lines: + // BenchmarkBAPCaller_Discover-10 73542 164193 ns/op 82913 B/op 662 allocs/op + // BenchmarkBAPCaller_Discover_Percentiles-10 72849 164518 ns/op 130.0 p50_µs 144.0 p95_µs 317.0 p99_µs 82528 B/op 660 allocs/op + // BenchmarkBAPCaller_RPS-4 700465 73466 ns/op 14356.0 req/s 80375 B/op 660 allocs/op + benchLineRe = regexp.MustCompile(`^(Benchmark\S+)\s+\d+\s+([\d.]+)\s+ns/op`) + bytesRe = regexp.MustCompile(`([\d.]+)\s+B/op`) + allocsRe = regexp.MustCompile(`([\d.]+)\s+allocs/op`) - // Matches custom metric lines in percentile output. + // Extracts any custom metric value from a benchmark line. metricRe = regexp.MustCompile(`([\d.]+)\s+(p50_µs|p95_µs|p99_µs|req/s)`) ) @@ -124,48 +124,43 @@ func parseRunFile(path string) ([]benchResult, error) { defer f.Close() var results []benchResult - currentBench := "" scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) - // Main benchmark line. - if m := benchLineRe.FindStringSubmatch(line); m != nil { - r := benchResult{name: stripCPUSuffix(m[1])} - r.nsPerOp = parseFloat(m[2]) - r.bytesOp = parseFloat(m[3]) - r.allocsOp = parseFloat(m[4]) - r.p50 = parseFloat(m[5]) - r.p95 = parseFloat(m[6]) - r.p99 = parseFloat(m[7]) - r.rps = parseFloat(m[8]) - results = append(results, r) - currentBench = r.name + m := benchLineRe.FindStringSubmatch(line) + if m == nil { continue } - // Custom metric lines (e.g., "123.4 p50_µs"). - if currentBench != "" { - for _, mm := range metricRe.FindAllStringSubmatch(line, -1) { - val := parseFloat(mm[1]) - metric := mm[2] - for i := range results { - if results[i].name == currentBench { - switch metric { - case "p50_µs": - results[i].p50 = val - case "p95_µs": - results[i].p95 = val - case "p99_µs": - results[i].p99 = val - case "req/s": - results[i].rps = val - } - } - } + r := benchResult{name: stripCPUSuffix(m[1])} + r.nsPerOp = parseFloat(m[2]) + + // B/op and allocs/op — extracted independently because Go places custom + // metrics (p50_µs, req/s, …) between ns/op and B/op on the same line. + if bm := bytesRe.FindStringSubmatch(line); bm != nil { + r.bytesOp = parseFloat(bm[1]) + } + if am := allocsRe.FindStringSubmatch(line); am != nil { + r.allocsOp = parseFloat(am[1]) + } + + // Custom metrics — scan the whole line regardless of position. + for _, mm := range metricRe.FindAllStringSubmatch(line, -1) { + switch mm[2] { + case "p50_µs": + r.p50 = parseFloat(mm[1]) + case "p95_µs": + r.p95 = parseFloat(mm[1]) + case "p99_µs": + r.p99 = parseFloat(mm[1]) + case "req/s": + r.rps = parseFloat(mm[1]) } } + + results = append(results, r) } return results, scanner.Err() } @@ -212,7 +207,11 @@ func writeThroughputCSV(path string, rows []cpuResult) error { w := csv.NewWriter(f) defer w.Flush() - header := []string{"gomaxprocs", "benchmark", "rps", "mean_latency_ms", "p95_latency_ms"} + // p95 latency is not available from the parallel sweep files — those benchmarks + // only emit ns/op and req/s. p95 data comes exclusively from + // BenchmarkBAPCaller_Discover_Percentiles, which runs at a single GOMAXPROCS + // setting and is not part of the concurrency sweep. + header := []string{"gomaxprocs", "benchmark", "rps", "mean_latency_ms"} if err := w.Write(header); err != nil { return err } @@ -223,7 +222,6 @@ func writeThroughputCSV(path string, rows []cpuResult) error { row.res.name, fmtFloat(row.res.rps), fmtFloat(row.res.nsPerOp / 1e6), - fmtFloat(row.res.p95), } if err := w.Write(r); err != nil { return err From beb0a3205eaf8d33a3229a5f88369f8c5648582b Mon Sep 17 00:00:00 2001 From: Mayuresh Date: Thu, 9 Apr 2026 17:03:49 +0530 Subject: [PATCH 6/9] fix(go.mod): revert accidental go 1.25.0 toolchain bump to go 1.24.6 go mod tidy bumped the directive from 1.24.6 to 1.25.0 because the local machine runs Go 1.25. Nothing in the benchmark code requires 1.25. Pinning back to 1.24.6 keeps this PR consistent with the CI workflows (beckn_ci.yml, beckn_ci_test.yml, build-and-deploy-plugins.yml) which all pin to Go 1.24.x. --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2513d64..2310340 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/beckn-one/beckn-onix -go 1.25.0 +go 1.24.6 require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 From 1c47276aedcc793861861c63ca072a54cc66fc2b Mon Sep 17 00:00:00 2001 From: Mayuresh Date: Thu, 9 Apr 2026 21:34:50 +0530 Subject: [PATCH 7/9] feat(benchmarks): display total runtime at end of run_benchmarks.sh --- benchmarks/run_benchmarks.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/benchmarks/run_benchmarks.sh b/benchmarks/run_benchmarks.sh index d4753d1..ae99cb9 100755 --- a/benchmarks/run_benchmarks.sh +++ b/benchmarks/run_benchmarks.sh @@ -18,6 +18,7 @@ # ============================================================================= set -euo pipefail +SCRIPT_START=$(date +%s) REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" RESULTS_DIR="$REPO_ROOT/benchmarks/results/$(date +%Y-%m-%d_%H-%M-%S)" BENCH_PKG="./benchmarks/e2e/..." @@ -127,10 +128,17 @@ if command -v go &>/dev/null; then fi # ── Summary ─────────────────────────────────────────────────────────────────── +SCRIPT_END=$(date +%s) +ELAPSED_SECS=$(( SCRIPT_END - SCRIPT_START )) +ELAPSED_MIN=$(( ELAPSED_SECS / 60 )) +ELAPSED_SEC_REM=$(( ELAPSED_SECS % 60 )) + echo "" echo "========================================" echo "✅ Benchmark run complete!" echo "" +echo "Total runtime : ${ELAPSED_MIN}m ${ELAPSED_SEC_REM}s" +echo "" echo "Results written to:" echo " $RESULTS_DIR" echo "" From e6accc3f26f6dacb2db7cb0ec420930c4a0657fe Mon Sep 17 00:00:00 2001 From: Mayuresh Date: Thu, 9 Apr 2026 22:01:56 +0530 Subject: [PATCH 8/9] feat(benchmarks): auto-generate BENCHMARK_REPORT.md at end of run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add benchmarks/reports/REPORT_TEMPLATE.md — template with __MARKER__ placeholders for all auto-populated fields (latency, throughput, percentiles, cache delta, benchstat block, environment, ONIX version) - Add benchmarks/tools/generate_report.go — reads latency_report.csv, throughput_report.csv, benchstat_summary.txt and run1.txt metadata, fills the template, and writes BENCHMARK_REPORT.md to the results dir. ONIX version sourced from the latest git tag (falls back to 'dev'). - Update run_benchmarks.sh to call generate_report.go after parse_results.go; also derive ONIX_VERSION from git tag and pass to generator - Update README and directory layout to reflect new files and workflow --- benchmarks/README.md | 17 +- benchmarks/reports/REPORT_TEMPLATE.md | 148 ++++++++++ benchmarks/run_benchmarks.sh | 38 ++- benchmarks/tools/generate_report.go | 401 ++++++++++++++++++++++++++ 4 files changed, 586 insertions(+), 18 deletions(-) create mode 100644 benchmarks/reports/REPORT_TEMPLATE.md create mode 100644 benchmarks/tools/generate_report.go diff --git a/benchmarks/README.md b/benchmarks/README.md index 3aecfd4..963046d 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -100,11 +100,14 @@ benchmarks/ │ ├── init_request.json │ └── confirm_request.json ├── tools/ -│ └── parse_results.go ← CSV exporter for latency + throughput data -├── reports/ ← committed benchmark reports +│ ├── parse_results.go ← CSV exporter for latency + throughput data +│ └── generate_report.go ← fills REPORT_TEMPLATE.md with run data +├── reports/ ← committed benchmark reports and template +│ ├── REPORT_TEMPLATE.md ← template used to generate each run's report │ └── REPORT_ONIX_v150.md ← baseline report (Apple M5, Beckn v2.0.0) └── results/ ← gitignored; created by run_benchmarks.sh └── / + ├── BENCHMARK_REPORT.md — generated human-readable report ├── run1.txt, run2.txt, run3.txt — raw go test -bench output ├── parallel_cpu*.txt — concurrency sweep ├── benchstat_summary.txt — statistical aggregation @@ -122,11 +125,11 @@ Committed reports are stored in `benchmarks/reports/`. Each report documents the |------|----------|-----------------| | `REPORT_ONIX_v150.md` | Apple M5 · darwin/arm64 · GOMAXPROCS=10 | beckn-onix v1.5.0 | -To add a new report after a benchmark run: -1. Run `bash benchmarks/run_benchmarks.sh` — results appear in `benchmarks/results//`. -2. Review `benchstat_summary.txt` and the CSV files. -3. Write a report (see the existing report as a template) and save it as `benchmarks/reports/REPORT_.md`. -4. Commit only the report file; `benchmarks/results/` remains gitignored. +The script auto-generates `BENCHMARK_REPORT.md` in each results directory using `REPORT_TEMPLATE.md`. To permanently record a run: +1. Run `bash benchmarks/run_benchmarks.sh` — `BENCHMARK_REPORT.md` is generated automatically. +2. Review it, fill in the B5 bottleneck analysis section. +3. Copy it to `benchmarks/reports/REPORT_.md` and commit. +4. `benchmarks/results/` stays gitignored; only the curated report goes in. --- diff --git a/benchmarks/reports/REPORT_TEMPLATE.md b/benchmarks/reports/REPORT_TEMPLATE.md new file mode 100644 index 0000000..d116cc1 --- /dev/null +++ b/benchmarks/reports/REPORT_TEMPLATE.md @@ -0,0 +1,148 @@ +# beckn-onix Adapter — Benchmark Report + +> **Run:** `__TIMESTAMP__` +> **Platform:** __CPU__ · __GOOS__/__GOARCH__ · GOMAXPROCS=__GOMAXPROCS__ (default) +> **Adapter version:** __ONIX_VERSION__ +> **Beckn Protocol:** v2.0.0 + +--- + +## Part A — Executive Summary + +### What Was Tested + +The beckn-onix ONIX adapter was benchmarked end-to-end using Go's native `testing.B` +framework and `net/http/httptest`. Requests flowed through a real compiled adapter — +with all production plugins active — against in-process mock servers, isolating +adapter-internal latency from network variables. + +**Pipeline tested (bapTxnCaller):** `addRoute → sign → validateSchema` + +**Plugins active:** `router`, `signer`, `simplekeymanager`, `cache` (miniredis), `schemav2validator` + +**Actions benchmarked:** `discover`, `select`, `init`, `confirm` + +### Key Results + +| Metric | Value | +|--------|-------| +| Serial p50 latency (discover) | **__P50_US__ µs** | +| Serial p95 latency (discover) | **__P95_US__ µs** | +| Serial p99 latency (discover) | **__P99_US__ µs** | +| Serial mean latency (discover) | **__MEAN_DISCOVER_US__ µs** | +| Peak parallel throughput | **__PEAK_RPS__ req/s** | +| Cache warm vs cold delta | **__CACHE_DELTA__** | +| Memory per request (discover) | **~__MEM_DISCOVER_KB__ KB · __ALLOCS_DISCOVER__ allocs** | + +### Interpretation + +_Review the numbers above and add interpretation here._ + +### Recommendation + +_Add sizing and tuning recommendations here._ + +--- + +## Part B — Technical Detail + +### B0 — Test Environment + +| Parameter | Value | +|-----------|-------| +| CPU | __CPU__ (__GOARCH__) | +| OS | __GOOS__/__GOARCH__ | +| Go package | `github.com/beckn-one/beckn-onix/benchmarks/e2e` | +| Default GOMAXPROCS | __GOMAXPROCS__ | +| Benchmark timeout | 30 minutes | +| Serial run duration | 10s per benchmark × 3 runs | +| Parallel sweep duration | 30s per GOMAXPROCS level | +| GOMAXPROCS sweep | 1, 2, 4, 8, 16 | +| Redis | miniredis (in-process, no network) | +| BPP | httptest mock (instant ACK) | +| Registry | httptest mock (dev key pair) | +| Schema spec | Beckn v2.0.0 OpenAPI (`beckn.yaml`, local file) | + +**Plugins and steps (bapTxnCaller):** + +| Step | Plugin | Role | +|------|--------|------| +| 1 | `router` | Resolves BPP URL from routing config | +| 2 | `signer` + `simplekeymanager` | Signs request body (Ed25519/BLAKE-512) | +| 3 | `schemav2validator` | Validates Beckn v2.0 API schema | + +--- + +### B1 — Latency by Action + +Averages from `run1.txt` (10s, GOMAXPROCS=__GOMAXPROCS__). Percentile values from `percentiles.txt`. + +| Action | Mean (µs) | p50 (µs) | p95 (µs) | p99 (µs) | Allocs/req | Bytes/req | +|--------|----------:|--------:|--------:|--------:|----------:|----------:| +| discover (serial) | __MEAN_DISCOVER_US__ | __P50_US__ | __P95_US__ | __P99_US__ | __ALLOCS_DISCOVER__ | __BYTES_DISCOVER__ (~__MEM_DISCOVER_KB__ KB) | +| select | __MEAN_SELECT_US__ | — | — | — | __ALLOCS_SELECT__ | __BYTES_SELECT__ (~__MEM_SELECT_KB__ KB) | +| init | __MEAN_INIT_US__ | — | — | — | __ALLOCS_INIT__ | __BYTES_INIT__ (~__MEM_INIT_KB__ KB) | +| confirm | __MEAN_CONFIRM_US__ | — | — | — | __ALLOCS_CONFIRM__ | __BYTES_CONFIRM__ (~__MEM_CONFIRM_KB__ KB) | + +--- + +### B2 — Throughput vs Concurrency + +Results from the concurrency sweep (`parallel_cpu*.txt`, 30s per level). + +__THROUGHPUT_TABLE__ + +--- + +### B3 — Cache Impact (Redis warm vs cold) + +Results from `cache_comparison.txt` (10s each, GOMAXPROCS=__GOMAXPROCS__). + +| Scenario | Mean (µs) | Allocs/req | Bytes/req | +|----------|----------:|-----------:|----------:| +| CacheWarm | __CACHE_WARM_US__ | __CACHE_WARM_ALLOCS__ | __CACHE_WARM_BYTES__ | +| CacheCold | __CACHE_COLD_US__ | __CACHE_COLD_ALLOCS__ | __CACHE_COLD_BYTES__ | +| **Delta** | **__CACHE_DELTA__** | — | — | + +--- + +### B4 — benchstat Statistical Summary (3 Runs) + +``` +__BENCHSTAT_SUMMARY__ +``` + +--- + +### B5 — Bottleneck Analysis + +> Populate after reviewing the numbers above and profiling with `go tool pprof`. + +| Rank | Plugin / Step | Estimated contribution | Evidence | +|:----:|---------------|------------------------|---------| +| 1 | | | | +| 2 | | | | +| 3 | | | | + +**Profiling commands:** + +```bash +# CPU profile +go test ./benchmarks/e2e/... -bench=BenchmarkBAPCaller_Discover \ + -benchtime=30s -cpuprofile=benchmarks/results/cpu.prof -timeout=5m +go tool pprof -http=:6060 benchmarks/results/cpu.prof + +# Memory profile +go test ./benchmarks/e2e/... -bench=BenchmarkBAPCaller_Discover \ + -benchtime=30s -memprofile=benchmarks/results/mem.prof -timeout=5m +go tool pprof -http=:6060 benchmarks/results/mem.prof + +# Lock contention (find serialisation under parallel load) +go test ./benchmarks/e2e/... -bench=BenchmarkBAPCaller_Discover_Parallel \ + -benchtime=30s -mutexprofile=benchmarks/results/mutex.prof -timeout=5m +go tool pprof -http=:6060 benchmarks/results/mutex.prof +``` + +--- + +*Generated from run `__TIMESTAMP__` · beckn-onix __ONIX_VERSION__ · Beckn Protocol v2.0.0* diff --git a/benchmarks/run_benchmarks.sh b/benchmarks/run_benchmarks.sh index ae99cb9..5825644 100755 --- a/benchmarks/run_benchmarks.sh +++ b/benchmarks/run_benchmarks.sh @@ -23,10 +23,13 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" RESULTS_DIR="$REPO_ROOT/benchmarks/results/$(date +%Y-%m-%d_%H-%M-%S)" BENCH_PKG="./benchmarks/e2e/..." BENCH_TIMEOUT="10m" -# ── Smoke-test values (small): swap to the "full run" values below once stable ─ -BENCH_TIME_SERIAL="10s" # full run: 10s -BENCH_TIME_PARALLEL="30s" # full run: 30s -BENCH_COUNT=1 # full run: keep at 1; benchstat uses the 3 serial files +BENCH_TIME_SERIAL="10s" +BENCH_TIME_PARALLEL="30s" +BENCH_COUNT=1 # benchstat uses the 3 serial files for stability + +# Adapter version — reads from git tag, falls back to "dev" +ONIX_VERSION="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 2>/dev/null || echo "dev")" +REPORT_TEMPLATE="$REPO_ROOT/benchmarks/reports/REPORT_TEMPLATE.md" cd "$REPO_ROOT" @@ -120,11 +123,21 @@ echo " Saved → $RESULTS_DIR/benchstat_summary.txt" echo "" # ── Parse results to CSV ────────────────────────────────────────────────────── -if command -v go &>/dev/null; then - echo "Parsing results to CSV..." - go run benchmarks/tools/parse_results.go \ +echo "Parsing results to CSV..." +go run "$REPO_ROOT/benchmarks/tools/parse_results.go" \ + -dir="$RESULTS_DIR" \ + -out="$RESULTS_DIR" 2>&1 || echo " (parse_results.go: skipping on error)" +echo "" + +# ── Generate human-readable report ─────────────────────────────────────────── +echo "Generating benchmark report..." +if [[ -f "$REPORT_TEMPLATE" ]]; then + go run "$REPO_ROOT/benchmarks/tools/generate_report.go" \ -dir="$RESULTS_DIR" \ - -out="$RESULTS_DIR" 2>&1 || echo " (parse_results.go: optional step, skipping if errors)" + -template="$REPORT_TEMPLATE" \ + -version="$ONIX_VERSION" 2>&1 || echo " (generate_report.go: skipping on error)" +else + echo " WARNING: template not found at $REPORT_TEMPLATE — skipping report generation" fi # ── Summary ─────────────────────────────────────────────────────────────────── @@ -143,11 +156,14 @@ echo "Results written to:" echo " $RESULTS_DIR" echo "" echo "Key files:" +echo " BENCHMARK_REPORT.md — generated human-readable report" echo " benchstat_summary.txt — statistical analysis of 3 serial runs" -echo " parallel_cpu*.txt — concurrency sweep results" +echo " latency_report.csv — per-benchmark latency and allocation data" +echo " throughput_report.csv — RPS and latency by GOMAXPROCS level" +echo " parallel_cpu*.txt — concurrency sweep raw output" echo " percentiles.txt — p50/p95/p99 latency data" echo " cache_comparison.txt — warm vs cold Redis cache comparison" echo "" -echo "To view benchstat summary:" -echo " cat $RESULTS_DIR/benchstat_summary.txt" +echo "To review the report:" +echo " open $RESULTS_DIR/BENCHMARK_REPORT.md" echo "========================================" diff --git a/benchmarks/tools/generate_report.go b/benchmarks/tools/generate_report.go new file mode 100644 index 0000000..e4f90a0 --- /dev/null +++ b/benchmarks/tools/generate_report.go @@ -0,0 +1,401 @@ +// generate_report.go — Fills REPORT_TEMPLATE.md with data from a completed +// benchmark run and writes BENCHMARK_REPORT.md to the results directory. +// +// Usage: +// +// go run benchmarks/tools/generate_report.go \ +// -dir=benchmarks/results// \ +// -template=benchmarks/reports/REPORT_TEMPLATE.md \ +// -version= +// +// The generator reads: +// - latency_report.csv — per-benchmark latency and allocation data +// - throughput_report.csv — RPS and latency by GOMAXPROCS level +// - benchstat_summary.txt — raw benchstat output block +// - run1.txt — goos / goarch / cpu metadata +// +// Placeholders filled in the template: +// +// __TIMESTAMP__ results dir basename (YYYY-MM-DD_HH-MM-SS) +// __ONIX_VERSION__ -version flag value +// __GOOS__ from run1.txt header +// __GOARCH__ from run1.txt header +// __CPU__ from run1.txt header +// __GOMAXPROCS__ derived from the benchmark name suffix in run1.txt +// __P50_US__ p50 latency in µs (from Discover_Percentiles row) +// __P95_US__ p95 latency in µs +// __P99_US__ p99 latency in µs +// __MEAN_DISCOVER_US__ mean latency in µs for discover +// __MEAN_SELECT_US__ mean latency in µs for select +// __MEAN_INIT_US__ mean latency in µs for init +// __MEAN_CONFIRM_US__ mean latency in µs for confirm +// __ALLOCS_DISCOVER__ allocs/req for discover +// __ALLOCS_SELECT__ allocs/req for select +// __ALLOCS_INIT__ allocs/req for init +// __ALLOCS_CONFIRM__ allocs/req for confirm +// __BYTES_DISCOVER__ bytes/req for discover +// __BYTES_SELECT__ bytes/req for select +// __BYTES_INIT__ bytes/req for init +// __BYTES_CONFIRM__ bytes/req for confirm +// __MEM_DISCOVER_KB__ bytes/req converted to KB for discover +// __MEM_SELECT_KB__ bytes/req converted to KB for select +// __MEM_INIT_KB__ bytes/req converted to KB for init +// __MEM_CONFIRM_KB__ bytes/req converted to KB for confirm +// __PEAK_RPS__ highest RPS across all GOMAXPROCS levels +// __CACHE_WARM_US__ mean latency in µs for CacheWarm +// __CACHE_COLD_US__ mean latency in µs for CacheCold +// __CACHE_WARM_ALLOCS__ allocs/req for CacheWarm +// __CACHE_COLD_ALLOCS__ allocs/req for CacheCold +// __CACHE_WARM_BYTES__ bytes/req for CacheWarm +// __CACHE_COLD_BYTES__ bytes/req for CacheCold +// __CACHE_DELTA__ formatted warm-vs-cold delta string +// __THROUGHPUT_TABLE__ generated markdown table from throughput_report.csv +// __BENCHSTAT_SUMMARY__ raw contents of benchstat_summary.txt +package main + +import ( + "bufio" + "encoding/csv" + "flag" + "fmt" + "io" + "math" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +func main() { + dir := flag.String("dir", "", "Results directory (required)") + tmplPath := flag.String("template", "benchmarks/reports/REPORT_TEMPLATE.md", "Path to report template") + version := flag.String("version", "unknown", "Adapter version (e.g. v1.5.0)") + flag.Parse() + + if *dir == "" { + fmt.Fprintln(os.Stderr, "ERROR: -dir is required") + os.Exit(1) + } + + // Derive timestamp from the directory basename. + timestamp := filepath.Base(*dir) + + // ── Read template ────────────────────────────────────────────────────────── + tmplBytes, err := os.ReadFile(*tmplPath) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: reading template %s: %v\n", *tmplPath, err) + os.Exit(1) + } + report := string(tmplBytes) + + // ── Parse run1.txt for environment metadata ──────────────────────────────── + env := parseEnv(filepath.Join(*dir, "run1.txt")) + + // ── Parse latency_report.csv ────────────────────────────────────────────── + latency, err := parseLatencyCSV(filepath.Join(*dir, "latency_report.csv")) + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: could not parse latency_report.csv: %v\n", err) + } + + // ── Parse throughput_report.csv ─────────────────────────────────────────── + throughput, err := parseThroughputCSV(filepath.Join(*dir, "throughput_report.csv")) + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: could not parse throughput_report.csv: %v\n", err) + } + + // ── Read benchstat_summary.txt ──────────────────────────────────────────── + benchstat := readFileOrDefault(filepath.Join(*dir, "benchstat_summary.txt"), + "(benchstat output not available)") + + // ── Compute derived values ───────────────────────────────────────────────── + + // Mean latency: convert ms → µs, round to integer. + meanDiscoverUS := msToUS(latency["BenchmarkBAPCaller_Discover"]["mean_ms"]) + meanSelectUS := msToUS(latency["BenchmarkBAPCaller_AllActions/select"]["mean_ms"]) + meanInitUS := msToUS(latency["BenchmarkBAPCaller_AllActions/init"]["mean_ms"]) + meanConfirmUS := msToUS(latency["BenchmarkBAPCaller_AllActions/confirm"]["mean_ms"]) + + // Percentiles come from the Discover_Percentiles row. + perc := latency["BenchmarkBAPCaller_Discover_Percentiles"] + p50 := fmtMetric(perc["p50_µs"], "µs") + p95 := fmtMetric(perc["p95_µs"], "µs") + p99 := fmtMetric(perc["p99_µs"], "µs") + + // Memory: bytes → KB (1 decimal place). + memDiscoverKB := bytesToKB(latency["BenchmarkBAPCaller_Discover"]["bytes_op"]) + memSelectKB := bytesToKB(latency["BenchmarkBAPCaller_AllActions/select"]["bytes_op"]) + memInitKB := bytesToKB(latency["BenchmarkBAPCaller_AllActions/init"]["bytes_op"]) + memConfirmKB := bytesToKB(latency["BenchmarkBAPCaller_AllActions/confirm"]["bytes_op"]) + + // Cache delta. + warmUS := msToUS(latency["BenchmarkBAPCaller_CacheWarm"]["mean_ms"]) + coldUS := msToUS(latency["BenchmarkBAPCaller_CacheCold"]["mean_ms"]) + cacheDelta := formatCacheDelta(warmUS, coldUS) + + // Peak RPS across all concurrency levels. + peakRPS := "—" + var peakRPSVal float64 + for _, row := range throughput { + if v := parseFloatOrZero(row["rps"]); v > peakRPSVal { + peakRPSVal = v + peakRPS = fmt.Sprintf("%.0f", peakRPSVal) + } + } + + // ── Build throughput table ───────────────────────────────────────────────── + throughputTable := buildThroughputTable(throughput) + + // ── Apply substitutions ──────────────────────────────────────────────────── + replacements := map[string]string{ + "__TIMESTAMP__": timestamp, + "__ONIX_VERSION__": *version, + "__GOOS__": env["goos"], + "__GOARCH__": env["goarch"], + "__CPU__": env["cpu"], + "__GOMAXPROCS__": env["gomaxprocs"], + "__P50_US__": p50, + "__P95_US__": p95, + "__P99_US__": p99, + "__MEAN_DISCOVER_US__": meanDiscoverUS, + "__MEAN_SELECT_US__": meanSelectUS, + "__MEAN_INIT_US__": meanInitUS, + "__MEAN_CONFIRM_US__": meanConfirmUS, + "__ALLOCS_DISCOVER__": fmtInt(latency["BenchmarkBAPCaller_Discover"]["allocs_op"]), + "__ALLOCS_SELECT__": fmtInt(latency["BenchmarkBAPCaller_AllActions/select"]["allocs_op"]), + "__ALLOCS_INIT__": fmtInt(latency["BenchmarkBAPCaller_AllActions/init"]["allocs_op"]), + "__ALLOCS_CONFIRM__": fmtInt(latency["BenchmarkBAPCaller_AllActions/confirm"]["allocs_op"]), + "__BYTES_DISCOVER__": fmtInt(latency["BenchmarkBAPCaller_Discover"]["bytes_op"]), + "__BYTES_SELECT__": fmtInt(latency["BenchmarkBAPCaller_AllActions/select"]["bytes_op"]), + "__BYTES_INIT__": fmtInt(latency["BenchmarkBAPCaller_AllActions/init"]["bytes_op"]), + "__BYTES_CONFIRM__": fmtInt(latency["BenchmarkBAPCaller_AllActions/confirm"]["bytes_op"]), + "__MEM_DISCOVER_KB__": memDiscoverKB, + "__MEM_SELECT_KB__": memSelectKB, + "__MEM_INIT_KB__": memInitKB, + "__MEM_CONFIRM_KB__": memConfirmKB, + "__PEAK_RPS__": peakRPS, + "__CACHE_WARM_US__": warmUS, + "__CACHE_COLD_US__": coldUS, + "__CACHE_WARM_ALLOCS__": fmtInt(latency["BenchmarkBAPCaller_CacheWarm"]["allocs_op"]), + "__CACHE_COLD_ALLOCS__": fmtInt(latency["BenchmarkBAPCaller_CacheCold"]["allocs_op"]), + "__CACHE_WARM_BYTES__": fmtInt(latency["BenchmarkBAPCaller_CacheWarm"]["bytes_op"]), + "__CACHE_COLD_BYTES__": fmtInt(latency["BenchmarkBAPCaller_CacheCold"]["bytes_op"]), + "__CACHE_DELTA__": cacheDelta, + "__THROUGHPUT_TABLE__": throughputTable, + "__BENCHSTAT_SUMMARY__": benchstat, + } + + for placeholder, value := range replacements { + report = strings.ReplaceAll(report, placeholder, value) + } + + // ── Write output ─────────────────────────────────────────────────────────── + outPath := filepath.Join(*dir, "BENCHMARK_REPORT.md") + if err := os.WriteFile(outPath, []byte(report), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: writing report: %v\n", err) + os.Exit(1) + } + fmt.Printf(" Written → %s\n", outPath) +} + +// ── Parsers ──────────────────────────────────────────────────────────────────── + +var gomaxprocsRe = regexp.MustCompile(`-(\d+)$`) + +// parseEnv reads goos, goarch, cpu, and GOMAXPROCS from a run*.txt file header. +func parseEnv(path string) map[string]string { + env := map[string]string{ + "goos": "unknown", "goarch": "unknown", + "cpu": "unknown", "gomaxprocs": "unknown", + } + f, err := os.Open(path) + if err != nil { + return env + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + switch { + case strings.HasPrefix(line, "goos:"): + env["goos"] = strings.TrimSpace(strings.TrimPrefix(line, "goos:")) + case strings.HasPrefix(line, "goarch:"): + env["goarch"] = strings.TrimSpace(strings.TrimPrefix(line, "goarch:")) + case strings.HasPrefix(line, "cpu:"): + env["cpu"] = strings.TrimSpace(strings.TrimPrefix(line, "cpu:")) + case strings.HasPrefix(line, "Benchmark"): + // Extract GOMAXPROCS from first benchmark line suffix (e.g. "-10"). + if m := gomaxprocsRe.FindStringSubmatch(strings.Fields(line)[0]); m != nil { + env["gomaxprocs"] = m[1] + } + } + } + return env +} + +// parseLatencyCSV returns a map of benchmark name → field name → raw string value. +// When multiple rows exist for the same benchmark (3 serial runs), values from +// the first non-empty occurrence are used. +func parseLatencyCSV(path string) (map[string]map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + r := csv.NewReader(f) + header, err := r.Read() + if err != nil { + return nil, err + } + + result := map[string]map[string]string{} + for { + row, err := r.Read() + if err == io.EOF { + break + } + if err != nil || len(row) == 0 { + continue + } + name := row[0] + if _, exists := result[name]; !exists { + result[name] = map[string]string{} + } + for i, col := range header[1:] { + idx := i + 1 + if idx < len(row) && row[idx] != "" && result[name][col] == "" { + result[name][col] = row[idx] + } + } + } + return result, nil +} + +// parseThroughputCSV returns rows as a slice of field maps. +func parseThroughputCSV(path string) ([]map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + r := csv.NewReader(f) + header, err := r.Read() + if err != nil { + return nil, err + } + + var rows []map[string]string + for { + row, err := r.Read() + if err == io.EOF { + break + } + if err != nil || len(row) == 0 { + continue + } + m := map[string]string{} + for i, col := range header { + if i < len(row) { + m[col] = row[i] + } + } + rows = append(rows, m) + } + return rows, nil +} + +// buildThroughputTable renders the throughput CSV as a markdown table. +func buildThroughputTable(rows []map[string]string) string { + if len(rows) == 0 { + return "_No concurrency sweep data available._" + } + var sb strings.Builder + sb.WriteString("| GOMAXPROCS | Mean Latency (µs) | RPS |\n") + sb.WriteString("|:----------:|------------------:|----:|\n") + for _, row := range rows { + cpu := orDash(row["gomaxprocs"]) + latUS := "—" + if v := parseFloatOrZero(row["mean_latency_ms"]); v > 0 { + latUS = fmt.Sprintf("%.0f", v*1000) + } + rps := orDash(row["rps"]) + sb.WriteString(fmt.Sprintf("| %s | %s | %s |\n", cpu, latUS, rps)) + } + return sb.String() +} + +// ── Formatters ───────────────────────────────────────────────────────────────── + +// msToUS converts a ms string to a rounded µs string. +func msToUS(ms string) string { + v := parseFloatOrZero(ms) + if v == 0 { + return "—" + } + return fmt.Sprintf("%.0f", v*1000) +} + +// bytesToKB converts a bytes string to a KB string with 1 decimal place. +func bytesToKB(bytes string) string { + v := parseFloatOrZero(bytes) + if v == 0 { + return "—" + } + return fmt.Sprintf("%.1f", v/1024) +} + +// fmtInt formats a float string as a rounded integer string. +func fmtInt(s string) string { + v := parseFloatOrZero(s) + if v == 0 { + return "—" + } + return fmt.Sprintf("%.0f", math.Round(v)) +} + +// fmtMetric formats a metric value with the given unit, or returns "—". +func fmtMetric(s, unit string) string { + v := parseFloatOrZero(s) + if v == 0 { + return "—" + } + return fmt.Sprintf("%.0f %s", v, unit) +} + +// formatCacheDelta produces a human-readable warm-vs-cold delta string. +func formatCacheDelta(warmUS, coldUS string) string { + w := parseFloatOrZero(warmUS) + c := parseFloatOrZero(coldUS) + if w == 0 || c == 0 { + return "—" + } + delta := w - c + sign := "+" + if delta < 0 { + sign = "" + } + return fmt.Sprintf("%s%.0f µs (warm vs cold)", sign, delta) +} + +func orDash(s string) string { + if s == "" { + return "—" + } + return s +} + +func parseFloatOrZero(s string) float64 { + v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64) + return v +} + +func readFileOrDefault(path, def string) string { + b, err := os.ReadFile(path) + if err != nil { + return def + } + return strings.TrimRight(string(b), "\n") +} From e0d7e3508f960ddcaba38e6cfdeb049cc2cd4d1d Mon Sep 17 00:00:00 2001 From: Mayuresh Date: Thu, 9 Apr 2026 22:34:52 +0530 Subject: [PATCH 9/9] feat(benchmarks): auto-generate Interpretation/Recommendation and add -report-only flag generate_report.go: - buildInterpretation: derives narrative from p99/p50 tail-latency ratio, per-action complexity trend (% increase vs discover baseline), concurrency scaling efficiency (GOMAXPROCS=1 vs 16), and cache warm/cold delta - buildRecommendation: identifies the best throughput/cost GOMAXPROCS level from scaling efficiency and adds production sizing guidance run_benchmarks.sh: - Add -report-only flag: re-runs parse_results.go + generate_report.go against an existing results directory without rerunning benchmarks REPORT_TEMPLATE.md: - Replace manual placeholders with __INTERPRETATION__ and __RECOMMENDATION__ markers filled by the generator --- benchmarks/reports/REPORT_TEMPLATE.md | 4 +- benchmarks/run_benchmarks.sh | 33 ++++- benchmarks/tools/generate_report.go | 196 +++++++++++++++++++++++++- 3 files changed, 229 insertions(+), 4 deletions(-) diff --git a/benchmarks/reports/REPORT_TEMPLATE.md b/benchmarks/reports/REPORT_TEMPLATE.md index d116cc1..04beec7 100644 --- a/benchmarks/reports/REPORT_TEMPLATE.md +++ b/benchmarks/reports/REPORT_TEMPLATE.md @@ -36,11 +36,11 @@ adapter-internal latency from network variables. ### Interpretation -_Review the numbers above and add interpretation here._ +__INTERPRETATION__ ### Recommendation -_Add sizing and tuning recommendations here._ +__RECOMMENDATION__ --- diff --git a/benchmarks/run_benchmarks.sh b/benchmarks/run_benchmarks.sh index 5825644..d8575b9 100755 --- a/benchmarks/run_benchmarks.sh +++ b/benchmarks/run_benchmarks.sh @@ -20,7 +20,6 @@ set -euo pipefail SCRIPT_START=$(date +%s) REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -RESULTS_DIR="$REPO_ROOT/benchmarks/results/$(date +%Y-%m-%d_%H-%M-%S)" BENCH_PKG="./benchmarks/e2e/..." BENCH_TIMEOUT="10m" BENCH_TIME_SERIAL="10s" @@ -31,6 +30,38 @@ BENCH_COUNT=1 # benchstat uses the 3 serial files for stability ONIX_VERSION="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 2>/dev/null || echo "dev")" REPORT_TEMPLATE="$REPO_ROOT/benchmarks/reports/REPORT_TEMPLATE.md" +# ── -report-only : regenerate report from an existing results directory ── +if [[ "${1:-}" == "-report-only" ]]; then + RESULTS_DIR="${2:-}" + if [[ -z "$RESULTS_DIR" ]]; then + echo "Usage: bash benchmarks/run_benchmarks.sh -report-only " + echo "Example: bash benchmarks/run_benchmarks.sh -report-only benchmarks/results/2026-04-09_10-30-00" + exit 1 + fi + if [[ ! -d "$RESULTS_DIR" ]]; then + echo "ERROR: results directory not found: $RESULTS_DIR" + exit 1 + fi + echo "=== Regenerating report from existing results ===" + echo "Results dir : $RESULTS_DIR" + echo "" + cd "$REPO_ROOT" + echo "Parsing results to CSV..." + go run "$REPO_ROOT/benchmarks/tools/parse_results.go" \ + -dir="$RESULTS_DIR" -out="$RESULTS_DIR" 2>&1 || true + echo "" + echo "Generating benchmark report..." + go run "$REPO_ROOT/benchmarks/tools/generate_report.go" \ + -dir="$RESULTS_DIR" \ + -template="$REPORT_TEMPLATE" \ + -version="$ONIX_VERSION" + echo "" + echo "Done. Report written to: $RESULTS_DIR/BENCHMARK_REPORT.md" + exit 0 +fi + +RESULTS_DIR="$REPO_ROOT/benchmarks/results/$(date +%Y-%m-%d_%H-%M-%S)" + cd "$REPO_ROOT" # ── benchstat is declared as a go tool in go.mod; no separate install needed ── diff --git a/benchmarks/tools/generate_report.go b/benchmarks/tools/generate_report.go index e4f90a0..1a5a235 100644 --- a/benchmarks/tools/generate_report.go +++ b/benchmarks/tools/generate_report.go @@ -146,6 +146,10 @@ func main() { // ── Build throughput table ───────────────────────────────────────────────── throughputTable := buildThroughputTable(throughput) + // ── Generate interpretation and recommendation ───────────────────────────── + interpretation := buildInterpretation(perc, latency, throughput, warmUS, coldUS) + recommendation := buildRecommendation(throughput) + // ── Apply substitutions ──────────────────────────────────────────────────── replacements := map[string]string{ "__TIMESTAMP__": timestamp, @@ -181,8 +185,10 @@ func main() { "__CACHE_WARM_BYTES__": fmtInt(latency["BenchmarkBAPCaller_CacheWarm"]["bytes_op"]), "__CACHE_COLD_BYTES__": fmtInt(latency["BenchmarkBAPCaller_CacheCold"]["bytes_op"]), "__CACHE_DELTA__": cacheDelta, - "__THROUGHPUT_TABLE__": throughputTable, + "__THROUGHPUT_TABLE__": throughputTable, "__BENCHSTAT_SUMMARY__": benchstat, + "__INTERPRETATION__": interpretation, + "__RECOMMENDATION__": recommendation, } for placeholder, value := range replacements { @@ -399,3 +405,191 @@ func readFileOrDefault(path, def string) string { } return strings.TrimRight(string(b), "\n") } + +// ── Narrative generators ─────────────────────────────────────────────────────── + +// buildInterpretation generates a data-driven interpretation paragraph from the +// benchmark results. It covers tail-latency control, action complexity trend, +// concurrency scaling efficiency, and cache impact. +func buildInterpretation( + perc map[string]string, + latency map[string]map[string]string, + throughput []map[string]string, + warmUS, coldUS string, +) string { + var sb strings.Builder + + p50 := parseFloatOrZero(perc["p50_µs"]) + p99 := parseFloatOrZero(perc["p99_µs"]) + meanDiscover := parseFloatOrZero(latency["BenchmarkBAPCaller_Discover"]["mean_ms"]) * 1000 + + // Tail-latency control. + if p50 > 0 && p99 > 0 { + ratio := p99 / p50 + quality := "good" + if ratio > 5 { + quality = "poor" + } else if ratio > 3 { + quality = "moderate" + } + sb.WriteString(fmt.Sprintf( + "The adapter delivers a p50 latency of **%.0f µs** for the discover action. "+ + "The p99/p50 ratio is **%.1f×**, indicating %s tail-latency control — "+ + "spikes are %s relative to the median.\n\n", + p50, ratio, quality, tailDescription(ratio), + )) + } else if meanDiscover > 0 { + sb.WriteString(fmt.Sprintf( + "The adapter delivers a mean latency of **%.0f µs** for the discover action. "+ + "Run with `-bench=BenchmarkBAPCaller_Discover_Percentiles` to obtain p50/p95/p99 data.\n\n", + meanDiscover, + )) + } + + // Action complexity trend. + selectMS := parseFloatOrZero(latency["BenchmarkBAPCaller_AllActions/select"]["mean_ms"]) * 1000 + initMS := parseFloatOrZero(latency["BenchmarkBAPCaller_AllActions/init"]["mean_ms"]) * 1000 + confirmMS := parseFloatOrZero(latency["BenchmarkBAPCaller_AllActions/confirm"]["mean_ms"]) * 1000 + if meanDiscover > 0 && selectMS > 0 && initMS > 0 && confirmMS > 0 { + sb.WriteString(fmt.Sprintf( + "Latency scales with payload complexity: select (+%.0f%%), init (+%.0f%%), confirm (+%.0f%%) "+ + "vs the discover baseline. Allocation counts track proportionally, driven by JSON "+ + "unmarshalling and schema validation of larger payloads.\n\n", + pctChange(meanDiscover, selectMS), + pctChange(meanDiscover, initMS), + pctChange(meanDiscover, confirmMS), + )) + } + + // Concurrency scaling. + lat1 := latencyAtCPU(throughput, "1") + lat16 := latencyAtCPU(throughput, "16") + if lat1 > 0 && lat16 > 0 { + improvement := lat1 / lat16 + sb.WriteString(fmt.Sprintf( + "Concurrency scaling is effective: mean latency drops from **%.0f µs** at GOMAXPROCS=1 "+ + "to **%.0f µs** at GOMAXPROCS=16 — a **%.1f× improvement**.", + lat1*1000, lat16*1000, improvement, + )) + if improvement < 4 { + sb.WriteString(" Gains taper beyond 8 cores, suggesting a shared serialisation point " + + "(likely schema validation or key derivation).") + } + sb.WriteString("\n\n") + } + + // Cache impact. + w := parseFloatOrZero(warmUS) + c := parseFloatOrZero(coldUS) + if w > 0 && c > 0 { + delta := math.Abs(w-c) / w * 100 + if delta < 5 { + sb.WriteString(fmt.Sprintf( + "The Redis key-manager cache shows **no measurable impact** in this setup "+ + "(warm vs cold delta: %.0f µs, %.1f%% of mean). "+ + "miniredis is in-process; signing and schema validation dominate. "+ + "Cache benefit would be visible with real Redis over a network.", + math.Abs(w-c), delta, + )) + } else { + sb.WriteString(fmt.Sprintf( + "The Redis key-manager cache provides a **%.0f µs improvement** (%.1f%%) "+ + "on the warm path vs cold.", + math.Abs(w-c), delta, + )) + } + sb.WriteString("\n") + } + + if sb.Len() == 0 { + return "_Insufficient data to generate interpretation. Ensure all benchmark scenarios completed successfully._" + } + return strings.TrimRight(sb.String(), "\n") +} + +// buildRecommendation generates a sizing and tuning recommendation based on the +// concurrency sweep results. +func buildRecommendation(throughput []map[string]string) string { + if len(throughput) == 0 { + return "_Run the concurrency sweep to generate sizing recommendations._" + } + + // Find the GOMAXPROCS level with best scaling efficiency (RPS gain per core). + type cpuPoint struct { + cpu int + rps float64 + lat float64 + } + var points []cpuPoint + for _, row := range throughput { + cpu := int(parseFloatOrZero(row["gomaxprocs"])) + rps := parseFloatOrZero(row["rps"]) + lat := parseFloatOrZero(row["mean_latency_ms"]) * 1000 + if cpu > 0 && lat > 0 { + points = append(points, cpuPoint{cpu, rps, lat}) + } + } + + if len(points) == 0 { + return "_Run the concurrency sweep (parallel_cpu*.txt) to generate sizing recommendations._" + } + + // Find sweet spot: largest latency improvement per doubling of cores. + bestEffCPU := points[0].cpu + bestEff := 0.0 + for i := 1; i < len(points); i++ { + if points[i-1].lat > 0 { + eff := (points[i-1].lat - points[i].lat) / points[i-1].lat + if eff > bestEff { + bestEff = eff + bestEffCPU = points[i].cpu + } + } + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf( + "**%d cores** offers the best throughput/cost ratio based on the concurrency sweep — "+ + "scaling efficiency begins to taper beyond this point.\n\n", + bestEffCPU, + )) + sb.WriteString("The adapter is ready for staged load testing against a real BPP. " + + "For production sizing, start with the recommended core count above and adjust based " + + "on observed throughput targets. If schema validation dominates CPU (likely at high " + + "concurrency), profile with `go tool pprof` using the commands in B5 to isolate the bottleneck.") + + return sb.String() +} + +// ── Narrative helpers ────────────────────────────────────────────────────────── + +func tailDescription(ratio float64) string { + switch { + case ratio <= 2: + return "minimal" + case ratio <= 3: + return "modest" + case ratio <= 5: + return "noticeable" + default: + return "significant" + } +} + +func pctChange(base, val float64) float64 { + if base == 0 { + return 0 + } + return (val - base) / base * 100 +} + +func latencyAtCPU(throughput []map[string]string, cpu string) float64 { + for _, row := range throughput { + if row["gomaxprocs"] == cpu { + if v := parseFloatOrZero(row["mean_latency_ms"]); v > 0 { + return v + } + } + } + return 0 +}