Problem Statement
As part of the Elasticsearch → OpenSearch migration, the search aggregation API was made vendor-neutral. The return type of the aggregations exposed to Velocity changed, which silently breaks existing customer VTL templates that read aggregations from $estool.search(...) (the ESContentTool viewtool) / $contentlets results.
- Before:
$results.aggregations returned org.elasticsearch.search.aggregations.Aggregations. Templates walked it as aggregations.<name>.buckets, then bucket.getKeyAsNumber(), bucket.getDocCount(), and bucket.getAggregations().get("top_content").getHits().getHits() for nested top_hits.
- After:
$results.aggregations returns java.util.Map<String, java.util.List<AggregationBucket>>. AggregationBucket exposes only key() (String) and docCount() (long), and nested sub-aggregations are dropped entirely.
The query still executes and the data is present in the response — but the template can no longer reach the buckets, so the customer sees empty output / "no results" from the aggregation loop. No error is thrown (Velocity runtime.references.strict=false swallows the broken accessors), which makes it look like missing data.
Impact: Any customer/template relying on the documented Velocity aggregation walk is broken after the migration. Originally reported by a customer using a content_types terms aggregation with nested top_hits.
Findings — accessor-by-accessor breakage map
| VTL access |
Before (ES Aggregations) |
After (neutral) |
Status |
$results.totalResults |
getTotalResults() |
getTotalResults() |
✅ works |
$results.aggregations |
Aggregations object |
Map<String,List<AggregationBucket>> |
⚠️ type changed |
...aggregations.content_types |
Aggregations.get(name) → a Terms agg |
Map.get(name) → already the List<AggregationBucket> |
⚠️ semantics changed |
...content_types.buckets |
Terms.getBuckets() |
a List has no .buckets → null |
🔴 BREAKS |
#foreach($g in $contentTypeGroups) |
iterates buckets |
iterates null → 0 iterations |
🔴 nothing printed |
$g.getKeyAsNumber() |
exists on Terms.Bucket |
AggregationBucket only has key() (String) |
🔴 missing |
$g.getDocCount() |
exists |
AggregationBucket has docCount() (no get prefix) |
🔴 missing |
$g.getAggregations().get("top_content") |
nested sub-agg |
AggregationBucket has no sub-aggregations |
🔴 unrecoverable |
$topHits.getHits().getHits() |
top_hits hits |
— |
🔴 unrecoverable |
Three distinct regressions (not one)
.buckets removed — aggregations.<name> is the bucket list now; templates must drop .buckets.
- Accessors renamed and un-prefixed —
AggregationBucket uses Immutables-style key() / docCount(). There is no getKeyAsNumber() / getDocCount(), and because the methods have no get/is prefix they are not reachable as Velocity properties ($g.key fails); they only work as explicit calls $g.key().
- Nested aggregations lost —
ContentSearchResponse.from(...) maps only first-level terms aggregations ("Only terms aggregations are mapped; other types are silently skipped"). The nested top_content / top_hits per bucket is gone. This cannot be fixed by migrating the template — the neutral model lacks the capability.
Steps to Reproduce
Customer VTL (verbatim):
$response.setContentType("text/plain")
$response.setHeader("Cache-Control", "no-cache")
#set($esQuery = '{"aggs":{"content_types":{"terms":{"field":"contentType","size":5},"aggs":{"top_content":{"top_hits":{"size":3}}}}},"size":5,"query":{"bool":{"filter":[{"term":{"live":true}}]}}}')
#set($results = $estool.search($esQuery))
totalResults: $!{results.totalResults}
aggregations: $!{results.aggregations}
CT: $!{results.aggregations.content_types}
#set($contentTypeGroups = $results.aggregations.content_types.buckets)
buckets: $!{contentTypeGroups}
#foreach($group in $contentTypeGroups)
key: $!{group.getKeyAsNumber()}
docCount: $!{group.getDocCount()}
#set($topHits = $group.getAggregations().get("top_content"))
#foreach($hit in $topHits.getHits().getHits())
hit id: $!{hit.id}
#end
#end
Reproduction steps:
- Create a content type and publish a few live contentlets.
- Render the template above through the dotCMS Velocity engine with
$estool bound to ESContentTool and $response bound to the HTTP response.
- Observe:
totalResults and aggregations render with data (the content_types buckets are present in the map), but $contentTypeGroups (.buckets) is null, the #foreach runs 0 times, and no key: / docCount: / hit id: lines are emitted.
A reproduction integration test exists at dotcms-integration/.../viewtools/ContentSearchToolTest.java:
test_customerVTL_verbatim_reproducesAggregationRegression — renders the verbatim template and asserts the regression.
test_customerVTL_migratedToNeutralApi_emitsBuckets — shows the flat terms agg working once the template drops .buckets and calls key() / docCount() (still no path for nested top_hits).
Acceptance Criteria
dotCMS Version
Latest from main branch (Elasticsearch → OpenSearch migration work in progress).
Severity
Critical - System unusable
Links
NA (internal investigation; surfaced from a customer report — Freshdesk ticket TBD)
Problem Statement
As part of the Elasticsearch → OpenSearch migration, the search aggregation API was made vendor-neutral. The return type of the aggregations exposed to Velocity changed, which silently breaks existing customer VTL templates that read aggregations from
$estool.search(...)(theESContentToolviewtool) /$contentletsresults.$results.aggregationsreturnedorg.elasticsearch.search.aggregations.Aggregations. Templates walked it asaggregations.<name>.buckets, thenbucket.getKeyAsNumber(),bucket.getDocCount(), andbucket.getAggregations().get("top_content").getHits().getHits()for nestedtop_hits.$results.aggregationsreturnsjava.util.Map<String, java.util.List<AggregationBucket>>.AggregationBucketexposes onlykey()(String) anddocCount()(long), and nested sub-aggregations are dropped entirely.The query still executes and the data is present in the response — but the template can no longer reach the buckets, so the customer sees empty output / "no results" from the aggregation loop. No error is thrown (Velocity
runtime.references.strict=falseswallows the broken accessors), which makes it look like missing data.Impact: Any customer/template relying on the documented Velocity aggregation walk is broken after the migration. Originally reported by a customer using a
content_typesterms aggregation with nestedtop_hits.Findings — accessor-by-accessor breakage map
Aggregations)$results.totalResultsgetTotalResults()getTotalResults()$results.aggregationsAggregationsobjectMap<String,List<AggregationBucket>>...aggregations.content_typesAggregations.get(name)→ aTermsaggMap.get(name)→ already theList<AggregationBucket>...content_types.bucketsTerms.getBuckets()Listhas no.buckets→ null#foreach($g in $contentTypeGroups)null→ 0 iterations$g.getKeyAsNumber()Terms.BucketAggregationBucketonly haskey()(String)$g.getDocCount()AggregationBuckethasdocCount()(nogetprefix)$g.getAggregations().get("top_content")AggregationBuckethas no sub-aggregations$topHits.getHits().getHits()top_hitshitsThree distinct regressions (not one)
.bucketsremoved —aggregations.<name>is the bucket list now; templates must drop.buckets.AggregationBucketuses Immutables-stylekey()/docCount(). There is nogetKeyAsNumber()/getDocCount(), and because the methods have noget/isprefix they are not reachable as Velocity properties ($g.keyfails); they only work as explicit calls$g.key().ContentSearchResponse.from(...)maps only first-leveltermsaggregations ("Onlytermsaggregations are mapped; other types are silently skipped"). The nestedtop_content/top_hitsper bucket is gone. This cannot be fixed by migrating the template — the neutral model lacks the capability.Steps to Reproduce
Customer VTL (verbatim):
Reproduction steps:
$estoolbound toESContentTooland$responsebound to the HTTP response.totalResultsandaggregationsrender with data (thecontent_typesbuckets are present in the map), but$contentTypeGroups(.buckets) isnull, the#foreachruns 0 times, and nokey:/docCount:/hit id:lines are emitted.A reproduction integration test exists at
dotcms-integration/.../viewtools/ContentSearchToolTest.java:test_customerVTL_verbatim_reproducesAggregationRegression— renders the verbatim template and asserts the regression.test_customerVTL_migratedToNeutralApi_emitsBuckets— shows the flat terms agg working once the template drops.bucketsand callskey()/docCount()(still no path for nestedtop_hits).Acceptance Criteria
aggregations.<name>.bucketscontinues to work, OR a clearly documented migration path is published and the change is treated as a breaking change with release notes.getKey()/getKeyAsNumber()/getDocCount()in addition tokey()/docCount()).top_hitsinside atermsbucket — are representable through the neutral model (or an explicit, documented alternative is provided).ContentSearchToolTest(reproduction) passes against the fixed API.dotCMS Version
Latest from
mainbranch (Elasticsearch → OpenSearch migration work in progress).Severity
Critical - System unusable
Links
NA (internal investigation; surfaced from a customer report — Freshdesk ticket TBD)