diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java
index cec5b06df16..2a71d0f19aa 100644
--- a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java
@@ -93,7 +93,9 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Function;
+import java.util.function.Function;
import java.util.stream.Collectors;
+import java.util.stream.Gatherers;
import java.util.stream.Stream;
import static com.dotcms.content.elasticsearch.business.ESMappingAPIImpl.INCLUDE_DOTRAW_METADATA_FIELDS;
@@ -1030,6 +1032,43 @@ private int calculateContentletChunkSize(int totalInodes) {
return calculatedSize;
}
+ /**
+ * How many chunks may be in flight at once. Bounded by what the database can serve
+ * concurrently, not by the CPU: the loaders block on a socket, so the ceiling that matters is
+ * the connection pool.
+ */
+ private static final int MAX_CONCURRENT_CHUNKS =
+ Config.getIntProperty("browser.contentlet.load.max.concurrent.chunks", 8);
+
+ /**
+ * Splits {@code items} into chunks of {@code chunkSize}, hands each chunk to {@code loader}
+ * with at most {@code concurrency} chunks in flight, and flattens the results.
+ *
+ *
Two gatherers, one per concern: {@code windowFixed} cuts (emitting the short final chunk
+ * itself) and {@code mapConcurrent} runs the loaders on virtual threads while preserving
+ * input order, which is what a {@code parallelStream()} does not promise.
+ *
+ * Concurrency is bounded by the connection pool, not by the CPU. The loader here goes
+ * to PostgreSQL, so more in-flight chunks than the pool has connections only moves the queue
+ * from this method into Hikari. That is why the limit is a parameter and not
+ * {@code availableProcessors()}.
+ *
+ * What this does not give back: {@code mapConcurrent} has no timeout of any kind.
+ * The shape it replaces carried {@code orTimeout(90s)} per chunk and a 180s ceiling on the
+ * whole set. Neither survives here. Re-adding them means either putting a timeout inside the
+ * loader or reaching for {@code StructuredTaskScope}, which is still preview in Java 25 — so
+ * this is a deliberate, documented loss, not an oversight.
+ */
+ static List inParallelChunks(final List items, final int chunkSize,
+ final int concurrency, final Function, List> loader) {
+
+ return items.stream()
+ .gather(Gatherers.windowFixed(chunkSize))
+ .gather(Gatherers.mapConcurrent(concurrency, loader::apply))
+ .flatMap(List::stream)
+ .toList();
+ }
+
/**
* Loads contentlets in a single request (for small sets).
*/
@@ -1048,81 +1087,37 @@ private List loadContentletsSingle(List inodes, long startTi
/**
* Loads contentlets in parallel chunks for better performance and reliability.
+ *
+ * See {@link #inParallelChunks(List, int, int, Function)} for what the two gatherers replace
+ * and for the per-chunk and global timeouts that this shape no longer carries.
*/
- private List loadContentletsParallel(Set inodes, int chunkSize, long startTime) {
- final List inodesList = new ArrayList<>(inodes);
- final int totalInodes = inodesList.size();
-
- // Create chunks for parallel processing
- final List> chunks = createChunks(inodesList, chunkSize);
- final int chunkCount = chunks.size();
- Logger.debug(this, String.format("Loading contentlets in parallel: %d inodes in %d chunks (chunk size: %d)",
- totalInodes, chunkCount, chunkSize));
+ private List loadContentletsParallel(final Set inodes, final int chunkSize,
+ final long startTime) {
- // Process chunks in parallel using CompletableFuture
- final DotSubmitter submitter = DotConcurrentFactory.getInstance().getSubmitter();
- final CompletableFuture>[] futures = new CompletableFuture[chunkCount];
+ final List inodesList = new ArrayList<>(inodes);
final ContentletAPI contentletAPI = APILocator.getContentletAPI();
- for (int i = 0; i < chunkCount; i++) {
- final List chunk = chunks.get(i);
- final int chunkIndex = i + 1;
-
- futures[i] = CompletableFuture
- .supplyAsync(() -> {
- final long chunkStartTime = System.currentTimeMillis();
- Logger.debug(BrowserAPIImpl.this, String.format("Loading contentlet chunk %d/%d: %d inodes",
- chunkIndex, chunkCount, chunk.size()));
+ final List allContentlets = inParallelChunks(inodesList, chunkSize,
+ MAX_CONCURRENT_CHUNKS,
+ chunk -> {
+ final long chunkStart = System.currentTimeMillis();
try {
- final List chunkContentlets = contentletAPI.findContentlets(chunk);
- final long chunkDuration = System.currentTimeMillis() - chunkStartTime;
- Logger.debug(BrowserAPIImpl.this, String.format(
- "Contentlet chunk %d/%d completed: %d inodes → %d contentlets in %d ms",
- chunkIndex, chunkCount, chunk.size(), chunkContentlets.size(), chunkDuration));
- return chunkContentlets;
- } catch (Exception e) {
- Logger.error(BrowserAPIImpl.this, String.format("Contentlet chunk %d failed: %s",
- chunkIndex, e.getMessage()), e);
- return new ArrayList();
+ final List loaded = contentletAPI.findContentlets(chunk);
+ Logger.debug(this, String.format(
+ "Contentlet chunk completed: %d inodes -> %d contentlets in %d ms",
+ chunk.size(), loaded.size(), System.currentTimeMillis() - chunkStart));
+ return loaded;
+ } catch (final Exception e) {
+ // Same contract as before: one bad chunk must not fail the whole load.
+ Logger.error(this, String.format("Contentlet chunk of %d inodes failed: %s",
+ chunk.size(), e.getMessage()), e);
+ return List.of();
}
- }, submitter)
- .orTimeout(90, TimeUnit.SECONDS) // Longer timeout for DB operations
- .exceptionally(throwable -> {
- Logger.error(BrowserAPIImpl.this, String.format("Contentlet chunk %d timed out or failed: %s",
- chunkIndex, throwable.getMessage()), throwable);
- return new ArrayList<>();
});
- }
- // Collect results from all chunks
- final List allContentlets = new ArrayList<>();
- try {
- CompletableFuture allFutures = CompletableFuture.allOf(futures);
- allFutures.get(180, TimeUnit.SECONDS); // Global timeout for all chunks
-
- for (CompletableFuture> future : futures) {
- try {
- List chunkContentlets = future.get();
- allContentlets.addAll(chunkContentlets);
- } catch (Exception e) {
- Logger.warn(this, "Failed to get result from contentlet chunk future: " + e.getMessage());
- Thread.currentThread().interrupt();
- }
- }
-
- final long totalDuration = System.currentTimeMillis() - startTime;
- Logger.debug(this, String.format(
- "Parallel contentlet loading completed: %d inodes in %d chunks → %d contentlets in %d ms",
- totalInodes, chunkCount, allContentlets.size(), totalDuration));
-
- } catch (InterruptedException e) {
- Logger.error(this, "Parallel contentlet loading interrupted: " + e.getMessage(), e);
- Thread.currentThread().interrupt();
- } catch (ExecutionException e) {
- Logger.error(this, "Parallel contentlet loading execution error: " + e.getMessage(), e);
- } catch (TimeoutException e) {
- Logger.error(this, "Parallel contentlet loading timed out: " + e.getMessage(), e);
- }
+ Logger.debug(this, String.format(
+ "Parallel contentlet loading completed: %d inodes -> %d contentlets in %d ms",
+ inodesList.size(), allContentlets.size(), System.currentTimeMillis() - startTime));
return allContentlets;
}
@@ -1137,51 +1132,25 @@ private List loadContentletsParallel(Set inodes, int chunkSi
private List