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 @@
+
+
Цели: + *
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 @@