feat: coordinate stable shadow snapshots
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Immutable metadata identifying one completed shadow read-model generation.
|
||||
*/
|
||||
public record PublishedShadowSnapshot(
|
||||
long generation,
|
||||
String warehouseId,
|
||||
Instant synchronizedAt,
|
||||
String sourceSignature) {
|
||||
|
||||
public PublishedShadowSnapshot {
|
||||
Objects.requireNonNull(warehouseId, "warehouseId");
|
||||
Objects.requireNonNull(synchronizedAt, "synchronizedAt");
|
||||
Objects.requireNonNull(sourceSignature, "sourceSignature");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
public enum ShadowSkipReason {
|
||||
NOT_READY,
|
||||
BUSY,
|
||||
STALE,
|
||||
WAREHOUSE,
|
||||
CONCURRENT
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Coordinates one JVM-local published snapshot without making the Legacy path wait.
|
||||
*/
|
||||
public final class ShadowSnapshotCoordinator {
|
||||
|
||||
private static final Duration DEFAULT_SNAPSHOT_MAX_AGE = Duration.ofSeconds(60);
|
||||
private static final long REFRESH_LEASE = Long.MIN_VALUE;
|
||||
|
||||
private final String configuredWarehouseId;
|
||||
private final Duration snapshotMaxAge;
|
||||
private final AtomicReference<State> state = new AtomicReference<>(
|
||||
new State(Phase.INITIALIZING, 0L, 0, null, null, null, null));
|
||||
|
||||
public ShadowSnapshotCoordinator(String configuredWarehouseId) {
|
||||
this(configuredWarehouseId, DEFAULT_SNAPSHOT_MAX_AGE);
|
||||
}
|
||||
|
||||
public ShadowSnapshotCoordinator(String configuredWarehouseId, Duration snapshotMaxAge) {
|
||||
this.configuredWarehouseId = Objects.requireNonNull(configuredWarehouseId, "configuredWarehouseId").trim();
|
||||
this.snapshotMaxAge = Objects.requireNonNull(snapshotMaxAge, "snapshotMaxAge");
|
||||
if (snapshotMaxAge.isNegative() || snapshotMaxAge.isZero()) {
|
||||
throw new IllegalArgumentException("snapshotMaxAge must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public enum Phase {
|
||||
INITIALIZING,
|
||||
READY,
|
||||
BUSY,
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
public Admission enter(Instant now) {
|
||||
Objects.requireNonNull(now, "now");
|
||||
while (true) {
|
||||
State current = state.get();
|
||||
long nextEpoch = current.epoch() + 1;
|
||||
int nextActiveCount = current.activeLegacyCount() + 1;
|
||||
boolean canLease = current.phase() == Phase.READY
|
||||
&& current.activeLegacyCount() == 0
|
||||
&& current.leasedEpoch() == null;
|
||||
PublishedShadowSnapshot snapshot = canLease ? current.published() : null;
|
||||
ShadowSkipReason skipReason = null;
|
||||
if (canLease && !isFresh(snapshot, now)) {
|
||||
skipReason = ShadowSkipReason.STALE;
|
||||
} else if (canLease && !WarehouseIdNormalizer.equivalent(
|
||||
configuredWarehouseId, snapshot.warehouseId())) {
|
||||
skipReason = ShadowSkipReason.WAREHOUSE;
|
||||
} else if (!canLease) {
|
||||
skipReason = skipReason(current);
|
||||
}
|
||||
|
||||
boolean accepted = skipReason == null;
|
||||
Phase nextPhase = current.phase() == Phase.READY ? Phase.BUSY : current.phase();
|
||||
Long nextLeasedEpoch = current.leasedEpoch();
|
||||
if (accepted) {
|
||||
nextLeasedEpoch = nextEpoch;
|
||||
}
|
||||
Instant nextQuietSince = current.activeLegacyCount() == 0 ? null : current.quietSince();
|
||||
State next = new State(nextPhase, nextEpoch, nextActiveCount, current.published(),
|
||||
nextLeasedEpoch, nextQuietSince, current.lastFailure());
|
||||
if (state.compareAndSet(current, next)) {
|
||||
return new Admission(this, accepted, skipReason, snapshot, nextEpoch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void exit(Admission admission) {
|
||||
if (admission == null || admission.owner != this
|
||||
|| !admission.exited.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
while (true) {
|
||||
State current = state.get();
|
||||
if (current.activeLegacyCount() == 0) {
|
||||
return;
|
||||
}
|
||||
int nextActiveCount = current.activeLegacyCount() - 1;
|
||||
Instant nextQuietSince = nextActiveCount == 0 ? Instant.now() : current.quietSince();
|
||||
State next = new State(current.phase(), current.epoch(), nextActiveCount, current.published(),
|
||||
current.leasedEpoch(), nextQuietSince, current.lastFailure());
|
||||
if (state.compareAndSet(current, next)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void finishObservation(Admission admission) {
|
||||
if (admission == null || admission.owner != this || !admission.accepted
|
||||
|| !admission.observationFinished.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
while (true) {
|
||||
State current = state.get();
|
||||
if (!Objects.equals(current.leasedEpoch(), admission.epoch)) {
|
||||
return;
|
||||
}
|
||||
State next = new State(current.phase(), current.epoch(), current.activeLegacyCount(),
|
||||
current.published(), null, current.quietSince(), current.lastFailure());
|
||||
if (state.compareAndSet(current, next)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<RefreshTicket> tryBeginRefresh(Instant now) {
|
||||
Objects.requireNonNull(now, "now");
|
||||
while (true) {
|
||||
State current = state.get();
|
||||
if (current.activeLegacyCount() != 0 || current.leasedEpoch() != null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
State next = new State(Phase.BUSY, current.epoch(), current.activeLegacyCount(), current.published(),
|
||||
REFRESH_LEASE, current.quietSince() == null ? now : current.quietSince(), current.lastFailure());
|
||||
if (state.compareAndSet(current, next)) {
|
||||
return Optional.of(new RefreshTicket(this, next));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean publish(RefreshTicket ticket, PublishedShadowSnapshot snapshot, Instant now) {
|
||||
if (ticket == null || ticket.owner != this || snapshot == null || now == null) {
|
||||
return false;
|
||||
}
|
||||
State expected = ticket.state;
|
||||
State next = new State(Phase.READY, expected.epoch(), expected.activeLegacyCount(), snapshot,
|
||||
null, expected.quietSince() == null ? now : expected.quietSince(), null);
|
||||
return state.compareAndSet(expected, next);
|
||||
}
|
||||
|
||||
public void failRefresh(RefreshTicket ticket, String failure) {
|
||||
if (ticket == null || ticket.owner != this) {
|
||||
return;
|
||||
}
|
||||
State expected = ticket.state;
|
||||
State next = new State(Phase.UNAVAILABLE, expected.epoch(), expected.activeLegacyCount(),
|
||||
expected.published(), null, expected.quietSince(), failure);
|
||||
state.compareAndSet(expected, next);
|
||||
}
|
||||
|
||||
public boolean isComparable(Admission admission) {
|
||||
if (admission == null || admission.owner != this || !admission.accepted
|
||||
|| admission.observationFinished.get()) {
|
||||
return false;
|
||||
}
|
||||
State current = state.get();
|
||||
return current.epoch() == admission.epoch
|
||||
&& current.leasedEpoch() != null
|
||||
&& current.leasedEpoch() == admission.epoch
|
||||
&& current.published() == admission.snapshot;
|
||||
}
|
||||
|
||||
public Phase phase() {
|
||||
return state.get().phase();
|
||||
}
|
||||
|
||||
public int activeLegacyCount() {
|
||||
return state.get().activeLegacyCount();
|
||||
}
|
||||
|
||||
public long epoch() {
|
||||
return state.get().epoch();
|
||||
}
|
||||
|
||||
public Instant quietSince() {
|
||||
return state.get().quietSince();
|
||||
}
|
||||
|
||||
public String lastFailure() {
|
||||
return state.get().lastFailure();
|
||||
}
|
||||
|
||||
public static final class Admission {
|
||||
private final ShadowSnapshotCoordinator owner;
|
||||
private final boolean accepted;
|
||||
private final ShadowSkipReason skipReason;
|
||||
private final PublishedShadowSnapshot snapshot;
|
||||
private final long epoch;
|
||||
private final AtomicBoolean exited = new AtomicBoolean();
|
||||
private final AtomicBoolean observationFinished = new AtomicBoolean();
|
||||
|
||||
private Admission(ShadowSnapshotCoordinator owner, boolean accepted, ShadowSkipReason skipReason,
|
||||
PublishedShadowSnapshot snapshot, long epoch) {
|
||||
this.owner = owner;
|
||||
this.accepted = accepted;
|
||||
this.skipReason = skipReason;
|
||||
this.snapshot = snapshot;
|
||||
this.epoch = epoch;
|
||||
}
|
||||
|
||||
public boolean accepted() {
|
||||
return accepted;
|
||||
}
|
||||
|
||||
public ShadowSkipReason skipReason() {
|
||||
return skipReason;
|
||||
}
|
||||
|
||||
public PublishedShadowSnapshot snapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class RefreshTicket {
|
||||
private final ShadowSnapshotCoordinator owner;
|
||||
private final State state;
|
||||
|
||||
private RefreshTicket(ShadowSnapshotCoordinator owner, State state) {
|
||||
this.owner = owner;
|
||||
this.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
private ShadowSkipReason skipReason(State current) {
|
||||
if (current.phase() != Phase.READY || current.leasedEpoch() != null || current.activeLegacyCount() != 0) {
|
||||
return current.phase() == Phase.INITIALIZING || current.phase() == Phase.UNAVAILABLE
|
||||
? ShadowSkipReason.NOT_READY : ShadowSkipReason.BUSY;
|
||||
}
|
||||
return ShadowSkipReason.NOT_READY;
|
||||
}
|
||||
|
||||
private boolean isFresh(PublishedShadowSnapshot snapshot, Instant now) {
|
||||
return !snapshot.synchronizedAt().plus(snapshotMaxAge).isBefore(now);
|
||||
}
|
||||
|
||||
private record State(
|
||||
Phase phase,
|
||||
long epoch,
|
||||
int activeLegacyCount,
|
||||
PublishedShadowSnapshot published,
|
||||
Long leasedEpoch,
|
||||
Instant quietSince,
|
||||
String lastFailure) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ShadowSnapshotCoordinatorTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-09-03T00:00:00Z");
|
||||
|
||||
@Test
|
||||
void startsNotReadyAndAllowsOnlyOneObservationLease() {
|
||||
ShadowSnapshotCoordinator coordinator = coordinatorAt("01");
|
||||
|
||||
ShadowSnapshotCoordinator.Admission beforePublish = coordinator.enter(NOW);
|
||||
assertThat(beforePublish.accepted()).isFalse();
|
||||
assertThat(beforePublish.skipReason()).isEqualTo(ShadowSkipReason.NOT_READY);
|
||||
coordinator.exit(beforePublish);
|
||||
|
||||
publish(coordinator, snapshot(1, "01", NOW));
|
||||
ShadowSnapshotCoordinator.Admission first = coordinator.enter(NOW);
|
||||
ShadowSnapshotCoordinator.Admission second = coordinator.enter(NOW);
|
||||
|
||||
assertThat(first.accepted()).isTrue();
|
||||
assertThat(first.snapshot()).isEqualTo(snapshot(1, "01", NOW));
|
||||
assertThat(second.accepted()).isFalse();
|
||||
assertThat(second.skipReason()).isEqualTo(ShadowSkipReason.BUSY);
|
||||
|
||||
coordinator.finishObservation(first);
|
||||
coordinator.finishObservation(first);
|
||||
assertThat(coordinator.activeLegacyCount()).isEqualTo(2);
|
||||
|
||||
coordinator.exit(second);
|
||||
coordinator.exit(second);
|
||||
coordinator.exit(first);
|
||||
coordinator.exit(first);
|
||||
assertThat(coordinator.activeLegacyCount()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentLegacyEntryInvalidatesRefreshPublicationAndComparison() {
|
||||
ShadowSnapshotCoordinator coordinator = coordinatorAt("01");
|
||||
publish(coordinator, snapshot(1, "01", NOW));
|
||||
|
||||
ShadowSnapshotCoordinator.Admission first = coordinator.enter(NOW);
|
||||
ShadowSnapshotCoordinator.Admission later = coordinator.enter(NOW);
|
||||
assertThat(first.accepted()).isTrue();
|
||||
assertThat(coordinator.isComparable(first)).isFalse();
|
||||
coordinator.exit(later);
|
||||
coordinator.finishObservation(first);
|
||||
coordinator.exit(first);
|
||||
|
||||
ShadowSnapshotCoordinator.RefreshTicket refresh = coordinator.tryBeginRefresh(NOW).orElseThrow();
|
||||
ShadowSnapshotCoordinator.Admission concurrent = coordinator.enter(NOW);
|
||||
|
||||
assertThat(coordinator.publish(refresh, snapshot(2, "01", NOW), NOW)).isFalse();
|
||||
assertThat(coordinator.phase()).isNotEqualTo(ShadowSnapshotCoordinator.Phase.READY);
|
||||
coordinator.exit(concurrent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsStaleSnapshotsAndMismatchedWarehouses() {
|
||||
ShadowSnapshotCoordinator coordinator = coordinatorAt("01");
|
||||
|
||||
publish(coordinator, snapshot(3, "01", NOW.minusSeconds(61)));
|
||||
ShadowSnapshotCoordinator.Admission stale = coordinator.enter(NOW);
|
||||
assertThat(stale.accepted()).isFalse();
|
||||
assertThat(stale.skipReason()).isEqualTo(ShadowSkipReason.STALE);
|
||||
coordinator.exit(stale);
|
||||
|
||||
publish(coordinator, snapshot(4, "02", NOW));
|
||||
ShadowSnapshotCoordinator.Admission wrongWarehouse = coordinator.enter(NOW);
|
||||
assertThat(wrongWarehouse.accepted()).isFalse();
|
||||
assertThat(wrongWarehouse.skipReason()).isEqualTo(ShadowSkipReason.WAREHOUSE);
|
||||
coordinator.exit(wrongWarehouse);
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsNumericWarehouseIdsAsEquivalent() {
|
||||
ShadowSnapshotCoordinator coordinator = coordinatorAt("01");
|
||||
publish(coordinator, snapshot(5, "1", NOW));
|
||||
|
||||
ShadowSnapshotCoordinator.Admission admission = coordinator.enter(NOW);
|
||||
|
||||
assertThat(admission.accepted()).isTrue();
|
||||
coordinator.exit(admission);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRefreshBecomesUnavailableUntilAValidPublication() {
|
||||
ShadowSnapshotCoordinator coordinator = coordinatorAt("01");
|
||||
ShadowSnapshotCoordinator.RefreshTicket refresh = coordinator.tryBeginRefresh(NOW).orElseThrow();
|
||||
|
||||
coordinator.failRefresh(refresh, "signature changed");
|
||||
|
||||
assertThat(coordinator.phase()).isEqualTo(ShadowSnapshotCoordinator.Phase.UNAVAILABLE);
|
||||
assertThat(coordinator.lastFailure()).isEqualTo("signature changed");
|
||||
ShadowSnapshotCoordinator.Admission unavailable = coordinator.enter(NOW);
|
||||
assertThat(unavailable.skipReason()).isEqualTo(ShadowSkipReason.NOT_READY);
|
||||
coordinator.exit(unavailable);
|
||||
|
||||
publish(coordinator, snapshot(6, "01", NOW));
|
||||
assertThat(coordinator.phase()).isEqualTo(ShadowSnapshotCoordinator.Phase.READY);
|
||||
}
|
||||
|
||||
private ShadowSnapshotCoordinator coordinatorAt(String warehouseId) {
|
||||
return new ShadowSnapshotCoordinator(warehouseId, Duration.ofSeconds(60));
|
||||
}
|
||||
|
||||
private static PublishedShadowSnapshot snapshot(long generation, String warehouseId, Instant synchronizedAt) {
|
||||
return new PublishedShadowSnapshot(generation, warehouseId, synchronizedAt, "signature-" + generation);
|
||||
}
|
||||
|
||||
private static void publish(ShadowSnapshotCoordinator coordinator, PublishedShadowSnapshot snapshot) {
|
||||
ShadowSnapshotCoordinator.RefreshTicket refresh = coordinator.tryBeginRefresh(NOW).orElseThrow();
|
||||
assertThat(coordinator.publish(refresh, snapshot, NOW)).isTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user