RelationProjectionReadService.java
package com.taxonomy.relations.service;
import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import com.taxonomy.catalog.service.TaxonomyRelationService;
import com.taxonomy.dto.TaxonomyRelationDto;
import com.taxonomy.model.RelationType;
import com.taxonomy.relations.model.RelationDecisionProjection;
import com.taxonomy.relations.service.RelationBranchProjectionReadinessService.Readiness;
import com.taxonomy.relations.service.RelationBranchProjectionReadinessService.ReadinessState;
import com.taxonomy.workspace.model.SystemRepository;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.RepositoryScope;
import com.taxonomy.workspace.service.SystemRepositoryService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Predicate;
import java.util.function.Supplier;
/**
* Product read boundary for relation decisions.
*
* <p>A complete branch projection is the normal source. The only compatibility
* fallback is the historic primary repository's configured default branch while
* no projection has ever been built and no failed Git-authoritative projection
* is pending. Stale, corrupt, branch-missing, workspace and fork projections
* fail closed instead of exposing an unrelated legacy overlay.</p>
*/
@Service
public class RelationProjectionReadService {
private static final Logger log = LoggerFactory.getLogger(
RelationProjectionReadService.class);
private final RelationBranchProjectionReadinessService readinessService;
private final RelationProjectionRecoveryService recoveryService;
private final TaxonomyRelationService legacyRelationService;
private final TaxonomyNodeRepository nodeRepository;
private final SystemRepositoryService repositoryService;
private final Set<String> reportedFallbacks = ConcurrentHashMap.newKeySet();
public RelationProjectionReadService(
RelationBranchProjectionReadinessService readinessService,
RelationProjectionRecoveryService recoveryService,
TaxonomyRelationService legacyRelationService,
TaxonomyNodeRepository nodeRepository,
SystemRepositoryService repositoryService) {
this.readinessService = Objects.requireNonNull(
readinessService, "readinessService");
this.recoveryService = Objects.requireNonNull(
recoveryService, "recoveryService");
this.legacyRelationService = Objects.requireNonNull(
legacyRelationService, "legacyRelationService");
this.nodeRepository = Objects.requireNonNull(
nodeRepository, "nodeRepository");
this.repositoryService = Objects.requireNonNull(
repositoryService, "repositoryService");
}
public ReadResult readAll(RepositoryContext context) {
RepositoryContext selected = Objects.requireNonNull(context, "context");
return read(
selected,
ignored -> true,
() -> legacyRelationService.getAllRelationsInContext(selected));
}
public ReadResult readByType(
RepositoryContext context,
RelationType relationType) {
RepositoryContext selected = Objects.requireNonNull(context, "context");
RelationType type = Objects.requireNonNull(
relationType, "relationType");
return read(
selected,
row -> row.getRelationType() == type,
() -> legacyRelationService.getRelationsByTypeInContext(
type, selected));
}
public ReadResult readForNode(
RepositoryContext context,
String nodeCode) {
RepositoryContext selected = Objects.requireNonNull(context, "context");
String code = requireText(nodeCode, "nodeCode");
return read(
selected,
row -> code.equals(row.getSourceCode())
|| code.equals(row.getTargetCode()),
() -> legacyRelationService.getRelationsForNodeInContext(
code, selected));
}
/** Count from the same proven source without allocating relation DTOs. */
public CountResult count(RepositoryContext context) {
RepositoryContext selected = Objects.requireNonNull(context, "context");
ResolvedSource source = resolveSource(selected);
long count = source.readModel() == ReadModel.PROJECTION
? source.readiness().rows().size()
: legacyRelationService.countRelationsInContext(selected);
return new CountResult(
source.readModel(),
source.readiness().state(),
source.readiness().currentHeadCommit(),
count);
}
/**
* Resolve one immutable relation-identity snapshot for proposal duplicate
* validation without loading node names or allocating relation DTOs for a
* ready projection.
*/
public IdentitySnapshot readIdentitySnapshot(RepositoryContext context) {
RepositoryContext selected = Objects.requireNonNull(context, "context");
ResolvedSource source = resolveSource(selected);
Set<RelationIdentity> identities = new LinkedHashSet<>();
if (source.readModel() == ReadModel.PROJECTION) {
source.readiness().rows().forEach(row -> identities.add(
new RelationIdentity(
row.getSourceCode(),
row.getRelationType(),
row.getTargetCode())));
} else {
legacyRelationService.getAllRelationsInContext(selected)
.forEach(relation -> identities.add(new RelationIdentity(
relation.getSourceCode(),
RelationType.valueOf(relation.getRelationType()),
relation.getTargetCode())));
}
return new IdentitySnapshot(
source.readModel(),
source.readiness().state(),
source.readiness().currentHeadCommit(),
identities);
}
private ReadResult read(
RepositoryContext selected,
Predicate<RelationDecisionProjection> projectionFilter,
Supplier<List<TaxonomyRelationDto>> legacyRead) {
ResolvedSource source = resolveSource(selected);
if (source.readModel() == ReadModel.PROJECTION) {
List<RelationDecisionProjection> selectedRows = source.readiness()
.rows()
.stream()
.filter(projectionFilter)
.toList();
return new ReadResult(
source.readModel(),
source.readiness().state(),
source.readiness().currentHeadCommit(),
projectionDtos(selectedRows));
}
return new ReadResult(
source.readModel(),
source.readiness().state(),
source.readiness().currentHeadCommit(),
legacyRead.get());
}
/** One authority/fallback decision shared by list, count and identity reads. */
private ResolvedSource resolveSource(RepositoryContext selected) {
Readiness readiness = readinessService.inspect(selected);
if (readiness.state() == ReadinessState.READY) {
return new ResolvedSource(ReadModel.PROJECTION, readiness);
}
long pendingRecoveries = recoveryService.pendingCount(selected);
if (readiness.state() == ReadinessState.NOT_BUILT
&& pendingRecoveries == 0
&& mayUseLegacyFallback(selected)) {
reportFallback(selected);
return new ResolvedSource(ReadModel.LEGACY_FALLBACK, readiness);
}
throw unavailable(selected, readiness, pendingRecoveries);
}
private static RelationProjectionUnavailableException unavailable(
RepositoryContext selected,
Readiness readiness,
long pendingRecoveries) {
return new RelationProjectionUnavailableException(
selected,
readiness.state(),
readiness.currentHeadCommit(),
readiness.projectedCommit(),
pendingRecoveries);
}
private List<TaxonomyRelationDto> projectionDtos(
List<RelationDecisionProjection> rows) {
List<RelationDecisionProjection> projections = List.copyOf(rows);
Set<String> codes = new LinkedHashSet<>();
projections.forEach(row -> {
codes.add(row.getSourceCode());
codes.add(row.getTargetCode());
});
Map<String, String> names = names(codes);
return projections.stream()
.map(row -> projectionDto(row, names))
.toList();
}
private Map<String, String> names(Collection<String> codes) {
if (codes.isEmpty()) {
return Map.of();
}
Map<String, String> names = new HashMap<>();
nodeRepository.findByCodeIn(codes).forEach(node -> names.put(
node.getCode(), displayName(node)));
return Map.copyOf(names);
}
private static String displayName(TaxonomyNode node) {
return node.getNameEn() == null || node.getNameEn().isBlank()
? node.getCode()
: node.getNameEn();
}
private static TaxonomyRelationDto projectionDto(
RelationDecisionProjection row,
Map<String, String> names) {
TaxonomyRelationDto dto = new TaxonomyRelationDto();
dto.setId(row.getId());
dto.setSourceCode(row.getSourceCode());
dto.setSourceName(names.getOrDefault(
row.getSourceCode(), row.getSourceCode()));
dto.setTargetCode(row.getTargetCode());
dto.setTargetName(names.getOrDefault(
row.getTargetCode(), row.getTargetCode()));
dto.setRelationType(row.getRelationType().name());
dto.setDescription(null);
dto.setProvenance(row.getProvenance());
dto.setWeight(null);
dto.setBidirectional(false);
return dto;
}
private boolean mayUseLegacyFallback(RepositoryContext context) {
if (context.scope() != RepositoryScope.CENTRAL_READ
&& context.scope() != RepositoryScope.CENTRAL_WRITE) {
return false;
}
SystemRepository primary = repositoryService.getPrimaryRepository();
return primary.getRepositoryId().equals(context.repositoryId())
&& primary.getDefaultBranch().equals(context.branch());
}
private void reportFallback(RepositoryContext context) {
String key = context.repositoryId() + "/" + context.branch();
if (reportedFallbacks.add(key)) {
log.warn("Relation reads use the migration-only legacy fallback for {}. "
+ "Build the complete Git branch projection to remove this fallback.",
key);
}
}
private static String requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return value.strip();
}
public enum ReadModel {
PROJECTION,
LEGACY_FALLBACK
}
public record ReadResult(
ReadModel readModel,
ReadinessState readinessState,
String authoritativeCommitId,
List<TaxonomyRelationDto> relations) {
public ReadResult {
readModel = Objects.requireNonNull(readModel, "readModel");
readinessState = Objects.requireNonNull(
readinessState, "readinessState");
relations = List.copyOf(Objects.requireNonNull(
relations, "relations"));
}
}
public record CountResult(
ReadModel readModel,
ReadinessState readinessState,
String authoritativeCommitId,
long count) {
public CountResult {
readModel = Objects.requireNonNull(readModel, "readModel");
readinessState = Objects.requireNonNull(
readinessState, "readinessState");
if (count < 0) {
throw new IllegalArgumentException("count must not be negative");
}
}
}
public record RelationIdentity(
String sourceCode,
RelationType relationType,
String targetCode) {
public RelationIdentity {
sourceCode = requireText(sourceCode, "sourceCode");
relationType = Objects.requireNonNull(
relationType, "relationType");
targetCode = requireText(targetCode, "targetCode");
}
}
public record IdentitySnapshot(
ReadModel readModel,
ReadinessState readinessState,
String authoritativeCommitId,
Set<RelationIdentity> identities) {
public IdentitySnapshot {
readModel = Objects.requireNonNull(readModel, "readModel");
readinessState = Objects.requireNonNull(
readinessState, "readinessState");
identities = Set.copyOf(Objects.requireNonNull(
identities, "identities"));
}
public boolean contains(
String sourceCode,
RelationType relationType,
String targetCode) {
return identities.contains(new RelationIdentity(
sourceCode, relationType, targetCode));
}
}
private record ResolvedSource(
ReadModel readModel,
Readiness readiness) {
private ResolvedSource {
readModel = Objects.requireNonNull(readModel, "readModel");
readiness = Objects.requireNonNull(readiness, "readiness");
}
}
public static final class RelationProjectionUnavailableException
extends IllegalStateException {
private final RepositoryContext context;
private final ReadinessState readinessState;
private final String currentHeadCommit;
private final String projectedCommit;
private final long pendingRecoveryCount;
public RelationProjectionUnavailableException(
RepositoryContext context,
ReadinessState readinessState,
String currentHeadCommit,
String projectedCommit,
long pendingRecoveryCount) {
super("Relation projection is not safely readable for "
+ context.repositoryId() + "/" + context.branch()
+ ": " + readinessState
+ " (pending recovery=" + pendingRecoveryCount + ")");
this.context = Objects.requireNonNull(context, "context");
this.readinessState = Objects.requireNonNull(
readinessState, "readinessState");
this.currentHeadCommit = currentHeadCommit;
this.projectedCommit = projectedCommit;
this.pendingRecoveryCount = pendingRecoveryCount;
}
public RepositoryContext getContext() {
return context;
}
public ReadinessState getReadinessState() {
return readinessState;
}
public String getCurrentHeadCommit() {
return currentHeadCommit;
}
public String getProjectedCommit() {
return projectedCommit;
}
public long getPendingRecoveryCount() {
return pendingRecoveryCount;
}
}
}