/*
 * 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.time.Duration;
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;

import org.assertj.core.data.Offset;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.reactivestreams.Subscription;

import reactor.core.Disposable;
import reactor.core.Disposables;
import reactor.core.publisher.BaseSubscriber;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
import reactor.pool.TestUtils.PoolableTest;
import reactor.test.util.RaceTestUtils;
import reactor.util.function.Tuple2;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static reactor.pool.PoolBuilder.from;

/**
 * @author Simon Baslé
 */
class SimpleFifoPoolTest {

    private Disposable.Composite disposeList;

    @BeforeEach
    void initComposite() {
        disposeList = Disposables.composite();
    }

    @AfterEach
    void cleanup() {
        disposeList.dispose();
    }

    <T extends Disposable> T autoDispose(T toDispose) {
        disposeList.add(toDispose);
        return toDispose;
    }

    //==utils for package-private config==
    static final PoolConfig<PoolableTest> poolableTestConfig(int minSize, int maxSize, Mono<PoolableTest> allocator) {
        return from(allocator)
                .sizeBetween(minSize, maxSize)
                .releaseHandler(pt -> Mono.fromRunnable(pt::clean))
                .evictionPredicate((value, metadata) -> !value.isHealthy())
                .buildConfig();
    }

    static final PoolConfig<PoolableTest> poolableTestConfig(int minSize, int maxSize, Mono<PoolableTest> allocator, Scheduler deliveryScheduler) {
        return from(allocator)
                .sizeBetween(minSize, maxSize)
                .sizeBetween(0, maxSize)
                .releaseHandler(pt -> Mono.fromRunnable(pt::clean))
                .evictionPredicate((value, metadata) -> !value.isHealthy())
                .acquisitionScheduler(deliveryScheduler)
                .buildConfig();
    }

    static final PoolConfig<PoolableTest> poolableTestConfig(int minSize, int maxSize, Mono<PoolableTest> allocator,
            Consumer<? super PoolableTest> additionalCleaner) {
        return from(allocator)
                .sizeBetween(minSize, maxSize)
                .releaseHandler(poolableTest -> Mono.fromRunnable(() -> {
                    poolableTest.clean();
                    additionalCleaner.accept(poolableTest);
                }))
                .evictionPredicate((value, metadata) -> !value.isHealthy())
                .buildConfig();
    }
    //======

    @Test
    void demonstrateAcquireInScopePipeline() throws InterruptedException {
        AtomicInteger counter = new AtomicInteger();
        AtomicReference<String> releaseRef = new AtomicReference<>();

        SimpleFifoPool<String> pool = new SimpleFifoPool<>(
                from(Mono.just("Hello Reactive World"))
                        .sizeBetween(0, 1)
                        .releaseHandler(s -> Mono.fromRunnable(()-> releaseRef.set(s)))
                        .buildConfig());

        Flux<String> words = pool.withPoolable(poolable -> Mono.just(poolable)
                //simulate deriving a value from the resource (ie. query from DB connection)
                .map(resource -> resource.split(" "))
                //then further process the derived value to produce multiple values (ie. rows from a query)
                .flatMapIterable(Arrays::asList)
                //and all that with latency
                .delayElements(Duration.ofMillis(500)));

        words.subscribe(v -> counter.incrementAndGet());
        assertThat(counter).hasValue(0);

        Thread.sleep(1100);
        //we're in the middle of processing the "rows"
        assertThat(counter).as("before all emitted").hasValue(2);
        assertThat(releaseRef).as("still acquiring").hasValue(null);

        Thread.sleep(500);
        //we've finished processing, let's check resource has been automatically released
        assertThat(counter).as("after all emitted").hasValue(3);
        assertThat(pool.poolConfig.allocationStrategy().estimatePermitCount()).as("allocation permits").isZero();
        assertThat(pool.elements).as("available").hasSize(1);
        assertThat(releaseRef).as("released").hasValue("Hello Reactive World");
    }

    @Nested
    @DisplayName("Tests around the acquire() manual mode of acquiring")
    @SuppressWarnings("ClassCanBeStatic")
    class AcquireTest {

        @Test
        @Tag("loops")
        void allocatedReleasedOrAbortedIfCancelRequestRace_loop() throws InterruptedException {
            AtomicInteger newCount = new AtomicInteger();
            AtomicInteger releasedCount = new AtomicInteger();
            for (int i = 0; i < 100; i++) {
                allocatedReleasedOrAbortedIfCancelRequestRace(i, newCount, releasedCount, i % 2 == 0);
            }
            System.out.println("Total release of " + releasedCount.get() + " for " + newCount.get() + " created over 100 rounds");
        }

        @Test
        void allocatedReleasedOrAbortedIfCancelRequestRace() throws InterruptedException {
            allocatedReleasedOrAbortedIfCancelRequestRace(0, new AtomicInteger(), new AtomicInteger(), true);
            allocatedReleasedOrAbortedIfCancelRequestRace(1, new AtomicInteger(), new AtomicInteger(), false);

        }

        @SuppressWarnings("FutureReturnValueIgnored")
        void allocatedReleasedOrAbortedIfCancelRequestRace(int round, AtomicInteger newCount, AtomicInteger releasedCount, boolean cancelFirst) throws InterruptedException {
            Scheduler scheduler = Schedulers.newParallel("poolable test allocator");
            final ExecutorService executorService = Executors.newFixedThreadPool(2);

            try {

                PoolConfig<PoolableTest> testConfig = poolableTestConfig(0, 1,
                        Mono.defer(() -> Mono.delay(Duration.ofMillis(50)).thenReturn(new PoolableTest(newCount.incrementAndGet())))
                            .subscribeOn(scheduler),
                        pt -> releasedCount.incrementAndGet());
                SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

                //acquire the only element and capture the subscription, don't request just yet
                CountDownLatch latch = new CountDownLatch(1);
                final BaseSubscriber<PooledRef<PoolableTest>> baseSubscriber = new BaseSubscriber<PooledRef<PoolableTest>>() {
                    @Override
                    protected void hookOnSubscribe(Subscription subscription) {
                        //don't request
                        latch.countDown();
                    }
                };
                pool.acquire().subscribe(baseSubscriber);
                latch.await();

                if (cancelFirst) {
                    executorService.submit(baseSubscriber::cancel);
                    executorService.submit(baseSubscriber::requestUnbounded);
                }
                else {
                    executorService.submit(baseSubscriber::requestUnbounded);
                    executorService.submit(baseSubscriber::cancel);
                }

                //release due to cancel is async, give it ample time
                await().atMost(200, TimeUnit.MILLISECONDS).with().pollInterval(10, TimeUnit.MILLISECONDS)
                       .untilAsserted(() -> assertThat(releasedCount)
                               .as("released vs created in round " + round + (cancelFirst? " (cancel first)" : " (request first)"))
                               .hasValue(newCount.get()));
            }
            finally {
                scheduler.dispose();
                executorService.shutdownNow();
            }
        }

        @Test
        void defaultThreadDeliveringWhenHasElements() throws InterruptedException {
            AtomicReference<String> threadName = new AtomicReference<>();
            Scheduler acquireScheduler = Schedulers.newSingle("acquire");
            PoolConfig<PoolableTest> testConfig = poolableTestConfig(1, 1,
                    Mono.fromCallable(PoolableTest::new)
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")));
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);
            pool.warmup().block();

            //the pool is started and warmed up with one available element
            //we prepare to acquire it
            Mono<PooledRef<PoolableTest>> borrower = pool.acquire();
            CountDownLatch latch = new CountDownLatch(1);

            //we actually request the acquire from a separate thread and see from which thread the element was delivered
            acquireScheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName()), e -> latch.countDown(), latch::countDown));
            latch.await(1, TimeUnit.SECONDS);

            assertThat(threadName.get())
                    .startsWith("acquire-");
        }

        @Test
        void defaultThreadDeliveringWhenNoElementsButNotFull() throws InterruptedException {
            AtomicReference<String> threadName = new AtomicReference<>();
            Scheduler acquireScheduler = Schedulers.newSingle("acquire");
            PoolConfig<PoolableTest> testConfig = poolableTestConfig(0, 1,
                    Mono.fromCallable(PoolableTest::new)
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")));
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //the pool is started with no elements, and has capacity for 1
            //we prepare to acquire, which would allocate the element
            Mono<PooledRef<PoolableTest>> borrower = pool.acquire();
            CountDownLatch latch = new CountDownLatch(1);

            //we actually request the acquire from a separate thread, but the allocation also happens in a dedicated thread
            //we look at which thread the element was delivered from
            acquireScheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName()), e -> latch.countDown(), latch::countDown));
            latch.await(1, TimeUnit.SECONDS);

            assertThat(threadName.get())
                    .startsWith("poolable test allocator-");
        }

        @Test
        void defaultThreadDeliveringWhenNoElementsAndFull() throws InterruptedException {
            AtomicReference<String> threadName = new AtomicReference<>();
            Scheduler acquireScheduler = Schedulers.newSingle("acquire");
            Scheduler releaseScheduler = Schedulers.fromExecutorService(
                    Executors.newSingleThreadScheduledExecutor((r -> new Thread(r,"release"))));
            PoolConfig<PoolableTest> testConfig = poolableTestConfig(1, 1,
                    Mono.fromCallable(PoolableTest::new)
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")));
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //the pool is started with one elements, and has capacity for 1.
            //we actually first acquire that element so that next acquire will wait for a release
            PooledRef<PoolableTest> uniqueSlot = pool.acquire().block();
            assertThat(uniqueSlot).isNotNull();

            //we prepare next acquire
            Mono<PooledRef<PoolableTest>> borrower = pool.acquire();
            CountDownLatch latch = new CountDownLatch(1);

            //we actually perform the acquire from its dedicated thread, capturing the thread on which the element will actually get delivered
            acquireScheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName()),
                    e -> latch.countDown(), latch::countDown));
            //after a short while, we release the acquired unique element from a third thread
            releaseScheduler.schedule(uniqueSlot.release()::block, 500, TimeUnit.MILLISECONDS);
            latch.await(1, TimeUnit.SECONDS);

            assertThat(threadName.get())
                    .isEqualTo("release");
        }

        @Test
        @Tag("loops")
        void defaultThreadDeliveringWhenNoElementsAndFullAndRaceDrain_loop() throws InterruptedException {
            AtomicInteger releaserWins = new AtomicInteger();
            AtomicInteger borrowerWins = new AtomicInteger();

            for (int i = 0; i < 100; i++) {
                defaultThreadDeliveringWhenNoElementsAndFullAndRaceDrain(i, releaserWins, borrowerWins);
            }
            //look at the stats and show them in case of assertion error. We expect all deliveries to be on either of the racer threads.
            //we expect a subset of the deliveries to happen on the second borrower's thread
            String stats = "releaser won " + releaserWins.get() + ", borrower won " + borrowerWins.get();
            assertThat(releaserWins.get()).as("releaser should win some. " + stats).isPositive();
            assertThat(releaserWins.get() + borrowerWins.get()).as(stats).isEqualTo(100);
        }

        @Test
        void defaultThreadDeliveringWhenNoElementsAndFullAndRaceDrain() throws InterruptedException {
            AtomicInteger releaserWins = new AtomicInteger();
            AtomicInteger borrowerWins = new AtomicInteger();

            defaultThreadDeliveringWhenNoElementsAndFullAndRaceDrain(0, releaserWins, borrowerWins);

            assertThat(releaserWins.get() + borrowerWins.get()).isEqualTo(1);
        }

        void defaultThreadDeliveringWhenNoElementsAndFullAndRaceDrain(int round, AtomicInteger releaserWins, AtomicInteger borrowerWins) throws InterruptedException {
            AtomicReference<String> threadName = new AtomicReference<>();
            AtomicInteger newCount = new AtomicInteger();
            Scheduler acquire1Scheduler = Schedulers.newSingle("acquire1");
            Scheduler racerReleaseScheduler = Schedulers.fromExecutorService(
                    Executors.newSingleThreadScheduledExecutor((r -> new Thread(r,"racerRelease"))));
            Scheduler racerAcquireScheduler = Schedulers.fromExecutorService(
                    Executors.newSingleThreadScheduledExecutor((r -> new Thread(r,"racerAcquire"))));

            PoolConfig<PoolableTest> testConfig = poolableTestConfig(1, 1,
                    Mono.fromCallable(() -> new PoolableTest(newCount.getAndIncrement()))
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")));

            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //the pool is started with one elements, and has capacity for 1.
            //we actually first acquire that element so that next acquire will wait for a release
            PooledRef<PoolableTest> uniqueSlot = pool.acquire().block();
            assertThat(uniqueSlot).isNotNull();

            //we prepare next acquire
            Mono<PooledRef<PoolableTest>> borrower = pool.acquire();
            CountDownLatch latch = new CountDownLatch(1);

            //we actually perform the acquire from its dedicated thread, capturing the thread on which the element will actually get delivered
            acquire1Scheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName())
                    , e -> latch.countDown(), latch::countDown));

            //in parallel, we'll both attempt concurrent acquire AND release the unique element (each on their dedicated threads)
            racerAcquireScheduler.schedule(pool.acquire()::block, 100, TimeUnit.MILLISECONDS);
            racerReleaseScheduler.schedule(uniqueSlot.release()::block, 100, TimeUnit.MILLISECONDS);
            latch.await(1, TimeUnit.SECONDS);

            assertThat(newCount).as("created 1 poolable in round " + round).hasValue(1);

            //we expect that sometimes the race will let the second borrower thread drain, which would mean first borrower
            //will get the element delivered from racerAcquire thread. Yet the rest of the time it would get drained by racerRelease.
            if (threadName.get().startsWith("racerRelease")) releaserWins.incrementAndGet();
            else if (threadName.get().startsWith("racerAcquire")) borrowerWins.incrementAndGet();
            else System.out.println(threadName.get());
        }

        @Test
        void consistentThreadDeliveringWhenHasElements() throws InterruptedException {
            Scheduler deliveryScheduler = Schedulers.newSingle("delivery");
            AtomicReference<String> threadName = new AtomicReference<>();
            Scheduler acquireScheduler = Schedulers.newSingle("acquire");
            PoolConfig<PoolableTest> testConfig = poolableTestConfig(1, 1,
                    Mono.fromCallable(PoolableTest::new)
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")),
                    deliveryScheduler);
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //the pool is started with one available element
            //we prepare to acquire it
            Mono<PooledRef<PoolableTest>> borrower = pool.acquire();
            CountDownLatch latch = new CountDownLatch(1);

            //we actually request the acquire from a separate thread and see from which thread the element was delivered
            acquireScheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName()), e -> latch.countDown(), latch::countDown));
            latch.await(1, TimeUnit.SECONDS);

            assertThat(threadName.get())
                    .startsWith("delivery-");
        }

        @Test
        void consistentThreadDeliveringWhenNoElementsButNotFull() throws InterruptedException {
            Scheduler deliveryScheduler = Schedulers.newSingle("delivery");
            AtomicReference<String> threadName = new AtomicReference<>();
            Scheduler acquireScheduler = Schedulers.newSingle("acquire");
            PoolConfig<PoolableTest> testConfig = poolableTestConfig(0, 1,
                    Mono.fromCallable(PoolableTest::new)
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")),
                    deliveryScheduler);
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //the pool is started with no elements, and has capacity for 1
            //we prepare to acquire, which would allocate the element
            Mono<PooledRef<PoolableTest>> borrower = pool.acquire();
            CountDownLatch latch = new CountDownLatch(1);

            //we actually request the acquire from a separate thread, but the allocation also happens in a dedicated thread
            //we look at which thread the element was delivered from
            acquireScheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName()), e -> latch.countDown(), latch::countDown));
            latch.await(1, TimeUnit.SECONDS);

            assertThat(threadName.get())
                    .startsWith("delivery-");
        }

        @Test
        void consistentThreadDeliveringWhenNoElementsAndFull() throws InterruptedException {
            Scheduler deliveryScheduler = Schedulers.newSingle("delivery");
            AtomicReference<String> threadName = new AtomicReference<>();
            Scheduler acquireScheduler = Schedulers.newSingle("acquire");
            Scheduler releaseScheduler = Schedulers.fromExecutorService(
                    Executors.newSingleThreadScheduledExecutor((r -> new Thread(r,"release"))));
            PoolConfig<PoolableTest> testConfig = poolableTestConfig(1, 1,
                    Mono.fromCallable(PoolableTest::new)
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")),
                    deliveryScheduler);
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //the pool is started with one elements, and has capacity for 1.
            //we actually first acquire that element so that next acquire will wait for a release
            PooledRef<PoolableTest> uniqueSlot = pool.acquire().block();
            assertThat(uniqueSlot).isNotNull();

            //we prepare next acquire
            Mono<PooledRef<PoolableTest>> borrower = pool.acquire();
            CountDownLatch latch = new CountDownLatch(1);

            //we actually perform the acquire from its dedicated thread, capturing the thread on which the element will actually get delivered
            acquireScheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName()),
                    e -> latch.countDown(), latch::countDown));
            //after a short while, we release the acquired unique element from a third thread
            releaseScheduler.schedule(uniqueSlot.release()::block, 500, TimeUnit.MILLISECONDS);
            latch.await(1, TimeUnit.SECONDS);

            assertThat(threadName.get())
                    .startsWith("delivery-");
        }

        @Test
        @Tag("loops")
        void consistentThreadDeliveringWhenNoElementsAndFullAndRaceDrain_loop() throws InterruptedException {
            for (int i = 0; i < 100; i++) {
                consistentThreadDeliveringWhenNoElementsAndFullAndRaceDrain(i);
            }
        }

        @Test
        void consistentThreadDeliveringWhenNoElementsAndFullAndRaceDrain() throws InterruptedException {
            consistentThreadDeliveringWhenNoElementsAndFullAndRaceDrain(0);
        }

        void consistentThreadDeliveringWhenNoElementsAndFullAndRaceDrain(int i) throws InterruptedException {
            Scheduler deliveryScheduler = Schedulers.newSingle("delivery");
            AtomicReference<String> threadName = new AtomicReference<>();
            AtomicInteger newCount = new AtomicInteger();

            Scheduler acquire1Scheduler = Schedulers.newSingle("acquire1");
            Scheduler racerReleaseScheduler = Schedulers.fromExecutorService(
                    Executors.newSingleThreadScheduledExecutor((r -> new Thread(r,"racerRelease"))));
            Scheduler racerAcquireScheduler = Schedulers.newSingle("racerAcquire");

            PoolConfig<PoolableTest> testConfig = poolableTestConfig(1, 1,
                    Mono.fromCallable(() -> new PoolableTest(newCount.getAndIncrement()))
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")),
                    deliveryScheduler);
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //the pool is started with one elements, and has capacity for 1.
            //we actually first acquire that element so that next acquire will wait for a release
            PooledRef<PoolableTest> uniqueSlot = pool.acquire().block();
            assertThat(uniqueSlot).isNotNull();

            //we prepare next acquire
            Mono<PooledRef<PoolableTest>> borrower = pool.acquire();
            CountDownLatch latch = new CountDownLatch(1);

            //we actually perform the acquire from its dedicated thread, capturing the thread on which the element will actually get delivered
            acquire1Scheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName())
                    , e -> latch.countDown(), latch::countDown));

            //in parallel, we'll both attempt a second acquire AND release the unique element (each on their dedicated threads
            Mono<PooledRef<PoolableTest>> otherBorrower = pool.acquire();
            racerAcquireScheduler.schedule(() -> otherBorrower.subscribe().dispose(), 100, TimeUnit.MILLISECONDS);
            racerReleaseScheduler.schedule(uniqueSlot.release()::block, 100, TimeUnit.MILLISECONDS);
            latch.await(1, TimeUnit.SECONDS);

            //we expect that, consistently, the poolable is delivered on a `delivery` thread
            assertThat(threadName.get()).as("round #" + i).startsWith("delivery-");

            //we expect that only 1 element was created
            assertThat(newCount).as("elements created in round " + i).hasValue(1);
        }

        @Test
        @Tag("loops")
        void acquireReleaseRaceWithMinSize_loop() {
            final Scheduler racer = Schedulers.fromExecutorService(Executors.newFixedThreadPool(2));
            AtomicInteger newCount = new AtomicInteger();
            try {
                PoolConfig<PoolableTest> testConfig = from(Mono.fromCallable(() -> new PoolableTest(newCount.getAndIncrement())))
                        .sizeBetween(4, 5)
                        .buildConfig();
                SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

                for (int i = 0; i < 100; i++) {
                    RaceTestUtils.race(() -> pool.acquire().block().release().block(),
                            () -> pool.acquire().block().release().block(),
                            racer);
                }
                //we expect that only 3 element was created
                assertThat(newCount).as("elements created in total").hasValue(4);
            }
            finally {
                racer.dispose();
            }
        }
    }

    @Nested
    @DisplayName("Tests around the withPoolable(Function) mode of acquiring")
    @SuppressWarnings("ClassCanBeStatic")
    class AcquireInScopeTest {

        @Test
        @DisplayName("acquire delays instead of allocating past maxSize")
        void acquireDelaysNotAllocate() {
            AtomicInteger newCount = new AtomicInteger();
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(poolableTestConfig(2, 3,
                    Mono.defer(() -> Mono.just(new PoolableTest(newCount.incrementAndGet())))));

            pool.withPoolable(poolable -> Mono.just(poolable).delayElement(Duration.ofMillis(500))).subscribe();
            pool.withPoolable(poolable -> Mono.just(poolable).delayElement(Duration.ofMillis(500))).subscribe();
            pool.withPoolable(poolable -> Mono.just(poolable).delayElement(Duration.ofMillis(500))).subscribe();

            final Tuple2<Long, PoolableTest> tuple2 = pool.withPoolable(Mono::just).elapsed().blockLast();

            assertThat(tuple2).isNotNull();

            assertThat(tuple2.getT1()).as("pending for 500ms").isCloseTo(500L, Offset.offset(50L));
            assertThat(tuple2.getT2().usedUp).as("discarded twice").isEqualTo(2);
            assertThat(tuple2.getT2().id).as("id").isLessThan(4);
        }

        @Test
        @Tag("loops")
        void allocatedReleasedOrAbortedIfCancelRequestRace_loop() throws InterruptedException {
            AtomicInteger newCount = new AtomicInteger();
            AtomicInteger releasedCount = new AtomicInteger();
            for (int i = 0; i < 100; i++) {
                allocatedReleasedOrAbortedIfCancelRequestRace(i, newCount, releasedCount, i % 2 == 0);
            }
            System.out.println("Total release of " + releasedCount.get() + " for " + newCount.get() + " created over 100 rounds");
        }

        @Test
        void allocatedReleasedOrAbortedIfCancelRequestRace() throws InterruptedException {
            allocatedReleasedOrAbortedIfCancelRequestRace(0, new AtomicInteger(), new AtomicInteger(), true);
            allocatedReleasedOrAbortedIfCancelRequestRace(1, new AtomicInteger(), new AtomicInteger(), false);

        }

        @SuppressWarnings("FutureReturnValueIgnored")
        void allocatedReleasedOrAbortedIfCancelRequestRace(int round, AtomicInteger newCount, AtomicInteger releasedCount, boolean cancelFirst) throws InterruptedException {
            Scheduler scheduler = Schedulers.newParallel("poolable test allocator");

            PoolConfig<PoolableTest> testConfig = poolableTestConfig(0, 1,
                    Mono.defer(() -> Mono.delay(Duration.ofMillis(50)).thenReturn(new PoolableTest(newCount.incrementAndGet())))
                        .subscribeOn(scheduler),
                    pt -> releasedCount.incrementAndGet());
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //acquire the only element and capture the subscription, don't request just yet
            CountDownLatch latch = new CountDownLatch(1);
            final BaseSubscriber<PoolableTest> baseSubscriber = new BaseSubscriber<PoolableTest>() {
                @Override
                protected void hookOnSubscribe(Subscription subscription) {
                    //don't request
                    latch.countDown();
                }
            };
            pool.withPoolable(Mono::just).subscribe(baseSubscriber);
            latch.await();

            final ExecutorService executorService = Executors.newFixedThreadPool(2);
            if (cancelFirst) {
                executorService.submit(baseSubscriber::cancel);
                executorService.submit(baseSubscriber::requestUnbounded);
            }
            else {
                executorService.submit(baseSubscriber::requestUnbounded);
                executorService.submit(baseSubscriber::cancel);
            }

            //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 vs created in round " + round + (cancelFirst? " (cancel first)" : " (request first)"))
                           .hasValue(newCount.get()));
        }

        @Test
        void defaultThreadDeliveringWhenHasElements() throws InterruptedException {
            AtomicReference<String> threadName = new AtomicReference<>();
            Scheduler acquireScheduler = Schedulers.newSingle("acquire");
            PoolConfig<PoolableTest> testConfig = poolableTestConfig(1, 1,
                    Mono.fromCallable(PoolableTest::new)
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")));
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);
            pool.warmup().block();

            //the pool is started and warmed up with one available element
            //we prepare to acquire it
            Mono<PoolableTest> borrower = Mono.fromDirect(pool.withPoolable(Mono::just));
            CountDownLatch latch = new CountDownLatch(1);

            //we actually request the acquire from a separate thread and see from which thread the element was delivered
            acquireScheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName()), e -> latch.countDown(), latch::countDown));
            latch.await(1, TimeUnit.SECONDS);

            assertThat(threadName.get())
                    .startsWith("acquire-");
        }

        @Test
        void defaultThreadDeliveringWhenNoElementsButNotFull() throws InterruptedException {
            AtomicReference<String> threadName = new AtomicReference<>();
            Scheduler acquireScheduler = Schedulers.newSingle("acquire");
            PoolConfig<PoolableTest> testConfig = poolableTestConfig(0, 1,
                    Mono.fromCallable(PoolableTest::new)
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")));
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //the pool is started with no elements, and has capacity for 1
            //we prepare to acquire, which would allocate the element
            Mono<PoolableTest> borrower = Mono.fromDirect(pool.withPoolable(Mono::just));
            CountDownLatch latch = new CountDownLatch(1);

            //we actually request the acquire from a separate thread, but the allocation also happens in a dedicated thread
            //we look at which thread the element was delivered from
            acquireScheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName()), e -> latch.countDown(), latch::countDown));
            latch.await(1, TimeUnit.SECONDS);

            assertThat(threadName.get())
                    .startsWith("poolable test allocator-");
        }

        @Test
        void defaultThreadDeliveringWhenNoElementsAndFull() throws InterruptedException {
            AtomicReference<String> threadName = new AtomicReference<>();
            Scheduler acquireScheduler = Schedulers.newSingle("acquire");
            Scheduler releaseScheduler = Schedulers.fromExecutorService(
                    Executors.newSingleThreadScheduledExecutor((r -> new Thread(r,"release"))));
            PoolConfig<PoolableTest> testConfig = poolableTestConfig(1, 1,
                    Mono.fromCallable(PoolableTest::new)
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")));
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //the pool is started with one elements, and has capacity for 1.
            //we actually first acquire that element so that next acquire will wait for a release
            PooledRef<PoolableTest> uniqueSlot = pool.acquire().block();
            assertThat(uniqueSlot).isNotNull();

            //we prepare next acquire
            Mono<PoolableTest> borrower = Mono.fromDirect(pool.withPoolable(Mono::just));
            CountDownLatch latch = new CountDownLatch(1);

            //we actually perform the acquire from its dedicated thread, capturing the thread on which the element will actually get delivered
            acquireScheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName()),
                    e -> latch.countDown(), latch::countDown));
            //after a short while, we release the acquired unique element from a third thread
            releaseScheduler.schedule(uniqueSlot.release()::block, 500, TimeUnit.MILLISECONDS);
            latch.await(1, TimeUnit.SECONDS);

            assertThat(threadName.get())
                    .isEqualTo("release");
        }

        @Test
        @Tag("loops")
        void defaultThreadDeliveringWhenNoElementsAndFullAndRaceDrain_loop() throws InterruptedException {
            AtomicInteger releaserWins = new AtomicInteger();
            AtomicInteger borrowerWins = new AtomicInteger();

            for (int i = 0; i < 100; i++) {
                defaultThreadDeliveringWhenNoElementsAndFullAndRaceDrain(i, releaserWins, borrowerWins);
            }
            //look at the stats and show them in case of assertion error. We expect all deliveries to be on either of the racer threads.
            //we expect a subset of the deliveries to happen on the second borrower's thread
            String stats = "releaser won " + releaserWins.get() + ", borrower won " + borrowerWins.get();
            assertThat(releaserWins.get()).as("releaser should win some. " + stats).isPositive();
            assertThat(releaserWins.get() + borrowerWins.get()).as(stats).isEqualTo(100);
        }

        @Test
        void defaultThreadDeliveringWhenNoElementsAndFullAndRaceDrain() throws InterruptedException {
            AtomicInteger releaserWins = new AtomicInteger();
            AtomicInteger borrowerWins = new AtomicInteger();

            defaultThreadDeliveringWhenNoElementsAndFullAndRaceDrain(0, releaserWins, borrowerWins);

            assertThat(releaserWins.get() + borrowerWins.get()).isEqualTo(1);
        }

        void defaultThreadDeliveringWhenNoElementsAndFullAndRaceDrain(int round, AtomicInteger releaserWins, AtomicInteger borrowerWins) throws InterruptedException {
            AtomicReference<String> threadName = new AtomicReference<>();
            AtomicInteger newCount = new AtomicInteger();
            Scheduler acquire1Scheduler = Schedulers.newSingle("acquire1");
            Scheduler racerReleaseScheduler = Schedulers.fromExecutorService(
                    Executors.newSingleThreadScheduledExecutor((r -> new Thread(r,"racerRelease"))));
            Scheduler racerAcquireScheduler = Schedulers.fromExecutorService(
                    Executors.newSingleThreadScheduledExecutor((r -> new Thread(r,"racerAcquire"))));
            Scheduler allocatorScheduler = Schedulers.newParallel("poolable test allocator");

            try {
                PoolConfig<PoolableTest> testConfig = poolableTestConfig(1, 1,
                        Mono.fromCallable(() -> new PoolableTest(newCount.getAndIncrement()))
                            .subscribeOn(allocatorScheduler));

                SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

                //the pool is started with one elements, and has capacity for 1.
                //we actually first acquire that element so that next acquire will wait for a release
                PooledRef<PoolableTest> uniqueSlot = pool.acquire().block();
                assertThat(uniqueSlot).isNotNull();

                //we prepare next acquire
                Mono<PoolableTest> borrower = Mono.fromDirect(pool.withPoolable(Mono::just));
                CountDownLatch latch = new CountDownLatch(3);

                //we actually perform the acquire from its dedicated thread, capturing the thread on which the element will actually get delivered
                acquire1Scheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName())
                        , e -> latch.countDown(), latch::countDown));

                //in parallel, we'll both attempt concurrent acquire AND release the unique element (each on their dedicated threads)
                racerAcquireScheduler.schedule(() -> {
                    pool.acquire().block();
                    latch.countDown();
                }, 100, TimeUnit.MILLISECONDS);
                racerReleaseScheduler.schedule(() -> {
                    uniqueSlot.release().block();
                    latch.countDown();
                }, 100, TimeUnit.MILLISECONDS);

                assertThat(latch.await(1, TimeUnit.SECONDS)).as("1s").isTrue();

                assertThat(newCount).as("created 1 poolable in round " + round).hasValue(1);

                //we expect that sometimes the race will let the second borrower thread drain, which would mean first borrower
                //will get the element delivered from racerAcquire thread. Yet the rest of the time it would get drained by racerRelease.
                if (threadName.get().startsWith("racerRelease")) releaserWins.incrementAndGet();
                else if (threadName.get().startsWith("racerAcquire")) borrowerWins.incrementAndGet();
                else System.out.println(threadName.get());
            }
            finally {
                acquire1Scheduler.dispose();
                racerAcquireScheduler.dispose();
                racerReleaseScheduler.dispose();
                allocatorScheduler.dispose();
            }
        }

        @Test
        void consistentThreadDeliveringWhenHasElements() throws InterruptedException {
            Scheduler deliveryScheduler = Schedulers.newSingle("delivery");
            AtomicReference<String> threadName = new AtomicReference<>();
            Scheduler acquireScheduler = Schedulers.newSingle("acquire");
            PoolConfig<PoolableTest> testConfig = poolableTestConfig(1, 1,
                    Mono.fromCallable(PoolableTest::new)
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")),
                    deliveryScheduler);
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //the pool is started with one available element
            //we prepare to acquire it
            Mono<PoolableTest> borrower = Mono.fromDirect(pool.withPoolable(Mono::just));
            CountDownLatch latch = new CountDownLatch(1);

            //we actually request the acquire from a separate thread and see from which thread the element was delivered
            acquireScheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName()), e -> latch.countDown(), latch::countDown));
            latch.await(1, TimeUnit.SECONDS);

            assertThat(threadName.get())
                    .startsWith("delivery-");
        }

        @Test
        void consistentThreadDeliveringWhenNoElementsButNotFull() throws InterruptedException {
            Scheduler deliveryScheduler = Schedulers.newSingle("delivery");
            AtomicReference<String> threadName = new AtomicReference<>();
            Scheduler acquireScheduler = Schedulers.newSingle("acquire");
            PoolConfig<PoolableTest> testConfig = poolableTestConfig(0, 1,
                    Mono.fromCallable(PoolableTest::new)
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")),
                    deliveryScheduler);
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //the pool is started with no elements, and has capacity for 1
            //we prepare to acquire, which would allocate the element
            Mono<PoolableTest> borrower = Mono.fromDirect(pool.withPoolable(Mono::just));
            CountDownLatch latch = new CountDownLatch(1);

            //we actually request the acquire from a separate thread, but the allocation also happens in a dedicated thread
            //we look at which thread the element was delivered from
            acquireScheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName()), e -> latch.countDown(), latch::countDown));
            latch.await(1, TimeUnit.SECONDS);

            assertThat(threadName.get())
                    .startsWith("delivery-");
        }

        @Test
        void consistentThreadDeliveringWhenNoElementsAndFull() throws InterruptedException {
            Scheduler deliveryScheduler = Schedulers.newSingle("delivery");
            AtomicReference<String> threadName = new AtomicReference<>();
            Scheduler acquireScheduler = Schedulers.newSingle("acquire");
            Scheduler releaseScheduler = Schedulers.fromExecutorService(
                    Executors.newSingleThreadScheduledExecutor((r -> new Thread(r,"release"))));
            PoolConfig<PoolableTest> testConfig = poolableTestConfig(1, 1,
                    Mono.fromCallable(PoolableTest::new)
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")),
                    deliveryScheduler);
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //the pool is started with one elements, and has capacity for 1.
            //we actually first acquire that element so that next acquire will wait for a release
            PooledRef<PoolableTest> uniqueSlot = pool.acquire().block();
            assertThat(uniqueSlot).isNotNull();

            //we prepare next acquire
            Mono<PoolableTest> borrower = Mono.fromDirect(pool.withPoolable(Mono::just));
            CountDownLatch latch = new CountDownLatch(1);

            //we actually perform the acquire from its dedicated thread, capturing the thread on which the element will actually get delivered
            acquireScheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName()),
                    e -> latch.countDown(), latch::countDown));
            //after a short while, we release the acquired unique element from a third thread
            releaseScheduler.schedule(uniqueSlot.release()::block, 500, TimeUnit.MILLISECONDS);
            latch.await(1, TimeUnit.SECONDS);

            assertThat(threadName.get())
                    .startsWith("delivery-");
        }

        @Test
        @Tag("loops")
        void consistentThreadDeliveringWhenNoElementsAndFullAndRaceDrain_loop() throws InterruptedException {
            for (int i = 0; i < 100; i++) {
                consistentThreadDeliveringWhenNoElementsAndFullAndRaceDrain(i);
            }
        }

        @Test
        void consistentThreadDeliveringWhenNoElementsAndFullAndRaceDrain() throws InterruptedException {
            consistentThreadDeliveringWhenNoElementsAndFullAndRaceDrain(0);
        }

        void consistentThreadDeliveringWhenNoElementsAndFullAndRaceDrain(int i) throws InterruptedException {
            Scheduler deliveryScheduler = Schedulers.newSingle("delivery");
            AtomicReference<String> threadName = new AtomicReference<>();
            AtomicInteger newCount = new AtomicInteger();

            Scheduler acquire1Scheduler = Schedulers.newSingle("acquire1");
            Scheduler racerReleaseScheduler = Schedulers.fromExecutorService(
                    Executors.newSingleThreadScheduledExecutor((r -> new Thread(r,"racerRelease"))));
            Scheduler racerAcquireScheduler = Schedulers.newSingle("racerAcquire");

            PoolConfig<PoolableTest> testConfig = poolableTestConfig(1, 1,
                    Mono.fromCallable(() -> new PoolableTest(newCount.getAndIncrement()))
                        .subscribeOn(Schedulers.newParallel("poolable test allocator")),
                    deliveryScheduler);
            SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(testConfig);

            //the pool is started with one elements, and has capacity for 1.
            //we actually first acquire that element so that next acquire will wait for a release
            PooledRef<PoolableTest> uniqueSlot = pool.acquire().block();
            assertThat(uniqueSlot).isNotNull();

            //we prepare next acquire
            Mono<PoolableTest> borrower = Mono.fromDirect(pool.withPoolable(Mono::just));
            CountDownLatch latch = new CountDownLatch(1);

            //we actually perform the acquire from its dedicated thread, capturing the thread on which the element will actually get delivered
            acquire1Scheduler.schedule(() -> borrower.subscribe(v -> threadName.set(Thread.currentThread().getName())
                    , e -> latch.countDown(), latch::countDown));

            //in parallel, we'll both attempt a second acquire AND release the unique element (each on their dedicated threads
            Mono<PooledRef<PoolableTest>> otherBorrower = pool.acquire();
            racerAcquireScheduler.schedule(() -> otherBorrower.subscribe().dispose(), 100, TimeUnit.MILLISECONDS);
            racerReleaseScheduler.schedule(uniqueSlot.release()::block, 100, TimeUnit.MILLISECONDS);
            latch.await(1, TimeUnit.SECONDS);

            //we expect that, consistently, the poolable is delivered on a `delivery` thread
            assertThat(threadName.get()).as("round #" + i).startsWith("delivery-");

            //we expect that only 1 element was created
            assertThat(newCount).as("elements created in round " + i).hasValue(1);
        }
    }

    @Test
    void stillacquiredAfterPoolDisposedMaintainsCount() {
        AtomicInteger cleanerCount = new AtomicInteger();
        SimpleFifoPool<PoolableTest> pool = new SimpleFifoPool<>(
                from(Mono.fromCallable(PoolableTest::new))
                        .sizeBetween(3, 3)
                        .releaseHandler(p -> Mono.fromRunnable(cleanerCount::incrementAndGet))
                        .evictionPredicate((value, metadata) -> !value.isHealthy())
                        .buildConfig());

        PooledRef<PoolableTest> acquired1 = pool.acquire().block();
        PooledRef<PoolableTest> acquired2 = pool.acquire().block();
        PooledRef<PoolableTest> acquired3 = pool.acquire().block();

        assertThat(acquired1).as("acquired1").isNotNull();
        assertThat(acquired2).as("acquired2").isNotNull();
        assertThat(acquired3).as("acquired3").isNotNull();

        pool.dispose();

        assertThat(pool.acquired).as("before releases").isEqualTo(3);

        acquired1.release().block();
        acquired2.release().block();
        acquired3.release().block();

        assertThat(pool.acquired).as("after releases").isEqualTo(0);
    }

    @SuppressWarnings("FutureReturnValueIgnored")
    @ParameterizedTest
    @CsvSource({"4, 1", "4, 100000", "10, 1", "10, 100000"})
    //see https://github.com/reactor/reactor-pool/issues/65
    void concurrentAcquireCorrectlyAccountsAll(int parallelism, int loops) throws InterruptedException {
        final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(parallelism);
        autoDispose(executorService::shutdownNow);

        for (int l = 0; l < loops; l++) {
            PoolConfig<String> config = PoolBuilder.from(Mono.just("foo"))
                                                   .sizeBetween(0, 100)
                                                   .buildConfig();
            SimpleFifoPool<String> fifoPool = autoDispose(new SimpleFifoPool<>(config));
            CountDownLatch latch = new CountDownLatch(parallelism);

            for (int i = 0; i < parallelism; i++) {
                executorService.submit(() -> {
                    fifoPool.acquire()
                            .block();
                    latch.countDown();
                });
            }
            boolean awaited = latch.await(1, TimeUnit.SECONDS);
            assertThat(awaited).as("all concurrent acquire served in loop #" + l).isTrue();
        }
    }
}