This is an automated email from the ASF dual-hosted git repository.
epugh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-mcp.git
The following commit(s) were added to refs/heads/main by this push:
new cb04f63 feat(compat): Solr 10 support with metrics API migration (#61)
cb04f63 is described below
commit cb04f63fdd4e5f6190ae04df3b4525c78e8c9b1b
Author: Aditya Parikh <[email protected]>
AuthorDate: Thu Apr 23 14:49:43 2026 -0400
feat(compat): Solr 10 support with metrics API migration (#61)
Comprehensive Solr 10 compatibility PR that consolidates the metrics API
migration,
create-collection tool, CI matrix expansion, and code review hardening into
a single
reviewable changeset.
Metrics API migration: getCacheMetrics() and getHandlerMetrics() now use
/admin/metrics (available since Solr 7.1+) instead of the removed
/admin/mbeans
Create-collection MCP tool: New create-collection tool with authentication,
configSet/numShards/replicationFactor defaults
CI matrix: Tests now run against Solr 8.11, 9.4, 9.9, 9.10, and 10
Code review fixes:
Fix ClassCastException risk in CollectionUtils.getFloat — added instanceof
check and String fallback parsing
Add extractCollectionName() to checkHealth for shard name support
Populate collection field in SolrHealthStatus response
Eliminate 3 redundant listCollections() round-trips per getCollectionStats
call
Return null instead of 0.0f from getFloat for missing values (consistent
with getLong/getInteger)
Integration tests rewritten: Setup indexes 50 documents and runs 15 warm-up
queries (with filter queries) against real Solr via Testcontainers. Tests
assert
actual non-zero metric values:
numDocs == 50, totalResults == 50, totalDocuments == 50
queryResultCache.lookups > 0, documentCache.lookups > 0,
filterCache.lookups > 0
selectHandler.requests > 0, updateHandler.requests > 0
System.out.println replaced with SLF4J, @BeforeEach/static-boolean replaced
with @BeforeAll/@TestInstance(PER_CLASS)
---
.../mcp/server/collection/CollectionService.java | 413 +++++++++------------
.../mcp/server/collection/CollectionUtils.java | 14 +-
.../CollectionServiceIntegrationTest.java | 357 +++++++++---------
.../server/collection/CollectionServiceTest.java | 286 +++++++-------
.../mcp/server/collection/CollectionUtilsTest.java | 28 +-
5 files changed, 540 insertions(+), 558 deletions(-)
diff --git
a/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java
b/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java
index d5dd1df..48ec56d 100644
--- a/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java
+++ b/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java
@@ -133,36 +133,39 @@ public class CollectionService {
// Constants for API Parameters and Paths
// ========================================
- /** Category parameter value for cache-related MBeans requests */
- private static final String CACHE_CATEGORY = "CACHE";
-
- /** Category parameter value for query handler MBeans requests */
- private static final String QUERY_HANDLER_CATEGORY = "QUERYHANDLER";
-
- /**
- * Combined category parameter value for both query and update handler
MBeans
- * requests
- */
- private static final String HANDLER_CATEGORIES =
"QUERYHANDLER,UPDATEHANDLER";
-
/** Universal Solr query pattern to match all documents in a collection
*/
private static final String ALL_DOCUMENTS_QUERY = "*:*";
/** Suffix pattern used to identify shard names in SolrCloud
deployments */
private static final String SHARD_SUFFIX = "_shard";
- /** Request parameter name for enabling statistics in MBeans requests */
- private static final String STATS_PARAM = "stats";
-
- /** Request parameter name for specifying category filters in MBeans
requests */
- private static final String CAT_PARAM = "cat";
-
/** Request parameter name for specifying response writer type */
private static final String WT_PARAM = "wt";
/** JSON format specification for response writer type */
private static final String JSON_FORMAT = "json";
+ /** URL path for Solr Metrics admin endpoint */
+ private static final String ADMIN_METRICS_PATH = "/admin/metrics";
+
+ /** Request parameter name for specifying the metrics group */
+ private static final String GROUP_PARAM = "group";
+
+ /** Request parameter name for filtering metrics by key prefix */
+ private static final String PREFIX_PARAM = "prefix";
+
+ /** Metrics group for core-level metrics */
+ private static final String CORE_GROUP = "core";
+
+ /** Prefix for cache metrics in the Metrics API */
+ private static final String CACHE_METRIC_PREFIX = "CACHE.searcher";
+
+ /** Prefix for select handler metrics in the Metrics API */
+ private static final String SELECT_HANDLER_METRIC_PREFIX =
"QUERY./select";
+
+ /** Prefix for update handler metrics in the Metrics API */
+ private static final String UPDATE_HANDLER_METRIC_PREFIX =
"UPDATE./update";
+
// ========================================
// Constants for Response Parsing
// ========================================
@@ -173,30 +176,23 @@ public class CollectionService {
/** Key name for segment count information in Luke response */
private static final String SEGMENT_COUNT_KEY = "segmentCount";
- /** Key name for query result cache in MBeans cache responses */
- private static final String QUERY_RESULT_CACHE_KEY = "queryResultCache";
-
- /** Key name for document cache in MBeans cache responses */
- private static final String DOCUMENT_CACHE_KEY = "documentCache";
+ /** Top-level key in Metrics API responses */
+ private static final String METRICS_KEY = "metrics";
- /** Key name for filter cache in MBeans cache responses */
- private static final String FILTER_CACHE_KEY = "filterCache";
+ /** Metrics API key for query result cache */
+ private static final String QUERY_RESULT_CACHE_KEY =
"CACHE.searcher.queryResultCache";
- /** Key name for statistics section in MBeans responses */
- private static final String STATS_KEY = "stats";
+ /** Metrics API key for document cache */
+ private static final String DOCUMENT_CACHE_KEY =
"CACHE.searcher.documentCache";
- // ========================================
- // Constants for Handler Paths
- // ========================================
+ /** Metrics API key for filter cache */
+ private static final String FILTER_CACHE_KEY =
"CACHE.searcher.filterCache";
- /** URL path for Solr select (query) handler */
- private static final String SELECT_HANDLER_PATH = "/select";
+ /** Flat metric key prefix for select handler stats */
+ private static final String SELECT_HANDLER_KEY = "QUERY./select.";
- /** URL path for Solr update handler */
- private static final String UPDATE_HANDLER_PATH = "/update";
-
- /** URL path for Solr MBeans admin endpoint */
- private static final String ADMIN_MBEANS_PATH = "/admin/mbeans";
+ /** Flat metric key prefix for update handler stats */
+ private static final String UPDATE_HANDLER_KEY = "UPDATE./update.";
// ========================================
// Constants for Statistics Field Names
@@ -232,12 +228,6 @@ public class CollectionService {
/** Field name for handler total processing time statistics */
private static final String TOTAL_TIME_FIELD = "totalTime";
- /** Field name for handler average time per request statistics */
- private static final String AVG_TIME_PER_REQUEST_FIELD =
"avgTimePerRequest";
-
- /** Field name for handler average requests per second statistics */
- private static final String AVG_REQUESTS_PER_SECOND_FIELD =
"avgRequestsPerSecond";
-
// ========================================
// Constants for Error Messages
// ========================================
@@ -438,7 +428,7 @@ public class CollectionService {
QueryResponse statsResponse =
solrClient.query(actualCollection, new
SolrQuery(ALL_DOCUMENTS_QUERY).setRows(0));
return new SolrMetrics(buildIndexStats(lukeResponse),
buildQueryStats(statsResponse),
- getCacheMetrics(actualCollection),
getHandlerMetrics(actualCollection), new Date());
+ fetchCacheMetrics(actualCollection),
fetchHandlerMetrics(actualCollection), new Date());
}
/**
@@ -525,7 +515,7 @@ public class CollectionService {
* Retrieves cache performance metrics for all cache types in a Solr
collection.
*
* <p>
- * Collects detailed cache utilization statistics from Solr's MBeans
endpoint,
+ * Collects detailed cache utilization statistics from Solr's Metrics
API,
* providing insights into cache effectiveness and memory usage
patterns. Cache
* performance directly impacts query response times and system
efficiency.
*
@@ -567,38 +557,30 @@ public class CollectionService {
* @see #isCacheStatsEmpty(CacheStats)
*/
public CacheStats getCacheMetrics(String collection) {
- try {
- // Get MBeans for cache information
- ModifiableSolrParams params = new
ModifiableSolrParams();
- params.set(STATS_PARAM, "true");
- params.set(CAT_PARAM, CACHE_CATEGORY);
- params.set(WT_PARAM, JSON_FORMAT);
-
- // Extract actual collection name from shard name if
needed
- String actualCollection =
extractCollectionName(collection);
-
- // Validate collection exists first
- if (!validateCollectionExists(actualCollection)) {
- return null; // Return null instead of empty
object
- }
-
- String path = "/" + actualCollection +
ADMIN_MBEANS_PATH;
+ String actualCollection = extractCollectionName(collection);
- GenericSolrRequest request = new
GenericSolrRequest(SolrRequest.METHOD.GET, path, params);
+ if (!validateCollectionExists(actualCollection)) {
+ return null;
+ }
- NamedList<Object> response =
solrClient.request(request);
- CacheStats stats = extractCacheStats(response);
+ return fetchCacheMetrics(actualCollection);
+ }
- // Return null if all cache stats are empty/null
- if (isCacheStatsEmpty(stats)) {
+ /**
+ * Internal cache metrics fetch that assumes the collection has already
been
+ * validated and the name has been extracted from any shard identifier.
+ */
+ private CacheStats fetchCacheMetrics(String collection) {
+ try {
+ NamedList<Object> coreMetrics =
fetchMetrics(collection, CACHE_METRIC_PREFIX);
+ if (coreMetrics == null) {
return null;
}
- return stats;
+ CacheStats stats = extractCacheStats(coreMetrics);
+ return isCacheStatsEmpty(stats) ? null : stats;
} catch (SolrServerException | IOException | RuntimeException
_) {
- // RuntimeException covers SolrException subclasses
(e.g. RemoteSolrException)
- // thrown when the /admin/mbeans endpoint is
unavailable (removed in Solr 10).
- return null; // Return null instead of empty object
+ return null;
}
}
@@ -620,81 +602,26 @@ public class CollectionService {
}
/**
- * Extracts cache performance statistics from Solr MBeans response data.
- *
- * <p>
- * Parses the raw MBeans response to extract structured cache
performance
- * metrics for all available cache types. Each cache type provides
detailed
- * statistics including hit ratios, eviction rates, and current
utilization.
- *
- * <p>
- * <strong>Parsed Cache Types:</strong>
- *
- * <ul>
- * <li>queryResultCache - Complete query result caching
- * <li>documentCache - Retrieved document data caching
- * <li>filterCache - Filter query result caching
- * </ul>
+ * Extracts cache performance statistics from Solr Metrics API response
data.
*
- * <p>
- * For each cache type, the following metrics are extracted:
- *
- * <ul>
- * <li>lookups, hits, hitratio - Performance effectiveness
- * <li>inserts, evictions - Memory management patterns
- * <li>size - Current utilization
- * </ul>
- *
- * @param mbeans
- * the raw MBeans response from Solr admin endpoint
+ * @param coreMetrics
+ * the core metrics from the Solr Metrics API
* @return CacheStats object containing parsed metrics for all cache
types
- * @see CacheStats
- * @see CacheInfo
*/
- private CacheStats extractCacheStats(NamedList<Object> mbeans) {
- CacheInfo queryResultCacheInfo = null;
- CacheInfo documentCacheInfo = null;
- CacheInfo filterCacheInfo = null;
-
- @SuppressWarnings("unchecked")
- NamedList<Object> caches = (NamedList<Object>)
mbeans.get(CACHE_CATEGORY);
-
- if (caches != null) {
- // Query result cache
- @SuppressWarnings("unchecked")
- NamedList<Object> queryResultCache =
(NamedList<Object>) caches.get(QUERY_RESULT_CACHE_KEY);
- if (queryResultCache != null) {
- @SuppressWarnings("unchecked")
- NamedList<Object> stats = (NamedList<Object>)
queryResultCache.get(STATS_KEY);
- queryResultCacheInfo = new
CacheInfo(getLong(stats, LOOKUPS_FIELD), getLong(stats, HITS_FIELD),
- getFloat(stats,
HITRATIO_FIELD), getLong(stats, INSERTS_FIELD), getLong(stats, EVICTIONS_FIELD),
- getLong(stats, SIZE_FIELD));
- }
-
- // Document cache
- @SuppressWarnings("unchecked")
- NamedList<Object> documentCache = (NamedList<Object>)
caches.get(DOCUMENT_CACHE_KEY);
- if (documentCache != null) {
- @SuppressWarnings("unchecked")
- NamedList<Object> stats = (NamedList<Object>)
documentCache.get(STATS_KEY);
- documentCacheInfo = new
CacheInfo(getLong(stats, LOOKUPS_FIELD), getLong(stats, HITS_FIELD),
- getFloat(stats,
HITRATIO_FIELD), getLong(stats, INSERTS_FIELD), getLong(stats, EVICTIONS_FIELD),
- getLong(stats, SIZE_FIELD));
- }
+ private CacheStats extractCacheStats(NamedList<Object> coreMetrics) {
+ return new CacheStats(extractSingleCacheInfo(coreMetrics,
QUERY_RESULT_CACHE_KEY),
+ extractSingleCacheInfo(coreMetrics,
DOCUMENT_CACHE_KEY),
+ extractSingleCacheInfo(coreMetrics,
FILTER_CACHE_KEY));
+ }
- // Filter cache
- @SuppressWarnings("unchecked")
- NamedList<Object> filterCache = (NamedList<Object>)
caches.get(FILTER_CACHE_KEY);
- if (filterCache != null) {
- @SuppressWarnings("unchecked")
- NamedList<Object> stats = (NamedList<Object>)
filterCache.get(STATS_KEY);
- filterCacheInfo = new CacheInfo(getLong(stats,
LOOKUPS_FIELD), getLong(stats, HITS_FIELD),
- getFloat(stats,
HITRATIO_FIELD), getLong(stats, INSERTS_FIELD), getLong(stats, EVICTIONS_FIELD),
- getLong(stats, SIZE_FIELD));
- }
+ @SuppressWarnings("unchecked")
+ private CacheInfo extractSingleCacheInfo(NamedList<Object> coreMetrics,
String key) {
+ NamedList<Object> cache = (NamedList<Object>)
coreMetrics.get(key);
+ if (cache == null) {
+ return null;
}
-
- return new CacheStats(queryResultCacheInfo, documentCacheInfo,
filterCacheInfo);
+ return new CacheInfo(getLong(cache, LOOKUPS_FIELD),
getLong(cache, HITS_FIELD), getFloat(cache, HITRATIO_FIELD),
+ getLong(cache, INSERTS_FIELD), getLong(cache,
EVICTIONS_FIELD), getLong(cache, SIZE_FIELD));
}
/**
@@ -709,10 +636,10 @@ public class CollectionService {
* <strong>Monitored Handlers:</strong>
*
* <ul>
- * <li><strong>Select Handler ({@value #SELECT_HANDLER_PATH})</strong>:
- * Processes search and query requests
- * <li><strong>Update Handler ({@value #UPDATE_HANDLER_PATH})</strong>:
- * Processes document indexing operations
+ * <li><strong>Select Handler (/select)</strong>: Processes search and
query
+ * requests
+ * <li><strong>Update Handler (/update)</strong>: Processes document
indexing
+ * operations
* </ul>
*
* <p>
@@ -738,41 +665,36 @@ public class CollectionService {
* null if unavailable
* @see HandlerStats
* @see HandlerInfo
- * @see #extractHandlerStats(NamedList)
+ * @see #fetchFlatHandlerInfo(String, String, String)
* @see #isHandlerStatsEmpty(HandlerStats)
*/
public HandlerStats getHandlerMetrics(String collection) {
- try {
- ModifiableSolrParams params = new
ModifiableSolrParams();
- params.set(STATS_PARAM, "true");
- params.set(CAT_PARAM, HANDLER_CATEGORIES);
- params.set(WT_PARAM, JSON_FORMAT);
-
- // Extract actual collection name from shard name if
needed
- String actualCollection =
extractCollectionName(collection);
-
- // Validate collection exists first
- if (!validateCollectionExists(actualCollection)) {
- return null; // Return null instead of empty
object
- }
-
- String path = "/" + actualCollection +
ADMIN_MBEANS_PATH;
-
- GenericSolrRequest request = new
GenericSolrRequest(SolrRequest.METHOD.GET, path, params);
+ String actualCollection = extractCollectionName(collection);
- NamedList<Object> response =
solrClient.request(request);
- HandlerStats stats = extractHandlerStats(response);
+ if (!validateCollectionExists(actualCollection)) {
+ return null;
+ }
- // Return null if all handler stats are empty/null
- if (isHandlerStatsEmpty(stats)) {
- return null;
- }
+ return fetchHandlerMetrics(actualCollection);
+ }
- return stats;
+ /**
+ * Internal handler metrics fetch that assumes the collection has
already been
+ * validated and the name has been extracted from any shard identifier.
+ */
+ private HandlerStats fetchHandlerMetrics(String collection) {
+ try {
+ // Handler metrics are flat keys (e.g.
QUERY./select.requests) so we
+ // fetch each handler prefix separately and reconstruct
HandlerInfo
+ HandlerInfo selectHandler =
fetchFlatHandlerInfo(collection, SELECT_HANDLER_METRIC_PREFIX,
+ SELECT_HANDLER_KEY);
+ HandlerInfo updateHandler =
fetchFlatHandlerInfo(collection, UPDATE_HANDLER_METRIC_PREFIX,
+ UPDATE_HANDLER_KEY);
+
+ HandlerStats stats = new HandlerStats(selectHandler,
updateHandler);
+ return isHandlerStatsEmpty(stats) ? null : stats;
} catch (SolrServerException | IOException | RuntimeException
_) {
- // RuntimeException covers SolrException subclasses
(e.g. RemoteSolrException)
- // thrown when the /admin/mbeans endpoint is
unavailable (removed in Solr 10).
- return null; // Return null instead of empty object
+ return null;
}
}
@@ -793,69 +715,92 @@ public class CollectionService {
}
/**
- * Extracts request handler performance statistics from Solr MBeans
response
- * data.
- *
- * <p>
- * Parses the raw MBeans response to extract structured handler
performance
- * metrics for query and update operations. Each handler provides
detailed
- * statistics about request processing including volume, errors, and
timing.
- *
- * <p>
- * <strong>Parsed Handler Types:</strong>
- *
- * <ul>
- * <li>/select - Search and query request handler
- * <li>/update - Document indexing request handler
- * </ul>
- *
- * <p>
- * For each handler type, the following metrics are extracted:
+ * Fetches metrics from the Solr Metrics API for a given collection and
prefix.
*
- * <ul>
- * <li>requests, errors, timeouts - Volume and reliability
- * <li>totalTime, avgTimePerRequest - Performance characteristics
- * <li>avgRequestsPerSecond - Throughput capacity
- * </ul>
- *
- * @param mbeans
- * the raw MBeans response from Solr admin endpoint
- * @return HandlerStats object containing parsed metrics for all
handler types
- * @see HandlerStats
- * @see HandlerInfo
+ * @param collection
+ * the collection name
+ * @param prefix
+ * the metric key prefix to filter (e.g. "CACHE.searcher",
"HANDLER")
+ * @return the core-level metrics NamedList, or null if unavailable
*/
- private HandlerStats extractHandlerStats(NamedList<Object> mbeans) {
- HandlerInfo selectHandlerInfo = null;
- HandlerInfo updateHandlerInfo = null;
-
- @SuppressWarnings("unchecked")
- NamedList<Object> queryHandlers = (NamedList<Object>)
mbeans.get(QUERY_HANDLER_CATEGORY);
+ @SuppressWarnings("unchecked")
+ private NamedList<Object> fetchMetrics(String collection, String
prefix) throws SolrServerException, IOException {
+ ModifiableSolrParams params = new ModifiableSolrParams();
+ params.set(GROUP_PARAM, CORE_GROUP);
+ params.set(PREFIX_PARAM, prefix);
+ params.set(WT_PARAM, JSON_FORMAT);
+
+ // Metrics API is a node-level endpoint, not per-collection
+ GenericSolrRequest request = new
GenericSolrRequest(SolrRequest.METHOD.GET, ADMIN_METRICS_PATH, params);
+
+ NamedList<Object> response = solrClient.request(request);
+ NamedList<Object> metrics = (NamedList<Object>)
response.get(METRICS_KEY);
+ if (metrics == null || metrics.size() == 0) {
+ return null;
+ }
- if (queryHandlers != null) {
- // Select handler
- @SuppressWarnings("unchecked")
- NamedList<Object> selectHandler = (NamedList<Object>)
queryHandlers.get(SELECT_HANDLER_PATH);
- if (selectHandler != null) {
- @SuppressWarnings("unchecked")
- NamedList<Object> stats = (NamedList<Object>)
selectHandler.get(STATS_KEY);
- selectHandlerInfo = new
HandlerInfo(getLong(stats, REQUESTS_FIELD), getLong(stats, ERRORS_FIELD),
- getLong(stats, TIMEOUTS_FIELD),
getLong(stats, TOTAL_TIME_FIELD),
- getFloat(stats,
AVG_TIME_PER_REQUEST_FIELD), getFloat(stats, AVG_REQUESTS_PER_SECOND_FIELD));
+ // Find the core registry matching the requested collection
+ // Keys are like "solr.core.<collection>.<shard>.<replica>"
+ String corePrefix = "solr.core." + collection + ".";
+ for (int i = 0; i < metrics.size(); i++) {
+ String key = metrics.getName(i);
+ if (key != null && key.startsWith(corePrefix)) {
+ return (NamedList<Object>) metrics.getVal(i);
}
+ }
+ return null;
+ }
- // Update handler
- @SuppressWarnings("unchecked")
- NamedList<Object> updateHandler = (NamedList<Object>)
queryHandlers.get(UPDATE_HANDLER_PATH);
- if (updateHandler != null) {
- @SuppressWarnings("unchecked")
- NamedList<Object> stats = (NamedList<Object>)
updateHandler.get(STATS_KEY);
- updateHandlerInfo = new
HandlerInfo(getLong(stats, REQUESTS_FIELD), getLong(stats, ERRORS_FIELD),
- getLong(stats, TIMEOUTS_FIELD),
getLong(stats, TOTAL_TIME_FIELD),
- getFloat(stats,
AVG_TIME_PER_REQUEST_FIELD), getFloat(stats, AVG_REQUESTS_PER_SECOND_FIELD));
- }
+ /**
+ * Fetches and extracts handler metrics from flat Solr Metrics API keys.
+ *
+ * <p>
+ * Handler metrics in Solr are stored as flat keys (e.g.
+ * {@code QUERY./select.requests}) rather than nested objects. This
method
+ * fetches core metrics filtered by the handler prefix and reconstructs
a
+ * {@link HandlerInfo} from the individual flat keys.
+ *
+ * @param collection
+ * the collection name
+ * @param metricPrefix
+ * the prefix for the Metrics API filter (e.g. {@code
QUERY./select})
+ * @param keyPrefix
+ * the flat key prefix including trailing dot (e.g.
+ * {@code QUERY./select.})
+ * @return HandlerInfo with stats, or null if unavailable
+ */
+ private HandlerInfo fetchFlatHandlerInfo(String collection, String
metricPrefix, String keyPrefix)
+ throws SolrServerException, IOException {
+ NamedList<Object> coreMetrics = fetchMetrics(collection,
metricPrefix);
+ if (coreMetrics == null) {
+ return null;
}
+ return extractFlatHandlerInfo(coreMetrics, keyPrefix);
+ }
- return new HandlerStats(selectHandlerInfo, updateHandlerInfo);
+ /**
+ * Extracts a {@link HandlerInfo} from flat metric keys in core metrics.
+ *
+ * @param coreMetrics
+ * the core metrics NamedList with flat keys
+ * @param keyPrefix
+ * the flat key prefix including trailing dot (e.g.
+ * {@code QUERY./select.})
+ * @return HandlerInfo reconstructed from flat keys, or null if no
requests key
+ * found
+ */
+ private HandlerInfo extractFlatHandlerInfo(NamedList<Object>
coreMetrics, String keyPrefix) {
+ Long requests = getLong(coreMetrics, keyPrefix +
REQUESTS_FIELD);
+ if (requests == null) {
+ return null;
+ }
+ Long errors = getLong(coreMetrics, keyPrefix + ERRORS_FIELD);
+ Long timeouts = getLong(coreMetrics, keyPrefix +
TIMEOUTS_FIELD);
+ Long totalTime = getLong(coreMetrics, keyPrefix +
TOTAL_TIME_FIELD);
+ // avgTimePerRequest and avgRequestsPerSecond are not available
as flat metrics;
+ // compute avgTimePerRequest from totalTime/requests when
possible
+ Float avgTimePerRequest = (requests > 0 && totalTime != null) ?
(float) totalTime / requests : null;
+ return new HandlerInfo(requests, errors, timeouts, totalTime,
avgTimePerRequest, null);
}
/**
@@ -956,7 +901,7 @@ public class CollectionService {
// shard
// names)
return collections.stream().anyMatch(c ->
c.startsWith(collection + SHARD_SUFFIX));
- } catch (Exception _) {
+ } catch (Exception e) {
return false;
}
}
@@ -1012,18 +957,20 @@ public class CollectionService {
*/
@McpTool(name = "check-health", description = "Check health of a Solr
collection")
public SolrHealthStatus checkHealth(@McpToolParam(description = "Solr
collection") String collection) {
+ String actualCollection = extractCollectionName(collection);
try {
// Ping Solr
- SolrPingResponse pingResponse =
solrClient.ping(collection);
+ SolrPingResponse pingResponse =
solrClient.ping(actualCollection);
// Get basic stats
- QueryResponse statsResponse =
solrClient.query(collection, new SolrQuery(ALL_DOCUMENTS_QUERY).setRows(0));
+ QueryResponse statsResponse =
solrClient.query(actualCollection,
+ new
SolrQuery(ALL_DOCUMENTS_QUERY).setRows(0));
return new SolrHealthStatus(true, null,
pingResponse.getElapsedTime(),
-
statsResponse.getResults().getNumFound(), new Date(), null, null, null);
+
statsResponse.getResults().getNumFound(), new Date(), actualCollection, null,
null);
} catch (Exception e) {
- return new SolrHealthStatus(false, e.getMessage(),
null, null, new Date(), null, null, null);
+ return new SolrHealthStatus(false, e.getMessage(),
null, null, new Date(), actualCollection, null, null);
}
}
diff --git
a/src/main/java/org/apache/solr/mcp/server/collection/CollectionUtils.java
b/src/main/java/org/apache/solr/mcp/server/collection/CollectionUtils.java
index 5c02fed..2df9309 100644
--- a/src/main/java/org/apache/solr/mcp/server/collection/CollectionUtils.java
+++ b/src/main/java/org/apache/solr/mcp/server/collection/CollectionUtils.java
@@ -64,6 +64,7 @@ import org.apache.solr.common.util.NamedList;
public class CollectionUtils {
private CollectionUtils() {
+ // Utility class — prevent instantiation
}
/**
@@ -181,7 +182,18 @@ public class CollectionUtils {
*/
public static Float getFloat(NamedList<Object> stats, String key) {
Object value = stats.get(key);
- return value != null ? ((Number) value).floatValue() : 0.0f;
+ if (value == null)
+ return null;
+
+ if (value instanceof Number number) {
+ return number.floatValue();
+ }
+
+ try {
+ return Float.parseFloat(value.toString());
+ } catch (NumberFormatException _) {
+ return null;
+ }
}
/**
diff --git
a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java
b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java
index c520bd0..242ca84 100644
---
a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java
+++
b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java
@@ -18,12 +18,20 @@ package org.apache.solr.mcp.server.collection;
import static org.junit.jupiter.api.Assertions.*;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
-import org.apache.solr.client.solrj.SolrClient;
-import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import java.util.Map;
import org.apache.solr.mcp.server.TestcontainersConfiguration;
-import org.junit.jupiter.api.BeforeEach;
+import org.apache.solr.mcp.server.indexing.IndexingService;
+import org.apache.solr.mcp.server.search.SearchResponse;
+import org.apache.solr.mcp.server.search.SearchService;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@@ -32,216 +40,210 @@ import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest
@Import(TestcontainersConfiguration.class)
@Testcontainers(disabledWithoutDocker = true)
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CollectionServiceIntegrationTest {
+ private static final Logger log =
LoggerFactory.getLogger(CollectionServiceIntegrationTest.class);
+
private static final String TEST_COLLECTION = "test_collection";
+
+ private static final int DOC_COUNT = 50;
+
@Autowired
private CollectionService collectionService;
- @Autowired
- private SolrClient solrClient;
- private static boolean initialized = false;
-
- @BeforeEach
- void setupCollection() throws Exception {
- if (!initialized) {
- // Create a test collection using the container's
connection details
- // Create a collection for testing
- CollectionAdminRequest.Create createRequest =
CollectionAdminRequest.createCollection(TEST_COLLECTION,
- "_default", 1, 1);
- createRequest.process(solrClient);
+ @Autowired
+ private IndexingService indexingService;
- // Verify collection was created successfully
- CollectionAdminRequest.List listRequest = new
CollectionAdminRequest.List();
- listRequest.process(solrClient);
+ @Autowired
+ private SearchService searchService;
- System.out.println("[DEBUG_LOG] Test collection
created: " + TEST_COLLECTION);
- initialized = true;
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @BeforeAll
+ void setupCollectionWithData() throws Exception {
+ // 1. Create collection via CollectionService MCP tool
+ CollectionCreationResult created =
collectionService.createCollection(TEST_COLLECTION, null, null, null);
+ assertTrue(created.success(), "Collection creation should
succeed: " + created.message());
+ log.debug("Test collection created: {}", TEST_COLLECTION);
+
+ // 2. Index documents via IndexingService MCP tool
+ List<Map<String, Object>> docs = new ArrayList<>();
+ for (int i = 0; i < DOC_COUNT; i++) {
+ Map<String, Object> doc = new LinkedHashMap<>();
+ doc.put("id", "doc-" + i);
+ doc.put("title_s", "Document " + i);
+ doc.put("category_s", (i % 2 == 0) ? "even" : "odd");
+ doc.put("count_i", i);
+ docs.add(doc);
+ }
+ String json = objectMapper.writeValueAsString(docs);
+ indexingService.indexJsonDocuments(TEST_COLLECTION, json);
+ log.debug("Indexed {} documents via IndexingService",
DOC_COUNT);
+
+ // 3. Run searches via SearchService MCP tool to populate
caches and handler
+ // stats
+ for (int i = 0; i < 5; i++) {
+ searchService.search(TEST_COLLECTION, "*:*", null,
null, null, 0, 10);
+ searchService.search(TEST_COLLECTION,
"title_s:Document", null, null, null, 0, 5);
+ searchService.search(TEST_COLLECTION, "*:*",
List.of("category_s:even"), null, null, 0, 10);
}
+ log.debug("Ran warm-up queries via SearchService");
}
@Test
void testListCollections() {
- // Test listing collections
List<String> collections = collectionService.listCollections();
- // Print the collections for debugging
- System.out.println("[DEBUG_LOG] Collections: " + collections);
+ log.debug("Collections: {}", collections);
- // Enhanced assertions for collections list
- assertNotNull(collections, "Collections list should not be
null");
- assertFalse(collections.isEmpty(), "Collections list should not
be empty");
+ assertNotNull(collections);
+ assertFalse(collections.isEmpty());
- // Check if the test collection exists (either as exact name or
as shard)
boolean testCollectionExists =
collections.contains(TEST_COLLECTION)
|| collections.stream().anyMatch(col ->
col.startsWith(TEST_COLLECTION + "_shard"));
assertTrue(testCollectionExists,
- "Collections should contain the test
collection: " + TEST_COLLECTION + " (found: " + collections + ")");
+ "Collections should contain " + TEST_COLLECTION
+ " (found: " + collections + ")");
- // Verify collection names are not null or empty
- for (String collection : collections) {
- assertNotNull(collection, "Collection name should not
be null");
- assertFalse(collection.trim().isEmpty(), "Collection
name should not be empty");
- }
-
- // Verify expected collection characteristics
assertEquals(collections.size(),
collections.stream().distinct().count(), "Collection names should be unique");
-
- // Verify that collections follow expected naming patterns
- for (String collection : collections) {
- // Collection names should either be simple names or
shard names
-
assertTrue(collection.matches("^[a-zA-Z0-9_]+(_shard\\d+_replica_n\\d+)?$"),
- "Collection name should follow expected
pattern: " + collection);
- }
}
@Test
- void testGetCollectionStats() throws Exception {
- // Test getting collection stats
+ void testGetCollectionStats_reflectsIndexedData() throws Exception {
SolrMetrics metrics =
collectionService.getCollectionStats(TEST_COLLECTION);
- // Enhanced assertions for metrics
- assertNotNull(metrics, "Collection stats should not be null");
- assertNotNull(metrics.timestamp(), "Timestamp should not be
null");
+ assertNotNull(metrics);
+ assertNotNull(metrics.timestamp());
- // Verify index stats
- assertNotNull(metrics.indexStats(), "Index stats should not be
null");
+ // Index stats should reflect the documents we indexed
IndexStats indexStats = metrics.indexStats();
- assertNotNull(indexStats.numDocs(), "Number of documents should
not be null");
- assertTrue(indexStats.numDocs() >= 0, "Number of documents
should be non-negative");
+ assertNotNull(indexStats);
+ assertEquals(DOC_COUNT, indexStats.numDocs(), "numDocs should
match indexed document count");
+ assertNotNull(indexStats.segmentCount());
+ assertTrue(indexStats.segmentCount() >= 1, "Should have at
least one segment after indexing");
- // Verify query stats
- assertNotNull(metrics.queryStats(), "Query stats should not be
null");
+ // Query stats come from the *:* probe query inside
getCollectionStats
QueryStats queryStats = metrics.queryStats();
- assertNotNull(queryStats.queryTime(), "Query time should not be
null");
- assertTrue(queryStats.queryTime() >= 0, "Query time should be
non-negative");
- assertNotNull(queryStats.totalResults(), "Total results should
not be null");
- assertTrue(queryStats.totalResults() >= 0, "Total results
should be non-negative");
- assertNotNull(queryStats.start(), "Start should not be null");
- assertTrue(queryStats.start() >= 0, "Start should be
non-negative");
-
- // Verify timestamp is recent (within last 10 seconds)
- long currentTime = System.currentTimeMillis();
- long timestampTime = metrics.timestamp().getTime();
- assertTrue(currentTime - timestampTime < 10000, "Timestamp
should be recent (within 10 seconds)");
-
- // Verify optional stats (cache and handler stats may be null,
which is
- // acceptable)
- if (metrics.cacheStats() != null) {
- CacheStats cacheStats = metrics.cacheStats();
- // Verify at least one cache type exists if cache stats
are present
- assertTrue(
- cacheStats.queryResultCache() != null
|| cacheStats.documentCache() != null
- ||
cacheStats.filterCache() != null,
- "At least one cache type should be
present if cache stats exist");
- }
-
- if (metrics.handlerStats() != null) {
- HandlerStats handlerStats = metrics.handlerStats();
- // Verify at least one handler type exists if handler
stats are present
- assertTrue(handlerStats.selectHandler() != null ||
handlerStats.updateHandler() != null,
- "At least one handler type should be
present if handler stats exist");
- }
+ assertNotNull(queryStats);
+ assertNotNull(queryStats.queryTime());
+ assertEquals((long) DOC_COUNT, queryStats.totalResults(),
"totalResults should match indexed document count");
+ assertEquals(0L, queryStats.start());
+
+ // Cache stats should be present after warm-up queries
+ assertNotNull(metrics.cacheStats(), "Cache stats should not be
null after queries ran");
+ CacheStats cacheStats = metrics.cacheStats();
+ assertNotNull(cacheStats.queryResultCache());
+ assertTrue(cacheStats.queryResultCache().lookups() > 0, "Query
result cache should have lookups after queries");
+ assertNotNull(cacheStats.documentCache());
+ assertTrue(cacheStats.documentCache().lookups() > 0, "Document
cache should have lookups after queries");
+ assertNotNull(cacheStats.filterCache());
+ assertTrue(cacheStats.filterCache().lookups() > 0, "Filter
cache should have lookups after filter queries");
+
+ // Handler stats should reflect actual request counts
+ assertNotNull(metrics.handlerStats(), "Handler stats should not
be null after queries ran");
+ HandlerStats handlerStats = metrics.handlerStats();
+ assertNotNull(handlerStats.selectHandler());
+ assertTrue(handlerStats.selectHandler().requests() > 0, "Select
handler should have processed requests");
+ assertNotNull(handlerStats.updateHandler());
+ assertTrue(handlerStats.updateHandler().requests() > 0,
+ "Update handler should have processed requests
from indexing");
}
@Test
- void testCheckHealthHealthy() {
- // Test checking health of a valid collection
+ void testCheckHealth_healthy() {
SolrHealthStatus status =
collectionService.checkHealth(TEST_COLLECTION);
- // Print the status for debugging
- System.out.println("[DEBUG_LOG] Health status for valid
collection: " + status);
-
- // Enhanced assertions for healthy collection
- assertNotNull(status, "Health status should not be null");
- assertTrue(status.isHealthy(), "Collection should be healthy");
-
- // Verify response time
- assertNotNull(status.responseTime(), "Response time should not
be null");
- assertTrue(status.responseTime() >= 0, "Response time should be
non-negative");
- assertTrue(status.responseTime() < 30000, "Response time should
be reasonable (< 30 seconds)");
-
- // Verify document count
- assertNotNull(status.totalDocuments(), "Total documents should
not be null");
- assertTrue(status.totalDocuments() >= 0, "Total documents
should be non-negative");
-
- // Verify timestamp
- assertNotNull(status.lastChecked(), "Last checked timestamp
should not be null");
- long currentTime = System.currentTimeMillis();
- long lastCheckedTime = status.lastChecked().getTime();
- assertTrue(currentTime - lastCheckedTime < 5000,
- "Last checked timestamp should be very recent
(within 5 seconds)");
-
- // Verify no error message for healthy collection
- assertNull(status.errorMessage(), "Error message should be null
for healthy collection");
-
- // Verify string representation contains meaningful information
- String statusString = status.toString();
- if (statusString != null) {
- assertTrue(statusString.contains("healthy") ||
statusString.contains("true"),
- "Status string should indicate healthy
state");
- }
+ log.debug("Health status: {}", status);
+
+ assertNotNull(status);
+ assertTrue(status.isHealthy());
+ assertNull(status.errorMessage());
+ assertEquals(TEST_COLLECTION, status.collection());
+
+ assertNotNull(status.responseTime());
+ assertTrue(status.responseTime() >= 0);
+
+ assertEquals((long) DOC_COUNT, status.totalDocuments(), "Health
check should report indexed document count");
+
+ assertNotNull(status.lastChecked());
+ assertTrue(System.currentTimeMillis() -
status.lastChecked().getTime() < 5000);
}
@Test
- void testCheckHealthUnhealthy() {
- // Test checking health of an invalid collection
- String nonExistentCollection = "non_existent_collection";
- SolrHealthStatus status =
collectionService.checkHealth(nonExistentCollection);
-
- // Print the status for debugging
- System.out.println("[DEBUG_LOG] Health status for invalid
collection: " + status);
-
- // Enhanced assertions for unhealthy collection
- assertNotNull(status, "Health status should not be null");
- assertFalse(status.isHealthy(), "Collection should not be
healthy");
-
- // Verify timestamp
- assertNotNull(status.lastChecked(), "Last checked timestamp
should not be null");
- long currentTime = System.currentTimeMillis();
- long lastCheckedTime = status.lastChecked().getTime();
- assertTrue(currentTime - lastCheckedTime < 5000,
- "Last checked timestamp should be very recent
(within 5 seconds)");
-
- // Verify error message
- assertNotNull(status.errorMessage(), "Error message should not
be null for unhealthy collection");
- assertFalse(status.errorMessage().trim().isEmpty(),
- "Error message should not be empty for
unhealthy collection");
-
- // Verify that performance metrics are null for unhealthy
collection
- assertNull(status.responseTime(), "Response time should be null
for unhealthy collection");
- assertNull(status.totalDocuments(), "Total documents should be
null for unhealthy collection");
-
- // Verify error message contains meaningful information
- String errorMessage = status.errorMessage().toLowerCase();
- assertTrue(
- errorMessage.contains("collection") ||
errorMessage.contains("not found")
- ||
errorMessage.contains("error") || errorMessage.contains("fail"),
- "Error message should contain meaningful error
information");
-
- // Verify string representation indicates unhealthy state
- String statusString = status.toString();
- if (statusString != null) {
- assertTrue(statusString.contains("false") ||
statusString.contains("unhealthy")
- || statusString.contains("error"),
"Status string should indicate unhealthy state");
- }
+ void testCheckHealth_nonExistentCollection() {
+ String missing = "non_existent_collection";
+ SolrHealthStatus status =
collectionService.checkHealth(missing);
+
+ assertNotNull(status);
+ assertFalse(status.isHealthy());
+ assertNotNull(status.errorMessage());
+ assertFalse(status.errorMessage().isBlank());
+ assertEquals(missing, status.collection());
+ assertNull(status.responseTime());
+ assertNull(status.totalDocuments());
}
@Test
void testCollectionNameExtraction() {
- // Test collection name extraction functionality
- assertEquals(TEST_COLLECTION,
collectionService.extractCollectionName(TEST_COLLECTION),
- "Regular collection name should be returned
as-is");
+ assertEquals(TEST_COLLECTION,
collectionService.extractCollectionName(TEST_COLLECTION));
+ assertEquals("films",
collectionService.extractCollectionName("films_shard1_replica_n1"));
+ assertEquals("products",
collectionService.extractCollectionName("products_shard2_replica_n3"));
+ assertNull(collectionService.extractCollectionName(null));
+ assertEquals("", collectionService.extractCollectionName(""));
+ }
- assertEquals("films",
collectionService.extractCollectionName("films_shard1_replica_n1"),
- "Shard name should be extracted to base
collection name");
+ @Test
+ void testGetCacheMetrics_afterQueries() {
+ CacheStats cacheStats =
collectionService.getCacheMetrics(TEST_COLLECTION);
+
+ assertNotNull(cacheStats, "Cache stats should not be null after
warm-up queries");
+
+ // Query result cache: warm-up queries should have generated
lookups
+ CacheInfo qrc = cacheStats.queryResultCache();
+ assertNotNull(qrc);
+ assertTrue(qrc.lookups() > 0, "queryResultCache lookups should
be positive after queries");
+ assertNotNull(qrc.hitratio());
+ assertNotNull(qrc.size());
+
+ // Document cache: reading documents populates this cache
+ CacheInfo dc = cacheStats.documentCache();
+ assertNotNull(dc);
+ assertTrue(dc.lookups() > 0, "documentCache lookups should be
positive after queries");
+
+ // Filter cache: the filter queries we ran should generate
lookups
+ CacheInfo fc = cacheStats.filterCache();
+ assertNotNull(fc);
+ assertTrue(fc.lookups() > 0, "filterCache lookups should be
positive after filter queries");
+ }
- assertEquals("products",
collectionService.extractCollectionName("products_shard2_replica_n3"),
- "Complex shard name should be extracted
correctly");
+ @Test
+ void testGetHandlerMetrics_afterQueriesAndIndexing() {
+ HandlerStats handlerStats =
collectionService.getHandlerMetrics(TEST_COLLECTION);
+
+ assertNotNull(handlerStats, "Handler stats should not be null
after activity");
+
+ // Select handler: warm-up queries should have driven request
counts > 0
+ HandlerInfo select = handlerStats.selectHandler();
+ assertNotNull(select);
+ assertTrue(select.requests() > 0, "Select handler requests
should be positive after queries");
+ assertNotNull(select.errors());
+ assertNotNull(select.timeouts());
+
+ // Update handler: indexing 50 docs should have driven request
counts > 0
+ HandlerInfo update = handlerStats.updateHandler();
+ assertNotNull(update);
+ assertTrue(update.requests() > 0, "Update handler requests
should be positive after indexing");
+ }
- assertNull(collectionService.extractCollectionName(null), "Null
input should return null");
+ @Test
+ void testGetCacheMetrics_nonExistentCollection() {
+
assertNull(collectionService.getCacheMetrics("non_existent_collection"));
+ }
- assertEquals("", collectionService.extractCollectionName(""),
"Empty string should return empty string");
+ @Test
+ void testGetHandlerMetrics_nonExistentCollection() {
+
assertNull(collectionService.getHandlerMetrics("non_existent_collection"));
}
@Test
@@ -250,13 +252,26 @@ class CollectionServiceIntegrationTest {
CollectionCreationResult result =
collectionService.createCollection(name, null, null, null);
- assertTrue(result.success(), "Collection creation should
succeed");
- assertEquals(name, result.name(), "Result should contain the
collection name");
- assertNotNull(result.createdAt(), "Creation timestamp should be
set");
+ assertTrue(result.success());
+ assertEquals(name, result.name());
+ assertNotNull(result.createdAt());
List<String> collections = collectionService.listCollections();
- boolean collectionExists = collections.contains(name)
+ boolean exists = collections.contains(name)
|| collections.stream().anyMatch(col ->
col.startsWith(name + "_shard"));
- assertTrue(collectionExists, "Newly created collection should
appear in list (found: " + collections + ")");
+ assertTrue(exists, "Newly created collection should appear in
list (found: " + collections + ")");
+ }
+
+ @Test
+ void testSearchVerifiesIndexedDocuments() throws Exception {
+ // Verify the documents we indexed are actually searchable via
SearchService
+ SearchResponse all = searchService.search(TEST_COLLECTION,
"*:*", null, null, null, 0, DOC_COUNT);
+ assertEquals(DOC_COUNT, all.numFound(), "Should find all
indexed documents");
+ assertEquals(DOC_COUNT, all.documents().size(), "Should return
all documents in single page");
+
+ // Filter search should return only even-category docs
+ SearchResponse evens = searchService.search(TEST_COLLECTION,
"*:*", List.of("category_s:even"), null, null, 0,
+ DOC_COUNT);
+ assertEquals(DOC_COUNT / 2, evens.numFound(), "Should find 25
even-category documents");
}
}
diff --git
a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java
b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java
index 4f72424..81777d0 100644
---
a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java
+++
b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java
@@ -200,6 +200,7 @@ class CollectionServiceTest {
assertNull(result.errorMessage());
assertEquals(10L, result.responseTime());
assertEquals(100L, result.totalDocuments());
+ assertEquals("test_collection", result.collection());
}
@Test
@@ -217,6 +218,7 @@ class CollectionServiceTest {
assertTrue(result.errorMessage().contains("Connection failed"));
assertNull(result.responseTime());
assertNull(result.totalDocuments());
+ assertEquals("unhealthy_collection", result.collection());
}
@Test
@@ -460,9 +462,9 @@ class CollectionServiceTest {
CollectionService spyService = spy(collectionService);
doReturn(Arrays.asList("test_collection")).when(spyService).listCollections();
- NamedList<Object> mbeans = new NamedList<>();
- mbeans.add("CACHE", new NamedList<>());
-
when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans);
+ // Metrics response with core metrics that contain no cache keys
+ NamedList<Object> response = wrapInMetricsResponse(new
NamedList<>());
+
when(solrClient.request(any(SolrRequest.class))).thenReturn(response);
CacheStats result =
spyService.getCacheMetrics("test_collection");
@@ -474,8 +476,8 @@ class CollectionServiceTest {
CollectionService spyService = spy(collectionService);
doReturn(Arrays.asList("films_shard1_replica_n1")).when(spyService).listCollections();
- NamedList<Object> mbeans = createMockCacheData();
-
when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans);
+ NamedList<Object> response =
wrapInMetricsResponse(createCacheCoreMetrics(), "films");
+
when(solrClient.request(any(SolrRequest.class))).thenReturn(response);
CacheStats result =
spyService.getCacheMetrics("films_shard1_replica_n1");
@@ -484,11 +486,11 @@ class CollectionServiceTest {
@Test
void extractCacheStats() throws Exception {
- NamedList<Object> mbeans = createMockCacheData();
+ NamedList<Object> coreMetrics = createCacheCoreMetrics();
Method method =
CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class);
method.setAccessible(true);
- CacheStats result = (CacheStats)
method.invoke(collectionService, mbeans);
+ CacheStats result = (CacheStats)
method.invoke(collectionService, coreMetrics);
assertNotNull(result.queryResultCache());
assertEquals(100L, result.queryResultCache().lookups());
@@ -497,11 +499,11 @@ class CollectionServiceTest {
@Test
void extractCacheStats_AllCacheTypes() throws Exception {
- NamedList<Object> mbeans = createCompleteMockCacheData();
+ NamedList<Object> coreMetrics =
createCompleteCacheCoreMetrics();
Method method =
CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class);
method.setAccessible(true);
- CacheStats result = (CacheStats)
method.invoke(collectionService, mbeans);
+ CacheStats result = (CacheStats)
method.invoke(collectionService, coreMetrics);
assertNotNull(result.queryResultCache());
assertNotNull(result.documentCache());
@@ -509,14 +511,14 @@ class CollectionServiceTest {
}
@Test
- void extractCacheStats_NullCacheCategory() throws Exception {
- NamedList<Object> mbeans = new NamedList<>();
- mbeans.add("CACHE", null);
+ void extractCacheStats_NoCacheKeys() throws Exception {
+ // Core metrics with no cache keys at all
+ NamedList<Object> coreMetrics = new NamedList<>();
Method method =
CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class);
method.setAccessible(true);
- CacheStats result = (CacheStats)
method.invoke(collectionService, mbeans);
+ CacheStats result = (CacheStats)
method.invoke(collectionService, coreMetrics);
assertNotNull(result);
assertNull(result.queryResultCache());
@@ -552,13 +554,15 @@ class CollectionServiceTest {
CollectionService spyService = spy(collectionService);
doReturn(Arrays.asList("test_collection")).when(spyService).listCollections();
- NamedList<Object> mbeans = createMockHandlerData();
-
when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans);
+ // getHandlerMetrics makes two fetchMetrics calls (select then
update);
+ // return select handler data for both calls (second has no
update keys -> null)
+
when(solrClient.request(any(SolrRequest.class))).thenReturn(createMockSelectHandlerData());
HandlerStats result =
spyService.getHandlerMetrics("test_collection");
assertNotNull(result);
assertNotNull(result.selectHandler());
+ assertEquals(500L, result.selectHandler().requests());
}
@Test
@@ -600,9 +604,9 @@ class CollectionServiceTest {
CollectionService spyService = spy(collectionService);
doReturn(Arrays.asList("test_collection")).when(spyService).listCollections();
- NamedList<Object> mbeans = new NamedList<>();
- mbeans.add("QUERYHANDLER", new NamedList<>());
-
when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans);
+ // Metrics response with core metrics that contain no handler
keys
+ NamedList<Object> response = wrapInMetricsResponse(new
NamedList<>());
+
when(solrClient.request(any(SolrRequest.class))).thenReturn(response);
HandlerStats result =
spyService.getHandlerMetrics("test_collection");
@@ -614,8 +618,7 @@ class CollectionServiceTest {
CollectionService spyService = spy(collectionService);
doReturn(Arrays.asList("films_shard1_replica_n1")).when(spyService).listCollections();
- NamedList<Object> mbeans = createMockHandlerData();
-
when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans);
+
when(solrClient.request(any(SolrRequest.class))).thenReturn(createMockSelectHandlerData("films"));
HandlerStats result =
spyService.getHandlerMetrics("films_shard1_replica_n1");
@@ -623,44 +626,47 @@ class CollectionServiceTest {
}
@Test
- void extractHandlerStats() throws Exception {
- NamedList<Object> mbeans = createMockHandlerData();
- Method method =
CollectionService.class.getDeclaredMethod("extractHandlerStats",
NamedList.class);
+ void extractFlatHandlerInfo_SelectHandler() throws Exception {
+ NamedList<Object> coreMetrics =
createSelectHandlerCoreMetrics();
+ Method method =
CollectionService.class.getDeclaredMethod("extractFlatHandlerInfo",
NamedList.class,
+ String.class);
method.setAccessible(true);
- HandlerStats result = (HandlerStats)
method.invoke(collectionService, mbeans);
+ HandlerInfo result = (HandlerInfo)
method.invoke(collectionService, coreMetrics, "QUERY./select.");
- assertNotNull(result.selectHandler());
- assertEquals(500L, result.selectHandler().requests());
+ assertNotNull(result);
+ assertEquals(500L, result.requests());
+ assertEquals(5L, result.errors());
+ assertEquals(2L, result.timeouts());
+ assertEquals(10000L, result.totalTime());
+ // avgTimePerRequest computed: 10000/500 = 20.0
+ assertEquals(20.0f, result.avgTimePerRequest());
}
@Test
- void extractHandlerStats_BothHandlers() throws Exception {
- NamedList<Object> mbeans = createCompleteHandlerData();
- Method method =
CollectionService.class.getDeclaredMethod("extractHandlerStats",
NamedList.class);
+ void extractFlatHandlerInfo_UpdateHandler() throws Exception {
+ NamedList<Object> coreMetrics =
createUpdateHandlerCoreMetrics();
+ Method method =
CollectionService.class.getDeclaredMethod("extractFlatHandlerInfo",
NamedList.class,
+ String.class);
method.setAccessible(true);
- HandlerStats result = (HandlerStats)
method.invoke(collectionService, mbeans);
+ HandlerInfo result = (HandlerInfo)
method.invoke(collectionService, coreMetrics, "UPDATE./update.");
- assertNotNull(result.selectHandler());
- assertNotNull(result.updateHandler());
- assertEquals(500L, result.selectHandler().requests());
- assertEquals(250L, result.updateHandler().requests());
+ assertNotNull(result);
+ assertEquals(250L, result.requests());
+ assertEquals(2L, result.errors());
}
@Test
- void extractHandlerStats_NullHandlerCategory() throws Exception {
- NamedList<Object> mbeans = new NamedList<>();
- mbeans.add("QUERYHANDLER", null);
-
- Method method =
CollectionService.class.getDeclaredMethod("extractHandlerStats",
NamedList.class);
+ void extractFlatHandlerInfo_NoHandlerKeys() throws Exception {
+ NamedList<Object> coreMetrics = new NamedList<>();
+ Method method =
CollectionService.class.getDeclaredMethod("extractFlatHandlerInfo",
NamedList.class,
+ String.class);
method.setAccessible(true);
- HandlerStats result = (HandlerStats)
method.invoke(collectionService, mbeans);
+ HandlerInfo result = (HandlerInfo)
method.invoke(collectionService, coreMetrics, "QUERY./select.");
- assertNotNull(result);
- assertNull(result.selectHandler());
- assertNull(result.updateHandler());
+ assertNull(result);
}
@Test
@@ -725,121 +731,99 @@ class CollectionServiceTest {
assertTrue(result.isEmpty());
}
- // Helper methods
- private NamedList<Object> createMockCacheData() {
- NamedList<Object> mbeans = new NamedList<>();
- NamedList<Object> cacheCategory = new NamedList<>();
- NamedList<Object> queryResultCache = new NamedList<>();
- NamedList<Object> queryStats = new NamedList<>();
+ // Helper methods — mock the Solr Metrics API response format:
+ // response -> "metrics" -> "solr.core.<name>" -> "CACHE.searcher.xxx" /
+ // "HANDLER./xxx"
+
+ // Core metrics builders (unwrapped — used by reflection tests for
extract*
+ // methods)
+ private NamedList<Object> createCacheCoreMetrics() {
+ NamedList<Object> coreMetrics = new NamedList<>();
- queryStats.add("lookups", 100L);
- queryStats.add("hits", 80L);
- queryStats.add("hitratio", 0.8f);
- queryStats.add("inserts", 20L);
- queryStats.add("evictions", 5L);
- queryStats.add("size", 100L);
- queryResultCache.add("stats", queryStats);
- cacheCategory.add("queryResultCache", queryResultCache);
- mbeans.add("CACHE", cacheCategory);
+ NamedList<Object> queryResultCache = new NamedList<>();
+ queryResultCache.add("lookups", 100L);
+ queryResultCache.add("hits", 80L);
+ queryResultCache.add("hitratio", 0.8f);
+ queryResultCache.add("inserts", 20L);
+ queryResultCache.add("evictions", 5L);
+ queryResultCache.add("size", 100L);
+ coreMetrics.add("CACHE.searcher.queryResultCache",
queryResultCache);
- return mbeans;
+ return coreMetrics;
}
- private NamedList<Object> createCompleteMockCacheData() {
- NamedList<Object> mbeans = new NamedList<>();
- NamedList<Object> cacheCategory = new NamedList<>();
+ private NamedList<Object> createCompleteCacheCoreMetrics() {
+ NamedList<Object> coreMetrics = createCacheCoreMetrics();
- // Query Result Cache
- NamedList<Object> queryResultCache = new NamedList<>();
- NamedList<Object> queryStats = new NamedList<>();
- queryStats.add("lookups", 100L);
- queryStats.add("hits", 80L);
- queryStats.add("hitratio", 0.8f);
- queryStats.add("inserts", 20L);
- queryStats.add("evictions", 5L);
- queryStats.add("size", 100L);
- queryResultCache.add("stats", queryStats);
-
- // Document Cache
NamedList<Object> documentCache = new NamedList<>();
- NamedList<Object> docStats = new NamedList<>();
- docStats.add("lookups", 200L);
- docStats.add("hits", 150L);
- docStats.add("hitratio", 0.75f);
- docStats.add("inserts", 50L);
- docStats.add("evictions", 10L);
- docStats.add("size", 180L);
- documentCache.add("stats", docStats);
-
- // Filter Cache
+ documentCache.add("lookups", 200L);
+ documentCache.add("hits", 150L);
+ documentCache.add("hitratio", 0.75f);
+ documentCache.add("inserts", 50L);
+ documentCache.add("evictions", 10L);
+ documentCache.add("size", 180L);
+ coreMetrics.add("CACHE.searcher.documentCache", documentCache);
+
NamedList<Object> filterCache = new NamedList<>();
- NamedList<Object> filterStats = new NamedList<>();
- filterStats.add("lookups", 150L);
- filterStats.add("hits", 120L);
- filterStats.add("hitratio", 0.8f);
- filterStats.add("inserts", 30L);
- filterStats.add("evictions", 8L);
- filterStats.add("size", 140L);
- filterCache.add("stats", filterStats);
-
- cacheCategory.add("queryResultCache", queryResultCache);
- cacheCategory.add("documentCache", documentCache);
- cacheCategory.add("filterCache", filterCache);
- mbeans.add("CACHE", cacheCategory);
-
- return mbeans;
- }
-
- private NamedList<Object> createMockHandlerData() {
- NamedList<Object> mbeans = new NamedList<>();
- NamedList<Object> queryHandlerCategory = new NamedList<>();
- NamedList<Object> selectHandler = new NamedList<>();
- NamedList<Object> selectStats = new NamedList<>();
-
- selectStats.add("requests", 500L);
- selectStats.add("errors", 5L);
- selectStats.add("timeouts", 2L);
- selectStats.add("totalTime", 10000L);
- selectStats.add("avgTimePerRequest", 20.0f);
- selectStats.add("avgRequestsPerSecond", 25.0f);
- selectHandler.add("stats", selectStats);
- queryHandlerCategory.add("/select", selectHandler);
- mbeans.add("QUERYHANDLER", queryHandlerCategory);
-
- return mbeans;
- }
-
- private NamedList<Object> createCompleteHandlerData() {
- NamedList<Object> mbeans = new NamedList<>();
- NamedList<Object> queryHandlerCategory = new NamedList<>();
-
- // Select Handler
- NamedList<Object> selectHandler = new NamedList<>();
- NamedList<Object> selectStats = new NamedList<>();
- selectStats.add("requests", 500L);
- selectStats.add("errors", 5L);
- selectStats.add("timeouts", 2L);
- selectStats.add("totalTime", 10000L);
- selectStats.add("avgTimePerRequest", 20.0f);
- selectStats.add("avgRequestsPerSecond", 25.0f);
- selectHandler.add("stats", selectStats);
-
- // Update Handler
- NamedList<Object> updateHandler = new NamedList<>();
- NamedList<Object> updateStats = new NamedList<>();
- updateStats.add("requests", 250L);
- updateStats.add("errors", 2L);
- updateStats.add("timeouts", 1L);
- updateStats.add("totalTime", 5000L);
- updateStats.add("avgTimePerRequest", 20.0f);
- updateStats.add("avgRequestsPerSecond", 50.0f);
- updateHandler.add("stats", updateStats);
-
- queryHandlerCategory.add("/select", selectHandler);
- queryHandlerCategory.add("/update", updateHandler);
- mbeans.add("QUERYHANDLER", queryHandlerCategory);
-
- return mbeans;
+ filterCache.add("lookups", 150L);
+ filterCache.add("hits", 120L);
+ filterCache.add("hitratio", 0.8f);
+ filterCache.add("inserts", 30L);
+ filterCache.add("evictions", 8L);
+ filterCache.add("size", 140L);
+ coreMetrics.add("CACHE.searcher.filterCache", filterCache);
+
+ return coreMetrics;
+ }
+
+ // Handler metrics use flat keys in the Metrics API (e.g.
+ // QUERY./select.requests)
+ private NamedList<Object> createSelectHandlerCoreMetrics() {
+ NamedList<Object> coreMetrics = new NamedList<>();
+ coreMetrics.add("QUERY./select.requests", 500L);
+ coreMetrics.add("QUERY./select.errors", 5L);
+ coreMetrics.add("QUERY./select.timeouts", 2L);
+ coreMetrics.add("QUERY./select.totalTime", 10000L);
+ return coreMetrics;
+ }
+
+ private NamedList<Object> createUpdateHandlerCoreMetrics() {
+ NamedList<Object> coreMetrics = new NamedList<>();
+ coreMetrics.add("UPDATE./update.requests", 250L);
+ coreMetrics.add("UPDATE./update.errors", 2L);
+ coreMetrics.add("UPDATE./update.timeouts", 1L);
+ coreMetrics.add("UPDATE./update.totalTime", 5000L);
+ return coreMetrics;
+ }
+
+ // Wrapped response builders (used by tests that go through
+ // getCacheMetrics/getHandlerMetrics)
+ private NamedList<Object> createMockCacheData() {
+ return wrapInMetricsResponse(createCacheCoreMetrics());
+ }
+
+ private NamedList<Object> createMockSelectHandlerData() {
+ return wrapInMetricsResponse(createSelectHandlerCoreMetrics());
+ }
+
+ private NamedList<Object> createMockSelectHandlerData(String
collection) {
+ return wrapInMetricsResponse(createSelectHandlerCoreMetrics(),
collection);
+ }
+
+ private NamedList<Object> createMockUpdateHandlerData() {
+ return wrapInMetricsResponse(createUpdateHandlerCoreMetrics());
+ }
+
+ private NamedList<Object> wrapInMetricsResponse(NamedList<Object>
coreMetrics) {
+ return wrapInMetricsResponse(coreMetrics, "test_collection");
+ }
+
+ private NamedList<Object> wrapInMetricsResponse(NamedList<Object>
coreMetrics, String collection) {
+ NamedList<Object> metrics = new NamedList<>();
+ metrics.add("solr.core." + collection + ".shard1.replica_n1",
coreMetrics);
+ NamedList<Object> response = new NamedList<>();
+ response.add("metrics", metrics);
+ return response;
}
// createCollection tests
diff --git
a/src/test/java/org/apache/solr/mcp/server/collection/CollectionUtilsTest.java
b/src/test/java/org/apache/solr/mcp/server/collection/CollectionUtilsTest.java
index a4f9a7d..d4f0a89 100644
---
a/src/test/java/org/apache/solr/mcp/server/collection/CollectionUtilsTest.java
+++
b/src/test/java/org/apache/solr/mcp/server/collection/CollectionUtilsTest.java
@@ -123,14 +123,14 @@ class CollectionUtilsTest {
NamedList<Object> namedList = new NamedList<>();
namedList.add("nullKey", null);
- assertEquals(0.0f, CollectionUtils.getFloat(namedList,
"nullKey"));
+ assertNull(CollectionUtils.getFloat(namedList, "nullKey"));
}
@Test
void testGetFloat_withMissingKey() {
NamedList<Object> namedList = new NamedList<>();
- assertEquals(0.0f, CollectionUtils.getFloat(namedList,
"missingKey"));
+ assertNull(CollectionUtils.getFloat(namedList, "missingKey"));
}
@Test
@@ -173,6 +173,30 @@ class CollectionUtilsTest {
assertEquals(123.45f, CollectionUtils.getFloat(namedList,
"bigDecKey"), 0.001f);
}
+ @Test
+ void testGetFloat_withValidStringValue() {
+ NamedList<Object> namedList = new NamedList<>();
+ namedList.add("stringKey", "123.45");
+
+ assertEquals(123.45f, CollectionUtils.getFloat(namedList,
"stringKey"), 0.001f);
+ }
+
+ @Test
+ void testGetFloat_withInvalidStringValue() {
+ NamedList<Object> namedList = new NamedList<>();
+ namedList.add("invalidStringKey", "not_a_number");
+
+ assertNull(CollectionUtils.getFloat(namedList,
"invalidStringKey"));
+ }
+
+ @Test
+ void testGetFloat_withEmptyStringValue() {
+ NamedList<Object> namedList = new NamedList<>();
+ namedList.add("emptyStringKey", "");
+
+ assertNull(CollectionUtils.getFloat(namedList,
"emptyStringKey"));
+ }
+
@Test
void testGetInteger_withNullValue() {
NamedList<Object> namedList = new NamedList<>();