Per-dictionary opt-in для Redis projection materialization. Default false
— projection-writer пропускает event'ы того dict'а, никаких dead writes
в Redis. Включается админ'ом через UI checkbox в Metadata tab.
Migration 0017:
- ALTER TABLE dictionary_definitions ADD COLUMN redis_projection_enabled
BOOLEAN NOT NULL DEFAULT false.
- Index idx_dict_def_redis_proj для быстрого filtering в list queries.
Backend:
- DictionaryDefinition entity: new field + getter/setter.
- DictionaryResponse DTO: returns flag в response.
- CreateDictionaryRequest DTO: optional Boolean (null/missing = no change).
- DictionaryDefinitionService.create + updateSchema: применяет flag из request.
- ProjectionWriter (projection-writer service):
* upsert() — early-return + skip counter если flag=false на dict.
* delete() — symmetric: skip если flag=false (защита от stale keys
после flag-flip; полный cleanup TODO Phase 2).
* New metric: ordinis_projection_records_skipped_total.
Admin UI:
- DictionaryEditorDialog Metadata tab: checkbox "Включить Redis-проекцию"
с описанием. Apply через CreateDictionaryRequest payload.
- DictionaryDefinition + CreateDictionaryRequest types updated.
- i18n RU/EN: schema.redisProjection.{label,hint}.
Что НЕ делается (deferred Phase 2):
- read-api Redis routing — read-api сейчас всегда идёт в PG read replica.
Когда dict опт-инится в флаг, projection материализуется, но read-api
ещё не использует. Read routing — отдельная feature с benchmark proof
(через JMH + k6 SLO test).
- One-shot cleanup job для stale keys после flag-flip from true→false.
Behavior change в production:
- До patch: projection-writer пишет в Redis для ВСЕХ dict events (40
dictionaries). Default flag=false после migration → projection-writer
пропускает всё → Redis keys для existing dicts становятся stale.
Это OK потому что read-api НИКОГДА не читал из Redis (writes были
dead). Stale keys eventually evicted Redis maxmemory policy.
- После patch: только dicts с redis_projection_enabled=true получают
материализацию. По default — нет. Admin opt-ins per-dict через UI.
Verify:
- mvn test (full reactor): SUCCESS, all green.
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration 0017 applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
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.
Записи ground_station и similar dicts имеют lat/lon в data, но
geometryWkt не задан. Spatial filter (AOI) WHERE geometry IS NOT NULL
AND ST_Intersects(...) возвращал 0 records даже когда координаты
есть.
Три места починены:
1. DictionaryRecordService.resolveGeometry — для CRUD через REST.
Если req.geometryWkt() задан — приоритет (explicit). Иначе
читаем data.lat/lon (или latitude/longitude/lng), валидируем
ranges, строим POINT(lon lat) с SRID 4326. Игнор out-of-range
без throw — не ломаем legacy data.
2. CuodBundleImporter.deriveLatLonGeometry — bundle bypass'ит service,
reuse'аем ту же логику локально (5 строк inline). Применяется при
создании НОВЫХ records (existing skipped по businessKey).
3. Liquibase migration 0015-backfill-latlon-geometry — UPDATE existing
records где geometry IS NULL AND data has lat/lon. Три changeset'а
для разных aliases (lat/lon, latitude/longitude, lat/lng). Idempotent
(только NULL→value), rollback supported (NULL обратно).
После deploy: existing 3 ground_station записей (Отрадное и др.) и
spacecraft с lat/lon получат POINT geometry. AOI bbox-фильтр заработает.
Phase 1 e2e — proof framework работает:
- E2ESupport: Postgres 18 (postgis/postgis:18-3.6) + Kafka (cp-kafka 7.6.0)
через Testcontainers, reuse=true. db-init.sql создаёт postgis+btree_gist
расширения которые ожидает Liquibase 0001.
- application-test.yml: тушит cloud-config/vault/otel/outbox publishing,
включает Liquibase autoconfig. Auth disabled для упрощения.
- SmokeE2ETest: full e2e — POST dictionary → POST record → GET record →
проверка audit_log row. UUID-suffix в dictName для идемпотентности.
- master.xml: relativeToChangelogFile=true для всех include — иначе
Liquibase из ordinis-app classpath не находит changes/X.xml в JAR.
- pom.xml: testcontainers 1.21.3, surefire env DOCKER_HOST + DOCKER_API_VERSION
для macOS Docker Desktop.
Найден реальный prod-баг: audit_log.ip_address был INET, Hibernate JPA не
делает автоматический cast String→INET → INSERT падал. AuditLogger.write
обёрнут в try/catch, поэтому тихо съедал ошибку — на staging audit_log
оставался пустым после CRUD.
- 0012-audit-log-ipaddress-varchar.xml: ALTER COLUMN INET → VARCHAR(64)
- AuditLog entity: убрал columnDefinition="inet", length=64.
0008 dropped IF EXISTS dictionary_records_data_scope_check, но реальный
constraint в 0003 назывался dictionary_records_scope_check (без _data_).
Старый lowercase constraint остался жив — записи с UPPERCASE
data_scope не проходили. 0009 удаляет оба варианта имени и создаёт
UPPERCASE constraint.
JPA persists enum DataScope as @Enumerated(EnumType.STRING) by default,
which writes the enum name verbatim — UPPERCASE. The original CHECK
constraint allowed only lowercase ('public','internal','restricted'),
making every insert violate it.
Drop and recreate constraint with UPPERCASE values to match enum names.
Applies to dictionary_definitions.scope and dictionary_records.data_scope.
DictionaryDefinition and DictionaryRecord entities have @Version (BIGINT)
fields for optimistic locking, but the initial migrations did not create
the column. App fails with 'column dd1_0.version does not exist' on first
query.
Add 0007-add-version-columns.xml with idempotent addColumn changesets
(preConditions guard against re-add).
- 0001-extensions.xml: переставил <preConditions> перед <comment> (XSD требует
preConditions первым в changeSet)
- master.xml: include paths сделаны relative к самому master.xml (changes/...
вместо db/changelog/changes/...) — CWD inside Docker image это /liquibase,
Liquibase searchPath = /liquibase/changelog где master.xml лежит, includes
должны идти от него
CI: insecure-registry: registry.k8s.265.nstart.local (HTTP без auth для
internal cluster), REGISTRY_USER/PASSWORD optional (skip docker login если
не заданы)