RequirementAnalysisJob.java
package com.taxonomy.portfolio.model;
import com.taxonomy.portfolio.model.PortfolioTypes.AnalysisStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinColumns;
import jakarta.persistence.ManyToOne;
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.Objects;
/** Persisted batch job for independently analyzing project requirements in one exact tenant. */
@Entity
@Table(name = "req_analysis_job", indexes = {
@Index(name = "idx_job_project", columnList = "scope_key,project_id,created_at"),
@Index(name = "idx_job_status", columnList = "scope_key,project_id,status"),
@Index(name = "idx_job_scope", columnList = "scope_key")
}, uniqueConstraints = {
@UniqueConstraint(name = "uq_job_idempotency",
columnNames = {"scope_key", "project_id", "idempotency_key"}),
@UniqueConstraint(name = "uq_job_id_scope", columnNames = {"id", "scope_key"}),
@UniqueConstraint(name = "uq_job_id_proj_scope",
columnNames = {"id", "project_id", "scope_key"})
})
public class RequirementAnalysisJob {
@Id
@Column(length = 36)
private String id;
@Column(name = "scope_key", nullable = false,
length = PortfolioTenantIdentity.MAX_SCOPE_KEY_LENGTH)
private String scopeKey;
/** Scalar parent identity is the write authority for the tenant-bound foreign key. */
@Column(name = "project_id", nullable = false)
private Long projectId;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumns({
@JoinColumn(name = "project_id", referencedColumnName = "id",
nullable = false, insertable = false, updatable = false),
@JoinColumn(name = "scope_key", referencedColumnName = "scope_key",
nullable = false, insertable = false, updatable = false)
})
private ArchitectureProject project;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private AnalysisStatus status = AnalysisStatus.PENDING;
/** Always populated; automatically generated keys keep SQL Server uniqueness portable. */
@Column(name = "idempotency_key", nullable = false, length = 160)
private String idempotencyKey;
@Column(length = 40)
private String provider;
@Column(name = "max_architecture_nodes", nullable = false)
private int maxArchitectureNodes;
@Column(name = "requested_by", nullable = false, length = 160)
private String requestedBy;
/** Retained as display provenance; {@link #scopeKey} is the persistence authority. */
@Column(name = "workspace_id", length = 120)
private String workspaceId;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "started_at")
private Instant startedAt;
@Column(name = "completed_at")
private Instant completedAt;
@Column(name = "total_items", nullable = false)
private int totalItems;
@Column(name = "successful_items", nullable = false)
private int successfulItems;
@Column(name = "partial_items", nullable = false)
private int partialItems;
@Column(name = "failed_items", nullable = false)
private int failedItems;
@Column(name = "error_summary", length = 2000)
private String errorSummary;
@Version
@Column(name = "row_version", nullable = false)
private long rowVersion;
protected RequirementAnalysisJob() {
}
public RequirementAnalysisJob(String id,
ArchitectureProject project,
String idempotencyKey,
String provider,
int maxArchitectureNodes,
String requestedBy,
String workspaceId,
int totalItems,
Instant createdAt) {
this.id = id;
this.project = project;
this.idempotencyKey = idempotencyKey != null ? idempotencyKey : "auto:" + id;
this.provider = provider;
this.maxArchitectureNodes = maxArchitectureNodes;
this.requestedBy = requestedBy;
this.workspaceId = workspaceId;
this.totalItems = totalItems;
this.createdAt = createdAt;
synchronizeTenantAuthority(false);
}
public void markPending() {
this.status = AnalysisStatus.PENDING;
this.startedAt = null;
this.completedAt = null;
this.errorSummary = null;
}
public void markRunning(Instant now) {
this.status = AnalysisStatus.RUNNING;
this.startedAt = now;
this.completedAt = null;
this.errorSummary = null;
}
public void complete(int successfulItems,
int partialItems,
int failedItems,
String errorSummary,
Instant now) {
this.successfulItems = successfulItems;
this.partialItems = partialItems;
this.failedItems = failedItems;
this.errorSummary = errorSummary;
int completedItems = successfulItems + partialItems + failedItems;
if (completedItems < totalItems) {
// Another worker may still own RUNNING items. Never expose a
// premature SUCCESS state merely because this request claimed no
// additional work. Preserve an existing start time, but enforce
// the invariant that every externally visible RUNNING job has one.
this.status = AnalysisStatus.RUNNING;
if (this.startedAt == null) {
this.startedAt = now;
}
this.completedAt = null;
return;
}
this.completedAt = now;
if (failedItems == totalItems && totalItems > 0) {
this.status = AnalysisStatus.FAILED;
} else if (failedItems > 0 || partialItems > 0) {
this.status = AnalysisStatus.PARTIAL;
} else {
this.status = AnalysisStatus.SUCCESS;
}
}
public void cancel(Instant now) {
this.status = AnalysisStatus.CANCELLED;
this.completedAt = now;
}
@PrePersist
@PreUpdate
private void synchronizeTenantAuthority() {
synchronizeTenantAuthority(true);
}
private void synchronizeTenantAuthority(boolean requirePersistentParent) {
if (project == null || project.getScopeKey() == null
|| project.getScopeKey().isBlank()) {
throw new IllegalArgumentException(
"Analysis job project must expose an exact tenant scope");
}
String projectScope = project.getScopeKey().strip();
if (scopeKey != null && !scopeKey.isBlank()
&& !projectScope.equals(scopeKey.strip())) {
throw new IllegalArgumentException(
"Analysis job tenant scope does not match its project");
}
scopeKey = projectScope;
Long persistentProjectId = project.getId();
if (persistentProjectId == null) {
if (requirePersistentParent) {
throw new IllegalArgumentException(
"Analysis job project must be persisted before the job");
}
return;
}
if (projectId != null && !Objects.equals(projectId, persistentProjectId)) {
throw new IllegalArgumentException(
"Analysis job project ID does not match its association");
}
projectId = persistentProjectId;
}
public String getId() { return id; }
public String getScopeKey() { return scopeKey; }
public Long getProjectId() { return projectId; }
public ArchitectureProject getProject() { return project; }
public AnalysisStatus getStatus() { return status; }
public String getIdempotencyKey() { return idempotencyKey; }
public String getProvider() { return provider; }
public int getMaxArchitectureNodes() { return maxArchitectureNodes; }
public String getRequestedBy() { return requestedBy; }
public String getWorkspaceId() { return workspaceId; }
public Instant getCreatedAt() { return createdAt; }
public Instant getStartedAt() { return startedAt; }
public Instant getCompletedAt() { return completedAt; }
public int getTotalItems() { return totalItems; }
public int getSuccessfulItems() { return successfulItems; }
public int getPartialItems() { return partialItems; }
public int getFailedItems() { return failedItems; }
public String getErrorSummary() { return errorSummary; }
public long getRowVersion() { return rowVersion; }
}