feat: add fail-open shadow planning boundary

This commit is contained in:
zgw
2026-08-11 18:39:15 +08:00
parent 86fc240b84
commit 01333cbf03
13 changed files with 734 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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");
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper">
<select id="selectInboundInput" resultType="com.ruoyi.algorithm.shadow.ShadowInboundReference">
select top (1)
job.job_id as requestId,
job.tray_code as trayNo,
coalesce(nullif(ltrim(rtrim(detail.spare4)), ''),
nullif(ltrim(rtrim(stock.spare4)), '')) as projectNo,
jobDetail.product_id as productId,
jobDetail.lot_no as lotNo
from mydo_job job
left join mydo_job_detail jobDetail on jobDetail.job_id = job.job_id
left join in_stockin_detail detail on detail.stockin_detail_id = jobDetail.stockin_detail_id
left join bas_stock stock on stock.stock_id = jobDetail.stock_id
where job.task_no = #{taskNo}
and job.tray_code = #{trayNo}
order by jobDetail.job_detail_id
</select>
<select id="selectProjectOutboundReference" resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
select detail.stockout_detail_id as requestId,
detail.warehouse_id as warehouseId,
nullif(ltrim(rtrim(detail.spare4)), '') as projectNo,
detail.product_id as productId
from out_stockout_detail detail
where detail.stockout_detail_id = #{stockoutDetailId}
</select>
<select id="selectStockReference" resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
select top (1)
stock.stock_id as stockId,
stock.tray_code as trayNo,
stock.cargo_space_id as spaceId,
stock.warehouse_id as warehouseId,
nullif(ltrim(rtrim(stock.spare4)), '') as projectNo,
stock.product_id as productId
from bas_stock stock
where stock.stock_id = #{stockId}
and stock.stock_status = '1'
and stock.product_status = '1'
and stock.quantity &gt; stock.out_quantity
</select>
<insert id="insertComparison">
insert into alg.decision_log (
decision_id, warehouse_id, decision_type, input_version, result_code, reason)
values (
try_convert(uniqueidentifier, #{decisionId}), #{warehouseId}, #{decisionType},
null, #{resultCode}, #{reason})
</insert>
</mapper>

View File

@@ -0,0 +1,49 @@
package com.ruoyi.algorithm.shadow;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ShadowObservationGatewayTest {
@Test
void disabledShadowNeverInvokesThePlanningEngine() {
ShadowObservationProperties properties = new ShadowObservationProperties();
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, engine);
InboundShadowObservation observation = gateway.planInbound("TASK-1", "TRAY-1");
assertThat(observation.observed()).isFalse();
verify(engine, never()).planInbound("TASK-1", "TRAY-1");
}
@Test
void planningFailureIsConvertedToAnEmptyObservation() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setEnabled(true);
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
when(engine.planInbound("TASK-2", "TRAY-2"))
.thenThrow(new IllegalStateException("algorithm objects are unavailable"));
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, engine);
InboundShadowObservation observation = gateway.planInbound("TASK-2", "TRAY-2");
assertThat(observation.observed()).isFalse();
}
@Test
void missingEngineFailsOpenForOutboundObservation() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setEnabled(true);
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, null);
OutboundShadowObservation observation = gateway.planExactStockOutbound("DETAIL-1", "STOCK-1");
assertThat(observation.observed()).isFalse();
}
}

View File

@@ -0,0 +1,74 @@
package com.ruoyi.algorithm.shadow;
import com.ruoyi.algorithm.inbound.InboundAssignment;
import com.ruoyi.algorithm.inbound.InboundBorrowType;
import com.ruoyi.algorithm.inbound.InboundLadderRung;
import com.ruoyi.algorithm.inbound.InboundPlan;
import com.ruoyi.algorithm.integration.SchedulingFacade;
import com.ruoyi.algorithm.repository.VersionToken;
import com.ruoyi.algorithm.service.InboundPlanningResult;
import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.SimpleTransactionStatus;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ShadowPlanningEngineTest {
@Test
void inboundPlanningUsesRequiresNewAndConfiguredTimeout() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setWarehouseId("W01");
properties.setTimeoutSeconds(7);
CapturingTransactionManager transactions = new CapturingTransactionManager();
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
SchedulingFacade facade = mock(SchedulingFacade.class);
ShadowInboundReference input = new ShadowInboundReference();
input.setRequestId("JOB-1");
input.setTrayNo("TRAY-1");
input.setProjectNo("PROJECT-1");
input.setProductId("PRODUCT-1");
input.setLotNo("LOT-1");
when(mapper.selectInboundInput("TASK-1", "TRAY-1")).thenReturn(input);
InboundAssignment assignment = new InboundAssignment(
"JOB-1", "SPACE-9", "LANE-2", 4, 2,
InboundLadderRung.CONTINUE, InboundBorrowType.NONE, null,
1.0, 2.0, 3.0, 50.0, false, false, "shadow proposal");
when(facade.planInbound(eq("W01"), any()))
.thenReturn(new InboundPlanningResult(InboundPlan.assigned(assignment), VersionToken.numeric(8)));
ShadowPlanningEngine engine = new ShadowPlanningEngine(facade, mapper, properties, transactions);
InboundShadowObservation observation = engine.planInbound("TASK-1", "TRAY-1");
assertThat(transactions.definition.getPropagationBehavior())
.isEqualTo(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
assertThat(transactions.definition.getTimeout()).isEqualTo(7);
assertThat(observation.observed()).isTrue();
assertThat(observation.proposedSpaceId()).isEqualTo("SPACE-9");
}
private static final class CapturingTransactionManager implements PlatformTransactionManager {
private TransactionDefinition definition;
@Override
public TransactionStatus getTransaction(TransactionDefinition definition) {
this.definition = definition;
return new SimpleTransactionStatus();
}
@Override
public void commit(TransactionStatus status) {
}
@Override
public void rollback(TransactionStatus status) {
}
}
}

View File

@@ -0,0 +1,67 @@
package com.ruoyi.algorithm.shadow;
import com.ruoyi.algorithm.integration.AlgorithmProperties;
import com.ruoyi.algorithm.integration.SchedulingMode;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ShadowSafetyGuardTest {
@Test
void enabledShadowAcceptsOnlyShadowModeWithReadModelWrites() {
ShadowObservationProperties shadow = enabledShadow();
AlgorithmProperties algorithm = new AlgorithmProperties();
algorithm.setMode(SchedulingMode.SHADOW);
algorithm.setDatabaseWritesEnabled(true);
assertThatCode(() -> new ShadowSafetyGuard(shadow, algorithm).validate())
.doesNotThrowAnyException();
}
@Test
void enabledShadowRejectsActiveAndLegacyModes() {
ShadowObservationProperties shadow = enabledShadow();
AlgorithmProperties algorithm = new AlgorithmProperties();
algorithm.setMode(SchedulingMode.ACTIVE);
assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("SHADOW");
algorithm.setMode(SchedulingMode.LEGACY);
assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("SHADOW");
}
@Test
void enabledShadowRejectsDisabledReadModelWrites() {
ShadowObservationProperties shadow = enabledShadow();
AlgorithmProperties algorithm = new AlgorithmProperties();
algorithm.setMode(SchedulingMode.SHADOW);
algorithm.setDatabaseWritesEnabled(false);
assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("database writes");
}
@Test
void disabledShadowDoesNotRestrictTheStableLegacyApplication() {
ShadowObservationProperties shadow = new ShadowObservationProperties();
AlgorithmProperties algorithm = new AlgorithmProperties();
algorithm.setMode(SchedulingMode.ACTIVE);
algorithm.setDatabaseWritesEnabled(false);
assertThatCode(() -> new ShadowSafetyGuard(shadow, algorithm).validate())
.doesNotThrowAnyException();
}
private ShadowObservationProperties enabledShadow() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setEnabled(true);
return properties;
}
}