mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-19 23:14:10 +00:00
Retire consumed source plans
- Count the destinations in reach that asked for each source - Drop the entry once all of them did, so only the frontier stays cached
This commit is contained in:
@@ -140,12 +140,14 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
@Override
|
||||
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, x, z);
|
||||
int radius = getRadius();
|
||||
int sourceChunkRadius = sourceChunkRadius(radius);
|
||||
replaySourceChunks(
|
||||
x,
|
||||
z,
|
||||
getRadius(),
|
||||
radius,
|
||||
(sourceX, sourceZ) -> {
|
||||
ObjectSourcePlan plan = sourcePlans.acquire(sourceX, sourceZ, x, z,
|
||||
ObjectSourcePlan plan = sourcePlans.acquire(sourceX, sourceZ, x, z, sourceChunkRadius,
|
||||
() -> buildSourcePlan(writer, sourceX, sourceZ, context));
|
||||
if (plan != null) {
|
||||
transaction.apply(plan);
|
||||
@@ -169,6 +171,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
+ " waits=" + stats.waits()
|
||||
+ " replays=" + stats.replays()
|
||||
+ " drained=" + stats.drained()
|
||||
+ " retired=" + stats.retired()
|
||||
+ " retained=" + stats.retained()
|
||||
+ " retainedMiB=" + (stats.retainedBytes() >> 20)
|
||||
+ " budgetMiB=" + (stats.budgetBytes() >> 20));
|
||||
|
||||
@@ -23,6 +23,7 @@ final class ObjectSourcePlanCache {
|
||||
private final LongAdder waits = new LongAdder();
|
||||
private final LongAdder replays = new LongAdder();
|
||||
private final LongAdder drained = new LongAdder();
|
||||
private final LongAdder retired = new LongAdder();
|
||||
private volatile State state;
|
||||
|
||||
ObjectSourcePlanCache() {
|
||||
@@ -44,18 +45,31 @@ final class ObjectSourcePlanCache {
|
||||
/**
|
||||
* Returns the plan whose mutations the destination still has to replay, or null when the source writes nothing
|
||||
* there. Each destination consumes a retained plan once; a plan whose destinations were all consumed keeps only
|
||||
* its destination index, so a repeated consumption rebuilds it instead of trusting released state.
|
||||
* its destination index, so a repeated consumption rebuilds it instead of trusting released state. The entry is
|
||||
* dropped once every destination within the source chunk radius has asked for it.
|
||||
*/
|
||||
ObjectSourcePlan acquire(int sourceChunkX, int sourceChunkZ, int destinationChunkX, int destinationChunkZ,
|
||||
Supplier<ObjectSourcePlan> builder) {
|
||||
int sourceChunkRadius, Supplier<ObjectSourcePlan> builder) {
|
||||
Objects.requireNonNull(builder, "Source plan builder");
|
||||
lookups.increment();
|
||||
State current = state;
|
||||
long source = CacheKey.mix(key(sourceChunkX, sourceChunkZ));
|
||||
Entry entry = current.plans.getIfPresent(source);
|
||||
if (entry == null) {
|
||||
entry = load(current, source, builder);
|
||||
entry = load(current, source, sourceChunkRadius, builder);
|
||||
}
|
||||
boolean lastVisitor = entry.visitors.visit(destinationChunkX - sourceChunkX, destinationChunkZ - sourceChunkZ);
|
||||
ObjectSourcePlan plan = claim(current, source, entry, destinationChunkX, destinationChunkZ, builder);
|
||||
if (lastVisitor) {
|
||||
retired.increment();
|
||||
Visitors visitors = entry.visitors;
|
||||
current.plans.asMap().computeIfPresent(source, (Long key, Entry mapped) -> mapped.visitors == visitors ? null : mapped);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
private ObjectSourcePlan claim(State current, long source, Entry entry, int destinationChunkX, int destinationChunkZ,
|
||||
Supplier<ObjectSourcePlan> builder) {
|
||||
int slot = entry.slot(destinationChunkX, destinationChunkZ);
|
||||
if (slot < 0) {
|
||||
return null;
|
||||
@@ -74,7 +88,7 @@ final class ObjectSourcePlanCache {
|
||||
|
||||
private ObjectSourcePlan replay(State current, long source, Entry consumed, int destinationChunkX, int destinationChunkZ,
|
||||
Supplier<ObjectSourcePlan> builder) {
|
||||
Entry rebuilt = new Entry(build(builder));
|
||||
Entry rebuilt = new Entry(build(builder), consumed.visitors);
|
||||
int slot = rebuilt.slot(destinationChunkX, destinationChunkZ);
|
||||
Claim claim = slot < 0 ? null : rebuilt.claim(slot);
|
||||
if (claim == null) {
|
||||
@@ -86,7 +100,7 @@ final class ObjectSourcePlanCache {
|
||||
return claim.plan();
|
||||
}
|
||||
|
||||
private Entry load(State current, long source, Supplier<ObjectSourcePlan> builder) {
|
||||
private Entry load(State current, long source, int sourceChunkRadius, Supplier<ObjectSourcePlan> builder) {
|
||||
Pending created = new Pending(Thread.currentThread());
|
||||
Pending pending = current.pending.putIfAbsent(source, created);
|
||||
if (pending != null) {
|
||||
@@ -96,7 +110,7 @@ final class ObjectSourcePlanCache {
|
||||
try {
|
||||
Entry entry = current.plans.getIfPresent(source);
|
||||
if (entry == null) {
|
||||
entry = new Entry(build(builder));
|
||||
entry = new Entry(build(builder), new Visitors(sourceChunkRadius));
|
||||
if (entry.weight() <= maximumRetainedBytes) {
|
||||
current.plans.put(source, entry);
|
||||
}
|
||||
@@ -133,11 +147,11 @@ final class ObjectSourcePlanCache {
|
||||
long retainedBytes = current.plans.policy().eviction()
|
||||
.map(eviction -> eviction.weightedSize().orElse(0L))
|
||||
.orElse(0L);
|
||||
return new Stats(lookups.sum(), builds.sum(), waits.sum(), replays.sum(), drained.sum(),
|
||||
return new Stats(lookups.sum(), builds.sum(), waits.sum(), replays.sum(), drained.sum(), retired.sum(),
|
||||
current.plans.estimatedSize(), retainedBytes, maximumRetainedBytes);
|
||||
}
|
||||
|
||||
record Stats(long lookups, long builds, long waits, long replays, long drained, long retained,
|
||||
record Stats(long lookups, long builds, long waits, long replays, long drained, long retired, long retained,
|
||||
long retainedBytes, long budgetBytes) {
|
||||
}
|
||||
|
||||
@@ -159,22 +173,25 @@ final class ObjectSourcePlanCache {
|
||||
private static final class Entry {
|
||||
private final ObjectSourcePlan index;
|
||||
private final boolean[] claimed;
|
||||
private final Visitors visitors;
|
||||
private final int weight;
|
||||
private ObjectSourcePlan plan;
|
||||
private int remaining;
|
||||
|
||||
private Entry(ObjectSourcePlan plan) {
|
||||
private Entry(ObjectSourcePlan plan, Visitors visitors) {
|
||||
this.index = plan.isEmpty() ? ObjectSourcePlan.EMPTY : plan;
|
||||
this.plan = plan.isEmpty() ? null : plan;
|
||||
this.claimed = new boolean[plan.destinationCount()];
|
||||
this.visitors = visitors;
|
||||
this.remaining = claimed.length;
|
||||
this.weight = saturatedWeight(ENTRY_BYTES + (long) claimed.length + index.estimatedRetainedBytes());
|
||||
this.weight = weigh(index, claimed, visitors);
|
||||
}
|
||||
|
||||
private Entry(ObjectSourcePlan index, boolean[] claimed) {
|
||||
private Entry(ObjectSourcePlan index, boolean[] claimed, Visitors visitors) {
|
||||
this.index = index;
|
||||
this.claimed = claimed;
|
||||
this.weight = saturatedWeight(ENTRY_BYTES + (long) claimed.length + index.estimatedRetainedBytes());
|
||||
this.visitors = visitors;
|
||||
this.weight = weigh(index, claimed, visitors);
|
||||
}
|
||||
|
||||
private int slot(int destinationChunkX, int destinationChunkZ) {
|
||||
@@ -195,15 +212,50 @@ final class ObjectSourcePlanCache {
|
||||
}
|
||||
|
||||
private Entry released() {
|
||||
return new Entry(index.destinationIndex(), claimed);
|
||||
return new Entry(index.destinationIndex(), claimed, visitors);
|
||||
}
|
||||
|
||||
private int weight() {
|
||||
return weight;
|
||||
}
|
||||
|
||||
private static int saturatedWeight(long weight) {
|
||||
return (int) Math.min(Integer.MAX_VALUE, weight);
|
||||
private static int weigh(ObjectSourcePlan index, boolean[] claimed, Visitors visitors) {
|
||||
return (int) Math.min(Integer.MAX_VALUE,
|
||||
ENTRY_BYTES + (long) claimed.length + visitors.retainedBytes() + index.estimatedRetainedBytes());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Visitors {
|
||||
private final int radius;
|
||||
private final int side;
|
||||
private final long[] seen;
|
||||
private int remaining;
|
||||
|
||||
private Visitors(int radius) {
|
||||
if (radius < 0) {
|
||||
throw new IllegalArgumentException("Source chunk radius must not be negative");
|
||||
}
|
||||
this.radius = radius;
|
||||
this.side = 2 * radius + 1;
|
||||
this.seen = new long[(side * side + 63) >>> 6];
|
||||
this.remaining = side * side;
|
||||
}
|
||||
|
||||
private synchronized boolean visit(int offsetX, int offsetZ) {
|
||||
if (Math.abs(offsetX) > radius || Math.abs(offsetZ) > radius) {
|
||||
return false;
|
||||
}
|
||||
int bit = (offsetX + radius) * side + offsetZ + radius;
|
||||
long mask = 1L << (bit & 63);
|
||||
if ((seen[bit >>> 6] & mask) != 0L) {
|
||||
return false;
|
||||
}
|
||||
seen[bit >>> 6] |= mask;
|
||||
return --remaining == 0;
|
||||
}
|
||||
|
||||
private long retainedBytes() {
|
||||
return 32L + 8L * seen.length;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ public class MantleObjectComponentCollisionDeterminismTest {
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, destination, 0);
|
||||
MantleObjectComponent.replaySourceChunks(destination, 0, RADIUS, (sourceX, sourceZ) -> {
|
||||
ObjectSourcePlan plan = cache.acquire(sourceX, sourceZ, destination, 0,
|
||||
() -> buildSourcePlan(writer, sourceX, sourceZ, scenario));
|
||||
MantleObjectComponent.sourceChunkRadius(RADIUS), () -> buildSourcePlan(writer, sourceX, sourceZ, scenario));
|
||||
if (plan != null) {
|
||||
transaction.apply(plan);
|
||||
}
|
||||
|
||||
+60
-27
@@ -30,14 +30,14 @@ public class ObjectSourcePlanCacheTest {
|
||||
if ((x == 0 || x == 1) && z == 0) {
|
||||
continue;
|
||||
}
|
||||
assertNull(cache.acquire(0, 0, x, z, () -> {
|
||||
assertNull(cache.acquire(0, 0, x, z, 7, () -> {
|
||||
builds.incrementAndGet();
|
||||
return plan;
|
||||
}));
|
||||
}
|
||||
}
|
||||
assertEquals(1, builds.get());
|
||||
assertSame(plan, cache.acquire(0, 0, 1, 0, () -> {
|
||||
assertSame(plan, cache.acquire(0, 0, 1, 0, 7, () -> {
|
||||
builds.incrementAndGet();
|
||||
return plan;
|
||||
}));
|
||||
@@ -54,29 +54,30 @@ public class ObjectSourcePlanCacheTest {
|
||||
AtomicInteger builds = new AtomicInteger();
|
||||
ObjectSourcePlan plan = planTouching(new int[]{0, 0}, new int[]{1, 0});
|
||||
ObjectSourcePlan rebuilt = planTouching(new int[]{0, 0}, new int[]{1, 0});
|
||||
assertSame(plan, cache.acquire(0, 0, 0, 0, () -> {
|
||||
assertSame(plan, cache.acquire(0, 0, 0, 0, 7, () -> {
|
||||
builds.incrementAndGet();
|
||||
return plan;
|
||||
}));
|
||||
assertSame(plan, cache.acquire(0, 0, 1, 0, () -> {
|
||||
long retainedBeforeDrain = cache.stats().retainedBytes();
|
||||
assertSame(plan, cache.acquire(0, 0, 1, 0, 7, () -> {
|
||||
builds.incrementAndGet();
|
||||
return rebuilt;
|
||||
}));
|
||||
assertEquals(1, builds.get());
|
||||
assertEquals(1L, cache.stats().drained());
|
||||
assertTrue(cache.stats().retainedBytes() < plan.estimatedRetainedBytes());
|
||||
assertTrue(cache.stats().retainedBytes() < retainedBeforeDrain);
|
||||
|
||||
assertNull(cache.acquire(0, 0, 2, 0, () -> {
|
||||
assertNull(cache.acquire(0, 0, 2, 0, 7, () -> {
|
||||
builds.incrementAndGet();
|
||||
return rebuilt;
|
||||
}));
|
||||
assertSame(rebuilt, cache.acquire(0, 0, 0, 0, () -> {
|
||||
assertSame(rebuilt, cache.acquire(0, 0, 0, 0, 7, () -> {
|
||||
builds.incrementAndGet();
|
||||
return rebuilt;
|
||||
}));
|
||||
assertEquals(2, builds.get());
|
||||
assertEquals(1L, cache.stats().replays());
|
||||
assertSame(rebuilt, cache.acquire(0, 0, 1, 0, () -> {
|
||||
assertSame(rebuilt, cache.acquire(0, 0, 1, 0, 7, () -> {
|
||||
builds.incrementAndGet();
|
||||
return plan;
|
||||
}));
|
||||
@@ -84,13 +85,45 @@ public class ObjectSourcePlanCacheTest {
|
||||
assertEquals(2L, cache.stats().drained());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourcesRetireOnceEveryDestinationInReachAskedForThem() {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(1024L * 1024L);
|
||||
AtomicInteger builds = new AtomicInteger();
|
||||
ObjectSourcePlan plan = planTouching(new int[]{0, 0}, new int[]{1, 0});
|
||||
int returned = 0;
|
||||
int visits = 0;
|
||||
for (int x = 7; x >= -7; x--) {
|
||||
for (int z = -7; z <= 7; z++) {
|
||||
if (cache.acquire(0, 0, x, z, 7, () -> {
|
||||
builds.incrementAndGet();
|
||||
return plan;
|
||||
}) != null) {
|
||||
returned++;
|
||||
}
|
||||
if (++visits < 225) {
|
||||
assertEquals(1L, cache.estimatedSize());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(2, returned);
|
||||
assertEquals(1, builds.get());
|
||||
assertEquals(0L, cache.estimatedSize());
|
||||
assertEquals(1L, cache.stats().retired());
|
||||
assertSame(plan, cache.acquire(0, 0, 1, 0, 7, () -> {
|
||||
builds.incrementAndGet();
|
||||
return plan;
|
||||
}));
|
||||
assertEquals(2, builds.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyPlansAreRetainedAsIndexesAndNeverRebuilt() {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(1024L * 1024L);
|
||||
AtomicInteger builds = new AtomicInteger();
|
||||
for (int sweep = 0; sweep < 3; sweep++) {
|
||||
for (int chunk = 0; chunk < 1000; chunk++) {
|
||||
assertNull(cache.acquire(chunk, 0, chunk, 0, () -> {
|
||||
assertNull(cache.acquire(chunk, 0, chunk, 0, 7, () -> {
|
||||
builds.incrementAndGet();
|
||||
return new ObjectSourcePlan(List.of());
|
||||
}));
|
||||
@@ -113,13 +146,13 @@ public class ObjectSourcePlanCacheTest {
|
||||
try {
|
||||
assertEquals(Long.hashCode(CacheKey.mix(CacheKey.key(32113, 37))),
|
||||
Long.hashCode(CacheKey.mix(CacheKey.key(32667, 37))));
|
||||
Future<ObjectSourcePlan> firstResult = executor.submit(() -> cache.acquire(32113, 37, 32113, 37, () -> {
|
||||
Future<ObjectSourcePlan> firstResult = executor.submit(() -> cache.acquire(32113, 37, 32113, 37, 7, () -> {
|
||||
firstEntered.countDown();
|
||||
await(releaseFirst);
|
||||
return first;
|
||||
}));
|
||||
assertTrue(firstEntered.await(5L, TimeUnit.SECONDS));
|
||||
Future<ObjectSourcePlan> secondResult = executor.submit(() -> cache.acquire(32667, 37, 32667, 37, () -> second));
|
||||
Future<ObjectSourcePlan> secondResult = executor.submit(() -> cache.acquire(32667, 37, 32667, 37, 7, () -> second));
|
||||
|
||||
assertSame(second, secondResult.get(3L, TimeUnit.SECONDS));
|
||||
releaseFirst.countDown();
|
||||
@@ -149,7 +182,7 @@ public class ObjectSourcePlanCacheTest {
|
||||
futures.add(executor.submit(() -> {
|
||||
ready.countDown();
|
||||
assertTrue(start.await(5L, TimeUnit.SECONDS));
|
||||
return cache.acquire(4, -7, destinationX, -7, () -> {
|
||||
return cache.acquire(4, -7, destinationX, -7, 7, () -> {
|
||||
builds.incrementAndGet();
|
||||
return expected;
|
||||
});
|
||||
@@ -174,12 +207,12 @@ public class ObjectSourcePlanCacheTest {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(large.estimatedRetainedBytes());
|
||||
AtomicInteger builds = new AtomicInteger();
|
||||
|
||||
assertSame(large, cache.acquire(0, 0, 0, 0, () -> {
|
||||
assertSame(large, cache.acquire(0, 0, 0, 0, 7, () -> {
|
||||
builds.incrementAndGet();
|
||||
return large;
|
||||
}));
|
||||
assertEquals(0L, cache.estimatedSize());
|
||||
assertSame(large, cache.acquire(0, 0, 1, 0, () -> {
|
||||
assertSame(large, cache.acquire(0, 0, 1, 0, 7, () -> {
|
||||
builds.incrementAndGet();
|
||||
return large;
|
||||
}));
|
||||
@@ -190,10 +223,10 @@ public class ObjectSourcePlanCacheTest {
|
||||
public void cacheEvictsByRetainedBytesAndCanBeCleared() {
|
||||
ObjectSourcePlan first = planTouching(new int[]{0, 0}, new int[]{9, 9});
|
||||
ObjectSourcePlan second = planTouching(new int[]{1, 0}, new int[]{9, 9});
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(first.estimatedRetainedBytes() + 128L);
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(first.estimatedRetainedBytes() + 256L);
|
||||
|
||||
cache.acquire(0, 0, 0, 0, () -> first);
|
||||
cache.acquire(1, 0, 1, 0, () -> second);
|
||||
cache.acquire(0, 0, 0, 0, 7, () -> first);
|
||||
cache.acquire(1, 0, 1, 0, 7, () -> second);
|
||||
|
||||
assertEquals(1L, cache.estimatedSize());
|
||||
cache.clear();
|
||||
@@ -213,7 +246,7 @@ public class ObjectSourcePlanCacheTest {
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
|
||||
Future<ObjectSourcePlan> first = executor.submit(() -> cache.acquire(7, 8, 7, 8, () -> {
|
||||
Future<ObjectSourcePlan> first = executor.submit(() -> cache.acquire(7, 8, 7, 8, 7, () -> {
|
||||
entered.countDown();
|
||||
await(release);
|
||||
return old;
|
||||
@@ -221,12 +254,12 @@ public class ObjectSourcePlanCacheTest {
|
||||
try {
|
||||
assertTrue(entered.await(5L, TimeUnit.SECONDS));
|
||||
cache.clear();
|
||||
assertSame(replacement, cache.acquire(7, 8, 7, 8, () -> replacement));
|
||||
assertSame(replacement, cache.acquire(7, 8, 7, 8, 7, () -> replacement));
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
assertSame(old, first.get(5L, TimeUnit.SECONDS));
|
||||
assertSame(old, cache.acquire(7, 8, 7, 8, () -> old));
|
||||
assertSame(old, cache.acquire(7, 8, 7, 8, 7, () -> old));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +272,7 @@ public class ObjectSourcePlanCacheTest {
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
try (ExecutorService owner = Executors.newSingleThreadExecutor();
|
||||
ForkJoinPool pool = new ForkJoinPool(1)) {
|
||||
Future<ObjectSourcePlan> first = owner.submit(() -> cache.acquire(7, 8, 7, 8, () -> {
|
||||
Future<ObjectSourcePlan> first = owner.submit(() -> cache.acquire(7, 8, 7, 8, 7, () -> {
|
||||
entered.countDown();
|
||||
await(release);
|
||||
return expected;
|
||||
@@ -248,7 +281,7 @@ public class ObjectSourcePlanCacheTest {
|
||||
assertTrue(entered.await(5L, TimeUnit.SECONDS));
|
||||
Future<ObjectSourcePlan> second = pool.submit(() -> {
|
||||
waiting.countDown();
|
||||
return cache.acquire(7, 8, 8, 8, () -> { throw new AssertionError("Duplicate build"); });
|
||||
return cache.acquire(7, 8, 8, 8, 7, () -> { throw new AssertionError("Duplicate build"); });
|
||||
});
|
||||
assertTrue(waiting.await(5L, TimeUnit.SECONDS));
|
||||
pool.submit(release::countDown).get(5L, TimeUnit.SECONDS);
|
||||
@@ -265,12 +298,12 @@ public class ObjectSourcePlanCacheTest {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(4096L);
|
||||
IllegalArgumentException failure = new IllegalArgumentException("Build failed");
|
||||
assertSame(failure, assertThrows(IllegalArgumentException.class,
|
||||
() -> cache.acquire(7, 8, 7, 8, () -> { throw failure; })));
|
||||
assertThrows(NullPointerException.class, () -> cache.acquire(7, 8, 7, 8, () -> null));
|
||||
assertThrows(IllegalStateException.class, () -> cache.acquire(7, 8, 7, 8,
|
||||
() -> cache.acquire(7, 8, 7, 8, () -> planTouching(new int[]{7, 8}))));
|
||||
() -> cache.acquire(7, 8, 7, 8, 7, () -> { throw failure; })));
|
||||
assertThrows(NullPointerException.class, () -> cache.acquire(7, 8, 7, 8, 7, () -> null));
|
||||
assertThrows(IllegalStateException.class, () -> cache.acquire(7, 8, 7, 8, 7,
|
||||
() -> cache.acquire(7, 8, 7, 8, 7, () -> planTouching(new int[]{7, 8}))));
|
||||
ObjectSourcePlan expected = planTouching(new int[]{7, 8});
|
||||
assertSame(expected, cache.acquire(7, 8, 7, 8, () -> expected));
|
||||
assertSame(expected, cache.acquire(7, 8, 7, 8, 7, () -> expected));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user