Files
mdm-ordinis/docs/design/redis-fk-projection.md
T
2026-05-12 14:30:22 +00:00

227 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:<referenced_dict>:<businessKey> → 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<String> 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.<field>` JSON object
### Phase B: cascade invalidation (1 sprint)
Add reverse index update в `ProjectionWriter`:
- При upsert(record) проходим schema.properties, для каждого FK field add
`fk:<refDict>:<fkValue>` → SADD (recordDict, businessKey)
- При delete — SREM
Add `ProjectionInvalidator`:
- Subscribe to OutboxEvents `RecordUpdated{dict=X}`
- Lookup `fk:X:<businessKey>` → 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:<refDict>:<bk>` 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.