diff --git a/CHANGELOG.md b/CHANGELOG.md index f4544a2c..7239aaff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Changing URL for plugin APIs to /_plugin/_search_relevance [backend] ([#62](https://github.com/opensearch-project/search-relevance/pull/62) - Added lazy index creation for all APIs ([#65](https://github.com/opensearch-project/search-relevance/pull/65) - Realistic test data set based on ESCI (products, queries, judgements) ([#70](https://github.com/opensearch-project/search-relevance/pull/70) +- [Stats] Add stats API ([#63](https://github.com/opensearch-project/search-relevance/pull/63))) ### Removed diff --git a/src/main/java/org/opensearch/searchrelevance/dao/ExperimentDao.java b/src/main/java/org/opensearch/searchrelevance/dao/ExperimentDao.java index 3e8f45fe..dee69b1e 100644 --- a/src/main/java/org/opensearch/searchrelevance/dao/ExperimentDao.java +++ b/src/main/java/org/opensearch/searchrelevance/dao/ExperimentDao.java @@ -10,6 +10,8 @@ import static org.opensearch.searchrelevance.indices.SearchRelevanceIndices.EXPERIMENT; import java.io.IOException; +import java.util.Map; +import java.util.Optional; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -25,10 +27,21 @@ import org.opensearch.searchrelevance.exception.SearchRelevanceException; import org.opensearch.searchrelevance.indices.SearchRelevanceIndicesManager; import org.opensearch.searchrelevance.model.Experiment; +import org.opensearch.searchrelevance.model.ExperimentType; +import org.opensearch.searchrelevance.stats.events.EventStatName; +import org.opensearch.searchrelevance.stats.events.EventStatsManager; public class ExperimentDao { private static final Logger LOGGER = LogManager.getLogger(ExperimentDao.class); private final SearchRelevanceIndicesManager searchRelevanceIndicesManager; + private static Map experimentTypeIncrementers = Map.of( + ExperimentType.PAIRWISE_COMPARISON, + () -> EventStatsManager.increment(EventStatName.EXPERIMENT_PAIRWISE_COMPARISON_EXECUTIONS), + ExperimentType.POINTWISE_EVALUATION, + () -> EventStatsManager.increment(EventStatName.EXPERIMENT_POINTWISE_EVALUATION_EXECUTIONS), + ExperimentType.HYBRID_OPTIMIZER, + () -> EventStatsManager.increment(EventStatName.EXPERIMENT_HYBRID_OPTIMIZER_EXECUTIONS) + ); public ExperimentDao(SearchRelevanceIndicesManager searchRelevanceIndicesManager) { this.searchRelevanceIndicesManager = searchRelevanceIndicesManager; @@ -52,6 +65,8 @@ public void putExperiment(final Experiment experiment, final ActionListener list listener.onFailure(new SearchRelevanceException("Experiment cannot be null", RestStatus.BAD_REQUEST)); return; } + // Increment stats + recordStats(experiment); try { searchRelevanceIndicesManager.putDoc( experiment.id(), @@ -121,4 +136,9 @@ public SearchResponse listExperiment(SearchSourceBuilder sourceBuilder, ActionLi return searchRelevanceIndicesManager.listDocsBySearchRequest(sourceBuilder, EXPERIMENT, listener); } + + private void recordStats(Experiment experiment) { + EventStatsManager.increment(EventStatName.EXPERIMENT_EXECUTIONS); + Optional.ofNullable(experimentTypeIncrementers.get(experiment.type())).ifPresent(Runnable::run); + } } diff --git a/src/main/java/org/opensearch/searchrelevance/judgments/ImportJudgmentsProcessor.java b/src/main/java/org/opensearch/searchrelevance/judgments/ImportJudgmentsProcessor.java index 62ceeb90..8f5d01fa 100644 --- a/src/main/java/org/opensearch/searchrelevance/judgments/ImportJudgmentsProcessor.java +++ b/src/main/java/org/opensearch/searchrelevance/judgments/ImportJudgmentsProcessor.java @@ -19,6 +19,8 @@ import org.opensearch.core.rest.RestStatus; import org.opensearch.searchrelevance.exception.SearchRelevanceException; import org.opensearch.searchrelevance.model.JudgmentType; +import org.opensearch.searchrelevance.stats.events.EventStatName; +import org.opensearch.searchrelevance.stats.events.EventStatsManager; import org.opensearch.transport.client.Client; public class ImportJudgmentsProcessor implements BaseJudgmentsProcessor { @@ -37,6 +39,7 @@ public JudgmentType getJudgmentType() { @Override public void generateJudgmentRating(Map metadata, ActionListener>> listener) { + EventStatsManager.increment(EventStatName.IMPORT_JUDGMENT_RATING_GENERATIONS); List> sourceJudgementRatings = (List>) metadata.get("judgmentRatings"); metadata.remove("judgmentRatings"); diff --git a/src/main/java/org/opensearch/searchrelevance/judgments/LlmJudgmentsProcessor.java b/src/main/java/org/opensearch/searchrelevance/judgments/LlmJudgmentsProcessor.java index 396baeb8..8a78e855 100644 --- a/src/main/java/org/opensearch/searchrelevance/judgments/LlmJudgmentsProcessor.java +++ b/src/main/java/org/opensearch/searchrelevance/judgments/LlmJudgmentsProcessor.java @@ -49,6 +49,8 @@ import org.opensearch.searchrelevance.ml.MLAccessor; import org.opensearch.searchrelevance.model.JudgmentCache; import org.opensearch.searchrelevance.model.JudgmentType; +import org.opensearch.searchrelevance.stats.events.EventStatName; +import org.opensearch.searchrelevance.stats.events.EventStatsManager; import org.opensearch.searchrelevance.utils.TimeUtils; import org.opensearch.transport.client.Client; @@ -87,6 +89,7 @@ public JudgmentType getJudgmentType() { @Override public void generateJudgmentRating(Map metadata, ActionListener>> listener) { + EventStatsManager.increment(EventStatName.LLM_JUDGMENT_RATING_GENERATIONS); String querySetId = (String) metadata.get("querySetId"); List searchConfigurationList = (List) metadata.get("searchConfigurationList"); int size = (int) metadata.get("size"); diff --git a/src/main/java/org/opensearch/searchrelevance/judgments/UbiJudgmentsProcessor.java b/src/main/java/org/opensearch/searchrelevance/judgments/UbiJudgmentsProcessor.java index d912c263..dd1dc9da 100644 --- a/src/main/java/org/opensearch/searchrelevance/judgments/UbiJudgmentsProcessor.java +++ b/src/main/java/org/opensearch/searchrelevance/judgments/UbiJudgmentsProcessor.java @@ -20,6 +20,8 @@ import org.opensearch.searchrelevance.judgments.clickmodel.coec.CoecClickModel; import org.opensearch.searchrelevance.judgments.clickmodel.coec.CoecClickModelParameters; import org.opensearch.searchrelevance.model.JudgmentType; +import org.opensearch.searchrelevance.stats.events.EventStatName; +import org.opensearch.searchrelevance.stats.events.EventStatsManager; import org.opensearch.transport.client.Client; public class UbiJudgmentsProcessor implements BaseJudgmentsProcessor { @@ -38,6 +40,7 @@ public JudgmentType getJudgmentType() { @Override public void generateJudgmentRating(Map metadata, ActionListener>> listener) { + EventStatsManager.increment(EventStatName.UBI_JUDGMENT_RATING_GENERATIONS); String clickModel = (String) metadata.get("clickModel"); int maxRank = (int) metadata.get("maxRank"); diff --git a/src/main/java/org/opensearch/searchrelevance/plugin/SearchRelevancePlugin.java b/src/main/java/org/opensearch/searchrelevance/plugin/SearchRelevancePlugin.java index 82fbd61f..119da225 100644 --- a/src/main/java/org/opensearch/searchrelevance/plugin/SearchRelevancePlugin.java +++ b/src/main/java/org/opensearch/searchrelevance/plugin/SearchRelevancePlugin.java @@ -9,6 +9,7 @@ import static org.opensearch.searchrelevance.common.PluginConstants.EXPERIMENT_INDEX; import static org.opensearch.searchrelevance.common.PluginConstants.JUDGMENT_CACHE_INDEX; +import static org.opensearch.searchrelevance.settings.SearchRelevanceSettings.SEARCH_RELEVANCE_STATS_ENABLED; import static org.opensearch.searchrelevance.settings.SearchRelevanceSettings.SEARCH_RELEVANCE_WORKBENCH_ENABLED; import java.util.Collection; @@ -63,7 +64,10 @@ import org.opensearch.searchrelevance.rest.RestPutJudgmentAction; import org.opensearch.searchrelevance.rest.RestPutQuerySetAction; import org.opensearch.searchrelevance.rest.RestPutSearchConfigurationAction; +import org.opensearch.searchrelevance.rest.RestSearchRelevanceStatsAction; import org.opensearch.searchrelevance.settings.SearchRelevanceSettingsAccessor; +import org.opensearch.searchrelevance.stats.events.EventStatsManager; +import org.opensearch.searchrelevance.stats.info.InfoStatsManager; import org.opensearch.searchrelevance.transport.experiment.DeleteExperimentAction; import org.opensearch.searchrelevance.transport.experiment.DeleteExperimentTransportAction; import org.opensearch.searchrelevance.transport.experiment.GetExperimentAction; @@ -90,6 +94,9 @@ import org.opensearch.searchrelevance.transport.searchConfiguration.GetSearchConfigurationTransportAction; import org.opensearch.searchrelevance.transport.searchConfiguration.PutSearchConfigurationAction; import org.opensearch.searchrelevance.transport.searchConfiguration.PutSearchConfigurationTransportAction; +import org.opensearch.searchrelevance.transport.stats.SearchRelevanceStatsAction; +import org.opensearch.searchrelevance.transport.stats.SearchRelevanceStatsTransportAction; +import org.opensearch.searchrelevance.utils.ClusterUtil; import org.opensearch.threadpool.ThreadPool; import org.opensearch.transport.client.Client; import org.opensearch.watcher.ResourceWatcherService; @@ -112,6 +119,8 @@ public class SearchRelevancePlugin extends Plugin implements ActionPlugin, Syste private MLAccessor mlAccessor; private MetricsHelper metricsHelper; private SearchRelevanceSettingsAccessor settingsAccessor; + private ClusterUtil clusterUtil; + private InfoStatsManager infoStatsManager; @Override public Collection getSystemIndexDescriptors(Settings settings) { @@ -149,6 +158,10 @@ public Collection createComponents( this.mlAccessor = new MLAccessor(mlClient); this.metricsHelper = new MetricsHelper(clusterService, client, judgmentDao, evaluationResultDao, experimentVariantDao); this.settingsAccessor = new SearchRelevanceSettingsAccessor(clusterService, environment.settings()); + this.clusterUtil = new ClusterUtil(clusterService); + this.infoStatsManager = new InfoStatsManager(settingsAccessor); + EventStatsManager.instance().initialize(settingsAccessor); + return List.of( searchRelevanceIndicesManager, querySetDao, @@ -159,7 +172,8 @@ public Collection createComponents( evaluationResultDao, judgmentCacheDao, mlAccessor, - metricsHelper + metricsHelper, + infoStatsManager ); } @@ -186,7 +200,8 @@ public List getRestHandlers( new RestGetSearchConfigurationAction(settingsAccessor), new RestPutExperimentAction(settingsAccessor), new RestGetExperimentAction(settingsAccessor), - new RestDeleteExperimentAction(settingsAccessor) + new RestDeleteExperimentAction(settingsAccessor), + new RestSearchRelevanceStatsAction(settingsAccessor, clusterUtil) ); } @@ -205,12 +220,13 @@ public List getRestHandlers( new ActionHandler<>(GetSearchConfigurationAction.INSTANCE, GetSearchConfigurationTransportAction.class), new ActionHandler<>(PutExperimentAction.INSTANCE, PutExperimentTransportAction.class), new ActionHandler<>(DeleteExperimentAction.INSTANCE, DeleteExperimentTransportAction.class), - new ActionHandler<>(GetExperimentAction.INSTANCE, GetExperimentTransportAction.class) + new ActionHandler<>(GetExperimentAction.INSTANCE, GetExperimentTransportAction.class), + new ActionHandler<>(SearchRelevanceStatsAction.INSTANCE, SearchRelevanceStatsTransportAction.class) ); } @Override public List> getSettings() { - return List.of(SEARCH_RELEVANCE_WORKBENCH_ENABLED); + return List.of(SEARCH_RELEVANCE_WORKBENCH_ENABLED, SEARCH_RELEVANCE_STATS_ENABLED); } } diff --git a/src/main/java/org/opensearch/searchrelevance/rest/RestSearchRelevanceStatsAction.java b/src/main/java/org/opensearch/searchrelevance/rest/RestSearchRelevanceStatsAction.java new file mode 100644 index 00000000..417ee775 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/rest/RestSearchRelevanceStatsAction.java @@ -0,0 +1,312 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.rest; + +import static org.opensearch.searchrelevance.common.PluginConstants.SEARCH_RELEVANCE_BASE_URI; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.opensearch.Version; +import org.opensearch.common.util.set.Sets; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestActions; +import org.opensearch.searchrelevance.settings.SearchRelevanceSettingsAccessor; +import org.opensearch.searchrelevance.stats.SearchRelevanceStatsInput; +import org.opensearch.searchrelevance.stats.events.EventStatName; +import org.opensearch.searchrelevance.stats.info.InfoStatName; +import org.opensearch.searchrelevance.transport.stats.SearchRelevanceStatsAction; +import org.opensearch.searchrelevance.transport.stats.SearchRelevanceStatsRequest; +import org.opensearch.searchrelevance.utils.ClusterUtil; +import org.opensearch.transport.client.node.NodeClient; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +import lombok.AllArgsConstructor; +import lombok.extern.log4j.Log4j2; + +/** + * Rest action handler for the search relevance stats API + * Calculates info stats and aggregates event stats from nodes and returns them in the response + */ +@Log4j2 +@AllArgsConstructor +public class RestSearchRelevanceStatsAction extends BaseRestHandler { + /** + * Path parameter name for specified stats + */ + public static final String STAT_PARAM = "stat"; + + /** + * Path parameter name for specified node ids + */ + public static final String NODE_ID_PARAM = "nodeId"; + + /** + * Query parameter name to request flattened stat paths as keys + */ + public static final String FLATTEN_PARAM = "flat_stat_paths"; + + /** + * Query parameter name to include metadata + */ + public static final String INCLUDE_METADATA_PARAM = "include_metadata"; + + /** + * Query parameter name to include individual nodes data + */ + public static final String INCLUDE_INDIVIDUAL_NODES_PARAM = "include_individual_nodes"; + + /** + * Query parameter name to include individual nodes data + */ + public static final String INCLUDE_ALL_NODES_PARAM = "include_all_nodes"; + + /** + * Query parameter name to include individual nodes data + */ + public static final String INCLUDE_INFO_PARAM = "include_info"; + + /** + * Regex for valid params, containing only alphanumeric, -, or _ + */ + public static final String PARAM_REGEX = "^[A-Za-z0-9-_]+$"; + + /** + * Max length for an individual query or path param + */ + public static final int MAX_PARAM_LENGTH = 255; + + private SearchRelevanceSettingsAccessor settingsAccessor; + private ClusterUtil clusterUtil; + + private static final String NAME = "search_relevance_stats_action"; + + private static final Set EVENT_STAT_NAMES = EnumSet.allOf(EventStatName.class) + .stream() + .map(EventStatName::getNameString) + .map(str -> str.toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); + + private static final Set INFO_STAT_NAMES = EnumSet.allOf(InfoStatName.class) + .stream() + .map(InfoStatName::getNameString) + .map(str -> str.toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); + + private static final List ROUTES = ImmutableList.of( + new Route(RestRequest.Method.GET, SEARCH_RELEVANCE_BASE_URI + "/{nodeId}/stats/"), + new Route(RestRequest.Method.GET, SEARCH_RELEVANCE_BASE_URI + "/{nodeId}/stats/{stat}"), + new Route(RestRequest.Method.GET, SEARCH_RELEVANCE_BASE_URI + "/stats/"), + new Route(RestRequest.Method.GET, SEARCH_RELEVANCE_BASE_URI + "/stats/{stat}") + ); + + private static final Set RESPONSE_PARAMS = ImmutableSet.of( + NODE_ID_PARAM, + STAT_PARAM, + INCLUDE_METADATA_PARAM, + FLATTEN_PARAM, + INCLUDE_INDIVIDUAL_NODES_PARAM, + INCLUDE_ALL_NODES_PARAM, + INCLUDE_INFO_PARAM + ); + + /** + * Validates a param string if it's under the max length and matches simple string pattern + * @param param the string to validate + * @return whether it's valid + */ + public static boolean isValidParamString(String param) { + return param.matches(PARAM_REGEX) && param.length() < MAX_PARAM_LENGTH; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public List routes() { + return ROUTES; + } + + @Override + protected Set responseParams() { + return RESPONSE_PARAMS; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) { + if (settingsAccessor.isWorkbenchEnabled() == false) { + return channel -> channel.sendResponse(new BytesRestResponse(RestStatus.FORBIDDEN, "Search Relevance Workbench is disabled")); + } + + if (settingsAccessor.isStatsEnabled() == false) { + return channel -> channel.sendResponse( + new BytesRestResponse(RestStatus.FORBIDDEN, "Search Relevance Workbench stats is disabled") + ); + } + + // Read inputs and convert to BaseNodesRequest with correct info configured + SearchRelevanceStatsRequest searchRelevanceStatsRequest = createStatsRequest(request); + + return channel -> client.execute( + SearchRelevanceStatsAction.INSTANCE, + searchRelevanceStatsRequest, + new RestActions.NodesResponseRestListener<>(channel) + ); + } + + /** + * Creates a SearchRelevanceStatsRequest from a RestRequest + * + * @param request Rest request + * @return SearchRelevanceStatsRequest + */ + private SearchRelevanceStatsRequest createStatsRequest(RestRequest request) { + SearchRelevanceStatsInput searchRelevanceStatsInput = createSearchRelevanceStatsInputFromRequestParams(request); + String[] nodeIdsArr = searchRelevanceStatsInput.getNodeIds().toArray(new String[0]); + + SearchRelevanceStatsRequest searchRelevanceStatsRequest = new SearchRelevanceStatsRequest(nodeIdsArr, searchRelevanceStatsInput); + searchRelevanceStatsRequest.timeout(request.param("timeout")); + + return searchRelevanceStatsRequest; + } + + private SearchRelevanceStatsInput createSearchRelevanceStatsInputFromRequestParams(RestRequest request) { + SearchRelevanceStatsInput searchRelevanceStatsInput = new SearchRelevanceStatsInput(); + + // Parse specified nodes + Optional nodeIds = splitCommaSeparatedParam(request, NODE_ID_PARAM); + if (nodeIds.isPresent()) { + // Ignore node ids that don't pattern match + List validFormatNodeIds = Arrays.stream(nodeIds.get()).filter(this::isValidNodeId).toList(); + searchRelevanceStatsInput.getNodeIds().addAll(validFormatNodeIds); + } + + // Parse query parameters + boolean flatten = request.paramAsBoolean(FLATTEN_PARAM, false); + searchRelevanceStatsInput.setFlatten(flatten); + + boolean includeMetadata = request.paramAsBoolean(INCLUDE_METADATA_PARAM, false); + searchRelevanceStatsInput.setIncludeMetadata(includeMetadata); + + boolean includeIndividualNodes = request.paramAsBoolean(INCLUDE_INDIVIDUAL_NODES_PARAM, true); + searchRelevanceStatsInput.setIncludeIndividualNodes(includeIndividualNodes); + + boolean includeAllNodes = request.paramAsBoolean(INCLUDE_ALL_NODES_PARAM, true); + searchRelevanceStatsInput.setIncludeAllNodes(includeAllNodes); + + boolean includeInfo = request.paramAsBoolean(INCLUDE_INFO_PARAM, true); + searchRelevanceStatsInput.setIncludeInfo(includeInfo); + + // Process requested stats parameters + processStatsRequestParameters(request, searchRelevanceStatsInput); + + return searchRelevanceStatsInput; + } + + private void processStatsRequestParameters(RestRequest request, SearchRelevanceStatsInput searchRelevanceStatsInput) { + // Determine which stat names to retrieve based on user parameters + Optional optionalStats = splitCommaSeparatedParam(request, STAT_PARAM); + Version minClusterVersion = clusterUtil.getClusterMinVersion(); + boolean includeEvents = searchRelevanceStatsInput.isIncludeEvents(); + boolean includeInfo = searchRelevanceStatsInput.isIncludeInfo(); + + if (optionalStats.isPresent() == false || optionalStats.get().length == 0) { + // No specific stats requested, add all stats by default + addAllStats(searchRelevanceStatsInput, minClusterVersion); + return; + } + + String[] stats = optionalStats.get(); + Set invalidStatNames = new HashSet<>(); + for (String stat : stats) { + // Validate parameter + String normalizedStat = stat.toLowerCase(Locale.ROOT); + if (isValidParamString(normalizedStat) == false || isValidEventOrInfoStatName(normalizedStat) == false) { + invalidStatNames.add(normalizedStat); + continue; + } + + if (includeInfo && InfoStatName.isValidName(normalizedStat)) { + InfoStatName infoStatName = InfoStatName.from(normalizedStat); + if (infoStatName.version().onOrBefore(minClusterVersion)) { + searchRelevanceStatsInput.getInfoStatNames().add(InfoStatName.from(normalizedStat)); + } + } else if (includeEvents && EventStatName.isValidName(normalizedStat)) { + EventStatName eventStatName = EventStatName.from(normalizedStat); + if (eventStatName.version().onOrBefore(minClusterVersion)) { + searchRelevanceStatsInput.getEventStatNames().add(EventStatName.from(normalizedStat)); + } + } + } + + // When we reach this block, we must have added at least one stat to the input, or else invalid stats will be + // non-empty. So throwing this exception here without adding all covers the empty input case. + if (invalidStatNames.isEmpty() == false) { + throw new IllegalArgumentException( + unrecognized(request, invalidStatNames, Sets.union(EVENT_STAT_NAMES, INFO_STAT_NAMES), STAT_PARAM) + ); + } + } + + private void addAllStats(SearchRelevanceStatsInput searchRelevanceStatsInput, Version minVersion) { + if (minVersion == Version.CURRENT) { + if (searchRelevanceStatsInput.isIncludeInfo()) { + searchRelevanceStatsInput.getInfoStatNames().addAll(EnumSet.allOf(InfoStatName.class)); + } + if (searchRelevanceStatsInput.isIncludeEvents()) { + searchRelevanceStatsInput.getEventStatNames().addAll(EnumSet.allOf(EventStatName.class)); + } + } else { + // Use a separate case here to save on version comparison if not necessary + if (searchRelevanceStatsInput.isIncludeInfo()) { + searchRelevanceStatsInput.getInfoStatNames() + .addAll( + EnumSet.allOf(InfoStatName.class) + .stream() + .filter(statName -> statName.version().onOrBefore(minVersion)) + .collect(Collectors.toCollection(() -> EnumSet.noneOf(InfoStatName.class))) + ); + } + if (searchRelevanceStatsInput.isIncludeEvents()) { + searchRelevanceStatsInput.getEventStatNames() + .addAll( + EnumSet.allOf(EventStatName.class) + .stream() + .filter(statName -> statName.version().onOrBefore(minVersion)) + .collect(Collectors.toCollection(() -> EnumSet.noneOf(EventStatName.class))) + ); + } + } + } + + private boolean isValidEventOrInfoStatName(String statName) { + return InfoStatName.isValidName(statName) || EventStatName.isValidName(statName); + } + + private Optional splitCommaSeparatedParam(RestRequest request, String paramName) { + return Optional.ofNullable(request.param(paramName)).map(s -> s.split(",")); + } + + private boolean isValidNodeId(String nodeId) { + // Validate node id parameter + return isValidParamString(nodeId) && nodeId.length() == 22; + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/settings/SearchRelevanceSettings.java b/src/main/java/org/opensearch/searchrelevance/settings/SearchRelevanceSettings.java index a94f3639..86bb9503 100644 --- a/src/main/java/org/opensearch/searchrelevance/settings/SearchRelevanceSettings.java +++ b/src/main/java/org/opensearch/searchrelevance/settings/SearchRelevanceSettings.java @@ -30,4 +30,15 @@ public class SearchRelevanceSettings { Setting.Property.Dynamic ); + /** + * Enables or disables the Stats API and event stat collection. + * If stats API is called when stats are disabled, the response will 403. + * Event stat increment calls are also treated as no-ops. + */ + public static final Setting SEARCH_RELEVANCE_STATS_ENABLED = Setting.boolSetting( + "plugins.search_relevance.stats_enabled", + true, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); } diff --git a/src/main/java/org/opensearch/searchrelevance/settings/SearchRelevanceSettingsAccessor.java b/src/main/java/org/opensearch/searchrelevance/settings/SearchRelevanceSettingsAccessor.java index 103661ed..911087d2 100644 --- a/src/main/java/org/opensearch/searchrelevance/settings/SearchRelevanceSettingsAccessor.java +++ b/src/main/java/org/opensearch/searchrelevance/settings/SearchRelevanceSettingsAccessor.java @@ -10,6 +10,7 @@ import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.inject.Inject; import org.opensearch.common.settings.Settings; +import org.opensearch.searchrelevance.stats.events.EventStatsManager; import lombok.Getter; @@ -19,6 +20,8 @@ public class SearchRelevanceSettingsAccessor { @Getter private volatile boolean isWorkbenchEnabled; + @Getter + private volatile boolean isStatsEnabled; /** * Constructor, registers callbacks to update settings @@ -28,6 +31,7 @@ public class SearchRelevanceSettingsAccessor { @Inject public SearchRelevanceSettingsAccessor(ClusterService clusterService, Settings settings) { isWorkbenchEnabled = SearchRelevanceSettings.SEARCH_RELEVANCE_WORKBENCH_ENABLED.get(settings); + isStatsEnabled = SearchRelevanceSettings.SEARCH_RELEVANCE_STATS_ENABLED.get(settings); registerSettingsCallbacks(clusterService); } @@ -35,5 +39,13 @@ private void registerSettingsCallbacks(ClusterService clusterService) { clusterService.getClusterSettings().addSettingsUpdateConsumer(SearchRelevanceSettings.SEARCH_RELEVANCE_WORKBENCH_ENABLED, value -> { isWorkbenchEnabled = value; }); + + clusterService.getClusterSettings().addSettingsUpdateConsumer(SearchRelevanceSettings.SEARCH_RELEVANCE_STATS_ENABLED, value -> { + // If stats are being toggled off, clear and reset all stats + if (isStatsEnabled && (value == false)) { + EventStatsManager.instance().reset(); + } + isStatsEnabled = value; + }); } } diff --git a/src/main/java/org/opensearch/searchrelevance/stats/SearchRelevanceStatsInput.java b/src/main/java/org/opensearch/searchrelevance/stats/SearchRelevanceStatsInput.java new file mode 100644 index 00000000..74050686 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/SearchRelevanceStatsInput.java @@ -0,0 +1,201 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.searchrelevance.rest.RestSearchRelevanceStatsAction; +import org.opensearch.searchrelevance.stats.events.EventStatName; +import org.opensearch.searchrelevance.stats.info.InfoStatName; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +/** + * Entity class to hold input parameters for retrieving stats + * Responsible for filtering statistics by node IDs, event statistic types, and info stat types. + */ +@Getter +public class SearchRelevanceStatsInput implements ToXContentObject, Writeable { + public static final String NODE_IDS_FIELD = "node_ids"; + public static final String EVENT_STAT_NAMES_FIELD = "event_stats"; + public static final String STATE_STAT_NAMES_FIELD = "state_stats"; + + /** + * Collection of node IDs to filter statistics retrieval. + * If empty, stats from all nodes will be retrieved. + */ + private List nodeIds; + + /** + * Collection of event statistic types to filter. + */ + private EnumSet eventStatNames; + + /** + * Collection of info stat types to filter. + */ + private EnumSet infoStatNames; + + /** + * Controls whether metadata should be included in the statistics response. + */ + @Setter + private boolean includeMetadata; + + /** + * Controls whether the response keys should be flattened. + */ + @Setter + private boolean flatten; + + /** + * Controls whether the response will include individual nodes + */ + @Setter + private boolean includeIndividualNodes; + + /** + * Controls whether the response will include aggregated nodes + */ + @Setter + private boolean includeAllNodes; + + /** + * Controls whether the response will include info nodes + */ + @Setter + private boolean includeInfo; + + /** + * Builder constructor for creating SearchRelevanceStatsInput with specific filtering parameters. + * + * @param nodeIds node IDs to retrieve stats from + * @param eventStatNames event stats to retrieve + * @param infoStatNames info stats to retrieve + * @param includeMetadata whether to include metadata + * @param flatten whether to flatten keys + */ + @Builder + public SearchRelevanceStatsInput( + List nodeIds, + EnumSet eventStatNames, + EnumSet infoStatNames, + boolean includeMetadata, + boolean flatten, + boolean includeIndividualNodes, + boolean includeAllNodes, + boolean includeInfo + ) { + this.nodeIds = nodeIds; + this.eventStatNames = eventStatNames; + this.infoStatNames = infoStatNames; + this.includeMetadata = includeMetadata; + this.flatten = flatten; + this.includeIndividualNodes = includeIndividualNodes; + this.includeAllNodes = includeAllNodes; + this.includeInfo = includeInfo; + } + + /** + * Default constructor that initializes with empty filters and default settings. + * By default, metadata is excluded and keys are not flattened. + */ + public SearchRelevanceStatsInput() { + this.nodeIds = new ArrayList<>(); + this.eventStatNames = EnumSet.noneOf(EventStatName.class); + this.infoStatNames = EnumSet.noneOf(InfoStatName.class); + this.includeMetadata = false; + this.flatten = false; + this.includeIndividualNodes = true; + this.includeAllNodes = true; + this.includeInfo = true; + } + + /** + * Constructor for stream input + * + * @param input the StreamInput to read data from + * @throws IOException if there's an error reading from the stream + */ + public SearchRelevanceStatsInput(StreamInput input) throws IOException { + nodeIds = input.readOptionalStringList(); + eventStatNames = input.readOptionalEnumSet(EventStatName.class); + infoStatNames = input.readOptionalEnumSet(InfoStatName.class); + includeMetadata = input.readBoolean(); + flatten = input.readBoolean(); + includeIndividualNodes = input.readBoolean(); + includeAllNodes = input.readBoolean(); + includeInfo = input.readBoolean(); + } + + /** + * Serializes this object to a StreamOutput. + * + * @param out the StreamOutput to write data to + * @throws IOException If there's an error writing to the stream + */ + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeOptionalStringCollection(nodeIds); + out.writeOptionalEnumSet(eventStatNames); + out.writeOptionalEnumSet(infoStatNames); + out.writeBoolean(includeMetadata); + out.writeBoolean(flatten); + out.writeBoolean(includeIndividualNodes); + out.writeBoolean(includeAllNodes); + out.writeBoolean(includeInfo); + } + + /** + * Converts to fields xContent + * + * @param builder XContentBuilder + * @param params Params + * @return XContentBuilder + * @throws IOException thrown by builder for invalid field + */ + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + if (nodeIds != null) { + builder.field(NODE_IDS_FIELD, nodeIds); + } + if (eventStatNames != null) { + builder.field(EVENT_STAT_NAMES_FIELD, eventStatNames); + } + if (infoStatNames != null) { + builder.field(STATE_STAT_NAMES_FIELD, infoStatNames); + } + builder.field(RestSearchRelevanceStatsAction.INCLUDE_METADATA_PARAM, includeMetadata); + builder.field(RestSearchRelevanceStatsAction.FLATTEN_PARAM, flatten); + builder.field(RestSearchRelevanceStatsAction.INCLUDE_INDIVIDUAL_NODES_PARAM, includeIndividualNodes); + builder.field(RestSearchRelevanceStatsAction.INCLUDE_ALL_NODES_PARAM, includeAllNodes); + builder.field(RestSearchRelevanceStatsAction.INCLUDE_INFO_PARAM, includeInfo); + builder.endObject(); + return builder; + } + + /** + * Helper to determine if we should fetch event stats or if we can skip them + * If we exclude both individual and all nodes, then there is no need to fetch any specific stats from nodes + * @return whether we need to fetch event stats + */ + public boolean isIncludeEvents() { + return this.isIncludeAllNodes() || this.isIncludeIndividualNodes(); + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/common/StatName.java b/src/main/java/org/opensearch/searchrelevance/stats/common/StatName.java new file mode 100644 index 00000000..6c4ee274 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/common/StatName.java @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.common; + +import org.opensearch.Version; + +/** + * Interface for objects that hold stat name, path, and type information. + * The stat name is used as the unique identifier for the stat. It can be used as a request parameter for user filtering. + */ +public interface StatName { + /** + * Gets the name of the stat. These must be unique to support user request stat filtering. + * @return the name of the stat + */ + String getNameString(); + + /** + * Gets the path of the stat in dot notation. + * The path must be unique and avoid collisions with other stat names. + * @return the path of the stat + */ + String getFullPath(); + + /** + * The type of the stat + * @return the stat type + */ + StatType getStatType(); + + /** + * The release version the stat that it was added in + * @return the version the stat was added + */ + Version version(); +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/common/StatSnapshot.java b/src/main/java/org/opensearch/searchrelevance/stats/common/StatSnapshot.java new file mode 100644 index 00000000..22734113 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/common/StatSnapshot.java @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.common; + +import java.io.IOException; + +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; + +/** + * A serializable snapshot of a stat at a given point in time. + * Holds stat values, type, and metadata for processing and returning across rest layer. + * These are not meant to be persisted. + * @param The type of the value of the stat + */ +public interface StatSnapshot extends ToXContentFragment { + /** + * Field name of the stat_type in XContent + */ + String STAT_TYPE_FIELD = "stat_type"; + + /** + * Field name of the value in XContent + */ + String VALUE_FIELD = "value"; + + /** + * Gets the raw value of the stat, excluding any metadata + * @return the raw stat value + */ + T getValue(); + + /** + * Converts to fields xContent, including stat metadata + * + * @param builder XContentBuilder + * @param params Params + * @return XContentBuilder + * @throws IOException thrown by builder for invalid field + */ + XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException; +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/common/StatType.java b/src/main/java/org/opensearch/searchrelevance/stats/common/StatType.java new file mode 100644 index 00000000..f115b0f4 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/common/StatType.java @@ -0,0 +1,20 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.common; + +/** + * Interface for the type of stat. Used for stat type metadata + */ +public interface StatType { + + /** + * Get the name of the stat type containing info about the type and how to process it + * @return name of the stat type + */ + String getTypeString(); +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/events/EventStat.java b/src/main/java/org/opensearch/searchrelevance/stats/events/EventStat.java new file mode 100644 index 00000000..d7aaf322 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/events/EventStat.java @@ -0,0 +1,37 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.events; + +import org.opensearch.searchrelevance.stats.common.StatSnapshot; + +/** + * Interface for event stats. These contain logic to store and update ongoing event information. + */ +public interface EventStat { + /** + * Returns a single point in time value associated with the stat. Typically a counter. + * @return the value of the stat + */ + long getValue(); + + /** + * Returns a snapshot of the stat. Used to cross transport layer/rest layer + * @return + */ + StatSnapshot getStatSnapshot(); + + /** + * Increments the stat + */ + void increment(); + + /** + * Resets the stat value + */ + void reset(); +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/events/EventStatName.java b/src/main/java/org/opensearch/searchrelevance/stats/events/EventStatName.java new file mode 100644 index 00000000..b3430450 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/events/EventStatName.java @@ -0,0 +1,146 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.events; + +import java.util.Arrays; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +import org.opensearch.Version; +import org.opensearch.searchrelevance.stats.common.StatName; + +import lombok.Getter; + +/** + * Enum that contains all event stat names, paths, and types + */ +@Getter +public enum EventStatName implements StatName { + IMPORT_JUDGMENT_RATING_GENERATIONS( + "import_judgment_rating_generations", + "judgments", + EventStatType.TIMESTAMPED_EVENT_COUNTER, + Version.V_3_1_0 + ), + LLM_JUDGMENT_RATING_GENERATIONS( + "llm_judgment_rating_generations", + "judgments", + EventStatType.TIMESTAMPED_EVENT_COUNTER, + Version.V_3_1_0 + ), + UBI_JUDGMENT_RATING_GENERATIONS( + "ubi_judgment_rating_generations", + "judgments", + EventStatType.TIMESTAMPED_EVENT_COUNTER, + Version.V_3_1_0 + ), + EXPERIMENT_EXECUTIONS("experiment_executions", "experiments", EventStatType.TIMESTAMPED_EVENT_COUNTER, Version.V_3_1_0), + EXPERIMENT_PAIRWISE_COMPARISON_EXECUTIONS( + "experiment_pairwise_comparison_executions", + "experiments", + EventStatType.TIMESTAMPED_EVENT_COUNTER, + Version.V_3_1_0 + ), + EXPERIMENT_POINTWISE_EVALUATION_EXECUTIONS( + "experiment_pointwise_evaluation_executions", + "experiments", + EventStatType.TIMESTAMPED_EVENT_COUNTER, + Version.V_3_1_0 + ), + EXPERIMENT_HYBRID_OPTIMIZER_EXECUTIONS( + "experiment_hybrid_optimizer_executions", + "experiments", + EventStatType.TIMESTAMPED_EVENT_COUNTER, + Version.V_3_1_0 + ),; + + private final String nameString; + private final String path; + private final EventStatType statType; + private EventStat eventStat; + private final Version version; + + /** + * Enum lookup table by nameString + */ + private static final Map BY_NAME = Arrays.stream(values()) + .collect(Collectors.toMap(stat -> stat.nameString, stat -> stat)); + + /** + * Constructor + * @param nameString the unique name of the stat. + * @param path the unique path of the stat + * @param statType the category of stat + */ + EventStatName(String nameString, String path, EventStatType statType, Version version) { + this.nameString = nameString; + this.path = path; + this.statType = statType; + this.version = version; + + switch (statType) { + case EventStatType.TIMESTAMPED_EVENT_COUNTER: + eventStat = new TimestampedEventStat(this); + break; + } + + // Validates all event stats are instantiated correctly. This is covered by unit tests as well. + if (eventStat == null) { + throw new IllegalArgumentException( + String.format(Locale.ROOT, "Unable to initialize event stat [%s]. Unrecognized event stat type: [%s]", nameString, statType) + ); + } + } + + /** + * Gets the StatName associated with a unique string name + * @throws IllegalArgumentException if stat name does not exist + * @param name the string name of the stat + * @return the StatName enum associated with that String name + */ + public static EventStatName from(String name) { + if (isValidName(name) == false) { + throw new IllegalArgumentException(String.format(Locale.ROOT, "Event stat not found: %s", name)); + } + return BY_NAME.get(name); + } + + /** + * Gets the full dot notation path of the stat, defining its location in the response body + * @return the destination dot notation path of the stat value + */ + public String getFullPath() { + if (path == null || path.isBlank()) { + return nameString; + } + return String.join(".", path, nameString); + } + + /** + * Determines whether a given string is a valid stat name + * @param name name of the stat + * @return whether the name is valid + */ + public static boolean isValidName(String name) { + return BY_NAME.containsKey(name); + } + + /** + * Gets the version the stat was added + * @return the version the stat was added + */ + public Version version() { + return this.version; + } + + @Override + public String toString() { + return getNameString(); + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/events/EventStatType.java b/src/main/java/org/opensearch/searchrelevance/stats/events/EventStatType.java new file mode 100644 index 00000000..d7d346e7 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/events/EventStatType.java @@ -0,0 +1,27 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.events; + +import java.util.Locale; + +import org.opensearch.searchrelevance.stats.common.StatType; + +/** + * Enum for different kinds of event stat types to track + */ +public enum EventStatType implements StatType { + TIMESTAMPED_EVENT_COUNTER; + + /** + * Gets the name of the stat type, the enum name in lowercase + * @return the name of the stat type + */ + public String getTypeString() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/events/EventStatsManager.java b/src/main/java/org/opensearch/searchrelevance/stats/events/EventStatsManager.java new file mode 100644 index 00000000..2b2dbe87 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/events/EventStatsManager.java @@ -0,0 +1,99 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.events; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; + +import org.opensearch.searchrelevance.settings.SearchRelevanceSettingsAccessor; +import org.opensearch.searchrelevance.stats.common.StatSnapshot; + +import lombok.NoArgsConstructor; + +/** + * Singleton manager class for event stats, used to increment and store event stat related data + */ +@NoArgsConstructor +public class EventStatsManager { + private static EventStatsManager INSTANCE; + private SearchRelevanceSettingsAccessor settingsAccessor; + + /** + * Initializes dependencies for the EventStats manager + * @param settingsAccessor + */ + public void initialize(SearchRelevanceSettingsAccessor settingsAccessor) { + this.settingsAccessor = settingsAccessor; + } + + /** + * Returns the singleton instance of EventStatsManager. + * Creates a new instance with default settings if one doesn't exist. + * + * @return The singleton instance of EventStatsManager + */ + public static EventStatsManager instance() { + if (INSTANCE == null) { + INSTANCE = new EventStatsManager(); + } + return INSTANCE; + } + + /** + * Static helper to increments the counter for a specified event statistic on the singleton + * + * @param eventStatName The name of the event stat to increment + */ + public static void increment(EventStatName eventStatName) { + instance().inc(eventStatName); + } + + /** + * Instance level method to increment the counter for a specified event statistic. + * Treated as a NOOP if stats are disabled + * + * @param eventStatName The name of the event stat to increment + */ + public void inc(EventStatName eventStatName) { + if (settingsAccessor.isStatsEnabled()) { + eventStatName.getEventStat().increment(); + } + } + + /** + * Retrieves snapshots of specified event statistics. + * + * @param statsToRetrieve Set of event stat names to retrieve data for + * @return Map of event stat names to their current snapshots + */ + public Map getTimestampedEventStatSnapshots(EnumSet statsToRetrieve) { + // Filter stats based on passed in collection + Map eventStatsDataMap = new HashMap<>(); + for (EventStatName statName : statsToRetrieve) { + if (statName.getStatType() == EventStatType.TIMESTAMPED_EVENT_COUNTER) { + StatSnapshot snapshot = statName.getEventStat().getStatSnapshot(); + if (snapshot instanceof TimestampedEventStatSnapshot) { + // Get event data snapshot + eventStatsDataMap.put(statName, (TimestampedEventStatSnapshot) snapshot); + } + } + } + return eventStatsDataMap; + } + + /** + * Resets all statistics counters to their initial state. + * Called when stats_enabled cluster setting is toggled off + */ + public void reset() { + for (EventStatName statName : EventStatName.values()) { + statName.getEventStat().reset(); + } + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/events/TimestampedEventStat.java b/src/main/java/org/opensearch/searchrelevance/stats/events/TimestampedEventStat.java new file mode 100644 index 00000000..daf8e09e --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/events/TimestampedEventStat.java @@ -0,0 +1,163 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.events; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAdder; + +import com.google.common.annotations.VisibleForTesting; + +/** + * Event stat information tracker which store and updates ongoing event stat data and metadata + * Tracks a single monotonically increasing counter, a unix timestamp of the last event, and a value of the counter + * in a recent trailing interval of time defined by the constants + */ +public class TimestampedEventStat implements EventStat { + // The length of the rotating time bucket used to track the trailing interval + // Trailing interval size is determined by interval size * number of intervals + private static final long TRAILING_BUCKET_INTERVAL_MS = TimeUnit.SECONDS.toMillis(60); + + // Number of buckets to track for the trailing interval + private static final int TRAILING_NUMBER_OF_INTERVALS = 5; + + private EventStatName statName; + private long lastEventTimestamp; + private LongAdder totalCounter; + private Bucket[] buckets; + + /** + * Constructor + * @param statName the associate stat name identifier + */ + public TimestampedEventStat(EventStatName statName) { + this.statName = statName; + this.lastEventTimestamp = 0L; + this.totalCounter = new LongAdder(); + this.buckets = new Bucket[TRAILING_NUMBER_OF_INTERVALS + 1]; + + for (int i = 0; i < TRAILING_NUMBER_OF_INTERVALS + 1; i++) { + buckets[i] = new Bucket(); + } + } + + /** + * Gets the current counter value + * @return + */ + public long getValue() { + return totalCounter.longValue(); + } + + /** + * Increments the counter + */ + public void increment() { + totalCounter.increment(); + lastEventTimestamp = getCurrentTimeInMillis(); + incrementCurrentBucket(); + } + + /** + * Helper to increment the current bucket based on system time + */ + private void incrementCurrentBucket() { + long now = getCurrentTimeInMillis(); + + // Align current time to current minute + long currentBucketTime = now - (now % TRAILING_BUCKET_INTERVAL_MS); + + // Use aligned time to determine bucket index + int bucketIndex = (int) ((now / TRAILING_BUCKET_INTERVAL_MS) % (TRAILING_NUMBER_OF_INTERVALS + 1)); + + Bucket bucket = buckets[bucketIndex]; + long bucketTimestamp = bucket.timestamp.get(); + + // If bucket is out of date, rotate the bucket timestamp and reset the bucket + if (bucketTimestamp != currentBucketTime && bucket.timestamp.compareAndSet(bucketTimestamp, currentBucketTime)) { + bucket.count.reset(); + } + bucket.count.add(1); + } + + /** + * Gets the current count value of the trailing interval + * @return the total count of all events in the recent trailing interval + */ + public long getTrailingIntervalValue() { + long now = getCurrentTimeInMillis(); + long currentBucketTime = now - (now % TRAILING_BUCKET_INTERVAL_MS); // Start of current minute + + long cutoff = now - (TRAILING_NUMBER_OF_INTERVALS * TRAILING_BUCKET_INTERVAL_MS); // Cutoff is number of buckets away + long alignedCutoff = (cutoff / TRAILING_BUCKET_INTERVAL_MS) * TRAILING_BUCKET_INTERVAL_MS; // Align cutoff to bucket boundary + + long sum = 0; + for (Bucket bucket : buckets) { + long timestamp = bucket.timestamp.get(); + // Include buckets >= aligned cutoff and < current bucket (excludes current) + if (timestamp >= alignedCutoff && timestamp < currentBucketTime) { + sum += bucket.count.longValue(); + } + } + + return sum; + } + + /** + * Gets the number of minutes since the last event + * This is calculated relative to node system time to reduce time desync issues across different nodes + * @return the number of minutes since the last event + */ + public long getMinutesSinceLastEvent() { + long currentTimestamp = getCurrentTimeInMillis(); + return (currentTimestamp / (1000 * 60)) - (lastEventTimestamp / (1000 * 60)); + } + + /** + * Gets the StatSnapshot for the event stat data + * @return + */ + public TimestampedEventStatSnapshot getStatSnapshot() { + return TimestampedEventStatSnapshot.builder() + .statName(statName) + .value(getValue()) + .trailingIntervalValue(getTrailingIntervalValue()) + .minutesSinceLastEvent(getMinutesSinceLastEvent()) + .build(); + } + + /** + * Resets all stat data + * Used when the cluster setting to enable stats is toggled off + */ + public void reset() { + for (int i = 0; i < buckets.length; i++) { + buckets[i].timestamp.set(0); + buckets[i].count.reset(); + } + totalCounter.reset(); + lastEventTimestamp = 0; + } + + /** + * Helper class to get current time in millis. Abstracted for testing purposes + * @return current time in millis + */ + @VisibleForTesting + protected long getCurrentTimeInMillis() { + return System.currentTimeMillis(); + } + + /** + * Private inner class for tracking trailing interval values + */ + private class Bucket { + AtomicLong timestamp = new AtomicLong(0); // Start time of the bucket's minute + LongAdder count = new LongAdder(); // Running count of the bucket + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/events/TimestampedEventStatSnapshot.java b/src/main/java/org/opensearch/searchrelevance/stats/events/TimestampedEventStatSnapshot.java new file mode 100644 index 00000000..a44d5cd9 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/events/TimestampedEventStatSnapshot.java @@ -0,0 +1,145 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.events; + +import java.io.IOException; +import java.util.Collection; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.searchrelevance.stats.common.StatSnapshot; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +/** + * A stat snapshot for a timestamped event stat at a point in time + * This is generated from an event stat to freeze the ongoing counter and other time related metadata field + * These are meant for transport layer/rest layer and not meant to be persisted + */ +@Getter +@Builder +@AllArgsConstructor +public class TimestampedEventStatSnapshot implements Writeable, StatSnapshot { + public static final String TRAILING_INTERVAL_KEY = "trailing_interval_value"; + public static final String MINUTES_SINCE_LAST_EVENT_KEY = "minutes_since_last_event"; + + private EventStatName statName; + private long value; + private long trailingIntervalValue; + private long minutesSinceLastEvent; + + /** + * Create a stat new snapshot from an input stream + * @param in the input stream + * @throws IOException + */ + public TimestampedEventStatSnapshot(StreamInput in) throws IOException { + this.statName = in.readEnum(EventStatName.class); + this.value = in.readLong(); + this.trailingIntervalValue = in.readLong(); + this.minutesSinceLastEvent = in.readLong(); + } + + /** + * Gets the value of the counter + * @return the value of the counter + */ + @Override + public Long getValue() { + return value; + } + + /** + * Writes the stat snapshot to an output stream + * @param out the output stream + * @throws IOException + */ + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeEnum(statName); + out.writeLong(value); + out.writeLong(trailingIntervalValue); + out.writeLong(minutesSinceLastEvent); + } + + /** + * Static method to aggregate multiple event stats snapshots. + * This is intended for combining stat snapshots from multiple nodes to give an cluster level aggregate + * for the stat across nodes. + * Different metadata fields are aggregated differently + * @param snapshots the collection of snapshots + * @return + */ + public static TimestampedEventStatSnapshot aggregateEventStatSnapshots(Collection snapshots) + throws IllegalArgumentException { + if (snapshots == null || snapshots.isEmpty()) { + return null; + } + + EventStatName name = null; + long totalValue = 0; + long totalTrailingValue = 0; + Long minMinutes = null; + + for (TimestampedEventStatSnapshot stat : snapshots) { + // Mixed version clusters may have nodes that return null stat snapshots not available on older versions. + // If so, exclude those from aggregation + if (stat == null) { + continue; + } + + // The first stat name is taken. This should never be called across event stats that don't share stat names + if (name == null) { + name = stat.getStatName(); + } else if (name != stat.getStatName()) { + throw new IllegalArgumentException("Should not aggregate snapshots across different stat names"); + } + + // The value is summed + totalValue += stat.getValue(); + + // The trailing value is summed + totalTrailingValue += stat.getTrailingIntervalValue(); + + // Take the min of minutes since last event + if (minMinutes == null || stat.getMinutesSinceLastEvent() < minMinutes) { + minMinutes = stat.getMinutesSinceLastEvent(); + } + } + + return TimestampedEventStatSnapshot.builder() + .statName(name) + .value(totalValue) + .trailingIntervalValue(totalTrailingValue) + .minutesSinceLastEvent(minMinutes) + .build(); + } + + /** + * Converts to fields xContent, including stat metadata + * + * @param builder XContentBuilder + * @param params Params + * @return XContentBuilder + * @throws IOException thrown by builder for invalid field + */ + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field(StatSnapshot.VALUE_FIELD, value); + builder.field(StatSnapshot.STAT_TYPE_FIELD, statName.getStatType().getTypeString()); + builder.field(TRAILING_INTERVAL_KEY, trailingIntervalValue); + builder.field(MINUTES_SINCE_LAST_EVENT_KEY, minutesSinceLastEvent); + builder.endObject(); + return builder; + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/info/CountableInfoStatSnapshot.java b/src/main/java/org/opensearch/searchrelevance/stats/info/CountableInfoStatSnapshot.java new file mode 100644 index 00000000..ae892a1a --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/info/CountableInfoStatSnapshot.java @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.info; + +import java.io.IOException; +import java.util.concurrent.atomic.LongAdder; + +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.searchrelevance.stats.common.StatSnapshot; + +/** + * A countable stat snapshot for info stats. + * Can be updated in place + */ +public class CountableInfoStatSnapshot implements StatSnapshot { + private LongAdder adder; + private InfoStatName statName; + + /** + * Creates a new stat snapshot + * @param statName the name of the stat it corresponds to + */ + public CountableInfoStatSnapshot(InfoStatName statName) { + this.statName = statName; + this.adder = new LongAdder(); + } + + /** + * Gets the counter value + * @return the counter value + */ + public Long getValue() { + return adder.longValue(); + } + + /** + * Increment the counter by a given delta + * @param delta the amount ot increment by + */ + public void incrementBy(Long delta) { + adder.add(delta); + } + + /** + * Converts to fields xContent, including stat metadata + * + * @param builder XContentBuilder + * @param params Params + * @return XContentBuilder + * @throws IOException thrown by builder for invalid field + */ + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field(StatSnapshot.VALUE_FIELD, getValue()); + builder.field(StatSnapshot.STAT_TYPE_FIELD, statName.getStatType().getTypeString()); + builder.endObject(); + return builder; + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/info/InfoStatName.java b/src/main/java/org/opensearch/searchrelevance/stats/info/InfoStatName.java new file mode 100644 index 00000000..1620a057 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/info/InfoStatName.java @@ -0,0 +1,94 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.info; + +import java.util.Arrays; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +import org.opensearch.Version; +import org.opensearch.searchrelevance.stats.common.StatName; + +import lombok.Getter; + +/** + * Enum that contains all info stat names, paths, and types + */ +@Getter +public enum InfoStatName implements StatName { + // Cluster info + CLUSTER_VERSION("cluster_version", "", InfoStatType.INFO_STRING, Version.V_3_1_0),; + + private final String nameString; + private final String path; + private final InfoStatType statType; + private final Version version; + + private static final Map BY_NAME = Arrays.stream(values()) + .collect(Collectors.toMap(stat -> stat.nameString, stat -> stat)); + + /** + * Constructor + * @param nameString the unique name of the stat. + * @param path the unique path of the stat + * @param statType the category of stat + */ + InfoStatName(String nameString, String path, InfoStatType statType, Version version) { + this.nameString = nameString; + this.path = path; + this.statType = statType; + this.version = version; + } + + /** + * Gets the StatName associated with a unique string name + * @throws IllegalArgumentException if stat name does not exist + * @param name the string name of the stat + * @return the StatName enum associated with that String name + */ + public static InfoStatName from(String name) { + if (isValidName(name) == false) { + throw new IllegalArgumentException(String.format(Locale.ROOT, "Info stat not found: %s", name)); + } + return BY_NAME.get(name); + } + + /** + * Gets the full dot notation path of the stat, defining its location in the response body + * @return the destination dot notation path of the stat value + */ + public String getFullPath() { + if (path == null || path.isBlank()) { + return nameString; + } + return String.join(".", path, nameString); + } + + /** + * Determines whether a given string is a valid stat name + * @param name name of the stat + * @return whether the name is valid + */ + public static boolean isValidName(String name) { + return BY_NAME.containsKey(name); + } + + /** + * Gets the version the stat was added + * @return the version the stat was added + */ + public Version version() { + return this.version; + } + + @Override + public String toString() { + return getNameString(); + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/info/InfoStatType.java b/src/main/java/org/opensearch/searchrelevance/stats/info/InfoStatType.java new file mode 100644 index 00000000..c61a08a2 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/info/InfoStatType.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.info; + +import java.util.Locale; + +import org.opensearch.searchrelevance.stats.common.StatType; + +/** + * Enum for different kinds of info stat types to track + */ +public enum InfoStatType implements StatType { + INFO_COUNTER, + INFO_STRING, + INFO_BOOLEAN; + + /** + * Gets the name of the stat type, the enum name in lowercase + * @return the name of the stat type + */ + public String getTypeString() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/info/InfoStatsManager.java b/src/main/java/org/opensearch/searchrelevance/stats/info/InfoStatsManager.java new file mode 100644 index 00000000..19fa3df3 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/info/InfoStatsManager.java @@ -0,0 +1,138 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.info; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.opensearch.Version; +import org.opensearch.searchrelevance.settings.SearchRelevanceSettingsAccessor; +import org.opensearch.searchrelevance.stats.common.StatSnapshot; + +import lombok.AllArgsConstructor; + +/** + * Manager to generate stat snapshots for cluster level info stats + */ +@AllArgsConstructor +public class InfoStatsManager { + private SearchRelevanceSettingsAccessor settingsAccessor; + + /** + * Calculates and gets info stats + * @param statsToRetrieve a set of the enums to retrieve + * @return map of stat name to stat snapshot + */ + public Map> getStats(EnumSet statsToRetrieve) { + // info stats are calculated all at once regardless of filters + Map countableInfoStats = getCountableStats(); + Map> settableInfoStats = getSettableStats(); + + Map> prefilteredStats = new HashMap<>(); + prefilteredStats.putAll(countableInfoStats); + prefilteredStats.putAll(settableInfoStats); + + // Filter based on specified stats + Map> filteredStats = prefilteredStats.entrySet() + .stream() + .filter(entry -> statsToRetrieve.contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + return filteredStats; + } + + /** + * Calculates and gets info stats + * @return map of stat name to stat snapshot + */ + private Map getCountableStats() { + // Initialize empty map with keys so stat names are visible in JSON even if the value is not counted + Map countableInfoStats = new HashMap<>(); + for (InfoStatName stat : EnumSet.allOf(InfoStatName.class)) { + if (stat.getStatType() == InfoStatType.INFO_COUNTER) { + countableInfoStats.put(stat, new CountableInfoStatSnapshot(stat)); + } + } + + // Helpers to parse search pipeline processor configs for processor info would go here + return countableInfoStats; + } + + /** + * Calculates and gets settable info stats + * @return map of stat name to stat snapshot + */ + private Map> getSettableStats() { + Map> settableInfoStats = new HashMap<>(); + for (InfoStatName statName : EnumSet.allOf(InfoStatName.class)) { + switch (statName.getStatType()) { + case InfoStatType.INFO_BOOLEAN -> settableInfoStats.put(statName, new SettableInfoStatSnapshot(statName)); + case InfoStatType.INFO_STRING -> settableInfoStats.put(statName, new SettableInfoStatSnapshot(statName)); + } + } + + addClusterVersionStat(settableInfoStats); + return settableInfoStats; + } + + /** + * Adds cluster version to settable stats, mutating the input + * @param stats mutable map of info stats that the result will be added to + */ + private void addClusterVersionStat(Map> stats) { + InfoStatName infoStatName = InfoStatName.CLUSTER_VERSION; + stats.put(infoStatName, new SettableInfoStatSnapshot<>(infoStatName, Version.CURRENT)); + } + + /** + * Helper to cast generic object into a specific type + * Used to parse pipeline processor configs + * @param map the map + * @param key the key + * @param clazz the class to cast to + * @return the map + */ + @SuppressWarnings("unchecked") + private T getValue(Map map, String key, Class clazz) { + if (map == null || key == null) return null; + Object value = map.get(key); + return clazz.isInstance(value) ? clazz.cast(value) : null; + } + + /** + * Helper to cast generic object into Map + * Used to parse pipeline processor configs + * @param value the object + * @return the map + */ + @SuppressWarnings("unchecked") + private Map asMap(Object value) { + return value instanceof Map ? (Map) value : null; + } + + /** + * Helper to cast generic object into a list of Map + * Used to parse pipeline processor configs + * @param value the object + * @return the list of maps + */ + @SuppressWarnings("unchecked") + private List> asListOfMaps(Object value) { + if (value instanceof List) { + List list = (List) value; + for (Object item : list) { + if (!(item instanceof Map)) return null; + } + return (List>) value; + } + return null; + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/stats/info/SettableInfoStatSnapshot.java b/src/main/java/org/opensearch/searchrelevance/stats/info/SettableInfoStatSnapshot.java new file mode 100644 index 00000000..cc15fd26 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/stats/info/SettableInfoStatSnapshot.java @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.info; + +import java.io.IOException; + +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.searchrelevance.stats.common.StatSnapshot; + +import lombok.Getter; +import lombok.Setter; + +/** + * A settable info snapshot used to track Strings, booleans, or other simple serializable objects + * This are meant to be constructed, set, and serialized, not for long storage in memory + * @param the type of the value to set + */ +public class SettableInfoStatSnapshot implements StatSnapshot { + @Getter + @Setter + private T value; + + private InfoStatName statName; + + /** + * Creates a new stat snapshot with default null value + * @param statName the associated stat name + */ + public SettableInfoStatSnapshot(InfoStatName statName) { + this.statName = statName; + this.value = null; + } + + /** + * Creates a new stat snapshot for a given value + * @param statName the associated stat name + * @param value the initial value to set + */ + public SettableInfoStatSnapshot(InfoStatName statName, T value) { + this.statName = statName; + this.value = value; + } + + /** + * Converts to fields xContent, including stat metadata + * + * @param builder XContentBuilder + * @param params Params + * @return XContentBuilder + * @throws IOException thrown by builder for invalid field + */ + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field(StatSnapshot.VALUE_FIELD, getValue()); + builder.field(StatSnapshot.STAT_TYPE_FIELD, statName.getStatType().getTypeString()); + builder.endObject(); + return builder; + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsAction.java b/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsAction.java new file mode 100644 index 00000000..a19dfa8c --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsAction.java @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.transport.stats; + +import org.opensearch.action.ActionType; +import org.opensearch.core.common.io.stream.Writeable; + +/** + * SearchRelevanceStatsAction class + */ +public class SearchRelevanceStatsAction extends ActionType { + + public static final SearchRelevanceStatsAction INSTANCE = new SearchRelevanceStatsAction(); + public static final String NAME = "cluster:admin/search_relevance_stats_action"; + + /** + * Constructor + */ + private SearchRelevanceStatsAction() { + super(NAME, SearchRelevanceStatsResponse::new); + } + + @Override + public Writeable.Reader getResponseReader() { + return SearchRelevanceStatsResponse::new; + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsNodeRequest.java b/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsNodeRequest.java new file mode 100644 index 00000000..6f791ff6 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsNodeRequest.java @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.transport.stats; + +import java.io.IOException; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.transport.TransportRequest; + +import lombok.Getter; + +/** + * SearchRelevanceStatsNodeRequest represents the request to an individual node + */ +public class SearchRelevanceStatsNodeRequest extends TransportRequest { + @Getter + private SearchRelevanceStatsRequest request; + + /** + * Constructor + */ + public SearchRelevanceStatsNodeRequest() { + super(); + } + + /** + * Constructor + * + * @param in input stream + * @throws IOException in case of I/O errors + */ + public SearchRelevanceStatsNodeRequest(StreamInput in) throws IOException { + super(in); + request = new SearchRelevanceStatsRequest(in); + } + + /** + * Constructor + * + * @param request SearchRelevanceStatsRequest + */ + public SearchRelevanceStatsNodeRequest(SearchRelevanceStatsRequest request) { + this.request = request; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + request.writeTo(out); + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsNodeResponse.java b/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsNodeResponse.java new file mode 100644 index 00000000..12f8ac08 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsNodeResponse.java @@ -0,0 +1,86 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.transport.stats; + +import java.io.IOException; +import java.util.Map; + +import org.opensearch.action.support.nodes.BaseNodeResponse; +import org.opensearch.cluster.node.DiscoveryNode; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.searchrelevance.stats.events.EventStatName; +import org.opensearch.searchrelevance.stats.events.TimestampedEventStatSnapshot; + +import lombok.Getter; + +/** + * SearchRelevanceStatsNodeResponse represents the responses generated by an individual node + */ +public class SearchRelevanceStatsNodeResponse extends BaseNodeResponse implements ToXContentFragment { + @Getter + private Map stats; + + /** + * Constructor + * + * @param in stream + * @throws IOException in case of I/O errors + */ + public SearchRelevanceStatsNodeResponse(StreamInput in) throws IOException { + super(in); + this.stats = in.readMap(input -> input.readEnum(EventStatName.class), TimestampedEventStatSnapshot::new); + } + + /** + * Constructor + * + * @param node node + * @param stats mapping of stat name to value + */ + public SearchRelevanceStatsNodeResponse(DiscoveryNode node, Map stats) { + super(node); + this.stats = stats; + } + + /** + * Creates a new SearchRelevanceStatsNodeResponse object and reads in the stats from an input stream + * + * @param in StreamInput to read from + * @return SearchRelevanceStatsNodeResponse object corresponding to the input stream + * @throws IOException throws an IO exception if the StreamInput cannot be read from + */ + public static SearchRelevanceStatsNodeResponse readStats(StreamInput in) throws IOException { + SearchRelevanceStatsNodeResponse response = new SearchRelevanceStatsNodeResponse(in); + return response; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeMap(stats, StreamOutput::writeEnum, (output, eventStatData) -> eventStatData.writeTo(output)); + } + + /** + * Converts statsMap to xContent + * + * @param builder XContentBuilder + * @param params Params + * @return XContentBuilder + * @throws IOException thrown by builder for invalid field + */ + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + for (Map.Entry entry : stats.entrySet()) { + EventStatName stat = entry.getKey(); + builder.field(stat.getFullPath(), entry.getValue().getValue()); + } + return builder; + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsRequest.java b/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsRequest.java new file mode 100644 index 00000000..8416928a --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsRequest.java @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.transport.stats; + +import java.io.IOException; + +import org.opensearch.action.support.nodes.BaseNodesRequest; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.searchrelevance.stats.SearchRelevanceStatsInput; + +import lombok.Getter; + +/** + * SearchRelevanceStatsRequest gets node (cluster) level Stats for search relevance + * By default, all parameters will be true + */ +public class SearchRelevanceStatsRequest extends BaseNodesRequest { + + /** + * Key indicating all stats should be retrieved + */ + @Getter + private final SearchRelevanceStatsInput searchRelevanceStatsInput; + + /** + * Empty constructor needed for SearchRelevanceStatsTransportAction + */ + public SearchRelevanceStatsRequest() { + super((String[]) null); + this.searchRelevanceStatsInput = new SearchRelevanceStatsInput(); + } + + /** + * Constructor + * + * @param in input stream + * @throws IOException in case of I/O errors + */ + public SearchRelevanceStatsRequest(StreamInput in) throws IOException { + super(in); + this.searchRelevanceStatsInput = new SearchRelevanceStatsInput(in); + } + + /** + * Constructor + * + * @param nodeIds NodeIDs from which to retrieve stats + */ + public SearchRelevanceStatsRequest(String[] nodeIds, SearchRelevanceStatsInput searchRelevanceStatsInput) { + super(nodeIds); + this.searchRelevanceStatsInput = searchRelevanceStatsInput; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + searchRelevanceStatsInput.writeTo(out); + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsResponse.java b/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsResponse.java new file mode 100644 index 00000000..1361c3ba --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsResponse.java @@ -0,0 +1,208 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.transport.stats; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.nodes.BaseNodesResponse; +import org.opensearch.cluster.ClusterName; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.searchrelevance.stats.common.StatSnapshot; + +import lombok.Getter; + +/** + * SearchRelevanceStatsResponse consists of the aggregated responses from the nodes + */ +@Getter +public class SearchRelevanceStatsResponse extends BaseNodesResponse implements ToXContentObject { + public static final String INFO_KEY_PREFIX = "info"; + public static final String NODES_KEY_PREFIX = "nodes"; + public static final String AGGREGATED_NODES_KEY_PREFIX = "all_nodes"; + + private Map> infoStats; + private Map> aggregatedNodeStats; + private Map>> nodeIdToNodeEventStats; + private boolean flatten; + private boolean includeMetadata; + private boolean includeIndividualNodes; + private boolean includeAllNodes; + private boolean includeInfo; + + /** + * Constructor + * + * @param in StreamInput + * @throws IOException thrown when unable to read from stream + */ + public SearchRelevanceStatsResponse(StreamInput in) throws IOException { + super(new ClusterName(in), in.readList(SearchRelevanceStatsNodeResponse::readStats), in.readList(FailedNodeException::new)); + Map> castedInfoStats = (Map>) (Map) in.readMap(); + Map> castedAggregatedNodeStats = (Map>) (Map) in.readMap(); + Map>> castedNodeIdToNodeEventStats = (Map>>) (Map) in + .readMap(); + + this.infoStats = castedInfoStats; + this.aggregatedNodeStats = castedAggregatedNodeStats; + this.nodeIdToNodeEventStats = castedNodeIdToNodeEventStats; + this.flatten = in.readBoolean(); + this.includeMetadata = in.readBoolean(); + this.includeIndividualNodes = in.readBoolean(); + this.includeAllNodes = in.readBoolean(); + this.includeInfo = in.readBoolean(); + } + + /** + * Constructor + * + * @param clusterName the cluster name + * @param nodes the nodes responses + * @param failures the failures + * @param infoStats the cluster level info stats + * @param aggregatedNodeStats the cluster level aggregated node stats + * @param nodeIdToNodeEventStats the node id to node event stats + * @param flatten whether to flatten keys + * @param includeMetadata whether to include metadata + */ + public SearchRelevanceStatsResponse( + ClusterName clusterName, + List nodes, + List failures, + Map> infoStats, + Map> aggregatedNodeStats, + Map>> nodeIdToNodeEventStats, + boolean flatten, + boolean includeMetadata, + boolean includeIndividualNodes, + boolean includeAllNodes, + boolean includeInfo + ) { + super(clusterName, nodes, failures); + this.infoStats = infoStats; + this.aggregatedNodeStats = aggregatedNodeStats; + this.nodeIdToNodeEventStats = nodeIdToNodeEventStats; + this.flatten = flatten; + this.includeMetadata = includeMetadata; + this.includeIndividualNodes = includeIndividualNodes; + this.includeAllNodes = includeAllNodes; + this.includeInfo = includeInfo; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + Map downcastedInfoStats = (Map) (Map) (infoStats); + Map downcastedAggregatedNodeStats = (Map) (Map) (aggregatedNodeStats); + Map downcastedNodeIdToNodeEventStats = (Map) (Map) (nodeIdToNodeEventStats); + + out.writeMap(downcastedInfoStats); + out.writeMap(downcastedAggregatedNodeStats); + out.writeMap(downcastedNodeIdToNodeEventStats); + out.writeBoolean(flatten); + out.writeBoolean(includeMetadata); + out.writeBoolean(includeIndividualNodes); + out.writeBoolean(includeAllNodes); + out.writeBoolean(includeInfo); + + } + + @Override + public void writeNodesTo(StreamOutput out, List nodes) throws IOException { + out.writeList(nodes); + } + + @Override + public List readNodesFrom(StreamInput in) throws IOException { + return in.readList(SearchRelevanceStatsNodeResponse::readStats); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + if (includeInfo) { + Map formattedInfoStats = formatStats(infoStats); + builder.startObject(INFO_KEY_PREFIX); + builder.mapContents(formattedInfoStats); + builder.endObject(); + } + + if (includeAllNodes) { + Map formattedAggregatedNodeStats = formatStats(aggregatedNodeStats); + builder.startObject(AGGREGATED_NODES_KEY_PREFIX); + builder.mapContents(formattedAggregatedNodeStats); + builder.endObject(); + } + + if (includeIndividualNodes) { + Map formattedNodeEventStats = formatNodeEventStats(nodeIdToNodeEventStats); + builder.startObject(NODES_KEY_PREFIX); + builder.mapContents(formattedNodeEventStats); + builder.endObject(); + } + + return builder; + } + + private Map formatStats(Map> rawStats) { + if (flatten) { + return getFlattenedStats(rawStats); + } + return writeNestedMapWithDotNotation(rawStats, includeMetadata); + } + + private Map getFlattenedStats(Map> rawStats) { + if (includeMetadata) { + // Safe downcast to object + return (Map) (Map) rawStats; + } + return rawStats.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getValue())); + } + + private Map formatNodeEventStats(Map>> rawNodeStats) { + // Format nested maps for node event stats; + Map formattedNodeIdsToNodeEventStats = new HashMap<>(); + for (Map.Entry>> nodeEventStats : rawNodeStats.entrySet()) { + String nodeId = nodeEventStats.getKey(); + + // Format each nested map + Map formattedNodeStats = formatStats(nodeEventStats.getValue()); + formattedNodeIdsToNodeEventStats.put(nodeId, formattedNodeStats); + } + return formattedNodeIdsToNodeEventStats; + } + + private Map writeNestedMapWithDotNotation(Map> dotMap, boolean includeMetadata) { + Map nestedMap = new HashMap<>(); + + // For every key, iteratively create or access maps to put the final value; + for (Map.Entry> entry : dotMap.entrySet()) { + String[] parts = entry.getKey().split("\\."); + Map current = nestedMap; + + // Navigate to the end of the nested map + for (int i = 0; i < parts.length - 1; i++) { + // This is the only place we're putting things into nestedMap, so this cast is safe + // So long as we verify there are no stat path collisions (done in unit tests) + current = (Map) current.computeIfAbsent(parts[i], k -> new HashMap<>()); + } + + // If include metadata, put object in and it'll be written toXContent via StatSnapshot implementation + // Otherwise, provide the raw value + Object value = includeMetadata ? entry.getValue() : entry.getValue().getValue(); + current.put(parts[parts.length - 1], value); + } + return nestedMap; + } +} diff --git a/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsTransportAction.java b/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsTransportAction.java new file mode 100644 index 00000000..32112bea --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsTransportAction.java @@ -0,0 +1,214 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.transport.stats; + +import java.io.IOException; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.nodes.TransportNodesAction; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.searchrelevance.stats.common.StatSnapshot; +import org.opensearch.searchrelevance.stats.events.EventStatName; +import org.opensearch.searchrelevance.stats.events.EventStatsManager; +import org.opensearch.searchrelevance.stats.events.TimestampedEventStatSnapshot; +import org.opensearch.searchrelevance.stats.info.InfoStatName; +import org.opensearch.searchrelevance.stats.info.InfoStatsManager; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.TransportService; + +/** + * SearchRelevanceStatsTransportAction contains the logic to extract the stats from the nodes + */ +public class SearchRelevanceStatsTransportAction extends TransportNodesAction< + SearchRelevanceStatsRequest, + SearchRelevanceStatsResponse, + SearchRelevanceStatsNodeRequest, + SearchRelevanceStatsNodeResponse> { + private final EventStatsManager eventStatsManager; + private final InfoStatsManager infoStatsManager; + + /** + * Constructor + * + * @param threadPool ThreadPool to use + * @param clusterService ClusterService + * @param transportService TransportService + * @param actionFilters Action Filters + */ + @Inject + public SearchRelevanceStatsTransportAction( + ThreadPool threadPool, + ClusterService clusterService, + TransportService transportService, + ActionFilters actionFilters, + EventStatsManager eventStatsManager, + InfoStatsManager infoStatsManager + ) { + super( + SearchRelevanceStatsAction.NAME, + threadPool, + clusterService, + transportService, + actionFilters, + SearchRelevanceStatsRequest::new, + SearchRelevanceStatsNodeRequest::new, + ThreadPool.Names.MANAGEMENT, + SearchRelevanceStatsNodeResponse.class + ); + this.eventStatsManager = eventStatsManager; + this.infoStatsManager = infoStatsManager; + } + + @Override + protected SearchRelevanceStatsResponse newResponse( + SearchRelevanceStatsRequest request, + List responses, + List failures + ) { + // Convert node level stats to map + Map>> nodeIdToEventStats = processorNodeEventStatsIntoMap(responses); + + // Sum the map to aggregate + Map> aggregatedNodeStats = Collections.emptyMap(); + if (request.getSearchRelevanceStatsInput().isIncludeAllNodes()) { + aggregatedNodeStats = aggregateNodesResponses(responses, request.getSearchRelevanceStatsInput().getEventStatNames()); + } + + // Get info stats + Map> flatInfoStats = Collections.emptyMap(); + if (request.getSearchRelevanceStatsInput().isIncludeInfo()) { + // Get info stats + Map> infoStats = infoStatsManager.getStats( + request.getSearchRelevanceStatsInput().getInfoStatNames() + ); + // Convert stat name keys into flat path strings + flatInfoStats = infoStats.entrySet() + .stream() + .collect(Collectors.toMap(entry -> entry.getKey().getFullPath(), Map.Entry::getValue)); + } + + return new SearchRelevanceStatsResponse( + clusterService.getClusterName(), + responses, + failures, + flatInfoStats, + aggregatedNodeStats, + nodeIdToEventStats, + request.getSearchRelevanceStatsInput().isFlatten(), + request.getSearchRelevanceStatsInput().isIncludeMetadata(), + request.getSearchRelevanceStatsInput().isIncludeIndividualNodes(), + request.getSearchRelevanceStatsInput().isIncludeAllNodes(), + request.getSearchRelevanceStatsInput().isIncludeInfo() + ); + } + + @Override + protected SearchRelevanceStatsNodeRequest newNodeRequest(SearchRelevanceStatsRequest request) { + return new SearchRelevanceStatsNodeRequest(request); + } + + @Override + protected SearchRelevanceStatsNodeResponse newNodeResponse(StreamInput in) throws IOException { + return new SearchRelevanceStatsNodeResponse(in); + } + + /** + * Node operation to retrieve stats from node local event stats manager + * @param request the node level request + * @return the node level response containing node level event stats + */ + @Override + protected SearchRelevanceStatsNodeResponse nodeOperation(SearchRelevanceStatsNodeRequest request) { + // Reads node level stats on an individual node + EnumSet eventStatsToRetrieve = request.getRequest().getSearchRelevanceStatsInput().getEventStatNames(); + Map eventStatDataMap = eventStatsManager.getTimestampedEventStatSnapshots( + eventStatsToRetrieve + ); + + return new SearchRelevanceStatsNodeResponse(clusterService.localNode(), eventStatDataMap); + } + + /** + * Helper to aggregate node response event stats to give cluster level aggregate info on node-level stats + * @param responses node stat responses + * @param statsToRetrieve a list of stats to filter + * @return A map associating cluster level aggregated stat name strings with their stat snapshot values + */ + private Map> aggregateNodesResponses( + List responses, + EnumSet statsToRetrieve + ) { + // Catch empty nodes responses case. + if (responses == null || responses.isEmpty()) { + return new HashMap<>(); + } + + // Convert node responses into list of Map + List> nodeEventStatsList = responses.stream() + .map(SearchRelevanceStatsNodeResponse::getStats) + .map(map -> map.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))) + .toList(); + + // Aggregate all events from all responses for all stats to retrieve + Map> aggregatedMap = new HashMap<>(); + for (EventStatName eventStatName : statsToRetrieve) { + Set timestampedEventStatSnapshotCollection = new HashSet<>(); + for (Map eventStats : nodeEventStatsList) { + timestampedEventStatSnapshotCollection.add(eventStats.get(eventStatName)); + } + + TimestampedEventStatSnapshot aggregatedEventSnapshots = TimestampedEventStatSnapshot.aggregateEventStatSnapshots( + timestampedEventStatSnapshotCollection + ); + + // Skip adding null event stats. This happens when a node id parameter is invalid. + if (aggregatedEventSnapshots != null) { + aggregatedMap.put(eventStatName.getFullPath(), aggregatedEventSnapshots); + } + } + + return aggregatedMap; + } + + /** + * Helper to convert node responses into a map of node id to event stats + * @param nodeResponses node stat responses + * @return A map of node id strings to their event stat data + */ + private Map>> processorNodeEventStatsIntoMap(List nodeResponses) { + // Converts list of node responses into Map + Map>> results = new HashMap<>(); + + String nodeId; + for (SearchRelevanceStatsNodeResponse nodesResponse : nodeResponses) { + nodeId = nodesResponse.getNode().getId(); + + // Convert StatNames into paths + Map> resultNodeStatsMap = nodesResponse.getStats() + .entrySet() + .stream() + .collect(Collectors.toMap(entry -> entry.getKey().getFullPath(), Map.Entry::getValue)); + + // Map each node id to its stats + results.put(nodeId, resultNodeStatsMap); + } + return results; + } + +} diff --git a/src/main/java/org/opensearch/searchrelevance/utils/ClusterUtil.java b/src/main/java/org/opensearch/searchrelevance/utils/ClusterUtil.java new file mode 100644 index 00000000..589224c6 --- /dev/null +++ b/src/main/java/org/opensearch/searchrelevance/utils/ClusterUtil.java @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.utils; + +import org.opensearch.Version; +import org.opensearch.cluster.service.ClusterService; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class ClusterUtil { + private ClusterService clusterService; + + /** + * Return minimal OpenSearch version based on all nodes currently discoverable in the cluster + * @return minimal installed OpenSearch version, default to Version.CURRENT which is typically the latest version + */ + public Version getClusterMinVersion() { + return this.clusterService.state().getNodes().getMinNodeVersion(); + } +} diff --git a/src/test/java/org/opensearch/searchrelevance/plugin/SearchRelevancePluginTests.java b/src/test/java/org/opensearch/searchrelevance/plugin/SearchRelevancePluginTests.java index 6776a208..67045bac 100644 --- a/src/test/java/org/opensearch/searchrelevance/plugin/SearchRelevancePluginTests.java +++ b/src/test/java/org/opensearch/searchrelevance/plugin/SearchRelevancePluginTests.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.when; import static org.opensearch.searchrelevance.common.PluginConstants.EXPERIMENT_INDEX; import static org.opensearch.searchrelevance.common.PluginConstants.JUDGMENT_CACHE_INDEX; +import static org.opensearch.searchrelevance.settings.SearchRelevanceSettings.SEARCH_RELEVANCE_STATS_ENABLED; import static org.opensearch.searchrelevance.settings.SearchRelevanceSettings.SEARCH_RELEVANCE_WORKBENCH_ENABLED; import java.util.Arrays; @@ -47,6 +48,7 @@ import org.opensearch.searchrelevance.indices.SearchRelevanceIndicesManager; import org.opensearch.searchrelevance.metrics.MetricsHelper; import org.opensearch.searchrelevance.ml.MLAccessor; +import org.opensearch.searchrelevance.stats.info.InfoStatsManager; import org.opensearch.searchrelevance.transport.experiment.DeleteExperimentAction; import org.opensearch.searchrelevance.transport.experiment.GetExperimentAction; import org.opensearch.searchrelevance.transport.experiment.PutExperimentAction; @@ -101,7 +103,8 @@ public class SearchRelevancePluginTests extends OpenSearchTestCase { EvaluationResultDao.class, JudgmentCacheDao.class, MLAccessor.class, - MetricsHelper.class + MetricsHelper.class, + InfoStatsManager.class ); @Override @@ -116,7 +119,7 @@ public void setUp() throws Exception { // Mock ClusterService when(clusterService.getClusterSettings()).thenReturn( - new ClusterSettings(settings, new HashSet<>(Arrays.asList(SEARCH_RELEVANCE_WORKBENCH_ENABLED))) + new ClusterSettings(settings, new HashSet<>(Arrays.asList(SEARCH_RELEVANCE_WORKBENCH_ENABLED, SEARCH_RELEVANCE_STATS_ENABLED))) ); plugin = new SearchRelevancePlugin(); } @@ -165,7 +168,7 @@ public void testIsAnSystemIndexPlugin() { } public void testTotalRestHandlers() { - assertEquals(13, plugin.getRestHandlers(Settings.EMPTY, null, null, null, null, null, null).size()); + assertEquals(14, plugin.getRestHandlers(Settings.EMPTY, null, null, null, null, null, null).size()); } public void testQuerySetTransportIsAdded() { @@ -193,9 +196,12 @@ public void testQuerySetTransportIsAdded() { public void testGetSettings() { List> settings = plugin.getSettings(); - Setting setting = settings.get(0); - assertEquals("plugins.search_relevance.workbench_enabled", setting.getKey()); - assertEquals(1, settings.size()); - assertEquals(false, setting.get(Settings.EMPTY)); + Setting setting0 = settings.get(0); + assertEquals("plugins.search_relevance.workbench_enabled", setting0.getKey()); + Setting setting1 = settings.get(1); + assertEquals("plugins.search_relevance.stats_enabled", setting1.getKey()); + assertEquals(2, settings.size()); + assertEquals(false, setting0.get(Settings.EMPTY)); + assertEquals(true, setting1.get(Settings.EMPTY)); } } diff --git a/src/test/java/org/opensearch/searchrelevance/rest/RestSearchRelevanceStatsActionTests.java b/src/test/java/org/opensearch/searchrelevance/rest/RestSearchRelevanceStatsActionTests.java new file mode 100644 index 00000000..eb861bfc --- /dev/null +++ b/src/test/java/org/opensearch/searchrelevance/rest/RestSearchRelevanceStatsActionTests.java @@ -0,0 +1,283 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.rest; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Before; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opensearch.Version; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.RestChannel; +import org.opensearch.rest.RestRequest; +import org.opensearch.searchrelevance.plugin.SearchRelevanceRestTestCase; +import org.opensearch.searchrelevance.settings.SearchRelevanceSettingsAccessor; +import org.opensearch.searchrelevance.stats.SearchRelevanceStatsInput; +import org.opensearch.searchrelevance.stats.events.EventStatName; +import org.opensearch.searchrelevance.stats.info.InfoStatName; +import org.opensearch.searchrelevance.transport.stats.SearchRelevanceStatsAction; +import org.opensearch.searchrelevance.transport.stats.SearchRelevanceStatsRequest; +import org.opensearch.searchrelevance.transport.stats.SearchRelevanceStatsResponse; +import org.opensearch.searchrelevance.utils.ClusterUtil; +import org.opensearch.test.rest.FakeRestRequest; +import org.opensearch.threadpool.TestThreadPool; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.client.node.NodeClient; + +public class RestSearchRelevanceStatsActionTests extends SearchRelevanceRestTestCase { + private NodeClient client; + private ThreadPool threadPool; + + @Mock + RestChannel channel; + + @Mock + private SearchRelevanceSettingsAccessor settingsAccessor; + + @Mock + private ClusterUtil clusterUtil; + + @Before + public void setup() { + MockitoAnnotations.openMocks(this); + + threadPool = new TestThreadPool(this.getClass().getSimpleName() + "ThreadPool"); + client = spy(new NodeClient(Settings.EMPTY, threadPool)); + + when(clusterUtil.getClusterMinVersion()).thenReturn(Version.CURRENT); + when(settingsAccessor.isStatsEnabled()).thenReturn(true); + when(settingsAccessor.isWorkbenchEnabled()).thenReturn(true); + + doAnswer(invocation -> { + ActionListener actionListener = invocation.getArgument(2); + return null; + }).when(client).execute(eq(SearchRelevanceStatsAction.INSTANCE), any(), any()); + } + + @Override + public void tearDown() throws Exception { + super.tearDown(); + threadPool.shutdown(); + client.close(); + } + + public void test_execute() throws Exception { + RestSearchRelevanceStatsAction restSearchRelevanceStatsAction = new RestSearchRelevanceStatsAction(settingsAccessor, clusterUtil); + + RestRequest request = getRestRequest(); + restSearchRelevanceStatsAction.handleRequest(request, channel, client); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(SearchRelevanceStatsRequest.class); + verify(client, times(1)).execute(eq(SearchRelevanceStatsAction.INSTANCE), argumentCaptor.capture(), any()); + + SearchRelevanceStatsInput capturedInput = argumentCaptor.getValue().getSearchRelevanceStatsInput(); + assertEquals(capturedInput.getEventStatNames(), EnumSet.allOf(EventStatName.class)); + assertEquals(capturedInput.getInfoStatNames(), EnumSet.allOf(InfoStatName.class)); + assertFalse(capturedInput.isFlatten()); + assertFalse(capturedInput.isIncludeMetadata()); + assertTrue(capturedInput.isIncludeIndividualNodes()); + assertTrue(capturedInput.isIncludeAllNodes()); + assertTrue(capturedInput.isIncludeInfo()); + } + + public void test_execute_customParams_includePartial() throws Exception { + when(settingsAccessor.isStatsEnabled()).thenReturn(true); + when(clusterUtil.getClusterMinVersion()).thenReturn(Version.CURRENT); + + RestSearchRelevanceStatsAction restSearchRelevanceStatsAction = new RestSearchRelevanceStatsAction(settingsAccessor, clusterUtil); + + Map params = Map.of( + RestSearchRelevanceStatsAction.FLATTEN_PARAM, + "true", + RestSearchRelevanceStatsAction.INCLUDE_METADATA_PARAM, + "true", + RestSearchRelevanceStatsAction.INCLUDE_INDIVIDUAL_NODES_PARAM, + "false", + RestSearchRelevanceStatsAction.INCLUDE_ALL_NODES_PARAM, + "true", + RestSearchRelevanceStatsAction.INCLUDE_INFO_PARAM, + "true" + ); + RestRequest request = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withParams(params).build(); + + restSearchRelevanceStatsAction.handleRequest(request, channel, client); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(SearchRelevanceStatsRequest.class); + verify(client, times(1)).execute(eq(SearchRelevanceStatsAction.INSTANCE), argumentCaptor.capture(), any()); + + SearchRelevanceStatsInput capturedInput = argumentCaptor.getValue().getSearchRelevanceStatsInput(); + + assertEquals(capturedInput.getEventStatNames(), EnumSet.allOf(EventStatName.class)); + assertEquals(capturedInput.getInfoStatNames(), EnumSet.allOf(InfoStatName.class)); + assertTrue(capturedInput.isFlatten()); + assertTrue(capturedInput.isIncludeMetadata()); + assertFalse(capturedInput.isIncludeIndividualNodes()); + assertTrue(capturedInput.isIncludeAllNodes()); + assertTrue(capturedInput.isIncludeInfo()); + } + + public void test_execute_customParams_includeNone() throws Exception { + when(settingsAccessor.isStatsEnabled()).thenReturn(true); + when(clusterUtil.getClusterMinVersion()).thenReturn(Version.CURRENT); + + RestSearchRelevanceStatsAction restSearchRelevanceStatsAction = new RestSearchRelevanceStatsAction(settingsAccessor, clusterUtil); + + Map params = new HashMap<>(); + params.put(RestSearchRelevanceStatsAction.FLATTEN_PARAM, "true"); + params.put(RestSearchRelevanceStatsAction.INCLUDE_METADATA_PARAM, "true"); + params.put(RestSearchRelevanceStatsAction.INCLUDE_INDIVIDUAL_NODES_PARAM, "false"); + params.put(RestSearchRelevanceStatsAction.INCLUDE_ALL_NODES_PARAM, "false"); + params.put(RestSearchRelevanceStatsAction.INCLUDE_INFO_PARAM, "false"); + RestRequest request = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withParams(params).build(); + + restSearchRelevanceStatsAction.handleRequest(request, channel, client); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(SearchRelevanceStatsRequest.class); + verify(client, times(1)).execute(eq(SearchRelevanceStatsAction.INSTANCE), argumentCaptor.capture(), any()); + + SearchRelevanceStatsInput capturedInput = argumentCaptor.getValue().getSearchRelevanceStatsInput(); + + // Since we we set individual nodes and all nodes to false, we shouldn't fetch any stats + assertEquals(capturedInput.getEventStatNames(), EnumSet.noneOf(EventStatName.class)); + assertEquals(capturedInput.getInfoStatNames(), EnumSet.noneOf(InfoStatName.class)); + assertTrue(capturedInput.isFlatten()); + assertTrue(capturedInput.isIncludeMetadata()); + assertFalse(capturedInput.isIncludeIndividualNodes()); + assertFalse(capturedInput.isIncludeAllNodes()); + assertFalse(capturedInput.isIncludeInfo()); + + } + + public void test_execute_olderVersion() throws Exception { + when(clusterUtil.getClusterMinVersion()).thenReturn(Version.V_3_0_0); + + RestSearchRelevanceStatsAction restSearchRelevanceStatsAction = new RestSearchRelevanceStatsAction(settingsAccessor, clusterUtil); + + RestRequest request = getRestRequest(); + restSearchRelevanceStatsAction.handleRequest(request, channel, client); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(SearchRelevanceStatsRequest.class); + verify(client, times(1)).execute(eq(SearchRelevanceStatsAction.INSTANCE), argumentCaptor.capture(), any()); + + SearchRelevanceStatsInput capturedInput = argumentCaptor.getValue().getSearchRelevanceStatsInput(); + assertEquals(capturedInput.getEventStatNames(), EnumSet.noneOf(EventStatName.class)); + assertEquals(capturedInput.getInfoStatNames(), EnumSet.noneOf(InfoStatName.class)); + } + + public void test_handleRequest_disabledForbidden() throws Exception { + when(settingsAccessor.isStatsEnabled()).thenReturn(false); + + RestSearchRelevanceStatsAction restSearchRelevanceStatsAction = new RestSearchRelevanceStatsAction(settingsAccessor, clusterUtil); + + RestRequest request = getRestRequest(); + restSearchRelevanceStatsAction.handleRequest(request, channel, client); + + verify(client, never()).execute(eq(SearchRelevanceStatsAction.INSTANCE), any(), any()); + + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(BytesRestResponse.class); + verify(channel).sendResponse(responseCaptor.capture()); + + BytesRestResponse response = responseCaptor.getValue(); + assertEquals(RestStatus.FORBIDDEN, response.status()); + } + + public void test_execute_statParameters() throws Exception { + RestSearchRelevanceStatsAction restSearchRelevanceStatsAction = new RestSearchRelevanceStatsAction(settingsAccessor, clusterUtil); + + // Create request with stats not existing on 3.0.0 + Map params = new HashMap<>(); + params.put( + "stat", + String.join( + ",", + EventStatName.LLM_JUDGMENT_RATING_GENERATIONS.getNameString(), + EventStatName.UBI_JUDGMENT_RATING_GENERATIONS.getNameString() + ) + ); + params.put("include_metadata", "true"); + params.put("flat_stat_paths", "true"); + RestRequest request = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withParams(params).build(); + + restSearchRelevanceStatsAction.handleRequest(request, channel, client); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(SearchRelevanceStatsRequest.class); + verify(client, times(1)).execute(eq(SearchRelevanceStatsAction.INSTANCE), argumentCaptor.capture(), any()); + + SearchRelevanceStatsInput capturedInput = argumentCaptor.getValue().getSearchRelevanceStatsInput(); + assertEquals( + capturedInput.getEventStatNames(), + EnumSet.of(EventStatName.LLM_JUDGMENT_RATING_GENERATIONS, EventStatName.UBI_JUDGMENT_RATING_GENERATIONS) + ); + assertEquals(capturedInput.getInfoStatNames(), EnumSet.noneOf(InfoStatName.class)); + assertTrue(capturedInput.isFlatten()); + assertTrue(capturedInput.isIncludeMetadata()); + } + + public void test_execute_statParameters_olderVersion() throws Exception { + when(clusterUtil.getClusterMinVersion()).thenReturn(Version.V_3_0_0); + + RestSearchRelevanceStatsAction restSearchRelevanceStatsAction = new RestSearchRelevanceStatsAction(settingsAccessor, clusterUtil); + + // Create request with stats not existing on 3.0.0 + Map params = new HashMap<>(); + params.put( + "stat", + String.join( + ",", + EventStatName.LLM_JUDGMENT_RATING_GENERATIONS.getNameString(), + EventStatName.UBI_JUDGMENT_RATING_GENERATIONS.getNameString(), + EventStatName.IMPORT_JUDGMENT_RATING_GENERATIONS.getNameString() + ) + ); + RestRequest request = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withParams(params).build(); + + restSearchRelevanceStatsAction.handleRequest(request, channel, client); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(SearchRelevanceStatsRequest.class); + verify(client, times(1)).execute(eq(SearchRelevanceStatsAction.INSTANCE), argumentCaptor.capture(), any()); + + SearchRelevanceStatsInput capturedInput = argumentCaptor.getValue().getSearchRelevanceStatsInput(); + assertEquals(capturedInput.getEventStatNames(), EnumSet.noneOf(EventStatName.class)); + assertEquals(capturedInput.getInfoStatNames(), EnumSet.noneOf(InfoStatName.class)); + } + + public void test_handleRequest_invalidStatParameter() throws Exception { + RestSearchRelevanceStatsAction restSearchRelevanceStatsAction = new RestSearchRelevanceStatsAction(settingsAccessor, clusterUtil); + + // Create request with invalid stat parameter + Map params = new HashMap<>(); + params.put("stat", "INVALID_STAT"); + RestRequest request = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withParams(params).build(); + + assertThrows(IllegalArgumentException.class, () -> restSearchRelevanceStatsAction.handleRequest(request, channel, client)); + + verify(client, never()).execute(eq(SearchRelevanceStatsAction.INSTANCE), any(), any()); + } + + private RestRequest getRestRequest() { + Map params = new HashMap<>(); + return new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withParams(params).build(); + } +} diff --git a/src/test/java/org/opensearch/searchrelevance/stats/SearchRelevanceStatsInputTests.java b/src/test/java/org/opensearch/searchrelevance/stats/SearchRelevanceStatsInputTests.java new file mode 100644 index 00000000..2ebbcc2c --- /dev/null +++ b/src/test/java/org/opensearch/searchrelevance/stats/SearchRelevanceStatsInputTests.java @@ -0,0 +1,175 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.opensearch.searchrelevance.util.TestUtils.xContentBuilderToMap; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; + +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.searchrelevance.rest.RestSearchRelevanceStatsAction; +import org.opensearch.searchrelevance.stats.events.EventStatName; +import org.opensearch.searchrelevance.stats.info.InfoStatName; +import org.opensearch.test.OpenSearchTestCase; + +public class SearchRelevanceStatsInputTests extends OpenSearchTestCase { + private static final String NODE_ID_1 = "node1"; + private static final String NODE_ID_2 = "node2"; + private static final EventStatName EVENT_STAT = EventStatName.LLM_JUDGMENT_RATING_GENERATIONS; + private static final InfoStatName INFO_STAT = InfoStatName.CLUSTER_VERSION; + + public void test_defaultConstructorEmpty() { + SearchRelevanceStatsInput input = new SearchRelevanceStatsInput(); + + assertTrue(input.getNodeIds().isEmpty()); + assertTrue(input.getEventStatNames().isEmpty()); + assertTrue(input.getInfoStatNames().isEmpty()); + assertFalse(input.isIncludeMetadata()); + assertFalse(input.isFlatten()); + assertTrue(input.isIncludeIndividualNodes()); + assertTrue(input.isIncludeAllNodes()); + assertTrue(input.isIncludeInfo()); + } + + public void test_builderWithAllFields() { + List nodeIds = Arrays.asList(NODE_ID_1, NODE_ID_2); + EnumSet eventStats = EnumSet.of(EVENT_STAT); + EnumSet infoStats = EnumSet.of(INFO_STAT); + + SearchRelevanceStatsInput input = SearchRelevanceStatsInput.builder() + .nodeIds(nodeIds) + .eventStatNames(eventStats) + .infoStatNames(infoStats) + .includeMetadata(true) + .flatten(true) + .includeIndividualNodes(false) + .includeAllNodes(false) + .includeInfo(false) + .build(); + + assertEquals(nodeIds, input.getNodeIds()); + assertEquals(eventStats, input.getEventStatNames()); + assertEquals(infoStats, input.getInfoStatNames()); + assertTrue(input.isIncludeMetadata()); + assertTrue(input.isFlatten()); + } + + public void test_streamInput() throws IOException { + StreamInput mockInput = mock(StreamInput.class); + + // Have to return the readByte since readBoolean can't be mocked + when(mockInput.readByte()).thenReturn((byte) 1) // true for includeMetadata + .thenReturn((byte) 1) // true for flatten + .thenReturn((byte) 0) // false for includeIndividualNodes + .thenReturn((byte) 0) // false for includeAllNodes + .thenReturn((byte) 0); // false for includeInfo + + when(mockInput.readOptionalStringList()).thenReturn(Arrays.asList(NODE_ID_1, NODE_ID_2)); + when(mockInput.readOptionalEnumSet(EventStatName.class)).thenReturn(EnumSet.of(EVENT_STAT)); + when(mockInput.readOptionalEnumSet(InfoStatName.class)).thenReturn(EnumSet.of(INFO_STAT)); + + SearchRelevanceStatsInput input = new SearchRelevanceStatsInput(mockInput); + + assertEquals(Arrays.asList(NODE_ID_1, NODE_ID_2), input.getNodeIds()); + assertEquals(EnumSet.of(EVENT_STAT), input.getEventStatNames()); + assertEquals(EnumSet.of(INFO_STAT), input.getInfoStatNames()); + assertTrue(input.isIncludeMetadata()); + assertTrue(input.isFlatten()); + assertFalse(input.isIncludeIndividualNodes()); + assertFalse(input.isIncludeAllNodes()); + assertFalse(input.isIncludeInfo()); + + verify(mockInput, times(5)).readByte(); + verify(mockInput, times(1)).readOptionalStringList(); + verify(mockInput, times(2)).readOptionalEnumSet(any()); + } + + public void test_writeToOutputs() throws IOException { + List nodeIds = Arrays.asList(NODE_ID_1, NODE_ID_2); + EnumSet eventStats = EnumSet.of(EVENT_STAT); + EnumSet infoStats = EnumSet.of(INFO_STAT); + + SearchRelevanceStatsInput input = SearchRelevanceStatsInput.builder() + .nodeIds(nodeIds) + .eventStatNames(eventStats) + .infoStatNames(infoStats) + .includeMetadata(true) + .flatten(true) + .includeIndividualNodes(false) + .includeAllNodes(false) + .includeInfo(false) + .build(); + + StreamOutput mockOutput = mock(StreamOutput.class); + input.writeTo(mockOutput); + + verify(mockOutput).writeOptionalStringCollection(nodeIds); + verify(mockOutput).writeOptionalEnumSet(eventStats); + verify(mockOutput).writeOptionalEnumSet(infoStats); + + verify(mockOutput, times(2)).writeBoolean(true); + verify(mockOutput, times(3)).writeBoolean(false); + } + + public void test_toXContent() throws IOException { + List nodeIds = Arrays.asList(NODE_ID_1); + EnumSet eventStats = EnumSet.of(EVENT_STAT); + EnumSet infoStats = EnumSet.of(INFO_STAT); + + SearchRelevanceStatsInput input = SearchRelevanceStatsInput.builder() + .nodeIds(nodeIds) + .eventStatNames(eventStats) + .infoStatNames(infoStats) + .includeMetadata(true) + .flatten(true) + .includeIndividualNodes(false) + .includeAllNodes(false) + .includeInfo(false) + .build(); + + XContentBuilder builder = XContentFactory.jsonBuilder(); + input.toXContent(builder, ToXContent.EMPTY_PARAMS); + Map responseMap = xContentBuilderToMap(builder); + + assertEquals(Collections.singletonList(NODE_ID_1), responseMap.get("node_ids")); + assertEquals(Collections.singletonList(EVENT_STAT.getNameString()), responseMap.get("event_stats")); + assertEquals(Collections.singletonList(INFO_STAT.getNameString()), responseMap.get("state_stats")); + assertEquals(true, responseMap.get(RestSearchRelevanceStatsAction.INCLUDE_METADATA_PARAM)); + assertEquals(true, responseMap.get(RestSearchRelevanceStatsAction.FLATTEN_PARAM)); + assertEquals(false, responseMap.get(RestSearchRelevanceStatsAction.INCLUDE_INDIVIDUAL_NODES_PARAM)); + assertEquals(false, responseMap.get(RestSearchRelevanceStatsAction.INCLUDE_ALL_NODES_PARAM)); + assertEquals(false, responseMap.get(RestSearchRelevanceStatsAction.INCLUDE_INFO_PARAM)); + } + + public void test_writeToHandlesEmptyCollections() throws IOException { + SearchRelevanceStatsInput input = new SearchRelevanceStatsInput(); + StreamOutput mockOutput = mock(StreamOutput.class); + + input.writeTo(mockOutput); + + verify(mockOutput).writeOptionalStringCollection(any(List.class)); + verify(mockOutput, times(2)).writeOptionalEnumSet(any(EnumSet.class)); + + verify(mockOutput, times(2)).writeBoolean(false); + verify(mockOutput, times(3)).writeBoolean(true); + } +} diff --git a/src/test/java/org/opensearch/searchrelevance/stats/events/EventStatNameTests.java b/src/test/java/org/opensearch/searchrelevance/stats/events/EventStatNameTests.java new file mode 100644 index 00000000..5d1770f4 --- /dev/null +++ b/src/test/java/org/opensearch/searchrelevance/stats/events/EventStatNameTests.java @@ -0,0 +1,105 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.events; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.opensearch.searchrelevance.rest.RestSearchRelevanceStatsAction; +import org.opensearch.test.OpenSearchTestCase; + +public class EventStatNameTests extends OpenSearchTestCase { + public static final EnumSet EVENT_STATS = EnumSet.allOf(EventStatName.class); + + public void test_fromValid() { + String validStatName = EventStatName.LLM_JUDGMENT_RATING_GENERATIONS.getNameString(); + EventStatName result = EventStatName.from(validStatName); + assertEquals(EventStatName.LLM_JUDGMENT_RATING_GENERATIONS, result); + } + + public void test_fromInvalid() { + assertThrows(IllegalArgumentException.class, () -> { EventStatName.from("non_existent_stat"); }); + } + + public void test_allEnumsHaveNonNullStats() { + for (EventStatName statName : EVENT_STATS) { + assertNotNull(statName.getEventStat()); + } + } + + public void test_validNames() { + Set names = new HashSet<>(); + for (EventStatName statName : EVENT_STATS) { + String name = statName.getNameString().toLowerCase(Locale.ROOT); + assertFalse(String.format(Locale.ROOT, "Checking name uniqueness for %s", name), names.contains(name)); + assertTrue(RestSearchRelevanceStatsAction.isValidParamString(name)); + names.add(name); + } + } + + public void test_uniquePaths() { + Set paths = new HashSet<>(); + + // First pass to add all base paths (excluding stat names) to avoid colliding a stat name with a terminal path + // e.g. if a.b is a stat, a.b.c cannot be a stat. + for (EventStatName statName : EVENT_STATS) { + String path = statName.getPath().toLowerCase(Locale.ROOT); + paths.add(path); + } + + // Check possible path collisions + // i.e. a full path is a terminal path that should not have any children + for (EventStatName statName : EVENT_STATS) { + String path = statName.getFullPath().toLowerCase(Locale.ROOT); + assertFalse(String.format(Locale.ROOT, "Checking full path uniqueness for %s", path), paths.contains(path)); + paths.add(path); + } + } + + /** + * Tests if there are any path prefix collisions + * i.e. every full stat path should be terminal. + * There should be no other paths that start with another full stat path + */ + public void test_noPathCollisions() { + // Convert paths to list and sort them + List sortedPaths = new ArrayList<>(); + for (EventStatName stat : EVENT_STATS) { + sortedPaths.add(stat.getFullPath().toLowerCase(Locale.ROOT)); + } + sortedPaths.sort(String::compareTo); + + // Check adjacent paths for collisions + // When sorted alphabetically, we can reduce the number of path collision comparisons + for (int i = 0; i < sortedPaths.size() - 1; i++) { + String currentPath = sortedPaths.get(i); + String nextPath = sortedPaths.get(i + 1); + + // Check for prefix collision + assertFalse( + String.format(Locale.ROOT, "Path collision found: %s is a prefix of %s", currentPath, nextPath), + isPathPrefixOf(currentPath, nextPath) + ); + } + } + + private boolean isPathPrefixOf(String path1, String path2) { + if (path2.startsWith(path1)) { + if (path1.length() == path2.length()) { + return false; + } + return path2.charAt(path1.length()) == '.'; + } + return false; + } + +} diff --git a/src/test/java/org/opensearch/searchrelevance/stats/events/EventStatsManagerTests.java b/src/test/java/org/opensearch/searchrelevance/stats/events/EventStatsManagerTests.java new file mode 100644 index 00000000..bb5fa206 --- /dev/null +++ b/src/test/java/org/opensearch/searchrelevance/stats/events/EventStatsManagerTests.java @@ -0,0 +1,103 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.events; + +import static org.mockito.Mockito.when; + +import java.util.EnumSet; +import java.util.Map; + +import org.junit.Before; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opensearch.searchrelevance.settings.SearchRelevanceSettingsAccessor; +import org.opensearch.test.OpenSearchTestCase; + +public class EventStatsManagerTests extends OpenSearchTestCase { + private static final EventStatName STAT_NAME = EventStatName.LLM_JUDGMENT_RATING_GENERATIONS; + + @Mock + private SearchRelevanceSettingsAccessor mockSettingsAccessor; + + @Mock + private TimestampedEventStat mockEventStat; + + private EventStatsManager eventStatsManager; + + @Before + public void setup() { + MockitoAnnotations.openMocks(this); + eventStatsManager = new EventStatsManager(); + eventStatsManager.initialize(mockSettingsAccessor); + } + + public void test_increment() { + when(mockSettingsAccessor.isStatsEnabled()).thenReturn(true); + + EventStat originalStat = STAT_NAME.getEventStat(); + long originalValue = originalStat.getValue(); + + eventStatsManager.inc(STAT_NAME); + + long newValue = originalStat.getValue(); + assertEquals(originalValue + 1, newValue); + } + + public void test_incrementWhenStatsDisabled() { + when(mockSettingsAccessor.isStatsEnabled()).thenReturn(false); + + EventStat originalStat = STAT_NAME.getEventStat(); + long originalValue = originalStat.getValue(); + + eventStatsManager.inc(STAT_NAME); + + long newValue = originalStat.getValue(); + assertEquals(originalValue, newValue); + } + + public void test_getTimestampedEventStatSnapshots() { + Map result = eventStatsManager.getTimestampedEventStatSnapshots(EnumSet.of(STAT_NAME)); + + assertEquals(1, result.size()); + assertNotNull(result.get(STAT_NAME)); + } + + public void test_getTimestampedEventStatSnapshotsReturnsEmptyMap() { + Map result = eventStatsManager.getTimestampedEventStatSnapshots( + EnumSet.noneOf(EventStatName.class) + ); + + assertTrue(result.isEmpty()); + } + + public void test_reset() { + when(mockSettingsAccessor.isStatsEnabled()).thenReturn(true); + EventStat originalStat = STAT_NAME.getEventStat(); + long originalValue = originalStat.getValue(); + eventStatsManager.inc(STAT_NAME); + eventStatsManager.inc(STAT_NAME); + eventStatsManager.inc(STAT_NAME); + + long newValue = originalStat.getValue(); + assertEquals(originalValue + 3, newValue); + + eventStatsManager.reset(); + + newValue = originalStat.getValue(); + assertEquals(newValue, 0); + } + + public void test_singletonInstanceCreation() { + // Test that multiple calls return same instance + EventStatsManager instance1 = EventStatsManager.instance(); + EventStatsManager instance2 = EventStatsManager.instance(); + + assertNotNull(instance1); + assertSame(instance1, instance2); + } +} diff --git a/src/test/java/org/opensearch/searchrelevance/stats/events/TimestampedEventStatSnapshotTests.java b/src/test/java/org/opensearch/searchrelevance/stats/events/TimestampedEventStatSnapshotTests.java new file mode 100644 index 00000000..e817de58 --- /dev/null +++ b/src/test/java/org/opensearch/searchrelevance/stats/events/TimestampedEventStatSnapshotTests.java @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.events; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.opensearch.searchrelevance.util.TestUtils.xContentBuilderToMap; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.test.OpenSearchTestCase; + +public class TimestampedEventStatSnapshotTests extends OpenSearchTestCase { + private static final EventStatName STAT_NAME = EventStatName.LLM_JUDGMENT_RATING_GENERATIONS; + + public void test_constructorAndGetters() { + TimestampedEventStatSnapshot snapshot = new TimestampedEventStatSnapshot(STAT_NAME, 100L, 50L, 10L); + + assertEquals(STAT_NAME, snapshot.getStatName()); + assertEquals(100L, snapshot.getValue().longValue()); + assertEquals(50L, snapshot.getTrailingIntervalValue()); + assertEquals(10L, snapshot.getMinutesSinceLastEvent()); + } + + public void test_streamConstructor() throws IOException { + StreamInput mockInput = mock(StreamInput.class); + when(mockInput.readEnum(EventStatName.class)).thenReturn(STAT_NAME); + when(mockInput.readLong()).thenReturn(100L, 50L, 10L); + + TimestampedEventStatSnapshot snapshot = new TimestampedEventStatSnapshot(mockInput); + + assertEquals(STAT_NAME, snapshot.getStatName()); + assertEquals(100L, snapshot.getValue().longValue()); + assertEquals(50L, snapshot.getTrailingIntervalValue()); + assertEquals(10L, snapshot.getMinutesSinceLastEvent()); + + verify(mockInput, times(1)).readEnum(EventStatName.class); + verify(mockInput, times(3)).readLong(); + } + + public void test_writeToOutputs() throws IOException { + TimestampedEventStatSnapshot snapshot = new TimestampedEventStatSnapshot(STAT_NAME, 100L, 50L, 10L); + + StreamOutput mockOutput = mock(StreamOutput.class); + snapshot.writeTo(mockOutput); + + verify(mockOutput).writeEnum(STAT_NAME); + verify(mockOutput).writeLong(100L); + verify(mockOutput).writeLong(50L); + verify(mockOutput).writeLong(10L); + } + + public void test_aggregateEventStatSnapshots() { + TimestampedEventStatSnapshot snapshot1 = new TimestampedEventStatSnapshot(STAT_NAME, 100L, 50L, 10L); + TimestampedEventStatSnapshot snapshot2 = new TimestampedEventStatSnapshot(STAT_NAME, 200L, 100L, 5L); + + TimestampedEventStatSnapshot aggregatedSnapshot = TimestampedEventStatSnapshot.aggregateEventStatSnapshots( + Arrays.asList(snapshot1, snapshot2) + ); + + assertEquals(STAT_NAME, aggregatedSnapshot.getStatName()); + assertEquals(300L, aggregatedSnapshot.getValue().longValue()); + assertEquals(150L, aggregatedSnapshot.getTrailingIntervalValue()); + assertEquals(5L, aggregatedSnapshot.getMinutesSinceLastEvent()); + } + + public void test_aggregateEventStatSnapshotsReturnsNull() { + assertNull(TimestampedEventStatSnapshot.aggregateEventStatSnapshots(Collections.emptyList())); + } + + public void test_aggregateEventStatDataThrowsException() { + TimestampedEventStatSnapshot snapshot1 = new TimestampedEventStatSnapshot(STAT_NAME, 100L, 50L, 10L); + TimestampedEventStatSnapshot snapshot2 = new TimestampedEventStatSnapshot(null, 200L, 100L, 5L); + + assertThrows( + IllegalArgumentException.class, + () -> TimestampedEventStatSnapshot.aggregateEventStatSnapshots(Arrays.asList(snapshot1, snapshot2)) + ); + } + + public void test_toXContent() throws IOException { + XContentBuilder builder = JsonXContent.contentBuilder(); + TimestampedEventStatSnapshot snapshot = new TimestampedEventStatSnapshot(STAT_NAME, 100L, 50L, 10L); + + snapshot.toXContent(builder, null); + + Map responseMap = xContentBuilderToMap(builder); + + assertEquals(100, responseMap.get("value")); + assertEquals(50, responseMap.get("trailing_interval_value")); + assertEquals(10, responseMap.get("minutes_since_last_event")); + assertEquals(STAT_NAME.getStatType().getTypeString(), responseMap.get("stat_type")); + } +} diff --git a/src/test/java/org/opensearch/searchrelevance/stats/events/TimestampedEventStatTests.java b/src/test/java/org/opensearch/searchrelevance/stats/events/TimestampedEventStatTests.java new file mode 100644 index 00000000..9e775ae7 --- /dev/null +++ b/src/test/java/org/opensearch/searchrelevance/stats/events/TimestampedEventStatTests.java @@ -0,0 +1,135 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.events; + +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; + +import java.util.concurrent.TimeUnit; + +import org.junit.Before; +import org.mockito.Spy; +import org.opensearch.test.OpenSearchTestCase; + +public class TimestampedEventStatTests extends OpenSearchTestCase { + private static final long BUCKET_INTERVAL_MS = 60 * 1000; // 60 seconds + private static final EventStatName STAT_NAME = EventStatName.LLM_JUDGMENT_RATING_GENERATIONS; + + @Spy + private TimestampedEventStat stat; + + private long currentTime; + + @Before + public void setup() { + stat = spy(new TimestampedEventStat(STAT_NAME)); + currentTime = System.currentTimeMillis(); + doAnswer(inv -> currentTime).when(stat).getCurrentTimeInMillis(); + } + + public void test_initialization() { + assertEquals(0, stat.getValue()); + assertEquals(0, stat.getTrailingIntervalValue()); + assertNotEquals(0, stat.getMinutesSinceLastEvent()); + } + + public void test_basicIncrement() { + stat.increment(); + assertEquals(1, stat.getValue()); + + stat.increment(); + assertEquals(2, stat.getValue()); + } + + public void test_trailingIntervalSingleBucket() { + // Add events in same bucket + for (int i = 0; i < 5; i++) { + stat.increment(); + } + + // Should not count current bucket + assertEquals(0, stat.getTrailingIntervalValue()); + + // Move to next bucket + currentTime += BUCKET_INTERVAL_MS; + assertEquals(5, stat.getTrailingIntervalValue()); + } + + public void test_trailingIntervalMultipleBuckets() { + // Add events across multiple buckets + stat.increment(); // Bucket 1 + currentTime += BUCKET_INTERVAL_MS; + + stat.increment(); // Bucket 2 + stat.increment(); + currentTime += BUCKET_INTERVAL_MS; + + stat.increment(); // Bucket 3 + currentTime += BUCKET_INTERVAL_MS; + + assertEquals(4, stat.getValue()); + assertEquals(4, stat.getTrailingIntervalValue()); + } + + public void test_bucketRotation() { + // Fill buckets across 10 minutes + for (int i = 0; i < 10; i++) { + stat.increment(); + currentTime += BUCKET_INTERVAL_MS; + } + + // Should drop oldest buckets + assertEquals(10, stat.getValue()); + assertEquals(5, stat.getTrailingIntervalValue()); + } + + public void test_minutesSinceLastEvent() { + stat.increment(); + assertEquals(0, stat.getMinutesSinceLastEvent()); + + currentTime += TimeUnit.MINUTES.toMillis(5); + assertEquals(5, stat.getMinutesSinceLastEvent()); + } + + public void test_reset() { + stat.increment(); + stat.increment(); + currentTime += BUCKET_INTERVAL_MS; + stat.increment(); + + stat.reset(); + + assertEquals(0, stat.getValue()); + assertEquals(0, stat.getTrailingIntervalValue()); + assertNotEquals(0, stat.getMinutesSinceLastEvent()); + } + + public void test_eventStatSnapshot() { + stat.increment(); + currentTime += BUCKET_INTERVAL_MS * 2; + stat.increment(); + stat.increment(); + currentTime += BUCKET_INTERVAL_MS * 2; + + TimestampedEventStatSnapshot snapshot = stat.getStatSnapshot(); + assertEquals(3, snapshot.getValue().longValue()); + assertEquals(3, stat.getTrailingIntervalValue()); + assertEquals(2, snapshot.getMinutesSinceLastEvent()); + } + + public void test_longTimeGap() { + stat.increment(); + stat.increment(); + + // Simulate a very long time gap + currentTime += TimeUnit.DAYS.toMillis(1); + + assertEquals(0, stat.getTrailingIntervalValue()); + assertEquals(24 * 60, stat.getMinutesSinceLastEvent()); + } +} diff --git a/src/test/java/org/opensearch/searchrelevance/stats/info/CountableInfoStatSnapshotTests.java b/src/test/java/org/opensearch/searchrelevance/stats/info/CountableInfoStatSnapshotTests.java new file mode 100644 index 00000000..288da189 --- /dev/null +++ b/src/test/java/org/opensearch/searchrelevance/stats/info/CountableInfoStatSnapshotTests.java @@ -0,0 +1,45 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.info; + +import static org.opensearch.searchrelevance.util.TestUtils.xContentBuilderToMap; + +import java.io.IOException; +import java.util.Map; + +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.searchrelevance.stats.common.StatSnapshot; +import org.opensearch.test.OpenSearchTestCase; + +public class CountableInfoStatSnapshotTests extends OpenSearchTestCase { + private static final InfoStatName STAT_NAME = InfoStatName.CLUSTER_VERSION; + + public void test_increment() { + CountableInfoStatSnapshot snapshot = new CountableInfoStatSnapshot(STAT_NAME); + assertEquals(0L, snapshot.getValue().longValue()); + snapshot.incrementBy(5L); + assertEquals(5L, snapshot.getValue().longValue()); + snapshot.incrementBy(3L); + assertEquals(8L, snapshot.getValue().longValue()); + } + + public void test_toXContent() throws IOException { + CountableInfoStatSnapshot snapshot = new CountableInfoStatSnapshot(STAT_NAME); + snapshot.incrementBy(8675309L); + + XContentBuilder builder = JsonXContent.contentBuilder(); + snapshot.toXContent(builder, ToXContent.EMPTY_PARAMS); + + Map responseMap = xContentBuilderToMap(builder); + + assertEquals(8675309, responseMap.get(StatSnapshot.VALUE_FIELD)); + assertEquals(STAT_NAME.getStatType().getTypeString(), responseMap.get(StatSnapshot.STAT_TYPE_FIELD)); + } +} diff --git a/src/test/java/org/opensearch/searchrelevance/stats/info/InfoStatNameTests.java b/src/test/java/org/opensearch/searchrelevance/stats/info/InfoStatNameTests.java new file mode 100644 index 00000000..fd18e7f3 --- /dev/null +++ b/src/test/java/org/opensearch/searchrelevance/stats/info/InfoStatNameTests.java @@ -0,0 +1,99 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.info; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.opensearch.searchrelevance.rest.RestSearchRelevanceStatsAction; +import org.opensearch.test.OpenSearchTestCase; + +public class InfoStatNameTests extends OpenSearchTestCase { + public static final EnumSet INFO_STATS = EnumSet.allOf(InfoStatName.class); + + public void test_fromValid() { + String validStatName = InfoStatName.CLUSTER_VERSION.getNameString(); + InfoStatName result = InfoStatName.from(validStatName); + assertEquals(InfoStatName.CLUSTER_VERSION, result); + } + + public void test_fromInvalid() { + assertThrows(IllegalArgumentException.class, () -> { InfoStatName.from("non_existent_stat"); }); + } + + public void test_validNames() { + Set names = new HashSet<>(); + for (InfoStatName statName : INFO_STATS) { + String name = statName.getNameString().toLowerCase(Locale.ROOT); + assertFalse(String.format(Locale.ROOT, "Checking name uniqueness for %s", name), names.contains(name)); + assertTrue(RestSearchRelevanceStatsAction.isValidParamString(name)); + names.add(name); + } + } + + public void test_uniquePaths() { + Set paths = new HashSet<>(); + + // First pass to add all base paths (excluding stat names) to avoid colliding a stat name with a terminal path + // e.g. if a.b is a stat, a.b.c cannot be a stat. + for (InfoStatName statName : INFO_STATS) { + String path = statName.getPath().toLowerCase(Locale.ROOT); + paths.add(path); + } + + // Check possible path collisions + // i.e. a full path is a terminal path that should not have any children + for (InfoStatName statName : INFO_STATS) { + String path = statName.getFullPath().toLowerCase(Locale.ROOT); + assertFalse(String.format(Locale.ROOT, "Checking full path uniqueness for %s", path), paths.contains(path)); + paths.add(path); + } + } + + /** + * Tests if there are any path prefix collisions + * i.e. every full stat path should be terminal. + * There should be no other paths that start with another full stat path + */ + public void test_noPathCollisions() { + // Convert paths to list and sort them + List sortedPaths = new ArrayList<>(); + for (InfoStatName stat : INFO_STATS) { + sortedPaths.add(stat.getFullPath().toLowerCase(Locale.ROOT)); + } + sortedPaths.sort(String::compareTo); + + // Check adjacent paths for collisions + // When sorted alphabetically, we can reduce the number of path collision comparisons + for (int i = 0; i < sortedPaths.size() - 1; i++) { + String currentPath = sortedPaths.get(i); + String nextPath = sortedPaths.get(i + 1); + + // Check for prefix collision + assertFalse( + String.format(Locale.ROOT, "Path collision found: %s is a prefix of %s", currentPath, nextPath), + isPathPrefixOf(currentPath, nextPath) + ); + } + } + + private boolean isPathPrefixOf(String path1, String path2) { + if (path2.startsWith(path1)) { + if (path1.length() == path2.length()) { + return false; + } + return path2.charAt(path1.length()) == '.'; + } + return false; + } + +} diff --git a/src/test/java/org/opensearch/searchrelevance/stats/info/InfoStatsManagerTests.java b/src/test/java/org/opensearch/searchrelevance/stats/info/InfoStatsManagerTests.java new file mode 100644 index 00000000..1f2f8e0a --- /dev/null +++ b/src/test/java/org/opensearch/searchrelevance/stats/info/InfoStatsManagerTests.java @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.info; + +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; + +import org.junit.Before; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opensearch.searchrelevance.settings.SearchRelevanceSettingsAccessor; +import org.opensearch.searchrelevance.stats.common.StatSnapshot; +import org.opensearch.test.OpenSearchTestCase; + +public class InfoStatsManagerTests extends OpenSearchTestCase { + @Mock + private SearchRelevanceSettingsAccessor mockSettingsAccessor; + + private InfoStatsManager infoStatsManager; + + @Before + public void setup() { + MockitoAnnotations.openMocks(this); + infoStatsManager = new InfoStatsManager(mockSettingsAccessor); + } + + public void test_getStats_returnsAllStats() { + Map> stats = infoStatsManager.getStats(EnumSet.allOf(InfoStatName.class)); + Set allStatNames = EnumSet.allOf(InfoStatName.class); + + assertEquals(allStatNames, stats.keySet()); + } + + public void test_getStats_returnsFilteredStats() { + Map> stats = infoStatsManager.getStats(EnumSet.of(InfoStatName.CLUSTER_VERSION)); + + assertEquals(1, stats.size()); + assertTrue(stats.containsKey(InfoStatName.CLUSTER_VERSION)); + assertNotNull(((SettableInfoStatSnapshot) stats.get(InfoStatName.CLUSTER_VERSION)).getValue()); + } +} diff --git a/src/test/java/org/opensearch/searchrelevance/stats/info/SettableInfoStatSnapshotTests.java b/src/test/java/org/opensearch/searchrelevance/stats/info/SettableInfoStatSnapshotTests.java new file mode 100644 index 00000000..6d63bc4d --- /dev/null +++ b/src/test/java/org/opensearch/searchrelevance/stats/info/SettableInfoStatSnapshotTests.java @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.stats.info; + +import static org.opensearch.searchrelevance.util.TestUtils.xContentBuilderToMap; + +import java.io.IOException; +import java.util.Map; + +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.searchrelevance.stats.common.StatSnapshot; +import org.opensearch.test.OpenSearchTestCase; + +public class SettableInfoStatSnapshotTests extends OpenSearchTestCase { + + private static final InfoStatName STAT_NAME = InfoStatName.CLUSTER_VERSION; + private static final String SETTABLE_VALUE = "test-value"; + + public void test_constructorWithoutValue() { + SettableInfoStatSnapshot snapshot = new SettableInfoStatSnapshot<>(STAT_NAME); + assertNull(snapshot.getValue()); + } + + public void test_constructorWithValue() { + SettableInfoStatSnapshot snapshot = new SettableInfoStatSnapshot<>(STAT_NAME, SETTABLE_VALUE); + assertEquals(SETTABLE_VALUE, snapshot.getValue()); + } + + public void test_setValueUpdates() { + SettableInfoStatSnapshot snapshot = new SettableInfoStatSnapshot<>(STAT_NAME); + snapshot.setValue("new-value"); + assertEquals("new-value", snapshot.getValue()); + } + + public void test_toXContent() throws IOException { + SettableInfoStatSnapshot snapshot = new SettableInfoStatSnapshot<>(STAT_NAME, SETTABLE_VALUE); + XContentBuilder builder = JsonXContent.contentBuilder(); + snapshot.toXContent(builder, ToXContent.EMPTY_PARAMS); + + Map responseMap = xContentBuilderToMap(builder); + + assertEquals(SETTABLE_VALUE, responseMap.get(StatSnapshot.VALUE_FIELD)); + assertEquals(STAT_NAME.getStatType().getTypeString(), responseMap.get(StatSnapshot.STAT_TYPE_FIELD)); + } +} diff --git a/src/test/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsResponseTests.java b/src/test/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsResponseTests.java new file mode 100644 index 00000000..61707d98 --- /dev/null +++ b/src/test/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsResponseTests.java @@ -0,0 +1,386 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.transport.stats; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.opensearch.searchrelevance.util.TestUtils.xContentBuilderToMap; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opensearch.action.FailedNodeException; +import org.opensearch.cluster.ClusterName; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.searchrelevance.stats.common.StatSnapshot; +import org.opensearch.searchrelevance.stats.info.InfoStatName; +import org.opensearch.searchrelevance.stats.info.SettableInfoStatSnapshot; +import org.opensearch.test.OpenSearchTestCase; + +public class SearchRelevanceStatsResponseTests extends OpenSearchTestCase { + + private ClusterName clusterName; + private List nodes; + private List failures; + private Map> infoStats; + private Map> aggregatedNodeStats; + private Map>> nodeIdToNodeEventStats; + + @Mock + private StreamInput mockStreamInput; + + @Mock + private StreamOutput mockStreamOutput; + + @Before + public void setup() { + MockitoAnnotations.openMocks(this); + clusterName = new ClusterName("test-cluster"); + nodes = new ArrayList<>(); + failures = new ArrayList<>(); + infoStats = new HashMap<>(); + aggregatedNodeStats = new HashMap<>(); + nodeIdToNodeEventStats = new HashMap<>(); + } + + public void test_constructor() throws IOException { + when(mockStreamInput.readString()).thenReturn("test-cluster"); + when(mockStreamInput.readList(any())).thenReturn((List) nodes).thenReturn((List) failures); + when(mockStreamInput.readMap()).thenReturn((Map) infoStats) + .thenReturn((Map) aggregatedNodeStats) + .thenReturn((Map) nodeIdToNodeEventStats); + + // Booleans as bytes + when(mockStreamInput.readByte()).thenReturn((byte) 1).thenReturn((byte) 0); + + SearchRelevanceStatsResponse response = new SearchRelevanceStatsResponse(mockStreamInput); + + assertEquals("test-cluster", response.getClusterName().value()); + assertEquals(nodes, response.getNodes()); + assertEquals(failures, response.failures()); + assertEquals(infoStats, response.getInfoStats()); + assertEquals(aggregatedNodeStats, response.getAggregatedNodeStats()); + assertEquals(nodeIdToNodeEventStats, response.getNodeIdToNodeEventStats()); + assertTrue(response.isFlatten()); + assertFalse(response.isIncludeMetadata()); + } + + public void test_writeTo() throws IOException { + SearchRelevanceStatsResponse response = new SearchRelevanceStatsResponse( + clusterName, + nodes, + failures, + infoStats, + aggregatedNodeStats, + nodeIdToNodeEventStats, + true, + false, + true, + true, + true + ); + + response.writeTo(mockStreamOutput); + + verify(mockStreamOutput).writeString(clusterName.value()); + + // 2 calls, one by BaseNodesResponse, one by class under test + verify(mockStreamOutput, times(2)).writeList(nodes); + + // 2 calls, one by BaseNodesResponse, one by class under test + verify(mockStreamOutput, times(2)).writeList(failures); + verify(mockStreamOutput, times(3)).writeMap(any()); + verify(mockStreamOutput, times(4)).writeBoolean(true); + verify(mockStreamOutput, times(1)).writeBoolean(false); + } + + public void test_toXContent_emptyStats() throws IOException { + SearchRelevanceStatsResponse response = new SearchRelevanceStatsResponse( + clusterName, + nodes, + failures, + infoStats, + aggregatedNodeStats, + nodeIdToNodeEventStats, + false, + true, + true, + true, + true + ); + XContentBuilder builder = XContentFactory.jsonBuilder(); + + builder.startObject(); + response.toXContent(builder, null); + builder.endObject(); + + Map responseMap = xContentBuilderToMap(builder); + + assertTrue(responseMap.containsKey("nodes")); + assertTrue(((Map) responseMap.get("nodes")).isEmpty()); + } + + public void test_toXContent_withInfoStats() throws IOException { + StatSnapshot mockSnapshot = mock(StatSnapshot.class); + when(mockSnapshot.getValue()).thenReturn(42L); + infoStats.put("test.nested.stat", mockSnapshot); + + SearchRelevanceStatsResponse response = new SearchRelevanceStatsResponse( + clusterName, + nodes, + failures, + infoStats, + aggregatedNodeStats, + nodeIdToNodeEventStats, + false, + false, + true, + true, + true + ); + XContentBuilder builder = XContentFactory.jsonBuilder(); + + builder.startObject(); + response.toXContent(builder, null); + builder.endObject(); + Map responseMap = xContentBuilderToMap(builder); + + Map infoMap = (Map) responseMap.get("info"); + Map testMap = (Map) infoMap.get("test"); + Map nestedMap = (Map) testMap.get("nested"); + assertEquals(42, nestedMap.get("stat")); + } + + public void test_toXContent_withStats_flattened() throws IOException { + StatSnapshot mockSnapshot = mock(StatSnapshot.class); + when(mockSnapshot.getValue()).thenReturn(42L); + infoStats.put("test.nested.stat", mockSnapshot); + + SearchRelevanceStatsResponse response = new SearchRelevanceStatsResponse( + clusterName, + nodes, + failures, + infoStats, + aggregatedNodeStats, + nodeIdToNodeEventStats, + true, + false, + true, + true, + true + ); + XContentBuilder builder = XContentFactory.jsonBuilder(); + + builder.startObject(); + response.toXContent(builder, null); + builder.endObject(); + Map responseMap = xContentBuilderToMap(builder); + + Map infoMap = (Map) responseMap.get("info"); + assertEquals(42, infoMap.get("test.nested.stat")); + } + + public void test_toXContent_withNodeStats() throws IOException { + StatSnapshot mockSnapshot = mock(StatSnapshot.class); + when(mockSnapshot.getValue()).thenReturn(42L); + Map> nodeStats = new HashMap<>(); + nodeStats.put("test.stat", mockSnapshot); + nodeIdToNodeEventStats.put("node1", nodeStats); + + SearchRelevanceStatsResponse response = new SearchRelevanceStatsResponse( + clusterName, + nodes, + failures, + infoStats, + aggregatedNodeStats, + nodeIdToNodeEventStats, + false, + false, + true, + true, + true + ); + XContentBuilder builder = XContentFactory.jsonBuilder(); + + builder.startObject(); + response.toXContent(builder, null); + builder.endObject(); + Map responseMap = xContentBuilderToMap(builder); + + Map nodesMap = (Map) responseMap.get("nodes"); + Map node1Stats = (Map) nodesMap.get("node1"); + Map node1StatsTest = (Map) node1Stats.get("test"); + assertEquals(42, node1StatsTest.get("stat")); + } + + public void test_toXContent_withNodeStats_flattened() throws IOException { + StatSnapshot mockSnapshot = mock(StatSnapshot.class); + when(mockSnapshot.getValue()).thenReturn(42L); + Map> nodeStats = new HashMap<>(); + nodeStats.put("test.stat", mockSnapshot); + nodeIdToNodeEventStats.put("node1", nodeStats); + + SearchRelevanceStatsResponse response = new SearchRelevanceStatsResponse( + clusterName, + nodes, + failures, + infoStats, + aggregatedNodeStats, + nodeIdToNodeEventStats, + true, + false, + true, + true, + true + ); + XContentBuilder builder = XContentFactory.jsonBuilder(); + + builder.startObject(); + response.toXContent(builder, null); + builder.endObject(); + Map responseMap = xContentBuilderToMap(builder); + + Map nodesMap = (Map) responseMap.get("nodes"); + Map node1Stats = (Map) nodesMap.get("node1"); + assertEquals(42, node1Stats.get("test.stat")); + } + + public void test_toXContent_withMetadata() throws IOException { + // Use a real stat snapshot here to use real toXContent functionality + SettableInfoStatSnapshot infoStatSnapshot = new SettableInfoStatSnapshot<>( + InfoStatName.CLUSTER_VERSION, + "For crying out loud!" + ); + + infoStats.put("test.nested.stat", infoStatSnapshot); + + SearchRelevanceStatsResponse response = new SearchRelevanceStatsResponse( + clusterName, + nodes, + failures, + infoStats, + aggregatedNodeStats, + nodeIdToNodeEventStats, + false, + true, + true, + true, + true + ); + XContentBuilder builder = XContentFactory.jsonBuilder(); + + builder.startObject(); + response.toXContent(builder, null); + builder.endObject(); + + Map responseMap = xContentBuilderToMap(builder); + + Map infoMap = (Map) responseMap.get("info"); + Map testMap = (Map) infoMap.get("test"); + Map nestedMap = (Map) testMap.get("nested"); + Map statMap = (Map) nestedMap.get("stat"); + + // Verify fields + assertEquals(infoStatSnapshot.getValue(), statMap.get(StatSnapshot.VALUE_FIELD)); + assertEquals(InfoStatName.CLUSTER_VERSION.getStatType().getTypeString(), statMap.get(StatSnapshot.STAT_TYPE_FIELD)); + } + + public void test_toXContent_withIncludeIndividualNodeStats_false() throws IOException { + StatSnapshot mockSnapshot = mock(StatSnapshot.class); + when(mockSnapshot.getValue()).thenReturn(42L); + Map> nodeStats = new HashMap<>(); + nodeStats.put("test.stat", mockSnapshot); + nodeIdToNodeEventStats.put("node1", nodeStats); + + // This is a mock aggregated node stats + aggregatedNodeStats.put("test.stat", mockSnapshot); + + SearchRelevanceStatsResponse response = new SearchRelevanceStatsResponse( + clusterName, + nodes, + failures, + infoStats, + aggregatedNodeStats, + nodeIdToNodeEventStats, + false, + false, + false, + true, + true + ); + XContentBuilder builder = XContentFactory.jsonBuilder(); + + builder.startObject(); + response.toXContent(builder, null); + builder.endObject(); + Map responseMap = xContentBuilderToMap(builder); + + // Shouldn't contain individual nodes + assertFalse(responseMap.containsKey(SearchRelevanceStatsResponse.NODES_KEY_PREFIX)); + // Should contain info + assertTrue(responseMap.containsKey(SearchRelevanceStatsResponse.INFO_KEY_PREFIX)); + + // Should still contain aggregated nodes info + Map aggregatedNodesMap = (Map) responseMap.get( + SearchRelevanceStatsResponse.AGGREGATED_NODES_KEY_PREFIX + ); + Map nodeStatsTest = (Map) aggregatedNodesMap.get("test"); + + assertEquals(42, nodeStatsTest.get("stat")); + } + + public void test_toXContent_withIncludesCombination_false() throws IOException { + StatSnapshot mockSnapshot = mock(StatSnapshot.class); + when(mockSnapshot.getValue()).thenReturn(42L); + Map> nodeStats = new HashMap<>(); + nodeStats.put("test.stat", mockSnapshot); + nodeIdToNodeEventStats.put("node1", nodeStats); + + // This is a mock aggregated node stats + aggregatedNodeStats.put("test.stat", mockSnapshot); + + SearchRelevanceStatsResponse response = new SearchRelevanceStatsResponse( + clusterName, + nodes, + failures, + infoStats, + aggregatedNodeStats, + nodeIdToNodeEventStats, + false, + false, + true, + false, + true + ); + XContentBuilder builder = XContentFactory.jsonBuilder(); + + builder.startObject(); + response.toXContent(builder, null); + builder.endObject(); + Map responseMap = xContentBuilderToMap(builder); + + // Should contain individual nodes + assertTrue(responseMap.containsKey(SearchRelevanceStatsResponse.NODES_KEY_PREFIX)); + // Shouldn't contain aggregated nodes + assertFalse(responseMap.containsKey(SearchRelevanceStatsResponse.AGGREGATED_NODES_KEY_PREFIX)); + // Should contain info + assertTrue(responseMap.containsKey(SearchRelevanceStatsResponse.INFO_KEY_PREFIX)); + } +} diff --git a/src/test/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsTransportActionTests.java b/src/test/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsTransportActionTests.java new file mode 100644 index 00000000..c1fb568c --- /dev/null +++ b/src/test/java/org/opensearch/searchrelevance/transport/stats/SearchRelevanceStatsTransportActionTests.java @@ -0,0 +1,343 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.transport.stats; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.cluster.ClusterName; +import org.opensearch.cluster.node.DiscoveryNode; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.searchrelevance.stats.SearchRelevanceStatsInput; +import org.opensearch.searchrelevance.stats.common.StatSnapshot; +import org.opensearch.searchrelevance.stats.events.EventStatName; +import org.opensearch.searchrelevance.stats.events.EventStatsManager; +import org.opensearch.searchrelevance.stats.events.TimestampedEventStatSnapshot; +import org.opensearch.searchrelevance.stats.info.CountableInfoStatSnapshot; +import org.opensearch.searchrelevance.stats.info.InfoStatName; +import org.opensearch.searchrelevance.stats.info.InfoStatsManager; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.TransportService; + +public class SearchRelevanceStatsTransportActionTests extends OpenSearchTestCase { + + @Mock + private ThreadPool threadPool; + + @Mock + private ClusterService clusterService; + + @Mock + private TransportService transportService; + + @Mock + private ActionFilters actionFilters; + + @Mock + private EventStatsManager eventStatsManager; + + @Mock + private InfoStatsManager infoStatsManager; + + private SearchRelevanceStatsTransportAction transportAction; + private ClusterName clusterName; + + private static InfoStatName infoStatName = InfoStatName.CLUSTER_VERSION; + private static EventStatName eventStatName = EventStatName.LLM_JUDGMENT_RATING_GENERATIONS; + + @Before + public void setup() { + MockitoAnnotations.openMocks(this); + clusterName = new ClusterName("test-cluster"); + when(clusterService.getClusterName()).thenReturn(clusterName); + + transportAction = new SearchRelevanceStatsTransportAction( + threadPool, + clusterService, + transportService, + actionFilters, + eventStatsManager, + infoStatsManager + ); + } + + public void test_newResponse() { + // Create inputs + SearchRelevanceStatsInput input = new SearchRelevanceStatsInput(); + SearchRelevanceStatsRequest request = new SearchRelevanceStatsRequest(new String[] {}, input); + List responses = new ArrayList<>(); + List failures = new ArrayList<>(); + + // Execute response + SearchRelevanceStatsResponse response = transportAction.newResponse(request, responses, failures); + + // Validate response + assertNotNull(response); + assertEquals(clusterName, response.getClusterName()); + assertTrue(response.getNodes().isEmpty()); + } + + public void test_newResponse_customSearchRelevanceStatsInputParams() { + // Create inputs + SearchRelevanceStatsInput input = new SearchRelevanceStatsInput(); + input.setIncludeInfo(false); + input.setIncludeAllNodes(false); + input.setIncludeIndividualNodes(false); + + SearchRelevanceStatsRequest request = new SearchRelevanceStatsRequest(new String[] {}, input); + List responses = new ArrayList<>(); + List failures = new ArrayList<>(); + + // Execute response + SearchRelevanceStatsResponse response = transportAction.newResponse(request, responses, failures); + + // Validate response + assertNotNull(response); + assertEquals(clusterName, response.getClusterName()); + assertFalse(response.isIncludeIndividualNodes()); + assertFalse(response.isIncludeAllNodes()); + assertFalse(response.isIncludeInfo()); + } + + public void test_newResponseMultipleNodesStateAndEventStats() { + // Create inputs + EnumSet eventStats = EnumSet.of(eventStatName); + EnumSet infoStats = EnumSet.of(infoStatName); + + SearchRelevanceStatsInput input = SearchRelevanceStatsInput.builder() + .eventStatNames(eventStats) + .infoStatNames(infoStats) + .includeIndividualNodes(true) + .includeAllNodes(true) + .includeInfo(true) + .build(); + SearchRelevanceStatsRequest request = new SearchRelevanceStatsRequest(new String[] {}, input); + + // Create multiple nodes + DiscoveryNode node1 = mock(DiscoveryNode.class); + when(node1.getId()).thenReturn("test-node-1"); + DiscoveryNode node2 = mock(DiscoveryNode.class); + when(node2.getId()).thenReturn("test-node-2"); + + // Create event stats + TimestampedEventStatSnapshot snapshot1 = TimestampedEventStatSnapshot.builder() + .statName(eventStatName) + .value(17) + .minutesSinceLastEvent(3) + .trailingIntervalValue(5) + .build(); + + TimestampedEventStatSnapshot snapshot2 = TimestampedEventStatSnapshot.builder() + .statName(eventStatName) + .value(33) + .minutesSinceLastEvent(0) + .trailingIntervalValue(15) + .build(); + + Map nodeStats1 = new HashMap<>(); + nodeStats1.put(eventStatName, snapshot1); + Map nodeStats2 = new HashMap<>(); + nodeStats2.put(eventStatName, snapshot2); + + List responses = Arrays.asList( + new SearchRelevanceStatsNodeResponse(node1, nodeStats1), + new SearchRelevanceStatsNodeResponse(node2, nodeStats2) + ); + + // Create info stats + CountableInfoStatSnapshot infoStatSnapshot = new CountableInfoStatSnapshot(infoStatName); + infoStatSnapshot.incrementBy(2001L); + Map> mockInfoStats = new HashMap<>(); + mockInfoStats.put(InfoStatName.CLUSTER_VERSION, infoStatSnapshot); + when(infoStatsManager.getStats(infoStats)).thenReturn(mockInfoStats); + + List failures = new ArrayList<>(); + + // Execute + SearchRelevanceStatsResponse response = transportAction.newResponse(request, responses, failures); + + // Verify node level event stats + assertNotNull(response); + assertEquals(2, response.getNodes().size()); + + Map>> nodeEventStats = response.getNodeIdToNodeEventStats(); + + assertNotNull(nodeEventStats); + assertTrue(nodeEventStats.containsKey("test-node-1")); + assertTrue(nodeEventStats.containsKey("test-node-2")); + + StatSnapshot node1Stat = nodeEventStats.get("test-node-1").get(eventStatName.getFullPath()); + assertEquals(17L, node1Stat.getValue()); + + StatSnapshot node2Stat = nodeEventStats.get("test-node-2").get(eventStatName.getFullPath()); + assertEquals(33L, node2Stat.getValue()); + + Map> aggregatedNodeStats = response.getAggregatedNodeStats(); + assertNotNull(aggregatedNodeStats); + + // Validate timestamped event stats aggregated correctly + String aggregatedStatPath = eventStatName.getFullPath(); + TimestampedEventStatSnapshot aggregatedStat = (TimestampedEventStatSnapshot) aggregatedNodeStats.get(aggregatedStatPath); + assertNotNull(aggregatedStat); + + assertEquals(50L, aggregatedStat.getValue().longValue()); + assertEquals(0L, aggregatedStat.getMinutesSinceLastEvent()); + assertEquals(20L, aggregatedStat.getTrailingIntervalValue()); + assertEquals(eventStatName, aggregatedStat.getStatName()); + + // Verify info stats + Map> resultStats = response.getInfoStats(); + assertNotNull(resultStats); + + // Verify info stats + String infoStatPath = infoStatName.getFullPath(); + StatSnapshot resultStateSnapshot = resultStats.get(infoStatPath); + assertNotNull(resultStateSnapshot); + assertEquals(2001L, resultStateSnapshot.getValue()); + } + + public void test_newResponseMultipleNodesStateAndEventStats_customParams() { + // Create inputs + EnumSet eventStats = EnumSet.of(eventStatName); + EnumSet infoStats = EnumSet.of(infoStatName); + + SearchRelevanceStatsInput input = SearchRelevanceStatsInput.builder() + .eventStatNames(eventStats) + .infoStatNames(infoStats) + .includeIndividualNodes(true) + .includeAllNodes(false) // <- exclude + .includeInfo(false) // <- exclude + .build(); + SearchRelevanceStatsRequest request = new SearchRelevanceStatsRequest(new String[] {}, input); + + // Create multiple nodes + DiscoveryNode node1 = mock(DiscoveryNode.class); + when(node1.getId()).thenReturn("test-node-1"); + DiscoveryNode node2 = mock(DiscoveryNode.class); + when(node2.getId()).thenReturn("test-node-2"); + + // Create event stats + TimestampedEventStatSnapshot snapshot1 = TimestampedEventStatSnapshot.builder() + .statName(eventStatName) + .value(17) + .minutesSinceLastEvent(3) + .trailingIntervalValue(5) + .build(); + + TimestampedEventStatSnapshot snapshot2 = TimestampedEventStatSnapshot.builder() + .statName(eventStatName) + .value(33) + .minutesSinceLastEvent(0) + .trailingIntervalValue(15) + .build(); + + Map nodeStats1 = new HashMap<>(); + nodeStats1.put(eventStatName, snapshot1); + Map nodeStats2 = new HashMap<>(); + nodeStats2.put(eventStatName, snapshot2); + + List responses = Arrays.asList( + new SearchRelevanceStatsNodeResponse(node1, nodeStats1), + new SearchRelevanceStatsNodeResponse(node2, nodeStats2) + ); + + // Create info stats + CountableInfoStatSnapshot infoStatSnapshot = new CountableInfoStatSnapshot(infoStatName); + infoStatSnapshot.incrementBy(2001L); + Map> mockInfoStats = new HashMap<>(); + mockInfoStats.put(infoStatName, infoStatSnapshot); + when(infoStatsManager.getStats(infoStats)).thenReturn(mockInfoStats); + + List failures = new ArrayList<>(); + + // Execute + SearchRelevanceStatsResponse response = transportAction.newResponse(request, responses, failures); + + // Verify params + assertTrue(response.isIncludeIndividualNodes()); + assertFalse(response.isIncludeAllNodes()); + assertFalse(response.isIncludeInfo()); + + // Verify node level event stats + assertNotNull(response); + assertEquals(2, response.getNodes().size()); + + Map>> nodeEventStats = response.getNodeIdToNodeEventStats(); + + assertNotNull(nodeEventStats); + assertTrue(nodeEventStats.containsKey("test-node-1")); + assertTrue(nodeEventStats.containsKey("test-node-2")); + + StatSnapshot node1Stat = nodeEventStats.get("test-node-1").get(eventStatName.getFullPath()); + assertEquals(17L, node1Stat.getValue()); + + StatSnapshot node2Stat = nodeEventStats.get("test-node-2").get(eventStatName.getFullPath()); + assertEquals(33L, node2Stat.getValue()); + + // Validate all nodes is empty (since is excluded) + Map> aggregatedNodeStats = response.getAggregatedNodeStats(); + assertTrue(aggregatedNodeStats.isEmpty()); + + // Verify info stats is empty (since is excluded) + Map> resultStats = response.getInfoStats(); + assertTrue(resultStats.isEmpty()); + } + + public void test_nodeOperation() { + EnumSet eventStats = EnumSet.of(eventStatName); + SearchRelevanceStatsInput input = SearchRelevanceStatsInput.builder().eventStatNames(eventStats).build(); + + SearchRelevanceStatsRequest request = new SearchRelevanceStatsRequest(new String[] {}, input); + SearchRelevanceStatsNodeRequest nodeRequest = new SearchRelevanceStatsNodeRequest(request); + + DiscoveryNode localNode = mock(DiscoveryNode.class); + when(clusterService.localNode()).thenReturn(localNode); + + TimestampedEventStatSnapshot snapshot2 = TimestampedEventStatSnapshot.builder() + .statName(eventStatName) + .value(33) + .minutesSinceLastEvent(3) + .trailingIntervalValue(15) + .build(); + + Map mockStats = new HashMap<>(); + mockStats.put(eventStatName, snapshot2); + when(eventStatsManager.getTimestampedEventStatSnapshots(eventStats)).thenReturn(mockStats); + + SearchRelevanceStatsNodeResponse response = transportAction.nodeOperation(nodeRequest); + + assertNotNull(response); + assertEquals(localNode, response.getNode()); + + Map responseStats = response.getStats(); + assertFalse(responseStats.isEmpty()); + + TimestampedEventStatSnapshot stat = responseStats.get(eventStatName); + assertNotNull(stat); + assertEquals(33L, stat.getValue().longValue()); + assertEquals(3L, stat.getMinutesSinceLastEvent()); + assertEquals(15L, stat.getTrailingIntervalValue()); + assertEquals(eventStatName, stat.getStatName()); + + verify(eventStatsManager).getTimestampedEventStatSnapshots(eventStats); + } +} diff --git a/src/test/java/org/opensearch/searchrelevance/util/TestUtils.java b/src/test/java/org/opensearch/searchrelevance/util/TestUtils.java new file mode 100644 index 00000000..39d66619 --- /dev/null +++ b/src/test/java/org/opensearch/searchrelevance/util/TestUtils.java @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.searchrelevance.util; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import org.opensearch.common.xcontent.XContentHelper; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.searchrelevance.settings.SearchRelevanceSettingsAccessor; +import org.opensearch.searchrelevance.stats.events.EventStatsManager; + +public class TestUtils { + /** + * Convert an xContentBuilder to a map + * @param xContentBuilder to produce map from + * @return Map from xContentBuilder + */ + public static Map xContentBuilderToMap(XContentBuilder xContentBuilder) { + return XContentHelper.convertToMap(BytesReference.bytes(xContentBuilder), true, xContentBuilder.contentType()).v2(); + } + + /** + * Initializes static EventStatsManager with correct mocks + */ + public static void initializeEventStatsManager() { + SearchRelevanceSettingsAccessor settingsAccessor = mock(SearchRelevanceSettingsAccessor.class); + EventStatsManager.instance().reset(); + when(settingsAccessor.isStatsEnabled()).thenReturn(true); + when(settingsAccessor.isWorkbenchEnabled()).thenReturn(true); + EventStatsManager.instance().initialize(settingsAccessor); + } + +}