fix(shadow): recover confirmed late evidence commits

This commit is contained in:
zgw
2026-09-14 09:57:57 +08:00
parent 0d53f41f1a
commit 5d5668b04e
11 changed files with 549 additions and 23 deletions

View File

@@ -31,6 +31,12 @@ public class ShadowEvidenceConfiguration {
config.setMinimumIdle(0);
config.setConnectionTimeout(2_000L);
config.setInitializationFailTimeout(-1L);
if (config.getJdbcUrl() != null && config.getJdbcUrl().startsWith("jdbc:sqlserver:")) {
// Statement timeouts do not bound commit I/O. Allow a late acknowledgement,
// but never leave a socket or query cancellation waiting indefinitely.
config.addDataSourceProperty("socketTimeout", "15000");
config.addDataSourceProperty("cancelQueryTimeout", "1");
}
return ShadowSourceConfiguration.lazyDataSource(config);
}

View File

@@ -131,6 +131,8 @@ public final class Ft546ShadowCycle {
return result;
} catch (RuntimeException evidenceFailure) {
invalidatePendingComparisons();
statusReport.recordCollectionFailure(failureReason(evidenceFailure),
"EVIDENCE_TRANSACTION", evidenceFailure, clock.instant(), null);
return degrade(failureReason(evidenceFailure), clock.instant());
}
}

View File

@@ -31,6 +31,7 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
private static final Duration CYCLE_DEADLINE = Duration.ofSeconds(10);
private static final Duration SOURCE_READ_DEADLINE = Duration.ofSeconds(4);
private static final Duration CRASH_BACKOFF = Duration.ofSeconds(1);
private static final Duration COMMIT_RECOVERY_WINDOW = Duration.ofSeconds(30);
private static final String FINISH_QUEUE_VIEW =
"shadow_ro.v_wcs_srgv_queue_finish";
@@ -52,6 +53,9 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
private volatile Instant nextAttemptAt = Instant.EPOCH;
private volatile Thread cycleWorker;
private long lastTimingLogNanos;
private long lastStoppedWarningNanos;
private boolean runtimeInitialized;
private volatile PendingCommit pendingCommit;
public Ft546ShadowScheduler(SqlServerShadowFactReader factReader,
Ft546ShadowCycle shadowCycle,
@@ -107,6 +111,7 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
fixedDelayString = "${shadow.poll.fixed-delay-ms:5000}")
public void runScheduledCycle() {
if (permanentlyStopped.get() || clock.instant().isBefore(nextAttemptAt)) {
warnIfStopped();
publishRuntimeState();
} else {
runOnce();
@@ -116,16 +121,26 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
public boolean runOnce() {
long startedNanos = System.nanoTime();
Instant now = clock.instant();
if (permanentlyStopped.get() || now.isBefore(nextAttemptAt)
if (closed.get() || permanentlyStopped.get() || now.isBefore(nextAttemptAt)
|| !inFlight.compareAndSet(false, true)) {
return false;
}
if (pendingCommit != null) {
try {
return reconcilePendingCommit();
} finally {
try { publishRuntimeState(); } finally { inFlight.set(false); }
}
}
ShadowCycleDiagnostics diagnostics = new ShadowCycleDiagnostics();
lastAttemptAt = now;
diagnostics.enter("RUNTIME_PUBLICATION");
if (!publishRuntimeState()) {
inFlight.set(false);
return false;
if (!runtimeInitialized) {
diagnostics.enter("RUNTIME_PUBLICATION");
if (!publishRuntimeState()) {
inFlight.set(false);
return false;
}
runtimeInitialized = true;
}
cycleWorker = null;
String outcome = "FAILED";
@@ -154,6 +169,19 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
return true;
} catch (TimeoutException timeout) {
CycleStage timedOutStage = activeStage;
Map<String, Object> snapshot = diagnostics.snapshot("TIMEOUT", cycleWorker);
String phase = String.valueOf(snapshot.get("phase"));
if (future != null && ("TRANSACTION_COMMIT".equals(phase) || "RECORDING".equals(phase))) {
// Retain the future. cancel(true) loses the terminal result and can itself
// block inside JDBC cancellation. No new batch starts while this is pending.
pendingCommit = new PendingCommit(future, diagnostics, clock.instant());
statusReport.recordCycleDiagnostics(snapshot);
statusReport.awaitCommitCompletion(clock.instant());
statusReport.recordCollectionFailure("SHADOW_COMMIT_AWAITING_COMPLETION", phase,
timeout, clock.instant(), null);
LOGGER.warn("FT546 shadow commit exceeded cycle budget; awaiting worker completion; diagnostics={}", snapshot);
return false;
}
statusReport.recordCycleDiagnostics(diagnostics.cancel("TIMEOUT", cycleWorker));
if (future != null) future.cancel(true);
return stopPermanently("SHADOW_CYCLE_TIMEOUT", timeout,
@@ -171,17 +199,73 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
} catch (java.util.concurrent.RejectedExecutionException shutdown) {
return stopPermanently("SHADOW_EXECUTOR_STOPPED", shutdown, activeStage);
} finally {
Map<String, Object> completed = diagnostics.finish(outcome, null);
statusReport.recordCycleDiagnostics(completed);
long logTime = System.nanoTime();
if (!permanentlyStopped.get() && (lastTimingLogNanos == 0L
|| logTime - lastTimingLogNanos >= TimeUnit.MINUTES.toNanos(1))) {
LOGGER.info("FT546 shadow cycle diagnostics: {}", completed);
lastTimingLogNanos = logTime;
if (pendingCommit == null) {
Map<String, Object> completed = diagnostics.finish(outcome, null);
statusReport.recordCycleDiagnostics(completed);
long logTime = System.nanoTime();
if (!permanentlyStopped.get() && (lastTimingLogNanos == 0L
|| logTime - lastTimingLogNanos >= TimeUnit.MINUTES.toNanos(1))) {
LOGGER.info("FT546 shadow cycle diagnostics: {}", completed);
lastTimingLogNanos = logTime;
}
activeStage = CycleStage.IDLE;
}
inFlight.set(false);
try { publishRuntimeState(); } finally { inFlight.set(false); }
}
}
private boolean reconcilePendingCommit() {
PendingCommit pending = pendingCommit;
if (!pending.future.isDone()) {
if (pending.expired(clock.instant())) {
statusReport.recordCycleDiagnostics(pending.diagnostics.cancel("COMMIT_UNCONFIRMED", cycleWorker));
return stopPermanently("SHADOW_COMMIT_COMPLETION_UNCONFIRMED",
new TimeoutException("worker did not finish within commit recovery window"), CycleStage.SHADOW_CYCLE);
}
statusReport.recordCycleDiagnostics(pending.diagnostics.snapshot("AWAITING_COMMIT", cycleWorker));
return false;
}
try {
CycleAttempt attempt = pending.future.get();
pendingCommit = null;
activeStage = CycleStage.IDLE;
publishRuntimeState();
if (closed.get() || permanentlyStopped.get()) return false;
if ("DEGRADED".equals(attempt.result.getStatusCode())) {
permanentlyStopped.set(true);
statusReport.markPermanentlyDegraded(attempt.result.getReasonCode(), clock.instant());
statusReport.recordCycleDiagnostics(pending.diagnostics.finish("COMMIT_FAILED", null));
LOGGER.error("FT546 shadow late commit did not complete successfully; collection stopped: {}",
attempt.result.getReasonCode());
return false;
}
// The future has completed normally, including JDBC cleanup and after-commit
// callbacks. Do not repeat the transaction or compare across this delayed gap.
shadowCycle.invalidatePendingComparisons();
cursor = advanceFinishCursor(cursor, attempt.batch);
Long lag = attempt.batch.getDatabaseCapturedAt() == null ? null : Long.valueOf(Math.max(0L,
Duration.between(attempt.batch.getDatabaseCapturedAt(), attempt.completedAt).toMillis()));
statusReport.confirmCommitCompletion(attempt.completedAt, lag);
statusReport.recordCycleDiagnostics(pending.diagnostics.finish("COMMIT_CONFIRMED", null));
nextAttemptAt = Instant.EPOCH;
LOGGER.info("FT546 shadow late commit confirmed; collection can resume from the next batch; completedAt={}",
attempt.completedAt);
return true;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return stopPermanently("SHADOW_CYCLE_INTERRUPTED", interrupted, CycleStage.SHADOW_CYCLE);
} catch (ExecutionException | java.util.concurrent.CancellationException failure) {
statusReport.recordCycleDiagnostics(pending.diagnostics.finish("COMMIT_FAILED", null));
return stopPermanently("SHADOW_COMMIT_COMPLETION_FAILED", failure, CycleStage.SHADOW_CYCLE);
}
}
private void warnIfStopped() {
if (!permanentlyStopped.get() || closed.get()) return;
long now = System.nanoTime();
if (lastStoppedWarningNanos == 0L || now - lastStoppedWarningNanos >= TimeUnit.MINUTES.toNanos(1)) {
LOGGER.warn("FT546 shadow collection remains stopped; reason={}; lastCompletedCycle={}; heartbeat is not collection",
statusReport.getReasonCode(), statusReport.getLastCompletedCycle());
lastStoppedWarningNanos = now;
}
}
@@ -199,7 +283,13 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
if (!closed.compareAndSet(false, true)) return;
permanentlyStopped.set(true);
statusReport.markStopped(clock.instant());
cycleExecutor.shutdownNow();
PendingCommit pending = pendingCommit;
if (pending != null) {
pending.diagnostics.cancel("STOPPED", cycleWorker);
cycleExecutor.shutdown();
} else {
cycleExecutor.shutdownNow();
}
publishRuntimeState();
}
@@ -226,8 +316,8 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
if (sourceDeadline.isAfter(overallDeadline)) sourceDeadline = overallDeadline;
ShadowFactBatch batch = factReader.read(cursor, sourceDeadline);
activeStage = CycleStage.SHADOW_CYCLE;
return new CycleAttempt(batch,
shadowCycle.run(batch, clock.instant(), diagnostics));
ShadowCycleResult result = shadowCycle.run(batch, clock.instant(), diagnostics);
return new CycleAttempt(batch, result, clock.instant());
}
private boolean handleFailure(Throwable failure) {
@@ -353,10 +443,29 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
private static final class CycleAttempt {
private final ShadowFactBatch batch;
private final ShadowCycleResult result;
private final Instant completedAt;
private CycleAttempt(ShadowFactBatch batch, ShadowCycleResult result) {
private CycleAttempt(ShadowFactBatch batch, ShadowCycleResult result, Instant completedAt) {
this.batch = batch;
this.result = result;
this.completedAt = completedAt;
}
}
private static final class PendingCommit {
private final Future<CycleAttempt> future;
private final ShadowCycleDiagnostics diagnostics;
private final Instant deadline;
private final long startedNanos = System.nanoTime();
private PendingCommit(Future<CycleAttempt> future, ShadowCycleDiagnostics diagnostics, Instant startedAt) {
this.future = future;
this.diagnostics = diagnostics;
this.deadline = startedAt.plus(COMMIT_RECOVERY_WINDOW);
}
private boolean expired(Instant now) {
return !now.isBefore(deadline) || System.nanoTime() - startedNanos >= COMMIT_RECOVERY_WINDOW.toNanos();
}
}
}

View File

@@ -77,9 +77,17 @@ final class ShadowCycleDiagnostics {
}
synchronized Map<String, Object> finish(String outcome, Thread worker) {
if (completed != null) return completed;
completed = snapshot(outcome, worker);
return completed;
}
/** Observe an over-budget commit without cancelling its completion bookkeeping. */
synchronized Map<String, Object> snapshot(String outcome, Thread worker) {
if (completed != null) return completed;
long now = ticker.getAsLong();
accumulate(now);
phaseStarted = now;
Map<String, Object> snapshot = new LinkedHashMap<String, Object>();
snapshot.put("outcome", outcome);
snapshot.put("phase", phase);
@@ -95,8 +103,7 @@ final class ShadowCycleDiagnostics {
for (int i = 0; i < Math.min(16, frames.length); i++) stack.add(frames[i].toString());
snapshot.put("workerStack", Collections.unmodifiableList(stack));
}
completed = Collections.unmodifiableMap(snapshot);
return completed;
return Collections.unmodifiableMap(snapshot);
}
synchronized Map<String, Object> cancel(String outcome, Thread worker) {

View File

@@ -14,7 +14,7 @@ import java.util.TreeMap;
/** Identifies this source revision and the nonsecret dispatch/storage settings, not a built artifact. */
public final class ShadowRuntimeIdentity {
public static final String BUILD_ID = "ft546-shadow-20260910-source-recovery-v1";
public static final String BUILD_ID = "ft546-shadow-20260914-commit-recovery-v1";
private ShadowRuntimeIdentity() { }
public static Map<String, Object> describe(ShadowProperties properties, Environment env) {

View File

@@ -15,6 +15,7 @@ public final class ShadowStartupReport {
private volatile long evidenceBacklog;
private volatile String reasonCode;
private volatile String permanentDegradedReason;
private volatile boolean awaitingCommitCompletion;
private final Map<String, String> startupChecks;
private volatile Map<String, Object> cycleDiagnostics = Collections.emptyMap();
private volatile Map<String, Object> runtimeIdentity = Collections.emptyMap();
@@ -50,6 +51,21 @@ public final class ShadowStartupReport {
public Map<String, Object> getCycleDiagnostics() { return cycleDiagnostics; }
public Map<String, Object> getRuntimeIdentity() { return runtimeIdentity; }
public Map<String, Object> getLastCollectionFailure() { return lastCollectionFailure; }
public boolean isCollectionStopped() {
return permanentDegradedReason != null || status == Status.STOPPED;
}
public boolean isAwaitingCommitCompletion() { return awaitingCommitCompletion; }
public synchronized void awaitCommitCompletion(Instant checkedAt) {
if (isCollectionStopped()) return;
awaitingCommitCompletion = true;
markDegraded("SHADOW_COMMIT_AWAITING_COMPLETION", checkedAt);
}
public synchronized void confirmCommitCompletion(Instant completedAt, Long lagMillis) {
awaitingCommitCompletion = false;
markReady(completedAt, lagMillis);
}
public void recordCollectionFailure(String reason, String stage, Throwable failure,
Instant occurredAt, Instant nextAttemptAt) {
lastCollectionFailure = ShadowCollectionFailure.describe(reason, stage, failure, occurredAt, nextAttemptAt);
@@ -64,7 +80,7 @@ public final class ShadowStartupReport {
public synchronized void markReady(Instant completedAt,
Long sourceLagMillis) {
if (permanentDegradedReason != null || status == Status.STOPPED) {
if (permanentDegradedReason != null || status == Status.STOPPED || awaitingCommitCompletion) {
return;
}
this.status = Status.READY;
@@ -95,10 +111,12 @@ public final class ShadowStartupReport {
}
this.status = Status.DEGRADED;
this.reasonCode = permanentDegradedReason;
awaitingCommitCompletion = false;
}
public synchronized void markStopped(Instant stoppedAt) {
status = Status.STOPPED;
awaitingCommitCompletion = false;
checkedAt = stoppedAt;
if (reasonCode == null) reasonCode = "SHADOW_APPLICATION_STOPPED";
}
@@ -116,6 +134,7 @@ public final class ShadowStartupReport {
state.put("reasonCode", reasonCode);
state.put("collectionStopped", collectionStopped || status == Status.STOPPED
|| permanentDegradedReason != null);
state.put("awaitingCommitCompletion", awaitingCommitCompletion);
state.put("sourceLagMillis", sourceLagMillis);
state.put("cycleDiagnostics", cycleDiagnostics);
state.put("runtimeIdentity", runtimeIdentity);

View File

@@ -139,7 +139,9 @@ class Ft546ShadowApplicationTest {
.andExpect(jsonPath("$.status").value("STARTING"))
.andExpect(jsonPath("$.checkedAt").exists())
.andExpect(jsonPath("$.evidenceBacklog").value(0))
.andExpect(jsonPath("$.runtimeIdentity.buildId").value("ft546-shadow-20260910-source-recovery-v1"))
.andExpect(jsonPath("$.runtimeIdentity.buildId").value("ft546-shadow-20260914-commit-recovery-v1"))
.andExpect(jsonPath("$.collectionStopped").value(false))
.andExpect(jsonPath("$.awaitingCommitCompletion").value(false))
.andExpect(jsonPath("$.runtimeIdentity.effectiveSettingsSha256").exists())
.andExpect(jsonPath("$.runtimeIdentity.safePointConfiguration").value("UNCONFIGURED"))
.andExpect(jsonPath("$.startupChecks.SHADOW_ENABLED")

View File

@@ -0,0 +1,260 @@
package com.ruoyi.shadow;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.shadow.evidence.*;
import com.ruoyi.shadow.facts.*;
import com.ruoyi.shadow.kernel.*;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.TransactionSystemException;
import org.springframework.transaction.support.DefaultTransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.*;
import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.*;
class ShadowCommitRecoveryTest {
private static final Instant NOW = Instant.parse("2026-09-14T02:00:00Z");
@Test
void confirmedLateCommitResumesWithoutDuplicatingTheTransaction() throws Exception {
try (Fixture f = new Fixture(CommitResult.SUCCESS)) {
assertFalse(f.scheduler.runOnce());
assertTrue(f.repository.entered.await(0, TimeUnit.MILLISECONDS));
assertFalse(f.scheduler.isPermanentlyStopped(), "a pending commit must retain its result");
assertTrue(json(f.report).path("awaitingCommitCompletion").asBoolean());
assertFalse(json(f.report).path("collectionStopped").asBoolean());
assertEquals(ShadowStartupReport.Status.DEGRADED, f.report.getStatus());
assertEquals(0, f.repository.rows(), "uncommitted evidence must remain invisible");
for (int i = 0; i < 3; i++) assertFalse(f.scheduler.runOnce());
assertEquals(1, f.batches.get(), "waiting must not start another source read");
f.repository.release.countDown();
assertTrue(f.repository.finished.await(2, TimeUnit.SECONDS));
awaitResolution(f);
assertEquals(1, f.repository.rows(), "the late transaction must not be replayed");
assertEquals(1, f.batches.get());
assertEquals(ShadowStartupReport.Status.READY, f.report.getStatus());
assertFalse(json(f.report).path("awaitingCommitCompletion").asBoolean());
assertFalse(f.report.getLastCollectionFailure().isEmpty(), "keep timeout history after recovery");
assertTrue(f.scheduler.runOnce());
assertEquals(2, f.batches.get());
assertEquals(2, f.repository.rows());
}
}
@Test
void unconfirmedCommitOutcomeNeverTriggersAnotherBatch() throws Exception {
for (CommitResult outcome : Arrays.asList(CommitResult.ROLLBACK, CommitResult.ACK_LOST)) {
try (Fixture f = new Fixture(outcome)) {
assertFalse(f.scheduler.runOnce());
assertFalse(f.scheduler.isPermanentlyStopped());
f.repository.release.countDown();
assertTrue(f.repository.finished.await(2, TimeUnit.SECONDS));
awaitResolution(f);
assertTrue(f.scheduler.isPermanentlyStopped());
assertEquals("EVIDENCE_STORAGE_UNAVAILABLE", f.report.getReasonCode());
assertTrue(json(f.report).path("collectionStopped").asBoolean());
assertEquals(outcome == CommitResult.ACK_LOST ? 1 : 0, f.repository.rows());
assertFalse(f.scheduler.runOnce());
assertEquals(1, f.batches.get(), "a lost commit acknowledgement is not permission to retry");
}
}
}
@Test
void unfinishedCommitStopsAfterTheRecoveryWindowAndCannotRevive() throws Exception {
try (Fixture f = new Fixture(CommitResult.SUCCESS)) {
assertFalse(f.scheduler.runOnce());
assertFalse(f.scheduler.isPermanentlyStopped());
f.clock.advance(Duration.ofSeconds(31));
assertFalse(f.scheduler.runOnce());
assertTrue(f.scheduler.isPermanentlyStopped());
assertEquals("SHADOW_COMMIT_COMPLETION_UNCONFIRMED", f.report.getReasonCode());
f.repository.release.countDown();
assertTrue(f.repository.finished.await(2, TimeUnit.SECONDS));
f.report.markReady(f.clock.instant(), 0L);
assertEquals(ShadowStartupReport.Status.DEGRADED, f.report.getStatus());
assertFalse(f.scheduler.runOnce());
assertEquals(1, f.batches.get());
}
}
@Test
void closingWhileCommitIsPendingDoesNotPublishReadyLater() throws Exception {
try (Fixture f = new Fixture(CommitResult.SUCCESS)) {
assertFalse(f.scheduler.runOnce());
assertTrue(json(f.report).path("awaitingCommitCompletion").asBoolean());
f.scheduler.close();
f.repository.release.countDown();
assertTrue(f.repository.finished.await(2, TimeUnit.SECONDS));
f.report.markReady(NOW.plusSeconds(10), 0L);
assertEquals(ShadowStartupReport.Status.STOPPED, f.report.getStatus());
assertTrue(json(f.report).path("collectionStopped").asBoolean());
assertFalse(f.scheduler.runOnce());
assertEquals(1, f.batches.get());
}
}
@Test
void heartbeatFailureDuringPendingCommitPreventsLaterRecovery() throws Exception {
try (Fixture f = new Fixture(CommitResult.SUCCESS)) {
f.failPendingHeartbeat = true;
assertFalse(f.scheduler.runOnce());
assertTrue(f.scheduler.isPermanentlyStopped());
assertEquals("EVIDENCE_RUNTIME_WRITE_FAILED", f.report.getReasonCode());
f.repository.release.countDown();
assertTrue(f.repository.finished.await(2, TimeUnit.SECONDS));
assertFalse(f.scheduler.runOnce());
assertEquals(1, f.batches.get());
}
}
@Test
void healthyPollsPublishOnceAfterEachCycleFollowingTheStartupCheck() {
try (Fixture f = new Fixture(CommitResult.SUCCESS)) {
f.repository.release.countDown();
assertTrue(f.scheduler.runOnce());
assertTrue(f.scheduler.runOnce());
assertEquals(3, f.heartbeats.size(), "one initial check plus one completion heartbeat per poll");
assertEquals("STARTING", f.heartbeats.get(0).get("status"));
assertEquals("READY", f.heartbeats.get(2).get("status"));
assertEquals(2, f.repository.rows());
}
}
private static com.fasterxml.jackson.databind.JsonNode json(Object value) {
return new ObjectMapper().findAndRegisterModules().valueToTree(value);
}
private static void awaitResolution(Fixture fixture) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
while (System.nanoTime() < deadline) {
if (fixture.scheduler.runOnce() || fixture.scheduler.isPermanentlyStopped()) return;
Thread.sleep(5L);
}
fail("the completed worker was not reconciled");
}
private enum CommitResult { SUCCESS, ROLLBACK, ACK_LOST }
private static final class Fixture implements AutoCloseable {
final MutableClock clock = new MutableClock();
final AtomicInteger batches = new AtomicInteger();
final ShadowStartupReport report = ShadowStartupReport.starting(NOW, Collections.emptyMap());
final List<Map<String, Object>> heartbeats = new ArrayList<Map<String, Object>>();
final CommitRepository repository;
final Ft546ShadowScheduler scheduler;
boolean failPendingHeartbeat;
Fixture(CommitResult result) {
repository = new CommitRepository(result);
DriverManagerDataSource source = new DriverManagerDataSource(
"jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
SqlServerShadowFactReader reader = new SqlServerShadowFactReader(new SqlServerShadowFactReader.FactSource() {
public Instant readDatabaseClock(Instant deadline) { return clock.instant(); }
public List<ShadowQueueFact> readQueues(ShadowReadCursor cursor, Instant deadline) {
batches.incrementAndGet(); return Collections.emptyList();
}
public List<ShadowVehicleFact> readVehicles(Instant deadline) { return Collections.emptyList(); }
public List<ShadowMapFact> readMaps(Instant deadline) { return Collections.emptyList(); }
}, new TransactionTemplate(new DataSourceTransactionManager(source)), clock);
Ft546DeviceProfile profile = new Ft546DeviceProfile();
Map<String, String> registers = new LinkedHashMap<String, String>();
registers.put("taskNo", "4000"); registers.put("taskType", "4002");
registers.put("path", "4004"); registers.put("trigger", "4021");
profile.setRegisters(registers);
Set<String> capabilities = Collections.singleton("POINT_TO_POINT_COMMAND");
profile.setCapabilities(capabilities);
RecordingFt546CommandGateway gateway = new RecordingFt546CommandGateway(
new Ft546SrgvCommandEncoder(), profile, capabilities);
Ft546ShadowCycle cycle = new Ft546ShadowCycle(
new ShadowFactBatchValidator(Duration.ofSeconds(4), Duration.ofSeconds(30)),
batch -> { throw new AssertionError("missing vehicle facts must fail validation first"); },
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(snapshot -> DispatchCycleResult.retrySnapshotChanged(),
new ShadowCommandIntentFactory()::create), gateway,
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE), repository,
new ShadowCycleEvidenceCollector(UUID.randomUUID(), 1L, new ObjectMapper().findAndRegisterModules()),
report, clock);
scheduler = new Ft546ShadowScheduler(reader, cycle, report, clock, Duration.ofSeconds(1), state -> {
if (failPendingHeartbeat && Boolean.TRUE.equals(state.get("awaitingCommitCompletion"))) {
throw new IllegalStateException("heartbeat unavailable");
}
heartbeats.add(state);
});
}
public void close() { repository.release.countDown(); scheduler.close(); }
}
private static final class CommitRepository implements ShadowEvidenceRepository {
final CountDownLatch entered = new CountDownLatch(1);
final CountDownLatch release = new CountDownLatch(1);
final CountDownLatch finished = new CountDownLatch(1);
final JdbcTemplate jdbc;
final TransactionTemplate transaction;
CommitRepository(CommitResult outcome) {
DriverManagerDataSource data = new DriverManagerDataSource(
"jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(data);
jdbc.execute("CREATE TABLE committed_evidence (id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY)");
AtomicBoolean first = new AtomicBoolean(true);
transaction = new TransactionTemplate(new DataSourceTransactionManager(data) {
@Override protected void doCommit(DefaultTransactionStatus status) {
if (!first.compareAndSet(true, false)) { super.doCommit(status); return; }
entered.countDown();
boolean interrupted = false;
while (release.getCount() != 0) {
try { release.await(); } catch (InterruptedException ignored) { interrupted = true; }
}
try {
if (outcome == CommitResult.ROLLBACK) super.doRollback(status);
else super.doCommit(status);
if (outcome != CommitResult.SUCCESS) throw new TransactionSystemException("commit result unavailable");
} finally {
if (interrupted) Thread.currentThread().interrupt();
finished.countDown();
}
}
});
}
int rows() { return jdbc.queryForObject("SELECT COUNT(*) FROM committed_evidence", Integer.class); }
public <T> T inTransaction(Supplier<T> work) {
return transaction.execute(status -> { jdbc.update("INSERT INTO committed_evidence DEFAULT VALUES"); return work.get(); });
}
public SavedCycle save(ShadowCycleEvidence evidence) { return saveAll(Collections.singletonList(evidence)).get(0); }
public List<SavedCycle> saveAll(List<ShadowCycleEvidence> evidence) {
List<SavedCycle> result = new ArrayList<SavedCycle>();
for (ShadowCycleEvidence item : evidence) result.add(new SavedCycle(item.getCycleId(), item.getEvidenceHash(), item.getStatus(), item.getReasonCode()));
return result;
}
public SavedCycle findCycle(UUID runId, String hash, String task, String phase, long version) { return null; }
public ShadowEvidenceDigest dailyDigest(LocalDate date, ZoneId zone) { throw new UnsupportedOperationException(); }
public boolean isHealthy() { return true; }
}
private static final class MutableClock extends Clock {
private volatile Instant now = NOW;
void advance(Duration duration) { now = now.plus(duration); }
public ZoneId getZone() { return ZoneOffset.UTC; }
public Clock withZone(ZoneId zone) { return this; }
public Instant instant() { return now; }
}
}

View File

@@ -0,0 +1,30 @@
package com.ruoyi.shadow.config;
import com.microsoft.sqlserver.jdbc.SQLServerDriver;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.Test;
import java.sql.DriverPropertyInfo;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class ShadowEvidenceTimeoutTest {
@Test
void evidenceDriverHasFiniteIoAndCancellationLimitsEvenWhenUrlRequestsUnlimited() throws Exception {
ShadowProperties properties = new ShadowProperties();
properties.getEvidence().setJdbcUrl("jdbc:sqlserver://localhost;databaseName=shadow;socketTimeout=0;cancelQueryTimeout=-1");
try (HikariDataSource evidence = (HikariDataSource) new ShadowEvidenceConfiguration().shadowEvidenceDataSource(properties)) {
Map<String, String> effective = new HashMap<String, String>();
for (DriverPropertyInfo info : new SQLServerDriver().getPropertyInfo(
evidence.getJdbcUrl(), evidence.getDataSourceProperties())) effective.put(info.name, info.value);
int socketMillis = Integer.parseInt(effective.get("socketTimeout"));
int cancelSeconds = Integer.parseInt(effective.get("cancelQueryTimeout"));
assertTrue(socketMillis > 10_000 && socketMillis <= 15_000,
"commit I/O must have a finite bound with room to report a late successful commit");
assertTrue(cancelSeconds > 0 && cancelSeconds <= 2,
"query cancellation must not wait indefinitely after the statement deadline");
}
}
}

View File

@@ -27,6 +27,28 @@ class ShadowCycleDiagnosticsTest {
assertEquals(6L, phases.get("TRANSACTION_COMMIT"));
}
@Test
void observingPendingCommitAccumulatesTimeWithoutCancellingOrDoubleCounting() {
AtomicLong nanos = new AtomicLong();
ShadowCycleDiagnostics diagnostics = new ShadowCycleDiagnostics(nanos::get);
nanos.set(2_000_000L);
diagnostics.enter("TRANSACTION_COMMIT");
nanos.set(5_000_000L);
Map<String, Object> first = diagnostics.snapshot("AWAITING_COMMIT", null);
nanos.set(8_000_000L);
diagnostics.snapshot("AWAITING_COMMIT", null);
nanos.set(11_000_000L);
diagnostics.enter("RECORDING");
nanos.set(13_000_000L);
Map<String, Object> completed = diagnostics.finish("COMMIT_CONFIRMED", null);
Map<?, ?> phases = (Map<?, ?>) completed.get("phaseMillis");
assertEquals(13L, completed.get("elapsedMillis"));
assertEquals(2L, phases.get("QUEUED"));
assertEquals(9L, phases.get("TRANSACTION_COMMIT"));
assertEquals(2L, phases.get("RECORDING"));
assertEquals(3L, ((Map<?, ?>) first.get("phaseMillis")).get("TRANSACTION_COMMIT"));
}
@Test
void timeoutSnapshotIsFrozenAndLateWorkerCannotEnterPersistence() {
ShadowCycleDiagnostics diagnostics = new ShadowCycleDiagnostics();