From 6b8854ff54c7005d5e0ba57b608bbeda8ec3dadc Mon Sep 17 00:00:00 2001 From: Dianna Hohensee Date: Tue, 15 Jul 2025 14:32:10 -0700 Subject: [PATCH 1/6] Peek at queued task's latency in a thread pool --- ...utionTimeTrackingEsThreadPoolExecutor.java | 30 +++++++ .../common/util/concurrent/TimedRunnable.java | 7 ++ ...TimeTrackingEsThreadPoolExecutorTests.java | 80 ++++++++++++++++++- 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutor.java b/server/src/main/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutor.java index 762a8c280b7f3..20f480af172bd 100644 --- a/server/src/main/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutor.java +++ b/server/src/main/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutor.java @@ -24,6 +24,7 @@ import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedTransferQueue; import java.util.concurrent.RejectedExecutionHandler; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; @@ -146,6 +147,12 @@ public int getCurrentQueueSize() { return getQueue().size(); } + /** + * Returns the max queue latency seen since the last time that this method was called. Every call will reset the max seen back to zero. + * Latencies are only observed as tasks are taken off of the queue. This means that tasks in the queue will not contribute to the max + * latency until they are unqueued and handed to a thread to execute. To see the latency of tasks still in the queue, use + * {@link #peekMaxQueueLatencyInQueue}. If there have been no tasks in the queue since the last call, then zero latency is returned. + */ public long getMaxQueueLatencyMillisSinceLastPollAndReset() { if (trackMaxQueueLatency == false) { return 0; @@ -153,6 +160,29 @@ public long getMaxQueueLatencyMillisSinceLastPollAndReset() { return maxQueueLatencyMillisSinceLastPoll.getThenReset(); } + /** + * Returns the queue latency of the next task to be executed that is still in the task queue. Essentially peeks at the front of the + * queue and calculates how long it has been there. Returns zero if there is no queue. + */ + public long peekMaxQueueLatencyInQueue() { + if (trackMaxQueueLatency == false) { + return 0; + } + var queue = getQueue(); + if (queue.isEmpty()) { + return 0; + } + assert queue instanceof LinkedTransferQueue : "Not the type of queue expected: " + queue.getClass(); + var linkedTransferQueue = (LinkedTransferQueue) queue; + + var task = linkedTransferQueue.peek(); + assert task instanceof WrappedRunnable : "Not the type of task expected: " + task.getClass(); + var wrappedTask = ((WrappedRunnable) task).unwrap(); + assert wrappedTask instanceof TimedRunnable : "Not the type of task expected: " + task.getClass(); + var timedTask = (TimedRunnable) wrappedTask; + return timedTask.getTimeSinceCreationNanos(); + } + /** * Returns the fraction of the maximum possible thread time that was actually used since the last time this method was called. * There are two periodic pulling mechanisms that access utilization reporting: {@link UtilizationTrackingPurpose} distinguishes the diff --git a/server/src/main/java/org/elasticsearch/common/util/concurrent/TimedRunnable.java b/server/src/main/java/org/elasticsearch/common/util/concurrent/TimedRunnable.java index de89ad0d8ea3f..a0a3940aebd6d 100644 --- a/server/src/main/java/org/elasticsearch/common/util/concurrent/TimedRunnable.java +++ b/server/src/main/java/org/elasticsearch/common/util/concurrent/TimedRunnable.java @@ -72,6 +72,13 @@ long getQueueTimeNanos() { return beforeExecuteTime - creationTimeNanos; } + /** + * Returns the time in nanoseconds since this task was created. + */ + long getTimeSinceCreationNanos() { + return System.nanoTime() - creationTimeNanos; + } + /** * Return the time this task spent being run. * If the task is still running or has not yet been run, returns -1. diff --git a/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java b/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java index 505c26409a702..e7df1db1c22c3 100644 --- a/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java +++ b/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java @@ -93,14 +93,81 @@ public void testExecutionEWMACalculation() throws Exception { executor.awaitTermination(10, TimeUnit.SECONDS); } - public void testMaxQueueLatency() throws Exception { + /** + * Verifies that we can peek at the task in front of the task queue to fetch the duration that the oldest task has been queued. + * Tests {@link TaskExecutionTimeTrackingEsThreadPoolExecutor#peekMaxQueueLatencyInQueue}. + */ + public void testFrontOfQueueLatency() throws Exception { ThreadContext context = new ThreadContext(Settings.EMPTY); - RecordingMeterRegistry meterRegistry = new RecordingMeterRegistry(); - final var threadPoolName = randomIdentifier(); final var barrier = new CyclicBarrier(2); + // Replace all tasks submitted to the thread pool with a configurable task that supports configuring queue latency durations and + // waiting for task execution to begin via the supplied barrier. var adjustableTimedRunnable = new AdjustableQueueTimeWithExecutionBarrierTimedRunnable( barrier, - TimeUnit.NANOSECONDS.toNanos(1000000) + TimeUnit.NANOSECONDS.toNanos(1000000) // Until changed, queue latencies will always be 1 millisecond. + ); + TaskExecutionTimeTrackingEsThreadPoolExecutor executor = new TaskExecutionTimeTrackingEsThreadPoolExecutor( + "test-threadpool", + 1, + 1, + 1000, + TimeUnit.MILLISECONDS, + ConcurrentCollections.newBlockingQueue(), + (runnable) -> adjustableTimedRunnable, + EsExecutors.daemonThreadFactory("queue-latency-test"), + new EsAbortPolicy(), + context, + randomBoolean() + ? EsExecutors.TaskTrackingConfig.builder() + .trackOngoingTasks() + .trackMaxQueueLatency() + .trackExecutionTime(DEFAULT_EXECUTION_TIME_EWMA_ALPHA_FOR_TEST) + .build() + : EsExecutors.TaskTrackingConfig.builder() + .trackMaxQueueLatency() + .trackExecutionTime(DEFAULT_EXECUTION_TIME_EWMA_ALPHA_FOR_TEST) + .build() + ); + try { + executor.prestartAllCoreThreads(); + logger.info("--> executor: {}", executor); + + // Check that the peeking at a non-existence queue returns zero. + assertEquals("Zero should be returned when there is no queue", 0, executor.peekMaxQueueLatencyInQueue()); + + // Submit two tasks, into the thread pool with a single worker thread. The second one will be queued (because the pool only has + // one thread) and can be peeked at. + executor.execute(() -> {}); + executor.execute(() -> {}); + var frontOfQueueDuration = executor.peekMaxQueueLatencyInQueue(); + assertThat("Expected a task to be queued", frontOfQueueDuration, greaterThan(0L)); + safeAwait(barrier); // release the first task to finish + assertBusy(() -> { assertEquals("Queue should be emptied", 0, executor.peekMaxQueueLatencyInQueue()); }); + safeAwait(barrier); // release the second task to finish. + } finally { + // Clean up. + if (barrier.getNumberWaiting() > 0) { + // Release any potentially running task. This could be racy (a task may start executing and hit the barrier afterward) and + // is best-effort. + safeAwait(barrier); + } + executor.shutdown(); + executor.awaitTermination(10, TimeUnit.SECONDS); + } + } + + /** + * Verifies that tracking of the max queue latency (captured on task dequeue) is maintained. + * Tests {@link TaskExecutionTimeTrackingEsThreadPoolExecutor#getMaxQueueLatencyMillisSinceLastPollAndReset()}. + */ + public void testMaxDequeuedQueueLatency() throws Exception { + ThreadContext context = new ThreadContext(Settings.EMPTY); + final var barrier = new CyclicBarrier(2); + // Replace all tasks submitted to the thread pool with a configurable task that supports configuring queue latency durations and + // waiting for task execution to begin via the supplied barrier. + var adjustableTimedRunnable = new AdjustableQueueTimeWithExecutionBarrierTimedRunnable( + barrier, + TimeUnit.NANOSECONDS.toNanos(1000000) // Until changed, queue latencies will always be 1 millisecond. ); TaskExecutionTimeTrackingEsThreadPoolExecutor executor = new TaskExecutionTimeTrackingEsThreadPoolExecutor( "test-threadpool", @@ -146,6 +213,11 @@ public void testMaxQueueLatency() throws Exception { assertEquals("The max was just reset, should be zero", 0, executor.getMaxQueueLatencyMillisSinceLastPollAndReset()); } finally { // Clean up. + if (barrier.getNumberWaiting() > 0) { + // Release any potentially running task. This could be racy (a task may start executing and hit the barrier afterward) and + // is best-effort. + safeAwait(barrier); + } executor.shutdown(); executor.awaitTermination(10, TimeUnit.SECONDS); } From e3d7d0e798f39b90b002fea86de4403b6bfdd91e Mon Sep 17 00:00:00 2001 From: Dianna Hohensee Date: Wed, 23 Jul 2025 11:52:37 -0700 Subject: [PATCH 2/6] peek at the queue twice, ensure the queue duration reported the second peek is longer than the first --- ...TimeTrackingEsThreadPoolExecutorTests.java | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java b/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java index e7df1db1c22c3..fb3c399ef5a84 100644 --- a/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java +++ b/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java @@ -104,13 +104,15 @@ public void testFrontOfQueueLatency() throws Exception { // waiting for task execution to begin via the supplied barrier. var adjustableTimedRunnable = new AdjustableQueueTimeWithExecutionBarrierTimedRunnable( barrier, - TimeUnit.NANOSECONDS.toNanos(1000000) // Until changed, queue latencies will always be 1 millisecond. + // This won't actually be used, because it is reported when a task is taken off the queue. This test peeks at the still queued + // tasks. + TimeUnit.NANOSECONDS.toNanos(1_000_000) ); TaskExecutionTimeTrackingEsThreadPoolExecutor executor = new TaskExecutionTimeTrackingEsThreadPoolExecutor( "test-threadpool", 1, 1, - 1000, + 1_000, TimeUnit.MILLISECONDS, ConcurrentCollections.newBlockingQueue(), (runnable) -> adjustableTimedRunnable, @@ -139,14 +141,24 @@ public void testFrontOfQueueLatency() throws Exception { // one thread) and can be peeked at. executor.execute(() -> {}); executor.execute(() -> {}); + var frontOfQueueDuration = executor.peekMaxQueueLatencyInQueue(); assertThat("Expected a task to be queued", frontOfQueueDuration, greaterThan(0L)); - safeAwait(barrier); // release the first task to finish + safeSleep(10); + var updatedFrontOfQueueDuration = executor.peekMaxQueueLatencyInQueue(); + assertThat( + "Expected a second peek to report a longer duration", + updatedFrontOfQueueDuration, + greaterThan(frontOfQueueDuration) + ); + + // Release the first task that's running, and wait for the second to start -- then it is ensured that the queue will be empty. + safeAwait(barrier); + safeAwait(barrier); assertBusy(() -> { assertEquals("Queue should be emptied", 0, executor.peekMaxQueueLatencyInQueue()); }); - safeAwait(barrier); // release the second task to finish. } finally { // Clean up. - if (barrier.getNumberWaiting() > 0) { + while (barrier.getNumberWaiting() > 0) { // Release any potentially running task. This could be racy (a task may start executing and hit the barrier afterward) and // is best-effort. safeAwait(barrier); @@ -213,7 +225,7 @@ public void testMaxDequeuedQueueLatency() throws Exception { assertEquals("The max was just reset, should be zero", 0, executor.getMaxQueueLatencyMillisSinceLastPollAndReset()); } finally { // Clean up. - if (barrier.getNumberWaiting() > 0) { + while (barrier.getNumberWaiting() > 0) { // Release any potentially running task. This could be racy (a task may start executing and hit the barrier afterward) and // is best-effort. safeAwait(barrier); From 20641afcbd9cc99a71f3f656d54c7234c38d467c Mon Sep 17 00:00:00 2001 From: Dianna Hohensee Date: Wed, 23 Jul 2025 12:05:35 -0700 Subject: [PATCH 3/6] time unit change --- .../TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java b/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java index fb3c399ef5a84..ef12a06c01823 100644 --- a/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java +++ b/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java @@ -104,9 +104,9 @@ public void testFrontOfQueueLatency() throws Exception { // waiting for task execution to begin via the supplied barrier. var adjustableTimedRunnable = new AdjustableQueueTimeWithExecutionBarrierTimedRunnable( barrier, - // This won't actually be used, because it is reported when a task is taken off the queue. This test peeks at the still queued + // This won't actually be used, because it is reported after a task is taken off the queue. This test peeks at the still queued // tasks. - TimeUnit.NANOSECONDS.toNanos(1_000_000) + TimeUnit.MILLISECONDS.toNanos(1) ); TaskExecutionTimeTrackingEsThreadPoolExecutor executor = new TaskExecutionTimeTrackingEsThreadPoolExecutor( "test-threadpool", From 44e5f18bf87af8c5f48882530538a1ce99f909ff Mon Sep 17 00:00:00 2001 From: Dianna Hohensee Date: Fri, 1 Aug 2025 12:15:05 -0700 Subject: [PATCH 4/6] Removed best-effort while-loops Replaces executor shutdowns with more reliable ThreadPool#terminate calls Added assertBusy around queue latency checks, to avoid races with ThreadPool clock not moving forward --- ...TimeTrackingEsThreadPoolExecutorTests.java | 45 +++++++------------ 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java b/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java index ef12a06c01823..5361a420f2b02 100644 --- a/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java +++ b/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java @@ -89,8 +89,7 @@ public void testExecutionEWMACalculation() throws Exception { assertThat(executor.getTotalTaskExecutionTime(), equalTo(500L)); }); assertThat(executor.getOngoingTasks().toString(), executor.getOngoingTasks().size(), equalTo(0)); - executor.shutdown(); - executor.awaitTermination(10, TimeUnit.SECONDS); + ThreadPool.terminate(executor, 10, TimeUnit.SECONDS); } /** @@ -143,28 +142,27 @@ public void testFrontOfQueueLatency() throws Exception { executor.execute(() -> {}); var frontOfQueueDuration = executor.peekMaxQueueLatencyInQueue(); - assertThat("Expected a task to be queued", frontOfQueueDuration, greaterThan(0L)); + assertBusy( + // Wrap this call in an assertBusy because it's feasible for the thread pool's clock to see no time pass. + () -> assertThat("Expected a task to be queued", frontOfQueueDuration, greaterThan(0L)) + ); safeSleep(10); var updatedFrontOfQueueDuration = executor.peekMaxQueueLatencyInQueue(); - assertThat( - "Expected a second peek to report a longer duration", - updatedFrontOfQueueDuration, - greaterThan(frontOfQueueDuration) + assertBusy( + // Again add an assertBusy to ensure time passes on the thread pool's clock and there are no races. + () -> assertThat( + "Expected a second peek to report a longer duration", + updatedFrontOfQueueDuration, + greaterThan(frontOfQueueDuration) + ) ); // Release the first task that's running, and wait for the second to start -- then it is ensured that the queue will be empty. safeAwait(barrier); safeAwait(barrier); - assertBusy(() -> { assertEquals("Queue should be emptied", 0, executor.peekMaxQueueLatencyInQueue()); }); + assertEquals("Queue should be emptied", 0, executor.peekMaxQueueLatencyInQueue()); } finally { - // Clean up. - while (barrier.getNumberWaiting() > 0) { - // Release any potentially running task. This could be racy (a task may start executing and hit the barrier afterward) and - // is best-effort. - safeAwait(barrier); - } - executor.shutdown(); - executor.awaitTermination(10, TimeUnit.SECONDS); + ThreadPool.terminate(executor, 10, TimeUnit.SECONDS); } } @@ -224,14 +222,7 @@ public void testMaxDequeuedQueueLatency() throws Exception { assertEquals("Max should not be the last task", 5, executor.getMaxQueueLatencyMillisSinceLastPollAndReset()); assertEquals("The max was just reset, should be zero", 0, executor.getMaxQueueLatencyMillisSinceLastPollAndReset()); } finally { - // Clean up. - while (barrier.getNumberWaiting() > 0) { - // Release any potentially running task. This could be racy (a task may start executing and hit the barrier afterward) and - // is best-effort. - safeAwait(barrier); - } - executor.shutdown(); - executor.awaitTermination(10, TimeUnit.SECONDS); + ThreadPool.terminate(executor, 10, TimeUnit.SECONDS); } } @@ -268,8 +259,7 @@ public void testExceptionThrowingTask() throws Exception { assertThat(executor.getTotalTaskExecutionTime(), equalTo(0L)); assertThat(executor.getActiveCount(), equalTo(0)); assertThat(executor.getOngoingTasks().toString(), executor.getOngoingTasks().size(), equalTo(0)); - executor.shutdown(); - executor.awaitTermination(10, TimeUnit.SECONDS); + ThreadPool.terminate(executor, 10, TimeUnit.SECONDS); } public void testGetOngoingTasks() throws Exception { @@ -306,8 +296,7 @@ public void testGetOngoingTasks() throws Exception { exitTaskLatch.countDown(); assertBusy(() -> assertThat(executor.getOngoingTasks().toString(), executor.getOngoingTasks().size(), equalTo(0))); assertThat(executor.getTotalTaskExecutionTime(), greaterThan(0L)); - executor.shutdown(); - executor.awaitTermination(10, TimeUnit.SECONDS); + ThreadPool.terminate(executor, 10, TimeUnit.SECONDS); } public void testQueueLatencyHistogramMetrics() { From 7b294022da986e14d52e15c1ecf3be0f7a10fd6f Mon Sep 17 00:00:00 2001 From: Dianna Hohensee Date: Tue, 5 Aug 2025 13:33:30 -0700 Subject: [PATCH 5/6] add method to ensure time passes, replace assertBusy --- ...TimeTrackingEsThreadPoolExecutorTests.java | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java b/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java index 5361a420f2b02..5da7adaf6815d 100644 --- a/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java +++ b/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java @@ -141,20 +141,15 @@ public void testFrontOfQueueLatency() throws Exception { executor.execute(() -> {}); executor.execute(() -> {}); + waitForTimeToElapse(); var frontOfQueueDuration = executor.peekMaxQueueLatencyInQueue(); - assertBusy( - // Wrap this call in an assertBusy because it's feasible for the thread pool's clock to see no time pass. - () -> assertThat("Expected a task to be queued", frontOfQueueDuration, greaterThan(0L)) - ); - safeSleep(10); + assertThat("Expected a task to be queued", frontOfQueueDuration, greaterThan(0L)); + waitForTimeToElapse(); var updatedFrontOfQueueDuration = executor.peekMaxQueueLatencyInQueue(); - assertBusy( - // Again add an assertBusy to ensure time passes on the thread pool's clock and there are no races. - () -> assertThat( - "Expected a second peek to report a longer duration", - updatedFrontOfQueueDuration, - greaterThan(frontOfQueueDuration) - ) + assertThat( + "Expected a second peek to report a longer duration", + updatedFrontOfQueueDuration, + greaterThan(frontOfQueueDuration) ); // Release the first task that's running, and wait for the second to start -- then it is ensured that the queue will be empty. @@ -460,4 +455,15 @@ long getQueueTimeNanos() { return queuedTimeTakenNanos; } } + + /** + * Ensures that the time reported by {@code System.nanoTime()} has advanced. It is otherwise feasible for the clock to report no time + * passing between operations. Call this method if time passing must be guaranteed. + */ + private static void waitForTimeToElapse() throws InterruptedException { + final var startNanoTime = System.nanoTime(); + while (TimeUnit.MILLISECONDS.convert(System.nanoTime() - startNanoTime, TimeUnit.NANOSECONDS) <= 100) { + Thread.sleep(100); + } + } } From e2f8e78b6099eea2503611be4981dd5e4778fc59 Mon Sep 17 00:00:00 2001 From: Dianna Hohensee Date: Fri, 8 Aug 2025 10:01:30 -0700 Subject: [PATCH 6/6] update sleep to be very small -- any amount of time is sufficient --- .../TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java b/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java index 5da7adaf6815d..b4b33d1265bcb 100644 --- a/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java +++ b/server/src/test/java/org/elasticsearch/common/util/concurrent/TaskExecutionTimeTrackingEsThreadPoolExecutorTests.java @@ -17,6 +17,7 @@ import org.elasticsearch.test.ESTestCase; import org.elasticsearch.threadpool.ThreadPool; +import java.time.Duration; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; @@ -462,8 +463,8 @@ long getQueueTimeNanos() { */ private static void waitForTimeToElapse() throws InterruptedException { final var startNanoTime = System.nanoTime(); - while (TimeUnit.MILLISECONDS.convert(System.nanoTime() - startNanoTime, TimeUnit.NANOSECONDS) <= 100) { - Thread.sleep(100); + while ((System.nanoTime() - startNanoTime) < 1) { + Thread.sleep(Duration.ofNanos(1)); } } }