diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java index 52df011..1df5055 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java @@ -9,6 +9,7 @@ public class ShadowObservationProperties { private boolean enabled; private String warehouseId = ""; private int timeoutSeconds = 3; + private int refreshTimeoutSeconds = 10; private int snapshotMaxAgeSeconds = 60; private int quietIntervalMillis = 1000; @@ -36,6 +37,14 @@ public class ShadowObservationProperties { this.timeoutSeconds = timeoutSeconds; } + public int getRefreshTimeoutSeconds() { + return refreshTimeoutSeconds; + } + + public void setRefreshTimeoutSeconds(int refreshTimeoutSeconds) { + this.refreshTimeoutSeconds = refreshTimeoutSeconds; + } + public int getSnapshotMaxAgeSeconds() { return snapshotMaxAgeSeconds; } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java index 48337c2..2bde4d2 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java @@ -35,17 +35,19 @@ public class ShadowPlanningEngine { private final SchedulingFacade schedulingFacade; private final ShadowInputMapper inputMapper; private final ShadowObservationProperties properties; - private final TransactionTemplate transactions; + private final TransactionTemplate planningTransactions; + private final TransactionTemplate refreshTransactions; 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.setIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED); - this.transactions.setTimeout(properties.getTimeoutSeconds()); + PlatformTransactionManager requiredTransactionManager = + Objects.requireNonNull(transactionManager, "transactionManager"); + this.planningTransactions = transactionTemplate(requiredTransactionManager, properties.getTimeoutSeconds()); + this.refreshTransactions = transactionTemplate( + requiredTransactionManager, properties.getRefreshTimeoutSeconds()); } public InboundShadowObservation planInbound(String taskNo, String trayNo) { @@ -68,7 +70,7 @@ public class ShadowPlanningEngine { throw new IllegalArgumentException("shadow snapshot instanceId is required"); } String warehouseId = configuredWarehouseId(); - return transactions.execute(status -> { + return refreshTransactions.execute(status -> { if (!inputMapper.claimInstance(warehouseId, instanceId, INSTANCE_STALE_SECONDS)) { throw new IllegalStateException("could not claim shadow snapshot refresh for warehouse " + warehouseId); } @@ -215,7 +217,7 @@ public class ShadowPlanningEngine { public void compareInbound(InboundShadowObservation observation, String legacyResultCode, String legacySpaceId) { Objects.requireNonNull(observation, "observation"); - transactions.executeWithoutResult(status -> { + planningTransactions.executeWithoutResult(status -> { validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(), observation.snapshotSynchronizedAt(), observation.sourceSignature()); int inserted = inputMapper.insertComparison( @@ -230,7 +232,7 @@ public class ShadowPlanningEngine { public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) { Objects.requireNonNull(observation, "observation"); - transactions.executeWithoutResult(status -> { + planningTransactions.executeWithoutResult(status -> { validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(), observation.snapshotSynchronizedAt(), observation.sourceSignature()); int inserted = inputMapper.insertComparison( @@ -349,7 +351,7 @@ public class ShadowPlanningEngine { private T planWithStableSnapshot(PublishedShadowSnapshot snapshot, Supplier planner) { validateLeasedSnapshot(snapshot); - return transactions.execute(status -> { + return planningTransactions.execute(status -> { requirePublishedSnapshot(snapshot, "published snapshot changed during planning"); T result = planner.get(); requirePublishedSnapshot(snapshot, "published snapshot changed during planning"); @@ -357,6 +359,15 @@ public class ShadowPlanningEngine { }); } + private TransactionTemplate transactionTemplate( + PlatformTransactionManager transactionManager, int timeoutSeconds) { + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED); + template.setTimeout(timeoutSeconds); + return template; + } + private void validateLeasedSnapshot(PublishedShadowSnapshot snapshot) { Objects.requireNonNull(snapshot, "snapshot"); if (snapshot.generation() <= 0) { diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java index dd50fc4..f9276e4 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java @@ -36,6 +36,10 @@ public class ShadowSafetyGuard implements InitializingBean { if (shadowProperties.getTimeoutSeconds() < 1 || shadowProperties.getTimeoutSeconds() > 30) { throw new IllegalStateException("Shadow observation timeout must be between 1 and 30 seconds"); } + if (shadowProperties.getRefreshTimeoutSeconds() < 5 + || shadowProperties.getRefreshTimeoutSeconds() > 60) { + throw new IllegalStateException("Shadow refresh timeout must be between 5 and 60 seconds"); + } if (shadowProperties.getSnapshotMaxAgeSeconds() < 5 || shadowProperties.getSnapshotMaxAgeSeconds() > 300) { throw new IllegalStateException("Shadow snapshot maximum age must be between 5 and 300 seconds"); diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 698b0f1..3201cd6 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -189,6 +189,7 @@ fortucky: enabled: ${FORTUCKY_SHADOW_ENABLED:false} warehouse-id: ${FORTUCKY_SHADOW_WAREHOUSE_ID:} timeout-seconds: ${FORTUCKY_SHADOW_TIMEOUT_SECONDS:3} + refresh-timeout-seconds: ${FORTUCKY_SHADOW_REFRESH_TIMEOUT_SECONDS:10} snapshot-max-age-seconds: ${FORTUCKY_SHADOW_SNAPSHOT_MAX_AGE_SECONDS:60} quiet-interval-millis: ${FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS:1000} algorithm: diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowConfigurationTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowConfigurationTest.java index fcad020..0ae1d18 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowConfigurationTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowConfigurationTest.java @@ -33,6 +33,7 @@ class ShadowConfigurationTest { assertThat(shadow.isEnabled()).isFalse(); assertThat(shadow.getWarehouseId()).isEmpty(); assertThat(shadow.getTimeoutSeconds()).isEqualTo(3); + assertThat(shadow.getRefreshTimeoutSeconds()).isEqualTo(10); assertThat(shadow.getSnapshotMaxAgeSeconds()).isEqualTo(60); assertThat(shadow.getQuietIntervalMillis()).isEqualTo(1000); assertThat(algorithm.getMode()).isEqualTo(SchedulingMode.SHADOW); diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java index adec5e0..74525df 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java @@ -315,17 +315,23 @@ class ShadowPlanningEngineTest { @Test void refreshClaimsInstanceAndReturnsValidatedPublishedSnapshot() { + ShadowObservationProperties properties = properties(); + properties.setTimeoutSeconds(3); + properties.setRefreshTimeoutSeconds(11); + CapturingTransactionManager transactions = new CapturingTransactionManager(); ShadowInputMapper mapper = mock(ShadowInputMapper.class); when(mapper.claimInstance("01", "INSTANCE-1", 120)).thenReturn(true); PublishedShadowSnapshot refreshedSnapshot = snapshot(43L, "01"); when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(refreshedSnapshot)); - ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper); + ShadowPlanningEngine engine = new ShadowPlanningEngine( + mock(SchedulingFacade.class), mapper, properties, transactions); PublishedShadowSnapshot refreshed = engine.refreshSnapshot(43L, "INSTANCE-1"); verify(mapper).claimInstance("01", "INSTANCE-1", 120); verify(mapper).refreshSnapshot("01", 43L, "INSTANCE-1"); assertThat(refreshed).isEqualTo(refreshedSnapshot); + assertThat(transactions.definition.getTimeout()).isEqualTo(11); } @Test diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuardTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuardTest.java index 86feb04..ece30f9 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuardTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuardTest.java @@ -76,6 +76,22 @@ class ShadowSafetyGuardTest { .hasMessageContaining("between 100 and 10000"); } + @Test + void rejectsRefreshTimeoutOutsideValidatedRange() { + ShadowObservationProperties shadow = enabledShadow(); + AlgorithmProperties algorithm = new AlgorithmProperties(); + algorithm.setMode(SchedulingMode.SHADOW); + algorithm.setDatabaseWritesEnabled(true); + + shadow.setRefreshTimeoutSeconds(3); + assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate()) + .hasMessageContaining("refresh timeout"); + + shadow.setRefreshTimeoutSeconds(61); + assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate()) + .hasMessageContaining("refresh timeout"); + } + private ShadowObservationProperties enabledShadow() { ShadowObservationProperties properties = new ShadowObservationProperties(); properties.setEnabled(true);