From 76d8bdfe67fbf244970568053f259f169ff5b2d9 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Tue, 15 Sep 2026 22:43:02 -0300 Subject: [PATCH 1/3] chore(content-drive): start implementation branch for issue #37211 Spec approved and merged in #37525. Planning (/speckit-plan, /speckit-tasks) starts here. From ff86bd17693e0ea2bf169ccfdf6984f3d948717e Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Wed, 16 Sep 2026 20:23:13 -0300 Subject: [PATCH 2/3] fix(content-drive): bound the ES-narrowed scan by time, not row count (#37211) getContentByChunks dropped matches silently when no content-type filter narrowed the candidate set: the row-count cutoff (BROWSER_DB_MAX_SCAN_ROWS) could fire before a match further down DB order was ever sent to ES for text narrowing. Bound the ES-filtered path by elapsed time (BROWSER_DB_MAX_SCAN_TIME_MILLIS) instead, so it keeps scanning while still affordable; the permission-only path keeps its original row-count cutoff. Also resolves spec.md's NEEDS CLARIFICATION on the Search All comparison path (ESContentResourcePortlet). --- .../com/dotcms/browser/BrowserAPIImpl.java | 48 +++- .../com/dotcms/browser/BrowserAPITest.java | 252 ++++++++++++++++++ specs/37418-content-drive-global/spec.md | 17 +- 3 files changed, 306 insertions(+), 11 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java index b0e900cde2df..f745364b8c9f 100644 --- a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java @@ -247,6 +247,15 @@ 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). When {@code false} + * (permission-only filtering), it stays bounded by row count + * ({@code BROWSER_DB_MAX_SCAN_ROWS}), unchanged from before. + *

* * @param browserQuery query containing search criteria, user context, and the current cursor * @param maxRows maximum number of permission-visible items to return @@ -263,7 +272,15 @@ 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); + // 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; + final long scanStartNanos = System.nanoTime(); final List accumulatedContent = new ArrayList<>(); List candidateChunkInodes; @@ -296,10 +313,20 @@ private ContentUnderParent getContentByChunks(final BrowserQuery browserQuery, dbOffset += candidateChunkInodes.size(); - 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. + final boolean scanBudgetExhausted = applyESFilter + ? TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - scanStartNanos) >= scanTimeBudgetMillis + : dbOffset >= scanRowLimit; + + if (scanBudgetExhausted) { 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.", + applyESFilter ? scanTimeBudgetMillis + "ms" : scanRowLimit + " rows", + chunkCount, dbOffset, accumulatedContent.size())); nextContentCursor = dbOffset; hasMore = true; break; @@ -748,9 +775,22 @@ 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; + /** * Represents content items under a specific parent along with the total count. * This class is immutable and holds a list of content items and their total results count. 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 f543fa6e8b50..f885dfca1760 100644 --- a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java +++ b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java @@ -2228,6 +2228,258 @@ 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#getFolderContentList(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)* From 1ba054eec788bc76554122cf017a17d1e7afbc8b Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Wed, 16 Sep 2026 21:50:29 -0300 Subject: [PATCH 3/3] fix(content-drive): add row hard cap and cutoff test coverage per review (#37211) Addresses PR bot review feedback on the ES-narrowed scan time-budget fix: - Add BROWSER_DB_MAX_SCAN_ROWS_ES_HARD_CAP as a co-bound alongside the time budget, so a fast DB/ES pair (or many concurrent unfiltered searches) can't turn the time budget into unbounded per-request work. - Add an integration test that forces the time-cutoff branch itself (not just DB exhaustion) and confirms the returned cursor resumes correctly. - Add an integration test that forces the new row hard cap. - Document that completeness here is best-effort (load-dependent), and that BROWSER_DB_MAX_SCAN_ROWS still clamps the ES path's chunk size even though it no longer bounds the ES scan's total rows. Co-Authored-By: Claude Sonnet 5 --- .../com/dotcms/browser/BrowserAPIImpl.java | 67 ++++++- .../com/dotcms/browser/BrowserAPITest.java | 186 ++++++++++++++++++ 2 files changed, 244 insertions(+), 9 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java index 05fe0cc20fbe..7d63136e47ca 100644 --- a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java @@ -258,9 +258,21 @@ ContentUnderParent getContentUnderParentFromDB(final BrowserQuery browserQuery, * 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). When {@code false} - * (permission-only filtering), it stays bounded by row count - * ({@code BROWSER_DB_MAX_SCAN_ROWS}), unchanged from before. + * 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 @@ -284,6 +296,14 @@ private ContentUnderParent getContentByChunks(final BrowserQuery browserQuery, // scan limit below the caller's chunk size (e.g. below BROWSER_SINGLE_PASS_CHUNK_SIZE's // 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). + // 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 @@ -292,6 +312,15 @@ private ContentUnderParent getContentByChunks(final BrowserQuery browserQuery, 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<>(); @@ -356,18 +385,26 @@ private ContentUnderParent getContentByChunks(final BrowserQuery browserQuery, // 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. 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). + // 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 - ? TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - scanStartNanos) >= scanTimeBudgetMillis + ? (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 budget reached (%s) after %d chunks, %d rows scanned. Returning %d accumulated items.", - applyESFilter ? scanTimeBudgetMillis + "ms" : scanRowLimit + " rows", - chunkCount, dbOffset, accumulatedContent.size())); + exhaustedBoundDescription, chunkCount, dbOffset, accumulatedContent.size())); nextContentCursor = dbOffset; hasMore = true; break; @@ -878,6 +915,18 @@ private String buildPureESQuery(final BrowserQuery browserQuery) { 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 9c4ba59b4eac..f3a83cfb52a0 100644 --- a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java +++ b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java @@ -2490,6 +2490,192 @@ public void test_getPaginatedContents_unfilteredTextSearch_noMatchStaysBounded() } } + /** + *
      + *
    • 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)}