RelationProjectionOperationsService.java
package com.taxonomy.relations.service;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.dsl.storage.ExpectedHeadDslCommitter;
import com.taxonomy.relations.service.RelationBranchProjectionReadinessService.Readiness;
import com.taxonomy.relations.service.RelationBranchProjectionReadinessService.ReadinessState;
import com.taxonomy.relations.service.RelationBranchProjectionRebuildService.BranchProjectionSourceException;
import com.taxonomy.relations.service.RelationBranchProjectionRebuildService.RebuildResult;
import com.taxonomy.relations.service.RelationProjectionRecoveryService.ReconciliationResult;
import com.taxonomy.relations.service.RelationProjectionRecoveryService.RecoveryRecord;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.RepositoryScope;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
/** Authorized operational boundary for branch projection readiness and rebuilds. */
@Service
public class RelationProjectionOperationsService {
private final DslGitRepositoryFactory gitRepositoryFactory;
private final RelationBranchProjectionReadinessService readinessService;
private final RelationBranchProjectionRebuildService rebuildService;
private final RelationProjectionRecoveryService recoveryService;
private final ExpectedHeadDslCommitter expectedHeadVerifier;
@Autowired
public RelationProjectionOperationsService(
DslGitRepositoryFactory gitRepositoryFactory,
RelationBranchProjectionReadinessService readinessService,
RelationBranchProjectionRebuildService rebuildService,
RelationProjectionRecoveryService recoveryService) {
this(
gitRepositoryFactory,
readinessService,
rebuildService,
recoveryService,
new ExpectedHeadDslCommitter());
}
RelationProjectionOperationsService(
DslGitRepositoryFactory gitRepositoryFactory,
RelationBranchProjectionReadinessService readinessService,
RelationBranchProjectionRebuildService rebuildService,
RelationProjectionRecoveryService recoveryService,
ExpectedHeadDslCommitter expectedHeadVerifier) {
this.gitRepositoryFactory = Objects.requireNonNull(
gitRepositoryFactory, "gitRepositoryFactory");
this.readinessService = Objects.requireNonNull(
readinessService, "readinessService");
this.rebuildService = Objects.requireNonNull(
rebuildService, "rebuildService");
this.recoveryService = Objects.requireNonNull(
recoveryService, "recoveryService");
this.expectedHeadVerifier = Objects.requireNonNull(
expectedHeadVerifier, "expectedHeadVerifier");
}
public ProjectionStatus inspect(RepositoryContext context) {
RepositoryContext selected = Objects.requireNonNull(context, "context");
return new ProjectionStatus(
readinessService.inspect(selected),
recoveryService.pending(selected));
}
/**
* Rebuilds only after proving the caller's exact existing branch head. A
* benign rebuild of a concurrently newer head is reported as a
* precondition conflict rather than silently satisfying the stale request.
*/
public RebuildOperation rebuild(
RepositoryContext context,
String expectedHeadCommit) throws IOException {
RepositoryContext selected = requireMutable(context);
String requiredHead = requireExistingHead(expectedHeadCommit);
DslGitRepository repository = gitRepositoryFactory
.resolveRepository(selected);
String verifiedHead = expectedHeadVerifier.verifyExpectedHead(
repository,
selected.branch(),
requiredHead);
if (verifiedHead == null) {
throw new BranchProjectionSourceException(
"Cannot rebuild relation projection for an absent branch");
}
RebuildResult rebuilt = rebuildService.rebuild(selected);
if (!verifiedHead.equals(rebuilt.authoritativeCommitId())) {
throw new RebuildHeadConflictException(
verifiedHead,
rebuilt.authoritativeCommitId());
}
Readiness readiness = readinessService.inspect(selected);
if (!rebuilt.authoritativeCommitId().equals(
readiness.currentHeadCommit())) {
throw new RebuildHeadConflictException(
rebuilt.authoritativeCommitId(),
readiness.currentHeadCommit());
}
boolean verifiedProjection = readiness.state() == ReadinessState.READY
&& rebuilt.authoritativeCommitId().equals(
readiness.projectedCommit())
&& rebuilt.relationCount() == readiness.rows().size();
if (!verifiedProjection) {
throw new RebuildVerificationException(rebuilt, readiness);
}
ReconciliationResult reconciliation;
try {
reconciliation = recoveryService.reconcileAfterRebuild(
selected, rebuilt.authoritativeCommitId());
} catch (RuntimeException error) {
throw new RecoveryReconciliationPendingException(rebuilt, error);
}
return new RebuildOperation(rebuilt, reconciliation, readiness);
}
private static String requireExistingHead(String expectedHeadCommit) {
if (expectedHeadCommit == null || expectedHeadCommit.isBlank()) {
throw new IllegalArgumentException(
"Projection rebuild requires If-Match with the exact existing branch commit");
}
return expectedHeadCommit.strip();
}
private static RepositoryContext requireMutable(RepositoryContext context) {
RepositoryContext selected = Objects.requireNonNull(context, "context");
if (selected.scope() == RepositoryScope.CENTRAL_READ) {
throw new IllegalArgumentException(
"Projection rebuild requires a writable repository context");
}
return selected;
}
public record ProjectionStatus(
Readiness readiness,
List<RecoveryRecord> pendingRecoveries) {
public ProjectionStatus {
readiness = Objects.requireNonNull(readiness, "readiness");
pendingRecoveries = List.copyOf(Objects.requireNonNull(
pendingRecoveries, "pendingRecoveries"));
}
}
public record RebuildOperation(
RebuildResult rebuild,
ReconciliationResult reconciliation,
Readiness readiness) {
public RebuildOperation {
rebuild = Objects.requireNonNull(rebuild, "rebuild");
reconciliation = Objects.requireNonNull(
reconciliation, "reconciliation");
readiness = Objects.requireNonNull(readiness, "readiness");
}
}
public static final class RebuildHeadConflictException
extends IllegalStateException {
private final String expectedHeadCommit;
private final String actualHeadCommit;
public RebuildHeadConflictException(
String expectedHeadCommit,
String actualHeadCommit) {
super("Relation projection rebuild expected "
+ expectedHeadCommit + " but observed " + actualHeadCommit);
this.expectedHeadCommit = expectedHeadCommit;
this.actualHeadCommit = actualHeadCommit;
}
public String getExpectedHeadCommit() {
return expectedHeadCommit;
}
public String getActualHeadCommit() {
return actualHeadCommit;
}
}
public static final class RebuildVerificationException
extends IllegalStateException {
private final RebuildResult rebuild;
private final Readiness readiness;
public RebuildVerificationException(
RebuildResult rebuild,
Readiness readiness) {
super("Relation projection rebuild at "
+ rebuild.authoritativeCommitId()
+ " did not produce a verified READY projection: "
+ readiness.state());
this.rebuild = Objects.requireNonNull(rebuild, "rebuild");
this.readiness = Objects.requireNonNull(readiness, "readiness");
}
public RebuildResult getRebuild() {
return rebuild;
}
public Readiness getReadiness() {
return readiness;
}
}
public static final class RecoveryReconciliationPendingException
extends IllegalStateException {
private final RebuildResult rebuild;
public RecoveryReconciliationPendingException(
RebuildResult rebuild,
Throwable cause) {
super("Relation projection rebuilt at "
+ rebuild.authoritativeCommitId()
+ ", but recovery reconciliation is pending", cause);
this.rebuild = Objects.requireNonNull(rebuild, "rebuild");
}
public RebuildResult getRebuild() {
return rebuild;
}
}
}