feat(refs): orphan FK scanner — Prometheus gauge nsi_orphan_references_total

@Scheduled sweep раз в час (configurable). Walks all schemas, для каждого
x-references marker считает orphans через JdbcTemplate native SQL
(WHERE source.data->>field NOT IN target dict active records).

Метрика: Gauge per (source_dict, source_field, target_dict, target_field).
Cardinality bounded by FK count в bundles (~5 для cuod v1.2.1).

Conditional: ordinis.references.scan-enabled=true (default off, включаем
явно в prod values). Read-only, no write impact.

OrphanReferenceQuery (ordinis-domain): native SQL helper, identifier
validation против SQL injection. Field name regex check т.к. JSONB key
path не bind'ится через @Param.

Tests: 4 в OrphanReferenceScannerTest (parseRef edge cases). Реальный
SQL логику покроет integration тест на staging данных.

Total project tests: 191 → 195.
This commit is contained in:
Zimin A.N.
2026-05-06 17:23:00 +03:00
parent 7b6e2145ea
commit 0759a5a150
3 changed files with 299 additions and 0 deletions
@@ -0,0 +1,82 @@
package cloud.nstart.terravault.ordinis.domain.record;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* Native SQL для подсчёта orphan FK references — записи где
* {@code data->>source_field} не находится в active records target dictionary
* по {@code data->>target_field}.
*
* <p>Используется {@code OrphanReferenceScanner} раз в час для метрики
* {@code nsi_orphan_references_total}. Отдельный component (не repository
* метод) потому что используем JdbcTemplate с dynamic field names — JPA
* не может взять {@code @Param} в JSONB key path.
*/
@Component
public class OrphanReferenceQuery {
private final JdbcTemplate jdbc;
@Autowired
public OrphanReferenceQuery(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
/**
* Считает orphan references: active records в source dictionary где
* {@code data->>source_field} НЕ присутствует среди active records
* target dictionary {@code data->>target_field}. NULL-значения
* пропускаются (optional fields, x-references не required).
*
* <p>Field names валидируются caller'ом (берутся из schema parser, не
* из user input) — SQL injection невозможна. Используем native SQL
* concat потому что JdbcTemplate не bind'ит JSONB key paths.
*
* @return count of orphan source records
*/
public long countOrphans(
UUID sourceDictionaryId,
String sourceField,
UUID targetDictionaryId,
String targetField) {
validateField(sourceField);
validateField(targetField);
String sql = """
SELECT count(*) FROM dictionary_records src
WHERE src.dictionary_id = ?
AND src.valid_from <= now()
AND src.valid_to > now()
AND src.data ->> '%s' IS NOT NULL
AND src.data ->> '%s' NOT IN (
SELECT tgt.data ->> '%s'
FROM dictionary_records tgt
WHERE tgt.dictionary_id = ?
AND tgt.valid_from <= now()
AND tgt.valid_to > now()
AND tgt.data ->> '%s' IS NOT NULL
)
""".formatted(sourceField, sourceField, targetField, targetField);
Long count = jdbc.queryForObject(sql, Long.class, sourceDictionaryId, targetDictionaryId);
return count == null ? 0 : count;
}
/**
* SQL injection guard. Field name приходит из JSON Schema (мы парсим),
* но мы не доверяем — extra paranoid check. Алlowед identifier rules.
*/
private static void validateField(String field) {
if (field == null || field.isBlank()) {
throw new IllegalArgumentException("field name is empty");
}
if (!field.matches("^[a-zA-Z_][a-zA-Z0-9_]{0,127}$")) {
throw new IllegalArgumentException(
"field name has unsafe characters: " + field);
}
}
}