feat(ft546): collect bounded read-only shadow facts
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user