implements SerializablePagedCollection<
return context;
}
+ @Override
+ public String getNextSearchAfterToken()
+ {
+ return nextSearchAfterToken;
+ }
+
}
diff --git a/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/Paging.java b/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/Paging.java
index f297945f1b..082224ef81 100644
--- a/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/Paging.java
+++ b/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/Paging.java
@@ -27,6 +27,8 @@ package org.alfresco.rest.framework.resource.parameters;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.deser.std.StringDeserializer;
import org.alfresco.rest.framework.core.exceptions.InvalidArgumentException;
@@ -46,8 +48,9 @@ public class Paging
private final int skipCount;
private final int maxItems;
+ private final String searchAfterToken;
- private Paging(int skipCount, int maxItems)
+ private Paging(int skipCount, int maxItems, String searchAfterToken)
{
super();
if (skipCount < 0)
@@ -60,6 +63,7 @@ public class Paging
}
this.skipCount = skipCount;
this.maxItems = maxItems;
+ this.searchAfterToken = searchAfterToken;
}
/**
@@ -82,21 +86,39 @@ public class Paging
return this.maxItems;
}
- @JsonCreator
- public static Paging valueOf(@JsonProperty("skipCount") int skipCount, @JsonProperty("maxItems") int maxItems)
+ /**
+ * The opaque search_after cursor for the next page, or null if not using cursor-based paging.
+ *
+ * @return String
+ */
+ public String getSearchAfterToken()
{
- return new Paging(skipCount, maxItems);
+ return this.searchAfterToken;
+ }
+
+ public static Paging valueOf(int skipCount, int maxItems)
+ {
+ return new Paging(skipCount, maxItems, null);
+ }
+
+ @JsonCreator
+ public static Paging valueOf(@JsonProperty("skipCount") int skipCount, @JsonProperty("maxItems") int maxItems,
+ @JsonProperty("searchAfterToken") @JsonDeserialize(using = StringDeserializer.class) String searchAfterToken)
+ {
+ return new Paging(skipCount, maxItems, searchAfterToken);
}
@Override
public String toString()
{
StringBuilder builder = new StringBuilder();
- builder.append("Paging [skipCount=");
- builder.append(this.skipCount);
- builder.append(", maxItems=");
- builder.append(this.maxItems);
- builder.append("]");
+ builder.append("Paging [skipCount=")
+ .append(this.skipCount)
+ .append(", maxItems=")
+ .append(this.maxItems)
+ .append(", searchAfterToken=")
+ .append(this.searchAfterToken)
+ .append("]");
return builder.toString();
}
diff --git a/remote-api/src/main/java/org/alfresco/rest/framework/webscripts/ResourceWebScriptHelper.java b/remote-api/src/main/java/org/alfresco/rest/framework/webscripts/ResourceWebScriptHelper.java
index 1eb51e2c42..2ce6f7beeb 100644
--- a/remote-api/src/main/java/org/alfresco/rest/framework/webscripts/ResourceWebScriptHelper.java
+++ b/remote-api/src/main/java/org/alfresco/rest/framework/webscripts/ResourceWebScriptHelper.java
@@ -153,7 +153,7 @@ public class ResourceWebScriptHelper
}
}
return CollectionWithPagingInfo.asPaged(collectionToWrap.getPaging(), resultCollection, collectionToWrap.hasMoreItems(),
- collectionToWrap.getTotalItems(), sourceEntity, collectionToWrap.getContext());
+ collectionToWrap.getTotalItems(), sourceEntity, collectionToWrap.getContext(), collectionToWrap.getNextSearchAfterToken());
}
else
{
diff --git a/remote-api/src/main/resources/alfresco/public-rest-context.xml b/remote-api/src/main/resources/alfresco/public-rest-context.xml
index b338a41701..4ec8159b7c 100644
--- a/remote-api/src/main/resources/alfresco/public-rest-context.xml
+++ b/remote-api/src/main/resources/alfresco/public-rest-context.xml
@@ -144,6 +144,7 @@
+
@@ -1265,7 +1266,7 @@
-
+
diff --git a/remote-api/src/test/java/org/alfresco/rest/api/search/SearchMapperTests.java b/remote-api/src/test/java/org/alfresco/rest/api/search/SearchMapperTests.java
index c37ea39d58..69dcd28d0a 100644
--- a/remote-api/src/test/java/org/alfresco/rest/api/search/SearchMapperTests.java
+++ b/remote-api/src/test/java/org/alfresco/rest/api/search/SearchMapperTests.java
@@ -194,6 +194,21 @@ public class SearchMapperTests
assertEquals(searchParameters.getSkipCount(), paging.getSkipCount());
}
+ @Test
+ public void fromSearchAfter() throws Exception
+ {
+ SearchParameters searchParameters = new SearchParameters();
+ searchMapper.fromPaging(searchParameters, Paging.valueOf(0, 100));
+ assertNull(searchParameters.getSearchAfterToken());
+
+ searchMapper.fromPaging(searchParameters, Paging.valueOf(0, 100, "SEARCH_AFTER_TOKEN"));
+ assertEquals("SEARCH_AFTER_TOKEN", searchParameters.getSearchAfterToken());
+
+ // An explicit empty searchAfter starts a new cursor-paging session (first page).
+ searchMapper.fromPaging(searchParameters, Paging.valueOf(0, 100, ""));
+ assertEquals("", searchParameters.getSearchAfterToken());
+ }
+
@Test
public void fromSort() throws Exception
{
diff --git a/repository/src/main/java/org/alfresco/repo/search/SearchEngineResultSet.java b/repository/src/main/java/org/alfresco/repo/search/SearchEngineResultSet.java
index 5a181e42f2..19e1ca8a4c 100644
--- a/repository/src/main/java/org/alfresco/repo/search/SearchEngineResultSet.java
+++ b/repository/src/main/java/org/alfresco/repo/search/SearchEngineResultSet.java
@@ -2,7 +2,7 @@
* #%L
* Alfresco Data model classes
* %%
- * Copyright (C) 2005 - 2021 Alfresco Software Limited
+ * Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
@@ -52,4 +52,9 @@ public interface SearchEngineResultSet extends ResultSet, SearchEngineResultMeta
long getLastIndexedTxId();
boolean getProcessedDenies();
+
+ default String getNextSearchAfterToken()
+ {
+ return null;
+ }
}
diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngine.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngine.java
new file mode 100644
index 0000000000..0aa07a2911
--- /dev/null
+++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngine.java
@@ -0,0 +1,31 @@
+/*
+ * #%L
+ * Alfresco Repository
+ * %%
+ * Copyright (C) 2005 - 2026 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.repo.search.impl.elasticsearch.admin;
+
+public enum SearchEngine
+{
+ ELASTICSEARCH, OPENSEARCH, UNKNOWN
+}
diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngineDetector.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngineDetector.java
new file mode 100644
index 0000000000..81b71bed5c
--- /dev/null
+++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngineDetector.java
@@ -0,0 +1,126 @@
+/*
+ * #%L
+ * Alfresco Repository
+ * %%
+ * Copyright (C) 2005 - 2026 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.repo.search.impl.elasticsearch.admin;
+
+import java.io.IOException;
+import java.util.Locale;
+
+import org.json.JSONObject;
+import org.opensearch.client.opensearch.generic.Body;
+import org.opensearch.client.opensearch.generic.Request;
+import org.opensearch.client.opensearch.generic.Requests;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.alfresco.repo.search.impl.elasticsearch.client.ElasticsearchHttpClientFactory;
+import org.alfresco.repo.security.authentication.AuthenticationUtil;
+import org.alfresco.repo.transaction.RetryingTransactionHelper;
+import org.alfresco.service.cmr.attributes.AttributeService;
+import org.alfresco.service.transaction.TransactionService;
+
+/**
+ * Detects the search engine provider (OpenSearch vs Elasticsearch) and version, and persists it via {@link AttributeService}. Invoked by {@link org.alfresco.repo.search.impl.elasticsearch.contentmodelsync.ElasticsearchInitialiser} once the engine is confirmed reachable.
+ */
+public class SearchEngineDetector
+{
+ private static final Logger LOGGER = LoggerFactory.getLogger(SearchEngineDetector.class);
+
+ public static final String ATTR_ROOT = ".searchEngine";
+ public static final String ATTR_SEARCH_ENGINE_NAME = "name"; // "OpenSearch" | "Elasticsearch"
+ public static final String ATTR_SEARCH_ENGINE_VERSION = "version"; // e.g. "2.13.0"
+ // Access the search engine info using something like:
+ // Search engine name: attributeService.getAttribute(ATTR_ROOT, ATTR_SEARCH_ENGINE_NAME)
+ // Search engine version: attributeService.getAttribute(ATTR_ROOT, ATTR_SEARCH_ENGINE_VERSION)
+
+ private ElasticsearchHttpClientFactory httpClientFactory;
+ private AttributeService attributeService;
+ private TransactionService transactionService;
+
+ public void detectAndStore()
+ {
+ try
+ {
+ SearchEngineInfo searchEngineInfo = detect();// [provider, version]
+ store(searchEngineInfo);
+ LOGGER.info("Detected search engine: {} {}", searchEngineInfo.getSearchEngineName(), searchEngineInfo.getSearchEngineVersion());
+ }
+ catch (Exception e)
+ {
+ LOGGER.warn("Could not detect the search engine provider/version", e);
+ }
+ }
+
+ private SearchEngineInfo detect() throws IOException
+ {
+ Request request = Requests.builder().method("GET").endpoint("/").build();
+ try (var response = httpClientFactory.getElasticsearchClient().generic().execute(request))
+ {
+ String raw = response.getBody()
+ .map(Body::bodyAsString)
+ .orElseThrow(() -> new IOException("Empty response from root endpoint"));
+
+ JSONObject root = new JSONObject(raw);
+ String versionNumber = root.getJSONObject("version").getString("number");
+
+ SearchEngine searchEngine = switch (root.getString("tagline").toLowerCase(Locale.ROOT))
+ {
+ case "you know, for search" -> SearchEngine.ELASTICSEARCH;
+ case "the opensearch project: https://opensearch.org/" -> SearchEngine.OPENSEARCH;
+ default -> SearchEngine.UNKNOWN;
+ };
+ String provider = searchEngine.name().toLowerCase(Locale.ROOT);
+ return new SearchEngineInfo(provider, versionNumber);
+ }
+ }
+
+ private void store(SearchEngineInfo searchEngineInfo)
+ {
+ AuthenticationUtil.runAs((AuthenticationUtil.RunAsWork) () -> {
+ RetryingTransactionHelper txnHelper = transactionService.getRetryingTransactionHelper();
+ txnHelper.setForceWritable(true);
+ return txnHelper.doInTransaction(() -> {
+ attributeService.setAttribute(searchEngineInfo.getSearchEngineName(), ATTR_ROOT, ATTR_SEARCH_ENGINE_NAME);
+ attributeService.setAttribute(searchEngineInfo.getSearchEngineVersion(), ATTR_ROOT, ATTR_SEARCH_ENGINE_VERSION);
+ return null;
+ }, false, true);
+ }, AuthenticationUtil.getSystemUserName());
+ }
+
+ public void setHttpClientFactory(ElasticsearchHttpClientFactory httpClientFactory)
+ {
+ this.httpClientFactory = httpClientFactory;
+ }
+
+ public void setAttributeService(AttributeService attributeService)
+ {
+ this.attributeService = attributeService;
+ }
+
+ public void setTransactionService(TransactionService transactionService)
+ {
+ this.transactionService = transactionService;
+ }
+}
diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngineInfo.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngineInfo.java
new file mode 100644
index 0000000000..8fc4b1a6d8
--- /dev/null
+++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngineInfo.java
@@ -0,0 +1,48 @@
+/*
+ * #%L
+ * Alfresco Repository
+ * %%
+ * Copyright (C) 2005 - 2026 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.repo.search.impl.elasticsearch.admin;
+
+public class SearchEngineInfo
+{
+ private String searchEngineName;
+ private String searchEngineVersion;
+
+ public SearchEngineInfo(String searchEngineName, String searchEngineVersion)
+ {
+ this.searchEngineName = searchEngineName;
+ this.searchEngineVersion = searchEngineVersion;
+ }
+
+ public String getSearchEngineName()
+ {
+ return searchEngineName;
+ }
+
+ public String getSearchEngineVersion()
+ {
+ return searchEngineVersion;
+ }
+}
diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/client/ElasticsearchHttpClientFactory.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/client/ElasticsearchHttpClientFactory.java
index 66a25fe0c3..18fe5843a6 100644
--- a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/client/ElasticsearchHttpClientFactory.java
+++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/client/ElasticsearchHttpClientFactory.java
@@ -77,6 +77,9 @@ public class ElasticsearchHttpClientFactory
private String baseUrl;
private int port;
+ // Search engine implementation (elasticsearch, opensearch)
+ private String engine;
+
// SSL parameters for Elasticsearch server endpoint
private String secureComms;
private AlfrescoKeyStore sslTrustStore;
@@ -235,6 +238,16 @@ public class ElasticsearchHttpClientFactory
return (secureComms.equals("https") ? "https" : "http") + "://" + host + ":" + port + baseUrl;
}
+ /**
+ * Gets the configured search engine implementation (elasticsearch, opensearch).
+ *
+ * @return the search engine implementation name
+ */
+ public String getEngine()
+ {
+ return engine;
+ }
+
/**
* Creates an Elasticsearch client applying parameters from properties file
*
@@ -460,6 +473,11 @@ public class ElasticsearchHttpClientFactory
this.host = host;
}
+ public void setEngine(String engine)
+ {
+ this.engine = engine;
+ }
+
public void setBaseUrl(String baseUrl)
{
this.baseUrl = baseUrl;
diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiser.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiser.java
index 16d159ac39..0ad4dd5b9d 100644
--- a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiser.java
+++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiser.java
@@ -46,6 +46,7 @@ import org.alfresco.repo.dictionary.DictionaryListener;
import org.alfresco.repo.lock.JobLockService;
import org.alfresco.repo.lock.JobLockService.JobLockRefreshCallback;
import org.alfresco.repo.lock.LockAcquisitionException;
+import org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetector;
import org.alfresco.service.cmr.dictionary.PropertyDefinition;
import org.alfresco.service.namespace.NamespaceService;
import org.alfresco.service.namespace.QName;
@@ -92,10 +93,11 @@ public class ElasticsearchInitialiser implements DictionaryListener
private final Set modelCache = new HashSet<>();
// This counter will be used during the map model execution
private final AtomicInteger globalModelInitialisedCounter = new AtomicInteger(0);
+ private SearchEngineDetector searchEngineDetector;
public ElasticsearchInitialiser(DictionaryDAOImpl dictionary, ElasticsearchIndexService elasticsearchIndexService,
ContentModelSynchronizer contentModelSynchronizer, JobLockService jobLockService, int retryAttempts, int retryPeriodSeconds,
- int lockRetryAttempts, int lockRetryPeriodSeconds, boolean createIndexIfNotExists)
+ int lockRetryAttempts, int lockRetryPeriodSeconds, boolean createIndexIfNotExists, SearchEngineDetector searchEngineDetector)
{
this.dictionaryDAO = dictionary;
this.dictionaryDAO.registerListener(this);
@@ -107,6 +109,7 @@ public class ElasticsearchInitialiser implements DictionaryListener
this.lockRetryPeriodSeconds = lockRetryPeriodSeconds;
this.createIndexIfNotExists = createIndexIfNotExists;
this.elasticsearchIndexService = elasticsearchIndexService;
+ this.searchEngineDetector = searchEngineDetector;
}
public ElasticsearchInitialiser()
@@ -263,6 +266,11 @@ public class ElasticsearchInitialiser implements DictionaryListener
}
}
LOGGER.info("Successfully connected to Elasticsearch index.");
+
+ if (!isTerminated.get() && searchEngineDetector != null)
+ {
+ searchEngineDetector.detectAndStore();
+ }
// Attempt to map the models.
mapModels();
}
@@ -357,7 +365,7 @@ public class ElasticsearchInitialiser implements DictionaryListener
/**
* This method will be invoked at startup and every time a afterDictionaryInit event is triggered.
- *
+ *
* @return true if new models were mapped during the method execution, false otherwise.
*/
private boolean mapModels()
diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchRequestBuilderService.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchRequestBuilderService.java
index 020be8d8a7..252a42207a 100644
--- a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchRequestBuilderService.java
+++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchRequestBuilderService.java
@@ -95,19 +95,9 @@ public class SearchRequestBuilderService
int size,
String indexName)
{
- int trackTotalHitsLimit = DEFAULT_TRACK_TOTAL_HITS_UP_TO;
- if (searchParameters.getTrackTotalHits() == -1 || searchParameters.getTrackTotalHits() >= TRACK_TOTAL_HITS_ACCURATE)
- {
- trackTotalHitsLimit = TRACK_TOTAL_HITS_ACCURATE;
- }
- else if (searchParameters.getTrackTotalHits() > 0)
- {
- trackTotalHitsLimit = searchParameters.getTrackTotalHits();
- }
-
SearchRequestWrapper.Builder wrapperBuilder = SearchRequestWrapper.builder();
SearchRequest.Builder builder = baseBuilder(queryWithPermissions)
- .trackTotalHits(new TrackHits.Builder().count(trackTotalHitsLimit).build())
+ .trackTotalHits(new TrackHits.Builder().count(resolveTrackTotalHitsLimit(searchParameters)).build())
.from(from)
.size(size);
@@ -148,6 +138,19 @@ public class SearchRequestBuilderService
// Previous unified method with boolean flag removed. Update callers accordingly.
+ private int resolveTrackTotalHitsLimit(SearchParameters searchParameters)
+ {
+ if (searchParameters.getTrackTotalHits() == -1)
+ {
+ return TRACK_TOTAL_HITS_ACCURATE;
+ }
+ if (searchParameters.getTrackTotalHits() > 0)
+ {
+ return searchParameters.getTrackTotalHits();
+ }
+ return DEFAULT_TRACK_TOTAL_HITS_UP_TO;
+ }
+
private SearchRequest.Builder baseBuilder(Query queryWithPermissions)
{
return new SearchRequest.Builder()
diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchStrategySelector.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchStrategySelector.java
index bc88d64272..3a195f41f7 100644
--- a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchStrategySelector.java
+++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchStrategySelector.java
@@ -39,7 +39,8 @@ public class SearchStrategySelector implements SearchStrategy
private final SearchStrategy scrollStrategy;
private final int maxResultWindow;
- public SearchStrategySelector(SearchExecutionStrategy standardStrategy, SearchExecutionStrategy scrollStrategy, int maxResultWindow)
+ public SearchStrategySelector(SearchExecutionStrategy standardStrategy, SearchExecutionStrategy scrollStrategy,
+ int maxResultWindow)
{
this.standardStrategy = standardStrategy;
this.scrollStrategy = scrollStrategy;
diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSet.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSet.java
index 196fa2cf06..7be0a362c2 100644
--- a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSet.java
+++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSet.java
@@ -65,11 +65,13 @@ public class ElasticsearchResultSet implements SearchEngineResultSet
private final Map facetQueries;
private final Map>> fieldFacets;
private final Map>>> highlights;
+ private final String nextSearchAfterToken;
+ private final Boolean explicitHasMore;
public ElasticsearchResultSet(NodeService nodeService, List nodeRefAndScores, SimpleResultSetMetaData resultSetMetaData,
SpellCheckResult spellCheckResult, long queryTime, long numFound, int start,
Map facetQueries, Map>> fieldFacets,
- Map>>> highlights)
+ Map>>> highlights, String nextSearchAfterToken, Boolean explicitHasMore)
{
this.nodeService = nodeService;
this.nodeRefAndScores = nodeRefAndScores;
@@ -81,6 +83,8 @@ public class ElasticsearchResultSet implements SearchEngineResultSet
this.facetQueries = facetQueries;
this.fieldFacets = fieldFacets;
this.highlights = highlights;
+ this.nextSearchAfterToken = nextSearchAfterToken;
+ this.explicitHasMore = explicitHasMore;
}
@Override
@@ -154,9 +158,19 @@ public class ElasticsearchResultSet implements SearchEngineResultSet
return start;
}
+ @Override
+ public String getNextSearchAfterToken()
+ {
+ return nextSearchAfterToken;
+ }
+
@Override
public boolean hasMore()
{
+ if (explicitHasMore != null)
+ {
+ return explicitHasMore;
+ }
return getNumberFound() > (getStart() + length());
}
diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilder.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilder.java
index 4d4b070208..b562ff570d 100644
--- a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilder.java
+++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilder.java
@@ -63,8 +63,24 @@ public class ElasticsearchResultSetBuilder
this.aggregationHandler = aggregationHandler;
}
+ public ElasticsearchResultSet build(SearchParameters searchParameters, SearchResponse
+
+
+
+
+
.
+ * #L%
+ */
+package org.alfresco.repo.search.impl.elasticsearch.admin;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.openMocks;
+
+import static org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetector.ATTR_ROOT;
+import static org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetector.ATTR_SEARCH_ENGINE_NAME;
+import static org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetector.ATTR_SEARCH_ENGINE_VERSION;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.opensearch.client.opensearch.OpenSearchClient;
+import org.opensearch.client.opensearch.generic.Body;
+import org.opensearch.client.opensearch.generic.OpenSearchGenericClient;
+import org.opensearch.client.opensearch.generic.Request;
+import org.opensearch.client.opensearch.generic.Response;
+
+import org.alfresco.repo.search.impl.elasticsearch.client.ElasticsearchHttpClientFactory;
+import org.alfresco.repo.security.authentication.AuthenticationUtil;
+import org.alfresco.repo.security.authentication.AuthenticationUtil.RunAsWork;
+import org.alfresco.repo.transaction.RetryingTransactionHelper;
+import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback;
+import org.alfresco.service.cmr.attributes.AttributeService;
+import org.alfresco.service.transaction.TransactionService;
+
+/**
+ * Unit tests for {@link SearchEngineDetector}.
+ *
+ * The search engine root endpoint ({@code GET /}) is mocked at the OpenSearch generic-client level so the provider/version detection and the {@link AttributeService} persistence can be verified without a running Elasticsearch/OpenSearch instance.
+ */
+public class SearchEngineDetectorTest
+{
+ private static final String SYSTEM_USER = "System";
+
+ private static final String ES_BODY = "{"
+ + "\"name\":\"node-1\",\"cluster_name\":\"docker-cluster\","
+ + "\"version\":{\"number\":\"8.17.0\",\"build_flavor\":\"default\"},"
+ + "\"tagline\":\"You Know, for Search\"}";
+
+ private static final String OS_BODY = "{"
+ + "\"name\":\"node-1\",\"cluster_name\":\"docker-cluster\","
+ + "\"version\":{\"distribution\":\"opensearch\",\"number\":\"2.17.0\"},"
+ + "\"tagline\":\"The OpenSearch Project: https://opensearch.org/\"}";
+
+ private static final String UNKNOWN_BODY = "{"
+ + "\"version\":{\"number\":\"1.2.3\"},"
+ + "\"tagline\":\"Some other search engine\"}";
+
+ @Mock
+ private ElasticsearchHttpClientFactory httpClientFactory;
+ @Mock
+ private AttributeService attributeService;
+ @Mock
+ private TransactionService transactionService;
+ @Mock
+ private RetryingTransactionHelper txnHelper;
+ @Mock
+ private OpenSearchClient openSearchClient;
+ @Mock
+ private OpenSearchGenericClient genericClient;
+ @Mock
+ private Response response;
+ @Mock
+ private Body body;
+
+ private AutoCloseable mocks;
+ private MockedStatic authUtil;
+
+ private SearchEngineDetector detector;
+
+ @Before
+ public void setUp()
+ {
+ mocks = openMocks(this);
+
+ // Run the "run as system" work and the transactional work inline so the persistence can be verified.
+ authUtil = mockStatic(AuthenticationUtil.class);
+ authUtil.when(AuthenticationUtil::getSystemUserName).thenReturn(SYSTEM_USER);
+ authUtil.when(() -> AuthenticationUtil.runAs(any(), any()))
+ .thenAnswer(call -> ((RunAsWork>) call.getArgument(0)).doWork());
+
+ lenient().when(transactionService.getRetryingTransactionHelper()).thenReturn(txnHelper);
+ lenient().when(txnHelper.doInTransaction(any(), anyBoolean(), anyBoolean()))
+ .thenAnswer(call -> ((RetryingTransactionCallback>) call.getArgument(0)).execute());
+
+ detector = new SearchEngineDetector();
+ detector.setHttpClientFactory(httpClientFactory);
+ detector.setAttributeService(attributeService);
+ detector.setTransactionService(transactionService);
+ }
+
+ @After
+ public void tearDown() throws Exception
+ {
+ authUtil.close();
+ mocks.close();
+ }
+
+ @Test
+ public void shouldStoreElasticsearchProviderAndVersion() throws Exception
+ {
+ givenRootBody(ES_BODY);
+
+ detector.detectAndStore();
+
+ verify(attributeService).setAttribute(eq("elasticsearch"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_NAME));
+ verify(attributeService).setAttribute(eq("8.17.0"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_VERSION));
+ }
+
+ @Test
+ public void shouldStoreOpenSearchProviderAndVersion() throws Exception
+ {
+ givenRootBody(OS_BODY);
+
+ detector.detectAndStore();
+
+ verify(attributeService).setAttribute(eq("opensearch"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_NAME));
+ verify(attributeService).setAttribute(eq("2.17.0"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_VERSION));
+ }
+
+ @Test
+ public void shouldStoreUnknownWhenTaglineNotRecognised() throws Exception
+ {
+ givenRootBody(UNKNOWN_BODY);
+
+ detector.detectAndStore();
+
+ verify(attributeService).setAttribute(eq("unknown"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_NAME));
+ verify(attributeService).setAttribute(eq("1.2.3"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_VERSION));
+ }
+
+ @Test
+ public void shouldMatchTaglineCaseInsensitively() throws Exception
+ {
+ givenRootBody("{\"version\":{\"number\":\"8.1.0\"},\"tagline\":\"YOU KNOW, FOR SEARCH\"}");
+
+ detector.detectAndStore();
+
+ verify(attributeService).setAttribute(eq("elasticsearch"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_NAME));
+ }
+
+ @Test
+ public void shouldPersistAsSystemUserInWritableRequiresNewTransaction() throws Exception
+ {
+ givenRootBody(ES_BODY);
+
+ detector.detectAndStore();
+
+ authUtil.verify(() -> AuthenticationUtil.runAs(any(RunAsWork.class), eq(SYSTEM_USER)));
+ verify(txnHelper).setForceWritable(true);
+ // readOnly = false, requiresNew = true
+ verify(txnHelper).doInTransaction(any(RetryingTransactionCallback.class), eq(false), eq(true));
+ }
+
+ @Test
+ public void shouldNotStoreWhenBodyIsEmpty() throws Exception
+ {
+ givenClientChain();
+ when(response.getBody()).thenReturn(Optional.empty());
+
+ detector.detectAndStore();
+
+ verifyNoInteractions(attributeService);
+ }
+
+ @Test
+ public void shouldNotStoreWhenBodyIsMalformedJson() throws Exception
+ {
+ givenRootBody("this is not json");
+
+ detector.detectAndStore();
+
+ verifyNoInteractions(attributeService);
+ }
+
+ @Test
+ public void shouldNotStoreWhenVersionIsMissing() throws Exception
+ {
+ givenRootBody("{\"tagline\":\"You Know, for Search\"}");
+
+ detector.detectAndStore();
+
+ verifyNoInteractions(attributeService);
+ }
+
+ @Test
+ public void shouldNotStoreWhenTaglineIsMissing() throws Exception
+ {
+ givenRootBody("{\"version\":{\"number\":\"8.17.0\"}}");
+
+ detector.detectAndStore();
+
+ verifyNoInteractions(attributeService);
+ }
+
+ @Test
+ public void shouldNotStoreAndNotThrowWhenClientFails() throws Exception
+ {
+ givenClientChain();
+ when(genericClient.execute(any(Request.class))).thenThrow(new IOException("engine unreachable"));
+
+ detector.detectAndStore();
+
+ verifyNoInteractions(attributeService);
+ }
+
+ private void givenClientChain()
+ {
+ when(httpClientFactory.getElasticsearchClient()).thenReturn(openSearchClient);
+ when(openSearchClient.generic()).thenReturn(genericClient);
+ }
+
+ private void givenRootBody(String rawJson) throws IOException
+ {
+ givenClientChain();
+ when(genericClient.execute(any(Request.class))).thenReturn(response);
+ when(response.getBody()).thenReturn(Optional.of(body));
+ when(body.bodyAsString()).thenReturn(rawJson);
+ }
+}
diff --git a/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserIT.java b/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserIT.java
index 21a01194c3..01a3840269 100644
--- a/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserIT.java
+++ b/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserIT.java
@@ -65,6 +65,7 @@ import org.opensearch.client.opensearch.indices.PutMappingRequest;
import org.alfresco.repo.dictionary.DictionaryDAOImpl;
import org.alfresco.repo.lock.JobLockService;
import org.alfresco.repo.search.impl.elasticsearch.ElasticsearchSpringTest;
+import org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetector;
import org.alfresco.repo.search.impl.elasticsearch.client.ElasticsearchHttpClientFactory;
import org.alfresco.repo.search.impl.elasticsearch.util.LogListAppender;
@@ -102,7 +103,7 @@ public class ElasticsearchInitialiserIT extends ElasticsearchSpringTest
fieldMappingBuilder, elasticsearchHttpClientFactory, Locale.ENGLISH.getLanguage(), indexConfigurationInitializer);
toTest = new ElasticsearchInitialiser(dictionaryDAOImpl, elasticSearchIndexServiceSpy, contentModelSynchronizer,
- jobLockService, 1, 1, 1, 1, true);
+ jobLockService, 1, 1, 1, 1, true, new SearchEngineDetector());
client = elasticsearchHttpClientFactory.getElasticsearchClient();
this.indicesSpy = spy(client.indices());
}
diff --git a/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserTest.java b/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserTest.java
index 330b75f2b2..96bf1b9cc0 100644
--- a/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserTest.java
+++ b/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserTest.java
@@ -62,6 +62,7 @@ import org.alfresco.repo.dictionary.CompiledModel;
import org.alfresco.repo.dictionary.DictionaryDAOImpl;
import org.alfresco.repo.lock.JobLockService;
import org.alfresco.repo.lock.LockAcquisitionException;
+import org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetector;
import org.alfresco.service.namespace.QName;
import org.alfresco.util.GUID;
@@ -386,10 +387,10 @@ public class ElasticsearchInitialiserTest
when(secondElasticsearchIndexService.createIndex()).thenReturn(true);
ElasticsearchInitialiser elasticsearchInitialiser = new ElasticsearchInitialiser(mockDictionary,
- mockElasticSearchIndexService, mockContentModelSynchronizer, jobLockService, 0, 0, 3, 0, true);
+ mockElasticSearchIndexService, mockContentModelSynchronizer, jobLockService, 0, 0, 3, 0, true, new SearchEngineDetector());
ElasticsearchInitialiser secondElasticsearchInitialiser = new ElasticsearchInitialiser(mockDictionary,
- secondElasticsearchIndexService, secondContentModelSynchronizer, jobLockService, 0, 0, 3, 0, true);
+ secondElasticsearchIndexService, secondContentModelSynchronizer, jobLockService, 0, 0, 3, 0, true, new SearchEngineDetector());
Thread thread1 = new Thread(() -> elasticsearchInitialiser.initWithLock());
thread1.start();
diff --git a/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilderTest.java b/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilderTest.java
index a30d0e3d75..5e30ff48df 100644
--- a/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilderTest.java
+++ b/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilderTest.java
@@ -281,6 +281,64 @@ public class ElasticsearchResultSetBuilderTest
.anyMatch(pair -> "field1".equals(pair.getFirst())));
}
+ @Test
+ public void testBuild_searchAfterNotLastPage_hasMoreIsTrue()
+ {
+ // Given: a search_after page with a non-null nextSearchAfterToken (more pages remain),
+ // and skipCount is 0 as always the case for search_after cursor paging.
+ List> hits = createHitsList(
+ createHit(TEST_NODE_ID_1, TEST_SCORE_1),
+ createHit(TEST_NODE_ID_2, TEST_SCORE_2));
+ setupSearchResponse(hits);
+ setupNodeExistence(true, true);
+ setupAggregationAndHighlights();
+
+ // When
+ ElasticsearchResultSet result = builder.build(searchParameters, searchResponse, "opaque-cursor-token");
+
+ // Then
+ assertTrue("hasMore should be true when a nextSearchAfterToken is present", result.hasMore());
+ }
+
+ @Test
+ public void testBuild_searchAfterLastPage_hasMoreIsFalse()
+ {
+ // Given: a search_after page with a null nextSearchAfterToken (last page).
+ // Without the fix, hasMore() would incorrectly return true here because
+ // getNumberFound() (TEST_TOTAL_HITS=3) > getStart()+length() (0+1) for search_after,
+ // since skipCount always stays 0 across search_after pages.
+ List> hits = createHitsList(createHit(TEST_NODE_ID_1, TEST_SCORE_1));
+ setupSearchResponse(hits);
+ setupNodeExistence(true);
+ setupAggregationAndHighlights();
+
+ // When
+ ElasticsearchResultSet result = builder.build(searchParameters, searchResponse, null);
+
+ // Then
+ assertTrue("hasMore should be false on the last search_after page", !result.hasMore());
+ }
+
+ @Test
+ public void testBuild_standardSearch_hasMoreUsesStartPlusLength()
+ {
+ // Given: a standard (non search_after) search where all TEST_TOTAL_HITS (3) results
+ // are returned in one page starting at skipCount 0, so there is nothing more to fetch.
+ List> hits = createHitsList(
+ createHit(TEST_NODE_ID_1, TEST_SCORE_1),
+ createHit(TEST_NODE_ID_2, TEST_SCORE_2),
+ createHit(TEST_NODE_ID_3, TEST_SCORE_3));
+ setupSearchResponse(hits);
+ setupNodeExistence(true, true, true);
+ setupAggregationAndHighlights();
+
+ // When
+ ElasticsearchResultSet result = builder.build(searchParameters, searchResponse);
+
+ // Then
+ assertTrue("hasMore should be false when all results were returned in this page", !result.hasMore());
+ }
+
@Test
public void testBuildWithHitsList_doesNotIncludeAggregationsOrHighlights()
{