# Design: Redis Projection FK Resolution **Author:** zimin.an **Date:** 2026-05-12 **Status:** PROPOSED — awaits CEO/eng review (`/plan-eng-review`) **Sprint estimate:** 1-2 спринта (5-10 рабочих дней с тестами + observability) **Blockers:** none, но рекомендую делать только после v2.12.0 prod stable --- ## TL;DR Сейчас Redis projection (writer module) пишет per-locale flattened JSON записи в Redis для dict'ей с `redis_projection_enabled=true`. **FK ссылки** (поля с `x-references: "dict.field"`) хранятся как raw FK value (`satellite_type: "OPERATIONAL"`). Read-api / consumer делает N+1 lookup чтобы получить human label (`Действующий`). **Предложение:** при write resolve'ить FK и сохранять label рядом с FK value в projection. Per-dict / per-FK opt-in. Plus reverse-index для cascade invalidation когда referenced record меняется. **Win:** один Redis GET вместо N+1, latency 1ms вместо N×1ms (для записей с 5-10 FK). **Risk:** консистентность projection requires careful invalidation strategy, иначе projection stale до next write referencing'а dict'и. --- ## Текущее состояние ### Что есть ```java // ordinis-projection-writer/.../ProjectionWriter.java public void upsert(String bundle, String dictionary, String businessKey, JsonNode data) { DictionaryDefinition def = ... if (!def.isRedisProjectionEnabled()) { recordsSkippedCounter.increment(); return; } // Per-locale flattening: for (String locale : locales) { JsonNode flat = flatten(schema, data, locale, def.getDefaultLocale()); redis.opsForValue().set(RedisKeys.record(bundle, dictionary, businessKey, locale), ...); } // + index set: redis.opsForSet().add(RedisKeys.dictionaryIndex(bundle, dictionary), businessKey); } ``` `flatten()` сейчас обрабатывает **только** `x-localized` поля — для locale=ru заменяет `{name: {ru: "Земля", en: "Earth"}}` на `{name: "Земля"}`. FK поля проходят как есть. ### Что хочется В projection rows вместо: ```json { "businessKey": "ISS", "satellite_type": "OPERATIONAL", "country": "RU" } ``` Получать: ```json { "businessKey": "ISS", "satellite_type": "OPERATIONAL", "_resolved": { "satellite_type": "Действующий", "country": "Россия" } } ``` Read-api сразу отдаёт нужный label без второго round-trip. --- ## Сложности (почему 1-2 sprint, не 4 часа) ### 1. Cascade invalidation Если `satellite_types` dict обновляется (label changes), все projection'ы записей, ссылающихся на этот type, становятся stale. Нужен **reverse index**: ``` fk:: → SET of (referencing_dict, referencing_bk) ``` Пример: ``` fk:satellite_types:OPERATIONAL → {spacecraft:ISS, spacecraft:MIR, ...} ``` При write записи в `satellite_types`: 1. Resolve reverse index → list of dependent records 2. Re-write projection каждого dependent record (заново resolve'ить FK) Это N+1 на каждый satellite_type update. С 1000 spacecrafts ссылающихся → 1000 Redis writes per type-label change. **Mitigation:** background invalidation queue. Type update → enqueue invalidation event → worker processes batch'ами. Eventual consistency ~few seconds. ### 2. Cycle detection Schema A → B → A через FK chain — теоретически возможен. При resolve'е нужен depth limit (или цикл detection) чтобы не зацикливаться. **Solution:** только direct FK (1 level), nested FK через chain не resolve'им. Pragmatic limit. ### 3. Multi-locale FK labels Referenced record имеет `name: {ru: "...", en: "..."}`. Projection пишется per-locale. Значит FK resolve тоже per-locale → 2× writes для каждой записи. В принципе не страшно — сейчас тоже per-locale writes, просто payload теперь больше. ### 4. Schema-level config Где включать FK resolution? Варианты: - **A) Per-dict flag** `fk_resolution_enabled` — глобальный для всех FK полей dict'a - **B) Per-FK через schema annotation** `"x-references": "dict.field", "x-resolve-label": true` - **C) Always-on когда `redis_projection_enabled=true`** — без отдельного toggle Recommendation: **B**. Granular control, schema-author знает какие FK «hot» (labels часто читаются) vs cold (только FK value матчат при join'е). Default false для backward compat. ### 5. Backfill при включении флага Когда юзер впервые `x-resolve-label: true` на FK поле, существующие projection'ы записей без resolved label остаются stale. Нужен **one-shot backfill job** который проходит всех записей dict'a, для каждого resolve'ит FK поля и rewrite'ит projection. Backfill job — отдельный CLI / endpoint, не auto-trigger от schema change (иначе schema edit становится heavy operation). ### 6. Observability Новые метрики: - `ordinis_projection_fk_resolved_total{dict, fk_field}` — count successful FK resolutions - `ordinis_projection_fk_resolve_miss_total{dict, fk_field}` — FK target dict not accessible или businessKey не найден - `ordinis_projection_cascade_invalidation_total{trigger_dict}` — cascade fires count - `ordinis_projection_fk_resolve_duration_seconds` — histogram --- ## Архитектура ### Phase A: read-side FK resolver (1 sprint) Add `FkResolver` service: ```java @Component public class FkResolver { private final FlattenedRecordRepository recordRepo; private final DictionaryDefinitionRepository defRepo; /** Resolve FK value → human label per locale. Cached на 5 минут. */ @Cacheable("fk-labels") public Optional resolveLabel( String refDict, // "satellite_types" String refField, // "code" — default businessKey String fkValue, // "OPERATIONAL" String locale // "ru" ) { ... } } ``` Update `ProjectionWriter.flatten()`: - Pass schema property + record data + locale through walker - При `x-references` И `x-resolve-label: true`: - Lookup label via FkResolver - Inject в `_resolved.` JSON object ### Phase B: cascade invalidation (1 sprint) Add reverse index update в `ProjectionWriter`: - При upsert(record) проходим schema.properties, для каждого FK field add `fk::` → SADD (recordDict, businessKey) - При delete — SREM Add `ProjectionInvalidator`: - Subscribe to OutboxEvents `RecordUpdated{dict=X}` - Lookup `fk:X:` → set of (depDict, depBk) - Enqueue `ProjectionRewriteRequest` for each — async worker re-pulls record + rewrite projection - Metrics + retry если PG read fails ### Phase C: backfill + ops (~3 дня) - CLI endpoint `POST /api/v1/admin/projections/{dict}/backfill` — iterate all records, rewrite projection - Idempotent — safe to re-run - Progress reporting через outbox event stream --- ## Open questions 1. **Кэш FkResolver TTL.** 5 минут — компромисс между «свежие label'ы» и «не долбить PG на каждый record write». Альтернатива — invalidate cache при `RecordUpdated{dict=referenced}` event'е. Pro: instant freshness. Con: cache coupling с event stream. 2. **Что если FK target dict не accessible (scope-hide)?** Sub-FK fields внутри record вернут partial resolve. Predictable behavior: omit `_resolved` ключ для missing FK. Read-api получит null label → fallback на raw FK value. 3. **Batch optimization.** При invalidation cascade на 1000 dependent records — лучше batch'ом писать в Redis pipeline? Да, через `redis.executePipelined()`. Прирост ~10× throughput для high-fanout cascade. 4. **Storage cost.** Resolved labels удваивают payload size projection'а (label per locale × FK fields). Для dict'ей с 10 FK polями + 3 locales = 30 additional fields per record. Если 10k записей × 1KB → +300MB Redis для одного dict'a. Acceptable для AltUM-tier, но нужно alert thresholds. 5. **Eventual consistency window.** Worst case: type-label update → 1000 dependent records → 5sec batch invalidate. Read-api в это окно может вернуть stale label. SLA нужно зафиксировать (recommendation: «projection eventually consistent within 30s of source change»). --- ## Альтернатива: skip FK resolution, use read-api JOIN Вместо пре-resolve'инга при write — read-api делает JOIN на читающей стороне (через PG или client-side). Pro: simpler, no cascade. Con: N+1 на каждое чтение, ровно то что мы хотим избежать. Если RPS не упёрся в predisposed, можно отложить весь FK projection. Прагматичное правило: **включать FK resolution только когда конкретный dict упёрся в latency SLA**. Default off навсегда. --- ## Implementation plan (если зелёный свет) | Step | Effort | Owner | Notes | |---|---|---|---| | 1. Schema annotation `x-resolve-label` + JSON Schema validator update | 0.5d | be | Update SchemaValidator | | 2. FkResolver service + tests | 1d | be | @Cacheable, fallback on miss | | 3. Update ProjectionWriter.flatten() | 1d | be | `_resolved` injection | | 4. Reverse index update on upsert/delete | 1d | be | `fk::` keys | | 5. ProjectionInvalidator (cascade worker) | 2d | be | Outbox listener + batch rewrite | | 6. Backfill CLI endpoint | 1d | be | Idempotent paginated iterate | | 7. Metrics + Grafana dashboard | 0.5d | infra/be | New panels | | 8. Integration tests (Postgres + Redis testcontainers) | 1d | be | Happy path + cascade + cycle | | 9. Docs (ops runbook, schema annotation guide) | 0.5d | be | docs/ | | 10. Frontend: schema editor checkbox per FK field | 0.5d | fe | DictionaryEditorDialog | | **Total** | **8.5d** | | within 1-2 sprint budget | --- ## Recommendation **Defer until after v2.12.0 prod stable + 2 weeks dogfooding.** Текущий read path (PG read replica) handles RPS, no urgency. Когда первый dict упрётся в latency, активируй per-FK flag для cold start этого dict'a. Build full pipeline только если 3+ dict'ам нужно. **Next step (если decide go):** `/plan-eng-review` на этот doc, утверждение CEO, заведение epic + sprint allocation.