fix: keep shadow work off legacy threads

This commit is contained in:
zgw
2026-08-12 09:19:59 +08:00
parent 34c4d54669
commit bb3b93a79f
8 changed files with 274 additions and 169 deletions

View File

@@ -0,0 +1,49 @@
package com.ruoyi.algorithm.shadow;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class InboundShadowCompletion {
private static final InboundShadowCompletion REJECTED =
new InboundShadowCompletion(false, 0L);
private final boolean accepted;
private final long deadlineNanos;
private final CompletableFuture<LegacyResult> legacyResult = new CompletableFuture<>();
static InboundShadowCompletion accepted(int timeoutSeconds) {
return new InboundShadowCompletion(
true, System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds));
}
public static InboundShadowCompletion rejected() {
return REJECTED;
}
private InboundShadowCompletion(boolean accepted, long deadlineNanos) {
this.accepted = accepted;
this.deadlineNanos = deadlineNanos;
}
public boolean accepted() {
return accepted;
}
public void complete(String resultCode, String spaceId) {
if (accepted) {
legacyResult.complete(new LegacyResult(resultCode, spaceId));
}
}
LegacyResult awaitLegacyResult() throws Exception {
long remainingNanos = deadlineNanos - System.nanoTime();
if (remainingNanos <= 0) {
throw new TimeoutException("Shadow inbound observation deadline expired");
}
return legacyResult.get(remainingNanos, TimeUnit.NANOSECONDS);
}
record LegacyResult(String resultCode, String spaceId) {
}
}

View File

@@ -0,0 +1,46 @@
package com.ruoyi.algorithm.shadow;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class OutboundShadowCompletion {
private static final OutboundShadowCompletion REJECTED =
new OutboundShadowCompletion(false, 0L);
private final boolean accepted;
private final long deadlineNanos;
private final CompletableFuture<String> legacyResult = new CompletableFuture<>();
static OutboundShadowCompletion accepted(int timeoutSeconds) {
return new OutboundShadowCompletion(
true, System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds));
}
public static OutboundShadowCompletion rejected() {
return REJECTED;
}
private OutboundShadowCompletion(boolean accepted, long deadlineNanos) {
this.accepted = accepted;
this.deadlineNanos = deadlineNanos;
}
public boolean accepted() {
return accepted;
}
public void complete(String resultCode) {
if (accepted) {
legacyResult.complete(resultCode);
}
}
String awaitLegacyResult() throws Exception {
long remainingNanos = deadlineNanos - System.nanoTime();
if (remainingNanos <= 0) {
throw new TimeoutException("Shadow outbound observation deadline expired");
}
return legacyResult.get(remainingNanos, TimeUnit.NANOSECONDS);
}
}

View File

@@ -1,11 +1,15 @@
package com.ruoyi.algorithm.shadow; package com.ruoyi.algorithm.shadow;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@Component @Component
public class ShadowObservationGateway { public class ShadowObservationGateway {
@@ -13,88 +17,95 @@ public class ShadowObservationGateway {
private final ShadowObservationProperties properties; private final ShadowObservationProperties properties;
private final ShadowPlanningEngine engine; private final ShadowPlanningEngine engine;
private final AtomicBoolean planning = new AtomicBoolean(); private final ThreadPoolExecutor executor;
public ShadowObservationGateway(ShadowObservationProperties properties, ShadowPlanningEngine engine) { public ShadowObservationGateway(ShadowObservationProperties properties, ShadowPlanningEngine engine) {
this.properties = Objects.requireNonNull(properties, "properties"); this.properties = Objects.requireNonNull(properties, "properties");
this.engine = engine; this.engine = engine;
this.executor = new ThreadPoolExecutor(
1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>(), runnable -> {
Thread thread = new Thread(runnable, "ft546-shadow-observer");
thread.setDaemon(true);
return thread;
}, new ThreadPoolExecutor.AbortPolicy());
} }
public InboundShadowObservation planInbound(String taskNo, String trayNo) { public InboundShadowCompletion observeInbound(String taskNo, String trayNo) {
if (!properties.isEnabled() || engine == null) { if (!available()) {
return InboundShadowObservation.empty(); return InboundShadowCompletion.rejected();
}
if (!planning.compareAndSet(false, true)) {
return InboundShadowObservation.empty();
} }
InboundShadowCompletion completion =
InboundShadowCompletion.accepted(properties.getTimeoutSeconds());
try { try {
return engine.planInbound(taskNo, trayNo); executor.execute(() -> observeInbound(taskNo, trayNo, completion));
} catch (RuntimeException exception) { return completion;
LOG.warn("Shadow inbound planning failed open for task {}: {}", taskNo, exception.getMessage()); } catch (RejectedExecutionException exception) {
return InboundShadowObservation.empty(); LOG.debug("Shadow inbound observation skipped because worker is busy for task {}", taskNo);
} finally { return InboundShadowCompletion.rejected();
planning.set(false);
} }
} }
public OutboundShadowObservation planProjectOutbound(String stockoutDetailId) { public OutboundShadowCompletion observeProjectOutbound(String stockoutDetailId) {
if (!properties.isEnabled() || engine == null) { return observeOutbound(stockoutDetailId,
return OutboundShadowObservation.empty(); () -> engine.planProjectOutbound(stockoutDetailId));
} }
if (!planning.compareAndSet(false, true)) {
return OutboundShadowObservation.empty(); public OutboundShadowCompletion observeExactStockOutbound(String stockoutDetailId, String stockId) {
} return observeOutbound(stockoutDetailId,
() -> engine.planExactStockOutbound(stockoutDetailId, stockId));
}
private void observeInbound(String taskNo, String trayNo, InboundShadowCompletion completion) {
try { try {
return engine.planProjectOutbound(stockoutDetailId); InboundShadowObservation observation = engine.planInbound(taskNo, trayNo);
} catch (RuntimeException exception) { InboundShadowCompletion.LegacyResult legacy = completion.awaitLegacyResult();
LOG.warn("Shadow outbound planning failed open for detail {}: {}", engine.compareInbound(observation, legacy.resultCode(), legacy.spaceId());
stockoutDetailId, exception.getMessage()); } catch (InterruptedException exception) {
return OutboundShadowObservation.empty(); Thread.currentThread().interrupt();
} finally { LOG.warn("Shadow inbound observation interrupted for task {}", taskNo);
planning.set(false); } catch (Exception exception) {
LOG.warn("Shadow inbound observation failed open for task {}: {}", taskNo, exception.getMessage());
} }
} }
public OutboundShadowObservation planExactStockOutbound(String stockoutDetailId, String stockId) { private OutboundShadowCompletion observeOutbound(String stockoutDetailId,
if (!properties.isEnabled() || engine == null) { OutboundPlanner planner) {
return OutboundShadowObservation.empty(); if (!available()) {
} return OutboundShadowCompletion.rejected();
if (!planning.compareAndSet(false, true)) {
return OutboundShadowObservation.empty();
} }
OutboundShadowCompletion completion =
OutboundShadowCompletion.accepted(properties.getTimeoutSeconds());
try { try {
return engine.planExactStockOutbound(stockoutDetailId, stockId); executor.execute(() -> {
} catch (RuntimeException exception) { try {
LOG.warn("Shadow exact-stock planning failed open for detail {} stock {}: {}", OutboundShadowObservation observation = planner.plan();
stockoutDetailId, stockId, exception.getMessage()); engine.compareOutbound(observation, completion.awaitLegacyResult());
return OutboundShadowObservation.empty(); } catch (InterruptedException exception) {
} finally { Thread.currentThread().interrupt();
planning.set(false); LOG.warn("Shadow outbound observation interrupted for detail {}", stockoutDetailId);
} catch (Exception exception) {
LOG.warn("Shadow outbound observation failed open for detail {}: {}",
stockoutDetailId, exception.getMessage());
}
});
return completion;
} catch (RejectedExecutionException exception) {
LOG.debug("Shadow outbound observation skipped because worker is busy for detail {}", stockoutDetailId);
return OutboundShadowCompletion.rejected();
} }
} }
public void compareInbound(InboundShadowObservation observation, String legacyResultCode, private boolean available() {
String legacySpaceId) { return properties.isEnabled() && engine != null && !executor.isShutdown();
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) { @PreDestroy
if (!properties.isEnabled() || engine == null || observation == null || !observation.observed()) { public void shutdown() {
return; executor.shutdownNow();
} }
try {
engine.compareOutbound(observation, legacyResultCode); @FunctionalInterface
} catch (RuntimeException exception) { private interface OutboundPlanner {
LOG.warn("Shadow outbound comparison failed open for request {}: {}", OutboundShadowObservation plan();
observation.requestId(), exception.getMessage());
}
} }
} }

View File

@@ -6,7 +6,7 @@ import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.ruoyi.algorithm.shadow.InboundShadowObservation; import com.ruoyi.algorithm.shadow.InboundShadowCompletion;
import com.ruoyi.algorithm.shadow.ShadowObservationGateway; import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
import com.ruoyi.api.domain.*; import com.ruoyi.api.domain.*;
import com.ruoyi.api.service.IToErpService; import com.ruoyi.api.service.IToErpService;
@@ -416,21 +416,21 @@ public class ToWmsServiceImpl implements IToWmsService {
map.put("v_tray_code", inJob.getTrayCode()); map.put("v_tray_code", inJob.getTrayCode());
map.put("v_code", deviceCode); map.put("v_code", deviceCode);
InboundShadowObservation shadowObservation = InboundShadowObservation.empty(); InboundShadowCompletion shadowCompletion = InboundShadowCompletion.rejected();
try { try {
shadowObservation = shadowObservationGateway.planInbound(taskNo, inJob.getTrayCode()); shadowCompletion = shadowObservationGateway.observeInbound(taskNo, inJob.getTrayCode());
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
log.warn("Inbound shadow observation failed open for task {}: {}", log.warn("Inbound shadow submission failed open for task {}: {}",
taskNo, exception.getMessage()); taskNo, exception.getMessage());
} }
mydoJobMapper.allotInJobSpace(map); mydoJobMapper.allotInJobSpace(map);
String legacyResultCode = map.get("v_result").toString(); String legacyResultCode = map.get("v_result").toString();
if (!"10".equals(legacyResultCode)) { if (!"10".equals(legacyResultCode)) {
compareInboundShadow(shadowObservation, legacyResultCode, null); shadowCompletion.complete(legacyResultCode, null);
return AjaxResult.error(map.get("v_resultmsg").toString()); return AjaxResult.error(map.get("v_resultmsg").toString());
}else{ }else{
MydoJob inJob2 = mydoJobMapper.selectInJobByTaskNo(taskNo); MydoJob inJob2 = mydoJobMapper.selectInJobByTaskNo(taskNo);
compareInboundShadow(shadowObservation, legacyResultCode, shadowCompletion.complete(legacyResultCode,
inJob2 == null ? null : inJob2.getCargoSpaceId()); inJob2 == null ? null : inJob2.getCargoSpaceId());
return AjaxResult.success(inJob2); return AjaxResult.success(inJob2);
} }
@@ -440,15 +440,4 @@ public class ToWmsServiceImpl implements IToWmsService {
} }
} }
private void compareInboundShadow(InboundShadowObservation observation,
String legacyResultCode, String legacySpaceId) {
try {
shadowObservationGateway.compareInbound(observation, legacyResultCode, legacySpaceId);
} catch (RuntimeException exception) {
log.warn("Inbound shadow comparison failed open for request {}: {}",
observation == null ? null : observation.requestId(), exception.getMessage());
}
}
} }

View File

@@ -1,7 +1,7 @@
package com.ruoyi.out.outStockout.service.impl; package com.ruoyi.out.outStockout.service.impl;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.ruoyi.algorithm.shadow.OutboundShadowObservation; import com.ruoyi.algorithm.shadow.OutboundShadowCompletion;
import com.ruoyi.algorithm.shadow.ShadowObservationGateway; import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
import com.ruoyi.api.service.IToWcsService; import com.ruoyi.api.service.IToWcsService;
import com.ruoyi.bas.basJobId.service.IBasJobIdService; import com.ruoyi.bas.basJobId.service.IBasJobIdService;
@@ -188,10 +188,10 @@ public class OutStockoutServiceImpl implements IOutStockoutService
map.put("v_stockout_detail_id", detail.getStockoutDetailId()); map.put("v_stockout_detail_id", detail.getStockoutDetailId());
map.put("v_result", null); map.put("v_result", null);
map.put("v_resultmsg", null); map.put("v_resultmsg", null);
OutboundShadowObservation shadowObservation = OutboundShadowCompletion shadowCompletion =
planProjectOutboundShadow(detail.getStockoutDetailId()); observeProjectOutboundShadow(detail.getStockoutDetailId());
outStockoutMapper.autoAllot(map); outStockoutMapper.autoAllot(map);
compareOutboundShadow(shadowObservation, map.get("v_result")); shadowCompletion.complete(map.get("v_result"));
if (!"10".equals(map.get("v_result"))){ if (!"10".equals(map.get("v_result"))){
stringBuilder.append(",").append(detail.getStockoutDetailId()+map.get("v_resultmsg")); stringBuilder.append(",").append(detail.getStockoutDetailId()+map.get("v_resultmsg"));
} }
@@ -218,10 +218,10 @@ public class OutStockoutServiceImpl implements IOutStockoutService
for (String stock : stockList) { for (String stock : stockList) {
map.put("v_stockout_detail_id", stockoutDetailId); map.put("v_stockout_detail_id", stockoutDetailId);
map.put("v_stock_id", stock); map.put("v_stock_id", stock);
OutboundShadowObservation shadowObservation = OutboundShadowCompletion shadowCompletion =
planExactStockOutboundShadow(stockoutDetailId, stock); observeExactStockOutboundShadow(stockoutDetailId, stock);
outStockoutMapper.manualAllot(map); outStockoutMapper.manualAllot(map);
compareOutboundShadow(shadowObservation, map.get("v_result")); shadowCompletion.complete(map.get("v_result"));
//toWcsService.pushOutMydoJob(); //toWcsService.pushOutMydoJob();
if (!"10".equals(map.get("v_result"))){ if (!"10".equals(map.get("v_result"))){
stringBuilder.append(",").append(stockoutDetailId+map.get("v_resultString")); stringBuilder.append(",").append(stockoutDetailId+map.get("v_resultString"));
@@ -233,32 +233,23 @@ public class OutStockoutServiceImpl implements IOutStockoutService
return AjaxResult.success(); return AjaxResult.success();
} }
private OutboundShadowObservation planProjectOutboundShadow(String stockoutDetailId) { private OutboundShadowCompletion observeProjectOutboundShadow(String stockoutDetailId) {
try { try {
return shadowObservationGateway.planProjectOutbound(stockoutDetailId); return shadowObservationGateway.observeProjectOutbound(stockoutDetailId);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
log.warn("Project outbound shadow observation failed open for detail {}: {}", log.warn("Project outbound shadow submission failed open for detail {}: {}",
stockoutDetailId, exception.getMessage()); stockoutDetailId, exception.getMessage());
return OutboundShadowObservation.empty(); return OutboundShadowCompletion.rejected();
} }
} }
private OutboundShadowObservation planExactStockOutboundShadow(String stockoutDetailId, String stockId) { private OutboundShadowCompletion observeExactStockOutboundShadow(String stockoutDetailId, String stockId) {
try { try {
return shadowObservationGateway.planExactStockOutbound(stockoutDetailId, stockId); return shadowObservationGateway.observeExactStockOutbound(stockoutDetailId, stockId);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
log.warn("Exact-stock shadow observation failed open for detail {} stock {}: {}", log.warn("Exact-stock shadow submission failed open for detail {} stock {}: {}",
stockoutDetailId, stockId, exception.getMessage()); stockoutDetailId, stockId, exception.getMessage());
return OutboundShadowObservation.empty(); return OutboundShadowCompletion.rejected();
}
}
private void compareOutboundShadow(OutboundShadowObservation observation, String legacyResultCode) {
try {
shadowObservationGateway.compareOutbound(observation, legacyResultCode);
} catch (RuntimeException exception) {
log.warn("Outbound shadow comparison failed open for request {}: {}",
observation == null ? null : observation.requestId(), exception.getMessage());
} }
} }

View File

@@ -1,5 +1,6 @@
package com.ruoyi.algorithm.shadow; package com.ruoyi.algorithm.shadow;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@@ -11,90 +12,105 @@ import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class ShadowObservationGatewayTest { class ShadowObservationGatewayTest {
private ShadowObservationGateway gateway;
@AfterEach
void shutDownGateway() {
if (gateway != null) {
gateway.shutdown();
}
}
@Test @Test
void disabledShadowNeverInvokesThePlanningEngine() { void disabledShadowNeverInvokesThePlanningEngine() {
ShadowObservationProperties properties = new ShadowObservationProperties(); ShadowObservationProperties properties = new ShadowObservationProperties();
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class); ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, engine); gateway = new ShadowObservationGateway(properties, engine);
InboundShadowObservation observation = gateway.planInbound("TASK-1", "TRAY-1"); InboundShadowCompletion completion = gateway.observeInbound("TASK-1", "TRAY-1");
completion.complete("10", "SPACE-1");
assertThat(observation.observed()).isFalse();
verify(engine, never()).planInbound("TASK-1", "TRAY-1"); verify(engine, never()).planInbound("TASK-1", "TRAY-1");
} }
@Test @Test
void planningFailureIsConvertedToAnEmptyObservation() { void planningFailureIsContainedOnTheBackgroundWorker() {
ShadowObservationProperties properties = new ShadowObservationProperties(); ShadowObservationProperties properties = enabledProperties();
properties.setEnabled(true);
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class); ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
when(engine.planInbound("TASK-2", "TRAY-2")) when(engine.planInbound("TASK-2", "TRAY-2"))
.thenThrow(new IllegalStateException("algorithm objects are unavailable")); .thenThrow(new IllegalStateException("algorithm objects are unavailable"));
when(engine.planProjectOutbound("DETAIL-2")) gateway = new ShadowObservationGateway(properties, engine);
.thenReturn(OutboundShadowObservation.unavailable("W01", "DETAIL-2", "TEST", "completed"));
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, engine);
InboundShadowObservation observation = gateway.planInbound("TASK-2", "TRAY-2"); InboundShadowCompletion completion = gateway.observeInbound("TASK-2", "TRAY-2");
OutboundShadowObservation followingObservation = gateway.planProjectOutbound("DETAIL-2"); completion.complete("10", "SPACE-2");
assertThat(observation.observed()).isFalse(); verify(engine, timeout(1000)).planInbound("TASK-2", "TRAY-2");
assertThat(followingObservation.observed()).isTrue(); verify(engine, never()).compareInbound(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any());
} }
@Test @Test
void missingEngineFailsOpenForOutboundObservation() { void missingEngineFailsOpenForOutboundObservation() {
ShadowObservationProperties properties = new ShadowObservationProperties(); gateway = new ShadowObservationGateway(enabledProperties(), null);
properties.setEnabled(true);
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, null);
OutboundShadowObservation observation = gateway.planExactStockOutbound("DETAIL-1", "STOCK-1"); OutboundShadowCompletion completion =
gateway.observeExactStockOutbound("DETAIL-1", "STOCK-1");
completion.complete("10");
assertThat(observation.observed()).isFalse(); assertThat(completion.accepted()).isFalse();
} }
@Test @Test
void concurrentPlanningIsSkippedInsteadOfAddingDatabaseLoad() throws Exception { void planningSubmissionNeverWaitsAndConcurrentObservationIsDropped() throws Exception {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setEnabled(true);
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class); ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
CountDownLatch planningStarted = new CountDownLatch(1); CountDownLatch planningStarted = new CountDownLatch(1);
CountDownLatch releasePlanning = new CountDownLatch(1); CountDownLatch releasePlanning = new CountDownLatch(1);
InboundShadowObservation observation = new InboundShadowObservation(
true, "W01", "TASK-3", "READY", "SPACE-SHADOW", "proposal");
when(engine.planInbound("TASK-3", "TRAY-3")).thenAnswer(invocation -> { when(engine.planInbound("TASK-3", "TRAY-3")).thenAnswer(invocation -> {
planningStarted.countDown(); planningStarted.countDown();
if (!releasePlanning.await(5, TimeUnit.SECONDS)) { if (!releasePlanning.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("test planning was not released"); throw new IllegalStateException("test planning was not released");
} }
return InboundShadowObservation.unavailable("W01", "TASK-3", "TEST", "completed"); return observation;
}); });
when(engine.planProjectOutbound("DETAIL-3")) gateway = new ShadowObservationGateway(enabledProperties(), engine);
.thenReturn(OutboundShadowObservation.unavailable("W01", "DETAIL-3", "TEST", "completed")); ExecutorService legacyCaller = Executors.newSingleThreadExecutor();
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, engine);
ExecutorService executor = Executors.newSingleThreadExecutor();
try { try {
Future<InboundShadowObservation> first = Future<InboundShadowCompletion> submitted =
executor.submit(() -> gateway.planInbound("TASK-3", "TRAY-3")); legacyCaller.submit(() -> gateway.observeInbound("TASK-3", "TRAY-3"));
assertThat(planningStarted.await(5, TimeUnit.SECONDS)).isTrue(); InboundShadowCompletion first = submitted.get(1, TimeUnit.SECONDS);
assertThat(first.accepted()).isTrue();
assertThat(planningStarted.await(1, TimeUnit.SECONDS)).isTrue();
OutboundShadowObservation concurrent = gateway.planProjectOutbound("DETAIL-3"); OutboundShadowCompletion concurrent = gateway.observeProjectOutbound("DETAIL-3");
assertThat(concurrent.observed()).isFalse(); assertThat(concurrent.accepted()).isFalse();
concurrent.complete("10");
verify(engine, never()).planProjectOutbound("DETAIL-3"); verify(engine, never()).planProjectOutbound("DETAIL-3");
first.complete("10", "SPACE-LEGACY");
releasePlanning.countDown(); releasePlanning.countDown();
assertThat(first.get(5, TimeUnit.SECONDS).observed()).isTrue();
OutboundShadowObservation afterRelease = gateway.planProjectOutbound("DETAIL-3"); verify(engine, timeout(2000)).compareInbound(observation, "10", "SPACE-LEGACY");
assertThat(afterRelease.observed()).isTrue();
verify(engine).planProjectOutbound("DETAIL-3");
} finally { } finally {
releasePlanning.countDown(); releasePlanning.countDown();
executor.shutdownNow(); legacyCaller.shutdownNow();
} }
} }
private ShadowObservationProperties enabledProperties() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setEnabled(true);
properties.setTimeoutSeconds(3);
return properties;
}
} }

View File

@@ -1,6 +1,6 @@
package com.ruoyi.api.service.impl; package com.ruoyi.api.service.impl;
import com.ruoyi.algorithm.shadow.InboundShadowObservation; import com.ruoyi.algorithm.shadow.InboundShadowCompletion;
import com.ruoyi.algorithm.shadow.ShadowObservationGateway; import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
import com.ruoyi.api.domain.RequestEnter; import com.ruoyi.api.domain.RequestEnter;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
@@ -30,6 +30,8 @@ class ToWmsServiceImplShadowTest {
private MydoJobMapper mydoJobMapper; private MydoJobMapper mydoJobMapper;
@Mock @Mock
private ShadowObservationGateway shadowObservationGateway; private ShadowObservationGateway shadowObservationGateway;
@Mock
private InboundShadowCompletion shadowCompletion;
@InjectMocks @InjectMocks
private ToWmsServiceImpl service; private ToWmsServiceImpl service;
@@ -55,33 +57,31 @@ class ToWmsServiceImplShadowTest {
} }
@Test @Test
void observesBeforeLegacyAndReturnsTheReloadedLegacyJob() { void submitsObservationBeforeLegacyAndReturnsTheReloadedLegacyJob() {
InboundShadowObservation observation = new InboundShadowObservation( when(shadowObservationGateway.observeInbound("TASK-1", "TRAY-1"))
true, "W01", "JOB-1", "READY", "SPACE-SHADOW", "proposal"); .thenReturn(shadowCompletion);
when(shadowObservationGateway.planInbound("TASK-1", "TRAY-1")).thenReturn(observation);
AjaxResult result = service.requestEnterByTaskNo(request); AjaxResult result = service.requestEnterByTaskNo(request);
assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(legacyResult); assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(legacyResult);
InOrder order = inOrder(mydoJobMapper, shadowObservationGateway); InOrder order = inOrder(mydoJobMapper, shadowObservationGateway, shadowCompletion);
order.verify(mydoJobMapper).selectInJobByTaskNo("TASK-1"); order.verify(mydoJobMapper).selectInJobByTaskNo("TASK-1");
order.verify(shadowObservationGateway).planInbound("TASK-1", "TRAY-1"); order.verify(shadowObservationGateway).observeInbound("TASK-1", "TRAY-1");
order.verify(mydoJobMapper).allotInJobSpace(any()); order.verify(mydoJobMapper).allotInJobSpace(any());
order.verify(mydoJobMapper).selectInJobByTaskNo("TASK-1"); order.verify(mydoJobMapper).selectInJobByTaskNo("TASK-1");
order.verify(shadowObservationGateway) order.verify(shadowCompletion).complete("10", "SPACE-LEGACY");
.compareInbound(observation, "10", "SPACE-LEGACY");
verify(mydoJobMapper, times(1)).allotInJobSpace(any()); verify(mydoJobMapper, times(1)).allotInJobSpace(any());
} }
@Test @Test
void shadowExceptionCannotSkipOrReplaceLegacyAllocation() { void shadowExceptionCannotSkipOrReplaceLegacyAllocation() {
when(shadowObservationGateway.planInbound("TASK-1", "TRAY-1")) when(shadowObservationGateway.observeInbound("TASK-1", "TRAY-1"))
.thenThrow(new IllegalStateException("shadow unavailable")); .thenThrow(new IllegalStateException("shadow unavailable"));
AjaxResult result = service.requestEnterByTaskNo(request); AjaxResult result = service.requestEnterByTaskNo(request);
assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(legacyResult); assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(legacyResult);
verify(shadowObservationGateway).planInbound("TASK-1", "TRAY-1"); verify(shadowObservationGateway).observeInbound("TASK-1", "TRAY-1");
verify(mydoJobMapper, times(1)).allotInJobSpace(any()); verify(mydoJobMapper, times(1)).allotInJobSpace(any());
} }

View File

@@ -1,6 +1,6 @@
package com.ruoyi.out.outStockout.service.impl; package com.ruoyi.out.outStockout.service.impl;
import com.ruoyi.algorithm.shadow.OutboundShadowObservation; import com.ruoyi.algorithm.shadow.OutboundShadowCompletion;
import com.ruoyi.algorithm.shadow.ShadowObservationGateway; import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.out.outStockout.domain.OutStockout; import com.ruoyi.out.outStockout.domain.OutStockout;
@@ -23,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -35,6 +36,8 @@ class OutStockoutServiceImplShadowTest {
private IOutStockoutDetailService outStockoutDetailService; private IOutStockoutDetailService outStockoutDetailService;
@Mock @Mock
private ShadowObservationGateway shadowObservationGateway; private ShadowObservationGateway shadowObservationGateway;
@Mock
private OutboundShadowCompletion shadowCompletion;
@InjectMocks @InjectMocks
private OutStockoutServiceImpl service; private OutStockoutServiceImpl service;
@@ -51,9 +54,8 @@ class OutStockoutServiceImplShadowTest {
@Test @Test
void missingShadowProjectStillRunsAutoAllotAndPreservesLegacyWarning() { void missingShadowProjectStillRunsAutoAllotAndPreservesLegacyWarning() {
prepareAutoAllot(); prepareAutoAllot();
OutboundShadowObservation missingProject = OutboundShadowObservation.unavailable( when(shadowObservationGateway.observeProjectOutbound("DETAIL-1"))
"W01", "DETAIL-1", "SKIPPED_MISSING_PROJECT", "missing project"); .thenReturn(shadowCompletion);
when(shadowObservationGateway.planProjectOutbound("DETAIL-1")).thenReturn(missingProject);
doAnswer(invocation -> { doAnswer(invocation -> {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
HashMap<String, String> parameters = invocation.getArgument(0); HashMap<String, String> parameters = invocation.getArgument(0);
@@ -65,17 +67,17 @@ class OutStockoutServiceImplShadowTest {
AjaxResult result = service.autoAllot("OUT-1"); AjaxResult result = service.autoAllot("OUT-1");
assertThat(result.get(AjaxResult.MSG_TAG)).isEqualTo(",DETAIL-1LEGACY-WARNING"); assertThat(result.get(AjaxResult.MSG_TAG)).isEqualTo(",DETAIL-1LEGACY-WARNING");
InOrder order = inOrder(shadowObservationGateway, outStockoutMapper); InOrder order = inOrder(shadowObservationGateway, outStockoutMapper, shadowCompletion);
order.verify(shadowObservationGateway).planProjectOutbound("DETAIL-1"); order.verify(shadowObservationGateway).observeProjectOutbound("DETAIL-1");
order.verify(outStockoutMapper).autoAllot(any()); order.verify(outStockoutMapper).autoAllot(any());
order.verify(shadowObservationGateway).compareOutbound(missingProject, "20"); order.verify(shadowCompletion).complete("20");
verify(outStockoutMapper, times(1)).autoAllot(any()); verify(outStockoutMapper, times(1)).autoAllot(any());
} }
@Test @Test
void shadowExceptionStillRunsAutoAllotAndPreservesLegacySuccess() { void shadowExceptionStillRunsAutoAllotAndPreservesLegacySuccess() {
prepareAutoAllot(); prepareAutoAllot();
when(shadowObservationGateway.planProjectOutbound("DETAIL-1")) when(shadowObservationGateway.observeProjectOutbound("DETAIL-1"))
.thenThrow(new IllegalStateException("shadow unavailable")); .thenThrow(new IllegalStateException("shadow unavailable"));
doAnswer(invocation -> { doAnswer(invocation -> {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@@ -88,18 +90,18 @@ class OutStockoutServiceImplShadowTest {
AjaxResult result = service.autoAllot("OUT-1"); AjaxResult result = service.autoAllot("OUT-1");
assertThat(result.isSuccess()).isTrue(); assertThat(result.isSuccess()).isTrue();
verify(shadowObservationGateway).planProjectOutbound("DETAIL-1"); verify(shadowObservationGateway).observeProjectOutbound("DETAIL-1");
verify(outStockoutMapper, times(1)).autoAllot(any()); verify(outStockoutMapper, times(1)).autoAllot(any());
} }
@Test @Test
void missingShadowStockStillRunsManualAllotOncePerSelectedStock() { void missingShadowStockStillRunsManualAllotOncePerSelectedStock() {
OutboundShadowObservation missingStock = OutboundShadowObservation.unavailable( OutboundShadowCompletion firstCompletion = mock(OutboundShadowCompletion.class);
"W01", "DETAIL-1", "SKIPPED_MISSING_STOCK", "missing stock"); OutboundShadowCompletion secondCompletion = mock(OutboundShadowCompletion.class);
when(shadowObservationGateway.planExactStockOutbound("DETAIL-1", "STOCK-1")) when(shadowObservationGateway.observeExactStockOutbound("DETAIL-1", "STOCK-1"))
.thenReturn(missingStock); .thenReturn(firstCompletion);
when(shadowObservationGateway.planExactStockOutbound("DETAIL-1", "STOCK-2")) when(shadowObservationGateway.observeExactStockOutbound("DETAIL-1", "STOCK-2"))
.thenReturn(missingStock); .thenReturn(secondCompletion);
doAnswer(invocation -> { doAnswer(invocation -> {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
HashMap<String, String> parameters = invocation.getArgument(0); HashMap<String, String> parameters = invocation.getArgument(0);
@@ -112,9 +114,10 @@ class OutStockoutServiceImplShadowTest {
assertThat(result.isSuccess()).isTrue(); assertThat(result.isSuccess()).isTrue();
verify(outStockoutMapper, times(2)).manualAllot(any()); verify(outStockoutMapper, times(2)).manualAllot(any());
verify(shadowObservationGateway).planExactStockOutbound("DETAIL-1", "STOCK-1"); verify(shadowObservationGateway).observeExactStockOutbound("DETAIL-1", "STOCK-1");
verify(shadowObservationGateway).planExactStockOutbound("DETAIL-1", "STOCK-2"); verify(shadowObservationGateway).observeExactStockOutbound("DETAIL-1", "STOCK-2");
verify(shadowObservationGateway, times(2)).compareOutbound(missingStock, "10"); verify(firstCompletion).complete("10");
verify(secondCompletion).complete("10");
} }
private void prepareAutoAllot() { private void prepareAutoAllot() {