RelationProjectionRecovery.java

package com.taxonomy.relations.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;

import java.time.Instant;
import java.util.Locale;
import java.util.Objects;

/**
 * Durable recovery record for a Git-authoritative relation commit whose
 * database projection did not complete.
 *
 * <p>The authoritative commit is immutable. Rebuild reconciliation may only
 * complete or supersede the record after the selected branch has been rebuilt
 * from a commit that contains that authority.</p>
 */
@Entity
@Table(name = "relation_projection_recovery",
        indexes = {
                @Index(
                        name = "idx_rel_projection_recovery_repository",
                        columnList = "repository_id"),
                @Index(
                        name = "idx_rel_projection_recovery_pending",
                        columnList = "repository_id, workspace_scope_key, branch, status, id")
        },
        uniqueConstraints = @UniqueConstraint(
                name = "uq_rel_projection_recovery_authority",
                columnNames = {
                        "repository_id",
                        "workspace_scope_key",
                        "branch",
                        "authoritative_commit_id"
                }))
public class RelationProjectionRecovery {

    private static final int FAILURE_TYPE_LIMIT = 255;
    private static final int FAILURE_MESSAGE_LIMIT = 2000;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "repository_id", nullable = false, length = 255)
    private String repositoryId;

    @Column(name = "workspace_id", length = 255)
    private String workspaceId;

    @Column(name = "workspace_scope_key", nullable = false, length = 255)
    private String workspaceScopeKey = RelationDecisionProjection.CENTRAL_SCOPE_KEY;

    @Column(name = "branch", nullable = false, length = 255)
    private String branch;

    @Column(name = "previous_head_commit", length = 40)
    private String previousHeadCommit;

    @Column(name = "authoritative_commit_id", nullable = false, length = 40)
    private String authoritativeCommitId;

    @Column(name = "causation_id", nullable = false, length = 255)
    private String causationId;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 32)
    private RecoveryStatus status = RecoveryStatus.PENDING;

    @Column(name = "attempt_count", nullable = false)
    private int attemptCount;

    @Column(name = "failure_type", nullable = false, length = FAILURE_TYPE_LIMIT)
    private String failureType;

    @Column(name = "failure_message", nullable = false, length = FAILURE_MESSAGE_LIMIT)
    private String failureMessage;

    @Column(name = "first_observed_at", nullable = false)
    private Instant firstObservedAt;

    @Column(name = "last_observed_at", nullable = false)
    private Instant lastObservedAt;

    @Column(name = "completed_at")
    private Instant completedAt;

    @Version
    @Column(name = "version", nullable = false)
    private long version;

    @PrePersist
    void onCreate() {
        synchronize();
        Instant now = Instant.now();
        if (firstObservedAt == null) {
            firstObservedAt = now;
        }
        if (lastObservedAt == null) {
            lastObservedAt = firstObservedAt;
        }
    }

    @PreUpdate
    void onUpdate() {
        synchronize();
    }

    private void synchronize() {
        repositoryId = requireText(repositoryId, "repositoryId");
        workspaceId = normalizeOptional(workspaceId);
        workspaceScopeKey = RelationDecisionProjection.scopeKeyFor(workspaceId);
        branch = requireText(branch, "branch");
        previousHeadCommit = normalizeCommitId(
                previousHeadCommit, "previousHeadCommit", true);
        authoritativeCommitId = normalizeCommitId(
                authoritativeCommitId, "authoritativeCommitId", false);
        causationId = requireText(causationId, "causationId");
        status = Objects.requireNonNull(status, "status");
        failureType = limit(requireText(failureType, "failureType"), FAILURE_TYPE_LIMIT);
        failureMessage = limit(
                requireText(failureMessage, "failureMessage"),
                FAILURE_MESSAGE_LIMIT);
        if (attemptCount < 1) {
            throw new IllegalArgumentException("attemptCount must be positive");
        }
        if (status == RecoveryStatus.PENDING && completedAt != null) {
            throw new IllegalArgumentException(
                    "pending recovery must not have completedAt");
        }
        if (status != RecoveryStatus.PENDING && completedAt == null) {
            throw new IllegalArgumentException(
                    "completed recovery requires completedAt");
        }
    }

    public void recordFailure(Throwable failure) {
        Objects.requireNonNull(failure, "failure");
        Instant now = Instant.now();
        if (firstObservedAt == null) {
            firstObservedAt = now;
        }
        lastObservedAt = now;
        status = RecoveryStatus.PENDING;
        completedAt = null;
        attemptCount = Math.addExact(attemptCount, 1);
        failureType = limit(failure.getClass().getName(), FAILURE_TYPE_LIMIT);
        String message = normalizeOptional(failure.getMessage());
        failureMessage = limit(
                message == null ? failure.getClass().getSimpleName() : message,
                FAILURE_MESSAGE_LIMIT);
    }

    public void complete(RecoveryStatus completion) {
        if (completion == null || completion == RecoveryStatus.PENDING) {
            throw new IllegalArgumentException(
                    "completion must be RECOVERED or SUPERSEDED");
        }
        status = completion;
        completedAt = Instant.now();
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getRepositoryId() {
        return repositoryId;
    }

    public void setRepositoryId(String repositoryId) {
        this.repositoryId = requireText(repositoryId, "repositoryId");
    }

    public String getWorkspaceId() {
        return workspaceId;
    }

    public void setWorkspaceId(String workspaceId) {
        this.workspaceId = normalizeOptional(workspaceId);
        this.workspaceScopeKey = RelationDecisionProjection.scopeKeyFor(
                this.workspaceId);
    }

    public String getWorkspaceScopeKey() {
        return workspaceScopeKey;
    }

    public String getBranch() {
        return branch;
    }

    public void setBranch(String branch) {
        this.branch = requireText(branch, "branch");
    }

    public String getPreviousHeadCommit() {
        return previousHeadCommit;
    }

    public void setPreviousHeadCommit(String previousHeadCommit) {
        this.previousHeadCommit = normalizeCommitId(
                previousHeadCommit, "previousHeadCommit", true);
    }

    public String getAuthoritativeCommitId() {
        return authoritativeCommitId;
    }

    public void setAuthoritativeCommitId(String authoritativeCommitId) {
        this.authoritativeCommitId = normalizeCommitId(
                authoritativeCommitId, "authoritativeCommitId", false);
    }

    public String getCausationId() {
        return causationId;
    }

    public void setCausationId(String causationId) {
        this.causationId = requireText(causationId, "causationId");
    }

    public RecoveryStatus getStatus() {
        return status;
    }

    public int getAttemptCount() {
        return attemptCount;
    }

    public String getFailureType() {
        return failureType;
    }

    public String getFailureMessage() {
        return failureMessage;
    }

    public Instant getFirstObservedAt() {
        return firstObservedAt;
    }

    public Instant getLastObservedAt() {
        return lastObservedAt;
    }

    public Instant getCompletedAt() {
        return completedAt;
    }

    public long getVersion() {
        return version;
    }

    private static String normalizeCommitId(
            String value,
            String field,
            boolean optional) {
        String normalized = normalizeOptional(value);
        if (normalized == null && optional) {
            return null;
        }
        if (normalized == null
                || normalized.length() != 40
                || !normalized.chars().allMatch(RelationProjectionRecovery::isHex)) {
            throw new IllegalArgumentException(
                    field + " must be a full Git object ID");
        }
        return normalized.toLowerCase(Locale.ROOT);
    }

    private static boolean isHex(int value) {
        return value >= '0' && value <= '9'
                || value >= 'a' && value <= 'f'
                || value >= 'A' && value <= 'F';
    }

    private static String limit(String value, int limit) {
        return value.length() <= limit ? value : value.substring(0, limit);
    }

    private static String normalizeOptional(String value) {
        return value == null || value.isBlank() ? null : value.strip();
    }

    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 RecoveryStatus {
        PENDING,
        RECOVERED,
        SUPERSEDED
    }
}