feat(ft546): collect bounded read-only shadow facts

This commit is contained in:
zgw
2026-08-14 14:27:47 +08:00
parent b364831c4a
commit 08003407eb
16 changed files with 2412 additions and 4 deletions

View File

@@ -0,0 +1,277 @@
package com.ruoyi.shadow.facts;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ShadowFactBatchValidatorTest {
private static final Instant NOW = Instant.parse("2026-08-13T02:00:00Z");
private final ShadowFactBatchValidator validator =
new ShadowFactBatchValidator(Duration.ofSeconds(2),
Duration.ofSeconds(5));
@Test
void completeFreshFactsAreComparable() {
ShadowFactValidation result = validator.validate(batch(
Collections.singletonList(queue("Q-1", "070501", "070502", "1")),
Collections.singletonList(vehicle("1", "070501")),
Arrays.asList(map("070501"), map("070502")),
NOW.minusSeconds(1), NOW.minusMillis(500)), NOW);
assertEquals(ShadowFactValidation.Status.COMPARABLE,
result.getStatus());
assertEquals("FACTS_COMPLETE", result.getReasonCode());
}
@Test
void contradictoryVehiclePositionsAreInsufficient() {
ShadowFactValidation result = validator.validate(batch(
Collections.singletonList(queue("Q-1", "070501", "070502", "1")),
Arrays.asList(vehicle("1", "070501"),
vehicle("1", "070502")),
Arrays.asList(map("070501"), map("070502")),
NOW.minusSeconds(1), NOW.minusMillis(500)), NOW);
assertInsufficient(result, "CONTRADICTORY_VEHICLE_POSITION:1");
}
@Test
void contradictoryVehicleStateIsInsufficientEvenAtTheSameSite() {
ShadowVehicleFact changedBattery = completeVehicleBuilder(
"1", "070501").battery(20).build();
ShadowFactValidation result = validator.validate(batch(
Collections.singletonList(queue("Q-1", "070501", "070502", "1")),
Arrays.asList(vehicle("1", "070501"), changedBattery),
Arrays.asList(map("070501"), map("070502")),
NOW.minusSeconds(1), NOW.minusMillis(500)), NOW);
assertInsufficient(result, "CONTRADICTORY_VEHICLE_FACT:1");
}
@Test
void missingRequiredVehicleOrMapFactsAreInsufficient() {
ShadowFactValidation missingVehicles = validator.validate(batch(
Collections.emptyList(), Collections.emptyList(),
Collections.singletonList(map("070501")),
NOW.minusSeconds(1), NOW.minusMillis(500)), NOW);
assertInsufficient(missingVehicles, "MISSING_VEHICLE_FACTS");
ShadowFactValidation missingMaps = validator.validate(batch(
Collections.emptyList(),
Collections.singletonList(vehicle("1", "070501")),
Collections.emptyList(), NOW.minusSeconds(1),
NOW.minusMillis(500)), NOW);
assertInsufficient(missingMaps, "MISSING_MAP_FACTS");
}
@Test
void contradictoryMapFactsForOneSiteAreInsufficient() {
ShadowFactValidation result = validator.validate(batch(
Collections.emptyList(),
Collections.singletonList(vehicle("1", "070501")),
Arrays.asList(map("070501"),
new ShadowMapFact("M-OTHER", "070501", 2, 1, 1, 1)),
NOW.minusSeconds(1), NOW.minusMillis(500)), NOW);
assertInsufficient(result, "CONTRADICTORY_MAP_SITE:070501");
}
@Test
void queueMutationInsideOverlapIsInsufficient() {
ShadowQueueFact first = queue("Q-1", "070501", "070502", "1");
ShadowQueueFact changed = ShadowQueueFact.builder(
"shadow_ro.v_wcs_srgv_queue", "Q-1")
.taskNo("30001").taskType("2").priority(1L)
.startSite("070501").endSite("070503")
.createdAt(NOW.minusSeconds(2)).agvId("1")
.pathInfo("70501,70503,").turnPath("")
.default02("1").build();
ShadowFactValidation result = validator.validate(batch(
Arrays.asList(first, changed),
Collections.singletonList(vehicle("1", "070501")),
Arrays.asList(map("070501"), map("070502"), map("070503")),
NOW.minusSeconds(1), NOW.minusMillis(500)), NOW);
assertInsufficient(result,
"QUEUE_MUTATED_DURING_OVERLAP:shadow_ro.v_wcs_srgv_queue:Q-1");
}
@Test
void nonMonotonicOrPartialDatabaseClockIsInsufficient() {
ShadowFactBatch nonMonotonic = batch(Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(),
NOW, NOW.minusSeconds(1));
assertInsufficient(validator.validate(nonMonotonic, NOW),
"NON_MONOTONIC_DATABASE_CLOCK");
ShadowFactBatch partial = batch(Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(),
NOW.minusSeconds(4), NOW.minusSeconds(1));
assertInsufficient(validator.validate(partial, NOW),
"DATABASE_CAPTURE_SPAN_EXCEEDED");
}
@Test
void staleOrFutureDatabaseClockIsInsufficient() {
assertInsufficient(validator.validate(batch(Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(),
NOW.minusSeconds(8), NOW.minusSeconds(7)), NOW),
"STALE_DATABASE_CLOCK");
assertInsufficient(validator.validate(batch(Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(),
NOW.plusSeconds(1), NOW.plusSeconds(2)), NOW),
"DATABASE_CLOCK_IN_FUTURE");
}
@Test
void missingMapSiteIsInsufficient() {
ShadowFactValidation result = validator.validate(batch(
Collections.singletonList(queue("Q-1", "070501", "070502", "1")),
Collections.singletonList(vehicle("1", "070501")),
Collections.singletonList(map("070501")),
NOW.minusSeconds(1), NOW.minusMillis(500)), NOW);
assertInsufficient(result, "MISSING_MAP_SITE:070502");
}
@Test
void missingLegacyAssignedVehicleIsInsufficient() {
ShadowFactValidation result = validator.validate(batch(
Collections.singletonList(queue("Q-1", "070501", "070502", "9")),
Collections.singletonList(vehicle("1", "070501")),
Arrays.asList(map("070501"), map("070502")),
NOW.minusSeconds(1), NOW.minusMillis(500)), NOW);
assertInsufficient(result, "MISSING_LEGACY_ASSIGNED_VEHICLE:9");
}
@Test
void missingVehicleKernelFieldsAreInsufficient() {
ShadowVehicleFact missingSite = completeVehicleBuilder("1", null).build();
ShadowFactValidation siteResult = validator.validate(batch(
Collections.emptyList(), Collections.singletonList(missingSite),
Collections.emptyList(), NOW.minusSeconds(1),
NOW.minusMillis(500)), NOW);
assertInsufficient(siteResult, "MISSING_VEHICLE_CURRENT_SITE:1");
ShadowVehicleFact missingMode = completeVehicleBuilder("1", "070501")
.workMode(null).build();
ShadowFactValidation modeResult = validator.validate(batch(
Collections.emptyList(), Collections.singletonList(missingMode),
Collections.emptyList(), NOW.minusSeconds(1),
NOW.minusMillis(500)), NOW);
assertInsufficient(modeResult, "MISSING_VEHICLE_WORK_MODE:1");
ShadowVehicleFact invalidBattery = completeVehicleBuilder("1", "070501")
.battery(101).build();
ShadowFactValidation batteryResult = validator.validate(batch(
Collections.emptyList(), Collections.singletonList(invalidBattery),
Collections.emptyList(), NOW.minusSeconds(1),
NOW.minusMillis(500)), NOW);
assertInsufficient(batteryResult, "INVALID_VEHICLE_BATTERY:1");
}
@Test
void missingQueueClockIsInsufficient() {
ShadowQueueFact queue = ShadowQueueFact.builder(
"shadow_ro.v_wcs_srgv_queue", "Q-1")
.taskNo("30001").taskType("2").priority(1L)
.startSite("070501").endSite("070502")
.cargoPickSite("070501").cargoPlaceSite("070502")
.createdAt(null).agvId("1")
.pathInfo("70501,70502,").turnPath("")
.default02("1").build();
ShadowFactValidation result = validator.validate(batch(
Collections.singletonList(queue),
Collections.singletonList(vehicle("1", "070501")),
Arrays.asList(map("070501"), map("070502")),
NOW.minusSeconds(1), NOW.minusMillis(500)), NOW);
assertInsufficient(result,
"MISSING_QUEUE_CREATED_AT:shadow_ro.v_wcs_srgv_queue:Q-1");
}
@Test
void missingStageTwoVehicleGateIsInsufficientButKnownClosedGateIsComparable() {
ShadowQueueFact stageTwo = ShadowQueueFact.builder(
"shadow_ro.v_wcs_srgv_queue", "Q-2")
.taskNo("30002").taskType("2").priority(1L)
.startSite("070501").endSite("070502")
.cargoPickSite("070501").cargoPlaceSite("070502")
.createdAt(NOW.minusSeconds(2)).agvId("1")
.pathInfo("70501,70502,").turnPath("")
.default02("1").default06("070503").default08("2")
.build();
ShadowVehicleFact unknownGate = completeVehicleBuilder("1", "070501")
.spare5(null).build();
ShadowFactValidation unknown = validator.validate(batch(
Collections.singletonList(stageTwo),
Collections.singletonList(unknownGate),
Arrays.asList(map("070501"), map("070502")),
NOW.minusSeconds(1), NOW.minusMillis(500)), NOW);
assertInsufficient(unknown, "MISSING_STAGE_TWO_GATE:1");
ShadowVehicleFact closedGate = completeVehicleBuilder("1", "070501")
.spare5("0").build();
ShadowFactValidation knownClosed = validator.validate(batch(
Collections.singletonList(stageTwo),
Collections.singletonList(closedGate),
Arrays.asList(map("070501"), map("070502")),
NOW.minusSeconds(1), NOW.minusMillis(500)), NOW);
assertEquals(ShadowFactValidation.Status.COMPARABLE,
knownClosed.getStatus());
}
private static void assertInsufficient(ShadowFactValidation validation,
String reason) {
assertEquals(ShadowFactValidation.Status.INSUFFICIENT_FACTS,
validation.getStatus());
assertEquals(reason, validation.getReasonCode());
}
private static ShadowFactBatch batch(
java.util.List<ShadowQueueFact> queues,
java.util.List<ShadowVehicleFact> vehicles,
java.util.List<ShadowMapFact> maps,
Instant started, Instant completed) {
return ShadowFactBatch.create(started, completed, queues, vehicles, maps);
}
private static ShadowQueueFact queue(String guid, String start,
String end, String agvId) {
return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", guid)
.taskNo("30001").taskType("2").priority(1L)
.startSite(start).endSite(end)
.cargoPickSite(start).cargoPlaceSite(end)
.createdAt(NOW.minusSeconds(2)).agvId(agvId)
.pathInfo("70501,70502,").turnPath("")
.default02("1").build();
}
private static ShadowVehicleFact vehicle(String deviceId, String site) {
return completeVehicleBuilder(deviceId, site).build();
}
private static ShadowVehicleFact.Builder completeVehicleBuilder(
String deviceId, String site) {
return ShadowVehicleFact.builder("V-" + deviceId, deviceId)
.agvStatus("1").taskNo(0)
.sourceSite(site).targetSite(site).currentSite(site)
.taskType(0).battery(76).workMode(5).alarm(0).status(1)
.finish(0).chargeFlag(0).workPhase(0).errorCode(0)
.changeLayer(0).cargoFlag(0).path(site).allowSend(1)
.currentFloor(1).locked(0).spare2("0").spare5("0")
.capturedAt(NOW.minusSeconds(1));
}
private static ShadowMapFact map(String site) {
return new ShadowMapFact("M-" + site, site, 1, 1, 1, 1);
}
}

View File

@@ -0,0 +1,668 @@
package com.ruoyi.shadow.facts;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.SimpleTransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Timestamp;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
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 SqlServerShadowFactReaderTest {
private static final Instant NOW = Instant.parse("2026-08-13T02:00:00Z");
@Test
void readsOneImmutableOrderedBatchInOneReadOnlyTransaction() {
FakeFactSource source = completeSource();
source.queues = Arrays.asList(queue("Q-2", NOW.minusSeconds(1)),
queue("Q-1", NOW.minusSeconds(2)),
queue("Q-1", NOW.minusSeconds(2)));
source.vehicles = Arrays.asList(vehicle("2", "070503"),
vehicle("1", "070502"));
source.maps = Arrays.asList(map("070503"), map("070502"),
map("070501"));
RecordingTransactions transactions = new RecordingTransactions();
SqlServerShadowFactReader reader = reader(source, transactions);
ShadowFactBatch batch = reader.read(ShadowReadCursor.initial(),
NOW.plusSeconds(4));
assertEquals(Arrays.asList("Q-1", "Q-2"), guids(batch.getQueues()));
assertEquals(Arrays.asList("1", "2"), deviceIds(batch.getVehicles()));
assertEquals(Arrays.asList("070501", "070502", "070503"),
siteIds(batch.getMaps()));
assertEquals(3, batch.getSourceQueues().size());
assertEquals("070502", batch.vehicle("1").getCurrentSite());
assertTrue(batch.getDatabaseCapturedAt().isBefore(NOW.plusSeconds(4)));
assertEquals(1, transactions.calls);
assertTrue(transactions.readOnly);
assertThrows(UnsupportedOperationException.class,
() -> batch.getQueues().clear());
assertThrows(UnsupportedOperationException.class,
() -> batch.getVehicles().add(vehicle("2", "070503")));
}
@Test
void cursorReplaysFiveSecondOverlapAndRetainsMutatedEvidence() {
FakeFactSource source = completeSource();
ShadowQueueFact old = queue("Q-1", NOW.minusSeconds(10));
ShadowQueueFact changed = queueBuilder("Q-1", NOW.minusSeconds(10))
.endSite("070503").pathInfo("70501,70503,").build();
source.queues = Arrays.asList(old, changed);
SqlServerShadowFactReader reader = reader(source,
new RecordingTransactions());
ShadowFactBatch batch = reader.read(
new ShadowReadCursor(NOW.minusSeconds(6), "Q-9"),
NOW.plusSeconds(4));
assertEquals(NOW.minusSeconds(11), source.cursor.getOverlapStart());
assertEquals(2, batch.getSourceQueues().size());
assertEquals(2, batch.getQueues().size());
assertFalse(batch.getQueues().get(0).getContentHash().equals(
batch.getQueues().get(1).getContentHash()));
}
@Test
void hardRowLimitsDiscardPartialBatchAndAdvanceBackoff() {
FakeFactSource source = completeSource();
source.queues = repeatedQueues(501);
SqlServerShadowFactReader reader = reader(source,
new RecordingTransactions());
ShadowFactReadException first = assertThrows(
ShadowFactReadException.class,
() -> reader.read(ShadowReadCursor.initial(),
NOW.plusSeconds(4)));
assertEquals("QUEUE_ROW_LIMIT_EXCEEDED:500", first.getReasonCode());
assertEquals(Duration.ofSeconds(1), first.getRetryAfter());
source.queues = Collections.singletonList(queue("Q-1", NOW));
source.vehicles = repeatedVehicles(101);
ShadowFactReadException second = assertThrows(
ShadowFactReadException.class,
() -> reader.read(ShadowReadCursor.initial(),
NOW.plusSeconds(4)));
assertEquals("VEHICLE_ROW_LIMIT_EXCEEDED:100", second.getReasonCode());
assertEquals(Duration.ofSeconds(2), second.getRetryAfter());
source.vehicles = Collections.singletonList(vehicle("1", "070502"));
source.maps = repeatedMaps(10_001);
ShadowFactReadException third = assertThrows(
ShadowFactReadException.class,
() -> reader.read(ShadowReadCursor.initial(),
NOW.plusSeconds(4)));
assertEquals("MAP_ROW_LIMIT_EXCEEDED:10000", third.getReasonCode());
assertEquals(Duration.ofSeconds(4), third.getRetryAfter());
}
@Test
void sourceOutageBacksOffWithoutRetryAndSuccessResetsIt() {
FakeFactSource source = completeSource();
source.failureReason = "SOURCE_QUERY_TIMEOUT";
SqlServerShadowFactReader reader = reader(source,
new RecordingTransactions());
List<Duration> observed = new ArrayList<Duration>();
for (int i = 0; i < 6; i++) {
ShadowFactReadException failure = assertThrows(
ShadowFactReadException.class,
() -> reader.read(ShadowReadCursor.initial(),
NOW.plusSeconds(4)));
observed.add(failure.getRetryAfter());
}
assertEquals(Arrays.asList(Duration.ofSeconds(1),
Duration.ofSeconds(2), Duration.ofSeconds(4),
Duration.ofSeconds(8), Duration.ofSeconds(30),
Duration.ofSeconds(30)), observed);
assertEquals(6, source.queueCalls);
source.failureReason = null;
reader.read(ShadowReadCursor.initial(), NOW.plusSeconds(4));
source.failureReason = "SOURCE_UNAVAILABLE";
ShadowFactReadException reset = assertThrows(
ShadowFactReadException.class,
() -> reader.read(ShadowReadCursor.initial(),
NOW.plusSeconds(4)));
assertEquals(Duration.ofSeconds(1), reset.getRetryAfter());
}
@Test
void wholeBatchDeadlineAndQueryTimeoutAreFailClosed() {
assertEquals(3, SqlServerShadowFactReader.queryTimeoutSeconds(
NOW, NOW.plusMillis(3_500)));
assertEquals(1, SqlServerShadowFactReader.queryTimeoutSeconds(
NOW, NOW.plusMillis(1_001)));
ShadowFactReadException subSecond = assertThrows(
ShadowFactReadException.class,
() -> SqlServerShadowFactReader.queryTimeoutSeconds(
NOW, NOW.plusMillis(999)));
assertEquals("SOURCE_BATCH_DEADLINE_EXCEEDED",
subSecond.getReasonCode());
ShadowFactReadException expired = assertThrows(
ShadowFactReadException.class,
() -> SqlServerShadowFactReader.queryTimeoutSeconds(
NOW, NOW));
assertEquals("SOURCE_BATCH_DEADLINE_EXCEEDED",
expired.getReasonCode());
FakeFactSource source = completeSource();
SqlServerShadowFactReader reader = reader(source,
new RecordingTransactions());
ShadowFactReadException tooLong = assertThrows(
ShadowFactReadException.class,
() -> reader.read(ShadowReadCursor.initial(),
NOW.plusSeconds(5)));
assertEquals("SOURCE_BATCH_DEADLINE_MUST_NOT_EXCEED_4_SECONDS",
tooLong.getReasonCode());
}
@Test
void batchThatFinishesAfterItsDeadlineIsDiscardedAndBacksOff() {
FakeFactSource source = completeSource();
RecordingTransactions transactions = new RecordingTransactions();
SqlServerShadowFactReader reader = new SqlServerShadowFactReader(
source, transactions.template,
new SequenceClock(NOW, NOW, NOW, NOW.plusSeconds(5)));
ShadowFactReadException failure = assertThrows(
ShadowFactReadException.class,
() -> reader.read(ShadowReadCursor.initial(),
NOW.plusSeconds(4)));
assertEquals("SOURCE_BATCH_DEADLINE_EXCEEDED",
failure.getReasonCode());
assertEquals(Duration.ofSeconds(1), failure.getRetryAfter());
}
@Test
void contentHashDistinguishesMissingValuesFromLiteralNullText() {
assertFalse(FactHash.sha256((Object) null)
.equals(FactHash.sha256("null")));
}
@Test
void jdbcSourceUsesOnlyApprovedViewsAndNormalizedClockColumns()
throws Exception {
assertTrue(SqlServerShadowFactReader.JdbcFactSource.QUEUE_SQL_PREFIX
.contains("create_date_utc"));
assertTrue(SqlServerShadowFactReader.JdbcFactSource.QUEUE_SQL_SUFFIX
.contains("create_date_utc"));
assertTrue(SqlServerShadowFactReader.JdbcFactSource.VEHICLE_SQL
.contains("last_time_utc"));
String allSql = SqlServerShadowFactReader.JdbcFactSource.CLOCK_SQL
+ SqlServerShadowFactReader.JdbcFactSource.QUEUE_SQL_PREFIX
+ SqlServerShadowFactReader.JdbcFactSource.QUEUE_SQL_SUFFIX
+ SqlServerShadowFactReader.JdbcFactSource.VEHICLE_SQL
+ SqlServerShadowFactReader.JdbcFactSource.MAP_SQL;
assertFalse(allSql.toLowerCase().contains(" dbo."));
assertFalse(allSql.toLowerCase().contains(" wcs_srgv_queue"));
assertFalse(allSql.toLowerCase().contains(" device_srgv"));
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
PreparedStatement statement = mock(PreparedStatement.class);
ResultSet result = mock(ResultSet.class);
when(dataSource.getConnection()).thenReturn(connection);
when(connection.prepareStatement(
SqlServerShadowFactReader.JdbcFactSource.CLOCK_SQL))
.thenReturn(statement);
when(statement.executeQuery()).thenReturn(result);
when(result.next()).thenReturn(true);
when(result.getObject("database_utc_time")).thenReturn(null);
when(result.getTimestamp(
org.mockito.ArgumentMatchers.eq("database_utc_time"),
org.mockito.ArgumentMatchers.any(java.util.Calendar.class)))
.thenReturn(Timestamp.from(NOW.minusMillis(500)));
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
SqlServerShadowFactReader.JdbcFactSource source =
new SqlServerShadowFactReader.JdbcFactSource(jdbc,
Clock.fixed(NOW, ZoneOffset.UTC));
assertEquals(NOW.minusMillis(500),
source.readDatabaseClock(NOW.plusMillis(1_001)));
verify(statement).setQueryTimeout(1);
verify(result).getTimestamp(
org.mockito.ArgumentMatchers.eq("database_utc_time"),
org.mockito.ArgumentMatchers.argThat(calendar ->
"UTC".equals(calendar.getTimeZone().getID())));
verify(result, never()).getTimestamp("database_utc_time");
verify(connection, never()).prepareStatement(
org.mockito.ArgumentMatchers.contains("dbo."));
}
@Test
void jdbcVehicleProjectionCapturesDispatchAndLegacyGateFacts()
throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
PreparedStatement statement = mock(PreparedStatement.class);
ResultSet result = mock(ResultSet.class);
when(dataSource.getConnection()).thenReturn(connection);
when(connection.prepareStatement(
SqlServerShadowFactReader.JdbcFactSource.VEHICLE_SQL))
.thenReturn(statement);
when(statement.executeQuery()).thenReturn(result);
when(result.next()).thenReturn(true, false);
when(result.wasNull()).thenReturn(false);
when(result.getString("guid")).thenReturn("V-1");
when(result.getString("device_id")).thenReturn("1");
when(result.getString("agv_status")).thenReturn("1");
when(result.getString("src_site")).thenReturn("070501");
when(result.getString("tar_site")).thenReturn("070504");
when(result.getString("curr_site")).thenReturn("070502");
when(result.getString("path")).thenReturn("070502,070503,070504");
when(result.getString("spare2")).thenReturn("1");
when(result.getString("spare5")).thenReturn("1");
when(result.getInt("task_no")).thenReturn(30001);
when(result.getInt("task_type")).thenReturn(3);
when(result.getInt("battery")).thenReturn(76);
when(result.getInt("work_mode")).thenReturn(5);
when(result.getInt("alarm")).thenReturn(0);
when(result.getInt("status")).thenReturn(1);
when(result.getInt("finish")).thenReturn(0);
when(result.getInt("charge_flag")).thenReturn(0);
when(result.getInt("work_phase")).thenReturn(2);
when(result.getInt("err_msg")).thenReturn(0);
when(result.getInt("change_layer")).thenReturn(1);
when(result.getInt("cargo_flag")).thenReturn(1);
when(result.getInt("allow_send")).thenReturn(1);
when(result.getInt("curr_floor")).thenReturn(2);
when(result.getInt("locked")).thenReturn(0);
when(result.getTimestamp(
org.mockito.ArgumentMatchers.eq("last_time_utc"),
org.mockito.ArgumentMatchers.any(java.util.Calendar.class)))
.thenReturn(Timestamp.from(NOW.minusSeconds(1)));
SqlServerShadowFactReader.JdbcFactSource source =
new SqlServerShadowFactReader.JdbcFactSource(
new JdbcTemplate(dataSource),
Clock.fixed(NOW, ZoneOffset.UTC));
ShadowVehicleFact vehicle = source.readVehicles(
NOW.plusMillis(1_001)).get(0);
assertEquals("1", vehicle.getAgvStatus());
assertEquals(Integer.valueOf(30001), vehicle.getTaskNo());
assertEquals("070501", vehicle.getSourceSite());
assertEquals("070504", vehicle.getTargetSite());
assertEquals(Integer.valueOf(3), vehicle.getTaskType());
assertEquals(Integer.valueOf(76), vehicle.getBattery());
assertEquals(Integer.valueOf(5), vehicle.getWorkMode());
assertEquals(Integer.valueOf(0), vehicle.getAlarm());
assertEquals(Integer.valueOf(1), vehicle.getStatus());
assertEquals(Integer.valueOf(0), vehicle.getFinish());
assertEquals(Integer.valueOf(0), vehicle.getChargeFlag());
assertEquals(Integer.valueOf(2), vehicle.getWorkPhase());
assertEquals(Integer.valueOf(0), vehicle.getErrorCode());
assertEquals(Integer.valueOf(1), vehicle.getChangeLayer());
assertEquals(Integer.valueOf(1), vehicle.getCargoFlag());
assertEquals("070502,070503,070504", vehicle.getPath());
assertEquals(Integer.valueOf(1), vehicle.getAllowSend());
assertEquals(Integer.valueOf(2), vehicle.getCurrentFloor());
assertEquals(Integer.valueOf(0), vehicle.getLocked());
assertEquals("1", vehicle.getSpare2());
assertEquals("1", vehicle.getSpare5());
assertEquals(NOW.minusSeconds(1), vehicle.getCapturedAt());
verify(statement).setInt(1, 101);
verify(statement).setQueryTimeout(1);
}
@Test
void concurrentCycleIsRejectedWithoutStartingAnotherTransaction()
throws Exception {
FakeFactSource source = completeSource();
source.block = new CountDownLatch(1);
source.entered = new CountDownLatch(1);
RecordingTransactions transactions = new RecordingTransactions();
SqlServerShadowFactReader reader = reader(source, transactions);
AtomicReference<Throwable> backgroundFailure =
new AtomicReference<Throwable>();
Thread first = new Thread(() -> {
try {
reader.read(ShadowReadCursor.initial(), NOW.plusSeconds(4));
} catch (Throwable failure) {
backgroundFailure.set(failure);
}
});
first.start();
assertTrue(source.entered.await(2, TimeUnit.SECONDS));
ShadowFactReadException overlap = assertThrows(
ShadowFactReadException.class,
() -> reader.read(ShadowReadCursor.initial(),
NOW.plusSeconds(4)));
assertEquals("SOURCE_CYCLE_ALREADY_IN_FLIGHT",
overlap.getReasonCode());
assertEquals(Duration.ZERO, overlap.getRetryAfter());
source.block.countDown();
first.join(2_000L);
assertFalse(first.isAlive());
assertEquals(null, backgroundFailure.get());
assertEquals(1, transactions.calls);
}
@Test
void projectsOrdinaryCrossFloorAndBothTwoStageContractsIndependently() {
LegacyQueueProtocolProjector projector =
new LegacyQueueProtocolProjector();
ShadowLegacyQueueResult ordinary = projector.project(
queueBuilder("ORD", NOW).taskNo("30001")
.default02("1").pathInfo("70501,70502,")
.turnPath("").build());
assertEquals("4004,4000,4002,4021", ordinary.getWriteAddresses());
assertEquals(Arrays.asList(70501, 70502, 0, 0, 0, 0, 0, 0),
ordinary.getWrites().get(0).getValues());
ShadowLegacyQueueResult crossFloor = projector.project(
queueBuilder("CROSS", NOW).taskNo("30002")
.default02("8").default07("8")
.pathInfo("70501,70507,80507,80501,")
.turnPath("70507,80507").build());
assertEquals("4004,4000,4002,4021,4020,4022",
crossFloor.getWriteAddresses());
assertEquals(Collections.singletonList(8),
crossFloor.write("4022").getValues());
ShadowLegacyQueueResult stageOne = projector.project(
queueBuilder("STAGE-1", NOW).taskNo("30003")
.default02("1").default08("1")
.pathInfo("70501,70503,").turnPath("").build());
assertEquals("4404,4422,4402,4400", stageOne.getWriteAddresses());
ShadowLegacyQueueResult stageTwo = projector.project(
queueBuilder("STAGE-2", NOW).taskNo("30004")
.default02("1").default08("2")
.endSite("070503").default06("070504")
.pathInfo("70501,70503,").build());
assertEquals("4404,4422,4423,4402,4400",
stageTwo.getWriteAddresses());
assertEquals(Arrays.asList(70503, 70504, 0, 0, 0, 0, 0, 0),
stageTwo.getWrites().get(0).getValues());
assertEquals(Collections.singletonList(9),
stageTwo.write("4402").getValues());
}
@Test
void missingLegacyFieldsNeverProduceAComparableRegisterSequence() {
LegacyQueueProtocolProjector projector =
new LegacyQueueProtocolProjector();
ShadowLegacyQueueResult result = projector.project(
queueBuilder("BROKEN", NOW).taskNo(null)
.pathInfo(null).default02("8")
.default07(null).build());
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
result.getStatus());
assertEquals("LEGACY_RESULT_UNAVAILABLE:MISSING_TASK_NO",
result.getReasonCode());
assertTrue(result.getWrites().isEmpty());
}
@Test
void legacyProjectionUsesTheExecutedPathEndpointsInsteadOfQueueOrigins() {
ShadowQueueFact queue = queueBuilder("REPOSITIONED", NOW)
.startSite("070501").endSite("070504")
.pathInfo("070502,070503,070504,")
.turnPath("070503").build();
ShadowLegacyQueueResult result =
new LegacyQueueProtocolProjector().project(queue);
assertEquals(Arrays.asList(70502, 70504, 70503, 0, 0, 0, 0, 0),
result.write("4004").getValues());
}
@Test
void legacyProjectionAcceptsPersistedZeroPaddedTurnPoints() {
ShadowQueueFact queue = queueBuilder("PADDED-TURNS", NOW)
.pathInfo("070501,070502,070504,")
.turnPath("070503,0,0,0,0,0").build();
ShadowLegacyQueueResult result =
new LegacyQueueProtocolProjector().project(queue);
assertEquals(ShadowLegacyQueueResult.Status.AVAILABLE,
result.getStatus());
assertEquals(Arrays.asList(70501, 70504, 70503, 0, 0, 0, 0, 0),
result.write("4004").getValues());
}
private static SqlServerShadowFactReader reader(FakeFactSource source,
RecordingTransactions tx) {
return new SqlServerShadowFactReader(source, tx.template,
Clock.fixed(NOW, ZoneOffset.UTC));
}
private static FakeFactSource completeSource() {
FakeFactSource source = new FakeFactSource();
source.clockValues.add(NOW.minusMillis(500));
source.clockValues.add(NOW.minusMillis(100));
source.queues = Collections.singletonList(queue("Q-1", NOW.minusSeconds(2)));
source.vehicles = Collections.singletonList(vehicle("1", "070502"));
source.maps = Arrays.asList(map("070501"), map("070502"));
return source;
}
private static ShadowQueueFact queue(String guid, Instant createdAt) {
return queueBuilder(guid, createdAt).build();
}
private static ShadowQueueFact.Builder queueBuilder(String guid,
Instant createdAt) {
return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", guid)
.taskNo("30001").taskType("2").priority(1L)
.cargoPickSite("070501").cargoPlaceSite("070502")
.startSite("070501").endSite("070502")
.createdAt(createdAt).agvId("1")
.pathInfo("70501,70502,").turnPath("")
.default02("1").default03("").default04("")
.default05("").default06("").default07("")
.default08("");
}
private static ShadowVehicleFact vehicle(String deviceId,
String currentSite) {
return completeVehicleBuilder(deviceId, currentSite).build();
}
private static ShadowVehicleFact.Builder completeVehicleBuilder(
String deviceId, String currentSite) {
return ShadowVehicleFact.builder("V-" + deviceId, deviceId)
.agvStatus("1").taskNo(0)
.sourceSite(currentSite).targetSite(currentSite)
.currentSite(currentSite).taskType(0)
.battery(76).workMode(5).alarm(0).status(1)
.finish(0).chargeFlag(0).workPhase(0).errorCode(0)
.changeLayer(0).cargoFlag(0).path(currentSite)
.allowSend(1).currentFloor(2).locked(0)
.spare2("0").spare5("0")
.capturedAt(NOW.minusSeconds(1));
}
private static ShadowMapFact map(String siteId) {
return new ShadowMapFact("M-" + siteId, siteId, 1, 1, 1, 1);
}
private static List<ShadowQueueFact> repeatedQueues(int count) {
List<ShadowQueueFact> facts = new ArrayList<ShadowQueueFact>();
for (int i = 0; i < count; i++) {
facts.add(queue("Q-" + i, NOW.minusSeconds(1)));
}
return facts;
}
private static List<ShadowVehicleFact> repeatedVehicles(int count) {
List<ShadowVehicleFact> facts = new ArrayList<ShadowVehicleFact>();
for (int i = 0; i < count; i++) {
facts.add(vehicle(Integer.toString(i), "070502"));
}
return facts;
}
private static List<ShadowMapFact> repeatedMaps(int count) {
List<ShadowMapFact> facts = new ArrayList<ShadowMapFact>();
for (int i = 0; i < count; i++) {
facts.add(map(Integer.toString(70_000 + i)));
}
return facts;
}
private static List<String> guids(List<ShadowQueueFact> facts) {
List<String> result = new ArrayList<String>();
for (ShadowQueueFact fact : facts) {
result.add(fact.getGuid());
}
return result;
}
private static List<String> deviceIds(List<ShadowVehicleFact> facts) {
List<String> result = new ArrayList<String>();
for (ShadowVehicleFact fact : facts) {
result.add(fact.getDeviceId());
}
return result;
}
private static List<String> siteIds(List<ShadowMapFact> facts) {
List<String> result = new ArrayList<String>();
for (ShadowMapFact fact : facts) {
result.add(fact.getSiteId());
}
return result;
}
private static final class SequenceClock extends Clock {
private final Instant[] values;
private int index;
private SequenceClock(Instant... values) {
this.values = values;
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
if (!ZoneOffset.UTC.equals(zone)) {
throw new IllegalArgumentException("only UTC is supported");
}
return this;
}
@Override
public Instant instant() {
int current = Math.min(index, values.length - 1);
index++;
return values[current];
}
}
private static final class FakeFactSource
implements SqlServerShadowFactReader.FactSource {
private final List<Instant> clockValues = new ArrayList<Instant>();
private List<ShadowQueueFact> queues = Collections.emptyList();
private List<ShadowVehicleFact> vehicles = Collections.emptyList();
private List<ShadowMapFact> maps = Collections.emptyList();
private ShadowReadCursor cursor;
private String failureReason;
private int queueCalls;
private CountDownLatch entered;
private CountDownLatch block;
@Override
public Instant readDatabaseClock(Instant deadline) {
if (clockValues.size() > 1) {
return clockValues.remove(0);
}
return clockValues.isEmpty() ? NOW : clockValues.get(0);
}
@Override
public List<ShadowQueueFact> readQueues(ShadowReadCursor cursor,
Instant deadline) {
this.cursor = cursor;
queueCalls++;
if (entered != null) {
entered.countDown();
}
if (block != null) {
try {
if (!block.await(2, TimeUnit.SECONDS)) {
throw new IllegalStateException("test latch timeout");
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
}
if (failureReason != null) {
throw new ShadowFactReadException(failureReason,
Duration.ZERO);
}
return queues;
}
@Override
public List<ShadowVehicleFact> readVehicles(Instant deadline) {
return vehicles;
}
@Override
public List<ShadowMapFact> readMaps(Instant deadline) {
return maps;
}
}
private static final class RecordingTransactions {
private final PlatformTransactionManager manager =
mock(PlatformTransactionManager.class);
private final TransactionTemplate template =
new TransactionTemplate(manager);
private int calls;
private boolean readOnly;
private RecordingTransactions() {
when(manager.getTransaction(any(TransactionDefinition.class)))
.thenAnswer(invocation -> {
TransactionDefinition definition = invocation.getArgument(0);
calls++;
readOnly = definition.isReadOnly();
return new SimpleTransactionStatus();
});
template.setReadOnly(true);
}
}
}