11 KiB
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:
- Resolve reverse index → list of dependent records
- 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 resolutionsordinis_projection_fk_resolve_miss_total{dict, fk_field}— FK target dict not accessible или businessKey не найденordinis_projection_cascade_invalidation_total{trigger_dict}— cascade fires countordinis_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
ProjectionRewriteRequestfor 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
-
Кэш FkResolver TTL. 5 минут — компромисс между «свежие label'ы» и «не долбить PG на каждый record write». Альтернатива — invalidate cache при
RecordUpdated{dict=referenced}event'е. Pro: instant freshness. Con: cache coupling с event stream. -
Что если 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. -
Batch optimization. При invalidation cascade на 1000 dependent records — лучше batch'ом писать в Redis pipeline? Да, через
redis.executePipelined(). Прирост ~10× throughput для high-fanout cascade. -
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.
-
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.