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

11 KiB
Raw Blame History

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'и.


Текущее состояние

Что есть

// 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 вместо:

{
  "businessKey": "ISS",
  "satellite_type": "OPERATIONAL",
  "country": "RU"
}

Получать:

{
  "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:

@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.