From 5ed39162ccae7c81406cb8958c46710905e1bdbe Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Thu, 3 Sep 2026 11:39:31 +0800 Subject: [PATCH] fix: clarify shadow refresh recovery contract --- .../task-2-report.md | 129 ++++++++++++++++++ .../shadow/ShadowSnapshotCoordinator.java | 9 +- .../shadow/ShadowSnapshotCoordinatorTest.java | 24 ++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 .superpowers/sdd/2026-09-03-ft546wms-shadow-v3/task-2-report.md diff --git a/.superpowers/sdd/2026-09-03-ft546wms-shadow-v3/task-2-report.md b/.superpowers/sdd/2026-09-03-ft546wms-shadow-v3/task-2-report.md new file mode 100644 index 0000000..e47f68e --- /dev/null +++ b/.superpowers/sdd/2026-09-03-ft546wms-shadow-v3/task-2-report.md @@ -0,0 +1,129 @@ +# Task 2 Implementation Report + +Date: 2026-09-03 +Task: Build the Lock-Free Snapshot Coordinator +Worktree: `D:/workSpace/PROJECT_SPACE/FT546/FT546MWS/worktrees/shadow-from-main-20260811` + +## What Changed + +Implemented the JVM-local, observation-only snapshot coordinator using one immutable `AtomicReference` and compare-and-set transitions. + +- Added immutable `PublishedShadowSnapshot` metadata with generation, warehouse, synchronization time, and source signature. +- Added `ShadowSkipReason` values for startup, busy, stale, warehouse, and concurrent outcomes. +- Added `ShadowSnapshotCoordinator` with startup, ready, busy, and unavailable phases; bounded freshness checks; warehouse normalization through `WarehouseIdNormalizer.equivalent`; single observation leasing; refresh tickets; stamped publication; failure recovery state; and separately idempotent Legacy activity and observation-lease cleanup. +- Added behavior tests for startup, one lease, busy admission, exact-once cleanup, concurrent publication invalidation, comparability invalidation, stale snapshots, warehouse mismatch, numeric warehouse equivalence, and failed-refresh recovery. +- No gateway, SQL, cross-instance, or later-task behavior was added. + +## Files Changed + +- `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/PublishedShadowSnapshot.java` +- `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSkipReason.java` +- `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinator.java` +- `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinatorTest.java` + +## TDD Evidence + +### RED + +The initial direct reactor command could not resolve locally built reactor dependencies: + +```text +D:/BDevTools/maven/apache-maven-3.9.11/bin/mvn.cmd -pl ruoyi-admin -Dtest=ShadowSnapshotCoordinatorTest test +``` + +It failed before compilation with missing `ruoyi-framework`, `ruoyi-quartz`, `ruoyi-generator`, and `fortucky-algorithm` artifacts. Following the brief, the same named test scope was rerun with the reactor fallback: + +```text +D:/BDevTools/maven/apache-maven-3.9.11/bin/mvn.cmd -pl ruoyi-admin -am -Dtest=ShadowSnapshotCoordinatorTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected RED output: + +```text +[ERROR] ...ShadowSnapshotCoordinatorTest.java:[108,13] cannot find symbol + symbol: class ShadowSnapshotCoordinator +[ERROR] ...ShadowSnapshotCoordinatorTest.java:[112,20] cannot find symbol + symbol: class PublishedShadowSnapshot +[ERROR] ...ShadowSnapshotCoordinatorTest.java:[116,33] cannot find symbol + symbol: class ShadowSnapshotCoordinator +[ERROR] ...ShadowSnapshotCoordinatorTest.java:[116,72] cannot find symbol + symbol: class PublishedShadowSnapshot +[INFO] ruoyi-admin ........................................ FAILURE +[INFO] BUILD FAILURE +``` + +The failure was the intended missing-production-type compilation failure; no test behavior ran before implementation. + +### GREEN + +After the minimum production implementation, the focused command was run twice: + +```text +D:/BDevTools/maven/apache-maven-3.9.11/bin/mvn.cmd -pl ruoyi-admin -am -Dtest=ShadowSnapshotCoordinatorTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Run 1: + +```text +[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 +[INFO] ruoyi-admin ........................................ SUCCESS +[INFO] BUILD SUCCESS +``` + +Run 2: + +```text +[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 +[INFO] ruoyi-admin ........................................ SUCCESS +[INFO] BUILD SUCCESS +``` + +Both runs completed without timing sleeps. + +## Full-Suite Evidence + +Before committing, the relevant reactor suite was run once: + +```text +D:/BDevTools/maven/apache-maven-3.9.11/bin/mvn.cmd -pl ruoyi-admin -am -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Result: + +```text +fortucky-algorithm: Tests run: 122, Failures: 0, Errors: 0, Skipped: 0 +ruoyi-admin: Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +Total: 148 tests, 0 failures, 0 errors, 0 skipped. The output included existing Lombok equals/hashCode and deprecation warnings in unrelated sources; none were introduced by this task. + +## Self-Review + +- Confirmed the staged change contained exactly the four brief-listed implementation/test files. +- `git diff --cached --check` completed cleanly. +- Confirmed the coordinator has one `AtomicReference` and CAS loops for admission, exit, lease cleanup, refresh begin, publication, and refresh failure. +- Confirmed `exit` and `finishObservation` use separate per-admission `AtomicBoolean` guards. +- Confirmed caller admission performs only bounded in-memory work and atomic transitions; there is no synchronized block, lock, wait, sleep, database call, gateway call, or SQL behavior. +- Confirmed the publication CAS uses the refresh ticket's full stamped `State` object, so activity or any intervening state replacement invalidates publication even when generation values match. +- Confirmed numeric warehouse equivalence delegates to the Task 1 normalizer and freshness defaults to 60 seconds. +- No subagents or external reviewers were dispatched, per request. + +## Commit + +`c6a8b4f feat: coordinate stable shadow snapshots` + +## Concerns + +- The coordinator intentionally does not wait for the configured quiet interval; it records `quietSince`, while the later background worker owns waiting and refresh scheduling. +- Gateway integration, SQL refresh/publication, metrics, and cross-instance coordination remain later tasks by design. +- The report is a standalone delivery artifact at the requested path; the feature commit contains the four files listed in the brief. + +## Fix Round 1 + +- Changed `ShadowSnapshotCoordinator` to accept an injectable `java.time.Clock`; `exit` now records `clock.instant()` instead of `Instant.now()`. +- Added tests proving stale/warehouse skips transition to `BUSY` and recover via refresh, plus deterministic quiet-since timing. +- RED: focused reactor command initially failed in this shell because `mvn` resolved an incompatible Maven/plugin environment; the corrected Maven 3.9.11 command then compiled and exercised the new tests. +- GREEN: `D:/BDevTools/maven/apache-maven-3.9.11/bin/mvn.cmd -pl ruoyi-admin -am -Dtest=ShadowSnapshotCoordinatorTest '-Dsurefire.failIfNoSpecifiedTests=false' test` passed twice; 7 tests, 0 failures, 0 errors each run. +- Commit: pending. +- Concerns: default constructors retain `Clock.systemUTC()` behavior; no gateway, SQL, or Legacy behavior changed. diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinator.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinator.java index 400a41d..d462bbb 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinator.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinator.java @@ -2,6 +2,7 @@ package com.ruoyi.algorithm.shadow; import java.time.Duration; import java.time.Instant; +import java.time.Clock; import java.util.Objects; import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; @@ -17,6 +18,7 @@ public final class ShadowSnapshotCoordinator { private final String configuredWarehouseId; private final Duration snapshotMaxAge; + private final Clock clock; private final AtomicReference state = new AtomicReference<>( new State(Phase.INITIALIZING, 0L, 0, null, null, null, null)); @@ -25,8 +27,13 @@ public final class ShadowSnapshotCoordinator { } public ShadowSnapshotCoordinator(String configuredWarehouseId, Duration snapshotMaxAge) { + this(configuredWarehouseId, snapshotMaxAge, Clock.systemUTC()); + } + + public ShadowSnapshotCoordinator(String configuredWarehouseId, Duration snapshotMaxAge, Clock clock) { this.configuredWarehouseId = Objects.requireNonNull(configuredWarehouseId, "configuredWarehouseId").trim(); this.snapshotMaxAge = Objects.requireNonNull(snapshotMaxAge, "snapshotMaxAge"); + this.clock = Objects.requireNonNull(clock, "clock"); if (snapshotMaxAge.isNegative() || snapshotMaxAge.isZero()) { throw new IllegalArgumentException("snapshotMaxAge must be positive"); } @@ -85,7 +92,7 @@ public final class ShadowSnapshotCoordinator { return; } int nextActiveCount = current.activeLegacyCount() - 1; - Instant nextQuietSince = nextActiveCount == 0 ? Instant.now() : current.quietSince(); + Instant nextQuietSince = nextActiveCount == 0 ? clock.instant() : current.quietSince(); State next = new State(current.phase(), current.epoch(), nextActiveCount, current.published(), current.leasedEpoch(), nextQuietSince, current.lastFailure()); if (state.compareAndSet(current, next)) { diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinatorTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinatorTest.java index 61801a3..e840a77 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinatorTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinatorTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test; import java.time.Duration; import java.time.Instant; +import java.time.ZoneOffset; +import java.time.Clock; import static org.assertj.core.api.Assertions.assertThat; @@ -75,9 +77,31 @@ class ShadowSnapshotCoordinatorTest { ShadowSnapshotCoordinator.Admission wrongWarehouse = coordinator.enter(NOW); assertThat(wrongWarehouse.accepted()).isFalse(); assertThat(wrongWarehouse.skipReason()).isEqualTo(ShadowSkipReason.WAREHOUSE); + assertThat(coordinator.phase()).isEqualTo(ShadowSnapshotCoordinator.Phase.BUSY); coordinator.exit(wrongWarehouse); } + @Test + void staleOrMismatchedEntryRecoversThroughRefresh() { + ShadowSnapshotCoordinator coordinator = coordinatorAt("01"); + publish(coordinator, snapshot(7, "02", NOW)); + + ShadowSnapshotCoordinator.Admission skipped = coordinator.enter(NOW); + coordinator.exit(skipped); + assertThat(coordinator.phase()).isEqualTo(ShadowSnapshotCoordinator.Phase.BUSY); + assertThat(coordinator.tryBeginRefresh(NOW)).isPresent(); + } + + @Test + void exitUsesInjectedClockForQuietSince() { + Instant exitTime = NOW.plusSeconds(17); + ShadowSnapshotCoordinator coordinator = new ShadowSnapshotCoordinator( + "01", Duration.ofSeconds(60), Clock.fixed(exitTime, ZoneOffset.UTC)); + ShadowSnapshotCoordinator.Admission admission = coordinator.enter(NOW); + coordinator.exit(admission); + assertThat(coordinator.quietSince()).isEqualTo(exitTime); + } + @Test void treatsNumericWarehouseIdsAsEquivalent() { ShadowSnapshotCoordinator coordinator = coordinatorAt("01");