/*
 * Copyright (c) 2018-Present Pivotal Software Inc, All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *       https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package reactor.pool;

import java.io.Closeable;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Formatter;
import java.util.FormatterClosedException;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.assertj.core.api.Assertions;
import org.assertj.core.data.Offset;
import org.awaitility.Awaitility;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;

import reactor.core.Disposable;
import reactor.core.Disposables;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.SignalType;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
import reactor.pool.InstrumentedPool.PoolMetrics;
import reactor.pool.TestUtils.PoolableTest;
import reactor.test.StepVerifier;
import reactor.test.publisher.TestPublisher;
import reactor.test.scheduler.VirtualTimeScheduler;
import reactor.test.util.RaceTestUtils;
import reactor.test.util.TestLogger;
import reactor.util.Logger;
import reactor.util.Loggers;

import static org.assertj.core.api.Assertions.*;
import static org.awaitility.Awaitility.await;

/**
 * @author Simon Baslé
 */
public class CommonPoolTest {

	static final <T> Function<PoolBuilder<T, ?>, AbstractPool<T>> simplePoolFifo() {
		return new Function<PoolBuilder<T, ?>, AbstractPool<T>>() {
			@Override
			public AbstractPool<T> apply(PoolBuilder<T, ?> builder) {
				return (AbstractPool<T>) builder.fifo();
			}

			@Override
			public String toString() {
				return "simplePool FIFO";
			}
		};
	}

	static final <T> Function<PoolBuilder<T, ?>, AbstractPool<T>> simplePoolLifo() {
		return new Function<PoolBuilder<T, ?>, AbstractPool<T>>() {
			@Override
			public AbstractPool<T> apply(PoolBuilder<T, ?> builder) {
				return (AbstractPool<T>) builder.lifo();
			}

			@Override
			public String toString() {
				return "simplePool LIFO";
			}
		};
	}

	static <T> List<Function<PoolBuilder<T, ?>, AbstractPool<T>>> allPools() {
		return Arrays.asList(simplePoolFifo(), simplePoolLifo());
	}

	static <T> List<Function<PoolBuilder<T, ?>, AbstractPool<T>>> fifoPools() {
		return Collections.singletonList(simplePoolFifo());
	}

	static <T> List<Function<PoolBuilder<T, ?>, AbstractPool<T>>> lifoPools() {
		return Collections.singletonList(simplePoolLifo());
	}

	@ParameterizedTest
	@MethodSource("fifoPools")
	void smokeTestFifo(Function<PoolBuilder<PoolableTest, ?>, Pool<PoolableTest>> configAdjuster) throws InterruptedException {
		AtomicInteger newCount = new AtomicInteger();

		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				//default maxUse is 5, but this test relies on it being 2
				.from(Mono.defer(() -> Mono.just(new PoolableTest(newCount.incrementAndGet(), 2))))
				.sizeBetween(2, 3)
				.releaseHandler(pt -> Mono.fromRunnable(pt::clean))
				.evictionPredicate((poolable, metadata) -> !poolable.isHealthy());

		Pool<PoolableTest> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		List<PooledRef<PoolableTest>> acquired1 = new ArrayList<>();
		pool.acquire().subscribe(acquired1::add);
		pool.acquire().subscribe(acquired1::add);
		pool.acquire().subscribe(acquired1::add);
		List<PooledRef<PoolableTest>> acquired2 = new ArrayList<>();
		pool.acquire().subscribe(acquired2::add);
		pool.acquire().subscribe(acquired2::add);
		pool.acquire().subscribe(acquired2::add);
		List<PooledRef<PoolableTest>> acquired3 = new ArrayList<>();
		pool.acquire().subscribe(acquired3::add);
		pool.acquire().subscribe(acquired3::add);
		pool.acquire().subscribe(acquired3::add);

		assertThat(acquired1).hasSize(3);
		assertThat(acquired2).isEmpty();
		assertThat(acquired3).isEmpty();

		Thread.sleep(1000);
		for (PooledRef<PoolableTest> slot : acquired1) {
			slot.release().block();
		}
		assertThat(acquired2).hasSize(3);
		assertThat(acquired3).isEmpty();

		Thread.sleep(1000);
		for (PooledRef<PoolableTest> slot : acquired2) {
			slot.release().block();
		}
		assertThat(acquired3).hasSize(3);

		List<PoolableTest> poolables1 = acquired1.stream().map(PooledRef::poolable).collect(Collectors.toList());
		List<PoolableTest> poolables2 = acquired2.stream().map(PooledRef::poolable).collect(Collectors.toList());
		assertThat(poolables1)
				.as("poolable1 and 2 all used up")
				.hasSameElementsAs(poolables2)
				.allMatch(poolable -> poolable.usedUp == 2);

		assertThat(acquired3)
				.as("acquired3 all new")
				.allSatisfy(slot -> assertThat(slot.poolable().usedUp).isZero());
	}

	@ParameterizedTest
	@MethodSource("fifoPools")
	void smokeTestInScopeFifo(Function<PoolBuilder<PoolableTest, ?>, Pool<PoolableTest>> configAdjuster) {
		AtomicInteger newCount = new AtomicInteger();

		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				//default maxUse is 5, but this test relies on it being 2
				.from(Mono.defer(() -> Mono.just(new PoolableTest(newCount.incrementAndGet(), 2))))
				.sizeBetween(2, 3)
				.releaseHandler(pt -> Mono.fromRunnable(pt::clean))
				.evictionPredicate((poolable, metadata) -> !poolable.isHealthy());
		Pool<PoolableTest> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		TestPublisher<Integer> trigger1 = TestPublisher.create();
		TestPublisher<Integer> trigger2 = TestPublisher.create();
		TestPublisher<Integer> trigger3 = TestPublisher.create();

		List<PoolableTest> acquired1 = new ArrayList<>();

		Mono.when(
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired1::add).delayUntil(__ -> trigger1)),
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired1::add).delayUntil(__ -> trigger1)),
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired1::add).delayUntil(__ -> trigger1))
		).subscribe();

		List<PoolableTest> acquired2 = new ArrayList<>();
		Mono.when(
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired2::add).delayUntil(__ -> trigger2)),
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired2::add).delayUntil(__ -> trigger2)),
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired2::add).delayUntil(__ -> trigger2))
		).subscribe();

		List<PoolableTest> acquired3 = new ArrayList<>();
		Mono.when(
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired3::add).delayUntil(__ -> trigger3)),
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired3::add).delayUntil(__ -> trigger3)),
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired3::add).delayUntil(__ -> trigger3))
		).subscribe();

		assertThat(acquired1).as("first batch not pending").hasSize(3);
		assertThat(acquired2).as("second and third pending").hasSameSizeAs(acquired3).isEmpty();

		trigger1.emit(1);

		assertThat(acquired2).as("batch2 after trigger1").hasSize(3);
		assertThat(acquired3).as("batch3 after trigger1").isEmpty();

		trigger2.emit(1);

		assertThat(acquired3).as("batch3 after trigger2").hasSize(3);
		assertThat(newCount).as("allocated total").hasValue(6);

		assertThat(acquired1)
				.as("acquired1/2 all used up")
				.hasSameElementsAs(acquired2)
				.allSatisfy(elem -> assertThat(elem.usedUp).isEqualTo(2));

		assertThat(acquired3)
				.as("acquired3 all new (released once)")
				.allSatisfy(elem -> assertThat(elem.usedUp).isZero());
	}

	@ParameterizedTest
	@MethodSource("fifoPools")
	void smokeTestAsyncFifo(Function<PoolBuilder<PoolableTest, ?>, Pool<PoolableTest>> configAdjuster) throws InterruptedException {
		AtomicInteger newCount = new AtomicInteger();

		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				//default maxUse is 5, but this test relies on it being 2
				.from(Mono.defer(() -> Mono.just(new PoolableTest(newCount.incrementAndGet(), 2)))
				          .subscribeOn(Schedulers.newParallel("poolable test allocator")))
				.sizeBetween(2, 3)
				.releaseHandler(pt -> Mono.fromRunnable(pt::clean))
				.evictionPredicate((poolable, metadata) -> !poolable.isHealthy());

		Pool<PoolableTest> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		List<PooledRef<PoolableTest>> acquired1 = new CopyOnWriteArrayList<>();
		CountDownLatch latch1 = new CountDownLatch(3);
		pool.acquire().subscribe(acquired1::add, Throwable::printStackTrace, latch1::countDown);
		pool.acquire().subscribe(acquired1::add, Throwable::printStackTrace, latch1::countDown);
		pool.acquire().subscribe(acquired1::add, Throwable::printStackTrace, latch1::countDown);

		List<PooledRef<PoolableTest>> acquired2 = new CopyOnWriteArrayList<>();
		pool.acquire().subscribe(acquired2::add);
		pool.acquire().subscribe(acquired2::add);
		pool.acquire().subscribe(acquired2::add);

		List<PooledRef<PoolableTest>> acquired3 = new CopyOnWriteArrayList<>();
		CountDownLatch latch3 = new CountDownLatch(3);
		pool.acquire().subscribe(acquired3::add, Throwable::printStackTrace, latch3::countDown);
		pool.acquire().subscribe(acquired3::add, Throwable::printStackTrace, latch3::countDown);
		pool.acquire().subscribe(acquired3::add, Throwable::printStackTrace, latch3::countDown);

		if (!latch1.await(1, TimeUnit.SECONDS)) { //wait for creation of max elements
			fail("not enough elements created initially, missing " + latch1.getCount());
		}
		assertThat(acquired1).hasSize(3);
		assertThat(acquired2).isEmpty();
		assertThat(acquired3).isEmpty();

		Thread.sleep(1000);
		for (PooledRef<PoolableTest> slot : acquired1) {
			slot.release().block();
		}
		assertThat(acquired2).hasSize(3);
		assertThat(acquired3).isEmpty();

		Thread.sleep(1000);
		for (PooledRef<PoolableTest> slot : acquired2) {
			slot.release().block();
		}

		if (latch3.await(5, TimeUnit.SECONDS)) { //wait for the re-creation of max elements

			assertThat(acquired3).hasSize(3);

			List<PoolableTest> poolables1 = acquired1.stream().map(PooledRef::poolable).collect(Collectors.toList());
			List<PoolableTest> poolables2 = acquired2.stream().map(PooledRef::poolable).collect(Collectors.toList());

			assertThat(poolables1)
					.as("poolables 1 and 2 all used up")
					.hasSameElementsAs(poolables2)
					.allSatisfy(poolable -> assertThat(poolable.usedUp).isEqualTo(2));

			assertThat(acquired3)
					.as("acquired3 all new")
					.allSatisfy(slot -> {
						assertThat(slot).as("acquire3 slot").isNotNull();
						assertThat(slot.poolable()).as("acquire3 poolable").isNotNull();
						assertThat(slot.poolable().usedUp).as("acquire3 usedUp").isZero();
					});
		}
		else {
			fail("not enough new elements generated, missing " + latch3.getCount());
		}
	}

	@ParameterizedTest
	@MethodSource("lifoPools")
	void simpleLifo(Function<PoolBuilder<PoolableTest, ?>, AbstractPool<PoolableTest>> configAdjuster)
			throws InterruptedException {
		CountDownLatch latch = new CountDownLatch(2);
		AtomicInteger verif = new AtomicInteger();
		AtomicInteger newCount = new AtomicInteger();

		PoolBuilder<PoolableTest, ?> builder =
				PoolBuilder.from(Mono.defer(() ->
						Mono.just(new PoolableTest(newCount.incrementAndGet()))))
				           .sizeBetween(0, 1)
				           .releaseHandler(pt -> Mono.fromRunnable(pt::clean));

		AbstractPool<PoolableTest> pool = configAdjuster.apply(builder);

		PooledRef<PoolableTest> ref = pool.acquire().block();

		pool.acquire()
		    .doOnNext(v -> {
			    verif.compareAndSet(1, 2);
			    System.out.println("first in got " + v);
		    })
		    .flatMap(PooledRef::release)
		    .subscribe(v -> {}, e -> latch.countDown(), latch::countDown);

		pool.acquire()
		    .doOnNext(v -> {
			    verif.compareAndSet(0, 1);
			    System.out.println("second in got " + v);
		    })
		    .flatMap(PooledRef::release)
		    .subscribe(v -> {}, e -> latch.countDown(), latch::countDown);

		ref.release().block();
		latch.await(1, TimeUnit.SECONDS);

		assertThat(verif).as("second in, first out").hasValue(2);
		assertThat(newCount).as("created one").hasValue(1);
	}

	@ParameterizedTest
	@MethodSource("lifoPools")
	void smokeTestLifo(Function<PoolBuilder<PoolableTest, ?>, AbstractPool<PoolableTest>> configAdjuster) throws InterruptedException {
		AtomicInteger newCount = new AtomicInteger();
		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				//default maxUse is 5, but this test relies on it being 2
				.from(Mono.defer(() -> Mono.just(new PoolableTest(newCount.incrementAndGet(), 2))))
				.sizeBetween(2, 3)
				.releaseHandler(pt -> Mono.fromRunnable(pt::clean))
				.evictionPredicate((value, metadata) -> !value.isHealthy());
		AbstractPool<PoolableTest> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		List<PooledRef<PoolableTest>> acquired1 = new ArrayList<>();
		pool.acquire().subscribe(acquired1::add);
		pool.acquire().subscribe(acquired1::add);
		pool.acquire().subscribe(acquired1::add);
		List<PooledRef<PoolableTest>> acquired2 = new ArrayList<>();
		pool.acquire().subscribe(acquired2::add);
		pool.acquire().subscribe(acquired2::add);
		pool.acquire().subscribe(acquired2::add);
		List<PooledRef<PoolableTest>> acquired3 = new ArrayList<>();
		pool.acquire().subscribe(acquired3::add);
		pool.acquire().subscribe(acquired3::add);
		pool.acquire().subscribe(acquired3::add);

		assertThat(acquired1).hasSize(3);
		assertThat(acquired2).isEmpty();
		assertThat(acquired3).isEmpty();

		Thread.sleep(1000);
		for (PooledRef<PoolableTest> slot : acquired1) {
			slot.release().block();
		}
		assertThat(acquired3).hasSize(3);
		assertThat(acquired2).isEmpty();

		Thread.sleep(1000);
		for (PooledRef<PoolableTest> slot : acquired3) {
			slot.release().block();
		}
		assertThat(acquired2).hasSize(3);

		List<PoolableTest> poolables1 = acquired1.stream().map(PooledRef::poolable).collect(Collectors.toList());
		List<PoolableTest> poolables3 = acquired3.stream().map(PooledRef::poolable).collect(Collectors.toList());

		assertThat(poolables1)
				.as("poolable 1 and 3 all used up")
				.hasSameElementsAs(poolables3)
				.allSatisfy(poolable -> assertThat(poolable.usedUp).isEqualTo(2));

		assertThat(acquired2)
				.as("acquired2 all new")
				.allSatisfy(slot -> assertThat(slot.poolable().usedUp).isZero());
	}

	@ParameterizedTest
	@MethodSource("lifoPools")
	void smokeTestInScopeLifo(Function<PoolBuilder<PoolableTest, ?>, AbstractPool<PoolableTest>> configAdjuster) {
		AtomicInteger newCount = new AtomicInteger();
		PoolBuilder<PoolableTest, ?> builder =
				//default maxUse is 5, but this test relies on it being 2
				PoolBuilder.from(Mono.defer(() -> Mono.just(new PoolableTest(newCount.incrementAndGet(), 2))))
				           .sizeBetween(2, 3)
				           .releaseHandler(pt -> Mono.fromRunnable(pt::clean))
				           .evictionPredicate((value, metadata) -> !value.isHealthy());
		AbstractPool<PoolableTest> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		TestPublisher<Integer> trigger1 = TestPublisher.create();
		TestPublisher<Integer> trigger2 = TestPublisher.create();
		TestPublisher<Integer> cleanupTrigger = TestPublisher.create();

		List<PoolableTest> acquired1 = new ArrayList<>();

		Mono.when(
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired1::add).delayUntil(__ -> trigger1)),
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired1::add).delayUntil(__ -> trigger1)),
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired1::add).delayUntil(__ -> trigger1))
		).subscribe();

		List<PoolableTest> acquired2 = new ArrayList<>();
		Mono.when(
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired2::add).delayUntil(__ -> cleanupTrigger)),
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired2::add).delayUntil(__ -> cleanupTrigger)),
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired2::add).delayUntil(__ -> cleanupTrigger))
		).subscribe();

		List<PoolableTest> acquired3 = new ArrayList<>();
		Mono.when(
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired3::add).delayUntil(__ -> trigger2)),
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired3::add).delayUntil(__ -> trigger2)),
				pool.withPoolable(poolable -> Mono.just(poolable).doOnNext(acquired3::add).delayUntil(__ -> trigger2))
		).subscribe();

		assertThat(acquired1).as("first batch not pending").hasSize(3);
		assertThat(acquired2).as("second and third pending").hasSameSizeAs(acquired3).isEmpty();

		trigger1.emit(1);

		assertThat(acquired3).as("batch3 after trigger1").hasSize(3);
		assertThat(acquired2).as("batch2 after trigger1").isEmpty();

		trigger2.emit(1);
		assertThat(acquired2).as("batch2 after trigger2").hasSize(3);

		assertThat(newCount).as("allocated total").hasValue(6);

		cleanupTrigger.emit(1); //release the objects

		assertThat(acquired1)
				.as("acquired1/3 all used up")
				.hasSameElementsAs(acquired3)
				.allSatisfy(elem -> assertThat(elem.usedUp).isEqualTo(2));

		assertThat(acquired2)
				.as("acquired2 all new (released once)")
				.allSatisfy(elem -> assertThat(elem.usedUp).isOne());
	}

	@ParameterizedTest
	@MethodSource("lifoPools")
	void smokeTestAsyncLifo(Function<PoolBuilder<PoolableTest, ?>, AbstractPool<PoolableTest>> configAdjuster) throws InterruptedException {
		AtomicInteger newCount = new AtomicInteger();

		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				//default maxUse is 5, but this test relies on it being 2
				.from(Mono.defer(() -> Mono.just(new PoolableTest(newCount.incrementAndGet(), 2)))
				          .subscribeOn(Schedulers.newParallel(
						          "poolable test allocator")))
				.sizeBetween(2, 3)
				.releaseHandler(pt -> Mono.fromRunnable(pt::clean))
				.evictionPredicate((value, metadata) -> !value.isHealthy());
		AbstractPool<PoolableTest> pool = configAdjuster.apply(builder);
		pool.warmup().block();


		List<PooledRef<PoolableTest>> acquired1 = new CopyOnWriteArrayList<>();
		CountDownLatch latch1 = new CountDownLatch(3);
		pool.acquire().subscribe(acquired1::add, Throwable::printStackTrace, latch1::countDown);
		pool.acquire().subscribe(acquired1::add, Throwable::printStackTrace, latch1::countDown);
		pool.acquire().subscribe(acquired1::add, Throwable::printStackTrace, latch1::countDown);

		List<PooledRef<PoolableTest>> acquired2 = new CopyOnWriteArrayList<>();
		CountDownLatch latch2 = new CountDownLatch(3);
		pool.acquire().subscribe(acquired2::add, Throwable::printStackTrace, latch2::countDown);
		pool.acquire().subscribe(acquired2::add, Throwable::printStackTrace, latch2::countDown);
		pool.acquire().subscribe(acquired2::add, Throwable::printStackTrace, latch2::countDown);

		List<PooledRef<PoolableTest>> acquired3 = new CopyOnWriteArrayList<>();
		pool.acquire().subscribe(acquired3::add);
		pool.acquire().subscribe(acquired3::add);
		pool.acquire().subscribe(acquired3::add);

		if (!latch1.await(15, TimeUnit.SECONDS)) { //wait for creation of max elements
			fail("not enough elements created initially, missing " + latch1.getCount());
		}
		assertThat(acquired1).hasSize(3);
		assertThat(acquired2).isEmpty();
		assertThat(acquired3).isEmpty();

		Thread.sleep(1000);
		for (PooledRef<PoolableTest> slot : acquired1) {
			slot.release().block();
		}
		assertThat(acquired3).hasSize(3);
		assertThat(acquired2).isEmpty();

		Thread.sleep(1000);
		for (PooledRef<PoolableTest> slot : acquired3) {
			slot.release().block();
		}

		if (latch2.await(15, TimeUnit.SECONDS)) { //wait for the re-creation of max elements

			assertThat(acquired2).as("acquired2 size").hasSize(3);

			List<PoolableTest> poolables1 = acquired1.stream().map(PooledRef::poolable).collect(Collectors.toList());
			List<PoolableTest> poolables3 = acquired3.stream().map(PooledRef::poolable).collect(Collectors.toList());

			assertThat(poolables1)
					.as("poolables 1 and 3 all used up")
					.hasSameElementsAs(poolables3)
					.allSatisfy(poolable -> assertThat(poolable.usedUp).isEqualTo(2));

			assertThat(acquired2)
					.as("acquired2 all new")
					.allSatisfy(slot -> {
						assertThat(slot).as("acquire2 slot").isNotNull();
						assertThat(slot.poolable()).as("acquire2 poolable").isNotNull();
						assertThat(slot.poolable().usedUp).as("acquire2 usedUp").isZero();
					});
		}
		else {
			fail("not enough new elements generated, missing " + latch2.getCount());
		}
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void firstAcquireCausesWarmupWithMinSize(Function<PoolBuilder<PoolableTest, ?>, Pool<PoolableTest>> configAdjuster)
			throws InterruptedException {
		AtomicInteger allocationCount = new AtomicInteger();

		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				.from(Mono.fromCallable(() -> {
					int id = allocationCount.incrementAndGet();
					return new PoolableTest(id);
				}))
				.sizeBetween(4, 8)
				.releaseHandler(p -> Mono.fromRunnable(p::clean));

		final Pool<PoolableTest> pool = configAdjuster.apply(builder);
		//notice no explicit warmup in this one
		assertThat(pool).isInstanceOf(InstrumentedPool.class);
		final PoolMetrics metrics = ((InstrumentedPool) pool).metrics();

		assertThat(metrics.allocatedSize()).as("constructor allocated").isEqualTo(0);

		final PooledRef<PoolableTest> firstAcquire = pool.acquire()
		                                                 .block();

		assertThat(metrics.allocatedSize()).as("first acquire allocated").isEqualTo(4);
		assertThat(metrics.idleSize()).as("warmup idle").isEqualTo(3);

		final PooledRef<PoolableTest> secondAcquire = pool.acquire()
		                                                  .block();

		assertThat(metrics.allocatedSize()).as("second acquire allocated").isEqualTo(4);
		assertThat(metrics.idleSize()).as("second acquire idle").isEqualTo(2);

		//give time for an unexpected warmup to take place so that the last assertion is valid
		Thread.sleep(100);
		assertThat(allocationCount).as("total allocations").hasValue(4);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void destroyBelowMinSizeThreshold(Function<PoolBuilder<PoolableTest, ?>, Pool<PoolableTest>> configAdjuster)
			throws InterruptedException {
		AtomicInteger allocationCount = new AtomicInteger();

		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				.from(Mono.fromCallable(() -> {
					int id = allocationCount.incrementAndGet();
					return new PoolableTest(id);
				}))
				.sizeBetween(3, 4)
				.releaseHandler(p -> Mono.fromRunnable(p::clean));

		final Pool<PoolableTest> pool = configAdjuster.apply(builder);
		assertThat(pool).isInstanceOf(InstrumentedPool.class);
		final PoolMetrics metrics = ((InstrumentedPool) pool).metrics();

		final PooledRef<PoolableTest> ref1 = pool.acquire().block();
		final PooledRef<PoolableTest> ref2 = pool.acquire().block();
		final PooledRef<PoolableTest> ref3 = pool.acquire().block();
		final PooledRef<PoolableTest> ref4 = pool.acquire().block();

		assertThat(metrics.allocatedSize()).as("initial allocated").isEqualTo(4);
		assertThat(metrics.idleSize()).as("initial idle").isEqualTo(0);

		ref1.invalidate().block();
		ref2.invalidate().block();
		ref3.invalidate().block();

		assertThat(metrics.allocatedSize()).as("3 releases: allocated").isEqualTo(1);
		assertThat(metrics.idleSize()).as("3 releases: idle").isEqualTo(0);

		PooledRef<PoolableTest> ref5 = pool.acquire()
		                                   .block();

		assertThat(metrics.allocatedSize()).as("second acquire allocated").isEqualTo(3); //1 borrowed, 1 acquiring, 1 extra matches min of 3
		assertThat(metrics.idleSize()).as("second acquire idle").isEqualTo(1); //only 1 extra created this time

		//give time for an unexpected warmup to take place so that the last assertion is valid
		Thread.sleep(100);
		assertThat(allocationCount).as("total allocations").hasValue(6);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void returnedNotReleasedIfBorrowerCancelledEarly(Function<PoolBuilder<PoolableTest, ?>, Pool<PoolableTest>> configAdjuster) {
		AtomicInteger releasedCount = new AtomicInteger();

		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				.from(Mono.fromCallable(PoolableTest::new))
				.sizeBetween(1, 1)
				.releaseHandler(poolableTest -> Mono.fromRunnable(() -> {
					poolableTest.clean();
					releasedCount.incrementAndGet();
				}))
				.evictionPredicate((poolable, metadata) -> !poolable.isHealthy());

		Pool<PoolableTest> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		//acquire the only element and hold on to it
		PooledRef<PoolableTest> slot = pool.acquire().block();
		assertThat(slot).isNotNull();

		pool.acquire().subscribe().dispose();

		assertThat(releasedCount).as("before returning").hasValue(0);

		//release the element, which should only mark it once, as the pending acquire should not be visible anymore
		slot.release().block();

		assertThat(releasedCount).as("after returning").hasValue(1);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void fixedPoolReplenishOnDestroy(Function<PoolBuilder<PoolableTest, ?>, Pool<PoolableTest>> configAdjuster) throws Exception{
		AtomicInteger releasedCount = new AtomicInteger();
		AtomicInteger destroyedCount = new AtomicInteger();

		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				.from(Mono.fromCallable(PoolableTest::new))
				.sizeBetween(1, 1)
				.destroyHandler(poolableTest -> Mono.fromRunnable(() -> {
					poolableTest.clean();
					releasedCount.incrementAndGet();
				}))
				.releaseHandler(poolableTest -> Mono.fromRunnable(() -> {
					poolableTest.clean();
					destroyedCount.incrementAndGet();
				}))
				.evictionPredicate((poolable, metadata) -> !poolable.isHealthy());

		Pool<PoolableTest> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		//acquire the only element
		PooledRef<PoolableTest> slot = pool.acquire().block();
		AtomicReference<PooledRef<PoolableTest>> acquired = new AtomicReference<>();
		CountDownLatch latch = new CountDownLatch(1);
		pool.acquire().subscribe(p -> {
			acquired.set(p);
			latch.countDown();
		});
		assertThat(slot).isNotNull();

		slot.invalidate().block();

		assertThat(releasedCount).as("before returning").hasValue(1);
		assertThat(destroyedCount).as("before returning").hasValue(0);

		//release the element, which should forward to the cancelled second acquire, itself also cleaning
		latch.await(5, TimeUnit.SECONDS);
		assertThat(acquired.get()).isNotNull();

		acquired.get().release().block();

		assertThat(destroyedCount).as("after returning").hasValue(1);
		assertThat(releasedCount).as("after returning").hasValue(1);
	}


	@ParameterizedTest
	@MethodSource("allPools")
	void returnedNotReleasedIfBorrowerInScopeCancelledEarly(Function<PoolBuilder<PoolableTest, ?>, Pool<PoolableTest>> configAdjuster) {
		AtomicInteger releasedCount = new AtomicInteger();
		PoolBuilder<PoolableTest, ?> builder =
				PoolBuilder.from(Mono.fromCallable(PoolableTest::new))
				           .sizeBetween(1, 1)
				           .releaseHandler(poolableTest -> Mono.fromRunnable(() -> {
					           poolableTest.clean();
					           releasedCount.incrementAndGet();
				           }))
				           .evictionPredicate((poolable, metadata) -> !poolable.isHealthy());
		Pool<PoolableTest> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		//acquire the only element and hold on to it
		PooledRef<PoolableTest> slot = pool.acquire().block();
		assertThat(slot).isNotNull();

		pool.withPoolable(Mono::just).subscribe().dispose();

		assertThat(releasedCount).as("before returning").hasValue(0);

		//release the element after the second acquire has been cancelled, so it should never "see" it
		slot.release().block();

		assertThat(releasedCount).as("after returning").hasValue(1);
	}


	@ParameterizedTest
	@MethodSource("allPools")
	void allocatedReleasedIfBorrowerCancelled(Function<PoolBuilder<PoolableTest, ?>, Pool<PoolableTest>> configAdjuster) {
		Scheduler scheduler = Schedulers.newParallel("poolable test allocator");
		AtomicInteger newCount = new AtomicInteger();
		AtomicInteger releasedCount = new AtomicInteger();

		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				.from(Mono.defer(() -> Mono.delay(Duration.ofMillis(50)).thenReturn(new PoolableTest(newCount.incrementAndGet())))
				          .subscribeOn(scheduler))
				.sizeBetween(0, 1)
				.releaseHandler(poolableTest -> Mono.fromRunnable(() -> {
					poolableTest.clean();
					releasedCount.incrementAndGet();
				}))
				.evictionPredicate((poolable, metadata) -> !poolable.isHealthy());

		Pool<PoolableTest> pool = configAdjuster.apply(builder);

		//acquire the only element and immediately dispose
		pool.acquire().subscribe().dispose();

		//release due to cancel is async, give it a bit of time
		await()
				.atMost(100, TimeUnit.MILLISECONDS)
				.with().pollInterval(10, TimeUnit.MILLISECONDS)
				.untilAsserted(
						() -> assertThat(releasedCount).as("released").hasValue(1));

		assertThat(newCount).as("created").hasValue(1);
	}


	@ParameterizedTest
	@MethodSource("allPools")
	void allocatedReleasedIfBorrowerInScopeCancelled(Function<PoolBuilder<PoolableTest, ?>, Pool<PoolableTest>> configAdjuster) {
		Scheduler scheduler = Schedulers.newParallel("poolable test allocator");
		AtomicInteger newCount = new AtomicInteger();
		AtomicInteger releasedCount = new AtomicInteger();

		PoolBuilder<PoolableTest, ?> builder =
				PoolBuilder.from(Mono.defer(() -> Mono.delay(Duration.ofMillis(50)).thenReturn(new PoolableTest(newCount.incrementAndGet())))
				                     .subscribeOn(scheduler))
				           .sizeBetween(0, 1)
				           .releaseHandler(poolableTest -> Mono.fromRunnable(() -> {
					           poolableTest.clean();
					           releasedCount.incrementAndGet();
				           }))
				           .evictionPredicate((poolable, metadata) -> !poolable.isHealthy());
		Pool<PoolableTest> pool = configAdjuster.apply(builder);

		//acquire the only element and immediately dispose
		pool.withPoolable(Mono::just).subscribe().dispose();

		//release due to cancel is async, give it a bit of time
		await()
				.atMost(100, TimeUnit.MILLISECONDS)
				.with().pollInterval(10, TimeUnit.MILLISECONDS)
				.untilAsserted(
						() -> assertThat(releasedCount).as("released").hasValue(1));

		assertThat(newCount).as("created").hasValue(1);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void pendingLimitSync(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) {
		AtomicInteger allocatorCount = new AtomicInteger();
		Disposable.Composite composite = Disposables.composite();

		try {
			PoolBuilder<Integer, ?> builder = PoolBuilder.from(Mono.fromCallable(allocatorCount::incrementAndGet))
			                                             .sizeBetween(1, 1)
			                                             .maxPendingAcquire(1);
			AbstractPool<Integer> pool = configAdjuster.apply(builder);
			pool.warmup().block();
			PooledRef<Integer> hold = pool.acquire().block();

			AtomicReference<Throwable> error = new AtomicReference<>();
			AtomicInteger errorCount = new AtomicInteger();
			AtomicInteger otherTerminationCount = new AtomicInteger();

			for (int i = 0; i < 2; i++) {
				composite.add(
						pool.acquire()
						    .doFinally(fin -> {
							    if (SignalType.ON_ERROR == fin) errorCount.incrementAndGet();
							    else otherTerminationCount.incrementAndGet();
						    })
						    .doOnError(error::set)
						    .subscribe()
				);
			}

			assertThat(AbstractPool.PENDING_COUNT.get(pool)).as("pending counter limited to 1").isEqualTo(1);

			assertThat(errorCount).as("immediate error of extraneous pending").hasValue(1);
			assertThat(otherTerminationCount).as("no other immediate termination").hasValue(0);
			assertThat(error.get()).as("extraneous pending error")
			                       .isInstanceOf(PoolAcquirePendingLimitException.class)
			                       .hasMessage("Pending acquire queue has reached its maximum size of 1");

			hold.release().block();

			assertThat(errorCount).as("error count stable after release").hasValue(1);
			assertThat(otherTerminationCount).as("pending succeeds after release").hasValue(1);
		}
		finally {
			composite.dispose();
		}
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void pendingLimitAsync(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) {
		AtomicInteger allocatorCount = new AtomicInteger();
		final Disposable.Composite composite = Disposables.composite();

		try {
			PoolBuilder<Integer, ?> builder = PoolBuilder.from(Mono.fromCallable(allocatorCount::incrementAndGet))
			                                             .sizeBetween(1, 1)
			                                             .maxPendingAcquire(1);
			AbstractPool<Integer> pool = configAdjuster.apply(builder);
			pool.warmup().block();
			PooledRef<Integer> hold = pool.acquire().block();

			AtomicReference<Throwable> error = new AtomicReference<>();
			AtomicInteger errorCount = new AtomicInteger();
			AtomicInteger otherTerminationCount = new AtomicInteger();

			final Runnable runnable = () -> composite.add(
					pool.acquire()
					    .doFinally(fin -> {
					    	if (SignalType.ON_ERROR == fin) errorCount.incrementAndGet();
					    	else otherTerminationCount.incrementAndGet();
					    })
					    .doOnError(error::set)
					    .subscribe()
			);
			RaceTestUtils.race(runnable, runnable);

			assertThat(AbstractPool.PENDING_COUNT.get(pool)).as("pending counter limited to 1").isEqualTo(1);

			assertThat(errorCount).as("immediate error of extraneous pending").hasValue(1);
			assertThat(otherTerminationCount).as("no other immediate termination").hasValue(0);
			assertThat(error.get()).as("extraneous pending error")
			                       .isInstanceOf(PoolAcquirePendingLimitException.class)
			                       .hasMessage("Pending acquire queue has reached its maximum size of 1");

			hold.release().block();

			assertThat(errorCount).as("error count stable after release").hasValue(1);
			assertThat(otherTerminationCount).as("pending succeeds after release").hasValue(1);
		}
		finally {
			composite.dispose();
		}
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void cleanerFunctionError(Function<PoolBuilder<PoolableTest, ?>, Pool<PoolableTest>> configAdjuster) {
		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				.from(Mono.fromCallable(PoolableTest::new))
				.sizeBetween(0, 1)
				.releaseHandler(poolableTest -> Mono.fromRunnable(() -> {
					poolableTest.clean();
					throw new IllegalStateException("boom");
				}))
				.evictionPredicate((poolable, metadata) -> !poolable.isHealthy());

		Pool<PoolableTest> pool = configAdjuster.apply(builder);

		PooledRef<PoolableTest> slot = pool.acquire().block();

		assertThat(slot).isNotNull();

		StepVerifier.create(slot.release())
		            .verifyErrorMessage("boom");
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void cleanerFunctionErrorDiscards(Function<PoolBuilder<PoolableTest, ?>, Pool<PoolableTest>> configAdjuster) {
		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				.from(Mono.fromCallable(PoolableTest::new))
				.sizeBetween(0, 1)
				.releaseHandler(poolableTest -> Mono.fromRunnable(() -> {
					poolableTest.clean();
					throw new IllegalStateException("boom");
				}))
				.evictionPredicate((poolable, metadata) -> !poolable.isHealthy());

		Pool<PoolableTest> pool = configAdjuster.apply(builder);

		PooledRef<PoolableTest> slot = pool.acquire().block();

		assertThat(slot).isNotNull();

		StepVerifier.create(slot.release())
		            .verifyErrorMessage("boom");

		assertThat(slot.poolable().discarded).as("discarded despite cleaner error").isEqualTo(1);
	}


	@ParameterizedTest
	@MethodSource("allPools")
	void disposingPoolDisposesElements(Function<PoolBuilder<PoolableTest, ?>, AbstractPool<PoolableTest>> configAdjuster) {
		AtomicInteger cleanerCount = new AtomicInteger();

		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				.from(Mono.fromCallable(PoolableTest::new))
				.sizeBetween(0, 3)
				.releaseHandler(p -> Mono.fromRunnable(cleanerCount::incrementAndGet))
				.evictionPredicate((poolable, metadata) -> !poolable.isHealthy());

		AbstractPool<PoolableTest> pool = configAdjuster.apply(builder);

		PoolableTest pt1 = new PoolableTest(1);
		PoolableTest pt2 = new PoolableTest(2);
		PoolableTest pt3 = new PoolableTest(3);

		pool.elementOffer(pt1);
		pool.elementOffer(pt2);
		pool.elementOffer(pt3);
		//important: acquire the permits to simulate the proper creation of resources
		assertThat(pool.poolConfig.allocationStrategy().getPermits(3)).as("permits granted").isEqualTo(3);

		//this releases the permits acquired above
		pool.dispose();

		assertThat(pool.idleSize()).as("idleSize").isZero();
		assertThat(cleanerCount).as("recycled elements").hasValue(0);
		assertThat(pt1.isDisposed()).as("pt1 disposed").isTrue();
		assertThat(pt2.isDisposed()).as("pt2 disposed").isTrue();
		assertThat(pt3.isDisposed()).as("pt3 disposed").isTrue();
		assertThat(pool.poolConfig.allocationStrategy().estimatePermitCount()).as("permits available post dispose").isEqualTo(3);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void disposingPoolFailsPendingBorrowers(Function<PoolBuilder<PoolableTest, ?>, AbstractPool<PoolableTest>> configAdjuster) {
		AtomicInteger cleanerCount = new AtomicInteger();
		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				.from(Mono.fromCallable(PoolableTest::new))
				.sizeBetween(3, 3)
				.releaseHandler(p -> Mono.fromRunnable(cleanerCount::incrementAndGet))
				.evictionPredicate((poolable, metadata) -> !poolable.isHealthy());

		AbstractPool<PoolableTest> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		PooledRef<PoolableTest> slot1 = pool.acquire().block();
		PooledRef<PoolableTest> slot2 = pool.acquire().block();
		PooledRef<PoolableTest> slot3 = pool.acquire().block();
		assertThat(slot1).as("slot1").isNotNull();
		assertThat(slot2).as("slot2").isNotNull();
		assertThat(slot3).as("slot3").isNotNull();

		PoolableTest acquired1 = slot1.poolable();
		PoolableTest acquired2 = slot2.poolable();
		PoolableTest acquired3 = slot3.poolable();


		AtomicReference<Throwable> borrowerError = new AtomicReference<>();
		Mono<PooledRef<PoolableTest>> pendingBorrower = pool.acquire();
		pendingBorrower.subscribe(v -> fail("unexpected value " + v),
				borrowerError::set);

		pool.dispose();

		assertThat(pool.idleSize()).as("idleSize").isZero();
		assertThat(cleanerCount).as("recycled elements").hasValue(0);
		assertThat(acquired1.isDisposed()).as("acquired1 held").isFalse();
		assertThat(acquired2.isDisposed()).as("acquired2 held").isFalse();
		assertThat(acquired3.isDisposed()).as("acquired3 held").isFalse();
		assertThat(borrowerError.get()).hasMessage("Pool has been shut down");
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void releasingToDisposedPoolDisposesElement(Function<PoolBuilder<PoolableTest, ?>, AbstractPool<PoolableTest>> configAdjuster) {
		AtomicInteger cleanerCount = new AtomicInteger();
		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				.from(Mono.fromCallable(PoolableTest::new))
				.sizeBetween(3, 3)
				.releaseHandler(p -> Mono.fromRunnable(cleanerCount::incrementAndGet))
				.evictionPredicate((poolable, metadata) -> !poolable.isHealthy());
		AbstractPool<PoolableTest> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		PooledRef<PoolableTest> slot1 = pool.acquire().block();
		PooledRef<PoolableTest> slot2 = pool.acquire().block();
		PooledRef<PoolableTest> slot3 = pool.acquire().block();

		assertThat(slot1).as("slot1").isNotNull();
		assertThat(slot2).as("slot2").isNotNull();
		assertThat(slot3).as("slot3").isNotNull();

		pool.dispose();

		assertThat(pool.idleSize()).as("idleSize").isZero();

		slot1.release().block();
		slot2.release().block();
		slot3.release().block();

		assertThat(cleanerCount).as("recycled elements").hasValue(0);
		assertThat(slot1.poolable().isDisposed()).as("acquired1 disposed").isTrue();
		assertThat(slot2.poolable().isDisposed()).as("acquired2 disposed").isTrue();
		assertThat(slot3.poolable().isDisposed()).as("acquired3 disposed").isTrue();
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void acquiringFromDisposedPoolFailsBorrower(Function<PoolBuilder<PoolableTest, ?>, AbstractPool<PoolableTest>> configAdjuster) {
		AtomicInteger cleanerCount = new AtomicInteger();
		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				.from(Mono.fromCallable(PoolableTest::new))
				.sizeBetween(0, 3)
				.releaseHandler(p -> Mono.fromRunnable(cleanerCount::incrementAndGet))
				.evictionPredicate((poolable, metadata) -> !poolable.isHealthy());
		AbstractPool<PoolableTest> pool = configAdjuster.apply(builder);

		assertThat(pool.idleSize()).as("idleSize").isZero();

		pool.dispose();

		StepVerifier.create(pool.acquire())
		            .verifyErrorMessage("Pool has been shut down");

		assertThat(cleanerCount).as("recycled elements").hasValue(0);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void poolIsDisposed(Function<PoolBuilder<PoolableTest, ?>, AbstractPool<PoolableTest>> configAdjuster) {
		PoolBuilder<PoolableTest, ?> builder = PoolBuilder
				.from(Mono.fromCallable(PoolableTest::new))
				.sizeBetween(0, 3)
				.evictionPredicate((poolable, metadata) -> !poolable.isHealthy());
		AbstractPool<PoolableTest> pool = configAdjuster.apply(builder);

		assertThat(pool.isDisposed()).as("not yet disposed").isFalse();

		pool.dispose();

		assertThat(pool.isDisposed()).as("disposed").isTrue();
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void disposingPoolClosesCloseable(Function<PoolBuilder<Formatter, ?>, AbstractPool<Formatter>> configAdjuster) {
		Formatter uniqueElement = new Formatter();

		PoolBuilder<Formatter, ?> builder = PoolBuilder
				.from(Mono.just(uniqueElement))
				.sizeBetween(1, 1)
				.evictionPredicate((poolable, metadata) -> true);
		AbstractPool<Formatter> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		pool.dispose();

		assertThatExceptionOfType(FormatterClosedException.class)
				.isThrownBy(uniqueElement::flush);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void disposeLaterIsLazy(Function<PoolBuilder<Formatter, ?>, AbstractPool<Formatter>> configAdjuster) {
		Formatter uniqueElement = new Formatter();

		PoolBuilder<Formatter, ?> builder = PoolBuilder
				.from(Mono.just(uniqueElement))
				.sizeBetween(1, 1)
				.evictionPredicate((poolable, metadata) -> true);
		AbstractPool<Formatter> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		Mono<Void> disposeMono = pool.disposeLater();
		Mono<Void> disposeMono2 = pool.disposeLater();

		assertThat(disposeMono)
				.isNotSameAs(disposeMono2);

		assertThatCode(uniqueElement::flush)
				.doesNotThrowAnyException();
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void disposeLaterCompletesWhenAllReleased(Function<PoolBuilder<AtomicBoolean, ?>, AbstractPool<AtomicBoolean>> configAdjuster) {
		List<AtomicBoolean> elements = Arrays.asList(new AtomicBoolean(), new AtomicBoolean(), new AtomicBoolean());
		AtomicInteger index = new AtomicInteger(0);

		PoolBuilder<AtomicBoolean, ?> builder = PoolBuilder
				.from(Mono.fromCallable(() -> elements.get(index.getAndIncrement())))
				.sizeBetween(3, 3)
				.evictionPredicate((poolable, metadata) -> true)
				.destroyHandler(ab -> Mono.fromRunnable(() -> ab.set(true)));
		AbstractPool<AtomicBoolean> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		Mono<Void> disposeMono = pool.disposeLater();

		assertThat(elements).as("before disposeLater subscription").noneMatch(AtomicBoolean::get);

		disposeMono.block();

		assertThat(elements).as("after disposeLater done").allMatch(AtomicBoolean::get);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void disposeLaterReleasedConcurrently(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) {
		AtomicInteger live = new AtomicInteger(0);

		PoolBuilder<Integer, ?> builder = PoolBuilder
				.from(Mono.fromCallable(live::getAndIncrement))
				.sizeBetween(3, 3)
				.evictionPredicate((poolable, metadata) -> true)
				.destroyHandler(ab -> Mono.delay(Duration.ofMillis(500))
				                          .doOnNext(v -> live.decrementAndGet())
				                          .then());
		AbstractPool<Integer> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		Mono<Void> disposeMono = pool.disposeLater();

		assertThat(live).as("before disposeLater subscription").hasValue(3);

		disposeMono.subscribe();

		Awaitility.await().atMost(600, TimeUnit.MILLISECONDS) //serially would add up to 1500ms
		          .untilAtomic(live, CoreMatchers.is(0));
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void allocatorErrorInAcquireDrains_NoMinSize(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		AtomicInteger errorThrown = new AtomicInteger();
		PoolBuilder<String, ?> builder = PoolBuilder
				.from(Mono.delay(Duration.ofMillis(50))
						.flatMap(l -> {
					if (errorThrown.compareAndSet(0, 1)) {
						return Mono.<String>error(new IllegalStateException("boom"));
					}
					else {
						return Mono.just("success");
					}
				}))
				.sizeBetween(0, 1)
				.evictionPredicate((poolable, metadata) -> true);
		AbstractPool<String> pool = configAdjuster.apply(builder);

		StepVerifier.create(Flux.just(pool.acquire().onErrorResume(e -> Mono.empty()), pool.acquire())
								.flatMap(Function.identity()))
					.expectNextMatches(pooledRef -> "success".equals(pooledRef.poolable()))
					.expectComplete()
					.verify(Duration.ofSeconds(5));
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("loops")
	void loopAllocatorErrorInAcquireDrains_NoMinSize(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		TestLogger testLogger = new TestLogger();
		Loggers.useCustomLoggers(s -> testLogger);
		try {
			for (int i = 0; i < 100; i++) {
				allocatorErrorInAcquireDrains_NoMinSize(configAdjuster);
			}
			String log = testLogger.getOutContent();
			if (!log.isEmpty()) {
				System.out.println("Log in loop test, removed duplicated lines:");
				Stream.of(log.split("\n"))
				      .distinct()
				      .forEach(System.out::println);
			}
		}
		finally {
			Loggers.resetLoggerFactory();
		}
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void allocatorErrorInAcquireDrains_WithMinSize(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		PoolBuilder<String, ?> builder = PoolBuilder
				.from(Mono.delay(Duration.ofMillis(50))
						.flatMap(l -> Mono.<String>error(new IllegalStateException("boom"))))
				.sizeBetween(3, 3)
				.evictionPredicate((poolable, metadata) -> true);
		AbstractPool<String> pool = configAdjuster.apply(builder);

		StepVerifier.create(
				Flux.just(pool.acquire().onErrorResume(e -> Mono.empty()),
						pool.acquire().onErrorResume(e -> Mono.empty()),
						pool.acquire())
				    .flatMap(Function.identity()))
		            .expectErrorSatisfies(e -> assertThat(e).isInstanceOf(IllegalStateException.class)
		                                                    .hasMessage("boom"))
		            .verify(Duration.ofSeconds(5));
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("loops")
	void loopAllocatorErrorInAcquireDrains_WithMinSize(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		TestLogger testLogger = new TestLogger();
		Loggers.useCustomLoggers(s -> testLogger);
		try {
			for (int i = 0; i < 100; i++) {
				allocatorErrorInAcquireDrains_WithMinSize(configAdjuster);
			}
			String log = testLogger.getOutContent();
			if (!log.isEmpty()) {
				System.out.println("Log in loop test, removed duplicated lines:");
				Stream.of(log.split("\n"))
				      .distinct()
				      .forEach(System.out::println);
			}
		}
		finally {
			Loggers.resetLoggerFactory();
		}
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void allocatorErrorInAcquireIsPropagated(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		PoolBuilder<String, ?> builder = PoolBuilder
				.from(Mono.<String>error(new IllegalStateException("boom")))
				.sizeBetween(0, 1)
				.evictionPredicate((poolable, metadata) -> true);
		AbstractPool<String> pool = configAdjuster.apply(builder);

		StepVerifier.create(pool.acquire())
		            .verifyErrorSatisfies(e -> assertThat(e).isInstanceOf(IllegalStateException.class)
		                                                    .hasMessage("boom"));
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void allocatorErrorInWarmupIsPropagated(Function<PoolBuilder<Object, ?>, AbstractPool<Object>> configAdjuster) {
		final PoolBuilder<Object, ?> builder = PoolBuilder
				.from(Mono.error(new IllegalStateException("boom")))
				.sizeBetween(1, 1)
				.evictionPredicate((poolable, metadata) -> true);
		AbstractPool<Object> pool = configAdjuster.apply(builder);

		StepVerifier.create(pool.warmup())
		            .verifyErrorSatisfies(e -> assertThat(e).isInstanceOf(IllegalStateException.class)
		                                                    .hasMessage("boom"));
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void discardCloseableWhenCloseFailureLogs(Function<PoolBuilder<Closeable, ?>, AbstractPool<Closeable>> configAdjuster) {
		TestLogger testLogger = new TestLogger();
		Loggers.useCustomLoggers(it -> testLogger);
		try {
			Closeable closeable = () -> {
				throw new IOException("boom");
			};

			PoolBuilder<Closeable, ?> builder = PoolBuilder
					.from(Mono.just(closeable))
					.sizeBetween(1, 1)
					.evictionPredicate((poolable, metadata) -> true);
			AbstractPool<Closeable> pool = configAdjuster.apply(builder);
			pool.warmup().block();

			pool.dispose();

			assertThat(testLogger.getOutContent())
					.contains("Failure while discarding a released Poolable that is Closeable, could not close - java.io.IOException: boom");
		}
		finally {
			Loggers.resetLoggerFactory();
		}
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void pendingTimeoutNotImpactedByLongAllocation(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		VirtualTimeScheduler vts1 = VirtualTimeScheduler.getOrSet();

		try {
			PoolBuilder<String, ?> builder = PoolBuilder
					.from(Mono.just("delayed").delaySubscription(Duration.ofMillis(500)))
					.sizeBetween(0, 1);
			AbstractPool<String> pool = configAdjuster.apply(builder);

			StepVerifier.withVirtualTime(
					() -> pool.acquire(Duration.ofMillis(100)),
					() -> vts1,
					1)
			            .expectSubscription()
			            .expectNoEvent(Duration.ofMillis(500))
			            .assertNext(n -> {
			            	Assertions.assertThat(n.poolable()).isEqualTo("delayed");
			            	n.release()
				             .subscribe();
			            })
			            .verifyComplete();


			VirtualTimeScheduler vts2 = VirtualTimeScheduler.getOrSet();

			StepVerifier.withVirtualTime(
					() -> pool.acquire(Duration.ofMillis(100)).map(PooledRef::poolable),
					() -> vts2,
					1)
			            .expectSubscription()
			            .expectNext("delayed")
			            .verifyComplete();
		}
		finally {
			VirtualTimeScheduler.reset();
		}
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void pendingTimeoutImpactedByLongRelease(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		PoolBuilder<String, ?> builder = PoolBuilder
				.from(Mono.just("instant"))
				.sizeBetween(0, 1);
		AbstractPool<String> pool = configAdjuster.apply(builder);

		PooledRef<String> uniqueRef = pool.acquire().block();

		StepVerifier.withVirtualTime(() -> pool.acquire(Duration.ofMillis(100)).map(PooledRef::poolable))
		            .expectSubscription()
		            .expectNoEvent(Duration.ofMillis(100))
		            .thenAwait(Duration.ofMillis(1))
		            .verifyErrorSatisfies(e -> assertThat(e)
				            .isInstanceOf(TimeoutException.class)
				            .isExactlyInstanceOf(PoolAcquireTimeoutException.class)
				            .hasMessage("Pool#acquire(Duration) has been pending for more than the configured timeout of 100ms"));
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void pendingTimeoutDoesntCauseExtraReleasePostTimeout(Function<PoolBuilder<AtomicInteger, ?>, AbstractPool<AtomicInteger>> configAdjuster) {
		AtomicInteger resource = new AtomicInteger();
		PoolBuilder<AtomicInteger, ?> builder = PoolBuilder
				.from(Mono.just(resource))
				.releaseHandler(atomic -> Mono.fromRunnable(atomic::incrementAndGet))
				.sizeBetween(0, 1);
		AbstractPool<AtomicInteger> pool = configAdjuster.apply(builder);

		PooledRef<AtomicInteger> uniqueRef = pool.acquire().block();
		assert uniqueRef != null;

		StepVerifier.withVirtualTime(() -> pool.acquire(Duration.ofMillis(100)).map(PooledRef::poolable))
		            .expectSubscription()
		            .expectNoEvent(Duration.ofMillis(100))
		            .thenAwait(Duration.ofMillis(1))
		            .verifyErrorSatisfies(e -> assertThat(e)
				            .isInstanceOf(TimeoutException.class)
				            .isExactlyInstanceOf(PoolAcquireTimeoutException.class)
				            .hasMessage("Pool#acquire(Duration) has been pending for more than the configured timeout of 100ms"));

		assertThat(resource).as("post timeout but before resource available").hasValue(0);

		uniqueRef.release().block();
		assertThat(resource).as("post timeout and after resource available").hasValue(1);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void eachBorrowerCanOnlyReleaseOnce(Function<PoolBuilder<AtomicInteger, ?>, AbstractPool<AtomicInteger>> configAdjuster) {
		AtomicInteger resource = new AtomicInteger();
		PoolBuilder<AtomicInteger, ?> builder = PoolBuilder
				.from(Mono.just(resource))
				.releaseHandler(atomic -> Mono.fromRunnable(atomic::incrementAndGet))
				.sizeBetween(0, 1);
		AbstractPool<AtomicInteger> pool = configAdjuster.apply(builder);

		PooledRef<AtomicInteger> acquire1 = pool.acquire().block();

		Mono<Void> releaserMono1 = acquire1.release();
		releaserMono1.block();
		releaserMono1.block();
		Mono<Void> releaserMono2 = acquire1.release();
		releaserMono2.block();
		releaserMono2.block();

		assertThat(resource).as("first acquire multi-release").hasValue(1);

		Mono<Void> releaserMono3 = pool.acquire().block().release();
		releaserMono3.block();
		releaserMono3.block();

		assertThat(resource).as("second acquire multi-release").hasValue(2);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void eachBorrowerCanOnlyInvalidateOnce(Function<PoolBuilder<AtomicInteger, ?>, AbstractPool<AtomicInteger>> configAdjuster) {
		AtomicInteger resource = new AtomicInteger();
		PoolBuilder<AtomicInteger, ?> builder = PoolBuilder
				.from(Mono.just(resource))
				.destroyHandler(atomic -> Mono.fromRunnable(atomic::incrementAndGet))
				.sizeBetween(0, 1);
		AbstractPool<AtomicInteger> pool = configAdjuster.apply(builder);

		PooledRef<AtomicInteger> acquire1 = pool.acquire().block();

		Mono<Void> invalidateMono1 = acquire1.invalidate();
		invalidateMono1.block();
		invalidateMono1.block();
		final Mono<Void> invalidateMono2 = acquire1.invalidate();
		invalidateMono2.block();
		invalidateMono2.block();

		assertThat(resource).as("first acquire multi-invalidate").hasValue(1);

		final Mono<Void> invalidateMono3 = pool.acquire().block().invalidate();
		invalidateMono3.block();
		invalidateMono3.block();

		assertThat(resource).as("second acquire multi-invalidate").hasValue(2);
	}

	// === METRICS ===

	protected TestUtils.InMemoryPoolMetrics recorder;

	@BeforeEach
	void initRecorder() {
		this.recorder = new TestUtils.InMemoryPoolMetrics();
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void recordsAllocationCountInWarmup(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		AtomicBoolean flip = new AtomicBoolean();
		//note the starter method here is irrelevant, only the config is created and passed to createPool
		PoolBuilder<String, ?> builder = PoolBuilder
				.from(Mono.defer(() -> {
					if (flip.compareAndSet(false, true)) {
						return Mono.just("foo");
					}
					else {
						flip.compareAndSet(true, false);
						return Mono.error(new IllegalStateException("boom"));
					}
				}))
				.sizeBetween(10, Integer.MAX_VALUE)
				.metricsRecorder(recorder)
				.clock(recorder);
		AbstractPool<String> pool = configAdjuster.apply(builder);

		assertThatIllegalStateException()
				.isThrownBy(() -> pool.warmup().block());

		assertThat(recorder.getAllocationTotalCount()).isEqualTo(2);
		assertThat(recorder.getAllocationSuccessCount())
				.isOne()
				.isEqualTo(recorder.getAllocationSuccessHistogram().getTotalCount());
		assertThat(recorder.getAllocationErrorCount())
				.isOne()
				.isEqualTo(recorder.getAllocationErrorHistogram().getTotalCount());
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void recordsAllocationCountInBorrow(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		AtomicBoolean flip = new AtomicBoolean();
		//note the starter method here is irrelevant, only the config is created and passed to createPool
		PoolBuilder<String, ?> builder = PoolBuilder
				.from(Mono.defer(() -> {
					if (flip.compareAndSet(false, true)) {
						return Mono.just("foo");
					}
					else {
						flip.compareAndSet(true, false);
						return Mono.error(new IllegalStateException("boom"));
					}
				}))
				.metricsRecorder(recorder)
				.clock(recorder);
		Pool<String> pool = configAdjuster.apply(builder);

		pool.acquire().block(); //success
		pool.acquire().map(PooledRef::poolable)
		    .onErrorReturn("error").block(); //error
		pool.acquire().block(); //success
		pool.acquire().map(PooledRef::poolable)
		    .onErrorReturn("error").block(); //error
		pool.acquire().block(); //success
		pool.acquire().map(PooledRef::poolable)
		    .onErrorReturn("error").block(); //error

		assertThat(recorder.getAllocationTotalCount())
				.as("total allocations")
				.isEqualTo(6);
		assertThat(recorder.getAllocationSuccessCount())
				.as("allocation success")
				.isEqualTo(3)
				.isEqualTo(recorder.getAllocationSuccessHistogram().getTotalCount());
		assertThat(recorder.getAllocationErrorCount())
				.as("allocation errors")
				.isEqualTo(3)
				.isEqualTo(recorder.getAllocationErrorHistogram().getTotalCount());
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void recordsAllocationLatenciesInWarmup(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		AtomicBoolean flip = new AtomicBoolean();
		//note the starter method here is irrelevant, only the config is created and passed to createPool
		PoolBuilder<String, ?> builder = PoolBuilder
				.from(Mono.defer(() -> {
					if (flip.compareAndSet(false, true)) {
						return Mono.just("foo").delayElement(Duration.ofMillis(100));
					}
					else {
						flip.compareAndSet(true, false);
						return Mono.delay(Duration.ofMillis(200)).then(Mono.error(new IllegalStateException("boom")));
					}
				}))
				.sizeBetween(10, Integer.MAX_VALUE)
				.metricsRecorder(recorder)
				.clock(recorder);
		AbstractPool<String> pool = configAdjuster.apply(builder);

		assertThatIllegalStateException()
				.isThrownBy(() -> pool.warmup().block());

		assertThat(recorder.getAllocationTotalCount()).isEqualTo(2);

		long minSuccess = recorder.getAllocationSuccessHistogram().getMinValue();
		long minError = recorder.getAllocationErrorHistogram().getMinValue();

		assertThat(minSuccess).as("allocation success latency").isGreaterThanOrEqualTo(100L);
		assertThat(minError).as("allocation error latency").isGreaterThanOrEqualTo(200L);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void recordsAllocationLatenciesInBorrow(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		AtomicBoolean flip = new AtomicBoolean();
		//note the starter method here is irrelevant, only the config is created and passed to createPool
		PoolBuilder<String, ?> builder = PoolBuilder
				.from(Mono.defer(() -> {
					if (flip.compareAndSet(false, true)) {
						return Mono.just("foo").delayElement(Duration.ofMillis(100));
					}
					else {
						flip.compareAndSet(true, false);
						return Mono.delay(Duration.ofMillis(200)).then(Mono.error(new IllegalStateException("boom")));
					}
				}))
				.metricsRecorder(recorder)
				.clock(recorder);
		Pool<String> pool = configAdjuster.apply(builder);

		pool.acquire().block(); //success
		pool.acquire().map(PooledRef::poolable)
		    .onErrorReturn("error").block(); //error

		long minSuccess = recorder.getAllocationSuccessHistogram().getMinValue();
		assertThat(minSuccess)
				.as("allocation success latency")
				.isGreaterThanOrEqualTo(100L);

		long minError = recorder.getAllocationErrorHistogram().getMinValue();
		assertThat(minError)
				.as("allocation error latency")
				.isGreaterThanOrEqualTo(200L);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void recordsResetLatencies(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		AtomicBoolean flip = new AtomicBoolean();
		//note the starter method here is irrelevant, only the config is created and passed to createPool
		PoolBuilder<String, ?> builder = PoolBuilder
				.from(Mono.just("foo"))
				.releaseHandler(s -> {
					if (flip.compareAndSet(false,
							true)) {
						return Mono.delay(Duration.ofMillis(
								100))
						           .then();
					}
					else {
						flip.compareAndSet(true,
								false);
						return Mono.empty();
					}
				})
				.metricsRecorder(recorder)
				.clock(recorder);
		Pool<String> pool = configAdjuster.apply(builder);

		pool.acquire().flatMap(PooledRef::release).block();
		pool.acquire().flatMap(PooledRef::release).block();

		assertThat(recorder.getResetCount()).as("reset").isEqualTo(2);

		long min = recorder.getResetHistogram().getMinValue();
		assertThat(min).isCloseTo(0L, Offset.offset(50L));

		long max = recorder.getResetHistogram().getMaxValue();
		assertThat(max).isCloseTo(100L, Offset.offset(50L));
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void recordsDestroyLatencies(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		AtomicBoolean flip = new AtomicBoolean();
		//note the starter method here is irrelevant, only the config is created and passed to createPool
		PoolBuilder<String, ?> builder = PoolBuilder
				.from(Mono.just("foo"))
				.evictionPredicate((poolable, metadata) -> true)
				.destroyHandler(s -> {
					if (flip.compareAndSet(false,
							true)) {
						return Mono.delay(Duration.ofMillis(500))
						           .then();
					}
					else {
						flip.compareAndSet(true,
								false);
						return Mono.empty();
					}
				})
				.metricsRecorder(recorder)
				.clock(recorder);
		Pool<String> pool = configAdjuster.apply(builder);

		pool.acquire().flatMap(PooledRef::release).block();
		pool.acquire().flatMap(PooledRef::release).block();

		//destroy is fire-and-forget so the 500ms one will not have finished
		assertThat(recorder.getDestroyCount()).as("destroy before 500ms").isEqualTo(1);

		await().pollDelay(500, TimeUnit.MILLISECONDS)
		       .atMost(600, TimeUnit.MILLISECONDS)
		       .untilAsserted(() -> assertThat(recorder.getDestroyCount()).as("destroy after 500ms").isEqualTo(2));

		long min = recorder.getDestroyHistogram().getMinValue();
		assertThat(min).isCloseTo(0L, Offset.offset(50L));

		long max = recorder.getDestroyHistogram().getMaxValue();
		assertThat(max).isCloseTo(500L, Offset.offset(50L));
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void recordsResetVsRecycle(Function<PoolBuilder<String, ?>, AbstractPool<String>> configAdjuster) {
		AtomicReference<String> content = new AtomicReference<>("foo");
		//note the starter method here is irrelevant, only the config is created and passed to createPool
		PoolBuilder<String, ?> builder = PoolBuilder
				.from(Mono.fromCallable(() -> content.getAndSet("bar")))
				.evictionPredicate((poolable, metadata) -> "foo".equals(poolable))
				.metricsRecorder(recorder)
				.clock(recorder);
		Pool<String> pool = configAdjuster.apply(builder);

		pool.acquire().flatMap(PooledRef::release).block();
		pool.acquire().flatMap(PooledRef::release).block();

		assertThat(recorder.getResetCount()).as("reset").isEqualTo(2);
		assertThat(recorder.getDestroyCount()).as("destroy").isEqualTo(1);
		assertThat(recorder.getRecycledCount()).as("recycle").isEqualTo(1);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void recordsLifetime(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) throws InterruptedException {
		AtomicInteger allocCounter = new AtomicInteger();
		AtomicInteger destroyCounter = new AtomicInteger();
		//note the starter method here is irrelevant, only the config is created and passed to createPool
		PoolBuilder<Integer, ?> builder = PoolBuilder
				.from(Mono.fromCallable(allocCounter::incrementAndGet))
				.sizeBetween(0, 2)
				.evictionPredicate((poolable, metadata) -> metadata.acquireCount() >= 2)
				.destroyHandler(i -> Mono.fromRunnable(destroyCounter::incrementAndGet))
				.metricsRecorder(recorder)
				.clock(recorder);
		Pool<Integer> pool = configAdjuster.apply(builder);

		//first round
		PooledRef<Integer> ref1 = pool.acquire().block();
		PooledRef<Integer> ref2 = pool.acquire().block();
		Thread.sleep(250);
		ref1.release().block();
		ref2.release().block();

		//second round
		ref1 = pool.acquire().block();
		ref2 = pool.acquire().block();
		Thread.sleep(300);
		ref1.release().block();
		ref2.release().block();

		//extra acquire to show 3 allocations
		pool.acquire().block().release().block();

		assertThat(allocCounter).as("allocations").hasValue(3);
		assertThat(destroyCounter).as("destructions").hasValue(2);

		assertThat(recorder.getLifetimeHistogram().getMinNonZeroValue())
				.isCloseTo(550L, Offset.offset(30L));
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void recordsIdleTimeFromConstructor(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) throws InterruptedException {
		AtomicInteger allocCounter = new AtomicInteger();
		//note the starter method here is irrelevant, only the config is created and passed to createPool
		PoolBuilder<Integer, ?> builder = PoolBuilder
				.from(Mono.fromCallable(allocCounter::incrementAndGet))
				.sizeBetween(2, 2)
				.metricsRecorder(recorder)
				.clock(recorder);
		Pool<Integer> pool = configAdjuster.apply(builder);
		pool.warmup().block();


		//wait 125ms and 250ms before first acquire respectively
		Thread.sleep(125);
		pool.acquire().block();
		Thread.sleep(125);
		pool.acquire().block();

		assertThat(allocCounter).as("allocations").hasValue(2);

		assertThat(recorder.getIdleTimeHistogram().getMinNonZeroValue())
				.as("min idle time")
				.isCloseTo(125L, Offset.offset(25L));
		assertThat(recorder.getIdleTimeHistogram().getMaxValue())
				.as("max idle time")
				.isCloseTo(250L, Offset.offset(25L));
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void recordsIdleTimeBetweenAcquires(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) throws InterruptedException {
		AtomicInteger allocCounter = new AtomicInteger();
		//note the starter method here is irrelevant, only the config is created and passed to createPool
		PoolBuilder<Integer, ?> builder = PoolBuilder
				.from(Mono.fromCallable(allocCounter::incrementAndGet))
				.sizeBetween(2, 2)
				.metricsRecorder(recorder)
				.clock(recorder);
		Pool<Integer> pool = configAdjuster.apply(builder);
		pool.warmup().block();

		//both idle for 125ms
		Thread.sleep(125);

		//first round
		PooledRef<Integer> ref1 = pool.acquire().block();
		PooledRef<Integer> ref2 = pool.acquire().block();

		ref1.release().block();
		//ref1 idle for 100ms more than ref2
		Thread.sleep(100);
		ref2.release().block();
		//ref2 idle for 40ms
		Thread.sleep(200);

		ref1 = pool.acquire().block();
		ref2 = pool.acquire().block();
		//not idle after that

		assertThat(allocCounter).as("allocations").hasValue(2);

		assertThat(recorder.getIdleTimeHistogram().getMinNonZeroValue())
				.as("min idle time")
				.isCloseTo(125L, Offset.offset(20L));

		assertThat(recorder.getIdleTimeHistogram().getMaxValue())
				.as("max idle time")
				.isCloseTo(300L, Offset.offset(40L));
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void acquireTimeout(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) {
		AtomicInteger allocCounter = new AtomicInteger();
		AtomicInteger didReset = new AtomicInteger();
		//note the starter method here is irrelevant, only the config is created and passed to createPool
		PoolBuilder<Integer, ?> builder = PoolBuilder
				.from(Mono.fromCallable(allocCounter::incrementAndGet))
				.releaseHandler(i -> Mono.fromRunnable(didReset::incrementAndGet))
				.sizeBetween(0, 1);
		Pool<Integer> pool = configAdjuster.apply(builder);

		PooledRef<Integer> uniqueElement = Objects.requireNonNull(pool.acquire().block());

		assertThat(uniqueElement.metadata().acquireCount()).isOne();

		assertThatExceptionOfType(RuntimeException.class)
				.isThrownBy(() -> pool.acquire().timeout(Duration.ofMillis(50)).block())
				.withCauseExactlyInstanceOf(TimeoutException.class);

		assertThat(didReset).hasValue(0);

		uniqueElement.release().block();
		assertThat(uniqueElement.metadata().acquireCount()).as("acquireCount post timeout-then-release").isOne();

		assertThat(pool.acquire().block()).isNotNull();
		assertThat(allocCounter).as("final allocation count").hasValue(1);
		assertThat(didReset).hasValue(1);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void instrumentedPoolsMetricsAreSelfViews(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) {
		PoolBuilder<Integer, ?> builder = PoolBuilder.from(Mono.just(1));
		Pool<Integer> pool = configAdjuster.apply(builder);

		assertThat(pool).isInstanceOf(InstrumentedPool.class);

		PoolMetrics metrics = ((InstrumentedPool) pool).metrics();

		assertThat(pool).isSameAs(metrics);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void instrumentAllocatedIdleAcquired(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) {
		PoolBuilder<Integer, ?> builder = PoolBuilder.from(Mono.just(1))
				.sizeBetween(0, 1);
		InstrumentedPool<Integer> pool = configAdjuster.apply(builder);
		PoolMetrics poolMetrics = pool.metrics();

		assertThat(poolMetrics.allocatedSize()).as("allocated at start").isZero();

		PooledRef<Integer> ref = pool.acquire().block();

		assertThat(poolMetrics.allocatedSize()).as("allocated at first acquire").isOne();
		assertThat(poolMetrics.idleSize()).as("idle at first acquire").isZero();
		assertThat(poolMetrics.acquiredSize()).as("acquired size at first acquire").isOne();

		ref.release().block();

		assertThat(poolMetrics.allocatedSize()).as("allocated after release").isOne();
		assertThat(poolMetrics.idleSize()).as("idle after release").isOne();
		assertThat(poolMetrics.acquiredSize()).as("acquired after release").isZero();
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void instrumentAllocatedIdleAcquired_1(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster)
			throws Exception {
		PoolBuilder<Integer, ?> builder = PoolBuilder.from(Mono.just(1))
				.sizeBetween(0, 1);
		InstrumentedPool<Integer> pool = configAdjuster.apply(builder);
		PoolMetrics poolMetrics = pool.metrics();

		AtomicInteger allocated = new AtomicInteger(poolMetrics.allocatedSize());
		AtomicInteger idle = new AtomicInteger(poolMetrics.idleSize());
		AtomicInteger acquired = new AtomicInteger(poolMetrics.acquiredSize());
		AtomicReference<PooledRef<Integer>> ref = new AtomicReference<>();

		assertThat(allocated.get()).as("allocated at start").isZero();

		CountDownLatch latch1 = new CountDownLatch(1);
		pool.acquire()
				.subscribe(pooledRef -> {
					allocated.set(poolMetrics.allocatedSize());
					idle.set(poolMetrics.idleSize());
					acquired.set(poolMetrics.acquiredSize());
					ref.set(pooledRef);
					latch1.countDown();
				});

		assertThat(latch1.await(30, TimeUnit.SECONDS)).isTrue();
		assertThat(allocated.get()).as("allocated at first acquire").isOne();
		assertThat(idle.get()).as("idle at first acquire").isZero();
		assertThat(acquired.get()).as("acquired size at first acquire").isOne();

		CountDownLatch latch2 = new CountDownLatch(1);
		ref.get()
				.release()
				.subscribe(null,
						null,
						() -> {
							allocated.set(poolMetrics.allocatedSize());
							idle.set(poolMetrics.idleSize());
							acquired.set(poolMetrics.acquiredSize());
							latch2.countDown();
						});

		assertThat(latch2.await(30, TimeUnit.SECONDS)).isTrue();
		assertThat(allocated.get()).as("allocated after release").isOne();
		assertThat(idle.get()).as("idle after release").isOne();
		assertThat(acquired.get()).as("acquired after release").isZero();
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void instrumentPendingAcquire(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) {
		PoolBuilder<Integer, ?> builder = PoolBuilder.from(Mono.just(1))
				.sizeBetween(0, 1);
		InstrumentedPool<Integer> pool = configAdjuster.apply(builder);
		PoolMetrics poolMetrics = pool.metrics();

		PooledRef<Integer> ref = pool.acquire().block();

		assertThat(poolMetrics.pendingAcquireSize()).as("first acquire not pending").isZero();


		pool.acquire().subscribe();

		assertThat(poolMetrics.pendingAcquireSize()).as("second acquire put in pending").isOne();

		ref.release().block();

		assertThat(poolMetrics.pendingAcquireSize()).as("second acquire not pending after release").isZero();
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void getConfigMaxPendingAcquire(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) {
		PoolBuilder<Integer, ?> builder = PoolBuilder.from(Mono.just(1))
		                                          .maxPendingAcquire(12);
		InstrumentedPool<Integer> pool = configAdjuster.apply(builder);
		PoolMetrics poolMetrics = pool.metrics();

		assertThat(poolMetrics.getMaxPendingAcquireSize()).isEqualTo(12);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void getConfigMaxPendingAcquireUnbounded(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) {
		PoolBuilder<Integer, ?> builder = PoolBuilder.from(Mono.just(1))
		                                          .maxPendingAcquireUnbounded();
		InstrumentedPool<Integer> pool = configAdjuster.apply(builder);
		PoolMetrics poolMetrics = pool.metrics();

		assertThat(poolMetrics.getMaxPendingAcquireSize()).isEqualTo(Integer.MAX_VALUE);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void getConfigMaxSize(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) {
		PoolBuilder<Integer, ?> builder = PoolBuilder.from(Mono.just(1))
		                                          .sizeBetween(0, 22);
		InstrumentedPool<Integer> pool = configAdjuster.apply(builder);
		PoolMetrics poolMetrics = pool.metrics();

		assertThat(poolMetrics.getMaxAllocatedSize()).isEqualTo(22);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	@Tag("metrics")
	void getConfigMaxSizeUnbounded(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) {
		PoolBuilder<Integer, ?> builder = PoolBuilder.from(Mono.just(1))
		                                          .sizeUnbounded();
		InstrumentedPool<Integer> pool = configAdjuster.apply(builder);
		PoolMetrics poolMetrics = pool.metrics();

		assertThat(poolMetrics.getMaxAllocatedSize()).isEqualTo(Integer.MAX_VALUE);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void invalidateRaceIdleState(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster)
			throws Exception {
		AtomicInteger destroyCounter = new AtomicInteger();
		PoolBuilder<Integer, ?> builder = PoolBuilder.from(Mono.just(1))
		                                             .evictionPredicate((obj, meta) -> true)
		                                             .destroyHandler(i -> Mono.fromRunnable(destroyCounter::incrementAndGet))
		                                             .sizeBetween(0, 1)
		                                             .maxPendingAcquire(1);
		InstrumentedPool<Integer> pool = configAdjuster.apply(builder);
		int loops = 4000;

		final CountDownLatch latch = new CountDownLatch(loops * 2);

		for (int i = 0; i < loops; i++) {
			final PooledRef<Integer> pooledRef = pool.acquire().block();
			RaceTestUtils.race(() -> pooledRef.release().subscribe(v -> {}, e -> latch.countDown(),
					latch::countDown),
					() -> pooledRef.invalidate().subscribe(v -> {}, e -> latch.countDown(),
							latch::countDown));
		}

		assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();

		assertThat(destroyCounter).hasValue(4000);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void releaseOnAcquire(Function<PoolBuilder<Integer, ?>, AbstractPool<Integer>> configAdjuster) {
		AtomicInteger intSource = new AtomicInteger();
		AtomicInteger releasedIndex = new AtomicInteger();
		ConcurrentLinkedQueue<Integer> destroyed = new ConcurrentLinkedQueue<>();

		PoolBuilder<Integer, PoolConfig<Integer>> builder =
				PoolBuilder.from(Mono.fromCallable(intSource::incrementAndGet))
				           .evictionPredicate((obj, meta) -> obj <= releasedIndex.get())
				           .destroyHandler(i -> Mono.fromRunnable(() -> destroyed.offer(i)))
				           .sizeBetween(0, 5)
				           .maxPendingAcquire(100);

		InstrumentedPool<Integer> pool = configAdjuster.apply(builder);

		//acquire THEN release four refs
		List<PooledRef<Integer>> fourRefs = Arrays.asList(
				pool.acquire().block(),
				pool.acquire().block(),
				pool.acquire().block(),
				pool.acquire().block()
		);
		for (PooledRef<Integer> ref : fourRefs) {
			ref.release().block();
		}
		//4 idle
		assertThat(pool.metrics().idleSize()).as("idleSize").isEqualTo(4);
		assertThat(destroyed).as("none destroyed so far").isEmpty();

		//set the release predicate to release <= 3 on acquire
		releasedIndex.set(3);

		assertThat(pool.acquire().block().poolable()).as("acquire post idle").isEqualTo(4);
		assertThat(intSource).as("didn't generate a new value").hasValue(4);
		assertThat(destroyed).as("single acquire released all evictable idle")
		                     .containsExactly(1, 2, 3);
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void releaseRacingWithPoolClose(Function<PoolBuilder<AtomicInteger, ?>, AbstractPool<AtomicInteger>> configAdjuster)
			throws InterruptedException {
		for (int i = 0; i < 10_000; i++) {
			final int round = i + 1;
			ConcurrentLinkedQueue<AtomicInteger> created = new ConcurrentLinkedQueue<>();
			PoolBuilder<AtomicInteger, PoolConfig<AtomicInteger>> builder =
					PoolBuilder.from(Mono.fromCallable(() -> {
						AtomicInteger resource = new AtomicInteger(round);
						created.add(resource);
						return resource;
					}))
					           .evictionPredicate((obj, meta) -> false)
					           .destroyHandler(ai -> Mono.fromRunnable(() -> ai.set(-1)))
					           .sizeBetween(0, 4);

			InstrumentedPool<AtomicInteger> pool = configAdjuster.apply(builder);

			PooledRef<AtomicInteger> ref = pool.acquire().block();

			final CountDownLatch latch = new CountDownLatch(2);
			//acquire-and-release, vs pool disposal
			RaceTestUtils.race(
					() -> ref.release().doFinally(__ -> latch.countDown()).subscribe(),
					() -> pool.disposeLater().doFinally(__ -> latch.countDown()).subscribe()
			);

			assertThat(latch.await(30, TimeUnit.SECONDS)).as("latch counted down").isTrue();
			assertThat(pool.isDisposed()).as("pool isDisposed").isTrue();
			assertThat(pool.metrics().idleSize()).as("pool has no idle elements").isZero();

//			Thread.sleep(10);
			assertThat(created).allSatisfy(ai -> assertThat(ai).hasValue(-1));
		}
	}

	@ParameterizedTest
	@MethodSource("allPools")
	void poolCloseRacingWithRelease(Function<PoolBuilder<AtomicInteger, ?>, AbstractPool<AtomicInteger>> configAdjuster)
			throws InterruptedException {
		for (int i = 0; i < 10_000; i++) {
			final int round = i + 1;
			ConcurrentLinkedQueue<AtomicInteger> created = new ConcurrentLinkedQueue<>();
			PoolBuilder<AtomicInteger, PoolConfig<AtomicInteger>> builder =
					PoolBuilder.from(Mono.fromCallable(() -> {
						AtomicInteger resource = new AtomicInteger(round);
						created.add(resource);
						return resource;
					}))
					           .evictionPredicate((obj, meta) -> false)
					           .destroyHandler(ai -> Mono.fromRunnable(() -> ai.set(-1)))
					           .sizeBetween(0, 4);

			InstrumentedPool<AtomicInteger> pool = configAdjuster.apply(builder);

			PooledRef<AtomicInteger> ref = pool.acquire().block();

			final CountDownLatch latch = new CountDownLatch(2);
			//acquire-and-release, vs pool disposal
			RaceTestUtils.race(
					() -> pool.disposeLater().doFinally(__ -> latch.countDown()).subscribe(),
					() -> ref.release().doFinally(__ -> latch.countDown()).subscribe()
			);

			assertThat(latch.await(30, TimeUnit.SECONDS)).as("latch counted down").isTrue();
			assertThat(pool.isDisposed()).as("pool isDisposed").isTrue();
			assertThat(pool.metrics().idleSize()).as("pool has no idle elements").isZero();
			assertThat(created).allSatisfy(ai -> assertThat(ai).hasValue(-1));
		}
	}

}