clustered-object-pool is a lightweight generic clustering object pool, building on generic-object-pool and adding keyed clustered pools.
Maven Dependency Setup
<dependency>
<groupId>com.github.bbottema</groupId>
<artifactId>clustered-object-pool</artifactId>
<version>4.4.0</version>
</dependency>For JPMS applications, the published JAR declares the stable automatic module name
org.bbottema.clusteredobjectpool.
4.4.0 (15 October 2026)
- #32:
removeEmptyCluster(...)lets long-lived registries forget fully retired clusters and their settings. Await pool shutdown first; active pools and unfinished disposal prevent removal. - Ordinary pool shutdown still retains configuration for reuse. Explicit removal starts a fresh configuration on later registration and keeps earlier selections retired.
- Existing APIs, Generic Object Pool 2.6.0, Java 8 and automatic module names remain supported.
4.3.0 (12 October 2026)
- #31:
selectPoolCandidates(...)lists the pools your application can consider without borrowing resources. For example, consider another service destination after a connection failure. The immutable list includes each captured registration once; the application decides whether to try another. See Considering another destination. - Normal one-pool selection remains unchanged. Only the first candidate advances the shared load-balancing position; alternatives are ordered privately. The caller still owns failure classification and any retry policy.
ResourcePoolSelection.claimWithContext(...)continues an acquisition started by another integration layer, preserving its identity, cancellation and elapsed budget.- Uses Generic Object Pool 2.6.0. Existing APIs, Java 8 and automatic module names remain supported.
4.2.0 (1 October 2026)
- #29: Select a registered pool, finish application work that depends on its key, then borrow a resource from that same registration. Selection does not allocate a resource or reserve capacity.
- Existing combined claim methods, Java 8 support and automatic module names remain unchanged. Both selection and the later claim support the existing cancellation and timeout controls, with separate budgets.
4.1.1 (17 September 2026)
- #27: Update Generic Object Pool to 2.5.1, fixing creation-age and last-allocation policies with matching thresholds or randomized bounds so combined policies retain both rules and keep separate expiry state (upstream #24).
- Matching configurations of the same policy type remain equal. Existing clustered APIs, Java 8 support and automatic module names remain unchanged.
4.0.1
- #6: Added cluster-specific Java configuration so each cluster key can define its own pool defaults, claim timeout, and load-balancing strategy.
This library builds on top of generic-object-pool, which means support for eager/lazy loading and auto expiring objects.
There are a couple of scenario's you can solve with clustered-object-pool:
- Have 1 cluster with 1 pool of size 1. Same as using generic-object-pool directly, where you have one resource, but can share/reuse it among threads.
- Have 1 cluster with 1 pool of size n. Same as using generic-object-pool directly, where multiple resources are shared/reused among threads.
- Have 1 cluster with n pools of size 1. If you have one cluster with rotating pools to draw a shareable/reusable object from. Usefull when you want to spread load around different servers.
- Have 1 cluster with n pools of size n. Same as above, except with multiple objects. For example multiple connections to multiple servers.
- Have n clusters .... Same as all the above except you have dedicated clusters for different purposes. For example a cluster for handling internal mails and a cluster for outgoing mails.
When creating clusters, you can optionally preregister pools with specific behavior. If you don't pools and clusters and pools are created on the fly with the provided defaults.
These examples illustrates this library using SMTP clustering connection pools, for which there is actually a dedicated micro library, smtp-connection-pool
// basic pool with no eager loading and no expiry policy and default round robin pool rotation
ClusterConfig<UUID, Session, Transport> clusterConfig = ClusterConfig.<UUID, Session, Transport>builder()
.allocatorFactory(allocatorFactory)
.defaultMaxPoolSize(defaultMaxPoolSize)
.build();
ResourceClusters<UUID, Session, Transport> clusters = new ResourceClusters<>(clusterConfig);
UUID keyCluster1 = UUID.randomUUID();
// claim from cluster, blocking until a resource becomes available
PoolableObject<Transport> resource = clusters.claimResourceFromCluster(keyCluster1);
// claim from specific pool, blocking until a resource becomes available
PoolableObject<Transport> resource = clusters.claimResourceFromPool(new ResourceClusterAndPoolKey<>(keyCluster1, SessionToServerA));
// above cluster and pools are created on the fly// the following has the same behavior as not preregistering and registering on the fly when claiming resources
// it simply uses the global defaults
clusters.registerResourcePool(new ResourceClusterAndPoolKey<>(keyCluster1, SessionForServerA));
clusters.registerResourcePool(new ResourceClusterAndPoolKey<>(keyCluster1, SessionForServerB));
// but you can also change individual pool behavior (enabling all the aforementioned scenario's):
clusters.registerResourcePool(new ResourceClusterAndPoolKey<>(keyCluster1, SessionForServerA),
new SpreadedTimeoutSinceLastAllocationExpirationPolicy(5, 10, TimeUnit.SECONDS),
10, // core pool size, which means eagerly loading
20); // max pool size
clusters.registerResourcePool(new ResourceClusterAndPoolKey<>(keyCluster1, SessionForServerB));The above example creates clusters and pools on the fly as resources are claimed with the appropriate keys, except for serverA in cluster1: for this server, 10 connections are preloaded with 10 max connections allowed at busy times and with auto expiring connections, disconnection spreading between 5 to 10 seconds after a connection was last used.
ClusterConfig<UUID, Session, Transport> constrainedClusterConfig = ClusterConfig.<UUID, Session, Transport>builder()
.allocatorFactory(allocatorFactory)
.defaultCorePoolSize(0)
.defaultMaxPoolSize(1)
.claimTimeout(new Timeout(30, TimeUnit.SECONDS))
.loadBalancingStrategy(new RandomAccessLoadBalancing<>())
.build();
clusters.registerResourceCluster(keyCluster2, constrainedClusterConfig);
clusters.registerResourcePool(new ResourceClusterAndPoolKey<>(keyCluster2, SessionForServerA));Pools registered for keyCluster2 now use the cluster-specific defaults. Other clusters still use the global defaults from the ResourceClusters constructor unless they are registered with their own config.
A stopped job can stop waiting for a resource without shutting down shared pools. Keep one control in the job and pass immutable options to its worker:
ClaimControl claimControl = new ClaimControl();
ClaimOptions options = ClaimOptions.withTimeout(30, TimeUnit.SECONDS)
.withClaimControl(claimControl);
// On the worker; this call still blocks normally.
PoolableObject<Transport> resource = clusters.claimResourceFromPool(
new ResourceClusterAndPoolKey<>(keyCluster1, SessionForServerA), options);
// Or: clusters.claimResourceFromCluster(keyCluster1, options).
// Matching-only: clusters.claimMatchingResourceFromPool(key, predicate, options).
// From the job's stop handler on another thread:
claimControl.requestCancellation();Creating a control does not cancel anything. An explicit request produces CancellationException, an expired budget
returns null, and thread interruption remains InterruptedException. Release or invalidate a returned resource as
usual: cancellation of acquisition does not revoke it after handoff.
One monotonic budget covers registration, selection, waiting and resource preparation. A configured cluster claim timeout can shorten that budget, never extend it. Existing overloads keep their configured legacy wait semantics. Matching never creates a cluster, registers a pool or initializes an unfinished registration.
Factories and load-balancing callbacks still run on the calling thread. Library-owned lock waits are cancellable; arbitrary application callbacks need cooperation and may outlast the budget. Load-balancing callbacks remain serialized, separately from registry bookkeeping. Once a shared pool's initialization has begun, it belongs to the pool, not its first caller: cancelling that caller does not remove a neighbour's registration or core resources. Shutdown tracks late initialization and its disposal; registrations made after retirement are new work.
Allocator factories are unchanged. An allocator can optionally override allocate(AllocationContext) and
allocateForReuse(resource, AllocationContext) to observe the same request and remaining budget. See
Generic Object Pool's cancellation and cleanup contract
for cooperative abort hooks and getDisposalCompletion().
PoolableObject<Transport> resource = clusters.claimMatchingResourceFromPool(
new ResourceClusterAndPoolKey<>(keyCluster1, SessionForServerA),
poolable -> poolable.idleAgeMs() >= TimeUnit.MINUTES.toMillis(5),
new Timeout(1, TimeUnit.SECONDS));
if (resource != null) {
try {
keepAlive(resource.getAllocatedObject());
resource.release();
} catch (Exception e) {
resource.invalidate();
}
}claimMatchingResourceFromPool only claims from an already registered pool and does not create new pools or resources.
The predicate is evaluated while the underlying pool claim lock is held, so keep it fast and side-effect free.
Normally you would provide an Allocator to the underlying generic-object-pool, now you provide an AllocatorFactory for when a new pool is being created.
For SMTP connections, here's a possible implementation:
class TransportAllocatorFactory implements AllocatorFactory<UUID, Session, Transport> {
@Override
public Allocator<Transport> create(ResourceKey<UUID, Session> resourceKey) {
return new TransportAllocator(resourceKey.getClusterKey(), resourceKey.getPoolKey());
}
}
class TransportAllocator extends Allocator<Transport> {
private static final Logger LOGGER = getLogger(TransportAllocator.class);
private final Session session;
TransportAllocator(final UUID clusterKey, final Session session) {
this.session = session;
}
@Override
public Transport allocate() {
LOGGER.trace("opening transport connection...");
try {
return session.getTransport();
} catch (NoSuchProviderException e) {
throw new RuntimeException("unable to get transport from session", e);
}
}
@Override
public void deallocate(Transport transport) {
LOGGER.trace("closing transport...");
try {
transport.close();
} catch (MessagingException e) {
throw new RuntimeException("error closing transport connection", e);
}
}
}Now each time a specific pool created -either by claiming for it or pre-registering-, this factory is invoked with the respective resource key, containing both the cluster key and pool key (the Session instance representing the server).
By default, load is balanced round robin, but you can easily use a different strategy. For example, to use the provided random balancer instead:
ClusterConfig<UUID, Session, Transport> clusterConfig = ClusterConfig.<UUID, Session, Transport>builder()
.loadBalancingStrategy(new RandomAccessLoadBalancing())
.(..)
.build();The following balancing strategies are provided by default:
- RoundRobinCyclingStrategy
- RandomAccessLoadBalancing
You can create your own load balancer as well. For example to define a load balancer that routes 50% of the traffic to server A and the other 50% to the rest of the servers, here's one way to implement this:
// load balancer that routes 50% of the traffic to the primary (first) server
public class PrimaryServerLoadBalancing<T> implements LoadBalancingStrategy<T, List<T>> {
public List<T> createCollectionForCycling() { return new ArrayList<>(); }
public T cycle(@NotNull List<T> items) {
final Random rng = ThreadLocalRandom.current();
items.get((rng.nextInt(100) < 50) ? 0 : 1 + rng.nextInt(items.size() - 1));
}
}Future<?> shutdownFuture = clusters.shutDown();
Future<?> shutdownFuture = clusters.shutDown(PoolKey);
shutdownFuture.get(); // blocks until all relevant pools have shut downShutdown keeps empty clusters and their settings available for reuse. If your application creates many short-lived clusters, explicitly forget an unused cluster after its pools have finished shutting down:
clusters.shutdownPool(poolKey).get();
boolean removed = clusters.removeEmptyCluster(clusterKey);removeEmptyCluster returns false if another pool is still registered or being disposed of; it never closes an active
pool or waits for it. When it returns true, neither the cluster nor its settings remain in the registry. Registering the
same cluster key later starts afresh; previously captured selections cannot borrow from that replacement.
Usually, claimResourceFromCluster(...) selects a pool and borrows a resource in one call. You can now separate those steps when
your application needs to know the destination before doing other work, such as loading configuration for the selected pool.
Select first, do that work, then claim when ready. No resource is held on your behalf in between.
This example shows acquisition only. prepareForDestination(...) represents your application's work, not a pool-library method:
ResourcePoolSelection<String, Connection> selected = clusters.selectPoolFromCluster("cluster", claimOptions);
if (selected == null) {
throw new TimeoutException("Timed out selecting a pool");
}
prepareForDestination(selected.getPoolKey());
PoolableObject<Connection> resource = selected.claim(claimOptions);
if (resource == null) {
throw new TimeoutException("Timed out acquiring a resource from the selected pool");
}
// Use, release or invalidate resource as before.Register the pools first. Selection uses the existing load balancer but does not initialize a pool or allocate a resource.
Check for null on timeout, as with ordinary clustered claims. Addressed selection is available through selectPool(key, claimOptions).
The selection stays bound to that registration: shutting it down makes later claims fail, even if its key is registered again.
If another integration layer has already started the acquisition budget, use selected.claimWithContext(context).
It preserves context.getClaimIdentity(), cancellation and elapsed time while capping the budget by the configured
cluster timeout. For example, an allocator callback can use that identity to find the caller's metrics or tracing context.
Do not start another context for the same claim; start a fresh one for an independent claim. Existing claim(options)
still starts its budget at the call, and a pinned selection never redirects to a replacement registration.
It reserves no capacity, keeps no pool alive, and does not need closing. The claimed resource still needs release or invalidation.
The pool does not perform or manage your intervening work. If you are ready to borrow immediately, keep using the combined claim method.
Selection and acquisition each start a timeout budget capped by the configured cluster timeout. Time spent between them is application work; supply the remaining application deadline when claiming if both operations must share one total deadline. Existing combined claim methods are unchanged. See #29.
Use selectPoolCandidates(...) when your application needs a list of destinations it can consider rather than just one.
For example, a service client might try another destination if opening a connection fails. The pool supplies the ordering;
your application still decides which failures permit moving on. It does not retry your requests.
Like the single-selection API, this requires already registered pools. This example selects the list, inspects the first
destination, then borrows from it. prepareForDestination(...) is application code, not a library method:
List<ResourcePoolSelection<String, Connection>> candidates = clusters.selectPoolCandidates("cluster", claimOptions);
if (candidates == null) {
throw new TimeoutException("Timed out selecting destinations");
}
ResourcePoolSelection<String, Connection> selected = candidates.get(0);
prepareForDestination(selected.getPoolKey());
PoolableObject<Connection> resource = selected.claim(claimOptions);
if (resource == null) {
throw new TimeoutException("Timed out borrowing from the selected pool");
}
// Use, release or invalidate resource as before.If acquisition fails in a way your application knows permits another destination, consider the next selection from this same list. Do not automatically move on for every exception: normal waiting for a busy pool, cancellation and an expired application deadline are different from a failed service. Once application work begins, deciding whether to repeat that work is also your responsibility.
The returned list is immutable and contains each captured registration once. The first follows normal load balancing; alternatives use the same strategy without advancing the shared selection position for every possible fallback. Pools registered later do not appear in the list. Re-registering a key does not redirect an old selection to the new pool.
Capturing this list neither initializes pools nor borrows resources or reserves capacity. Selections do not need closing; resources claimed from them still need release or invalidation. Selection and each claim have separate timeout budgets, so pass your remaining application budget when claiming if the complete operation has one deadline. Existing combined claim APIs are unchanged. These APIs are available from 4.3.0; see #31.