This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new 03deaf9de UNOMI-1020: Count aggregate matches exactly on the optimized 
path (#928)
03deaf9de is described below

commit 03deaf9de44887ee5c936d537c558c1015449ecf
Author: Serge Huber <[email protected]>
AuthorDate: Tue Oct 27 20:57:40 2026 +0100

    UNOMI-1020: Count aggregate matches exactly on the optimized path (#928)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../apache/unomi/itests/PersistenceServiceIT.java  | 37 ++++++++++++++++++++++
 .../ElasticSearchPersistenceServiceImpl.java       | 21 ++++--------
 .../OpenSearchPersistenceServiceImpl.java          | 22 ++++---------
 .../unomi/persistence/spi/PersistenceService.java  |  3 +-
 .../unomi/rest/endpoints/QueryServiceEndPoint.java |  2 +-
 5 files changed, 54 insertions(+), 31 deletions(-)

diff --git 
a/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java 
b/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
index 75ac30fe7..b7407678b 100644
--- a/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
@@ -437,6 +437,43 @@ public class PersistenceServiceIT extends BaseIT {
         Assert.assertEquals(Long.valueOf(2L), byLastName.get("_filtered"));
     }
 
+    /**
+     * Above 10,000 matches, the optimized aggregate must still report the 
exact number of matching items as
+     * _filtered, even when the bucket limit leaves some values out.
+     */
+    @Test
+    public void testOptimizedAggregateCountsMoreThan10000MatchesExactly() 
throws InterruptedException {
+        String marker = "agg-many-" + UUID.randomUUID();
+        int count = 10_005;
+        Condition matches = firstNameEquals(marker);
+        try {
+            for (int i = 0; i < count; i++) {
+                Profile profile = new Profile(marker + "-" + i);
+                profile.setProperty("firstName", marker);
+                profile.setProperty("lastName", "name-" + (i % 3));
+                persistenceService.save(profile, true);
+            }
+            keepTrying("All " + count + " profiles should be indexed", () -> {
+                        persistenceService.refreshIndex(Profile.class);
+                        return persistenceService.queryCount(matches, 
Profile.ITEM_TYPE);
+                    }, indexed -> indexed == count, DEFAULT_TRYING_TIMEOUT, 
120);
+
+            // Three last names but a bucket limit of 1, so the buckets only 
hold about a third of the matches
+            Map<String, Long> byLastName = 
persistenceService.aggregateWithOptimizedQuery(matches,
+                    new TermsAggregate("properties.lastName"), 
Profile.ITEM_TYPE, 1);
+            Assert.assertEquals(Long.valueOf(count), 
byLastName.get("_filtered"));
+
+            // The non-optimized path counts the tenant (_all) and the matches 
(_filtered) exactly too
+            Map<String, Long> nonOptimized = 
persistenceService.aggregateQuery(matches,
+                    new TermsAggregate("properties.lastName"), 
Profile.ITEM_TYPE);
+            Assert.assertEquals(Long.valueOf(count), 
nonOptimized.get("_filtered"));
+            
Assert.assertEquals(Long.valueOf(persistenceService.getAllItemsCount(Profile.ITEM_TYPE)),
 nonOptimized.get("_all"));
+        } finally {
+            persistenceService.removeByQuery(matches, Profile.class);
+            refreshPersistence(Profile.class);
+        }
+    }
+
     private Condition matchAllCondition() {
         return new 
Condition(definitionsService.getConditionType("matchAllCondition"));
     }
diff --git 
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
 
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
index b27807fec..ec7ffd2ad 100644
--- 
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
+++ 
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
@@ -2507,12 +2507,12 @@ public class ElasticSearchPersistenceServiceImpl 
implements PersistenceService,
 
                 Query tenantScopedQuery = 
wrapWithTenantAndItemTypeQuery(itemType, Query.of(q -> q.matchAll(m -> m)), 
finalTenantId);
 
-                // Optimized: put the optional condition in the query and skip 
the slow global aggregation.
-                // Non-optimized: keep a filter aggregation for _filtered, but 
always scope the query to the
-                // current tenant. A global aggregation ignores the query and 
would count every tenant's documents.
-                // Also register aggregations under stable names ("filter", 
"buckets", "missing"): the previous
-                // code used String.valueOf(Kind) ("Global") while reading 
Kind.jsonValue() ("global"), so POST
-                // aggregates with a condition returned nothing (UNOMI-1014).
+                // Optimized: the optional condition goes into the query, so 
_filtered is the hit count.
+                // Non-optimized: the query is the whole tenant (_all is the 
hit count) and a filter aggregation
+                // gives _filtered. Never use a global aggregation: it ignores 
the query and counts every tenant.
+                // hits.total stops at 10,000 unless hits are counted exactly; 
aggregations visit every matching
+                // document anyway, so exact counting costs little.
+                searchSourceBuilder.trackTotalHits(t -> t.enabled(true));
                 if (optimizedQuery) {
                     if (filter != null) {
                         
searchSourceBuilder.query(wrapWithTenantAndItemTypeQuery(itemType,
@@ -2523,8 +2523,6 @@ public class ElasticSearchPersistenceServiceImpl 
implements PersistenceService,
                     searchSourceBuilder.aggregations(aggregationsByType);
                 } else {
                     searchSourceBuilder.query(tenantScopedQuery);
-                    // _all comes from hits.total, which is capped at 10,000 
unless hits are tracked exactly.
-                    searchSourceBuilder.trackTotalHits(t -> t.enabled(true));
                     if (filter != null) {
                         Aggregation filterAggregation = new 
Aggregation.Builder().filter(wrapWithTenantAndItemTypeQuery(
                                         itemType, 
conditionESQueryBuilderDispatcher.buildFilter(filter), finalTenantId))
@@ -2586,20 +2584,15 @@ public class ElasticSearchPersistenceServiceImpl 
implements PersistenceService,
                             }
                         }
 
-                        long totalDocCount = addBucketCounts(bucketsAggregate, 
aggregate, results);
+                        addBucketCounts(bucketsAggregate, aggregate, results);
 
                         Aggregate missingAgg = aggregations.get("missing");
                         if (missingAgg != null && missingAgg.isMissing()) {
                             MissingAggregate missing = missingAgg.missing();
                             if (missing.docCount() > 0) {
                                 results.put("_missing", missing.docCount());
-                                totalDocCount += missing.docCount();
                             }
                         }
-                        if (response.hits() != null && response.hits().total() 
!= null
-                                && 
TotalHitsRelation.Gte.equals(response.hits().total().relation())) {
-                            results.put("_filtered", totalDocCount);
-                        }
                     }
                 }
                 return results;
diff --git 
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
 
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
index 235818bd2..fb2ff5b5b 100644
--- 
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
+++ 
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
@@ -2505,13 +2505,12 @@ public class OpenSearchPersistenceServiceImpl 
implements PersistenceService, Syn
                     }
                 }
 
-                // Optimized: put the optional condition in the query and skip 
the slow global aggregation.
-                // Non-optimized: keep a filter aggregation for _filtered, 
scoped to the current tenant.
-                // Register aggregations under stable names ("filter", 
"buckets", "missing"). The previous
-                // non-optimized path stored the global aggregation as 
"buckets" while reading "global", and
-                // nested the condition filter under "buckets" while reading 
"filter", so POST aggregates with
-                // a condition returned nothing (UNOMI-1014). A global 
aggregation also ignores the query and
-                // would count every tenant's documents.
+                // Optimized: the optional condition goes into the query, so 
_filtered is the hit count.
+                // Non-optimized: the query is the whole tenant (_all is the 
hit count) and a filter aggregation
+                // gives _filtered. Never use a global aggregation: it ignores 
the query and counts every tenant.
+                // hits.total stops at 10,000 unless hits are counted exactly; 
aggregations visit every matching
+                // document anyway, so exact counting costs little.
+                searchRequestBuilder.trackTotalHits(t -> t.enabled(true));
                 if (optimizedQuery) {
                     if (filter != null) {
                         
searchRequestBuilder.query(wrapWithTenantAndItemTypeQuery(itemType,
@@ -2524,8 +2523,6 @@ public class OpenSearchPersistenceServiceImpl implements 
PersistenceService, Syn
                     }
                 } else {
                     searchRequestBuilder.query(tenantScopedQuery);
-                    // _all comes from hits.total, which is capped at 10,000 
unless hits are tracked exactly.
-                    searchRequestBuilder.trackTotalHits(t -> t.enabled(true));
                     if (filter != null) {
                         Aggregation.Builder.ContainerBuilder 
filterAggregationContainerBuilder = new Aggregation.Builder().filter(
                                 wrapWithTenantAndItemTypeQuery(itemType, 
conditionOSQueryBuilderDispatcher.buildFilter(filter),
@@ -2585,19 +2582,14 @@ public class OpenSearchPersistenceServiceImpl 
implements PersistenceService, Syn
                             }
                         }
 
-                        long totalDocCount = addBucketCounts(bucketsAggregate, 
aggregate, results);
+                        addBucketCounts(bucketsAggregate, aggregate, results);
                         Aggregate missingAgg = aggregates.get("missing");
                         if (missingAgg != null && missingAgg.isMissing()) {
                             MissingAggregate missing = missingAgg.missing();
                             if (missing.docCount() > 0) {
                                 results.put("_missing", missing.docCount());
-                                totalDocCount += missing.docCount();
                             }
                         }
-                        if (response.hits() != null && response.hits().total() 
!= null
-                                && 
TotalHitsRelation.Gte.equals(response.hits().total().relation())) {
-                            results.put("_filtered", totalDocCount);
-                        }
                     }
                 }
                 return results;
diff --git 
a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
 
b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
index 430e71ac8..55d9f7c10 100644
--- 
a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
+++ 
b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
@@ -677,7 +677,8 @@ public interface PersistenceService {
     /**
      * Aggregates item counts for the given type, optionally filtered by 
condition and
      * aggregated according to the specified {@link BaseAggregate}.
-     * This aggregate won't return the {@code _all} count and should therefore 
be much faster than {@link #aggregateQuery(Condition, BaseAggregate, String)}
+     * Unlike {@link #aggregateQuery(Condition, BaseAggregate, String)}, it 
does not return the {@code _all} count, which saves
+     * counting every item of the type.
      *
      * @param filter    the condition the items must match or {@code null} if 
no filtering is needed
      * @param aggregate an aggregate specifying how matching items must be 
bundled
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/endpoints/QueryServiceEndPoint.java 
b/rest/src/main/java/org/apache/unomi/rest/endpoints/QueryServiceEndPoint.java
index 6a6d3c058..36b5a5fa3 100644
--- 
a/rest/src/main/java/org/apache/unomi/rest/endpoints/QueryServiceEndPoint.java
+++ 
b/rest/src/main/java/org/apache/unomi/rest/endpoints/QueryServiceEndPoint.java
@@ -92,7 +92,7 @@ public class QueryServiceEndPoint {
     /**
      * Returns property-value counts for an item type, optionally using an 
aggregate query.
      * <p>
-     * When {@code optimizedQuery} is {@code true}, the {@code _all} count of 
the tenant's items of that type is omitted for faster execution.
+     * When {@code optimizedQuery} is {@code true}, the {@code _all} count of 
the tenant's items of that type is omitted, which saves counting them.
      *
      * @param type the item type name from the class {@code ITEM_TYPE} field
      * @param property the property whose distinct values form aggregation 
buckets

Reply via email to