feat: add fail-open shadow planning boundary
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
public record InboundShadowObservation(
|
||||
boolean observed,
|
||||
String warehouseId,
|
||||
String requestId,
|
||||
String resultCode,
|
||||
String proposedSpaceId,
|
||||
String reason) {
|
||||
|
||||
public static InboundShadowObservation empty() {
|
||||
return new InboundShadowObservation(false, null, null, "NOT_OBSERVED", null, null);
|
||||
}
|
||||
|
||||
public static InboundShadowObservation unavailable(String warehouseId, String requestId,
|
||||
String resultCode, String reason) {
|
||||
return new InboundShadowObservation(true, warehouseId, requestId, resultCode, null, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
public record OutboundShadowObservation(
|
||||
boolean observed,
|
||||
String warehouseId,
|
||||
String requestId,
|
||||
String resultCode,
|
||||
String proposedStockId,
|
||||
String proposedSpaceId,
|
||||
String reason) {
|
||||
|
||||
public static OutboundShadowObservation empty() {
|
||||
return new OutboundShadowObservation(false, null, null, "NOT_OBSERVED", null, null, null);
|
||||
}
|
||||
|
||||
public static OutboundShadowObservation unavailable(String warehouseId, String requestId,
|
||||
String resultCode, String reason) {
|
||||
return new OutboundShadowObservation(true, warehouseId, requestId, resultCode, null, null, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
public class ShadowInboundReference {
|
||||
private String requestId;
|
||||
private String trayNo;
|
||||
private String projectNo;
|
||||
private String productId;
|
||||
private String lotNo;
|
||||
|
||||
public String getRequestId() {
|
||||
return requestId;
|
||||
}
|
||||
|
||||
public void setRequestId(String requestId) {
|
||||
this.requestId = requestId;
|
||||
}
|
||||
|
||||
public String getTrayNo() {
|
||||
return trayNo;
|
||||
}
|
||||
|
||||
public void setTrayNo(String trayNo) {
|
||||
this.trayNo = trayNo;
|
||||
}
|
||||
|
||||
public String getProjectNo() {
|
||||
return projectNo;
|
||||
}
|
||||
|
||||
public void setProjectNo(String projectNo) {
|
||||
this.projectNo = projectNo;
|
||||
}
|
||||
|
||||
public String getProductId() {
|
||||
return productId;
|
||||
}
|
||||
|
||||
public void setProductId(String productId) {
|
||||
this.productId = productId;
|
||||
}
|
||||
|
||||
public String getLotNo() {
|
||||
return lotNo;
|
||||
}
|
||||
|
||||
public void setLotNo(String lotNo) {
|
||||
this.lotNo = lotNo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@Component
|
||||
public class ShadowObservationGateway {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ShadowObservationGateway.class);
|
||||
|
||||
private final ShadowObservationProperties properties;
|
||||
private final ShadowPlanningEngine engine;
|
||||
|
||||
public ShadowObservationGateway(ShadowObservationProperties properties, ShadowPlanningEngine engine) {
|
||||
this.properties = Objects.requireNonNull(properties, "properties");
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
public InboundShadowObservation planInbound(String taskNo, String trayNo) {
|
||||
if (!properties.isEnabled() || engine == null) {
|
||||
return InboundShadowObservation.empty();
|
||||
}
|
||||
try {
|
||||
return engine.planInbound(taskNo, trayNo);
|
||||
} catch (RuntimeException exception) {
|
||||
LOG.warn("Shadow inbound planning failed open for task {}: {}", taskNo, exception.getMessage());
|
||||
return InboundShadowObservation.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public OutboundShadowObservation planProjectOutbound(String stockoutDetailId) {
|
||||
if (!properties.isEnabled() || engine == null) {
|
||||
return OutboundShadowObservation.empty();
|
||||
}
|
||||
try {
|
||||
return engine.planProjectOutbound(stockoutDetailId);
|
||||
} catch (RuntimeException exception) {
|
||||
LOG.warn("Shadow outbound planning failed open for detail {}: {}",
|
||||
stockoutDetailId, exception.getMessage());
|
||||
return OutboundShadowObservation.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public OutboundShadowObservation planExactStockOutbound(String stockoutDetailId, String stockId) {
|
||||
if (!properties.isEnabled() || engine == null) {
|
||||
return OutboundShadowObservation.empty();
|
||||
}
|
||||
try {
|
||||
return engine.planExactStockOutbound(stockoutDetailId, stockId);
|
||||
} catch (RuntimeException exception) {
|
||||
LOG.warn("Shadow exact-stock planning failed open for detail {} stock {}: {}",
|
||||
stockoutDetailId, stockId, exception.getMessage());
|
||||
return OutboundShadowObservation.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public void compareInbound(InboundShadowObservation observation, String legacyResultCode,
|
||||
String legacySpaceId) {
|
||||
if (!properties.isEnabled() || engine == null || observation == null || !observation.observed()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
engine.compareInbound(observation, legacyResultCode, legacySpaceId);
|
||||
} catch (RuntimeException exception) {
|
||||
LOG.warn("Shadow inbound comparison failed open for request {}: {}",
|
||||
observation.requestId(), exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) {
|
||||
if (!properties.isEnabled() || engine == null || observation == null || !observation.observed()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
engine.compareOutbound(observation, legacyResultCode);
|
||||
} catch (RuntimeException exception) {
|
||||
LOG.warn("Shadow outbound comparison failed open for request {}: {}",
|
||||
observation.requestId(), exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "fortucky.shadow")
|
||||
public class ShadowObservationProperties {
|
||||
private boolean enabled;
|
||||
private String warehouseId = "";
|
||||
private int timeoutSeconds = 3;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getWarehouseId() {
|
||||
return warehouseId;
|
||||
}
|
||||
|
||||
public void setWarehouseId(String warehouseId) {
|
||||
this.warehouseId = warehouseId == null ? "" : warehouseId.trim();
|
||||
}
|
||||
|
||||
public int getTimeoutSeconds() {
|
||||
return timeoutSeconds;
|
||||
}
|
||||
|
||||
public void setTimeoutSeconds(int timeoutSeconds) {
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
import com.ruoyi.algorithm.inbound.InboundPlan;
|
||||
import com.ruoyi.algorithm.inbound.InboundRequest;
|
||||
import com.ruoyi.algorithm.integration.SchedulingFacade;
|
||||
import com.ruoyi.algorithm.outbound.OutboundPlan;
|
||||
import com.ruoyi.algorithm.outbound.OutboundRequest;
|
||||
import com.ruoyi.algorithm.service.InboundPlanningResult;
|
||||
import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
public class ShadowPlanningEngine {
|
||||
private final SchedulingFacade schedulingFacade;
|
||||
private final ShadowInputMapper inputMapper;
|
||||
private final ShadowObservationProperties properties;
|
||||
private final TransactionTemplate transactions;
|
||||
|
||||
public ShadowPlanningEngine(SchedulingFacade schedulingFacade, ShadowInputMapper inputMapper,
|
||||
ShadowObservationProperties properties, PlatformTransactionManager transactionManager) {
|
||||
this.schedulingFacade = Objects.requireNonNull(schedulingFacade, "schedulingFacade");
|
||||
this.inputMapper = Objects.requireNonNull(inputMapper, "inputMapper");
|
||||
this.properties = Objects.requireNonNull(properties, "properties");
|
||||
this.transactions = new TransactionTemplate(Objects.requireNonNull(transactionManager, "transactionManager"));
|
||||
this.transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
this.transactions.setTimeout(properties.getTimeoutSeconds());
|
||||
}
|
||||
|
||||
public InboundShadowObservation planInbound(String taskNo, String trayNo) {
|
||||
return transactions.execute(status -> planInboundInsideTransaction(taskNo, trayNo));
|
||||
}
|
||||
|
||||
public OutboundShadowObservation planProjectOutbound(String stockoutDetailId) {
|
||||
return transactions.execute(status -> {
|
||||
ShadowStockReference reference = inputMapper.selectProjectOutboundReference(stockoutDetailId);
|
||||
String warehouseId = warehouseId();
|
||||
if (reference == null || isBlank(reference.getProjectNo())) {
|
||||
return OutboundShadowObservation.unavailable(warehouseId, stockoutDetailId,
|
||||
"SKIPPED_MISSING_PROJECT", "No project number was available for shadow planning");
|
||||
}
|
||||
OutboundRequest request = new OutboundRequest(stockoutDetailId, null,
|
||||
reference.getProjectNo(), reference.getProductId(), null, null, null, true);
|
||||
return toOutboundObservation(warehouseId, reference, schedulingFacade.planOutbound(warehouseId, request));
|
||||
});
|
||||
}
|
||||
|
||||
public OutboundShadowObservation planExactStockOutbound(String stockoutDetailId, String stockId) {
|
||||
return transactions.execute(status -> {
|
||||
ShadowStockReference reference = inputMapper.selectStockReference(stockId);
|
||||
String warehouseId = warehouseId();
|
||||
if (reference == null || isBlank(reference.getTrayNo())) {
|
||||
return OutboundShadowObservation.unavailable(warehouseId, stockoutDetailId,
|
||||
"SKIPPED_MISSING_STOCK", "Selected stock was unavailable to shadow planning");
|
||||
}
|
||||
OutboundRequest request = new OutboundRequest(stockoutDetailId, reference.getTrayNo(),
|
||||
reference.getProjectNo(), reference.getProductId(), null, null, null, true);
|
||||
return toOutboundObservation(warehouseId, reference, schedulingFacade.planOutbound(warehouseId, request));
|
||||
});
|
||||
}
|
||||
|
||||
public void compareInbound(InboundShadowObservation observation, String legacyResultCode,
|
||||
String legacySpaceId) {
|
||||
transactions.executeWithoutResult(status -> inputMapper.insertComparison(
|
||||
UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE",
|
||||
comparisonCode(observation.proposedSpaceId(), legacySpaceId),
|
||||
"request=" + observation.requestId() + "; shadow=" + observation.resultCode()
|
||||
+ "; proposedSpace=" + nullSafe(observation.proposedSpaceId())
|
||||
+ "; legacy=" + nullSafe(legacyResultCode)
|
||||
+ "; legacySpace=" + nullSafe(legacySpaceId)));
|
||||
}
|
||||
|
||||
public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) {
|
||||
transactions.executeWithoutResult(status -> inputMapper.insertComparison(
|
||||
UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE",
|
||||
"LEGACY_" + nullSafe(legacyResultCode),
|
||||
"request=" + observation.requestId() + "; shadow=" + observation.resultCode()
|
||||
+ "; proposedStock=" + nullSafe(observation.proposedStockId())
|
||||
+ "; proposedSpace=" + nullSafe(observation.proposedSpaceId())
|
||||
+ "; legacy=" + nullSafe(legacyResultCode)));
|
||||
}
|
||||
|
||||
private InboundShadowObservation planInboundInsideTransaction(String taskNo, String trayNo) {
|
||||
String warehouseId = warehouseId();
|
||||
ShadowInboundReference reference = inputMapper.selectInboundInput(taskNo, trayNo);
|
||||
if (reference == null || isBlank(reference.getRequestId()) || isBlank(reference.getTrayNo())) {
|
||||
return InboundShadowObservation.unavailable(warehouseId, taskNo,
|
||||
"SKIPPED_MISSING_INPUT", "Inbound task details were unavailable to shadow planning");
|
||||
}
|
||||
InboundRequest request = new InboundRequest(reference.getRequestId(), reference.getTrayNo(),
|
||||
reference.getProjectNo(), reference.getProductId(), reference.getLotNo(),
|
||||
null, null, null, Set.of(), null);
|
||||
InboundPlanningResult result = schedulingFacade.planInbound(warehouseId, request);
|
||||
InboundPlan plan = result.plan();
|
||||
if (plan.assignment().isPresent()) {
|
||||
var assignment = plan.assignment().orElseThrow();
|
||||
return new InboundShadowObservation(true, warehouseId, reference.getRequestId(),
|
||||
"READY", assignment.spaceId(), assignment.reason());
|
||||
}
|
||||
var rejection = plan.rejection().orElseThrow();
|
||||
return InboundShadowObservation.unavailable(warehouseId, reference.getRequestId(),
|
||||
"REJECTED_" + rejection.code().name(), rejection.reason());
|
||||
}
|
||||
|
||||
private OutboundShadowObservation toOutboundObservation(String warehouseId,
|
||||
ShadowStockReference reference, OutboundPlan plan) {
|
||||
String requestId = reference.getRequestId();
|
||||
if (isBlank(requestId)) {
|
||||
requestId = plan.assignment().map(assignment -> assignment.requestId()).orElse(null);
|
||||
}
|
||||
if (plan.assignment().isPresent()) {
|
||||
var assignment = plan.assignment().orElseThrow();
|
||||
return new OutboundShadowObservation(true, warehouseId, requestId, "READY",
|
||||
reference.getStockId(), assignment.sourceSpaceId(), assignment.reason());
|
||||
}
|
||||
if (plan.waiting().isPresent()) {
|
||||
var waiting = plan.waiting().orElseThrow();
|
||||
return OutboundShadowObservation.unavailable(warehouseId, requestId,
|
||||
"WAITING_" + waiting.code().name(), waiting.reason());
|
||||
}
|
||||
var error = plan.error().orElseThrow();
|
||||
return OutboundShadowObservation.unavailable(warehouseId, requestId,
|
||||
"ERROR_" + error.code().name(), error.message());
|
||||
}
|
||||
|
||||
private String warehouseId() {
|
||||
return isBlank(properties.getWarehouseId())
|
||||
? schedulingFacade.defaultWarehouseId()
|
||||
: properties.getWarehouseId();
|
||||
}
|
||||
|
||||
private String comparisonCode(String shadowSpaceId, String legacySpaceId) {
|
||||
if (isBlank(shadowSpaceId) || isBlank(legacySpaceId)) {
|
||||
return "NOT_COMPARABLE";
|
||||
}
|
||||
return shadowSpaceId.equals(legacySpaceId) ? "MATCH" : "DIFFERENT";
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private String nullSafe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
import com.ruoyi.algorithm.integration.AlgorithmProperties;
|
||||
import com.ruoyi.algorithm.integration.SchedulingMode;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@Component
|
||||
public class ShadowSafetyGuard implements InitializingBean {
|
||||
private final ShadowObservationProperties shadowProperties;
|
||||
private final AlgorithmProperties algorithmProperties;
|
||||
|
||||
public ShadowSafetyGuard(ShadowObservationProperties shadowProperties,
|
||||
AlgorithmProperties algorithmProperties) {
|
||||
this.shadowProperties = Objects.requireNonNull(shadowProperties, "shadowProperties");
|
||||
this.algorithmProperties = Objects.requireNonNull(algorithmProperties, "algorithmProperties");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
validate();
|
||||
}
|
||||
|
||||
public void validate() {
|
||||
if (!shadowProperties.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (algorithmProperties.getMode() != SchedulingMode.SHADOW) {
|
||||
throw new IllegalStateException("Shadow observation requires FORTUCKY_ALGORITHM_MODE=SHADOW");
|
||||
}
|
||||
if (!algorithmProperties.isDatabaseWritesEnabled()) {
|
||||
throw new IllegalStateException("Shadow observation requires algorithm database writes for alg.* read models");
|
||||
}
|
||||
if (shadowProperties.getTimeoutSeconds() <= 0) {
|
||||
throw new IllegalStateException("Shadow observation timeout must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
public class ShadowStockReference {
|
||||
private String requestId;
|
||||
private String stockId;
|
||||
private String trayNo;
|
||||
private String spaceId;
|
||||
private String warehouseId;
|
||||
private String projectNo;
|
||||
private String productId;
|
||||
|
||||
public String getRequestId() {
|
||||
return requestId;
|
||||
}
|
||||
|
||||
public void setRequestId(String requestId) {
|
||||
this.requestId = requestId;
|
||||
}
|
||||
|
||||
public String getStockId() {
|
||||
return stockId;
|
||||
}
|
||||
|
||||
public void setStockId(String stockId) {
|
||||
this.stockId = stockId;
|
||||
}
|
||||
|
||||
public String getTrayNo() {
|
||||
return trayNo;
|
||||
}
|
||||
|
||||
public void setTrayNo(String trayNo) {
|
||||
this.trayNo = trayNo;
|
||||
}
|
||||
|
||||
public String getSpaceId() {
|
||||
return spaceId;
|
||||
}
|
||||
|
||||
public void setSpaceId(String spaceId) {
|
||||
this.spaceId = spaceId;
|
||||
}
|
||||
|
||||
public String getWarehouseId() {
|
||||
return warehouseId;
|
||||
}
|
||||
|
||||
public void setWarehouseId(String warehouseId) {
|
||||
this.warehouseId = warehouseId;
|
||||
}
|
||||
|
||||
public String getProjectNo() {
|
||||
return projectNo;
|
||||
}
|
||||
|
||||
public void setProjectNo(String projectNo) {
|
||||
this.projectNo = projectNo;
|
||||
}
|
||||
|
||||
public String getProductId() {
|
||||
return productId;
|
||||
}
|
||||
|
||||
public void setProductId(String productId) {
|
||||
this.productId = productId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ruoyi.algorithm.shadow.mapper;
|
||||
|
||||
import com.ruoyi.algorithm.shadow.ShadowInboundReference;
|
||||
import com.ruoyi.algorithm.shadow.ShadowStockReference;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface ShadowInputMapper {
|
||||
ShadowInboundReference selectInboundInput(@Param("taskNo") String taskNo,
|
||||
@Param("trayNo") String trayNo);
|
||||
|
||||
ShadowStockReference selectProjectOutboundReference(
|
||||
@Param("stockoutDetailId") String stockoutDetailId);
|
||||
|
||||
ShadowStockReference selectStockReference(@Param("stockId") String stockId);
|
||||
|
||||
int insertComparison(@Param("decisionId") String decisionId,
|
||||
@Param("warehouseId") String warehouseId,
|
||||
@Param("decisionType") String decisionType,
|
||||
@Param("resultCode") String resultCode,
|
||||
@Param("reason") String reason);
|
||||
}
|
||||
Reference in New Issue
Block a user