diff --git a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json index d1b97f9..240feb8 100644 --- a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json +++ b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json @@ -2,11 +2,11 @@ "bundle": "cuod", "displayName": "ЦУОД ДЗЗ", "description": "Bundle справочников для Центра Управления Обработкой Данных ДЗЗ. Anchor v1.", - "version": "1.2.1", + "version": "1.3.0", "supportedLocales": ["ru-RU", "en-US"], "defaultLocale": "ru-RU", "dictionaries": [ - { "name": "spacecraft", "displayName": "Космические аппараты", "description": "Каталог КА (космических аппаратов) ДЗЗ-миссий", "scope": "PUBLIC", "schemaResource": "spacecraft.schema.json", "schemaVersion": "1.0.0" }, + { "name": "spacecraft", "displayName": "Космические аппараты", "description": "Каталог КА (космических аппаратов) ДЗЗ-миссий", "scope": "PUBLIC", "schemaResource": "spacecraft.schema.json", "schemaVersion": "1.1.0" }, { "name": "satellite_type", "displayName": "Типы КА", "description": "Классификация типов космических аппаратов по миссиям и характеристикам", "scope": "PUBLIC", "schemaResource": "satellite_type.schema.json", "schemaVersion": "1.0.0" }, { "name": "spacecraft_status", "displayName": "Статусы КА", "description": "Жизненный цикл КА: planned → operational → decommissioned/lost.", "scope": "PUBLIC", "schemaResource": "spacecraft_status.schema.json", "schemaVersion": "1.0.0" }, { "name": "ground_station", "displayName": "Наземные станции", "description": "Каталог наземных пунктов приёма и управления", "scope": "PUBLIC", "schemaResource": "ground_station.schema.json", "schemaVersion": "1.0.0" }, diff --git a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/spacecraft.schema.json b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/spacecraft.schema.json index 5714cf7..1716cc7 100644 --- a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/spacecraft.schema.json +++ b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/spacecraft.schema.json @@ -41,8 +41,9 @@ }, "satellite_type_code": { "type": "string", - "description": "FK на satellite_type.code", - "x-references": "satellite_type.code" + "description": "FK на satellite_type.code. Phase 4 pilot: x-references-on-close: warn — закрытие satellite_type не блокируется, spacecraft остаётся orphan; orphan scanner подхватит на metric (data steward потом repoints).", + "x-references": "satellite_type.code", + "x-references-on-close": "warn" }, "mass_kg": { "type": "number", diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/CascadeCloseService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/CascadeCloseService.java index 98e7fa0..0b129f1 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/CascadeCloseService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/CascadeCloseService.java @@ -11,6 +11,9 @@ import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder; import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.Timer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -23,6 +26,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.Set; /** @@ -60,6 +64,8 @@ public class CascadeCloseService { private final LineageIndexService lineage; private final RecordCloseSink closeSink; private final ScopeContext scopeContext; + /** Phase 4 SLO. Optional — null в test ctor'е. */ + private final Optional meterRegistry; @Autowired public CascadeCloseService( @@ -68,10 +74,12 @@ public class CascadeCloseService { DictionaryDefinitionService defService, AuditLogger auditLogger, OutboxRecorder outbox, - ScopeContext scopeContext) { + ScopeContext scopeContext, + Optional meterRegistry) { this(lineage, new DefaultRecordCloseSink(recordRepo, defService, auditLogger, outbox), - scopeContext); + scopeContext, + meterRegistry); } /** Test-only ctor — sink инжектится напрямую (Mockito не мокает concrete). */ @@ -79,9 +87,18 @@ public class CascadeCloseService { LineageIndexService lineage, RecordCloseSink closeSink, ScopeContext scopeContext) { + this(lineage, closeSink, scopeContext, Optional.empty()); + } + + CascadeCloseService( + LineageIndexService lineage, + RecordCloseSink closeSink, + ScopeContext scopeContext, + Optional meterRegistry) { this.lineage = lineage; this.closeSink = closeSink; this.scopeContext = scopeContext; + this.meterRegistry = meterRegistry; } /** @@ -129,10 +146,12 @@ public class CascadeCloseService { OffsetDateTime closeAt, String reason, boolean confirmed) { + Timer.Sample sample = startTimer(); OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt; CascadePlan plan = evaluatePlan(targetDict, targetKey, when); if (!plan.blockers().isEmpty()) { + stopTimerAndCount(sample, targetDict, "blocked"); throw OrdinisException.conflict( "x_references_blocked_by_dependents", "Cannot close " + targetDict + "/" + targetKey + " — " @@ -141,6 +160,7 @@ public class CascadeCloseService { } if (!plan.cascade().isEmpty() && !confirmed) { + stopTimerAndCount(sample, targetDict, "cascade_required"); throw OrdinisException.conflict( "x_references_cascade_required", plan.cascade().size() + " dependents will be cascaded. " @@ -148,6 +168,7 @@ public class CascadeCloseService { } if (plan.cascade().size() > CASCADE_MAX_PER_REQUEST) { + stopTimerAndCount(sample, targetDict, "too_large"); throw OrdinisException.badRequest( "x_references_cascade_too_large", "Cascade size " + plan.cascade().size() + " exceeds limit " @@ -164,18 +185,46 @@ public class CascadeCloseService { } log.info("Cascade close OK: target={}/{} cascaded={} warnings={}", targetDict, targetKey, plan.cascade().size(), plan.warnings().size()); + stopTimerAndCount(sample, targetDict, "success"); return new CascadeCloseResult( targetDict, targetKey, plan.cascade(), plan.warnings(), 1 + plan.cascade().size()); } catch (TransactionTimedOutException | QueryTimeoutException e) { log.error("Cascade close timeout for {}/{} after 30s: {}", targetDict, targetKey, e.getMessage()); + stopTimerAndCount(sample, targetDict, "timeout"); throw new OrdinisException( org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE, "x_references_cascade_timeout", "Cascade close transaction exceeded 30s timeout. Rollback complete. " + "Consider splitting via bulk-close or reducing cascade fan-out."); + } catch (RuntimeException e) { + stopTimerAndCount(sample, targetDict, "error"); + throw e; } } + // === Metrics helpers === + + private Timer.Sample startTimer() { + if (meterRegistry.isEmpty()) return null; + return Timer.start(meterRegistry.get()); + } + + /** Records timer + increments outcome counter (success/blocked/timeout/...). */ + private void stopTimerAndCount(Timer.Sample sample, String targetDict, String outcome) { + if (meterRegistry.isEmpty()) return; + var registry = meterRegistry.get(); + Tags tags = Tags.of("target_dict", targetDict, "outcome", outcome); + if (sample != null) { + Timer t = Timer.builder("nsi_cascade_close_duration_seconds") + .description("Cascade close transaction latency") + .tags(tags) + .publishPercentiles(0.5, 0.95, 0.99) + .register(registry); + sample.stop(t); + } + registry.counter("nsi_cascade_close_total", tags).increment(); + } + /** * Узкий test-friendly seam над close mechanics (repo + audit + outbox). * Concrete prod impl ниже; тесты подменяют через subclass / lambda. diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java index a9e9b95..fb66efc 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java @@ -7,6 +7,9 @@ import cloud.nstart.terravault.ordinis.domain.record.DependentsQuery; import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord; import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; import com.fasterxml.jackson.databind.JsonNode; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.Timer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -56,25 +59,30 @@ public class LineageIndexService { private final DependentsQuery dependentsQuery; /** Optional — присутствует только когда {@code ordinis.lineage.mv.enabled=true}. */ private final Optional mvRefresh; + /** Optional — Phase 4 SLO instrumentation. Null в test ctor'е (без metrics). */ + private final Optional meterRegistry; @Autowired public LineageIndexService( DictionaryDefinitionRepository definitionRepository, DictionaryRecordRepository recordRepository, DependentsQuery dependentsQuery, - Optional mvRefresh) { + Optional mvRefresh, + Optional meterRegistry) { this.definitionRepository = definitionRepository; this.recordRepository = recordRepository; this.dependentsQuery = dependentsQuery; this.mvRefresh = mvRefresh; + this.meterRegistry = meterRegistry; } - /** Test-friendly ctor без MV refresh service. */ + /** Test-friendly ctor без MV refresh service и metrics. */ public LineageIndexService( DictionaryDefinitionRepository definitionRepository, DictionaryRecordRepository recordRepository, DependentsQuery dependentsQuery) { - this(definitionRepository, recordRepository, dependentsQuery, Optional.empty()); + this(definitionRepository, recordRepository, dependentsQuery, + Optional.empty(), Optional.empty()); } /** @@ -90,6 +98,19 @@ public class LineageIndexService { @Transactional(readOnly = true) public List findSchemaDependents( String targetDictName, Set allowedScopes) { + Timer.Sample sample = startTimer(); + try { + var result = findSchemaDependentsInternal(targetDictName, allowedScopes); + stopTimer(sample, "schema", targetDictName); + return result; + } catch (RuntimeException e) { + stopTimer(sample, "schema_error", targetDictName); + throw e; + } + } + + private List findSchemaDependentsInternal( + String targetDictName, Set allowedScopes) { Optional targetOpt = definitionRepository.findByName(targetDictName); if (targetOpt.isEmpty()) return List.of(); DictionaryDefinition target = targetOpt.get(); @@ -169,6 +190,24 @@ public class LineageIndexService { Set allowedScopes, int page, int size) { + Timer.Sample sample = startTimer(); + try { + var result = findRecordDependentsInternal( + targetDictName, targetBusinessKey, allowedScopes, page, size); + stopTimer(sample, "record", targetDictName); + return result; + } catch (RuntimeException e) { + stopTimer(sample, "record_error", targetDictName); + throw e; + } + } + + private RecordDependentsPage findRecordDependentsInternal( + String targetDictName, + String targetBusinessKey, + Set allowedScopes, + int page, + int size) { if (page < 0) throw new IllegalArgumentException("page must be >= 0"); if (size < 1 || size > 200) { throw new IllegalArgumentException("size must be 1..200, got " + size); @@ -311,6 +350,24 @@ public class LineageIndexService { OnCloseAction onClose, long count) {} + // === Metrics helpers === + + private Timer.Sample startTimer() { + if (meterRegistry.isEmpty()) return null; + return Timer.start(meterRegistry.get()); + } + + private void stopTimer(Timer.Sample sample, String mode, String targetDict) { + if (sample == null || meterRegistry.isEmpty()) return; + // Cardinality bound: target_dict tag — bounded by # dictionaries (~40). + Timer t = Timer.builder("nsi_dependents_query_duration_seconds") + .description("Dependents lookup latency (Phase 4 SLO instrumentation)") + .tags(Tags.of("mode", mode, "target_dict", targetDict)) + .publishPercentiles(0.5, 0.95, 0.99) + .register(meterRegistry.get()); + sample.stop(t); + } + /** Paginated page + per-source aggregates + lineage source metadata. */ public record RecordDependentsPage( String targetDict, diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/MaterializedViewRefreshService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/MaterializedViewRefreshService.java index 1fac0e2..962f300 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/MaterializedViewRefreshService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/MaterializedViewRefreshService.java @@ -1,5 +1,8 @@ package cloud.nstart.terravault.ordinis.restapi.service.reference; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -13,6 +16,7 @@ import java.time.Duration; import java.time.Instant; import java.time.OffsetDateTime; import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; /* JDK 25 + Mockito не мочат concrete JdbcTemplate (subclass mock-maker @@ -60,17 +64,45 @@ public class MaterializedViewRefreshService { private final MvGateway gateway; /** Volatile guard — не позволяет двум refresh'ам параллельно. */ private final AtomicReference currentRefreshStart = new AtomicReference<>(null); + /** Phase 4: gauges feed Prometheus + alerts. */ + private final AtomicLong lastRefreshEpochMs = new AtomicLong(0); + private final AtomicLong lastRowCount = new AtomicLong(0); + private final AtomicLong lastDurationMs = new AtomicLong(0); + private final Optional meterRegistry; @Value("${ordinis.lineage.mv.min-interval-sec:30}") private long minIntervalSec; + public MaterializedViewRefreshService(JdbcTemplate jdbc, Optional meterRegistry) { + this(new JdbcMvGateway(jdbc), meterRegistry); + } + public MaterializedViewRefreshService(JdbcTemplate jdbc) { - this(new JdbcMvGateway(jdbc)); + this(new JdbcMvGateway(jdbc), Optional.empty()); } /** Test-only ctor — gateway инжектится напрямую. */ MaterializedViewRefreshService(MvGateway gateway) { + this(gateway, Optional.empty()); + } + + MaterializedViewRefreshService(MvGateway gateway, Optional meterRegistry) { this.gateway = gateway; + this.meterRegistry = meterRegistry; + } + + @PostConstruct + void registerGauges() { + if (meterRegistry.isEmpty()) return; + var reg = meterRegistry.get(); + // Last refresh age в секундах — используется в alert OrdinisLineageMvStale. + reg.gauge("nsi_lineage_mv_last_refresh_seconds_ago", Tags.empty(), this, + s -> s.lastRefreshEpochMs.get() == 0 + ? Double.NaN + : (System.currentTimeMillis() - s.lastRefreshEpochMs.get()) / 1000.0); + reg.gauge("nsi_lineage_mv_row_count", Tags.empty(), lastRowCount, AtomicLong::doubleValue); + reg.gauge("nsi_lineage_mv_last_duration_seconds", Tags.empty(), + lastDurationMs, l -> l.get() / 1000.0); } /** @@ -167,9 +199,18 @@ public class MaterializedViewRefreshService { } catch (DataAccessException e) { log.warn("Failed to update lineage_mv_meta: {}", e.getMessage()); } + // Phase 4: feed gauges + counter (separate from gateway updateMeta — + // нужно даже при ошибке записи в lineage_mv_meta). + lastDurationMs.set(duration); + if (rowCount != null) lastRowCount.set(rowCount); if ("ok".equals(status)) { + lastRefreshEpochMs.set(System.currentTimeMillis()); log.info("MV record_dependents_index refreshed: {}ms, {} rows", duration, rowCount); } + final String finalStatus = status; + meterRegistry.ifPresent(reg -> reg.counter( + "nsi_lineage_mv_refresh_total", + Tags.of("outcome", finalStatus)).increment()); } private static String truncate(String s, int max) {