fix: enforce stable shadow snapshot boundaries
This commit is contained in:
@@ -1,26 +1,43 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record InboundShadowObservation(
|
||||
boolean observed,
|
||||
long snapshotGeneration,
|
||||
String warehouseId,
|
||||
Instant snapshotSynchronizedAt,
|
||||
String sourceSignature,
|
||||
String requestId,
|
||||
String resultCode,
|
||||
String proposedSpaceId,
|
||||
String reason) {
|
||||
|
||||
public InboundShadowObservation(boolean observed, long snapshotGeneration, String warehouseId,
|
||||
String requestId, String resultCode, String proposedSpaceId, String reason) {
|
||||
this(observed, snapshotGeneration, warehouseId, null, null,
|
||||
requestId, resultCode, proposedSpaceId, reason);
|
||||
}
|
||||
|
||||
public InboundShadowObservation(boolean observed, String warehouseId, String requestId,
|
||||
String resultCode, String proposedSpaceId, String reason) {
|
||||
this(observed, 0L, warehouseId, requestId, resultCode, proposedSpaceId, reason);
|
||||
this(observed, 0L, warehouseId, null, null, requestId, resultCode, proposedSpaceId, reason);
|
||||
}
|
||||
|
||||
public static InboundShadowObservation empty() {
|
||||
return new InboundShadowObservation(false, 0L, null, null, "NOT_OBSERVED", null, null);
|
||||
return new InboundShadowObservation(false, 0L, null, null, null,
|
||||
null, "NOT_OBSERVED", null, null);
|
||||
}
|
||||
|
||||
public static InboundShadowObservation unavailable(long snapshotGeneration, String warehouseId,
|
||||
String requestId, String resultCode, String reason) {
|
||||
return new InboundShadowObservation(true, snapshotGeneration, warehouseId, requestId,
|
||||
resultCode, null, reason);
|
||||
return new InboundShadowObservation(true, snapshotGeneration, warehouseId, null, null,
|
||||
requestId, resultCode, null, reason);
|
||||
}
|
||||
|
||||
public static InboundShadowObservation unavailable(PublishedShadowSnapshot snapshot,
|
||||
String requestId, String resultCode, String reason) {
|
||||
return new InboundShadowObservation(true, snapshot.generation(), snapshot.warehouseId(),
|
||||
snapshot.synchronizedAt(), snapshot.sourceSignature(), requestId, resultCode, null, reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,47 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record OutboundShadowObservation(
|
||||
boolean observed,
|
||||
long snapshotGeneration,
|
||||
String warehouseId,
|
||||
Instant snapshotSynchronizedAt,
|
||||
String sourceSignature,
|
||||
String requestId,
|
||||
String resultCode,
|
||||
String proposedStockId,
|
||||
String proposedSpaceId,
|
||||
String reason) {
|
||||
|
||||
public OutboundShadowObservation(boolean observed, long snapshotGeneration, String warehouseId,
|
||||
String requestId, String resultCode, String proposedStockId, String proposedSpaceId,
|
||||
String reason) {
|
||||
this(observed, snapshotGeneration, warehouseId, null, null,
|
||||
requestId, resultCode, proposedStockId, proposedSpaceId, reason);
|
||||
}
|
||||
|
||||
public OutboundShadowObservation(boolean observed, String warehouseId, String requestId,
|
||||
String resultCode, String proposedStockId, String proposedSpaceId, String reason) {
|
||||
this(observed, 0L, warehouseId, requestId, resultCode, proposedStockId, proposedSpaceId, reason);
|
||||
this(observed, 0L, warehouseId, null, null,
|
||||
requestId, resultCode, proposedStockId, proposedSpaceId, reason);
|
||||
}
|
||||
|
||||
public static OutboundShadowObservation empty() {
|
||||
return new OutboundShadowObservation(false, 0L, null, null, "NOT_OBSERVED", null, null, null);
|
||||
return new OutboundShadowObservation(false, 0L, null, null, null,
|
||||
null, "NOT_OBSERVED", null, null, null);
|
||||
}
|
||||
|
||||
public static OutboundShadowObservation unavailable(long snapshotGeneration, String warehouseId,
|
||||
String requestId, String resultCode, String reason) {
|
||||
return new OutboundShadowObservation(true, snapshotGeneration, warehouseId, requestId,
|
||||
resultCode, null, null, reason);
|
||||
return new OutboundShadowObservation(true, snapshotGeneration, warehouseId, null, null,
|
||||
requestId, resultCode, null, null, reason);
|
||||
}
|
||||
|
||||
public static OutboundShadowObservation unavailable(PublishedShadowSnapshot snapshot,
|
||||
String requestId, String resultCode, String reason) {
|
||||
return new OutboundShadowObservation(true, snapshot.generation(), snapshot.warehouseId(),
|
||||
snapshot.synchronizedAt(), snapshot.sourceSignature(),
|
||||
requestId, resultCode, null, null, reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import java.util.function.Supplier;
|
||||
@Component
|
||||
public class ShadowPlanningEngine {
|
||||
private static final int INSTANCE_STALE_SECONDS = 120;
|
||||
private static final int DECISION_REASON_MAX_LENGTH = 2000;
|
||||
private static final String PUBLISHED = "PUBLISHED";
|
||||
|
||||
private final SchedulingFacade schedulingFacade;
|
||||
@@ -81,8 +82,8 @@ public class ShadowPlanningEngine {
|
||||
ShadowStockReference reference = inputMapper.selectProjectOutboundReference(
|
||||
warehouseId, snapshot.generation(), stockoutDetailId);
|
||||
if (reference == null || isBlank(reference.getProjectNo())) {
|
||||
return OutboundShadowObservation.unavailable(snapshot.generation(), warehouseId,
|
||||
stockoutDetailId, "SKIPPED_MISSING_PROJECT",
|
||||
return OutboundShadowObservation.unavailable(snapshot, stockoutDetailId,
|
||||
"SKIPPED_MISSING_PROJECT",
|
||||
outboundDiagnostic(snapshot, stockoutDetailId, reference,
|
||||
"No project number was available for shadow planning"));
|
||||
}
|
||||
@@ -100,8 +101,8 @@ public class ShadowPlanningEngine {
|
||||
ShadowStockReference reference = inputMapper.selectStockReference(
|
||||
warehouseId, snapshot.generation(), stockId);
|
||||
if (reference == null || isBlank(reference.getTrayNo())) {
|
||||
return OutboundShadowObservation.unavailable(snapshot.generation(), warehouseId,
|
||||
stockoutDetailId, "SKIPPED_MISSING_STOCK",
|
||||
return OutboundShadowObservation.unavailable(snapshot, stockoutDetailId,
|
||||
"SKIPPED_MISSING_STOCK",
|
||||
outboundDiagnostic(snapshot, stockoutDetailId, reference,
|
||||
"Selected stock was unavailable to shadow planning"));
|
||||
}
|
||||
@@ -116,37 +117,41 @@ public class ShadowPlanningEngine {
|
||||
String legacySpaceId) {
|
||||
Objects.requireNonNull(observation, "observation");
|
||||
transactions.executeWithoutResult(status -> {
|
||||
requirePublishedGeneration(observation.snapshotGeneration(), observation.warehouseId(),
|
||||
"published snapshot changed before comparison");
|
||||
inputMapper.insertComparison(
|
||||
validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(),
|
||||
observation.snapshotSynchronizedAt(), observation.sourceSignature());
|
||||
int inserted = inputMapper.insertComparison(
|
||||
UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE",
|
||||
observation.snapshotGeneration(),
|
||||
observation.snapshotGeneration(), observation.snapshotSynchronizedAt(),
|
||||
observation.sourceSignature(),
|
||||
comparisonCode(observation.proposedSpaceId(), legacySpaceId),
|
||||
"snapshotGeneration=" + observation.snapshotGeneration()
|
||||
abbreviate("snapshotGeneration=" + observation.snapshotGeneration()
|
||||
+ "; request=" + observation.requestId()
|
||||
+ "; shadow=" + observation.resultCode()
|
||||
+ "; proposedSpace=" + nullSafe(observation.proposedSpaceId())
|
||||
+ "; shadowReason=" + nullSafe(observation.reason())
|
||||
+ "; legacy=" + nullSafe(legacyResultCode)
|
||||
+ "; legacySpace=" + nullSafe(legacySpaceId));
|
||||
+ "; legacySpace=" + nullSafe(legacySpaceId), DECISION_REASON_MAX_LENGTH));
|
||||
requireComparisonInserted(inserted);
|
||||
});
|
||||
}
|
||||
|
||||
public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) {
|
||||
Objects.requireNonNull(observation, "observation");
|
||||
transactions.executeWithoutResult(status -> {
|
||||
requirePublishedGeneration(observation.snapshotGeneration(), observation.warehouseId(),
|
||||
"published snapshot changed before comparison");
|
||||
inputMapper.insertComparison(
|
||||
validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(),
|
||||
observation.snapshotSynchronizedAt(), observation.sourceSignature());
|
||||
int inserted = inputMapper.insertComparison(
|
||||
UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE",
|
||||
observation.snapshotGeneration(), "LEGACY_" + nullSafe(legacyResultCode),
|
||||
"snapshotGeneration=" + observation.snapshotGeneration()
|
||||
observation.snapshotGeneration(), observation.snapshotSynchronizedAt(),
|
||||
observation.sourceSignature(), "LEGACY_" + nullSafe(legacyResultCode),
|
||||
abbreviate("snapshotGeneration=" + observation.snapshotGeneration()
|
||||
+ "; request=" + observation.requestId()
|
||||
+ "; shadow=" + observation.resultCode()
|
||||
+ "; proposedStock=" + nullSafe(observation.proposedStockId())
|
||||
+ "; proposedSpace=" + nullSafe(observation.proposedSpaceId())
|
||||
+ "; shadowReason=" + nullSafe(observation.reason())
|
||||
+ "; legacy=" + nullSafe(legacyResultCode));
|
||||
+ "; legacy=" + nullSafe(legacyResultCode), DECISION_REASON_MAX_LENGTH));
|
||||
requireComparisonInserted(inserted);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,7 +161,7 @@ public class ShadowPlanningEngine {
|
||||
ShadowInboundReference reference = inputMapper.selectInboundInput(
|
||||
warehouseId, snapshot.generation(), taskNo, trayNo);
|
||||
if (reference == null || isBlank(reference.getRequestId()) || isBlank(reference.getTrayNo())) {
|
||||
return InboundShadowObservation.unavailable(snapshot.generation(), warehouseId, taskNo,
|
||||
return InboundShadowObservation.unavailable(snapshot, taskNo,
|
||||
"SKIPPED_MISSING_INPUT",
|
||||
inboundDiagnostic(snapshot, taskNo, trayNo, reference,
|
||||
"Inbound task details were unavailable to shadow planning"));
|
||||
@@ -169,11 +174,12 @@ public class ShadowPlanningEngine {
|
||||
if (plan.assignment().isPresent()) {
|
||||
var assignment = plan.assignment().orElseThrow();
|
||||
return new InboundShadowObservation(true, snapshot.generation(), warehouseId,
|
||||
snapshot.synchronizedAt(), snapshot.sourceSignature(),
|
||||
reference.getRequestId(), "READY", assignment.spaceId(),
|
||||
inboundDiagnostic(snapshot, taskNo, trayNo, reference, assignment.reason()));
|
||||
}
|
||||
var rejection = plan.rejection().orElseThrow();
|
||||
return InboundShadowObservation.unavailable(snapshot.generation(), warehouseId,
|
||||
return InboundShadowObservation.unavailable(snapshot,
|
||||
reference.getRequestId(), "REJECTED_" + rejection.code().name(),
|
||||
inboundDiagnostic(snapshot, taskNo, trayNo, reference, rejection.reason()));
|
||||
}
|
||||
@@ -186,17 +192,18 @@ public class ShadowPlanningEngine {
|
||||
if (plan.assignment().isPresent()) {
|
||||
var assignment = plan.assignment().orElseThrow();
|
||||
return new OutboundShadowObservation(true, snapshot.generation(), snapshot.warehouseId(),
|
||||
snapshot.synchronizedAt(), snapshot.sourceSignature(),
|
||||
requestId, "READY", reference.getStockId(), assignment.sourceSpaceId(),
|
||||
outboundDiagnostic(snapshot, requestId, reference, assignment.reason()));
|
||||
}
|
||||
if (plan.waiting().isPresent()) {
|
||||
var waiting = plan.waiting().orElseThrow();
|
||||
return OutboundShadowObservation.unavailable(snapshot.generation(), snapshot.warehouseId(),
|
||||
return OutboundShadowObservation.unavailable(snapshot,
|
||||
requestId, "WAITING_" + waiting.code().name(),
|
||||
outboundDiagnostic(snapshot, requestId, reference, waiting.reason()));
|
||||
}
|
||||
var error = plan.error().orElseThrow();
|
||||
return OutboundShadowObservation.unavailable(snapshot.generation(), snapshot.warehouseId(),
|
||||
return OutboundShadowObservation.unavailable(snapshot,
|
||||
requestId, "ERROR_" + error.code().name(),
|
||||
outboundDiagnostic(snapshot, requestId, reference, error.message()));
|
||||
}
|
||||
@@ -253,16 +260,17 @@ public class ShadowPlanningEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePublishedGeneration(long generation, String warehouseId, String message) {
|
||||
if (generation <= 0 || isBlank(warehouseId)) {
|
||||
throw new IllegalStateException(message);
|
||||
private void validateComparisonSnapshot(long generation, String warehouseId,
|
||||
Instant synchronizedAt, String sourceSignature) {
|
||||
if (generation <= 0 || isBlank(warehouseId) || synchronizedAt == null) {
|
||||
throw new IllegalStateException("published snapshot changed before comparison");
|
||||
}
|
||||
ShadowSnapshotDescriptor descriptor = inputMapper.selectPublishedSnapshot(warehouseId);
|
||||
if (descriptor == null
|
||||
|| !PUBLISHED.equals(descriptor.getSnapshotStatus())
|
||||
|| descriptor.getSnapshotGeneration() != generation
|
||||
|| !WarehouseIdNormalizer.equivalent(descriptor.getWarehouseId(), warehouseId)) {
|
||||
throw new IllegalStateException(message);
|
||||
requireUppercaseHexSignature(sourceSignature, "comparison source signature");
|
||||
}
|
||||
|
||||
private void requireComparisonInserted(int inserted) {
|
||||
if (inserted != 1) {
|
||||
throw new IllegalStateException("published snapshot changed before comparison");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.ruoyi.algorithm.shadow.ShadowStockReference;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Mapper
|
||||
public interface ShadowInputMapper {
|
||||
ShadowInboundReference selectInboundInput(@Param("warehouseId") String warehouseId,
|
||||
@@ -31,6 +33,8 @@ public interface ShadowInputMapper {
|
||||
@Param("warehouseId") String warehouseId,
|
||||
@Param("decisionType") String decisionType,
|
||||
@Param("inputVersion") long inputVersion,
|
||||
@Param("snapshotSynchronizedAt") Instant snapshotSynchronizedAt,
|
||||
@Param("sourceSignature") String sourceSignature,
|
||||
@Param("resultCode") String resultCode,
|
||||
@Param("reason") String reason);
|
||||
}
|
||||
|
||||
@@ -96,15 +96,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
state.snapshot_status as snapshotStatus,
|
||||
state.synchronized_at as synchronizedAt,
|
||||
convert(varchar(64), state.source_signature, 2) as sourceSignature
|
||||
from alg.shadow_snapshot_state state
|
||||
from alg.shadow_snapshot_state state with (readcommittedlock)
|
||||
where state.warehouse_id = #{warehouseId}
|
||||
</select>
|
||||
|
||||
<insert id="insertComparison">
|
||||
insert into alg.decision_log (
|
||||
decision_id, warehouse_id, decision_type, input_version, result_code, reason)
|
||||
values (
|
||||
try_convert(uniqueidentifier, #{decisionId}), #{warehouseId}, #{decisionType},
|
||||
convert(binary(8), #{inputVersion}), #{resultCode}, #{reason})
|
||||
select try_convert(uniqueidentifier, #{decisionId}), state.warehouse_id, #{decisionType},
|
||||
convert(binary(8), #{inputVersion}), #{resultCode}, #{reason}
|
||||
from alg.shadow_snapshot_state state with (updlock, holdlock)
|
||||
where state.warehouse_id = #{warehouseId}
|
||||
and state.snapshot_status = 'PUBLISHED'
|
||||
and state.snapshot_generation = #{inputVersion}
|
||||
and state.synchronized_at = #{snapshotSynchronizedAt}
|
||||
and convert(varchar(64), state.source_signature, 2) = #{sourceSignature}
|
||||
</insert>
|
||||
</mapper>
|
||||
|
||||
@@ -141,7 +141,8 @@ class ShadowPlanningEngineTest {
|
||||
assertThatThrownBy(() -> engine.planInbound(SNAPSHOT, "TASK-1", "TRAY-1"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("published snapshot changed during planning");
|
||||
verify(mapper, never()).insertComparison(any(), any(), any(), any(Long.class), any(), any());
|
||||
verify(mapper, never()).insertComparison(any(), any(), any(), any(Long.class),
|
||||
any(Instant.class), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -160,30 +161,66 @@ class ShadowPlanningEngineTest {
|
||||
@Test
|
||||
void comparisonPersistsLeasedGenerationAsInputVersion() {
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
|
||||
when(mapper.insertComparison(any(), any(), any(), any(Long.class),
|
||||
any(Instant.class), any(), any(), any())).thenReturn(1);
|
||||
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
|
||||
InboundShadowObservation observation = new InboundShadowObservation(
|
||||
true, 42L, "01", "JOB-1", "READY", "SPACE-9", "shadow diagnostic");
|
||||
true, 42L, "01", SNAPSHOT.synchronizedAt(), SNAPSHOT.sourceSignature(),
|
||||
"JOB-1", "READY", "SPACE-9", "shadow diagnostic");
|
||||
|
||||
engine.compareInbound(observation, "READY", "SPACE-9");
|
||||
|
||||
verify(mapper).insertComparison(any(), eq("01"), eq("INBOUND_COMPARE"), eq(42L),
|
||||
eq("MATCH"), argThat(reason -> reason.contains("request=JOB-1")));
|
||||
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("MATCH"),
|
||||
argThat(reason -> reason.contains("request=JOB-1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void comparisonDoesNotPersistAfterPublishedGenerationChanges() {
|
||||
void comparisonFailsWhenAtomicDescriptorGuardRejectsTheInsert() {
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
when(mapper.selectPublishedSnapshot("01"))
|
||||
.thenReturn(descriptor(snapshot(43L, "01")));
|
||||
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
|
||||
InboundShadowObservation observation = new InboundShadowObservation(
|
||||
true, 42L, "01", "JOB-1", "READY", "SPACE-9", "shadow diagnostic");
|
||||
true, 42L, "01", SNAPSHOT.synchronizedAt(), SNAPSHOT.sourceSignature(),
|
||||
"JOB-1", "READY", "SPACE-9", "shadow diagnostic");
|
||||
|
||||
assertThatThrownBy(() -> engine.compareInbound(observation, "READY", "SPACE-9"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("published snapshot changed before comparison");
|
||||
verify(mapper, never()).insertComparison(any(), any(), any(), any(Long.class), any(), any());
|
||||
verify(mapper).insertComparison(any(), eq("01"), eq("INBOUND_COMPARE"), eq(42L),
|
||||
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundComparisonCarriesTheCompleteSnapshotDescriptor() {
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
when(mapper.insertComparison(any(), any(), any(), any(Long.class),
|
||||
any(Instant.class), any(), any(), any())).thenReturn(1);
|
||||
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
|
||||
OutboundShadowObservation observation = new OutboundShadowObservation(
|
||||
true, 42L, "01", SNAPSHOT.synchronizedAt(), SNAPSHOT.sourceSignature(),
|
||||
"DETAIL-1", "READY", "STOCK-1", "SPACE-9", "shadow diagnostic");
|
||||
|
||||
engine.compareOutbound(observation, "10");
|
||||
|
||||
verify(mapper).insertComparison(any(), eq("01"), eq("OUTBOUND_COMPARE"), eq(42L),
|
||||
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("LEGACY_10"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void comparisonReasonIsBoundedToDecisionLogCapacity() {
|
||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||
when(mapper.insertComparison(any(), any(), any(), any(Long.class),
|
||||
any(Instant.class), any(), any(), any())).thenReturn(1);
|
||||
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
|
||||
InboundShadowObservation observation = new InboundShadowObservation(
|
||||
true, 42L, "01", SNAPSHOT.synchronizedAt(), SNAPSHOT.sourceSignature(),
|
||||
"JOB-1", "READY", "SPACE-9", "X".repeat(2500));
|
||||
|
||||
engine.compareInbound(observation, "READY", "SPACE-9");
|
||||
|
||||
verify(mapper).insertComparison(any(), any(), any(), any(Long.class),
|
||||
any(Instant.class), any(), any(),
|
||||
argThat(reason -> reason.length() <= 2000));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -247,7 +284,12 @@ class ShadowPlanningEngineTest {
|
||||
assertThat(statements.get("selectStockReference"))
|
||||
.contains("alg.inventory_item_snapshot", "stock_id = #{stockid}");
|
||||
assertThat(statements.get("insertComparison"))
|
||||
.contains("convert(binary(8), #{inputversion})");
|
||||
.contains("convert(binary(8), #{inputversion})",
|
||||
"from alg.shadow_snapshot_state state with (updlock, holdlock)",
|
||||
"state.snapshot_status = 'published'",
|
||||
"state.snapshot_generation = #{inputversion}",
|
||||
"state.synchronized_at = #{snapshotsynchronizedat}",
|
||||
"convert(varchar(64), state.source_signature, 2) = #{sourcesignature}");
|
||||
assertThat(statements.get("selectPublishedSnapshot"))
|
||||
.contains("alg.shadow_snapshot_state", "convert(varchar(64),",
|
||||
"source_signature", ", 2)");
|
||||
|
||||
Reference in New Issue
Block a user