diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 9712426..beacec0 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -35,6 +35,8 @@ export type DictionaryDefinition = { bundle: string supportedLocales: string[] defaultLocale: string + /** CEO plan E2: per-dict Redis projection materialization opt-in. */ + redisProjectionEnabled: boolean recordCount?: number createdAt: string updatedAt: string @@ -103,6 +105,8 @@ export type CreateDictionaryRequest = { bundle?: string supportedLocales?: string[] defaultLocale?: string + /** CEO plan E2: per-dict Redis projection opt-in. Default false. */ + redisProjectionEnabled?: boolean } export type CreateRecordRequest = { diff --git a/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx index 0e70098..982a3b0 100644 --- a/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx +++ b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx @@ -72,6 +72,9 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props initial?.supportedLocales ?? ['ru-RU'], ) const [defaultLocale, setDefaultLocale] = useState(initial?.defaultLocale ?? 'ru-RU') + const [redisProjection, setRedisProjection] = useState( + initial?.redisProjectionEnabled ?? false, + ) const [properties, setProperties] = useState(parsed.properties) const [idSource, setIdSource] = useState(parsed.idSource ?? '') const [nameError, setNameError] = useState(null) @@ -157,6 +160,7 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props bundle: bundle || undefined, supportedLocales: supportedLocales.length > 0 ? supportedLocales : undefined, defaultLocale: defaultLocale || undefined, + redisProjectionEnabled: redisProjection, } if (isEdit) { @@ -286,6 +290,24 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props value={defaultLocale} onChange={setDefaultLocale} /> + {/* CEO plan E2: per-dict Redis projection opt-in. Default false. + Включается когда dict хочет 10k+ RPS на read-api (вместо PG). */} + diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 12ff9ba..aafeae1 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -192,6 +192,8 @@ i18n 'schema.tabs.schema': 'Поля', 'schema.tabs.preview': 'JSON', '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.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.', @@ -541,6 +543,8 @@ i18n 'schema.tabs.schema': 'Fields', 'schema.tabs.preview': 'JSON', '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.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.', diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java index 39c7bf2..6870117 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java @@ -67,6 +67,15 @@ public class DictionaryDefinition { @Column(name = "default_locale", nullable = false, length = 10) 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 @Column(name = "created_at", nullable = false, updatable = false) private OffsetDateTime createdAt; @@ -106,6 +115,7 @@ public class DictionaryDefinition { public String getBundle() { return bundle; } public String[] getSupportedLocales() { return supportedLocales; } public String getDefaultLocale() { return defaultLocale; } + public boolean isRedisProjectionEnabled() { return redisProjectionEnabled; } public OffsetDateTime getCreatedAt() { return createdAt; } public OffsetDateTime getUpdatedAt() { return updatedAt; } public String getCreatedBy() { return createdBy; } @@ -119,4 +129,5 @@ public class DictionaryDefinition { public void setBundle(String v) { this.bundle = v; } public void setSupportedLocales(String[] v) { this.supportedLocales = v; } public void setDefaultLocale(String v) { this.defaultLocale = v; } + public void setRedisProjectionEnabled(boolean v) { this.redisProjectionEnabled = v; } } diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0017-redis-projection-flag.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0017-redis-projection-flag.xml new file mode 100644 index 0000000..bd71a8b --- /dev/null +++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0017-redis-projection-flag.xml @@ -0,0 +1,37 @@ + + + + + + Per-dict Redis projection feature flag (CEO plan E2) + + + + + + + + + + + + diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml index 6555d96..c12512c 100644 --- a/ordinis-migrations/src/main/resources/db/changelog/master.xml +++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml @@ -26,5 +26,6 @@ + diff --git a/ordinis-projection-writer/src/main/java/cloud/nstart/terravault/ordinis/projection/ProjectionWriter.java b/ordinis-projection-writer/src/main/java/cloud/nstart/terravault/ordinis/projection/ProjectionWriter.java index 2ee4e39..5b130d5 100644 --- a/ordinis-projection-writer/src/main/java/cloud/nstart/terravault/ordinis/projection/ProjectionWriter.java +++ b/ordinis-projection-writer/src/main/java/cloud/nstart/terravault/ordinis/projection/ProjectionWriter.java @@ -33,6 +33,7 @@ public class ProjectionWriter { private final ObjectMapper objectMapper; private final Counter recordsWrittenCounter; private final Counter recordsDeletedCounter; + private final Counter recordsSkippedCounter; public ProjectionWriter( StringRedisTemplate redis, @@ -48,6 +49,9 @@ public class ProjectionWriter { this.recordsDeletedCounter = Counter.builder("ordinis_projection_records_deleted_total") .description("Удалено из Redis projection") .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) @@ -58,6 +62,17 @@ public class ProjectionWriter { return; } 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(); String[] locales = def.getSupportedLocales(); @@ -75,7 +90,18 @@ public class ProjectionWriter { log.debug("Projection upserted: {}:{} ({} locales)", dictionary, businessKey, locales.length); } + @Transactional(readOnly = true) public void delete(String bundle, String dictionary, String businessKey) { + Optional 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 matching = redis.keys(RedisKeys.recordKeyPattern(bundle, dictionary, businessKey)); if (matching != null && !matching.isEmpty()) { redis.delete(matching); diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java index 3864474..cf79ec0 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java @@ -19,4 +19,6 @@ public record CreateDictionaryRequest( String schemaVersion, String bundle, List supportedLocales, - String defaultLocale) {} + String defaultLocale, + /** CEO plan E2: opt-in materialization Redis projection. Null/missing = false. */ + Boolean redisProjectionEnabled) {} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java index 745920e..1aba003 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java @@ -21,6 +21,8 @@ public record DictionaryResponse( String bundle, List supportedLocales, String defaultLocale, + /** CEO plan E2: per-dict Redis projection opt-in. */ + boolean redisProjectionEnabled, Long recordCount, OffsetDateTime createdAt, OffsetDateTime updatedAt, @@ -43,6 +45,7 @@ public record DictionaryResponse( d.getBundle(), d.getSupportedLocales() == null ? List.of() : List.of(d.getSupportedLocales()), d.getDefaultLocale(), + d.isRedisProjectionEnabled(), recordCount, d.getCreatedAt(), d.getUpdatedAt(), diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java index bc4f40c..26590cf 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java @@ -54,6 +54,9 @@ public class DictionaryDefinitionService { d.setSupportedLocales(req.supportedLocales().toArray(String[]::new)); } if (req.defaultLocale() != null) d.setDefaultLocale(req.defaultLocale()); + if (req.redisProjectionEnabled() != null) { + d.setRedisProjectionEnabled(req.redisProjectionEnabled()); + } var saved = repository.save(d); @@ -99,6 +102,9 @@ public class DictionaryDefinitionService { existing.setSupportedLocales(req.supportedLocales().toArray(String[]::new)); } if (req.defaultLocale() != null) existing.setDefaultLocale(req.defaultLocale()); + if (req.redisProjectionEnabled() != null) { + existing.setRedisProjectionEnabled(req.redisProjectionEnabled()); + } var saved = repository.save(existing);