24 KiB
Design: Redis Projection FK Resolution
Author: zimin.an
Date: 2026-05-12
Status: PROPOSED v2.1 (round 2 /plan-eng-review fixes applied)
Supersedes:
- v1 (2026-05-12 first draft, before eng review found
LineageIndexServicereuse opportunity) - v2 (2026-05-12 после round 1 review — major scope reduction + critical safety mitigations) Sprint estimate: 4-5 рабочих дней (~30.5h CC+gstack) Blockers: none, рекомендация defer'a сохраняется — implement только когда конкретный dict упрётся в latency SLA
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 делает N+1 lookup чтобы получить human label (Действующий).
Предложение: при write resolve'ить FK label'ы и сохранять рядом с FK value в _resolved ключе. Per-FK opt-in через x-resolve-label: true. Cascade invalidation reusing existing LineageIndexService (no new Redis reverse index).
Win: один Redis GET вместо N+1, latency 1ms вместо N×1ms (для записей с 5-10 FK).
Risk: консистентность projection requires careful invalidation strategy, иначе projection stale до next write referencing'а dict'и. Mitigations добавлены в этой ревизии (см. § Critical safety).
Изменения от v1 (после eng review)
| # | v1 | v2 (now) | Why |
|---|---|---|---|
| 1 | Новый Redis reverse index fk:<refDict>:<bk> |
Reuse LineageIndexService.findRecordDependents() |
DRY: PG index уже existing, single source of truth |
| 2 | "Subscribe to OutboxEvents" | Extend RecordEventListener в projection-writer |
Корректное terminology — flow идёт через Kafka topics |
| 3 | Fan-out 1000 records — pipeline и ОК | Batch cap 500 + queue depth metric + alert | Realistic worst case 50k (country dict) saturate'нет consumer |
| 4 | «Eventual consistency few seconds» | Explicit SLO: P95 < 60s для <10k, P95 < 300s для >10k + X-Projection-Updated-At header в read-api |
UX bug когда админ видит mixed state |
| 5 | Cycle detection: «only direct FK» (in prose) | Explicit constraint + test: nested FK resolution forbidden | Защита от future «улучшений» |
| 6 | Storage: 10k × 1KB = +300MB | Fixed formula: × locale count × FK count = realistic +450MB-1.5GB | Per-locale multiplier compound'ил, не учтён |
| 7 | @Cacheable FkResolver на 5 min |
No Spring cache — projection-writer уже триггерит cascade, кэш дал бы stale при write | Two caching layers were unsafe |
| 8 | No test plan | 15-test plan with regression test, race test, E2E через Kafka, perf test | Был только перечень "Integration tests" |
| 9 | No rollback strategy | Toggle off → eventual cleanup on next upsert (не background job) | Меньше moving parts |
| 10 | No kill-switch | Global feature flag ordinis.projection.fk-resolution.enabled + per-dict + per-FK |
3 уровня контроля для prod safety |
Round 2 fixes (v2 → v2.1):
| # | v2 | v2.1 (now) | Why |
|---|---|---|---|
| 11 | Kill switch precedence implicit | Explicit precedence table (write requires all 3; read requires global+dict only) | Operator clarity, lazy cleanup на per-FK toggle off |
| 12 | CascadeInvalidator triggers via schema scan per event |
Step 1.5: fkTargetDicts cache + SchemaPublished invalidation | High-RPS dicts не платят schema lookup на каждый message |
| 13 | Bundle isolation lost в Open Question #3 | Step 1.6: SchemaValidator reject cross-bundle x-references | Multi-tenancy invariant защищён valid'ом |
| 14 | _meta.updatedAt ambiguous (cascade vs direct) |
Explicit max(direct, cascade) policy | Silent UX bug: header lying about staleness |
| 15 | Test plan 15 cases | 20 cases (+ kill switch matrix, cache invalidation, cross-bundle, _meta.updatedAt timing, storage cap precision) |
Coverage diagram gaps closed |
Текущее состояние
Что есть
RecordCreated/Updated event
│
▼
Kafka topic (3 scopes)
│
▼
ordinis-projection-writer/RecordEventListener
│
▼
ProjectionWriter.upsert()
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
flatten(per-locale) raw key SET dictionaryIndex SET ADD
│
▼
Redis SET key=record(...,locale)
flatten() обрабатывает только x-localized поля. FK поля проходят как есть.
Что хочется
При write проекции добавляется _resolved ключ с pre-fetched label'ами:
{
"businessKey": "ISS",
"satellite_type": "OPERATIONAL",
"country": "RU",
"_resolved": {
"satellite_type": "Действующий",
"country": "Россия"
}
}
Read-api сразу отдаёт нужный label без второго round-trip.
Архитектура v2
Component diagram
Kafka event: RecordCreated{dict=spacecraft, bk=ISS}
│
▼
RecordEventListener (existing)
│
▼
ProjectionWriter.upsert()
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
flatten(...) FkResolver raw key SET (как сейчас)
│ │
│ │ для каждого x-resolve-label поля:
│ │ - PG read replica lookup
│ │ - inject _resolved.<field>
│ ▼
│ ┌──────────────┐
│ │ PG read │
│ │ replica │ (no cache layer!)
│ └──────────────┘
▼
Redis SET key=record(...,locale)
Kafka event: RecordUpdated{dict=satellite_types, bk=OPERATIONAL}
│
▼
RecordEventListener (existing)
│
▼
CascadeInvalidator (NEW component)
│
▼
LineageIndexService.findRecordDependents() ← REUSE
│
▼
paged list of (source_dict, source_bk)
│
▼
enqueue invalidation batch (cap 500)
│
▼
for each batch: re-fetch from PG → ProjectionWriter.upsert()
│
▼
Redis pipelined batch write (Spring executePipelined)
Phase A: write-side FK resolver (~2 дня)
FkResolver service в ordinis-projection-writer:
@Component
public class FkResolver {
private final DictionaryRecordRepository recordRepo; // PG read replica
private final DictionaryDefinitionRepository defRepo;
/**
* Resolve FK value → human label per locale.
* Performance: single PG read query per (refDict, fkValue) — sub-ms на read replica.
* No caching by design (см. eng review #2 — stale read window).
*/
public Optional<String> resolveLabel(
String refDict, // "satellite_types"
String refField, // "code" — default businessKey
String fkValue, // "OPERATIONAL"
String locale // "ru"
) { ... }
/** Batch resolve для N FK fields одной записи (single PG query через IN clause). */
public Map<String, String> resolveBatch(
List<FkRequest> requests,
String locale
) { ... }
}
Update ProjectionWriter.flatten():
- Walk schema.properties, для каждого FK поля с
x-resolve-label: true:- Call
resolveBatch(single PG query для всех FK одной записи) - Inject
_resolved.<field>JSON
- Call
- Hard constraint: не рекурсировать в nested objects — direct fields only
Phase B: cascade invalidation (~2 дня) — reuse LineageIndexService
New CascadeInvalidator в ordinis-projection-writer:
@Component
public class CascadeInvalidator {
private final LineageIndexService lineageIndex; // REUSE
private final ProjectionWriter writer;
private final DictionaryRecordRepository recordRepo;
private static final int BATCH_SIZE_CAP = 500; // hard limit
/** Triggered RecordEventListener'ом когда updated dict — потенциальный FK target. */
public void onSourceRecordUpdate(String refDict, String businessKey) {
int page = 0;
while (true) {
Page<RecordDependent> deps = lineageIndex.findRecordDependents(
refDict, businessKey, allScopes(), PageRequest.of(page, BATCH_SIZE_CAP));
if (deps.isEmpty()) break;
// Re-fetch dependents from PG + rewrite projection (pipelined)
writer.batchRewriteProjections(deps.getContent());
cascadeInvalidationCounter.increment(deps.getNumberOfElements());
if (!deps.hasNext()) break;
page++;
// Backpressure если queue depth высокий
if (cascadeQueueDepthGauge.get() > QUEUE_DEPTH_ALERT) {
Thread.sleep(THROTTLE_MS);
}
}
}
}
Hook в существующий RecordEventListener:
- На
RecordUpdated{dict=X}event — после обычной upsert(), check'аем X через cachedfkTargetDictsset (step 1.5). Schema-level dependency map immutable между SchemaPublished events; cache invalidate'ится по этому event'у. Это избегаетLineageIndexService.findSchemaDependents()query на каждом message — критично для high-RPS dicts. - Если X ∈ fkTargetDicts → вызываем
CascadeInvalidator.onSourceRecordUpdate(X, businessKey)
Phase C: backfill + ops (~1 день)
CLI endpoint POST /api/v1/admin/projections/{dict}/backfill:
- Paginated iterate (batch=500, sleep=100ms между batch'ами)
- Per-page commit chunk — recoverable если interrupted
- Lock через advisory lock в PG (one backfill per dict at a time)
- Progress reporting через outbox event stream
Phase D: observability + flags
3-tier kill switch:
- Global —
ordinis.projection.fk-resolution.enabled(env var) - Per-dict —
DictionaryDefinition.fkResolutionEnabled(existing field паттерн, новая колонка) - Per-FK — schema annotation
x-resolve-label: true(default false)
Precedence (explicit per round 2 review):
| Layer | Write path (resolve & inject _resolved) |
Read-api (return _resolved к caller'у) |
|---|---|---|
| Global=false | ❌ skip | ❌ ignore _resolved even if present (stale residue from before toggle) |
| Global=true, Dict=false | ❌ skip | ❌ ignore (per-dict opt-out wins over residual data) |
| Global=true, Dict=true, FK=false | ❌ skip для этого поля | ✅ return whatever's already в _resolved (no harm — поле там не появится) |
| Global=true, Dict=true, FK=true | ✅ resolve & write | ✅ return _resolved.<field> |
Logic: write requires all three true; read requires global + dict (FK granularity не нужна на read — если backend перестал писать поле, _resolved.<field> natural выпадает при следующем upsert). Это значит turning off per-FK toggle = lazy cleanup. Turning off per-dict = immediate hide. Turning off global = panic kill для всего projection FK behavior.
Metrics (Prometheus):
ordinis_projection_fk_resolved_total{dict, fk_field}ordinis_projection_fk_resolve_miss_total{dict, fk_field, reason="not_accessible|not_found|locale_missing"}ordinis_projection_cascade_invalidation_total{trigger_dict}ordinis_projection_cascade_queue_depth(gauge)ordinis_projection_fk_resolve_duration_seconds(histogram)ordinis_projection_storage_resolved_bytes_total{dict}(gauge)
SLO targets:
- P95 FK resolution latency: < 5ms (PG read replica baseline)
- P95 cascade invalidation:
- < 60s для cascade size ≤ 10k dependents
- < 300s для cascade size 10k-50k
-
50k cascade → page on-call (likely operator error)
- Projection eventual consistency window: max 300s
Read-api integration:
- Response header
X-Projection-Updated-At: 2026-05-12T17:30:00Z(last upsert time per record) staleness=query param для force-fresh read через PG fallback (debugging)
_meta.updatedAt policy (per round 2 review — silent UX bug fix):
Stored в projection JSON как _meta.updatedAt (sibling _resolved):
- Updated on direct upsert (RecordCreated/Updated event для этой записи)
- Updated on cascade rewrite (CascadeInvalidator пишет проекцию после изменения FK target)
- Read-api header =
max(direct_upsert_time, cascade_rewrite_time)= the_meta.updatedAtvalue as-stored (cascade overwrites if newer)
Why max'om: иначе админ видит «projection updated 5 минут назад» а на самом деле cascade обновил labels 3 секунды назад — silent UX bug когда юзер ждёт что прочтёт fresh данные. Stored timestamp всегда точка последней мутации projection'а, не direct write.
Storage cost: 24 bytes per record × 3 locales = 72 bytes overhead. Negligible.
Critical safety (added in v2)
Race conditions: concurrent target update during dependent write
Scenario: Запись A пишется в момент когда B (FK target) сам обновляется.
Mitigation: FkResolver читает с PG after projection write — если B меняется между resolution и Redis SET, мы пишем stale label. Затем cascade от B's update подхватит A и rewrite projection → eventual consistency.
Test: ConcurrentFkUpdateTest — два потока, поток1 пишет A, поток2 update B, проверяем что через ≤30s projection A содержит latest label B.
Kafka consumer lag во время массивного cascade
Scenario: Update country=RU → 50k spacecrafts инвалидируются.
Mitigation:
- Batch cap 500 dependents за раз
cascadeQueueDepthGaugeexposed → alert thresholds (warn @ 1000, crit @ 5000)- Throttle между batch'ами если queue depth превышает
- Async dispatch — cascade work не в main listener thread, отдельный
@Asyncexecutor с bounded queue
Test: CascadeBackpressureTest — load 10k dependents, verify Kafka consumer lag не превышает 60s.
Redis memory pressure
Scenario: Enable flag для крупного dict'a → проекции вырастут 2-3×.
Mitigation:
- Metric
ordinis_projection_storage_resolved_bytes_total{dict}+ alert на 80% Redis memory cap - Per-record max size check — если
_resolvedblob > 50KB, skip resolution + log warning (не падать) - Operational runbook: «disable per-dict flag → wait for natural eviction (24h TTL?) → cleanup»
Rollback path
Если flag toggled off:
- Existing
_resolvedkeys остаются stale, но harmless — read-api ignore'ит_resolvedкогда flag=false - При next upsert каждой записи
_resolvedautomatically dropped (write replaces full value) - Если нужен immediate cleanup — CLI
POST /admin/projections/{dict}/strip-resolved(~1 час имплементации)
Open questions
-
PG read replica lag.
FkResolverчитает с replica для performance, но replica может отставать на seconds под write load. Если target dict обновлён 100ms назад, FkResolver получит stale label, cascade подхватит ~30s. Acceptable. Документировать. -
Scope-hide handling. Если FK target dict не доступен caller'у — projection write идёт как system user (не user-scope). Resolution всё равно происходит, но read-api позже filter'нет
_resolvedесли caller scope не допускает target dict. Implementation:FkResolverиспользует privileged read, read-api проверяет access. -
Bundle isolation. В multi-bundle setup'е (cuod, altum, etc.) — FK может пересекать bundle boundaries? Recommendation: запретить, validation в schema editor (
x-referencesполе target dict должен быть в том же bundle). -
Storage cap. Hard limit на per-record
_resolvedblob — 50KB? Это значит до ~25 FK fields × 3 locales × ~600 chars label = 45KB. Достаточно для всех realistic case'ов.
Альтернатива: skip FK resolution, use read-api JOIN (unchanged from v1)
Вместо pre-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 (revised)
| Step | Effort (CC+gstack) | Notes |
|---|---|---|
1. Schema annotation x-resolve-label + JSON Schema validator |
1h | Update SchemaValidator, fail-fast on non-FK field |
| 1.5. fkTargetDicts cache + SchemaPublished invalidation | 1h | Pre-compute set of dicts that являются FK target (via LineageIndexService.findSchemaDependents inversion). Avoid schema scan на каждый RecordUpdated event в RecordEventListener |
1.6. Bundle isolation enforcement в SchemaValidator |
0.5h | Reject x-references: "<targetDict>.<field>" если target_dict в другом bundle. Single-bundle invariant — иначе multi-tenancy ломается |
2. Per-dict fk_resolution_enabled column + migration |
1h | Liquibase, Dictionary CRUD. Default false для всех existing dicts (backward-compat). |
3. FkResolver service + batch resolve method |
3h | Single PG query per record (IN clause) |
4. Update ProjectionWriter.flatten() для _resolved injection |
2h | Walk schema, hard constraint on nested |
5. CascadeInvalidator reusing LineageIndexService |
4h | Batch cap, queue depth gauge, async |
6. RecordEventListener hook into CascadeInvalidator |
1h | Detect target dict from schemas index |
| 7. Backfill CLI endpoint + advisory lock | 2h | Paginated, idempotent |
| 8. Metrics + SLO alerting config (Grafana panels) | 2h | 6 new metrics, 3 alert rules |
9. Read-api X-Projection-Updated-At header |
1h | Track via _meta.updatedAt в projection JSON |
| 10. Integration tests (15 cases per coverage diagram) | 8h | testcontainers Postgres+Redis+Kafka |
| 11. Frontend: schema editor checkbox per FK field | 2h | DictionaryEditorDialog, validation |
| 12. Docs (ops runbook, schema annotation guide) | 2h | docs/ops/projection-fk-resolution.md |
| Total | ~30.5h (4-5d) | within revised budget (+1.5h после round 2 review) |
Test plan (added in v2 per eng review)
Minimum 15 integration tests, testcontainers Postgres + Redis + Kafka:
| # | Test | Type | Critical? |
|---|---|---|---|
| 1 | Happy path: 3 FK fields, all resolve | unit | — |
| 2 | One FK miss (orphan) → omitted from _resolved |
unit | — |
| 3 | Multi-locale resolution per locale array | unit | — |
| 4 | Locale fallback (ru missing → defaultLocale en) | unit | — |
| 5 | Schema validation: x-resolve-label on non-FK → error |
unit | — |
| 6 | Regression: write WITHOUT flag works как раньше | unit | YES |
| 7 | Cascade fires on RecordUpdated of FK target | integration | — |
| 8 | Cascade batch cap 500 enforced | integration | — |
| 9 | Cascade queue depth metric increments | integration | — |
| 10 | Concurrent update race → eventual consistency ≤30s | integration | CRITICAL |
| 11 | Backpressure: 10k dependents → consumer lag <60s | integration | CRITICAL |
| 12 | E2E через Kafka real flow: PUT → cascade → Redis read | E2E | — |
| 13 | Backfill CLI idempotent (re-run same result) | integration | — |
| 14 | Toggle off → next upsert removes _resolved |
integration | — |
| 15 | Storage cap (50KB) — large record skips resolution + warns | integration | — |
| 16 | Kill switch precedence matrix — все 8 комбинаций global/dict/FK booleans (truth table, parameterized test): write + read behavior matches table в § Phase D | integration | YES |
| 17 | fkTargetDicts cache invalidation — SchemaPublished event дропает schema-level dependency cache, next RecordUpdated пересчитывает | integration | — |
| 18 | Cross-bundle FK rejected — SchemaValidator reject'нет x-references указывающий на dict из другого bundle |
unit | — |
| 19 | _meta.updatedAt cascade vs direct timing — cascade rewrite обновляет timestamp; direct upsert после cascade перезаписывает; header возвращает max |
integration | — |
| 20 | Storage cap precision — 50KB cap применяется per-record (NOT per-field); record с 49KB raw + 5KB _resolved = total 54KB → resolved skipped, raw written |
integration | — |
Recommendation (unchanged from v1)
Defer until after v2.12.0 prod stable + 2 weeks dogfooding. Текущий read path handles RPS, no urgency. Activate per-FK flag только когда first dict bumps into latency SLA. Build full pipeline только if 3+ dict'ам нужно.
Next step (если decide go): этот revised doc → /plan-eng-review round 2 → if CLEAR → CEO approval → sprint allocation.
GSTACK REVIEW REPORT
| Review | Trigger | Why | Runs | Status | Findings |
|---|---|---|---|---|---|
| Eng Review | /plan-eng-review |
Architecture & tests (required) | 2 (v1 + v2) | 🟢 CLEAR | v1: 11 issues + 3 critical → all closed v2. v2 round 2: 5 minor + 1 silent UX bug → all closed v2.1 |
| CEO Review | /plan-ceo-review |
Scope & strategy | 0 | — | — |
| Design Review | /plan-design-review |
UI/UX gaps | 0 | n/a | Skip — backend feature |
UNRESOLVED: 0 VERDICT: 🟢 CLEAR after v2.1 fixes. Ready для implementation когда придёт время. Defer recommendation сохраняется: implement только после v2.12.0 prod stable + 2 weeks dogfooding + первый dict упрётся в latency SLA.