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