fix(lineage): 3 e2e CI failures — Liquibase XSD + JSONB ?-op + size cap

Phase 4 e2e suite (skipped в local mvn verify, гонится только на CI с
testcontainers) поймал 3 issues. Все исправлены, e2e теперь 20/20 PASS
локально (Postgres testcontainer):
- AuthE2ETest 6/6
- AutoDeriveGeometryE2ETest 6/6
- BitemporalE2ETest 4/4
- OutboxKafkaE2ETest 1/1
- SmokeE2ETest 1/1
- WebhookE2ETest 2/2

Fix #1 — Liquibase XSD ordering (0016 changeset):
- <comment> должен идти ПОСЛЕ <preConditions>. Liquibase XSD строгий:
  preConditions → comment → operations.
- "Invalid content was found starting with element preConditions" на
  startup → ApplicationContext fail → весь webserver не поднимается.

Fix #2 — JSONB ?-operator vs JDBC parameter substitution (0016):
- Postgres JDBC PreparedStatement обрабатывает любой `?` как $N
  placeholder. SQL "prop.value ? 'x-references'" → "prop.value $1
  'x-references'" → "syntax error at or near $1". Same для regex с $
  anchor.
- Replaced `?` operator на эквивалент `-> 'key' IS NOT NULL`,
  drop-or-replace regex anchors на `LIKE '%.%'` (split_part guards
  malformed values gracefully).
- Comment в migration документирует ловушку для будущих changesets.

Fix #3 — service size cap mismatch (LineageIndexService):
- CascadeCloseService.evaluatePlan вызывает findRecordDependents с
  size=CASCADE_MAX_PER_REQUEST=500. Но findRecordDependents enforced
  size 1..200 → IllegalArgumentException → 400 на DELETE record path.
- Bumped service cap до 500. Controller отдельно держит public cap=200
  (UI не нуждается в больше).
- Test updated: expects "size 1..500".

Lessons learned:
- Local pre-push discipline должна включать `mvn -P e2e` для миграций.
  Не запустил, потому что: e2e требует Docker, медленнее, не было в
  routine. Now corrected — будущие migration changes будут идти через
  e2e check.
- Liquibase JDBC рабочий принцип: SQL прогоняется через PreparedStatement
  → любой `?` или `$N` ловится. Documenting в migration comment.

Verify:
- mvn -pl ordinis-app -am -P e2e test: BUILD SUCCESS, 20/20 PASS (28s).
- mvn test: 106 unit tests + 20 e2e — все green.
This commit is contained in:
Zimin A.N.
2026-05-08 11:48:39 +03:00
parent a7cdd5df5c
commit 7d2199a3f3
3 changed files with 18 additions and 7 deletions
@@ -29,7 +29,6 @@
pure JSONB indexing (jsonb_each + ?-marker) — не ожидаем больше. pure JSONB indexing (jsonb_each + ?-marker) — не ожидаем больше.
--> -->
<changeSet id="0016-record-dependents-index-create" author="ordinis"> <changeSet id="0016-record-dependents-index-create" author="ordinis">
<comment>MV record_dependents_index: precomputed reverse FK</comment>
<preConditions onFail="MARK_RAN"> <preConditions onFail="MARK_RAN">
<not> <not>
<sqlCheck expectedResult="1"> <sqlCheck expectedResult="1">
@@ -37,6 +36,7 @@
</sqlCheck> </sqlCheck>
</not> </not>
</preConditions> </preConditions>
<comment>MV record_dependents_index: precomputed reverse FK</comment>
<sql splitStatements="false"><![CDATA[ <sql splitStatements="false"><![CDATA[
CREATE MATERIALIZED VIEW record_dependents_index AS CREATE MATERIALIZED VIEW record_dependents_index AS
SELECT SELECT
@@ -58,9 +58,17 @@
FROM dictionary_records r FROM dictionary_records r
JOIN dictionary_definitions src_def ON src_def.id = r.dictionary_id JOIN dictionary_definitions src_def ON src_def.id = r.dictionary_id
JOIN LATERAL jsonb_each(src_def.schema_json -> 'properties') AS prop(key, value) ON true JOIN LATERAL jsonb_each(src_def.schema_json -> 'properties') AS prop(key, value) ON true
WHERE prop.value ? 'x-references' -- Note: избегаем JSONB ?-operator — Liquibase прогоняет SQL через
AND prop.value->>'x-references' ~ '^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$' -- PreparedStatement, который любой ? интерпретирует как $N
AND r.data ? prop.key -- placeholder → "syntax error at $1". Заменяем на эквиваленты:
-- prop.value ? 'x-references' → prop.value -> 'x-references' IS NOT NULL
-- r.data ? prop.key → r.data -> prop.key IS NOT NULL
-- Регекс с $ anchor по той же причине заменён на LIKE — split_part'ы
-- ниже сами справятся с malformed (вернут empty strings, никогда не
-- match-ятся с real records).
WHERE prop.value -> 'x-references' IS NOT NULL
AND prop.value->>'x-references' LIKE '%.%'
AND r.data -> prop.key IS NOT NULL
AND jsonb_typeof(r.data -> prop.key) = 'string' AND jsonb_typeof(r.data -> prop.key) = 'string'
WITH NO DATA; WITH NO DATA;
]]></sql> ]]></sql>
@@ -209,8 +209,11 @@ public class LineageIndexService {
int page, int page,
int size) { int size) {
if (page < 0) throw new IllegalArgumentException("page must be >= 0"); if (page < 0) throw new IllegalArgumentException("page must be >= 0");
if (size < 1 || size > 200) { // Service-level cap = 500 (CascadeCloseService запрашивает up to
throw new IllegalArgumentException("size must be 1..200, got " + size); // CASCADE_MAX_PER_REQUEST). Controller separately enforces 200 cap для
// public endpoint — UI не нуждается в больше.
if (size < 1 || size > 500) {
throw new IllegalArgumentException("size must be 1..500, got " + size);
} }
Optional<DictionaryDefinition> targetOpt = definitionRepository.findByName(targetDictName); Optional<DictionaryDefinition> targetOpt = definitionRepository.findByName(targetDictName);
@@ -206,7 +206,7 @@ class LineageIndexServiceTest {
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("size"); .hasMessageContaining("size");
assertThatThrownBy(() -> service.findRecordDependents( assertThatThrownBy(() -> service.findRecordDependents(
"d", "k", Set.of(DataScope.PUBLIC), 0, 201)) "d", "k", Set.of(DataScope.PUBLIC), 0, 501))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("size"); .hasMessageContaining("size");
} }