diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java
index 1ecd66e1bd9d..7d63136e47ca 100644
--- a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java
@@ -253,6 +253,27 @@ ContentUnderParent getContentUnderParentFromDB(final BrowserQuery browserQuery,
* Pagination resumes from {@link BrowserQuery#contentCursor}, which is the DB row offset
* returned by the previous page. On the first page it is 0.
*
+ *
+ * The scan's cost cap depends on {@code applyESFilter}: when {@code true} (text filtering
+ * through Elasticsearch), it is bounded by elapsed time
+ * ({@code BROWSER_DB_MAX_SCAN_TIME_MILLIS}), so an unfiltered global search keeps scanning
+ * while still affordable instead of giving up at an arbitrary row count and silently
+ * dropping matches that sit later in DB order (see issue #37211), plus a much higher hard
+ * row ceiling ({@code BROWSER_DB_MAX_SCAN_ROWS_ES_HARD_CAP}) as a co-bound so a fast DB/ES
+ * pair -- or many concurrent unfiltered searches -- cannot turn the time budget into
+ * unbounded per-request work (found in review). When {@code false} (permission-only
+ * filtering), it stays bounded by row count ({@code BROWSER_DB_MAX_SCAN_ROWS}), unchanged
+ * from before.
+ *
+ *
+ * Completeness here is best-effort, not a hard guarantee. Because the ES-narrowed
+ * cutoff depends on wall-clock time, a slower or more heavily loaded node can exhaust the
+ * budget before reaching a match that an idle node would find in the same request, so two
+ * otherwise-identical requests against the same data can return different result sets
+ * depending on load (found in review). This trades the old cutoff's determinism for
+ * completeness under normal conditions; it does not eliminate the possibility of a dropped
+ * match under sustained load, only make it far less likely and no longer position-dependent.
+ *
*
* @param browserQuery query containing search criteria, user context, and the current cursor
* @param maxRows maximum number of permission-visible items to return
@@ -269,13 +290,38 @@ private ContentUnderParent getContentByChunks(final BrowserQuery browserQuery,
final int maxRows, final SelectQuery sqlQuery, final int chunkSize,
final boolean applyESFilter) throws DotDataException, DotSecurityException {
- final int scanLimit = Config.getIntProperty(BROWSER_DB_MAX_SCAN_ROWS_KEY, BROWSER_DB_MAX_SCAN_ROWS_DEFAULT);
+ final int scanRowLimit = Config.getIntProperty(BROWSER_DB_MAX_SCAN_ROWS_KEY, BROWSER_DB_MAX_SCAN_ROWS_DEFAULT);
// Clamped against the guard rail: BROWSER_DB_MAX_SCAN_ROWS is configurable, and without
// this the very first chunk fetch can already overshoot it whenever an operator lowers the
// scan limit below the caller's chunk size (e.g. below BROWSER_SINGLE_PASS_CHUNK_SIZE's
- // 7,000 default) -- the dbOffset >= scanLimit check only runs after a chunk is fetched, so
+ // 7,000 default) -- the dbOffset >= scanRowLimit check only runs after a chunk is fetched, so
// nothing upstream of it would have caught that (found in review, issue #37184).
- final int effectiveChunkSize = Math.min(chunkSize, scanLimit);
+ // NOTE: on the ES-narrowed path (applyESFilter=true), BROWSER_DB_MAX_SCAN_ROWS no longer
+ // bounds the *total* rows the scan may read -- BROWSER_DB_MAX_SCAN_ROWS_ES_HARD_CAP does
+ // that instead (see below). This clamp still shapes the ES path's working chunk size for
+ // the same reason it does on the permission-only path (bounding the SQL page size), so it
+ // intentionally stays in effect for both; lowering BROWSER_DB_MAX_SCAN_ROWS therefore
+ // still shrinks ES chunk size (more round trips) without limiting the ES scan itself
+ // (found in review, issue #37211) -- surprising if read as "the" scan limit, so calling
+ // it out explicitly here.
+ final int effectiveChunkSize = Math.min(chunkSize, scanRowLimit);
+ // The ES-narrowed scan (text filter) bounds cost by elapsed time instead of row count --
+ // see BROWSER_DB_MAX_SCAN_TIME_MILLIS_KEY. The permission-only scan keeps the original
+ // row-count cutoff; it has no completeness gap to fix (every candidate row already
+ // matches the query's own SQL criteria).
+ final long scanTimeBudgetMillis = applyESFilter
+ ? Config.getLongProperty(BROWSER_DB_MAX_SCAN_TIME_MILLIS_KEY, BROWSER_DB_MAX_SCAN_TIME_MILLIS_DEFAULT)
+ : -1L;
+ // Co-bound alongside the time budget: without a row ceiling, a fast DB/ES pair (or many
+ // concurrent unfiltered searches sharing the DotSubmitter pool) could scan far more rows
+ // in scanTimeBudgetMillis than the old row-count cutoff ever allowed, multiplying real
+ // cost under load (found in review, issue #37211). Set high enough to comfortably cover
+ // the real-world scale this fix targets (~718,174 contentlets, per the issue) so it does
+ // not reintroduce the original completeness bug at that scale.
+ final int esRowHardCap = applyESFilter
+ ? Config.getIntProperty(BROWSER_DB_MAX_SCAN_ROWS_ES_HARD_CAP_KEY, BROWSER_DB_MAX_SCAN_ROWS_ES_HARD_CAP_DEFAULT)
+ : -1;
+ final long scanStartNanos = System.nanoTime();
final List accumulatedContent = new ArrayList<>();
List candidateChunkInodes;
@@ -310,9 +356,9 @@ private ContentUnderParent getContentByChunks(final BrowserQuery browserQuery,
// A satisfied page wins over the guard rail: when this chunk already produced enough
// visible items we must exit through generateNextContentCursor so the next page resumes
- // right after the last item returned. Checking the scan limit first would exit via the
+ // right after the last item returned. Checking the scan budget first would exit via the
// warn path with a chunk-aligned cursor and silently skip whatever is left over in this
- // chunk -- reachable whenever a chunk boundary lands exactly on the scan limit.
+ // chunk -- reachable whenever a chunk boundary lands exactly on the scan budget.
if (accumulatedContent.size() >= maxRows) {
hasMore = (candidateChunkInodes.size() == effectiveChunkSize);
nextContentCursor = generateNextContentCursor(accumulatedContent, maxRows,
@@ -321,13 +367,12 @@ private ContentUnderParent getContentByChunks(final BrowserQuery browserQuery,
}
// Natural DB exhaustion also wins over the guard rail, for the same reason: the scan
- // limit exists to cut off a search that is NOT done, not to relabel a search that
+ // budget exists to cut off a search that is NOT done, not to relabel a search that
// finished on its own. A partial last chunk (fewer rows than chunkSize) means there is
- // nothing left to scan, regardless of how far dbOffset has climbed -- checking the scan
- // limit first would report hasMore=true for a folder that is actually fully paged
- // through whenever the last (partial) chunk's ending offset happens to land on or past
- // the scan limit, which is reachable whenever chunkSize and the scan limit are close in
- // size (found in review, issue #37184).
+ // nothing left to scan, regardless of how far dbOffset/elapsed time has climbed --
+ // checking the scan budget first would report hasMore=true for a folder that is
+ // actually fully paged through whenever the last (partial) chunk's ending point happens
+ // to land on or past the budget (found in review, issue #37184).
if (candidateChunkInodes.size() < effectiveChunkSize) {
Logger.debug(this, String.format(
"Reached end of results (partial chunk) - DB is exhausted. Total accumulated: %d",
@@ -336,10 +381,30 @@ private ContentUnderParent getContentByChunks(final BrowserQuery browserQuery,
break;
}
- if (dbOffset >= scanLimit) {
+ // Row-count-only cutoff dropped ES-narrowed matches that sit past it in DB order
+ // without ever sending them to ES (#37211) -- an unfiltered global search's SQL
+ // candidate set is essentially the whole site, so DB position says nothing about
+ // whether a match exists. Bound that scan by elapsed time instead, so it keeps
+ // going while still affordable rather than giving up at an arbitrary row count --
+ // plus a much higher row hard cap as a co-bound, so a fast DB/ES pair (or many
+ // concurrent unfiltered searches) cannot turn the time budget into unbounded
+ // per-request work (found in review). The permission-only scan keeps the original
+ // row-count cutoff -- it has no completeness gap to fix (every candidate row
+ // already matches the query's own SQL criteria).
+ final boolean esTimeBudgetExhausted = applyESFilter
+ && TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - scanStartNanos) >= scanTimeBudgetMillis;
+ final boolean esRowHardCapExceeded = applyESFilter && dbOffset >= esRowHardCap;
+ final boolean scanBudgetExhausted = applyESFilter
+ ? (esTimeBudgetExhausted || esRowHardCapExceeded)
+ : dbOffset >= scanRowLimit;
+
+ if (scanBudgetExhausted) {
+ final String exhaustedBoundDescription = !applyESFilter
+ ? scanRowLimit + " rows"
+ : (esRowHardCapExceeded ? esRowHardCap + " rows (hard cap)" : scanTimeBudgetMillis + "ms");
Logger.warn(BrowserAPIImpl.class, String.format(
- "Scan limit reached (%d rows) after %d chunks. Returning %d accumulated items.",
- dbOffset, chunkCount, accumulatedContent.size()));
+ "Scan budget reached (%s) after %d chunks, %d rows scanned. Returning %d accumulated items.",
+ exhaustedBoundDescription, chunkCount, dbOffset, accumulatedContent.size()));
nextContentCursor = dbOffset;
hasMore = true;
break;
@@ -834,9 +899,34 @@ private String buildPureESQuery(final BrowserQuery browserQuery) {
// Maximum total DB rows to scan per request across all chunks. Acts as a safety cap to prevent
// runaway queries when a restricted user has access to a small fraction of site content.
// Default of 50,000 covers a worst-case ~5% permission pass rate for a full page of 300 items.
+ // Used as-is (row-count cutoff) for the permission-only scan (applyESFilter=false); see
+ // BROWSER_DB_MAX_SCAN_TIME_MILLIS_KEY for the ES-narrowed (text-filter) scan's cost bound.
static final String BROWSER_DB_MAX_SCAN_ROWS_KEY = "BROWSER_DB_MAX_SCAN_ROWS";
static final int BROWSER_DB_MAX_SCAN_ROWS_DEFAULT = 50_000;
+ // Maximum wall-clock time to spend scanning DB chunks when text-filtering through ES
+ // (applyESFilter=true). A row-count cutoff here silently drops matches that fall later in
+ // DB order than the cutoff, even though they were never actually sent to ES for narrowing
+ // (issue #37211) -- a global search with no content-type filter has a broad, effectively
+ // unbounded-by-type candidate set, so match position in DB order says nothing about whether
+ // the match exists. Bounding by elapsed time instead lets the scan keep going as long as it
+ // is still affordable, rather than giving up at an arbitrary row count regardless of
+ // coverage.
+ static final String BROWSER_DB_MAX_SCAN_TIME_MILLIS_KEY = "BROWSER_DB_MAX_SCAN_TIME_MILLIS";
+ static final long BROWSER_DB_MAX_SCAN_TIME_MILLIS_DEFAULT = 10_000L;
+
+ // Co-bound alongside BROWSER_DB_MAX_SCAN_TIME_MILLIS for the ES-narrowed scan
+ // (applyESFilter=true): a hard ceiling on total rows read, independent of elapsed time. The
+ // time budget alone does not cap *work* -- a fast DB/ES pair could scan far more rows in
+ // BROWSER_DB_MAX_SCAN_TIME_MILLIS than the old row-count cutoff ever allowed, which under
+ // concurrent unfiltered searches multiplies real cost (found in review, issue #37211). Set
+ // well above the real-world scale this fix targets (~718,174 contentlets, per the issue) so
+ // it does not reintroduce the original completeness bug at that scale; it exists to cap the
+ // pathological case (a much larger site, or many concurrent requests) that the time budget
+ // alone cannot.
+ static final String BROWSER_DB_MAX_SCAN_ROWS_ES_HARD_CAP_KEY = "BROWSER_DB_MAX_SCAN_ROWS_ES_HARD_CAP";
+ static final int BROWSER_DB_MAX_SCAN_ROWS_ES_HARD_CAP_DEFAULT = 1_000_000;
+
// Default DB chunk size for the hybrid DB+ES text-filtering loop.
static final String BROWSER_CONTENT_CHUNK_SIZE_KEY = "BROWSER_CONTENT_CHUNK_SIZE";
static final int BROWSER_CONTENT_CHUNK_SIZE_DEFAULT = 900;
diff --git a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java
index 51d33d8f56f8..f3a83cfb52a0 100644
--- a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java
+++ b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java
@@ -2238,6 +2238,444 @@ public void test_getPaginatedContents_scanLimitStopsLoop() throws Exception {
}
}
+ /**
+ *
+ * - Method to Test: {@link BrowserAPI#getPaginatedContents(BrowserQuery)}
+ * - Given Scenario: A folder holds enough content to exceed
+ * {@code BROWSER_DB_MAX_SCAN_ROWS} (intentionally lowered for the test, along with
+ * {@code BROWSER_CONTENT_CHUNK_SIZE} so the fixture actually spans multiple chunks --
+ * otherwise a small fixture fits in the default 900-row chunk and the whole thing gets
+ * ES-filtered before the scan-limit check ever runs), with no content-type filter
+ * applied. The one item whose title matches the free-text filter is the newest of the
+ * batch, so under the default ascending {@code mod_date} scan order it is scanned
+ * last -- in a chunk past the lowered scan limit.
+ * - Expected Result: The unfiltered global search must still return the
+ * matching item. Before the fix for
+ * #37211, the row-count-only
+ * scan cutoff drops it silently.
+ *
+ */
+ @Test
+ public void test_getPaginatedContents_unfilteredTextSearch_findsMatchPastScanLimit() throws Exception {
+ final int chunkSize = 5;
+ final int scanLimit = 15;
+ final int fillerCount = 20;
+
+ Config.setProperty(BrowserAPIImpl.BROWSER_DB_MAX_SCAN_ROWS_KEY, scanLimit);
+ Config.setProperty("BROWSER_CONTENT_CHUNK_SIZE", chunkSize);
+ try {
+ final Host host = new SiteDataGen().nextPersisted();
+ final Folder folder = new FolderDataGen().site(host).nextPersisted();
+ final var contentType = new ContentTypeDataGen()
+ .host(host)
+ .folder(folder)
+ .field(new FieldDataGen().name("title").velocityVarName("title").next())
+ .nextPersisted();
+
+ // Filler items created first -- oldest mod_date, scanned first under the default
+ // ascending order, all safely inside the scan limit.
+ for (int i = 0; i < fillerCount; i++) {
+ new ContentletDataGen(contentType)
+ .setProperty("title", "Filler " + i)
+ .host(host)
+ .folder(folder)
+ .setPolicy(IndexPolicy.WAIT_FOR)
+ .nextPersisted();
+ }
+
+ // The matching item is created LAST -- newest mod_date, scanned last, past the
+ // lowered scan limit -- reproducing "a just-uploaded item is missing from search".
+ final Contentlet capybara = new ContentletDataGen(contentType)
+ .setProperty("title", "Capybara Image")
+ .host(host)
+ .folder(folder)
+ .setPolicy(IndexPolicy.WAIT_FOR)
+ .nextPersisted();
+
+ final BrowserQuery query = BrowserQuery.builder()
+ .withHostOrFolderId(folder.getIdentifier())
+ .withFilter("Capybara")
+ .useElasticsearchFiltering(true) // Content Drive always sets this (ContentDriveHelper) --
+ // the bug only reproduces on the ES-routed path, never
+ // the plain DB ILIKE fallback used when this is false.
+ .showContent(true)
+ .showFiles(false)
+ .showFolders(false)
+ .showLinks(false)
+ .showDotAssets(false)
+ .showWorking(true)
+ .showArchived(false)
+ .maxResults(100)
+ .contentCursor(0)
+ .build();
+
+ final PaginatedContents result = browserAPI.getPaginatedContents(query);
+
+ assertNotNull("Result must not be null", result);
+ final Set foundInodes = result.list.stream()
+ .map(item -> (String) item.get("inode"))
+ .collect(Collectors.toSet());
+ assertTrue("Unfiltered global search must find a match that exists past the scan "
+ + "limit (issue #37211) -- found: " + foundInodes,
+ foundInodes.contains(capybara.getInode()));
+ } finally {
+ Config.setProperty(BrowserAPIImpl.BROWSER_DB_MAX_SCAN_ROWS_KEY,
+ BrowserAPIImpl.BROWSER_DB_MAX_SCAN_ROWS_DEFAULT);
+ Config.setProperty("BROWSER_CONTENT_CHUNK_SIZE", 900);
+ }
+ }
+
+ /**
+ *
+ * - Method to Test: {@link BrowserAPI#getPaginatedContents(BrowserQuery)}
+ * - Given Scenario: Two content types each hold one item matching the same
+ * free-text filter. An unfiltered global search is compared against the same search
+ * narrowed to a single content type.
+ * - Expected Result: The content-type-filtered result is a subset of the
+ * unfiltered result -- it drops the other type's match but introduces nothing the
+ * unfiltered search didn't already find (AC-002 of
+ * #37211).
+ *
+ */
+ @Test
+ public void test_getPaginatedContents_contentTypeFilter_onlyNarrowsUnfilteredMatches() throws Exception {
+ final Host host = new SiteDataGen().nextPersisted();
+ final Folder folder = new FolderDataGen().site(host).nextPersisted();
+
+ final var typeA = new ContentTypeDataGen()
+ .host(host)
+ .folder(folder)
+ .field(new FieldDataGen().name("title").velocityVarName("title").next())
+ .nextPersisted();
+ final var typeB = new ContentTypeDataGen()
+ .host(host)
+ .folder(folder)
+ .field(new FieldDataGen().name("title").velocityVarName("title").next())
+ .nextPersisted();
+
+ final Contentlet matchA = new ContentletDataGen(typeA)
+ .setProperty("title", "Capybara from Type A")
+ .host(host)
+ .folder(folder)
+ .setPolicy(IndexPolicy.WAIT_FOR)
+ .nextPersisted();
+ final Contentlet matchB = new ContentletDataGen(typeB)
+ .setProperty("title", "Capybara from Type B")
+ .host(host)
+ .folder(folder)
+ .setPolicy(IndexPolicy.WAIT_FOR)
+ .nextPersisted();
+
+ final BrowserQuery unfilteredQuery = BrowserQuery.builder()
+ .withHostOrFolderId(folder.getIdentifier())
+ .withFilter("Capybara")
+ .useElasticsearchFiltering(true)
+ .showContent(true)
+ .showFiles(false)
+ .showFolders(false)
+ .showLinks(false)
+ .showDotAssets(false)
+ .showWorking(true)
+ .showArchived(false)
+ .maxResults(100)
+ .contentCursor(0)
+ .build();
+
+ final PaginatedContents unfiltered = browserAPI.getPaginatedContents(unfilteredQuery);
+ final Set unfilteredInodes = unfiltered.list.stream()
+ .map(item -> (String) item.get("inode"))
+ .collect(Collectors.toSet());
+
+ assertTrue("Unfiltered search must find the Type A match", unfilteredInodes.contains(matchA.getInode()));
+ assertTrue("Unfiltered search must find the Type B match", unfilteredInodes.contains(matchB.getInode()));
+
+ final BrowserQuery typeAFilteredQuery = BrowserQuery.builder()
+ .withHostOrFolderId(folder.getIdentifier())
+ .withFilter("Capybara")
+ .withContentTypes(Set.of(typeA.id()))
+ .useElasticsearchFiltering(true)
+ .showContent(true)
+ .showFiles(false)
+ .showFolders(false)
+ .showLinks(false)
+ .showDotAssets(false)
+ .showWorking(true)
+ .showArchived(false)
+ .maxResults(100)
+ .contentCursor(0)
+ .build();
+
+ final PaginatedContents typeAFiltered = browserAPI.getPaginatedContents(typeAFilteredQuery);
+ final Set filteredInodes = typeAFiltered.list.stream()
+ .map(item -> (String) item.get("inode"))
+ .collect(Collectors.toSet());
+
+ assertTrue("Type-A-filtered search must still find the Type A match",
+ filteredInodes.contains(matchA.getInode()));
+ assertFalse("Type-A-filtered search must drop the Type B match",
+ filteredInodes.contains(matchB.getInode()));
+ assertTrue("Filtered result must be a subset of the unfiltered result -- it must not "
+ + "introduce items the unfiltered search didn't find",
+ unfilteredInodes.containsAll(filteredInodes));
+ }
+
+ /**
+ *
+ * - Method to Test: {@link BrowserAPI#getPaginatedContents(BrowserQuery)}
+ * - Given Scenario: A folder holds more content than the (lowered) scan limit,
+ * none of it matching the free-text filter at all.
+ * - Expected Result: The search still completes within a bounded time and
+ * returns no matches -- fixing the silent-drop defect
+ * (#37211) must not regress
+ * into an unconditional full-table scan (see PR #37395 and siblings for the original
+ * scan-cost concern the row cap was introduced to address).
+ *
+ */
+ @Test
+ public void test_getPaginatedContents_unfilteredTextSearch_noMatchStaysBounded() throws Exception {
+ final int chunkSize = 5;
+ final int scanLimit = 15;
+ final int fillerCount = 20;
+ final long boundedMillis = 30_000L;
+
+ Config.setProperty(BrowserAPIImpl.BROWSER_DB_MAX_SCAN_ROWS_KEY, scanLimit);
+ Config.setProperty("BROWSER_CONTENT_CHUNK_SIZE", chunkSize);
+ try {
+ final Host host = new SiteDataGen().nextPersisted();
+ final Folder folder = new FolderDataGen().site(host).nextPersisted();
+ final var contentType = new ContentTypeDataGen()
+ .host(host)
+ .folder(folder)
+ .field(new FieldDataGen().name("title").velocityVarName("title").next())
+ .nextPersisted();
+
+ for (int i = 0; i < fillerCount; i++) {
+ new ContentletDataGen(contentType)
+ .setProperty("title", "Filler " + i)
+ .host(host)
+ .folder(folder)
+ .setPolicy(IndexPolicy.WAIT_FOR)
+ .nextPersisted();
+ }
+
+ final BrowserQuery query = BrowserQuery.builder()
+ .withHostOrFolderId(folder.getIdentifier())
+ .withFilter("NoSuchTermAnywhere")
+ .useElasticsearchFiltering(true)
+ .showContent(true)
+ .showFiles(false)
+ .showFolders(false)
+ .showLinks(false)
+ .showDotAssets(false)
+ .showWorking(true)
+ .showArchived(false)
+ .maxResults(100)
+ .contentCursor(0)
+ .build();
+
+ final long start = System.currentTimeMillis();
+ final PaginatedContents result = browserAPI.getPaginatedContents(query);
+ final long elapsed = System.currentTimeMillis() - start;
+
+ assertNotNull("Result must not be null", result);
+ assertEquals("No item matches the filter term", 0, result.contentCount);
+ assertTrue("A non-matching scan must still complete within a bounded time ("
+ + elapsed + "ms) -- fixing #37211 must not reintroduce an "
+ + "unconditional full-table scan",
+ elapsed < boundedMillis);
+ } finally {
+ Config.setProperty(BrowserAPIImpl.BROWSER_DB_MAX_SCAN_ROWS_KEY,
+ BrowserAPIImpl.BROWSER_DB_MAX_SCAN_ROWS_DEFAULT);
+ Config.setProperty("BROWSER_CONTENT_CHUNK_SIZE", 900);
+ }
+ }
+
+ /**
+ *
+ * - Method to Test: {@link BrowserAPI#getPaginatedContents(BrowserQuery)}
+ * - Given Scenario: {@code BROWSER_DB_MAX_SCAN_TIME_MILLIS} is set to an
+ * effectively-zero budget (1 ms) so the ES-narrowed scan's time cutoff -- not
+ * DB exhaustion, not the row-count guard rail -- fires after the very first chunk, with
+ * a matching item still unscanned several chunks later. This exercises the mechanism
+ * itself (found in review: the earlier scan-limit tests only ever exhaust the DB
+ * naturally, so none of them actually drive {@code scanBudgetExhausted} to
+ * {@code true} via elapsed time).
+ * - Expected Result: Page 1 stops after one chunk with {@code hasMoreContent}
+ * true and a partial-progress cursor; the match is not yet in that page. Resuming from
+ * that cursor with a normal time budget reaches the match -- proving the time-cutoff
+ * path produces a valid, resumable cursor rather than silently losing coverage.
+ *
+ */
+ @Test
+ public void test_getPaginatedContents_timeBudgetExhausted_resumesFromCursor() throws Exception {
+ final int chunkSize = 5;
+ final int fillerCount = 15;
+ final long tinyTimeBudgetMillis = 1L;
+
+ Config.setProperty("BROWSER_CONTENT_CHUNK_SIZE", chunkSize);
+ Config.setProperty(BrowserAPIImpl.BROWSER_DB_MAX_SCAN_TIME_MILLIS_KEY, tinyTimeBudgetMillis);
+ try {
+ final Host host = new SiteDataGen().nextPersisted();
+ final Folder folder = new FolderDataGen().site(host).nextPersisted();
+ final var contentType = new ContentTypeDataGen()
+ .host(host)
+ .folder(folder)
+ .field(new FieldDataGen().name("title").velocityVarName("title").next())
+ .nextPersisted();
+
+ for (int i = 0; i < fillerCount; i++) {
+ new ContentletDataGen(contentType)
+ .setProperty("title", "Filler " + i)
+ .host(host)
+ .folder(folder)
+ .setPolicy(IndexPolicy.WAIT_FOR)
+ .nextPersisted();
+ }
+ final Contentlet match = new ContentletDataGen(contentType)
+ .setProperty("title", "Wombat Image")
+ .host(host)
+ .folder(folder)
+ .setPolicy(IndexPolicy.WAIT_FOR)
+ .nextPersisted();
+
+ final BrowserQuery firstQuery = BrowserQuery.builder()
+ .withHostOrFolderId(folder.getIdentifier())
+ .withFilter("Wombat")
+ .useElasticsearchFiltering(true)
+ .showContent(true)
+ .showFiles(false)
+ .showFolders(false)
+ .showLinks(false)
+ .showDotAssets(false)
+ .showWorking(true)
+ .showArchived(false)
+ .maxResults(100)
+ .contentCursor(0)
+ .build();
+
+ final PaginatedContents firstPage = browserAPI.getPaginatedContents(firstQuery);
+
+ assertNotNull("First page must not be null", firstPage);
+ assertTrue("The 1ms time budget must cut the scan short before the match's chunk "
+ + "is reached", firstPage.hasMoreContent);
+ assertTrue("nextContentCursor must reflect partial progress (> 0 and <= filler count, "
+ + "not the full dataset) -- was: " + firstPage.nextContentCursor,
+ firstPage.nextContentCursor > 0 && firstPage.nextContentCursor <= fillerCount);
+ final Set firstPageInodes = firstPage.list.stream()
+ .map(item -> (String) item.get("inode"))
+ .collect(Collectors.toSet());
+ assertFalse("The match must not appear in the time-cutoff page",
+ firstPageInodes.contains(match.getInode()));
+
+ // Resume from the returned cursor with a normal time budget so the scan can finish.
+ Config.setProperty(BrowserAPIImpl.BROWSER_DB_MAX_SCAN_TIME_MILLIS_KEY,
+ BrowserAPIImpl.BROWSER_DB_MAX_SCAN_TIME_MILLIS_DEFAULT);
+ final BrowserQuery secondQuery = BrowserQuery.builder()
+ .withHostOrFolderId(folder.getIdentifier())
+ .withFilter("Wombat")
+ .useElasticsearchFiltering(true)
+ .showContent(true)
+ .showFiles(false)
+ .showFolders(false)
+ .showLinks(false)
+ .showDotAssets(false)
+ .showWorking(true)
+ .showArchived(false)
+ .maxResults(100)
+ .contentCursor(firstPage.nextContentCursor)
+ .build();
+
+ final PaginatedContents secondPage = browserAPI.getPaginatedContents(secondQuery);
+ final Set secondPageInodes = secondPage.list.stream()
+ .map(item -> (String) item.get("inode"))
+ .collect(Collectors.toSet());
+
+ assertTrue("Resuming from the time-cutoff cursor must reach the match in a later "
+ + "chunk", secondPageInodes.contains(match.getInode()));
+ } finally {
+ Config.setProperty(BrowserAPIImpl.BROWSER_DB_MAX_SCAN_TIME_MILLIS_KEY,
+ BrowserAPIImpl.BROWSER_DB_MAX_SCAN_TIME_MILLIS_DEFAULT);
+ Config.setProperty("BROWSER_CONTENT_CHUNK_SIZE", 900);
+ }
+ }
+
+ /**
+ *
+ * - Method to Test: {@link BrowserAPI#getPaginatedContents(BrowserQuery)}
+ * - Given Scenario: Every item in the fixture matches the free-text filter, and
+ * {@code BROWSER_DB_MAX_SCAN_ROWS_ES_HARD_CAP} is lowered well below the fixture's row
+ * count while {@code BROWSER_DB_MAX_SCAN_TIME_MILLIS} is left generous, so only the row
+ * hard cap -- not the time budget, not running out of matches -- can terminate the
+ * ES-narrowed scan (found in review: the co-bound added alongside the time budget had no
+ * test of its own).
+ * - Expected Result: The scan stops at the hard cap with {@code hasMoreContent}
+ * true, proving the ES path is not left with an effectively unbounded row ceiling once
+ * the row-count-based cutoff was replaced by a time budget.
+ *
+ */
+ @Test
+ public void test_getPaginatedContents_esRowHardCap_stopsRunawayScanUnderGenerousTimeBudget()
+ throws Exception {
+ final int chunkSize = 5;
+ final int hardCap = 15;
+ final int fillerCount = 20;
+
+ Config.setProperty("BROWSER_CONTENT_CHUNK_SIZE", chunkSize);
+ Config.setProperty(BrowserAPIImpl.BROWSER_DB_MAX_SCAN_ROWS_ES_HARD_CAP_KEY, hardCap);
+ Config.setProperty(BrowserAPIImpl.BROWSER_DB_MAX_SCAN_TIME_MILLIS_KEY,
+ BrowserAPIImpl.BROWSER_DB_MAX_SCAN_TIME_MILLIS_DEFAULT);
+ try {
+ final Host host = new SiteDataGen().nextPersisted();
+ final Folder folder = new FolderDataGen().site(host).nextPersisted();
+ final var contentType = new ContentTypeDataGen()
+ .host(host)
+ .folder(folder)
+ .field(new FieldDataGen().name("title").velocityVarName("title").next())
+ .nextPersisted();
+
+ for (int i = 0; i < fillerCount; i++) {
+ new ContentletDataGen(contentType)
+ .setProperty("title", "AllMatchTerm " + i)
+ .host(host)
+ .folder(folder)
+ .setPolicy(IndexPolicy.WAIT_FOR)
+ .nextPersisted();
+ }
+
+ final BrowserQuery query = BrowserQuery.builder()
+ .withHostOrFolderId(folder.getIdentifier())
+ .withFilter("AllMatchTerm")
+ .withContentTypes(Set.of(contentType.id()))
+ .useElasticsearchFiltering(true)
+ .showContent(true)
+ .showFiles(false)
+ .showFolders(false)
+ .showLinks(false)
+ .showDotAssets(false)
+ .showWorking(true)
+ .showArchived(false)
+ .maxResults(100)
+ .contentCursor(0)
+ .build();
+
+ final PaginatedContents result = browserAPI.getPaginatedContents(query);
+
+ assertNotNull("Result must not be null", result);
+ assertTrue("The row hard cap must stop the scan before it reaches the end of the "
+ + "fixture (20 items) -- hasMoreContent should be true",
+ result.hasMoreContent);
+ assertTrue("nextContentCursor must reflect the hard cap having fired (>= hard cap, "
+ + "< full fixture size) -- was: " + result.nextContentCursor,
+ result.nextContentCursor >= hardCap && result.nextContentCursor < fillerCount);
+ } finally {
+ Config.setProperty(BrowserAPIImpl.BROWSER_DB_MAX_SCAN_ROWS_ES_HARD_CAP_KEY,
+ BrowserAPIImpl.BROWSER_DB_MAX_SCAN_ROWS_ES_HARD_CAP_DEFAULT);
+ Config.setProperty(BrowserAPIImpl.BROWSER_DB_MAX_SCAN_TIME_MILLIS_KEY,
+ BrowserAPIImpl.BROWSER_DB_MAX_SCAN_TIME_MILLIS_DEFAULT);
+ Config.setProperty("BROWSER_CONTENT_CHUNK_SIZE", 900);
+ }
+ }
+
/**
*
* - Method to Test: {@link BrowserAPI#getPaginatedContents(BrowserQuery)}
diff --git a/specs/37418-content-drive-global/spec.md b/specs/37418-content-drive-global/spec.md
index 1aa749f40158..a3852490ed68 100644
--- a/specs/37418-content-drive-global/spec.md
+++ b/specs/37418-content-drive-global/spec.md
@@ -127,13 +127,16 @@ Two adjacent theories were checked and **refuted**, ruling out alternative root
ever reaches ES.
The legacy Search All portlet does not exhibit this bug because it does not go through this
-DB-prescan/chunk mechanism — it queries ES directly. (Architecturally confirmed; the exact
-backing class/line was not pinned down in this pass — candidates are
-`ESSearchAPIImpl`/`ESContentResourcePortlet` under `dotCMS/src/main/java/com/dotcms/**` —
-and should be confirmed during planning.)
-
-[NEEDS CLARIFICATION: Confirm the exact Search All REST resource/service class and line
-reference, to formally document the working comparison path in the plan.]
+DB-prescan/chunk mechanism — it queries ES directly. **Confirmed during planning**: the search
+handler is `com.dotcms.rest.elasticsearch.ESContentResourcePortlet`
+(`dotCMS/src/main/java/com/dotcms/rest/elasticsearch/ESContentResourcePortlet.java`) —
+`search`/`searchPost` build a raw ES/OS Lucene query and execute it via
+`ContentletAPI.search(...)` (`esapi.search(...)`, lines 133-135 and its `searchPost`
+counterpart), with `esapi = APILocator.getContentletAPI()` (line 60); `searchRaw`
+(line 293) does the same via `esapi.searchRaw(...)`. None of these paths build DB-order
+paged candidate chunks first — the query goes straight to ES/OS, which matches and ranks
+over the whole index in one shot, so a match is never dropped by a DB-page cutoff before ES
+gets to see it.
## Fix Scope & Non-Goals *(mandatory)*