Skip to content

fix(search): column bulk operations search not returning results at scale#27216

Open
sonika-shah wants to merge 9 commits intomainfrom
fix/column-bulk-search-at-scale
Open

fix(search): column bulk operations search not returning results at scale#27216
sonika-shah wants to merge 9 commits intomainfrom
fix/column-bulk-search-at-scale

Conversation

@sonika-shah
Copy link
Copy Markdown
Collaborator

@sonika-shah sonika-shah commented Apr 9, 2026

Fixes #27227

Summary

  • Bug: Searching for a column name (e.g., "MAT", "MATNR") in Column Bulk Operations returned 0 results when tables have 20000+ columns (reported by RATP & Airbus)
  • Root cause: Composite aggregation returns ALL column names from matching documents, then Java post-filters. With page size 25, the first composite page rarely contained matching column names — they were hidden further in the alphabet
  • Fix: When columnNamePattern is set, switch from composite aggregation to terms aggregation with include regex — ES/OS filters at the aggregation level, so only matching column names produce buckets

How it works: Two-phase terms aggregation

  1. Phase 1 — Names query (lightweight, no sub-aggs): terms agg with include regex, size=10000, ordered by _key asc → returns all matching column names + accurate total count in a single fast query
  2. Java pagination: Sort all matching names, slice the requested page (offset-based cursor)
  3. Phase 2 — Data query (targeted): terms agg with include = exact page names + top_hits → fetches full entity data for only the 25 names on the current page

Why terms agg include(regex) works even with flat objects (columns are not nested):

  • Terms agg scans the global ordinals dictionary — a pre-built sorted list of every unique value in the field across the entire index
  • include(regex) tests each ordinal independently against the regex — it doesn't matter that multiple values came from the same document
  • Non-matching ordinals never allocate a bucket, never scan documents — zero cost

Non-search path (no columnNamePattern): Unchanged — still uses composite aggregation with cursor-based pagination.

Approaches considered and rejected

1. Composite agg + Java post-filter (previous approach — the bug)

  • Composite returns ALL column names from matching documents, Java filters with String.contains() after
  • Why it fails: With 20,000+ columns, page size 25, matching names hidden deep in alphabet → first pages always return 0 matches

2. Composite agg with query-level regexp filter

  • Add regexp query on columns.name.keyword to pre-filter documents before aggregation
  • Rejected: Filters documents (tables), not column values. Since columns are flat objects (not nested), a table with 1 matching + 500 non-matching columns still returns all 501 column names in composite buckets

3. Composite agg + filter sub-agg + bucket_selector (elastic/elasticsearch#29079)

  • Use filter sub-aggregation + bucket_selector pipeline agg to drop non-matching buckets
  • Rejected:
    • bucket_selector is officially unsupported with composite (ES docs)
    • Columns are flat objects, not nested — filter sub-agg operates on documents, can't isolate which array value corresponds to which bucket key
    • Still creates ALL buckets first then prunes — pages can come back empty

4. Composite agg with runtime field + conditional emit()

  • Runtime field script filters values via conditional emit(), composite paginates with after_key
  • Rejected: Requires OpenSearch 2.14+ (we support 2.6+). Also significant performance penalty — disables global ordinals and early termination optimizations

5. Terms agg include(regex) + exclude(array) for pagination

  • First request gets 10,000 names with include(regex). Next request adds exclude([...previously seen names...]) to get the next batch
  • Rejected: Mixing regex-based include with array-based exclude is not supported on OpenSearch. Feature was added in ES 7.11 (elastic/elasticsearch#63325), but OpenSearch forked from ES 7.10.2 — before this was merged

6. Terms agg include(partition/num_partitions) + query-level regexp

  • Move regex to query level, use hash-based partitioning for pagination
  • Rejected: partition and regex share the same include parameter — mutually exclusive. And query-level regexp has the same flat-object problem as Approach 2

7. Composite agg with include/exclude on terms source

  • The ideal solution — composite's native pagination + regex filtering
  • Does not exist: Requested in elastic/elasticsearch#50368, closed Feb 2024 as "not planned". Elastic's focus shifted to ESQL

Why 10,000 cap on matching names

  • Terms agg requires an upfront size — there is no cursor/pagination mechanism
  • partition/num_partitions can't be combined with include(regex) (same field)
  • No cross-platform (ES + OpenSearch) way to paginate a terms agg with regex filtering
  • 10,000 unique column names matching a search pattern is an extreme edge case for a search feature
  • Phase 1 is lightweight (just string keys, no document data) so 10,000 buckets is cheap

Files changed

File Change
ColumnAggregator.java Added shared toCaseInsensitiveRegex() utility (Lucene regex doesn't support (?i), so "MAT" → .*[mM][aA][tT].*)
ElasticSearchColumnAggregator.java Added search branch with aggregateColumnsWithPattern(), executeNamesQuery(), executePageDataQuery(). Extracted shared parseBucketHits() and applyTagPostFilter() to avoid duplication. Offset-based cursor for search pagination
OpenSearchColumnAggregator.java Same two-phase terms agg approach for OpenSearch client
ColumnAggregatorTest.java Unit tests for toCaseInsensitiveRegex — case insensitivity, special char escaping, edge cases

Test plan

  • ColumnAggregatorTest — 8 unit tests for regex generation (all pass)
  • ColumnMetadataGrouperTest — 7 existing tests still pass (no regression)
  • mvn compile — clean build
  • Manual test: search "MAT" on a table with 20000+ columns → should return MAT, MATNR results
  • Manual test: pagination through search results works correctly
  • Manual test: search + tag filter combination works correctly
  • Manual test: non-search browsing (no pattern) still works as before

🤖 Generated with Claude Code


Summary by Gitar

  • Test stability:
    • Refactored ColumnGridResourceIT to use await() polling for search index consistency
    • Wrapped assertions in untilAsserted to eliminate race conditions in test_getColumnGrid_patternPlusGlossaryFilter and test_getColumnGrid_glossaryFilter_onlyReturnsGlossaryOccurrences

This will update automatically on new commits.

Copilot AI review requested due to automatic review settings April 9, 2026 19:46
…cale

When searching by column name pattern (e.g., "MAT") in column bulk
operations, the composite aggregation returned ALL column names from
matching documents, then post-filtered in Java. With 20000+ columns,
the first composite page of 25 names rarely contained matches, so
users saw 0 results.

Switch to terms aggregation with `include` regex when a search pattern
is set. This filters at the ES/OS aggregation level — only matching
column names produce buckets. Two-phase approach: (1) lightweight
names query to get all matching names + accurate total, (2) targeted
data query with top_hits for the current page only.
@sonika-shah sonika-shah force-pushed the fix/column-bulk-search-at-scale branch from a773a85 to 9f3b664 Compare April 9, 2026 19:47
@github-actions github-actions bot added backend safe to test Add this label to run secure Github workflows on PRs labels Apr 9, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes column-name search in Column Bulk Operations for very wide schemas (20k+ columns) by switching the columnNamePattern path from composite aggregation + Java post-filtering to a two-phase terms aggregation that filters bucket keys server-side using an include regexp.

Changes:

  • Added ColumnAggregator.toCaseInsensitiveRegex() to generate a Lucene-compatible, case-insensitive regexp for terms.include.
  • Implemented a pattern-search branch in both Elasticsearch and OpenSearch column aggregators using a two-phase terms aggregation (names query + page data query).
  • Added unit tests for regex generation and edge cases.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
openmetadata-service/src/main/java/org/openmetadata/service/search/ColumnAggregator.java Adds shared utility to build Lucene-compatible case-insensitive regex for terms include.
openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchColumnAggregator.java Adds pattern-search code path using two-phase terms aggregation and offset-based pagination cursor.
openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchColumnAggregator.java Mirrors the two-phase terms aggregation approach for OpenSearch and refactors bucket parsing.
openmetadata-service/src/test/java/org/openmetadata/service/search/ColumnAggregatorTest.java Adds unit tests validating regex generation behavior (case handling + escaping).
Comments suppressed due to low confidence (2)

openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchColumnAggregator.java:68

  • MAX_PATTERN_SEARCH_NAMES is hard-capped at 10,000 for the phase-1 terms aggregation. On large schemas (e.g., 20k+ columns) a broad pattern (like a single character) can easily match >10k distinct column names, which will silently truncate matchingNames, undercount totalUniqueColumns, and prevent users from paging to the missing matches. Consider paging the name collection (e.g., via composite agg with after_key, or partitioning the terms agg) or raising the limit to cover worst-case table sizes and explicitly detecting/tracking truncation when the limit is hit.
  /** Max column names to retrieve in the names-only query during pattern search. */
  private static final int MAX_PATTERN_SEARCH_NAMES = 10000;

  /** Index configuration with field mappings for each entity type. Uses aliases defined in indexMapping.json */
  private static final Map<String, IndexConfig> INDEX_CONFIGS =
      Map.of(
          "table",

openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchColumnAggregator.java:70

  • The phase-1 pattern search uses a terms agg with size=MAX_PATTERN_SEARCH_NAMES (10,000). If the pattern matches more than 10k distinct column names (common on 20k+ column tables for broad patterns), the names list and totalUniqueColumns will be truncated and the remaining matches become unreachable via pagination. Consider implementing a paged name scan (e.g., composite agg with cursor) or otherwise guaranteeing retrieval of all matching names (and/or surfacing a truncation indicator).
  /** Max column names to retrieve in the names-only query during pattern search. */
  private static final int MAX_PATTERN_SEARCH_NAMES = 10000;

  /** Uses aliases defined in indexMapping.json */
  private static final List<String> DATA_ASSET_INDEXES =
      Arrays.asList("table", "dashboardDataModel", "topic", "searchIndex", "container");

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

🟡 Playwright Results — all passed (19 flaky)

✅ 3668 passed · ❌ 0 failed · 🟡 19 flaky · ⏭️ 89 skipped

Shard Passed Failed Flaky Skipped
🟡 Shard 1 480 0 1 4
🟡 Shard 2 649 0 4 7
🟡 Shard 3 655 0 4 1
🟡 Shard 4 631 0 3 27
🟡 Shard 5 610 0 1 42
🟡 Shard 6 643 0 6 8
🟡 19 flaky test(s) (passed on retry)
  • Pages/UserCreationWithPersona.spec.ts › Create user with persona and verify on profile (shard 1, 1 retry)
  • Features/BulkEditEntity.spec.ts › Glossary (shard 2, 1 retry)
  • Features/DataQuality/TestCaseImportExportE2eFlow.spec.ts › Admin: Complete export-import-validate flow (shard 2, 1 retry)
  • Features/DataQuality/TestCaseResultPermissions.spec.ts › User with only VIEW cannot PATCH results (shard 2, 1 retry)
  • Features/Glossary/GlossaryWorkflow.spec.ts › should inherit reviewers from glossary when term is created (shard 2, 1 retry)
  • Features/RestoreEntityInheritedFields.spec.ts › Validate restore with Inherited domain and data products assigned (shard 3, 2 retries)
  • Features/RestoreEntityInheritedFields.spec.ts › Validate restore with Inherited domain and data products assigned (shard 3, 1 retry)
  • Features/RestoreEntityInheritedFields.spec.ts › Validate restore with Inherited domain and data products assigned (shard 3, 1 retry)
  • Features/RestoreEntityInheritedFields.spec.ts › Validate restore with Inherited domain and data products assigned (shard 3, 1 retry)
  • Pages/Customproperties-part2.spec.ts › entityReferenceList shows item count, scrollable list, no expand toggle (shard 4, 1 retry)
  • Pages/DataContracts.spec.ts › Create Data Contract and validate for SearchIndex (shard 4, 1 retry)
  • Pages/Domains.spec.ts › Domain Rbac (shard 4, 1 retry)
  • Pages/Glossary.spec.ts › Add and Remove Assets (shard 5, 2 retries)
  • Pages/Lineage/DataAssetLineage.spec.ts › verify create lineage for entity - Search Index (shard 6, 1 retry)
  • Pages/Lineage/DataAssetLineage.spec.ts › verify create lineage for entity - Data Model (shard 6, 2 retries)
  • Pages/Lineage/LineageFilters.spec.ts › Verify lineage schema filter selection (shard 6, 1 retry)
  • Pages/Lineage/LineageRightPanel.spec.ts › Verify custom properties tab IS visible for supported type: searchIndex (shard 6, 1 retry)
  • Pages/Lineage/PlatformLineage.spec.ts › Verify domain platform view (shard 6, 1 retry)
  • Pages/Users.spec.ts › Permissions for table details page for Data Consumer (shard 6, 1 retry)

📦 Download artifacts

How to debug locally
# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip    # view trace

Copilot AI review requested due to automatic review settings April 13, 2026 06:30
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Copilot AI review requested due to automatic review settings April 19, 2026 05:18
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.

Copilot AI review requested due to automatic review settings April 20, 2026 06:31
@gitar-bot
Copy link
Copy Markdown

gitar-bot bot commented Apr 20, 2026

Code Review ✅ Approved 4 resolved / 4 findings

Bulk search column operations now correctly return results at scale. The fix replaces the case-sensitive HashMap with a case-insensitive TreeSet and aligns unit tests with the Lucene/ES regex engine.

✅ 4 resolved
Bug: Unit tests validate Java regex, not Lucene/ES regex engine

📄 openmetadata-service/src/test/java/org/openmetadata/service/search/ColumnAggregatorTest.java:30-36
ColumnAggregatorTest uses java.util.regex.Pattern to validate the output of toCaseInsensitiveRegex, but at runtime the regex is executed by Elasticsearch/OpenSearch's Lucene-based regex engine, which has different syntax rules (e.g., no backreferences, different anchoring behavior). While the subset of features used here (character classes, .*, literal escaping) is compatible with both engines, the tests don't guarantee correctness against the actual runtime engine.

Consider adding an integration test (or noting this caveat in the test class) that validates the regex against a real ES/OS instance, especially for edge cases like Unicode characters or less common special characters.

Edge Case: TreeSet case-insensitive dedup vs HashMap case-sensitive lookup

📄 openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchColumnAggregator.java:272-273 📄 openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchColumnAggregator.java:290-291 📄 openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchColumnAggregator.java:200-201 📄 openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchColumnAggregator.java:218-219
In aggregateColumnsWithKnownNames (ES line 272-291, OS line 200-219), a TreeSet(String.CASE_INSENSITIVE_ORDER) is used to deduplicate column names, but taggedColumns is a regular HashMap with case-sensitive keys. If two documents contribute the same column name with different casing (e.g., "MyCol" vs "mycol"), the TreeSet will keep only one variant. When taggedColumns.get(name) is called with that variant, it will only find entries under the exact matching case key, silently dropping occurrences stored under the other case variant.

In practice this is unlikely (column names from the same logical column usually have consistent casing), but it could cause missing occurrences in edge cases.

Bug: totalOccurrences is per-page, not global, in pattern search path

📄 openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchColumnAggregator.java:267-268 📄 openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchColumnAggregator.java:187
In the new aggregateColumnsWithPattern method, totalOccurrences is computed from only the current page's grid items (e.g., 25 columns), while totalUniqueColumns is computed from phase 1 across ALL matching names. This means the API response has an accurate totalUniqueColumns but an inaccurate (page-local) totalOccurrences that changes as users paginate.

If the UI displays "X total occurrences" alongside the correct unique-columns count, the numbers will appear inconsistent. On page 1 you might see totalOccurrences=50, on page 2 it becomes totalOccurrences=42, etc.

If getting a global total is too expensive, consider documenting in the response or API contract that totalOccurrences is approximate/page-local when a search pattern is active, or omit it entirely for pattern searches.

Edge Case: Terms agg silently truncates at 10000 matching column names

📄 openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchColumnAggregator.java:63 📄 openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchColumnAggregator.java:664 📄 openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchColumnAggregator.java:65 📄 openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchColumnAggregator.java:575
The phase-1 names query uses size(MAX_PATTERN_SEARCH_NAMES = 10000) on the terms aggregation. If a broad pattern (e.g., single character "a") matches more than 10,000 distinct column names, the results are silently truncated: totalUniqueColumns will report 10,000, pagination will stop there, and the user won't know results are missing.

Consider adding a check: if the returned bucket count equals MAX_PATTERN_SEARCH_NAMES, either log a warning, return an indicator in the response (e.g., totalUniqueColumns set to -1 or an isTruncated flag), or require a minimum pattern length to avoid overly broad matches.

Options

Display: compact → Showing less information.

Comment with these commands to change:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +1811 to +1816
ColumnGridResponse page2 = getColumnGrid(client, baseQuery + "&cursor=" + page1.getCursor());
assertEquals(2, page2.getColumns().size(), "Page 2 should have exactly 2 columns");
assertNotNull(page2.getCursor(), "Page 2 should have a cursor for next page");

ColumnGridResponse page3 = getColumnGrid(client, baseQuery + "&cursor=" + page2.getCursor());
assertEquals(1, page3.getColumns().size(), "Page 3 (last) should have exactly 1 column");
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cursor is appended to the query string without URL-encoding. Since cursors are Base64, they can contain characters like +, /, and = that are not safe in a raw query param (e.g., + may be decoded as a space), which can make this pagination test flaky. Encode the cursor before concatenating it into queryParams (e.g., via URLEncoder.encode(cursor, UTF_8)).

Copilot uses AI. Check for mistakes.
@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend safe to test Add this label to run secure Github workflows on PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Column Bulk Operation Search does not return results from subsequent pages for large column counts

2 participants