Issue #1646. How to find, tune, measure, and watch QuorumProof performance. Related: capacity-planning.md (sizing and load-test reference numbers), perf-regression.md (contract budget regression gates), cost-optimization-guide.md (fees), websocket-scaling.md.
Tune in this order: measure → find the bottleneck → change one parameter → re-measure. Every parameter below is an environment variable or constant that already exists; defaults are shown so you know what you're moving away from.
Ordered by how often they are the actual limit in production.
Every verification, credential read and dashboard query goes through
simulateCall to Soroban RPC (api-server/src/soroban.ts). At ~80 ms per
round-trip this dwarfs Express routing and JSON work (<5 ms). The load test
shows verification throughput tracks RPC latency almost linearly — see
capacity-planning.md.
Symptoms: p95 latency rises with no CPU increase; quorumproof_rpc_circuit_breaker_failures_total
climbs; /api/verify queue grows.
Levers: caching (§2.4), batching (POST /api/credentials/verify-batch, max 50),
a closer/dedicated RPC endpoint, the circuit breaker (§2.3).
Contract cost is CPU instructions and memory bytes metered by Soroban, not
wall-clock. The expensive paths, from benches/tests/benchmarks.rs:
| Operation | Why it's expensive |
|---|---|
verify_engineer |
Cross-contract QuorumProof → SbtRegistry → ZkVerifier. |
verify_plonk_proof, verify_aggregate_proof |
Pairing / curve arithmetic. |
batch_issue_credentials, verify_attestations_batch |
Linear in batch size; can hit the per-transaction limit. |
Slice intersection (benches/tests/intersection_benchmarks.rs) |
Grows with attestor count per slice. |
Symptoms: ExceededLimit / budget errors, rising fees, benchmark gate
failures in the Benchmarks workflow.
Levers: smaller batches, fewer attestors per slice, persistent vs temporary
storage choice, avoiding repeated env.storage() reads in loops (read once,
pass values), verifying proofs off-chain first where the protocol allows.
createConcurrencyLimiter (api-server/src/index.ts) caps in-flight requests
and queues the excess. When RPC slows down, requests hold slots longer, the
queue fills, and callers wait up to CONCURRENCY_MAX_WAIT_MS before a 503.
Symptoms: latency cliff (not slope) under load; 503s with queue-full messages.
Postgres-backed routes share the pool in api-server/src/db.ts. An undersized
pool shows up as quorumproof_db_pool_waiting > 0; an oversized one exhausts
Postgres max_connections once you scale replicas.
Each broadcast is serialised per subscriber. Slow consumers fill their send
queue and messages are dropped (quorumproof_ws_messages_dropped_total).
Cross-instance fan-out adds a Redis pub/sub hop when REDIS_URL is set.
The /api chain runs CORS, compression, DDoS protection, structured logging,
tracing, request dedup, request signing (HMAC), IP allow-list, PoW, adaptive
rate limiting and concurrency limiting on every request. Individually cheap;
together they matter for small, high-rate requests. Compression and HMAC
verification are the most CPU-intensive.
| Variable | Default | Tune when |
|---|---|---|
CONCURRENCY_MAX |
100 |
Raise if CPU < 60% and requests are queuing; lower if a replica OOMs under burst. |
CONCURRENCY_MAX_QUEUE |
200 |
Lower to fail fast (better for clients with retries); raise to absorb short bursts. |
CONCURRENCY_MAX_WAIT_MS |
5000 |
Keep below the client/ingress timeout, or clients time out on requests the server still processes. |
CONCURRENCY_VERIFY_MAX |
20 |
Upper bound ≈ RPC endpoint's sustainable concurrency ÷ replicas. |
CONCURRENCY_CREDENTIALS_MAX |
50 |
Same reasoning for credential reads. |
Rule of thumb (Little's law): CONCURRENCY_VERIFY_MAX ≈ target_rps × p95_rpc_latency_s.
For 200 verify req/s at 80 ms → 16; the default 20 leaves headroom.
| Variable | Default | Notes |
|---|---|---|
RATE_LIMIT_WINDOW_MS |
60000 |
|
RATE_LIMIT_MAX |
100 |
Per-client per window. Raise for trusted integrators via API keys rather than globally. |
RATE_LIMIT_AUTH_MAX |
20 |
Keep low — brute-force protection. |
RATE_LIMIT_BACKOFF |
2 |
Multiplier applied on repeated violations. |
RATE_LIMIT_ANOMALY_THRESHOLD |
3 |
Std-devs above baseline treated as anomalous. |
POW_RATE_LIMITING_ENABLED |
true |
PoW adds client-side cost only; disable for internal load tests, never in prod. |
Load tests will hit these first — run them with an exempt API key or raised limits in a non-production environment, or you'll be benchmarking the limiter.
| Parameter | Default | Where |
|---|---|---|
STELLAR_RPC_URL |
public testnet | Env. Use a dedicated/paid RPC provider in production; public endpoints throttle. |
Breaker failureThreshold |
5 |
api-server/src/services/rpcCircuitBreaker.ts |
Breaker resetTimeoutMs |
15000 |
Same file. Lower = faster recovery, more probe traffic against a sick endpoint. |
EVENT_LISTENER_POLL_MS |
15000 |
Critical-event polling. Lower only if alert latency matters more than RPC load. |
Multi-region: point each region at its nearest RPC (multi-region-deployment.md).
| Cache | Location | Notes |
|---|---|---|
| Proof verification memoisation | services/metadataHashCache.ts |
Bounded LRU keyed by proof inputs. Increase maxSize if the hit rate is low and memory allows. Invalidation rules: verification-cache-invalidation.md. |
| HTTP caching | middleware/cacheControl.ts |
Sets Cache-Control on read routes; put a CDN in front of public verification endpoints. |
| Request dedup | middleware/requestDeduplication.ts |
Collapses identical concurrent requests — most effective on hot credential ids. |
| Variable | Default | Notes |
|---|---|---|
DATABASE_POOL_MAX |
10 |
Total across replicas must stay below Postgres max_connections minus admin headroom: replicas × DATABASE_POOL_MAX ≤ max_connections − 10. |
DATABASE_POOL_IDLE_TIMEOUT_MS |
30000 |
|
DATABASE_POOL_CONNECT_TIMEOUT_MS |
5000 |
Upstream connection pool (services/connectionPool.ts):
POOL_MAX_SIZE=20, POOL_MIN_SIZE=2, POOL_MAX_WAIT_MS=5000,
POOL_IDLE_TIMEOUT_MS=30000. Raise POOL_MIN_SIZE to avoid cold-connection
latency after idle periods.
| Variable | Default | Notes |
|---|---|---|
WS_SEND_QUEUE_MAX_MESSAGES |
200 |
Per-connection. Raise for bursty dashboards; lower to shed slow clients sooner. |
WS_SEND_QUEUE_MAX_BYTES |
see ws/ |
Byte cap on the same queue. |
WS_BACKPRESSURE_HIGH_WATER_MARK |
see ws/ |
Socket buffer level at which sends pause. |
WS_DASHBOARD_BROADCAST_INTERVAL_MS |
5000 |
Live-dashboard push frequency. |
REDIS_URL |
— | Enables cross-replica fan-out; required beyond one replica. |
| Variable | Default | Notes |
|---|---|---|
COMPRESSION_LEVEL |
6 |
1–3 trades bandwidth for CPU on CPU-bound replicas. |
COMPRESSION_THRESHOLD |
1024 |
Bytes; small JSON isn't worth compressing. |
LOG_LEVEL |
info |
debug in production can cost 10–20% throughput. Use MODULE_LOGS=<module>:debug for targeted debugging. |
LOG_STDOUT |
true |
Set false in Kubernetes if promtail already tails LOG_FILE, to avoid double writes. |
| Setting | Recommendation |
|---|---|
NODE_OPTIONS=--max-old-space-size |
~75% of the container memory limit (e.g. 384 for 512Mi). |
| CPU limits | The API is single-threaded per process; scale out (replicas), not up. Requests ≥ 250m avoid throttling jitter. |
UV_THREADPOOL_SIZE |
Raise to 8–16 if crypto/zlib dominate (--cpu-prof shows libuv waits). |
| HPA | Scale on p95 latency or concurrency-queue depth rather than CPU; RPC-bound pods look idle while saturated. |
| Parameter | Guidance |
|---|---|
Batch size for batch_issue_credentials |
Start at the limits in batch-issuance-limits.md; reduce if simulations report budget near the cap. |
| Attestors per slice | Cost grows with slice size — prefer multiple smaller slices with weighted voting (weighted-voting.md). |
| Storage TTL / bump | Extend TTL in bulk during maintenance windows rather than on every read. |
| Proof scheme | PLONK vs Groth16 trade-offs: groth16-migration.md. |
- One variable at a time. Change one parameter per run; record it.
- Warm up. Discard the first 30 s (JIT, connection pools, caches).
- Repeat. At least 3 runs; report the median of each percentile.
- Report percentiles, not averages. p50, p95, p99, max, error rate, throughput.
- Same hardware. Compare only runs on the same instance type/region.
- Isolate the limiter. Raise rate limits / use an exempt key so you measure the system, not the throttle.
- Pin versions. Record git SHA, Node version, contract WASM hash.
cd benches
cargo test --release -- --nocapture # budget gates (±10%)
cargo run --release --bin scaling_report # complexity-class / scaling report
../scripts/benchmark_compare.sh # compare against committed historyBudget numbers are deterministic, so a single run suffices and any change is
real. CI runs these in .github/workflows/benchmarks.yml and appends to
benches/history/ on main. See perf-regression.md.
Contract profiling: scripts/profile_contracts.sh.
cd api-server
npm run loadtest:credentials # issuance + verification
npm run loadtest:ws # WebSocket fan-out
# Sweep a parameter
for c in 10 25 50 100; do
LOAD_VERIFY_CONCURRENCY=$c npm run loadtest:credentials | tee "run-verify-c$c.txt"
done
# Stress the breaker
LOAD_RPC_FAILURE_RATE=0.08 npm run loadtest:credentials| Knob | Default | |
|---|---|---|
LOAD_ISSUE_COUNT / LOAD_VERIFY_COUNT |
1000 / 10000 |
Size. |
LOAD_ISSUE_CONCURRENCY / LOAD_VERIFY_CONCURRENCY |
50 / 25 |
In-flight requests. |
LOAD_RPC_LATENCY_MS |
80 |
Synthetic RPC latency — set to your measured production p50. |
LOAD_RPC_FAILURE_RATE |
0.01 |
The stubbed RPC makes runs reproducible; to measure end-to-end against
testnet swap in the real simulateCall (see the header of
loadtest/credentialLoadTest.ts).
# CPU profile a local run under load
node --cpu-prof --cpu-prof-dir=./prof dist/index.js &
npm run loadtest:credentials
kill -INT %1 # open prof/*.cpuprofile in Chrome DevTools
# Event-loop delay
node --trace-event-categories node.perf.usertiming dist/index.jsLook for: synchronous JSON on large payloads, HMAC/compression hot spots,
per-request regex compilation, and unbounded await chains in loops that
should be Promise.all with a limit.
Keep a table per tuning exercise in the PR description:
| Run | SHA | Change | Concurrency | req/s | p50 | p95 | p99 | Errors |
|---|---|---|---|---|---|---|---|---|
| baseline | abc123 | — | 25 | 180 | 95ms | 160ms | 210ms | 0.9% |
| 1 | abc123 | CONCURRENCY_VERIFY_MAX=30 |
25 | … | … | … | … | … |
Accept a change only if it improves the target percentile by more than the run-to-run variance (typically ±5%) without raising error rate.
| Layer | Metric | Source | Dashboard |
|---|---|---|---|
| API latency | quorumproof_api_request_duration_seconds (p50/p95/p99) |
exporter | api-latency.json |
| API errors | quorumproof_api_errors_total |
exporter | api-latency.json |
| RPC health | quorumproof_rpc_circuit_breaker_{state,calls_total,failures_total,trips_total} |
/metrics/rpc |
— |
| DB pool | quorumproof_db_pool_{active,idle,waiting,utilization_pct} |
/metrics/db |
— |
| WebSockets | quorumproof_ws_{connections,messages_sent_total,messages_dropped_total} |
/metrics/ws |
— |
| Contracts | budget per op | benches/history/ |
Benchmarks workflow artifacts |
| Credential volume | issuance/verification rates | exporter | credential-volume.json |
Scrape every replica; aggregate with sum by (...) — each exposes only its
own counters.
The quorumproof-performance group in monitoring/prometheus/alerts.yml:
| Alert | Condition | First response |
|---|---|---|
ApiLatencyP95High |
p95 > 500 ms for 10 m | Check RPC breaker and concurrency queue; see §1.1, §1.3. |
ApiLatencyP99Critical |
p99 > 2 s for 5 m | Same; consider scaling out; check for a slow RPC provider. |
RpcCircuitBreakerOpen |
breaker open 1 m | Switch/fail over STELLAR_RPC_URL; see §2.3. |
DbPoolSaturated |
utilisation > 90% or waiters for 5 m | Raise DATABASE_POOL_MAX within the §2.5 budget, or find slow queries. |
WsMessagesDropping |
> 1 drop/s for 5 m | Raise WS_SEND_QUEUE_MAX_MESSAGES or investigate slow consumers. |
| SLI | Target |
|---|---|
GET credential / verify p95 |
< 300 ms |
| Batch verify (50) p95 | < 1 s |
| Availability (non-5xx) | 99.9% monthly |
| WS delivery (not dropped) | 99.99% |
- Every PR: contract budget gates (Benchmarks workflow).
- Weekly:
loadtest:credentialson staging; append results to the tuning table. - Every release: compare p95/p99 before and after the blue-green switch (blue-green-deployment.md); the soak gate catches error-rate regressions, latency needs a human look.
- Quarterly: re-validate the capacity model in capacity-planning.md against real traffic.
- Chaos:
monitoring/chaos/rpc-latency.yamlandnetwork-delay.yamlconfirm the breaker and concurrency limits degrade gracefully.
| Symptom | Likely bottleneck | Knob |
|---|---|---|
| p95 up, CPU flat | RPC latency | Caching, dedicated RPC, CONCURRENCY_VERIFY_MAX |
| Sudden 503s under burst | Concurrency queue full | CONCURRENCY_MAX_QUEUE, scale out |
| 429s in load tests | Rate limiter | RATE_LIMIT_MAX, exempt API key |
| CPU pegged, latency up | Compression / logging / HMAC | COMPRESSION_LEVEL, LOG_LEVEL, scale out |
db_pool_waiting > 0 |
DB pool | DATABASE_POOL_MAX |
| WS drops | Slow consumers | WS_SEND_QUEUE_MAX_* |
ExceededLimit on-chain |
Contract budget | Smaller batches / slices |
| Latency spike after idle | Cold connections | POOL_MIN_SIZE |