fix(content-drive): align keyword search with Content Search index query #36688 - #36767
fix(content-drive): align keyword search with Content Search index query #36688#36767ihoffmann-dot wants to merge 2 commits into
Conversation
|
Claude finished @ihoffmann-dot's task in 6m 58s —— View job PR Review — Content Drive keyword search #36688
New Issues
Resolved
What's good
RecommendationFix the suite registration (🟠 High) regardless of direction — the test is the whole regression guard and currently doesn't run. Then confirm the two open questions: (1) is uppercase |
|
Claude finished @ihoffmann-dot's task in 7m 16s —— View job Rollback-Safety Analysis
Verdict: ✅ Safe to Rollback — no category matched. Why:
What actually changed: a new On rollback to N-1: the toolbar keyword search simply reverts to routing through Elasticsearch again (re-introducing the original read-your-writes bug this PR fixes) — a functional regression, not data loss, startup failure, or a broken contract for a surviving consumer. N-1 boots normally and reads/writes exactly as before. Label |
fabrizzio-dotCMS
left a comment
There was a problem hiding this comment.
Code review
Thanks for the detailed write-up and for shipping a regression test with the fix. I agree the read-your-writes gap is real, but I don't think free-text search should be resolved in the database: I measured the query this PR introduces against two production-scale datasets and the cost is significant. I also don't think the "incoherent results" half of #36688 is fixed by this change.
🔴 Blocking
1. ContentDriveKeywordSearchTest is not registered in any suite, so it never runs in CI
It lives in dotcms-integration with a *Test name, so it only runs through @SuiteClasses. Its four siblings are registered (MainSuite3a.java:73-76: ContentDriveFieldFilterTest, ContentDriveHelperContentletAPIComparisonTest, ContentDriveWorkflowArchiveStepTest, ContentDriveWorkflowFilterTest), and the diff doesn't touch MainSuite3a. The "Integration Tests - MainSuite 3a" job on this PR passed in 27 minutes without executing a single new assertion — so the regression guard for #36688 is currently inert. One-line fix: add it to MainSuite3a.
2. Measured performance regression on the keyword search path
I rebuilt the exact SQL this PR produces — selectQuery base with skipFolder(true) (Content Drive at site root, so no parent_path predicate), the appendFilterQuery predicate (BrowserAPIImpl.java:2084-2104), order by c.mod_date desc LIMIT 900 — and ran EXPLAIN (ANALYZE, BUFFERS) on two anonymized production-scale datasets:
| Dataset | items in site scope | contentlet table |
pre-fix (no text predicate in SQL) | post-fix, common term | post-fix, no-match term |
|---|---|---|---|---|---|
| A | ~391,000 | 1.5 GB | 0.49 – 2.0 s | 4.7 s | 8.2 s |
| B | ~324,000 (+129k SYSTEM_HOST) | 9.2 GB | 0.37 s | — | 37.6 s |
Warm cache, local SSD, a single connection, no concurrency; EXPLAIN ANALYZE doesn't transfer rows, so these numbers are, if anything, optimistic.
Two aggravating factors that aren't visible in the diff:
order by c.mod_date descforces the predicate to be evaluated over the whole candidate set before theLIMIT 900can cut. There is no early exit for rare terms — and in a toolbar the user types progressive prefixes, so most keystrokes land in exactly the rare/no-match case (the 8–37 s column).- The existing safety valve stops bounding the work.
BROWSER_DB_MAX_SCAN_ROWS = 50_000(BrowserAPIImpl.java:725) caps rows returned per chunk, not rows examined. Previously the ILIKE was not in the SQL, so the DB scan stopped at 50k rows and ES narrowed each chunk. Now the predicate is inside the SQL, so Postgres sweeps the entire site (390k+ rows) evaluatingcontentlet_as_json::text, which forces a detoast + lz4 decompression of every row's JSON (postgres.sql:779). No trigram/GIN index exists that could serveILIKE '%x%', andpostgres.sql:2389only has an expression index on thetemplatefield.
This also reverses a deliberate decision: the ES text path was introduced for performance in #33416 / 6a024b8 ("significant performance improvements for text-based content searches"). Reversing it is fine if warranted, but it needs a measurement, and the measurement doesn't support it.
3. The "results unrelated to the typed text" half of #36688 is not fixed — the noise source just moves
appendFilterQuery matches contentlet_as_json::text, i.e. the whole serialized JSON document, not field values. That document contains (see com.dotcms.content.model.Contentlet:41-49, FieldValue's @JsonTypeInfo(property = "type"), and the ->'fields'->...->>'value' shape used in FolderIntegrityChecker:182):
identifier,inode,contentTypeids → any hex substring (1004,e5f) matches unrelated contentmodDateas ISO-8601 (ContentletJsonHelper:42setsWRITE_DATES_AS_TIMESTAMPS=false) → searching2026returns everything modified this year- JSON keys and type discriminators:
"fields","value","type":"Binary","Image","Text","TextArea"→ searchingimagereturns every content that has an Image field; searchingvalueortypereturns everything in scope
None of that was ever in the ES catchall, so for these terms precision gets worse, not better. The new test only asserts presence of the target and never exclusion of irrelevant items, which is why it passes. If the DB path stays in any form, the predicate should be scoped to field values (contentlet_as_json->'fields', e.g. jsonb_path_query_array(..., '$.fields.*.value')) and the test should include a negative assertion for a term like 2026 or Text.
Suggested alternatives — close the lag on the write side, not on every read
The lag is real and it's the default: INDEX_POLICY_SINGLE_CONTENT is DEFER (IndexPolicyProvider.java:35), so a save returns before the index refreshes. But that can be closed where it originates:
IndexPolicy.WAIT_FORon Content Drive's own write operations (upload, create, publish, rename, move). The write waits one refresh (≤refresh_interval, typically 1 s) and every subsequent search sees the item. That's ~1 s once per write instead of 4.7–37 s per search. Worth noting: the new test seeds its fixtures withsetPolicy(IndexPolicy.WAIT_FOR)— the primitive that solves this is already being used, in the right place.- Optimistic insert on the front end — zero backend cost. The client just created the item and holds its payload; it can prepend it to the list and reconcile on the next refresh. This is what drive-style UIs do, and it fully covers the "I just uploaded
IMG_1004.jpegand can't see it" report. - Recency-bounded overlay, if a server-side guarantee is wanted: keep ES for the search and union a second DB query restricted to recent writes (
and c.mod_date > now() - interval '5 minutes'plus the same scope). Same predicate as this PR, but bounded so the ILIKE evaluates over tens of rows instead of ~400k. - Fix the ES query, which is most likely the actual root cause.
buildBaseESQueryemits+(title:X* OR title:'X'^15 OR title_dotraw:*X*^5 OR +catchall:*X*^10). The+on the last clause makescatchallmandatory inquery_string, and wildcard/prefix terms don't go through the analyzer — so an uppercase term likeIMGcannot match the lowercased index terms (→ "finds nothing"), while lowercaseimgmatches any body containingimg(→ "unrelated results"). Those are precisely the two symptoms reported in #36688. Lowercasing the term, dropping the inner+, and targetingtitle/title_dotraw/metadata.nameinstead ofcatchalllikely fixes the report without moving anything to the database. If the ES text branch is kept for external callers, this bug stays latent either way and should be fixed.
🟠 Medium
4. Unescaped LIKE wildcards (BrowserAPIImpl.java:2085, 2102-2104) — pre-existing, but this PR promotes the path to Content Drive's primary keyword search. Searching 50% becomes ILIKE '%50%%' (matches "50" followed by anything); a_b matches "axb". Cheap fix: escape %, _, \ and add ESCAPE '\'.
5. The PR description overstates the DB predicate. Only the JSON clause is tokenized. asset_name, the binary asset name and the tag sub-select all use the full filter string, so the generated SQL is (json~tok1 AND json~tok2) OR asset_name~"full string" OR binName~"full string" OR tag~"full string". IMG 1004 works only because the file name is embedded in the JSON. Worth correcting in the description and the new javadoc.
🔵 Minor
- Two flags now encode one decision.
useElasticsearchFilteringis set only byContentDriveHelper, and only when INDEX field criteria exist, so the text group inbuildBaseESQuery/buildPureESQueryis unreachable from any in-product caller (external OSGi callers aside). Either drop it or mark it explicitly as external-caller-only — along with the+catchallbug above. ContentDriveHelper's class javadoc still says "Uses Elasticsearch for text filtering while maintaining database reliability" — stale after this change.keywordSearch_uppercaseIMG_findsFileandkeywordSearch_digits1004_findsFileare strict subsets ofkeywordSearch_findsFileAsset_caseInsensitive_anySubstring. The class already runs onDataProviderWeldRunner, so a@DataProviderover the terms would report each one separately instead of collapsing them into onefail().- Test:
languageId(1)is hardcoded while the search filters by the system default language — better derived fromAPILocator.getLanguageAPI().getDefaultLanguage().getId(). There's also no@AfterClasscleanup (site, content type, temp dirs leak), andremoveContentFromIndexleaves the index inconsistent for the rest of the suite. - No unit-level coverage for the two new branches.
BrowserAPITest.test_buildBaseESQuery_withDifferentFilterCombinations(already inMainSuite3a) is the natural home for two cases:resolveTextInDb=trueproducing no text group, and theisUseElasticSearchForFilteringtruth table.
What's good
- Blast radius is correctly contained:
useElasticsearchFilteringis only ever set byContentDriveHelper, so/v1/browserand the legacy JSP paths are byte-identical, and thefalsedefault guarantees it. - The read-your-writes test (de-index, then still find it) genuinely proves the behavior change — it would have failed before the fix.
- The composition test asserts exclusion when the field filter doesn't match, not just inclusion. 👍
- No DB schema, no ES mapping, no reindex, and
/v1/drive/searchis@Hiddenso noopenapi.yamlchurn — the rollback-safe claim holds.
Recommendation
As it stands the PR fixes a write-side problem (index lag) with a read-side regression (a full site scan per keystroke), and it doesn't close the precision half of the issue. I'd suggest (4) + (1) or (2) from the alternatives above, with (3) if a server-side guarantee is required, and I wouldn't leave resolveTextInDb as Content Drive's default. Item 1 (suite registration) should be fixed regardless of which direction the fix takes.
Problem
Content Drive's toolbar keyword/title search returned inconsistent, slow, and irrelevant results (issue #36688). Tested with client data, Content Drive diverged badly from the Search portlet: results took a long time and often had nothing to do with the query.
Root cause
Content Drive built its own Elasticsearch text query (
BrowserAPIImpl.buildBaseESQuery) that diverged from the Content Search portlet's query:catchall:*kw*(matches the term anywhere inside any term, across body text → unrelated matches; also a slow, non-indexed scan) instead of Content Search's selective prefix+catchall:kw*.+/ORboolean group.Content Search (
LuceneQueryBuilder+GlobalSearchAttributeStrategy) does not have these problems.Fix
buildBaseESQuerynow reusesGlobalSearchAttributeStrategy(the same strategy the Content Search portlet uses) to build the free-text clause — a selective+catchall:kw*prefix plus tokenized, escaped title boosts. Content Drive keyword search is now consistent with Content Search./v1/drive/searchis@Hidden, so noopenapi.yamlchange.Behavior (validated by
ContentDriveKeywordSearchTest)Seeding
IMG_1004.jpegand searching:IMG,img,Img,IMG_1004,jpeg1004,1004.jpeg,IMG_1004.jpegThe reported case (
IMG) now returns exactly the file (previously: unrelated results + the file missing).Note (consistency, not a regression introduced here): matching is prefix-based, so mid-token terms (
1004), boundary-spanning terms (1004.jpeg), and even the exact full filename (IMG_1004.jpeg— the.splits it intoimg_1004+jpegtokens) do not match. This is identical to the Content Search portlet using the same strategy. Worth a QA confirmation (cc perf/relevance testing).Out of scope / follow-ups
sortBy; full score-ordering parity with Content Search is a separate change to the hybrid flow.Closes #36688
🤖 Generated with Claude Code