227 lines
11 KiB
Markdown
227 lines
11 KiB
Markdown
# 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.
|