fix: clarify shadow refresh recovery contract

This commit is contained in:
zgw
2026-09-03 11:39:31 +08:00
parent c6a8b4f3b2
commit 5ed39162cc
3 changed files with 161 additions and 1 deletions

View File

@@ -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<State>` 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<State>` 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.

View File

@@ -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> 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)) {

View File

@@ -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");