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

@@ -6,6 +6,7 @@ import com.ruoyi.shadow.startup.ShadowDatabaseGate;
import com.ruoyi.shadow.startup.SqlServerPermissionProbe;
import com.ruoyi.shadow.startup.SqlServerShadowDatabaseGate;
import com.ruoyi.shadow.web.ShadowStatusController;
import com.ruoyi.shadow.facts.SqlServerShadowFactReader;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -14,8 +15,10 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.support.TransactionTemplate;
import javax.sql.DataSource;
import java.time.Clock;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ShadowProperties.class)
@@ -54,4 +57,17 @@ public class ShadowRuntimeConfiguration {
ShadowStartupReport report) {
return new ShadowStatusController(report);
}
@Bean
public SqlServerShadowFactReader shadowFactReader(
@Qualifier("shadowSourceJdbc") JdbcTemplate sourceJdbc,
@Qualifier("shadowSourceTx")
org.springframework.transaction.PlatformTransactionManager sourceTx) {
Clock clock = Clock.systemUTC();
TransactionTemplate transaction = new TransactionTemplate(sourceTx);
transaction.setReadOnly(true);
return new SqlServerShadowFactReader(
new SqlServerShadowFactReader.JdbcFactSource(sourceJdbc, clock),
transaction, clock);
}
}

View File

@@ -0,0 +1,41 @@
package com.ruoyi.shadow.facts;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
final class FactHash {
private FactHash() {
}
static String sha256(Object... values) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
for (Object value : values) {
if (value == null) {
digest.update((byte) 0);
continue;
}
digest.update((byte) 1);
byte[] bytes = value.toString().getBytes(StandardCharsets.UTF_8);
digest.update((byte) (bytes.length >>> 24));
digest.update((byte) (bytes.length >>> 16));
digest.update((byte) (bytes.length >>> 8));
digest.update((byte) bytes.length);
digest.update(bytes);
}
return hex(digest.digest());
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable", impossible);
}
}
private static String hex(byte[] bytes) {
StringBuilder result = new StringBuilder(bytes.length * 2);
for (byte value : bytes) {
result.append(Character.forDigit((value >>> 4) & 0xf, 16));
result.append(Character.forDigit(value & 0xf, 16));
}
return result.toString();
}
}

View File

@@ -0,0 +1,202 @@
package com.ruoyi.shadow.facts;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public final class LegacyQueueProtocolProjector {
public ShadowLegacyQueueResult project(ShadowQueueFact queue) {
if (queue == null) {
return unavailable("MISSING_QUEUE");
}
if (!notBlank(queue.getTaskNo())) {
return unavailable("MISSING_TASK_NO");
}
if (!notBlank(queue.getDefault02())) {
return unavailable("MISSING_DEFAULT02");
}
if (!notBlank(queue.getAgvId())) {
return unavailable("MISSING_AGV_ID");
}
if (!notBlank(queue.getPathInfo())) {
return unavailable("MISSING_PATH_INFO");
}
Integer taskNo = integer(queue.getTaskNo());
Integer taskType = integer(queue.getDefault02());
if (taskNo == null) {
return unavailable("INVALID_TASK_NO");
}
if (taskType == null) {
return unavailable("INVALID_DEFAULT02");
}
String stage = trim(queue.getDefault08());
if (stage.isEmpty()) {
return main(queue, taskNo, taskType);
}
if ("1".equals(stage)) {
return stageOne(queue, taskNo, taskType);
}
if ("2".equals(stage)) {
return stageTwo(queue, taskNo);
}
return unavailable("INVALID_DEFAULT08");
}
private static ShadowLegacyQueueResult main(ShadowQueueFact queue,
int taskNo, int taskType) {
List<Integer> path = ordinaryPath(queue);
if (path == null) {
return unavailable("INVALID_MAIN_PATH");
}
List<ShadowLegacyQueueResult.LegacyRegisterWrite> writes =
new ArrayList<ShadowLegacyQueueResult.LegacyRegisterWrite>();
add(writes, "4004", ShadowLegacyQueueResult.DataType.INT32, false, path);
add(writes, "4000", ShadowLegacyQueueResult.DataType.INT32, false,
Collections.singletonList(taskNo));
add(writes, "4002", ShadowLegacyQueueResult.DataType.INT16, false,
Collections.singletonList(taskType));
add(writes, "4021", ShadowLegacyQueueResult.DataType.INT16, true,
Collections.singletonList(1));
if (taskType == 8) {
Integer target = integer(queue.getDefault07());
if (target == null) {
return unavailable("MISSING_DEFAULT07");
}
add(writes, "4020", ShadowLegacyQueueResult.DataType.INT16, false,
Collections.singletonList(1));
add(writes, "4022", ShadowLegacyQueueResult.DataType.INT16, false,
Collections.singletonList(target));
}
return ShadowLegacyQueueResult.available(writes);
}
private static ShadowLegacyQueueResult stageOne(ShadowQueueFact queue,
int taskNo, int taskType) {
List<Integer> path = ordinaryPath(queue);
if (path == null) {
return unavailable("INVALID_STAGE_ONE_PATH");
}
List<ShadowLegacyQueueResult.LegacyRegisterWrite> writes =
new ArrayList<ShadowLegacyQueueResult.LegacyRegisterWrite>();
add(writes, "4404", ShadowLegacyQueueResult.DataType.INT32, false, path);
add(writes, "4422", ShadowLegacyQueueResult.DataType.INT16, true,
Collections.singletonList(1));
add(writes, "4402", ShadowLegacyQueueResult.DataType.INT16, false,
Collections.singletonList(taskType));
add(writes, "4400", ShadowLegacyQueueResult.DataType.INT32, false,
Collections.singletonList(taskNo));
return ShadowLegacyQueueResult.available(writes);
}
private static ShadowLegacyQueueResult stageTwo(ShadowQueueFact queue,
int taskNo) {
Integer start = site(queue.getEndSite());
Integer end = site(queue.getDefault06());
if (start == null) {
return unavailable("MISSING_STAGE_TWO_START");
}
if (end == null) {
return unavailable("MISSING_DEFAULT06");
}
List<Integer> path = new ArrayList<Integer>(Arrays.asList(start, end));
while (path.size() < 8) {
path.add(0);
}
List<ShadowLegacyQueueResult.LegacyRegisterWrite> writes =
new ArrayList<ShadowLegacyQueueResult.LegacyRegisterWrite>();
add(writes, "4404", ShadowLegacyQueueResult.DataType.INT32, false, path);
add(writes, "4422", ShadowLegacyQueueResult.DataType.INT16, true,
Collections.singletonList(2));
add(writes, "4423", ShadowLegacyQueueResult.DataType.INT16, false,
Collections.singletonList(99));
add(writes, "4402", ShadowLegacyQueueResult.DataType.INT16, false,
Collections.singletonList(9));
add(writes, "4400", ShadowLegacyQueueResult.DataType.INT32, false,
Collections.singletonList(taskNo));
return ShadowLegacyQueueResult.available(writes);
}
private static List<Integer> ordinaryPath(ShadowQueueFact queue) {
List<Integer> executedPath = executedPath(queue.getPathInfo());
if (executedPath == null || executedPath.size() < 2) {
return null;
}
List<Integer> result = new ArrayList<Integer>();
result.add(executedPath.get(0));
result.add(executedPath.get(executedPath.size() - 1));
if (notBlank(queue.getTurnPath())) {
for (String token : queue.getTurnPath().split(",")) {
if (!notBlank(token)) {
continue;
}
Integer point = integer(token);
if (point != null && point != 0) {
point = site(token);
}
if (point == null || result.size() == 8) {
return null;
}
result.add(point);
}
}
while (result.size() < 8) {
result.add(0);
}
return result;
}
private static List<Integer> executedPath(String value) {
if (!notBlank(value)) {
return null;
}
List<Integer> result = new ArrayList<Integer>();
for (String token : value.split(",")) {
if (!notBlank(token)) {
continue;
}
Integer point = site(token);
if (point == null) {
return null;
}
result.add(point);
}
return result;
}
private static void add(
List<ShadowLegacyQueueResult.LegacyRegisterWrite> writes,
String address, ShadowLegacyQueueResult.DataType type,
boolean trigger, List<Integer> values) {
writes.add(new ShadowLegacyQueueResult.LegacyRegisterWrite(address,
type, values, writes.size() + 1, trigger));
}
private static ShadowLegacyQueueResult unavailable(String reason) {
return ShadowLegacyQueueResult.unavailable(reason);
}
private static Integer site(String value) {
Integer parsed = integer(value);
return parsed == null || parsed <= 0 || parsed > 999_999 ? null : parsed;
}
private static Integer integer(String value) {
if (!notBlank(value)) {
return null;
}
try {
return Integer.valueOf(value.trim());
} catch (NumberFormatException invalid) {
return null;
}
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private static boolean notBlank(String value) {
return value != null && !value.trim().isEmpty();
}
}

View File

@@ -0,0 +1,120 @@
package com.ruoyi.shadow.facts;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class ShadowFactBatch {
private final Instant databaseCaptureStartedAt;
private final Instant databaseCapturedAt;
private final List<ShadowQueueFact> sourceQueues;
private final List<ShadowQueueFact> queues;
private final List<ShadowVehicleFact> vehicles;
private final List<ShadowMapFact> maps;
private ShadowFactBatch(Instant databaseCaptureStartedAt,
Instant databaseCapturedAt,
List<ShadowQueueFact> sourceQueues,
List<ShadowVehicleFact> vehicles,
List<ShadowMapFact> maps) {
this.databaseCaptureStartedAt = databaseCaptureStartedAt;
this.databaseCapturedAt = databaseCapturedAt;
this.sourceQueues = immutable(sourceQueues);
this.queues = immutable(deduplicateAndSort(sourceQueues));
this.vehicles = immutable(sortVehicles(vehicles));
this.maps = immutable(sortMaps(maps));
}
public static ShadowFactBatch create(Instant startedAt, Instant capturedAt,
List<ShadowQueueFact> queues,
List<ShadowVehicleFact> vehicles,
List<ShadowMapFact> maps) {
return new ShadowFactBatch(startedAt, capturedAt,
requiredList(queues, "queues"),
requiredList(vehicles, "vehicles"),
requiredList(maps, "maps"));
}
public Instant getDatabaseCaptureStartedAt() { return databaseCaptureStartedAt; }
public Instant getDatabaseCapturedAt() { return databaseCapturedAt; }
public List<ShadowQueueFact> getSourceQueues() { return sourceQueues; }
public List<ShadowQueueFact> getQueues() { return queues; }
public List<ShadowVehicleFact> getVehicles() { return vehicles; }
public List<ShadowMapFact> getMaps() { return maps; }
public ShadowQueueFact queue(String guid) {
for (ShadowQueueFact fact : queues) {
if (fact.getGuid().equals(guid)) {
return fact;
}
}
return null;
}
public ShadowVehicleFact vehicle(String deviceId) {
for (ShadowVehicleFact fact : vehicles) {
if (fact.getDeviceId().equals(deviceId)) {
return fact;
}
}
return null;
}
private static List<ShadowQueueFact> deduplicateAndSort(
List<ShadowQueueFact> source) {
Map<String, ShadowQueueFact> unique =
new LinkedHashMap<String, ShadowQueueFact>();
for (ShadowQueueFact fact : source) {
String key = fact.getTableName() + '\u0000' + fact.getGuid()
+ '\u0000' + fact.getContentHash();
unique.put(key, fact);
}
List<ShadowQueueFact> result =
new ArrayList<ShadowQueueFact>(unique.values());
Collections.sort(result, Comparator
.comparing(ShadowQueueFact::getCreatedAt,
Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparing(ShadowQueueFact::getGuid)
.thenComparing(ShadowQueueFact::getContentHash));
return result;
}
private static List<ShadowVehicleFact> sortVehicles(
List<ShadowVehicleFact> source) {
List<ShadowVehicleFact> result =
new ArrayList<ShadowVehicleFact>(source);
Collections.sort(result, Comparator
.comparing(ShadowVehicleFact::getDeviceId)
.thenComparing(ShadowVehicleFact::getGuid)
.thenComparing(ShadowVehicleFact::getContentHash));
return result;
}
private static List<ShadowMapFact> sortMaps(List<ShadowMapFact> source) {
List<ShadowMapFact> result = new ArrayList<ShadowMapFact>(source);
Collections.sort(result, Comparator
.comparing(ShadowMapFact::getSiteId)
.thenComparing(ShadowMapFact::getGuid));
return result;
}
private static <T> List<T> immutable(List<T> source) {
return Collections.unmodifiableList(new ArrayList<T>(source));
}
private static <T> List<T> requiredList(List<T> source, String name) {
if (source == null) {
throw new IllegalArgumentException(name + " must not be null");
}
for (T value : source) {
if (value == null) {
throw new IllegalArgumentException(name + " must not contain null");
}
}
return source;
}
}

View File

@@ -0,0 +1,183 @@
package com.ruoyi.shadow.facts;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public final class ShadowFactBatchValidator {
private final Duration maximumCaptureSpan;
private final Duration maximumAge;
public ShadowFactBatchValidator(Duration maximumCaptureSpan,
Duration maximumAge) {
if (maximumCaptureSpan == null || maximumCaptureSpan.isNegative()
|| maximumAge == null || maximumAge.isNegative()) {
throw new IllegalArgumentException("durations must not be negative");
}
this.maximumCaptureSpan = maximumCaptureSpan;
this.maximumAge = maximumAge;
}
public ShadowFactValidation validate(ShadowFactBatch batch, Instant now) {
if (batch == null || now == null) {
return ShadowFactValidation.insufficient("MISSING_BATCH_CLOCK");
}
Instant started = batch.getDatabaseCaptureStartedAt();
Instant completed = batch.getDatabaseCapturedAt();
if (started == null || completed == null) {
return ShadowFactValidation.insufficient("MISSING_DATABASE_CLOCK");
}
if (completed.isBefore(started)) {
return ShadowFactValidation.insufficient(
"NON_MONOTONIC_DATABASE_CLOCK");
}
if (Duration.between(started, completed).compareTo(maximumCaptureSpan) > 0) {
return ShadowFactValidation.insufficient(
"DATABASE_CAPTURE_SPAN_EXCEEDED");
}
if (completed.isAfter(now)) {
return ShadowFactValidation.insufficient("DATABASE_CLOCK_IN_FUTURE");
}
if (Duration.between(completed, now).compareTo(maximumAge) > 0) {
return ShadowFactValidation.insufficient("STALE_DATABASE_CLOCK");
}
ShadowFactValidation mutation = queueMutations(batch);
if (mutation != null) {
return mutation;
}
Map<String, String> vehicleSites = new HashMap<String, String>();
Map<String, String> vehicleHashes = new HashMap<String, String>();
Map<String, ShadowVehicleFact> vehicles =
new HashMap<String, ShadowVehicleFact>();
for (ShadowVehicleFact vehicle : batch.getVehicles()) {
if (!notBlank(vehicle.getCurrentSite())) {
return ShadowFactValidation.insufficient(
"MISSING_VEHICLE_CURRENT_SITE:" + vehicle.getDeviceId());
}
if (vehicle.getWorkMode() == null) {
return ShadowFactValidation.insufficient(
"MISSING_VEHICLE_WORK_MODE:" + vehicle.getDeviceId());
}
if (vehicle.getBattery() == null) {
return ShadowFactValidation.insufficient(
"MISSING_VEHICLE_BATTERY:" + vehicle.getDeviceId());
}
if (vehicle.getBattery() < 0 || vehicle.getBattery() > 100) {
return ShadowFactValidation.insufficient(
"INVALID_VEHICLE_BATTERY:" + vehicle.getDeviceId());
}
String existing = vehicleSites.putIfAbsent(vehicle.getDeviceId(),
vehicle.getCurrentSite());
if (existing != null && !same(existing, vehicle.getCurrentSite())) {
return ShadowFactValidation.insufficient(
"CONTRADICTORY_VEHICLE_POSITION:" + vehicle.getDeviceId());
}
if (vehicle.getCapturedAt() == null
|| vehicle.getCapturedAt().isAfter(now)
|| Duration.between(vehicle.getCapturedAt(), now)
.compareTo(maximumAge) > 0) {
return ShadowFactValidation.insufficient(
"STALE_VEHICLE_FACT:" + vehicle.getDeviceId());
}
String existingHash = vehicleHashes.putIfAbsent(
vehicle.getDeviceId(), vehicle.getContentHash());
if (existingHash != null
&& !existingHash.equals(vehicle.getContentHash())) {
return ShadowFactValidation.insufficient(
"CONTRADICTORY_VEHICLE_FACT:" + vehicle.getDeviceId());
}
vehicles.putIfAbsent(vehicle.getDeviceId(), vehicle);
}
if (vehicles.isEmpty()) {
return ShadowFactValidation.insufficient("MISSING_VEHICLE_FACTS");
}
Set<String> sites = new HashSet<String>();
Map<String, ShadowMapFact> maps =
new HashMap<String, ShadowMapFact>();
for (ShadowMapFact map : batch.getMaps()) {
ShadowMapFact existing = maps.putIfAbsent(map.getSiteId(), map);
if (existing != null && !same(existing, map)) {
return ShadowFactValidation.insufficient(
"CONTRADICTORY_MAP_SITE:" + map.getSiteId());
}
sites.add(map.getSiteId());
}
if (maps.isEmpty()) {
return ShadowFactValidation.insufficient("MISSING_MAP_FACTS");
}
for (ShadowQueueFact queue : batch.getQueues()) {
if (queue.getCreatedAt() == null) {
return ShadowFactValidation.insufficient(
"MISSING_QUEUE_CREATED_AT:" + queue.getTableName()
+ ':' + queue.getGuid());
}
String missing = firstMissingSite(sites, queue.getStartSite(),
queue.getEndSite(), queue.getCargoPickSite(),
queue.getCargoPlaceSite());
if (missing != null) {
return ShadowFactValidation.insufficient("MISSING_MAP_SITE:" + missing);
}
if (notBlank(queue.getAgvId())
&& !vehicleSites.containsKey(queue.getAgvId())) {
return ShadowFactValidation.insufficient(
"MISSING_LEGACY_ASSIGNED_VEHICLE:" + queue.getAgvId());
}
if ("2".equals(trimmed(queue.getDefault08()))
&& notBlank(queue.getAgvId())) {
ShadowVehicleFact vehicle = vehicles.get(queue.getAgvId());
if (vehicle != null && vehicle.getSpare5() == null) {
return ShadowFactValidation.insufficient(
"MISSING_STAGE_TWO_GATE:" + queue.getAgvId());
}
}
}
return ShadowFactValidation.comparable();
}
private static ShadowFactValidation queueMutations(ShadowFactBatch batch) {
Map<String, String> hashes = new HashMap<String, String>();
for (ShadowQueueFact queue : batch.getSourceQueues()) {
String key = queue.getTableName() + ':' + queue.getGuid();
String existing = hashes.putIfAbsent(key, queue.getContentHash());
if (existing != null && !existing.equals(queue.getContentHash())) {
return ShadowFactValidation.insufficient(
"QUEUE_MUTATED_DURING_OVERLAP:" + key);
}
}
return null;
}
private static String firstMissingSite(Set<String> sites, String... required) {
for (String site : required) {
if (notBlank(site) && !sites.contains(site.trim())) {
return site.trim();
}
}
return null;
}
private static boolean notBlank(String value) {
return value != null && !value.trim().isEmpty();
}
private static String trimmed(String value) {
return value == null ? null : value.trim();
}
private static boolean same(String left, String right) {
return left == null ? right == null : left.equals(right);
}
private static boolean same(ShadowMapFact left, ShadowMapFact right) {
return same(left.getGuid(), right.getGuid())
&& left.getX() == right.getX()
&& left.getY() == right.getY()
&& left.getZ() == right.getZ()
&& left.getValue() == right.getValue();
}
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.shadow.facts;
import java.time.Duration;
public final class ShadowFactReadException extends RuntimeException {
private final String reasonCode;
private final Duration retryAfter;
public ShadowFactReadException(String reasonCode, Duration retryAfter) {
this(reasonCode, retryAfter, null);
}
public ShadowFactReadException(String reasonCode, Duration retryAfter,
Throwable cause) {
super(reasonCode, cause);
if (reasonCode == null || reasonCode.trim().isEmpty()) {
throw new IllegalArgumentException("reasonCode must not be blank");
}
this.reasonCode = reasonCode.trim();
this.retryAfter = retryAfter == null ? Duration.ZERO : retryAfter;
}
public String getReasonCode() { return reasonCode; }
public Duration getRetryAfter() { return retryAfter; }
}

View File

@@ -0,0 +1,24 @@
package com.ruoyi.shadow.facts;
public final class ShadowFactValidation {
public enum Status { COMPARABLE, INSUFFICIENT_FACTS }
private final Status status;
private final String reasonCode;
private ShadowFactValidation(Status status, String reasonCode) {
this.status = status;
this.reasonCode = reasonCode;
}
public static ShadowFactValidation comparable() {
return new ShadowFactValidation(Status.COMPARABLE, "FACTS_COMPLETE");
}
public static ShadowFactValidation insufficient(String reasonCode) {
return new ShadowFactValidation(Status.INSUFFICIENT_FACTS, reasonCode);
}
public Status getStatus() { return status; }
public String getReasonCode() { return reasonCode; }
}

View File

@@ -0,0 +1,83 @@
package com.ruoyi.shadow.facts;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class ShadowLegacyQueueResult {
public enum Status { AVAILABLE, LEGACY_RESULT_UNAVAILABLE }
public enum DataType { INT16, INT32 }
private final Status status;
private final String reasonCode;
private final List<LegacyRegisterWrite> writes;
private ShadowLegacyQueueResult(Status status, String reasonCode,
List<LegacyRegisterWrite> writes) {
this.status = status;
this.reasonCode = reasonCode;
this.writes = Collections.unmodifiableList(
new ArrayList<LegacyRegisterWrite>(writes));
}
public static ShadowLegacyQueueResult available(
List<LegacyRegisterWrite> writes) {
return new ShadowLegacyQueueResult(Status.AVAILABLE,
"LEGACY_RESULT_AVAILABLE", writes);
}
public static ShadowLegacyQueueResult unavailable(String missing) {
return new ShadowLegacyQueueResult(Status.LEGACY_RESULT_UNAVAILABLE,
"LEGACY_RESULT_UNAVAILABLE:" + missing,
Collections.<LegacyRegisterWrite>emptyList());
}
public Status getStatus() { return status; }
public String getReasonCode() { return reasonCode; }
public List<LegacyRegisterWrite> getWrites() { return writes; }
public String getWriteAddresses() {
StringBuilder addresses = new StringBuilder();
for (LegacyRegisterWrite write : writes) {
if (addresses.length() > 0) {
addresses.append(',');
}
addresses.append(write.getAddress());
}
return addresses.toString();
}
public LegacyRegisterWrite write(String address) {
for (LegacyRegisterWrite write : writes) {
if (write.getAddress().equals(address)) {
return write;
}
}
return null;
}
public static final class LegacyRegisterWrite {
private final String address;
private final DataType dataType;
private final List<Integer> values;
private final int writeSequence;
private final boolean trigger;
LegacyRegisterWrite(String address, DataType dataType,
List<Integer> values, int writeSequence,
boolean trigger) {
this.address = address;
this.dataType = dataType;
this.values = Collections.unmodifiableList(
new ArrayList<Integer>(values));
this.writeSequence = writeSequence;
this.trigger = trigger;
}
public String getAddress() { return address; }
public DataType getDataType() { return dataType; }
public List<Integer> getValues() { return values; }
public int getWriteSequence() { return writeSequence; }
public boolean isTrigger() { return trigger; }
}
}

View File

@@ -0,0 +1,34 @@
package com.ruoyi.shadow.facts;
public final class ShadowMapFact {
private final String guid;
private final String siteId;
private final int x;
private final int y;
private final int z;
private final int value;
public ShadowMapFact(String guid, String siteId, int x, int y, int z,
int value) {
this.guid = required(guid, "guid");
this.siteId = required(siteId, "siteId");
this.x = x;
this.y = y;
this.z = z;
this.value = value;
}
public String getGuid() { return guid; }
public String getSiteId() { return siteId; }
public int getX() { return x; }
public int getY() { return y; }
public int getZ() { return z; }
public int getValue() { return value; }
private static String required(String value, String name) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value.trim();
}
}

View File

@@ -0,0 +1,135 @@
package com.ruoyi.shadow.facts;
import java.time.Instant;
public final class ShadowQueueFact {
private final String tableName;
private final String guid;
private final String taskNo;
private final String taskType;
private final Long priority;
private final String cargoPickSite;
private final String cargoPlaceSite;
private final String startSite;
private final String endSite;
private final Instant createdAt;
private final String agvId;
private final String pathInfo;
private final String turnPath;
private final String default02;
private final String default03;
private final String default04;
private final String default05;
private final String default06;
private final String default07;
private final String default08;
private final String contentHash;
private ShadowQueueFact(Builder builder) {
this.tableName = required(builder.tableName, "tableName");
this.guid = required(builder.guid, "guid");
this.taskNo = builder.taskNo;
this.taskType = builder.taskType;
this.priority = builder.priority;
this.cargoPickSite = builder.cargoPickSite;
this.cargoPlaceSite = builder.cargoPlaceSite;
this.startSite = builder.startSite;
this.endSite = builder.endSite;
this.createdAt = builder.createdAt;
this.agvId = builder.agvId;
this.pathInfo = builder.pathInfo;
this.turnPath = builder.turnPath;
this.default02 = builder.default02;
this.default03 = builder.default03;
this.default04 = builder.default04;
this.default05 = builder.default05;
this.default06 = builder.default06;
this.default07 = builder.default07;
this.default08 = builder.default08;
this.contentHash = FactHash.sha256(tableName, guid, taskNo, taskType,
priority, cargoPickSite, cargoPlaceSite, startSite, endSite,
createdAt, agvId, pathInfo, turnPath, default02, default03,
default04, default05, default06, default07, default08);
}
public static Builder builder(String tableName, String guid) {
return new Builder(tableName, guid);
}
public String getTableName() { return tableName; }
public String getGuid() { return guid; }
public String getTaskNo() { return taskNo; }
public String getTaskType() { return taskType; }
public Long getPriority() { return priority; }
public String getCargoPickSite() { return cargoPickSite; }
public String getCargoPlaceSite() { return cargoPlaceSite; }
public String getStartSite() { return startSite; }
public String getEndSite() { return endSite; }
public Instant getCreatedAt() { return createdAt; }
public String getAgvId() { return agvId; }
public String getPathInfo() { return pathInfo; }
public String getTurnPath() { return turnPath; }
public String getDefault02() { return default02; }
public String getDefault03() { return default03; }
public String getDefault04() { return default04; }
public String getDefault05() { return default05; }
public String getDefault06() { return default06; }
public String getDefault07() { return default07; }
public String getDefault08() { return default08; }
public String getContentHash() { return contentHash; }
private static String required(String value, String name) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value.trim();
}
public static final class Builder {
private final String tableName;
private final String guid;
private String taskNo;
private String taskType;
private Long priority;
private String cargoPickSite;
private String cargoPlaceSite;
private String startSite;
private String endSite;
private Instant createdAt;
private String agvId;
private String pathInfo;
private String turnPath;
private String default02;
private String default03;
private String default04;
private String default05;
private String default06;
private String default07;
private String default08;
private Builder(String tableName, String guid) {
this.tableName = tableName;
this.guid = guid;
}
public Builder taskNo(String value) { taskNo = value; return this; }
public Builder taskType(String value) { taskType = value; return this; }
public Builder priority(Long value) { priority = value; return this; }
public Builder cargoPickSite(String value) { cargoPickSite = value; return this; }
public Builder cargoPlaceSite(String value) { cargoPlaceSite = value; return this; }
public Builder startSite(String value) { startSite = value; return this; }
public Builder endSite(String value) { endSite = value; return this; }
public Builder createdAt(Instant value) { createdAt = value; return this; }
public Builder agvId(String value) { agvId = value; return this; }
public Builder pathInfo(String value) { pathInfo = value; return this; }
public Builder turnPath(String value) { turnPath = value; return this; }
public Builder default02(String value) { default02 = value; return this; }
public Builder default03(String value) { default03 = value; return this; }
public Builder default04(String value) { default04 = value; return this; }
public Builder default05(String value) { default05 = value; return this; }
public Builder default06(String value) { default06 = value; return this; }
public Builder default07(String value) { default07 = value; return this; }
public Builder default08(String value) { default08 = value; return this; }
public ShadowQueueFact build() { return new ShadowQueueFact(this); }
}
}

View File

@@ -0,0 +1,38 @@
package com.ruoyi.shadow.facts;
import java.time.Duration;
import java.time.Instant;
public final class ShadowReadCursor {
private static final Instant BEGINNING = Instant.parse("1970-01-01T00:00:00Z");
private final Instant lastCreateDate;
private final String lastGuid;
public ShadowReadCursor(Instant lastCreateDate, String lastGuid) {
if (lastCreateDate == null) {
throw new IllegalArgumentException("lastCreateDate must not be null");
}
this.lastCreateDate = lastCreateDate;
this.lastGuid = lastGuid == null ? "" : lastGuid;
}
public static ShadowReadCursor initial() {
return new ShadowReadCursor(BEGINNING, "");
}
public ShadowReadCursor withOverlap(Duration overlap) {
if (overlap == null || overlap.isNegative()) {
throw new IllegalArgumentException("overlap must not be negative");
}
Instant start = lastCreateDate.minus(overlap);
if (start.isBefore(BEGINNING)) {
start = BEGINNING;
}
return new ShadowReadCursor(start, "");
}
public Instant getLastCreateDate() { return lastCreateDate; }
public Instant getOverlapStart() { return lastCreateDate; }
public String getLastGuid() { return lastGuid; }
}

View File

@@ -0,0 +1,161 @@
package com.ruoyi.shadow.facts;
import java.time.Instant;
public final class ShadowVehicleFact {
private final String guid;
private final String deviceId;
private final String agvStatus;
private final Integer taskNo;
private final String sourceSite;
private final String targetSite;
private final String currentSite;
private final Integer taskType;
private final Integer battery;
private final Integer workMode;
private final Integer alarm;
private final Integer status;
private final Integer finish;
private final Integer chargeFlag;
private final Integer workPhase;
private final Integer errorCode;
private final Integer changeLayer;
private final Integer cargoFlag;
private final String path;
private final Integer allowSend;
private final Integer currentFloor;
private final Integer locked;
private final String spare2;
private final String spare5;
private final Instant capturedAt;
private final String contentHash;
private ShadowVehicleFact(Builder builder) {
this.guid = required(builder.guid, "guid");
this.deviceId = required(builder.deviceId, "deviceId");
this.agvStatus = builder.agvStatus;
this.taskNo = builder.taskNo;
this.sourceSite = builder.sourceSite;
this.targetSite = builder.targetSite;
this.currentSite = builder.currentSite;
this.taskType = builder.taskType;
this.battery = builder.battery;
this.workMode = builder.workMode;
this.alarm = builder.alarm;
this.status = builder.status;
this.finish = builder.finish;
this.chargeFlag = builder.chargeFlag;
this.workPhase = builder.workPhase;
this.errorCode = builder.errorCode;
this.changeLayer = builder.changeLayer;
this.cargoFlag = builder.cargoFlag;
this.path = builder.path;
this.allowSend = builder.allowSend;
this.currentFloor = builder.currentFloor;
this.locked = builder.locked;
this.spare2 = builder.spare2;
this.spare5 = builder.spare5;
this.capturedAt = builder.capturedAt;
this.contentHash = FactHash.sha256(guid, deviceId, agvStatus,
taskNo, sourceSite, targetSite, currentSite, taskType,
battery, workMode, alarm, status, finish, chargeFlag,
workPhase, errorCode, changeLayer, cargoFlag, path,
allowSend, currentFloor, locked, spare2, spare5, capturedAt);
}
public static Builder builder(String guid, String deviceId) {
return new Builder(guid, deviceId);
}
public String getGuid() { return guid; }
public String getDeviceId() { return deviceId; }
public String getAgvStatus() { return agvStatus; }
public Integer getTaskNo() { return taskNo; }
public String getSourceSite() { return sourceSite; }
public String getTargetSite() { return targetSite; }
public String getCurrentSite() { return currentSite; }
public Integer getTaskType() { return taskType; }
public Integer getBattery() { return battery; }
public Integer getWorkMode() { return workMode; }
public Integer getAlarm() { return alarm; }
public Integer getStatus() { return status; }
public Integer getFinish() { return finish; }
public Integer getChargeFlag() { return chargeFlag; }
public Integer getWorkPhase() { return workPhase; }
public Integer getErrorCode() { return errorCode; }
public Integer getChangeLayer() { return changeLayer; }
public Integer getCargoFlag() { return cargoFlag; }
public String getPath() { return path; }
public Integer getAllowSend() { return allowSend; }
public Integer getCurrentFloor() { return currentFloor; }
public Integer getLocked() { return locked; }
public String getSpare2() { return spare2; }
public String getSpare5() { return spare5; }
public Instant getCapturedAt() { return capturedAt; }
public String getContentHash() { return contentHash; }
private static String required(String value, String name) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value.trim();
}
public static final class Builder {
private final String guid;
private final String deviceId;
private String agvStatus;
private Integer taskNo;
private String sourceSite;
private String targetSite;
private String currentSite;
private Integer taskType;
private Integer battery;
private Integer workMode;
private Integer alarm;
private Integer status;
private Integer finish;
private Integer chargeFlag;
private Integer workPhase;
private Integer errorCode;
private Integer changeLayer;
private Integer cargoFlag;
private String path;
private Integer allowSend;
private Integer currentFloor;
private Integer locked;
private String spare2;
private String spare5;
private Instant capturedAt;
private Builder(String guid, String deviceId) {
this.guid = guid;
this.deviceId = deviceId;
}
public Builder agvStatus(String value) { agvStatus = value; return this; }
public Builder taskNo(Integer value) { taskNo = value; return this; }
public Builder sourceSite(String value) { sourceSite = value; return this; }
public Builder targetSite(String value) { targetSite = value; return this; }
public Builder currentSite(String value) { currentSite = value; return this; }
public Builder taskType(Integer value) { taskType = value; return this; }
public Builder battery(Integer value) { battery = value; return this; }
public Builder workMode(Integer value) { workMode = value; return this; }
public Builder alarm(Integer value) { alarm = value; return this; }
public Builder status(Integer value) { status = value; return this; }
public Builder finish(Integer value) { finish = value; return this; }
public Builder chargeFlag(Integer value) { chargeFlag = value; return this; }
public Builder workPhase(Integer value) { workPhase = value; return this; }
public Builder errorCode(Integer value) { errorCode = value; return this; }
public Builder changeLayer(Integer value) { changeLayer = value; return this; }
public Builder cargoFlag(Integer value) { cargoFlag = value; return this; }
public Builder path(String value) { path = value; return this; }
public Builder allowSend(Integer value) { allowSend = value; return this; }
public Builder currentFloor(Integer value) { currentFloor = value; return this; }
public Builder locked(Integer value) { locked = value; return this; }
public Builder spare2(String value) { spare2 = value; return this; }
public Builder spare5(String value) { spare5 = value; return this; }
public Builder capturedAt(Instant value) { capturedAt = value; return this; }
public ShadowVehicleFact build() { return new ShadowVehicleFact(this); }
}
}

View File

@@ -0,0 +1,388 @@
package com.ruoyi.shadow.facts;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.support.TransactionTemplate;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.List;
import java.util.TimeZone;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
public final class SqlServerShadowFactReader {
static final int QUEUE_LIMIT = 500;
static final int VEHICLE_LIMIT = 100;
static final int MAP_LIMIT = 10_000;
private static final Duration CURSOR_OVERLAP = Duration.ofSeconds(5);
private static final Duration MAXIMUM_BATCH_DURATION = Duration.ofSeconds(4);
private static final Duration[] BACKOFF = {
Duration.ofSeconds(1), Duration.ofSeconds(2),
Duration.ofSeconds(4), Duration.ofSeconds(8),
Duration.ofSeconds(30)
};
private final FactSource source;
private final TransactionTemplate sourceTransaction;
private final Clock clock;
private final AtomicBoolean inFlight = new AtomicBoolean();
private final AtomicInteger consecutiveFailures = new AtomicInteger();
private volatile List<ShadowMapFact> cachedMaps;
public SqlServerShadowFactReader(FactSource source,
TransactionTemplate sourceTransaction,
Clock clock) {
if (source == null || sourceTransaction == null || clock == null) {
throw new IllegalArgumentException(
"source, sourceTransaction, and clock are required");
}
this.source = source;
this.sourceTransaction = sourceTransaction;
this.sourceTransaction.setReadOnly(true);
this.clock = clock;
}
public ShadowFactBatch read(ShadowReadCursor cursor, Instant deadline) {
if (cursor == null || deadline == null) {
throw new IllegalArgumentException("cursor and deadline are required");
}
Instant startedAt = clock.instant();
Duration requested = Duration.between(startedAt, deadline);
if (requested.isNegative() || requested.isZero()) {
throw immediate("SOURCE_BATCH_DEADLINE_EXCEEDED");
}
if (requested.compareTo(MAXIMUM_BATCH_DURATION) > 0) {
throw immediate("SOURCE_BATCH_DEADLINE_MUST_NOT_EXCEED_4_SECONDS");
}
if (!inFlight.compareAndSet(false, true)) {
throw immediate("SOURCE_CYCLE_ALREADY_IN_FLIGHT");
}
try {
ShadowFactBatch batch = sourceTransaction.execute(status ->
collect(cursor.withOverlap(CURSOR_OVERLAP), deadline));
if (batch == null) {
throw new ShadowFactReadException("SOURCE_EMPTY_BATCH",
Duration.ZERO);
}
consecutiveFailures.set(0);
return batch;
} catch (ShadowFactReadException failure) {
if (failure.getReasonCode().startsWith("SOURCE_CYCLE_")
|| failure.getReasonCode().contains("DEADLINE_MUST_NOT")) {
throw failure;
}
throw failed(failure.getReasonCode(), failure);
} catch (DataAccessException failure) {
throw failed(classify(failure), failure);
} catch (RuntimeException failure) {
throw failed("SOURCE_READ_FAILED", failure);
} finally {
inFlight.set(false);
}
}
private ShadowFactBatch collect(ShadowReadCursor cursor, Instant deadline) {
ensureTime(deadline);
Instant databaseStartedAt = source.readDatabaseClock(deadline);
List<ShadowQueueFact> queues = immutable(source.readQueues(cursor, deadline));
requireLimit("QUEUE", queues.size(), QUEUE_LIMIT);
List<ShadowVehicleFact> vehicles =
immutable(source.readVehicles(deadline));
requireLimit("VEHICLE", vehicles.size(), VEHICLE_LIMIT);
List<ShadowMapFact> maps = cachedMaps;
if (maps == null) {
maps = immutable(source.readMaps(deadline));
requireLimit("MAP", maps.size(), MAP_LIMIT);
}
ensureTime(deadline);
Instant databaseCompletedAt = source.readDatabaseClock(deadline);
ensureTime(deadline);
ShadowFactBatch batch = ShadowFactBatch.create(databaseStartedAt,
databaseCompletedAt, queues, vehicles, maps);
if (cachedMaps == null) {
cachedMaps = maps;
}
return batch;
}
private void ensureTime(Instant deadline) {
queryTimeoutSeconds(clock.instant(), deadline);
}
public static int queryTimeoutSeconds(Instant now, Instant deadline) {
if (now == null || deadline == null || !deadline.isAfter(now)) {
throw immediate("SOURCE_BATCH_DEADLINE_EXCEEDED");
}
long remainingMillis = Duration.between(now, deadline).toMillis();
long completeSeconds = remainingMillis / 1_000L;
if (completeSeconds < 1L) {
throw immediate("SOURCE_BATCH_DEADLINE_EXCEEDED");
}
return (int) Math.min(3L, completeSeconds);
}
private static void requireLimit(String kind, int actual, int limit) {
if (actual > limit) {
throw new ShadowFactReadException(
kind + "_ROW_LIMIT_EXCEEDED:" + limit, Duration.ZERO);
}
}
private ShadowFactReadException failed(String reason, Throwable cause) {
int failure = consecutiveFailures.getAndIncrement();
Duration delay = BACKOFF[Math.min(failure, BACKOFF.length - 1)];
return new ShadowFactReadException(reason, delay, cause);
}
private static ShadowFactReadException immediate(String reason) {
return new ShadowFactReadException(reason, Duration.ZERO);
}
private static String classify(DataAccessException failure) {
String name = failure.getClass().getSimpleName().toUpperCase();
if (name.contains("TIMEOUT") || name.contains("TRANSIENT")) {
return "SOURCE_QUERY_TIMEOUT";
}
return "SOURCE_UNAVAILABLE";
}
private static <T> List<T> immutable(List<T> values) {
if (values == null) {
throw new ShadowFactReadException("SOURCE_RETURNED_NULL_FACT_LIST",
Duration.ZERO);
}
return Collections.unmodifiableList(new ArrayList<T>(values));
}
public interface FactSource {
Instant readDatabaseClock(Instant deadline);
List<ShadowQueueFact> readQueues(ShadowReadCursor cursor,
Instant deadline);
List<ShadowVehicleFact> readVehicles(Instant deadline);
List<ShadowMapFact> readMaps(Instant deadline);
}
public static final class JdbcFactSource implements FactSource {
static final String CLOCK_SQL =
"SELECT database_utc_time FROM shadow_ro.v_current_database_clock";
static final String QUEUE_SQL_PREFIX =
"SELECT TOP (?) guid, task_no, task_type, priority, "
+ "cargo_pick_site, cargo_place_site, start_site, end_site, "
+ "create_date_utc, agv_id, path_info, trun_path, default02, "
+ "default03, default04, default05, default06, default07, default08 ";
static final String QUEUE_SQL_SUFFIX =
" WHERE create_date_utc > ? "
+ "OR (create_date_utc = ? AND guid > ?) "
+ "ORDER BY create_date_utc, guid";
static final String ACTIVE_QUEUE_SQL_SUFFIX =
" ORDER BY create_date_utc, guid";
static final String VEHICLE_SQL =
"SELECT TOP (?) guid, device_id, agv_status, task_no, src_site, "
+ "tar_site, curr_site, task_type, battery, work_mode, alarm, "
+ "status, finish, charge_flag, work_phase, err_msg, "
+ "change_layer, cargo_flag, path, allow_send, curr_floor, "
+ "locked, spare2, spare5, last_time_utc "
+ "FROM shadow_ro.v_device_srgv ORDER BY device_id, guid";
static final String MAP_SQL =
"SELECT TOP (?) guid, site_id, x, y, z, value "
+ "FROM shadow_ro.v_wcs_srgv_map ORDER BY site_id, guid";
private final JdbcTemplate jdbc;
private final Clock clock;
public JdbcFactSource(JdbcTemplate jdbc, Clock clock) {
if (jdbc == null || clock == null) {
throw new IllegalArgumentException("jdbc and clock are required");
}
this.jdbc = jdbc;
this.clock = clock;
}
@Override
public Instant readDatabaseClock(Instant deadline) {
return jdbc.execute((ConnectionCallback<Instant>) connection -> {
try (PreparedStatement statement = connection.prepareStatement(
CLOCK_SQL)) {
timeout(statement, deadline);
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
throw new ShadowFactReadException(
"SOURCE_DATABASE_CLOCK_MISSING", Duration.ZERO);
}
return instant(result, "database_utc_time");
}
}
});
}
@Override
public List<ShadowQueueFact> readQueues(ShadowReadCursor cursor,
Instant deadline) {
List<ShadowQueueFact> result = queryQueues(
"shadow_ro.v_wcs_srgv_queue", null, QUEUE_LIMIT + 1,
deadline, ACTIVE_QUEUE_SQL_SUFFIX);
if (result.size() > QUEUE_LIMIT) {
return result;
}
int remainingWithOverflow = QUEUE_LIMIT - result.size() + 1;
result.addAll(queryQueues("shadow_ro.v_wcs_srgv_queue_finish",
cursor, remainingWithOverflow, deadline, QUEUE_SQL_SUFFIX));
return result;
}
@Override
public List<ShadowVehicleFact> readVehicles(Instant deadline) {
return jdbc.execute((ConnectionCallback<List<ShadowVehicleFact>>)
connection -> {
try (PreparedStatement statement = connection.prepareStatement(
VEHICLE_SQL)) {
statement.setInt(1, VEHICLE_LIMIT + 1);
timeout(statement, deadline);
try (ResultSet result = statement.executeQuery()) {
List<ShadowVehicleFact> facts =
new ArrayList<ShadowVehicleFact>();
while (result.next()) {
facts.add(ShadowVehicleFact.builder(
result.getString("guid"),
result.getString("device_id"))
.agvStatus(result.getString("agv_status"))
.taskNo(intValue(result, "task_no"))
.sourceSite(result.getString("src_site"))
.targetSite(result.getString("tar_site"))
.currentSite(result.getString("curr_site"))
.taskType(intValue(result, "task_type"))
.battery(intValue(result, "battery"))
.workMode(intValue(result, "work_mode"))
.alarm(intValue(result, "alarm"))
.status(intValue(result, "status"))
.finish(intValue(result, "finish"))
.chargeFlag(intValue(result, "charge_flag"))
.workPhase(intValue(result, "work_phase"))
.errorCode(intValue(result, "err_msg"))
.changeLayer(intValue(result, "change_layer"))
.cargoFlag(intValue(result, "cargo_flag"))
.path(result.getString("path"))
.allowSend(intValue(result, "allow_send"))
.currentFloor(intValue(result, "curr_floor"))
.locked(intValue(result, "locked"))
.spare2(result.getString("spare2"))
.spare5(result.getString("spare5"))
.capturedAt(instant(result, "last_time_utc"))
.build());
}
return facts;
}
}
});
}
@Override
public List<ShadowMapFact> readMaps(Instant deadline) {
return jdbc.execute((ConnectionCallback<List<ShadowMapFact>>)
connection -> {
try (PreparedStatement statement = connection.prepareStatement(
MAP_SQL)) {
statement.setInt(1, MAP_LIMIT + 1);
timeout(statement, deadline);
try (ResultSet result = statement.executeQuery()) {
List<ShadowMapFact> facts = new ArrayList<ShadowMapFact>();
while (result.next()) {
facts.add(new ShadowMapFact(result.getString("guid"),
result.getString("site_id"),
result.getInt("x"), result.getInt("y"),
result.getInt("z"), result.getInt("value")));
}
return facts;
}
}
});
}
private List<ShadowQueueFact> queryQueues(String table,
ShadowReadCursor cursor,
int limit,
Instant deadline,
String suffix) {
String sql = QUEUE_SQL_PREFIX + "FROM " + table + suffix;
return jdbc.execute((ConnectionCallback<List<ShadowQueueFact>>)
connection -> {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, limit);
if (cursor != null) {
Timestamp timestamp = Timestamp.from(
cursor.getLastCreateDate());
statement.setTimestamp(2, timestamp, utcCalendar());
statement.setTimestamp(3, timestamp, utcCalendar());
statement.setString(4, cursor.getLastGuid());
}
timeout(statement, deadline);
try (ResultSet result = statement.executeQuery()) {
List<ShadowQueueFact> facts =
new ArrayList<ShadowQueueFact>();
while (result.next()) {
facts.add(ShadowQueueFact.builder(table,
result.getString("guid"))
.taskNo(result.getString("task_no"))
.taskType(result.getString("task_type"))
.priority(longValue(result, "priority"))
.cargoPickSite(result.getString("cargo_pick_site"))
.cargoPlaceSite(result.getString("cargo_place_site"))
.startSite(result.getString("start_site"))
.endSite(result.getString("end_site"))
.createdAt(instant(result, "create_date_utc"))
.agvId(result.getString("agv_id"))
.pathInfo(result.getString("path_info"))
.turnPath(result.getString("trun_path"))
.default02(result.getString("default02"))
.default03(result.getString("default03"))
.default04(result.getString("default04"))
.default05(result.getString("default05"))
.default06(result.getString("default06"))
.default07(result.getString("default07"))
.default08(result.getString("default08"))
.build());
}
return facts;
}
}
});
}
private void timeout(PreparedStatement statement, Instant deadline)
throws SQLException {
statement.setQueryTimeout(queryTimeoutSeconds(clock.instant(), deadline));
}
private static Instant instant(ResultSet result, String column)
throws SQLException {
Timestamp timestamp = result.getTimestamp(column, utcCalendar());
return timestamp == null ? null : timestamp.toInstant();
}
private static Calendar utcCalendar() {
return Calendar.getInstance(TimeZone.getTimeZone("UTC"));
}
private static Long longValue(ResultSet result, String column)
throws SQLException {
long value = result.getLong(column);
return result.wasNull() ? null : value;
}
private static Integer intValue(ResultSet result, String column)
throws SQLException {
int value = result.getInt(column);
return result.wasNull() ? null : value;
}
}
}

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

View File

@@ -1,4 +1,5 @@
:setvar ShadowSourceDatabase "FT546-wcs"
:setvar ShadowSourceUtcOffset "+08:00"
SET NOCOUNT ON;
SET XACT_ABORT ON;
@@ -18,7 +19,11 @@ SELECT guid, task_no, task_type, priority, cargo, issued, path_info,
execution_status, cargo_pick_site, cargo_place_site, start_site,
end_site, the_floor, trun_path, up_down_site, create_date,
start_date, finish_date, agv_id, default02, default03, default04,
default05, default06, default07, default08
default05, default06, default07, default08,
CONVERT(datetime2(7), SWITCHOFFSET(TODATETIMEOFFSET(
TRY_CONVERT(datetime2(7), create_date, 120),
N'$(ShadowSourceUtcOffset)'), '+00:00'))
AS create_date_utc
FROM dbo.wcs_srgv_queue;
GO
@@ -28,7 +33,11 @@ SELECT guid, task_no, task_type, priority, cargo, issued, path_info,
execution_status, cargo_pick_site, cargo_place_site, start_site,
end_site, the_floor, trun_path, up_down_site, create_date,
start_date, finish_date, agv_id, default02, default03, default04,
default05, default06, default07, default08
default05, default06, default07, default08,
CONVERT(datetime2(7), SWITCHOFFSET(TODATETIMEOFFSET(
TRY_CONVERT(datetime2(7), create_date, 120),
N'$(ShadowSourceUtcOffset)'), '+00:00'))
AS create_date_utc
FROM dbo.wcs_srgv_queue_finish;
GO
@@ -39,7 +48,11 @@ SELECT guid, device_id, device_name, type, set_floor, power_time,
task_type, battery, work_mode, alarm, status, finish, charge_flag,
work_phase, err_msg, ssj_request, change_layer, cargo_flag,
manual_charge, path, allow_send, curr_floor, offline_site, locked,
spare1, spare2, spare3, spare4, spare5
spare1, spare2, spare3, spare4, spare5,
CONVERT(datetime2(7), SWITCHOFFSET(TODATETIMEOFFSET(
TRY_CONVERT(datetime2(7), last_time, 120),
N'$(ShadowSourceUtcOffset)'), '+00:00'))
AS last_time_utc
FROM dbo.device_srgv;
GO
@@ -71,7 +84,7 @@ GO
CREATE OR ALTER VIEW shadow_ro.v_current_database_clock
AS
SELECT SYSUTCDATETIME() AS database_utc_time,
SELECT CONVERT(datetime2(7), SYSUTCDATETIME()) AS database_utc_time,
DB_NAME() AS database_name,
@@SERVERNAME AS server_name;
GO