Skip to content

Commit 3c1c2a7

Browse files
tomas-samekTomáš Samek
andauthored
feat(events): configurable event executor pool sizing + metrics snapshot (#110) (#393)
* feat(events): configurable event executor pool sizing + metrics snapshot (#110) * test(events): use try-with-resources in #110 executor tests (Sonar S2093) --------- Co-authored-by: Tomáš Samek <jerry.samek@gmail.com>
1 parent cdd6be1 commit 3c1c2a7

10 files changed

Lines changed: 450 additions & 13 deletions

File tree

docs/events.md

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,9 @@ The framework dispatches via a bounded `ThreadPoolExecutor` sized for typical sm
107107

108108
| Knob | Value |
109109
|-------------------|----------------------------------------------------|
110-
| Core pool size | `max(2, cores / 2)` |
111-
| Max pool size | `cores * 4` |
112-
| Keep-alive | 60 seconds |
110+
| Core pool size | configurable via `eventExecutorCoreSize(...)` (default `max(2, cores / 2)`) |
111+
| Max pool size | configurable via `eventExecutorMaxSize(...)` (default `cores * 4`) |
112+
| Keep-alive | configurable via `eventExecutorKeepAlive(...)` (default 60 seconds) |
113113
| Queue | bounded `LinkedBlockingQueue`, capacity `queueCapacity` (default 1024) |
114114
| Rejection policy | chosen by `onOverflow(...)` (default `CALLER_RUNS` — slows publisher under overload) |
115115
| Thread name | `tiko-event-async-{n}` (daemon) |
@@ -134,6 +134,31 @@ TikoOptions opts = TikoOptions.builder()
134134

135135
Whatever the policy, once the container is shutting down a rejected task degrades to an observable logged drop — `BLOCK` never hangs teardown and `THROW` never throws on the shutdown path.
136136

137+
#### Tuning the default executor's pool size
138+
139+
Size the pool for your workload — more threads for I/O-bound handlers, fewer for CPU-bound. Omitting a knob keeps its processor-derived default, so these are purely additive:
140+
141+
```java
142+
TikoOptions opts = TikoOptions.builder()
143+
.eventExecutorCoreSize(8) // default max(2, cores / 2)
144+
.eventExecutorMaxSize(32) // default cores * 4
145+
.eventExecutorKeepAlive(Duration.ofSeconds(45)) // default 60s
146+
.build();
147+
```
148+
149+
The effective max size must be `>=` the effective core size, or container start fails with `IllegalArgumentException`.
150+
151+
#### Observing the default executor
152+
153+
Poll a point-in-time snapshot of the framework executor for monitoring (saturation, queue depth, throughput):
154+
155+
```java
156+
container.eventExecutorMetrics().ifPresent(m ->
157+
meterRegistry.gauge("tiko.events.queue", m.queueSize()));
158+
```
159+
160+
`eventExecutorMetrics()` returns `Optional<ExecutorMetrics>` — a record exposing `activeCount`, `poolSize`, `queueSize`, `queueRemainingCapacity`, `completedTaskCount`, `corePoolSize`, and `maxPoolSize`. It is **empty** when you supply your own executor (its metrics are yours to expose, not the framework's).
161+
137162
Workloads with extreme throughput or latency requirements can supply their own executor instead:
138163

139164
```java
@@ -144,7 +169,7 @@ TikoOptions opts = TikoOptions.builder()
144169
try (Container container = Tiko.create(opts)) { ... }
145170
```
146171

147-
When you supply your own executor, **you own its lifecycle**`Container.shutdown()` does not stop it, and `queueCapacity` / `onOverflow` have no effect (your executor brings its own queue and rejection policy). Async handler exceptions still route to the configured `ErrorHandler` regardless of which executor is in use.
172+
When you supply your own executor, **you own its lifecycle**`Container.shutdown()` does not stop it; `queueCapacity` / `onOverflow` and the pool-size knobs have no effect (your executor brings its own queue, rejection policy, and sizing); and `eventExecutorMetrics()` returns empty. Async handler exceptions still route to the configured `ErrorHandler` regardless of which executor is in use.
148173

149174
### Execution timeouts
150175

tiko-api/src/main/java/io/tiko/Container.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,22 @@ default EventBus events() {
191191
*/
192192
java.util.concurrent.ExecutorService getEventExecutor();
193193

194+
/**
195+
* Returns a point-in-time snapshot of the framework's event-dispatch executor (#110),
196+
* for monitoring saturation and queue depth.
197+
*
198+
* <p>Returns {@link java.util.Optional#empty()} when the container does not own a
199+
* sampleable executor — chiefly when a custom executor was supplied via
200+
* {@code TikoOptions.eventExecutor(...)} (its lifecycle and metrics are yours, not the
201+
* framework's). The default implementation returns {@code empty()} so user-supplied
202+
* {@code Container} implementations remain source-compatible.
203+
*
204+
* @return a snapshot of the framework executor, or empty when none is owned
205+
*/
206+
default java.util.Optional<ExecutorMetrics> eventExecutorMetrics() {
207+
return java.util.Optional.empty();
208+
}
209+
194210
/**
195211
* Returns the error handler configured for this container.
196212
*
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package io.tiko;
2+
3+
import java.util.concurrent.ThreadPoolExecutor;
4+
5+
/**
6+
* An immutable point-in-time snapshot of the framework's event-dispatch executor (#110).
7+
*
8+
* <p>Obtained via {@link Container#eventExecutorMetrics()}. The values mirror the
9+
* corresponding {@link ThreadPoolExecutor} getters at the instant the snapshot was taken;
10+
* because the pool is live and concurrent, they are a consistent-enough sample for
11+
* monitoring (saturation, queue depth, throughput), not a transactional view.
12+
*
13+
* @param activeCount threads actively executing tasks ({@link ThreadPoolExecutor#getActiveCount()})
14+
* @param poolSize current number of threads in the pool ({@link ThreadPoolExecutor#getPoolSize()})
15+
* @param queueSize tasks waiting in the work queue
16+
* @param queueRemainingCapacity free slots left in the bounded work queue before overflow
17+
* @param completedTaskCount approximate count of tasks that have completed
18+
* ({@link ThreadPoolExecutor#getCompletedTaskCount()})
19+
* @param corePoolSize configured core pool size ({@link ThreadPoolExecutor#getCorePoolSize()})
20+
* @param maxPoolSize configured maximum pool size ({@link ThreadPoolExecutor#getMaximumPoolSize()})
21+
*/
22+
public record ExecutorMetrics(
23+
int activeCount,
24+
int poolSize,
25+
int queueSize,
26+
int queueRemainingCapacity,
27+
long completedTaskCount,
28+
int corePoolSize,
29+
int maxPoolSize) {
30+
31+
/**
32+
* Samples the given executor into an immutable snapshot.
33+
*
34+
* @param tpe the live framework executor to sample
35+
* @return a snapshot of {@code tpe}'s current state
36+
*/
37+
public static ExecutorMetrics from(ThreadPoolExecutor tpe) {
38+
var queue = tpe.getQueue();
39+
return new ExecutorMetrics(
40+
tpe.getActiveCount(),
41+
tpe.getPoolSize(),
42+
queue.size(),
43+
queue.remainingCapacity(),
44+
tpe.getCompletedTaskCount(),
45+
tpe.getCorePoolSize(),
46+
tpe.getMaximumPoolSize());
47+
}
48+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package io.tiko;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.util.concurrent.LinkedBlockingQueue;
6+
import java.util.concurrent.ThreadPoolExecutor;
7+
import java.util.concurrent.TimeUnit;
8+
import org.junit.jupiter.api.Test;
9+
10+
class ExecutorMetricsTest {
11+
12+
@Test
13+
void fromMirrorsThreadPoolExecutorState() {
14+
try (ThreadPoolExecutor tpe =
15+
new ThreadPoolExecutor(2, 6, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(16))) {
16+
ExecutorMetrics m = ExecutorMetrics.from(tpe);
17+
18+
assertThat(m.corePoolSize()).isEqualTo(2);
19+
assertThat(m.maxPoolSize()).isEqualTo(6);
20+
assertThat(m.queueSize()).isZero();
21+
assertThat(m.queueRemainingCapacity()).isEqualTo(16);
22+
assertThat(m.activeCount()).isZero();
23+
assertThat(m.completedTaskCount()).isZero();
24+
}
25+
}
26+
27+
@Test
28+
void fromReflectsQueuedAndCompletedWork() throws Exception {
29+
// Single thread, capacity-1 queue: one task occupies the worker, the rest sit in the queue.
30+
ThreadPoolExecutor tpe = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(8));
31+
var gate = new java.util.concurrent.CountDownLatch(1);
32+
var started = new java.util.concurrent.CountDownLatch(1);
33+
try {
34+
tpe.execute(() -> {
35+
started.countDown();
36+
try {
37+
gate.await();
38+
} catch (InterruptedException e) {
39+
Thread.currentThread().interrupt();
40+
}
41+
});
42+
assertThat(started.await(2, TimeUnit.SECONDS)).isTrue();
43+
tpe.execute(() -> {}); // queued behind the blocked worker
44+
45+
ExecutorMetrics busy = ExecutorMetrics.from(tpe);
46+
assertThat(busy.activeCount()).isEqualTo(1);
47+
assertThat(busy.queueSize()).isEqualTo(1);
48+
49+
gate.countDown();
50+
tpe.shutdown();
51+
assertThat(tpe.awaitTermination(2, TimeUnit.SECONDS)).isTrue();
52+
53+
ExecutorMetrics done = ExecutorMetrics.from(tpe);
54+
assertThat(done.completedTaskCount()).isEqualTo(2);
55+
} finally {
56+
tpe.shutdownNow();
57+
}
58+
}
59+
}

tiko-runtime/src/main/java/io/tiko/runtime/AggregatingContainer.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,12 @@ public AggregatingContainer(
149149
this.errorHandler = errorHandler;
150150
this.eventExecutor = userEventExecutor != null
151151
? userEventExecutor
152-
: DefaultEventExecutorFactory.create(options.queueCapacity(), options.onOverflow());
152+
: DefaultEventExecutorFactory.create(
153+
options.queueCapacity(),
154+
options.onOverflow(),
155+
options.eventExecutorCoreSize(),
156+
options.eventExecutorMaxSize(),
157+
options.eventExecutorKeepAlive());
153158
this.ownsEventExecutor = (userEventExecutor == null);
154159
this.shutdownTimeout = shutdownTimeout;
155160
this.options = options;
@@ -498,6 +503,15 @@ public java.util.concurrent.ExecutorService getEventExecutor() {
498503
return eventExecutor;
499504
}
500505

506+
@Override
507+
public java.util.Optional<io.tiko.ExecutorMetrics> eventExecutorMetrics() {
508+
// Only the framework-owned default pool is sampleable (#110). A user-supplied executor —
509+
// even a ThreadPoolExecutor — is the user's to observe; we report empty for it.
510+
return ownsEventExecutor && eventExecutor instanceof java.util.concurrent.ThreadPoolExecutor tpe
511+
? java.util.Optional.of(io.tiko.ExecutorMetrics.from(tpe))
512+
: java.util.Optional.empty();
513+
}
514+
501515
@Override
502516
public void runInEventScope(Runnable task) {
503517
runNested(moduleContainers.iterator(), wrapWithUnitLifecycle(task), Container::runInEventScope);

tiko-runtime/src/main/java/io/tiko/runtime/DefaultEventExecutorFactory.java

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.tiko.runtime;
22

3+
import java.time.Duration;
34
import java.util.concurrent.ExecutorService;
45
import java.util.concurrent.LinkedBlockingQueue;
56
import java.util.concurrent.RejectedExecutionHandler;
@@ -14,9 +15,9 @@
1415
*
1516
* <p>Configuration:
1617
* <ul>
17-
* <li>Core pool: {@code Math.max(2, availableProcessors() / 2)}</li>
18-
* <li>Max pool: {@code availableProcessors() * 4}</li>
19-
* <li>Keep-alive: 60 seconds</li>
18+
* <li>Core pool: configurable (default {@code Math.max(2, availableProcessors() / 2)}, #110)</li>
19+
* <li>Max pool: configurable (default {@code availableProcessors() * 4}, #110)</li>
20+
* <li>Keep-alive: configurable (default 60 seconds, #110)</li>
2021
* <li>Queue: bounded {@link LinkedBlockingQueue}, capacity configurable (default 1024, #109)</li>
2122
* <li>Rejection policy: chosen by the {@link OverflowPolicy} (default {@code CALLER_RUNS}, #109).
2223
* Every policy degrades to an observable WARNING (never a silent drop, never a hang, never a
@@ -32,6 +33,9 @@ public final class DefaultEventExecutorFactory {
3233
/** Historical defaults, applied when callers do not configure backpressure (#109). */
3334
static final int DEFAULT_QUEUE_CAPACITY = 1024;
3435

36+
/** Default non-core thread idle keep-alive (#110). */
37+
static final long DEFAULT_KEEP_ALIVE_SECONDS = 60L;
38+
3539
private DefaultEventExecutorFactory() {}
3640

3741
public static ExecutorService create() {
@@ -40,12 +44,33 @@ public static ExecutorService create() {
4044

4145
/**
4246
* Builds the default event executor with a bounded queue of {@code queueCapacity} and the
43-
* given {@code overflowPolicy} for tasks rejected when that queue is full (#109).
47+
* given {@code overflowPolicy} for tasks rejected when that queue is full (#109), using the
48+
* framework's processor-derived pool sizing.
4449
*/
4550
public static ExecutorService create(int queueCapacity, OverflowPolicy overflowPolicy) {
51+
return create(queueCapacity, overflowPolicy, TikoOptions.UNSET_POOL_SIZE, TikoOptions.UNSET_POOL_SIZE, null);
52+
}
53+
54+
/**
55+
* Builds the default event executor with explicit pool sizing (#110). A {@link
56+
* TikoOptions#UNSET_POOL_SIZE} core or max size, or a {@code null} keep-alive, falls back to
57+
* the framework's processor-derived defaults (core {@code Math.max(2, cores / 2)}, max {@code
58+
* cores * 4}, keep-alive {@value #DEFAULT_KEEP_ALIVE_SECONDS}s) — so omitting every #110 knob
59+
* reproduces the historical behaviour exactly.
60+
*
61+
* @throws IllegalArgumentException if the effective max pool size is less than the effective core size
62+
*/
63+
public static ExecutorService create(
64+
int queueCapacity, OverflowPolicy overflowPolicy, int coreSize, int maxSize, Duration keepAlive) {
4665
int cores = Runtime.getRuntime().availableProcessors();
47-
int corePoolSize = Math.max(2, cores / 2);
48-
int maxPoolSize = cores * 4;
66+
int corePoolSize = coreSize == TikoOptions.UNSET_POOL_SIZE ? Math.max(2, cores / 2) : coreSize;
67+
int maxPoolSize = maxSize == TikoOptions.UNSET_POOL_SIZE ? cores * 4 : maxSize;
68+
if (maxPoolSize < corePoolSize) {
69+
throw new IllegalArgumentException("eventExecutorMaxSize (" + maxPoolSize
70+
+ ") must be >= eventExecutorCoreSize (" + corePoolSize + ")");
71+
}
72+
long keepAliveMs =
73+
keepAlive == null ? TimeUnit.SECONDS.toMillis(DEFAULT_KEEP_ALIVE_SECONDS) : keepAlive.toMillis();
4974

5075
ThreadFactory threadFactory = new ThreadFactory() {
5176
private final AtomicInteger counter = new AtomicInteger();
@@ -61,8 +86,8 @@ public Thread newThread(Runnable r) {
6186
return new ThreadPoolExecutor(
6287
corePoolSize,
6388
maxPoolSize,
64-
60L,
65-
TimeUnit.SECONDS,
89+
keepAliveMs,
90+
TimeUnit.MILLISECONDS,
6691
new LinkedBlockingQueue<>(queueCapacity),
6792
threadFactory,
6893
rejectionHandlerFor(overflowPolicy));

0 commit comments

Comments
 (0)