feat(webhook): time-series histogram stats endpoint + frontend chart
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user