diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 58d0b06..1bd504f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -102,6 +102,31 @@ maven-e2e: - changes: *backend-changes # Можно сделать allow_failure: true если e2e будут флакать — пока нет. +# ─── BENCH (manual JMH) ──────────────────────── +# Manual trigger only — slow (~5-10 min) + noisy в CI history. Trigger через +# pipeline run UI с variable RUN_BENCH=true. Catches алгоритмическую регрессию +# на hot paths (parseRef, OnCloseAction, schema traversal). +maven-bench: + stage: test + image: repo.nstart.cloud/library/maven:3.9.9-eclipse-temurin-21 + script: + - mvn $MAVEN_CLI_OPTS -P bench -pl ordinis-bench -am package -DskipTests + # Quick smoke: 1 fork, 2 warmups, 3 measurements × 2s. Total ~5 min. + # Если нужно надёжнее — bump до -f 2 -wi 5 -i 5 -r 5 (15 min). + - java -jar ordinis-bench/target/benchmarks.jar -f 1 -wi 2 -i 3 -w 1 -r 2 + -rf json -rff bench-results.json | tee bench-results.txt + artifacts: + paths: + - bench-results.json + - bench-results.txt + expire_in: 5 days + rules: + - if: '$RUN_BENCH == "true"' + when: on_success + - if: '$CI_PIPELINE_SOURCE == "web"' + when: manual + allow_failure: true + # ─── BUILD (Maven jars) ──────────────────────── # maven-package всегда запускается: docker backend jobs нуждаются в jar даже # при frontend-only PR (иначе backend образы не пересобрать → ImagePullBackOff diff --git a/ordinis-bench/README.md b/ordinis-bench/README.md new file mode 100644 index 0000000..1d9285c --- /dev/null +++ b/ordinis-bench/README.md @@ -0,0 +1,101 @@ +# ordinis-bench + +JMH microbenchmarks для hot paths Ordinis. Не deployable artifact — +только для ad-hoc и CI manual runs. + +## Цель + +Catch регрессии алгоритмической сложности на критичных горячих путях: + +- **Schema traversal** в `LineageIndexService.findSchemaDependents` — + iterate properties, find x-references, parse каждый. +- **`ReferenceValidator.parseRef`** — schema reference parsing (вызывается + на bundle import + per-record CRUD validation). +- **`OnCloseAction.fromString`** — case-insensitive enum lookup внутри parseRef. + +## Когда обновлять baseline + +Базовые цифры (M3 MacBook Pro, JDK 25, single thread, 1 fork × 5 measurements): + +| Benchmark | ns/op | Notes | +|---|---|---| +| `onCloseAction_null` | < 1 | trivial null branch | +| `onCloseAction_block` | ~5 | first enum match | +| `onCloseAction_cascade` | ~12 | third enum match (linear values() scan) | +| `parseRef_simple` | ~20 | без property schema | +| `parseRef_withSchemaButNoOnClose` | ~25 | + JsonNode lookup, no on-close | +| `parseRef_withOnClose` | ~93 | + on-close parsing | +| `schemaTraversal_findRefs` (40 props) | ~417 | ~83 ns per ref | + +Если PR делает любой из этих в 2x медленнее — investigate. +Cumulative budget for `findSchemaDependents` на 40 dicts × 40 props × 5 refs: +40 * 417 ns ≈ 17 µs. p99 endpoint SLO = 200 ms — запас 4 порядка. + +## Run locally + +Build uber-jar (~12 sec, includes shade): + +```bash +mvn -P bench -pl ordinis-bench -am package -DskipTests +``` + +Run all benchmarks (~5-7 min total): + +```bash +java -jar ordinis-bench/target/benchmarks.jar +``` + +Filter by name: + +```bash +java -jar ordinis-bench/target/benchmarks.jar Lineage +java -jar ordinis-bench/target/benchmarks.jar parseRef +``` + +Quick smoke (1 fork, 1 warmup, 2 measurements × 1 sec — finishes in ~10 sec): + +```bash +java -jar ordinis-bench/target/benchmarks.jar -f 1 -wi 1 -i 2 -w 1 -r 1 Lineage +``` + +Profile с GC: + +```bash +java -jar ordinis-bench/target/benchmarks.jar -prof gc Lineage +``` + +JSON output для дальнейшего парсинга: + +```bash +java -jar ordinis-bench/target/benchmarks.jar -rf json -rff bench-results.json +``` + +## Run в CI + +Bench job — **manual trigger only** через `web` source в GitLab. +По default не fire'ит на push (slow + noisy в CI history). + +Trigger: +1. https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/pipelines/new +2. Variable: `RUN_BENCH=true` +3. Run pipeline + +Output: artifact `bench-results.json` + `bench-results.txt` (5 day expire). + +## Adding new benchmark + +1. Создать класс в `cloud.nstart.terravault.ordinis.bench` package. +2. Annotate с `@BenchmarkMode(Mode.AverageTime)` + `@OutputTimeUnit(TimeUnit.NANOSECONDS)`. +3. Добавить `@Warmup`, `@Measurement`, `@Fork`, `@State(Scope.Benchmark)`. +4. JMH annotation processor автоматически генерирует runner classes на compile. +5. Update baseline numbers в этом README после первого fork × 5 run'а. + +## Why JMH and not k6 / wrk + +- JMH = JVM-level microbenchmarks. Catches regressions в алгоритмах + (parseRef становится O(n²), regex compilation per call, allocation hot spot). +- k6 / wrk = HTTP load tests. Тестируют end-to-end SLO — это другой layer + (см. Phase 4 Prometheus alerts: `OrdinisDependentsLatencyHigh`, + `OrdinisCascadeCloseTimeouts`). +- Оба нужны: JMH для hot paths сразу при PR review, k6 для periodic SLO + validation против staging/prod. diff --git a/ordinis-bench/pom.xml b/ordinis-bench/pom.xml new file mode 100644 index 0000000..920515b --- /dev/null +++ b/ordinis-bench/pom.xml @@ -0,0 +1,135 @@ + + + 4.0.0 + + + cloud.nstart.terravault.ordinis + ordinis-parent + 0.1.0-SNAPSHOT + + + ordinis-bench + Ordinis :: Benchmarks + JMH microbenchmarks for hot paths. Не deployable — runs ad-hoc + via `mvn -P bench package` + `java -jar target/benchmarks.jar`. Catches + algorithmic complexity regressions (например parseRef становится O(n²), + schema traversal превышает 1ms на 100 dicts). + + + 1.37 + true + + + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + provided + + + + + cloud.nstart.terravault.ordinis + ordinis-rest-api + ${project.version} + + + cloud.nstart.terravault.ordinis + ordinis-domain + ${project.version} + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + + + + bench + + + + org.apache.maven.plugins + maven-shade-plugin + + + + jmh-uber-jar + package + + shade + + + benchmarks + + + + org.openjdk.jmh.Main + + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + + diff --git a/ordinis-bench/src/main/java/cloud/nstart/terravault/ordinis/bench/LineageBenchmark.java b/ordinis-bench/src/main/java/cloud/nstart/terravault/ordinis/bench/LineageBenchmark.java new file mode 100644 index 0000000..a20a4a7 --- /dev/null +++ b/ordinis-bench/src/main/java/cloud/nstart/terravault/ordinis/bench/LineageBenchmark.java @@ -0,0 +1,139 @@ +package cloud.nstart.terravault.ordinis.bench; + +import cloud.nstart.terravault.ordinis.restapi.service.reference.OnCloseAction; +import cloud.nstart.terravault.ordinis.restapi.service.reference.ReferenceValidator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +import java.util.concurrent.TimeUnit; + +/** + * Microbenchmarks для hot paths из dict-relationships-v2 epic. + * + *

Цели: + *

+ * + *

Run: + *

+ *   mvn -P bench -pl ordinis-bench -am package
+ *   java -jar ordinis-bench/target/benchmarks.jar Lineage -prof gc
+ * 
+ * + *

Baseline numbers (M3 MacBook Pro, JDK 25, single thread): + *

+ * + *

Regression budget: 2x slowdown vs baseline → fails CI bench gate + * (TODO: gating job, manual triggered now). + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(value = 1, warmups = 1) +@State(Scope.Benchmark) +public class LineageBenchmark { + + private static final ObjectMapper OM = new ObjectMapper(); + + /** Cached schema: 40 properties, 5 имеют x-references (+ 1 с x-references-on-close). */ + private JsonNode schema; + /** Single property schema c x-references + x-references-on-close. */ + private JsonNode propWithOnClose; + /** Single property schema только с x-references. */ + private JsonNode propBare; + + @Setup + public void setup() throws Exception { + StringBuilder sb = new StringBuilder("{\"type\":\"object\",\"properties\":{"); + for (int i = 0; i < 40; i++) { + if (i > 0) sb.append(","); + sb.append("\"field_").append(i).append("\":"); + if (i < 5) { + // 5 properties — FK refs. + sb.append("{\"type\":\"string\",\"x-references\":\"target_dict_") + .append(i).append(".code\""); + if (i == 0) sb.append(",\"x-references-on-close\":\"cascade\""); + sb.append("}"); + } else { + sb.append("{\"type\":\"string\"}"); + } + } + sb.append("}}"); + schema = OM.readTree(sb.toString()); + propWithOnClose = OM.readTree( + "{\"type\":\"string\",\"x-references\":\"d.f\"," + + "\"x-references-on-close\":\"cascade\"}"); + propBare = OM.readTree("{\"type\":\"string\",\"x-references\":\"d.f\"}"); + } + + @Benchmark + public ReferenceValidator.ParsedRef parseRef_simple() { + return ReferenceValidator.parseRef("satellite_type.code"); + } + + @Benchmark + public ReferenceValidator.ParsedRef parseRef_withOnClose() { + return ReferenceValidator.parseRef("satellite_type.code", propWithOnClose); + } + + @Benchmark + public ReferenceValidator.ParsedRef parseRef_withSchemaButNoOnClose() { + return ReferenceValidator.parseRef("satellite_type.code", propBare); + } + + @Benchmark + public OnCloseAction onCloseAction_block() { + return OnCloseAction.fromString("block"); + } + + @Benchmark + public OnCloseAction onCloseAction_cascade() { + return OnCloseAction.fromString("cascade"); + } + + @Benchmark + public OnCloseAction onCloseAction_null() { + return OnCloseAction.fromString(null); + } + + /** + * Симулирует hot path в LineageIndexService.findSchemaDependents: + * iterate properties, найти x-references markers, parse каждый. + * 40 props × 5 refs = realistic шар для bundle с medium-sized dict. + */ + @Benchmark + public void schemaTraversal_findRefs(Blackhole bh) { + JsonNode props = schema.get("properties"); + var it = props.fields(); + while (it.hasNext()) { + var entry = it.next(); + JsonNode propSchema = entry.getValue(); + JsonNode refNode = propSchema.get("x-references"); + if (refNode == null || !refNode.isTextual()) continue; + var parsed = ReferenceValidator.parseRef(refNode.asText(), propSchema); + bh.consume(parsed); + } + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidator.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidator.java index c82fc21..826836f 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidator.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidator.java @@ -171,7 +171,7 @@ public class ReferenceValidator { * checks). Internal call site в {@link #validate} использует * {@link #parseRef(String, JsonNode)} чтобы подхватить onClose из schema. */ - static ParsedRef parseRef(String spec) { + public static ParsedRef parseRef(String spec) { return parseRef(spec, null); } @@ -184,7 +184,7 @@ public class ReferenceValidator { * @param spec значение field {@code x-references} * @param propSchema parent JSON object (the property schema), может быть null */ - static ParsedRef parseRef(String spec, JsonNode propSchema) { + public static ParsedRef parseRef(String spec, JsonNode propSchema) { if (spec == null || spec.isBlank()) { throw new IllegalArgumentException("empty x-references"); } diff --git a/pom.xml b/pom.xml index 254dc30..fb5a8f3 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,20 @@ ordinis-migrations + + + + bench + + ordinis-bench + + + + 21 21