fix(shadow): recover confirmed late evidence commits
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user