feat(redis-projection): per-dict feature flag (CEO plan E2 tiered perf)

Per-dictionary opt-in для Redis projection materialization. Default false
— projection-writer пропускает event'ы того dict'а, никаких dead writes
в Redis. Включается админ'ом через UI checkbox в Metadata tab.

Migration 0017:
- ALTER TABLE dictionary_definitions ADD COLUMN redis_projection_enabled
  BOOLEAN NOT NULL DEFAULT false.
- Index idx_dict_def_redis_proj для быстрого filtering в list queries.

Backend:
- DictionaryDefinition entity: new field + getter/setter.
- DictionaryResponse DTO: returns flag в response.
- CreateDictionaryRequest DTO: optional Boolean (null/missing = no change).
- DictionaryDefinitionService.create + updateSchema: применяет flag из request.
- ProjectionWriter (projection-writer service):
  * upsert() — early-return + skip counter если flag=false на dict.
  * delete() — symmetric: skip если flag=false (защита от stale keys
    после flag-flip; полный cleanup TODO Phase 2).
  * New metric: ordinis_projection_records_skipped_total.

Admin UI:
- DictionaryEditorDialog Metadata tab: checkbox "Включить Redis-проекцию"
  с описанием. Apply через CreateDictionaryRequest payload.
- DictionaryDefinition + CreateDictionaryRequest types updated.
- i18n RU/EN: schema.redisProjection.{label,hint}.

Что НЕ делается (deferred Phase 2):
- read-api Redis routing — read-api сейчас всегда идёт в PG read replica.
  Когда dict опт-инится в флаг, projection материализуется, но read-api
  ещё не использует. Read routing — отдельная feature с benchmark proof
  (через JMH + k6 SLO test).
- One-shot cleanup job для stale keys после flag-flip from true→false.

Behavior change в production:
- До patch: projection-writer пишет в Redis для ВСЕХ dict events (40
  dictionaries). Default flag=false после migration → projection-writer
  пропускает всё → Redis keys для existing dicts становятся stale.
  Это OK потому что read-api НИКОГДА не читал из Redis (writes были
  dead). Stale keys eventually evicted Redis maxmemory policy.
- После patch: только dicts с redis_projection_enabled=true получают
  материализацию. По default — нет. Admin opt-ins per-dict через UI.

Verify:
- mvn test (full reactor): SUCCESS, all green.
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration 0017 applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
This commit is contained in:
Zimin A.N.
2026-05-08 12:34:58 +03:00
parent c11044c32e
commit 0a8275ef4c
10 changed files with 117 additions and 1 deletions
+4
View File
@@ -35,6 +35,8 @@ export type DictionaryDefinition = {
bundle: string bundle: string
supportedLocales: string[] supportedLocales: string[]
defaultLocale: string defaultLocale: string
/** CEO plan E2: per-dict Redis projection materialization opt-in. */
redisProjectionEnabled: boolean
recordCount?: number recordCount?: number
createdAt: string createdAt: string
updatedAt: string updatedAt: string
@@ -103,6 +105,8 @@ export type CreateDictionaryRequest = {
bundle?: string bundle?: string
supportedLocales?: string[] supportedLocales?: string[]
defaultLocale?: string defaultLocale?: string
/** CEO plan E2: per-dict Redis projection opt-in. Default false. */
redisProjectionEnabled?: boolean
} }
export type CreateRecordRequest = { export type CreateRecordRequest = {
@@ -72,6 +72,9 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
initial?.supportedLocales ?? ['ru-RU'], initial?.supportedLocales ?? ['ru-RU'],
) )
const [defaultLocale, setDefaultLocale] = useState(initial?.defaultLocale ?? 'ru-RU') const [defaultLocale, setDefaultLocale] = useState(initial?.defaultLocale ?? 'ru-RU')
const [redisProjection, setRedisProjection] = useState(
initial?.redisProjectionEnabled ?? false,
)
const [properties, setProperties] = useState<PropertyDef[]>(parsed.properties) const [properties, setProperties] = useState<PropertyDef[]>(parsed.properties)
const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '') const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '')
const [nameError, setNameError] = useState<string | null>(null) const [nameError, setNameError] = useState<string | null>(null)
@@ -157,6 +160,7 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
bundle: bundle || undefined, bundle: bundle || undefined,
supportedLocales: supportedLocales.length > 0 ? supportedLocales : undefined, supportedLocales: supportedLocales.length > 0 ? supportedLocales : undefined,
defaultLocale: defaultLocale || undefined, defaultLocale: defaultLocale || undefined,
redisProjectionEnabled: redisProjection,
} }
if (isEdit) { if (isEdit) {
@@ -286,6 +290,24 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
value={defaultLocale} value={defaultLocale}
onChange={setDefaultLocale} onChange={setDefaultLocale}
/> />
{/* CEO plan E2: per-dict Redis projection opt-in. Default false.
Включается когда dict хочет 10k+ RPS на read-api (вместо PG). */}
<label className="md:col-span-2 flex items-start gap-3 px-3 py-2 rounded-sm border border-regolith bg-regolith/20 cursor-pointer">
<input
type="checkbox"
checked={redisProjection}
onChange={(e) => setRedisProjection(e.target.checked)}
className="mt-0.5 size-4 accent-ultramarain"
/>
<span className="flex-1">
<span className="block text-sm font-primary text-carbon">
{t('schema.redisProjection.label')}
</span>
<span className="block text-2xs text-carbon/60 mt-0.5">
{t('schema.redisProjection.hint')}
</span>
</span>
</label>
</div> </div>
</div> </div>
+4
View File
@@ -192,6 +192,8 @@ i18n
'schema.tabs.schema': 'Поля', 'schema.tabs.schema': 'Поля',
'schema.tabs.preview': 'JSON', 'schema.tabs.preview': 'JSON',
'schema.tabs.events': 'События', 'schema.tabs.events': 'События',
'schema.redisProjection.label': 'Включить Redis-проекцию для этого справочника',
'schema.redisProjection.hint': 'CEO plan E2 (tiered perf). По умолчанию выключено — read-api идёт в PG read replica. Включай когда dict упирается в RPS limit (целевые 10k+ RPS read через Redis hot cache). Projection-writer материализует updates per locale в Redis. Read-api routing появится отдельным feature — пока flag готовит почву.',
'schema.events.intro': 'Превью JSON событий, которые будут опубликованы в Kafka после save справочника. Данные подставлены на основе типов полей schema. Полезно интеграторам (Альтум, Геопортал) для понимания shape\'а до live-выкатки.', 'schema.events.intro': 'Превью JSON событий, которые будут опубликованы в Kafka после save справочника. Данные подставлены на основе типов полей schema. Полезно интеграторам (Альтум, Геопортал) для понимания shape\'а до live-выкатки.',
'schema.events.topic': 'Topic', 'schema.events.topic': 'Topic',
'schema.events.disclaimer': 'Это client-side preview. Реальные события содержат actual data + traceId/spanId из request scope. Schema events ordinis-events-api/src/main/java/.../events/ — единственный source of truth.', 'schema.events.disclaimer': 'Это client-side preview. Реальные события содержат actual data + traceId/spanId из request scope. Schema events ordinis-events-api/src/main/java/.../events/ — единственный source of truth.',
@@ -541,6 +543,8 @@ i18n
'schema.tabs.schema': 'Fields', 'schema.tabs.schema': 'Fields',
'schema.tabs.preview': 'JSON', 'schema.tabs.preview': 'JSON',
'schema.tabs.events': 'Events', 'schema.tabs.events': 'Events',
'schema.redisProjection.label': 'Enable Redis projection for this dictionary',
'schema.redisProjection.hint': 'CEO plan E2 (tiered perf). Off by default — read-api hits PG read replica. Turn on when this dict approaches RPS limit (target 10k+ RPS read through Redis hot cache). The projection writer materializes per-locale updates to Redis. Read-api routing will arrive in a separate feature; the flag prepares the ground.',
'schema.events.intro': 'Preview of JSON events that will be published to Kafka after the dictionary is saved. Data is filled in based on schema field types. Useful for integrators (Altum, Geoportal) to understand the shape before going live.', 'schema.events.intro': 'Preview of JSON events that will be published to Kafka after the dictionary is saved. Data is filled in based on schema field types. Useful for integrators (Altum, Geoportal) to understand the shape before going live.',
'schema.events.topic': 'Topic', 'schema.events.topic': 'Topic',
'schema.events.disclaimer': 'This is a client-side preview. Real events contain actual data + traceId/spanId from request scope. The events ordinis-events-api/src/main/java/.../events/ is the single source of truth.', 'schema.events.disclaimer': 'This is a client-side preview. Real events contain actual data + traceId/spanId from request scope. The events ordinis-events-api/src/main/java/.../events/ is the single source of truth.',
@@ -67,6 +67,15 @@ public class DictionaryDefinition {
@Column(name = "default_locale", nullable = false, length = 10) @Column(name = "default_locale", nullable = false, length = 10)
private String defaultLocale = "ru-RU"; private String defaultLocale = "ru-RU";
/**
* Per-dictionary Redis projection opt-in (CEO plan E2 tiered perf).
* False (default) — projection-writer skips this dict's events, read-api
* goes to PG read replica. True — projection-writer materializes updates
* to Redis (read-api routing TBD в отдельном feature).
*/
@Column(name = "redis_projection_enabled", nullable = false)
private boolean redisProjectionEnabled = false;
@CreatedDate @CreatedDate
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
@@ -106,6 +115,7 @@ public class DictionaryDefinition {
public String getBundle() { return bundle; } public String getBundle() { return bundle; }
public String[] getSupportedLocales() { return supportedLocales; } public String[] getSupportedLocales() { return supportedLocales; }
public String getDefaultLocale() { return defaultLocale; } public String getDefaultLocale() { return defaultLocale; }
public boolean isRedisProjectionEnabled() { return redisProjectionEnabled; }
public OffsetDateTime getCreatedAt() { return createdAt; } public OffsetDateTime getCreatedAt() { return createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; } public OffsetDateTime getUpdatedAt() { return updatedAt; }
public String getCreatedBy() { return createdBy; } public String getCreatedBy() { return createdBy; }
@@ -119,4 +129,5 @@ public class DictionaryDefinition {
public void setBundle(String v) { this.bundle = v; } public void setBundle(String v) { this.bundle = v; }
public void setSupportedLocales(String[] v) { this.supportedLocales = v; } public void setSupportedLocales(String[] v) { this.supportedLocales = v; }
public void setDefaultLocale(String v) { this.defaultLocale = v; } public void setDefaultLocale(String v) { this.defaultLocale = v; }
public void setRedisProjectionEnabled(boolean v) { this.redisProjectionEnabled = v; }
} }
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<!--
CEO plan E2 (Tiered perf): per-dictionary Redis projection feature flag.
`redis_projection_enabled` BOOLEAN на dictionary_definitions:
- false (default) — projection-writer пропускает Kafka events этого
dict'а, Redis для него не материализуется. read-api продолжает
читать из PG read replica (current path).
- true — projection-writer пишет updates в Redis. read-api в будущем
сможет route'ить queries в Redis для 10k+ RPS dicts. Read-api
routing — отдельная feature, не часть этой migration.
Default false — backward-compat: сейчас НИ ОДИН dict не помечен → Redis
становится пустым после deploy + первого refresh cycle. Это OK
потому что read-api сегодня НЕ читает из Redis (просто dead writes).
-->
<changeSet id="0017-redis-projection-flag" author="ordinis">
<comment>Per-dict Redis projection feature flag (CEO plan E2)</comment>
<addColumn tableName="dictionary_definitions">
<column name="redis_projection_enabled" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
<createIndex indexName="idx_dict_def_redis_proj"
tableName="dictionary_definitions">
<column name="redis_projection_enabled"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -26,5 +26,6 @@
<include file="changes/0014-webhook-deliveries.xml" relativeToChangelogFile="true"/> <include file="changes/0014-webhook-deliveries.xml" relativeToChangelogFile="true"/>
<include file="changes/0015-backfill-latlon-geometry.xml" relativeToChangelogFile="true"/> <include file="changes/0015-backfill-latlon-geometry.xml" relativeToChangelogFile="true"/>
<include file="changes/0016-record-dependents-index.xml" relativeToChangelogFile="true"/> <include file="changes/0016-record-dependents-index.xml" relativeToChangelogFile="true"/>
<include file="changes/0017-redis-projection-flag.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -33,6 +33,7 @@ public class ProjectionWriter {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final Counter recordsWrittenCounter; private final Counter recordsWrittenCounter;
private final Counter recordsDeletedCounter; private final Counter recordsDeletedCounter;
private final Counter recordsSkippedCounter;
public ProjectionWriter( public ProjectionWriter(
StringRedisTemplate redis, StringRedisTemplate redis,
@@ -48,6 +49,9 @@ public class ProjectionWriter {
this.recordsDeletedCounter = Counter.builder("ordinis_projection_records_deleted_total") this.recordsDeletedCounter = Counter.builder("ordinis_projection_records_deleted_total")
.description("Удалено из Redis projection") .description("Удалено из Redis projection")
.register(meterRegistry); .register(meterRegistry);
this.recordsSkippedCounter = Counter.builder("ordinis_projection_records_skipped_total")
.description("Skipped (dict has redis_projection_enabled=false). CEO E2 tiered perf.")
.register(meterRegistry);
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -58,6 +62,17 @@ public class ProjectionWriter {
return; return;
} }
DictionaryDefinition def = defOpt.get(); DictionaryDefinition def = defOpt.get();
// CEO plan E2 (Tiered perf): per-dict opt-in. Default false → projection
// не материализуется в Redis (read-api идёт в PG read replica).
// Admin переключает true когда dict упирается в RPS limit на PG.
if (!def.isRedisProjectionEnabled()) {
recordsSkippedCounter.increment();
log.trace("Projection upsert SKIPPED ({} has redis_projection_enabled=false): {}",
dictionary, businessKey);
return;
}
JsonNode schema = def.getSchemaJson(); JsonNode schema = def.getSchemaJson();
String[] locales = def.getSupportedLocales(); String[] locales = def.getSupportedLocales();
@@ -75,7 +90,18 @@ public class ProjectionWriter {
log.debug("Projection upserted: {}:{} ({} locales)", dictionary, businessKey, locales.length); log.debug("Projection upserted: {}:{} ({} locales)", dictionary, businessKey, locales.length);
} }
@Transactional(readOnly = true)
public void delete(String bundle, String dictionary, String businessKey) { public void delete(String bundle, String dictionary, String businessKey) {
Optional<DictionaryDefinition> defOpt = definitionRepository.findByName(dictionary);
if (defOpt.isPresent() && !defOpt.get().isRedisProjectionEnabled()) {
// Symmetric с upsert: nothing to delete если проекция выключена.
// Защита от случая когда раньше flag был true → есть keys → потом стал
// false → keys остаются stale. Этот edge case покрывается через
// отдельную one-shot cleanup job (TODO Phase 2 read-api routing).
recordsSkippedCounter.increment();
return;
}
Set<String> matching = redis.keys(RedisKeys.recordKeyPattern(bundle, dictionary, businessKey)); Set<String> matching = redis.keys(RedisKeys.recordKeyPattern(bundle, dictionary, businessKey));
if (matching != null && !matching.isEmpty()) { if (matching != null && !matching.isEmpty()) {
redis.delete(matching); redis.delete(matching);
@@ -19,4 +19,6 @@ public record CreateDictionaryRequest(
String schemaVersion, String schemaVersion,
String bundle, String bundle,
List<String> supportedLocales, List<String> supportedLocales,
String defaultLocale) {} String defaultLocale,
/** CEO plan E2: opt-in materialization Redis projection. Null/missing = false. */
Boolean redisProjectionEnabled) {}
@@ -21,6 +21,8 @@ public record DictionaryResponse(
String bundle, String bundle,
List<String> supportedLocales, List<String> supportedLocales,
String defaultLocale, String defaultLocale,
/** CEO plan E2: per-dict Redis projection opt-in. */
boolean redisProjectionEnabled,
Long recordCount, Long recordCount,
OffsetDateTime createdAt, OffsetDateTime createdAt,
OffsetDateTime updatedAt, OffsetDateTime updatedAt,
@@ -43,6 +45,7 @@ public record DictionaryResponse(
d.getBundle(), d.getBundle(),
d.getSupportedLocales() == null ? List.of() : List.of(d.getSupportedLocales()), d.getSupportedLocales() == null ? List.of() : List.of(d.getSupportedLocales()),
d.getDefaultLocale(), d.getDefaultLocale(),
d.isRedisProjectionEnabled(),
recordCount, recordCount,
d.getCreatedAt(), d.getCreatedAt(),
d.getUpdatedAt(), d.getUpdatedAt(),
@@ -54,6 +54,9 @@ public class DictionaryDefinitionService {
d.setSupportedLocales(req.supportedLocales().toArray(String[]::new)); d.setSupportedLocales(req.supportedLocales().toArray(String[]::new));
} }
if (req.defaultLocale() != null) d.setDefaultLocale(req.defaultLocale()); if (req.defaultLocale() != null) d.setDefaultLocale(req.defaultLocale());
if (req.redisProjectionEnabled() != null) {
d.setRedisProjectionEnabled(req.redisProjectionEnabled());
}
var saved = repository.save(d); var saved = repository.save(d);
@@ -99,6 +102,9 @@ public class DictionaryDefinitionService {
existing.setSupportedLocales(req.supportedLocales().toArray(String[]::new)); existing.setSupportedLocales(req.supportedLocales().toArray(String[]::new));
} }
if (req.defaultLocale() != null) existing.setDefaultLocale(req.defaultLocale()); if (req.defaultLocale() != null) existing.setDefaultLocale(req.defaultLocale());
if (req.redisProjectionEnabled() != null) {
existing.setRedisProjectionEnabled(req.redisProjectionEnabled());
}
var saved = repository.save(existing); var saved = repository.save(existing);