Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,11 @@
* <strong>Error Handling:</strong>
*
* <p>
* The service implements robust error handling with graceful degradation.
* Failed operations return null values rather than throwing exceptions (except
* where validation requires it), allowing partial metrics collection when some
* endpoints are unavailable.
* Collection-level operations (listing, validation) propagate
* {@link SolrServerException} and {@link IOException} so that callers
* (including the MCP framework) receive a clear error when Solr is unreachable.
* Sub-metric operations (cache, handler) degrade gracefully and return
* {@code null} when their specific endpoints are unavailable.
*
* <p>
* <strong>Example Usage:</strong>
Expand Down Expand Up @@ -282,7 +283,7 @@ public CollectionService(SolrClient solrClient, ObjectMapper objectMapper) {
* @return JSON string containing the list of collections
*/
@McpResource(uri = "solr://collections", name = "solr-collections", description = "List of all Solr collections available in the cluster", mimeType = "application/json")
public String getCollectionsResource() {
public String getCollectionsResource() throws SolrServerException, IOException {
return toJson(objectMapper, listCollections());
}

Expand All @@ -297,7 +298,7 @@ public String getCollectionsResource() {
* @return list of available collection names for autocompletion
*/
@McpComplete(uri = "solr://{collection}/schema")
public List<String> completeCollectionForSchema() {
public List<String> completeCollectionForSchema() throws SolrServerException, IOException {
return listCollections();
}

Expand All @@ -314,38 +315,30 @@ public List<String> completeCollectionForSchema() {
* the base collection name if needed.
*
* <p>
* <strong>Error Handling:</strong>
*
* <p>
* If the operation fails due to connectivity issues or API errors, an empty
* list is returned rather than throwing an exception, allowing the application
* to continue functioning with degraded capabilities.
*
* <p>
* <strong>MCP Tool Usage:</strong>
*
* <p>
* This method is exposed as an MCP tool and can be invoked by AI clients with
* natural language requests like "list all collections" or "show me available
* databases".
*
* @return a list of collection names, or an empty list if unable to retrieve
* them
* @return a list of collection names; never null (returns an empty list when
* Solr reports no collections)
* @throws SolrServerException
* if there are errors communicating with Solr
* @throws IOException
* if there are I/O errors during communication
* @see CollectionAdminRequest.List
*/
@PreAuthorize("isAuthenticated()")
@McpTool(name = "list-collections", description = "List solr collections")
public List<String> listCollections() {
try {
CollectionAdminRequest.List request = new CollectionAdminRequest.List();
CollectionAdminResponse response = request.process(solrClient);

@SuppressWarnings("unchecked")
List<String> collections = (List<String>) response.getResponse().get(COLLECTIONS_KEY);
return collections != null ? collections : new ArrayList<>();
} catch (SolrServerException | IOException _) {
return new ArrayList<>();
}
public List<String> listCollections() throws SolrServerException, IOException {
CollectionAdminRequest.List request = new CollectionAdminRequest.List();
CollectionAdminResponse response = request.process(solrClient);

@SuppressWarnings("unchecked")
List<String> collections = (List<String>) response.getResponse().get(COLLECTIONS_KEY);
return collections != null ? collections : new ArrayList<>();
}

/**
Expand Down Expand Up @@ -558,7 +551,7 @@ public QueryStats buildQueryStats(QueryResponse response) {
* @see #extractCacheStats(NamedList)
* @see #isCacheStatsEmpty(CacheStats)
*/
public CacheStats getCacheMetrics(String collection) {
public CacheStats getCacheMetrics(String collection) throws SolrServerException, IOException {
String actualCollection = extractCollectionName(collection);

if (!validateCollectionExists(actualCollection)) {
Expand Down Expand Up @@ -670,7 +663,7 @@ private CacheInfo extractSingleCacheInfo(NamedList<Object> coreMetrics, String k
* @see #fetchFlatHandlerInfo(String, String, String)
* @see #isHandlerStatsEmpty(HandlerStats)
*/
public HandlerStats getHandlerMetrics(String collection) {
public HandlerStats getHandlerMetrics(String collection) throws SolrServerException, IOException {
String actualCollection = extractCollectionName(collection);

if (!validateCollectionExists(actualCollection)) {
Expand Down Expand Up @@ -876,36 +869,29 @@ String extractCollectionName(String collectionOrShard) {
* This dual approach ensures compatibility with SolrCloud environments where
* shard names may be returned alongside collection names.
*
* <p>
* <strong>Error Handling:</strong>
*
* <p>
* Returns {@code false} if validation fails due to communication errors,
* allowing calling methods to handle missing collections appropriately.
*
* @param collection
* the collection name to validate
* @return true if the collection exists (either exact or shard match), false
* otherwise
* @throws SolrServerException
* if there are errors communicating with Solr
* @throws IOException
* if there are I/O errors during communication
* @see #listCollections()
* @see #extractCollectionName(String)
*/
private boolean validateCollectionExists(String collection) {
try {
List<String> collections = listCollections();

// Check for exact match first
if (collections.contains(collection)) {
return true;
}
private boolean validateCollectionExists(String collection) throws SolrServerException, IOException {
List<String> collections = listCollections();

// Check if any of the returned collections start with the collection name (for
// shard
// names)
return collections.stream().anyMatch(c -> c.startsWith(collection + SHARD_SUFFIX));
} catch (Exception e) {
return false;
// Check for exact match first
if (collections.contains(collection)) {
return true;
}

// Check if any of the returned collections start with the collection name (for
// shard
// names)
return collections.stream().anyMatch(c -> c.startsWith(collection + SHARD_SUFFIX));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ void setupCollectionWithData() throws Exception {
}

@Test
void testListCollections() {
void testListCollections() throws Exception {
List<String> collections = collectionService.listCollections();

log.debug("Collections: {}", collections);
Expand Down Expand Up @@ -200,7 +200,7 @@ void testCollectionNameExtraction() {
}

@Test
void testGetCacheMetrics_afterQueries() {
void testGetCacheMetrics_afterQueries() throws Exception {
CacheStats cacheStats = collectionService.getCacheMetrics(TEST_COLLECTION);

assertNotNull(cacheStats, "Cache stats should not be null after warm-up queries");
Expand All @@ -224,7 +224,7 @@ void testGetCacheMetrics_afterQueries() {
}

@Test
void testGetHandlerMetrics_afterQueriesAndIndexing() {
void testGetHandlerMetrics_afterQueriesAndIndexing() throws Exception {
HandlerStats handlerStats = collectionService.getHandlerMetrics(TEST_COLLECTION);

assertNotNull(handlerStats, "Handler stats should not be null after activity");
Expand All @@ -243,12 +243,12 @@ void testGetHandlerMetrics_afterQueriesAndIndexing() {
}

@Test
void testGetCacheMetrics_nonExistentCollection() {
void testGetCacheMetrics_nonExistentCollection() throws Exception {
assertNull(collectionService.getCacheMetrics("non_existent_collection"));
}

@Test
void testGetHandlerMetrics_nonExistentCollection() {
void testGetHandlerMetrics_nonExistentCollection() throws Exception {
assertNull(collectionService.getHandlerMetrics("non_existent_collection"));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,14 @@ void constructor_ShouldInitializeWithSolrClient() {
}

@Test
void listCollections_WhenExceptionOccurs_ShouldReturnEmptyList() throws Exception {
void listCollections_WhenExceptionOccurs_ShouldPropagate() throws Exception {
// Given - mock throws exception
when(solrClient.request(any(), any())).thenThrow(new SolrServerException("Connection error"));

// When
List<String> result = collectionService.listCollections();

// Then
assertNotNull(result);
assertTrue(result.isEmpty());
// When / Then - exception propagates to caller
SolrServerException exception = assertThrows(SolrServerException.class,
() -> collectionService.listCollections());
assertTrue(exception.getMessage().contains("Connection error"));
}

// Collection name extraction tests
Expand Down Expand Up @@ -365,7 +363,7 @@ void buildIndexStats_WithNullSegmentCount() {

// Collection validation tests
@Test
void getCollectionStats_NotFound() {
void getCollectionStats_NotFound() throws Exception {
CollectionService spyService = spy(collectionService);
doReturn(Collections.emptyList()).when(spyService).listCollections();

Expand All @@ -390,7 +388,7 @@ void validateCollectionExists() throws Exception {
}

@Test
void validateCollectionExists_WithException() throws Exception {
void validateCollectionExists_WithEmptyList() throws Exception {
CollectionService spyService = spy(collectionService);
doReturn(Collections.emptyList()).when(spyService).listCollections();

Expand All @@ -402,9 +400,12 @@ void validateCollectionExists_WithException() throws Exception {

// Cache metrics tests
@Test
void getCacheMetrics_WithNonExistentCollection_ShouldReturnNull() {
// When - Mock will not have collection configured
CacheStats result = collectionService.getCacheMetrics("nonexistent");
void getCacheMetrics_WithNonExistentCollection_ShouldReturnNull() throws Exception {
CollectionService spyService = spy(collectionService);
doReturn(Collections.emptyList()).when(spyService).listCollections();

// When - collection not found in empty list
CacheStats result = spyService.getCacheMetrics("nonexistent");

// Then
assertNull(result);
Expand All @@ -426,7 +427,7 @@ void getCacheMetrics_Success() throws Exception {
}

@Test
void getCacheMetrics_CollectionNotFound() {
void getCacheMetrics_CollectionNotFound() throws Exception {
CollectionService spyService = spy(collectionService);
doReturn(Collections.emptyList()).when(spyService).listCollections();

Expand Down Expand Up @@ -543,9 +544,12 @@ void isCacheStatsEmpty() throws Exception {

// Handler metrics tests
@Test
void getHandlerMetrics_WithNonExistentCollection_ShouldReturnNull() {
// When - Mock will not have collection configured
HandlerStats result = collectionService.getHandlerMetrics("nonexistent");
void getHandlerMetrics_WithNonExistentCollection_ShouldReturnNull() throws Exception {
CollectionService spyService = spy(collectionService);
doReturn(Collections.emptyList()).when(spyService).listCollections();

// When - collection not found in empty list
HandlerStats result = spyService.getHandlerMetrics("nonexistent");

// Then
assertNull(result);
Expand All @@ -568,7 +572,7 @@ void getHandlerMetrics_Success() throws Exception {
}

@Test
void getHandlerMetrics_CollectionNotFound() {
void getHandlerMetrics_CollectionNotFound() throws Exception {
CollectionService spyService = spy(collectionService);
doReturn(Collections.emptyList()).when(spyService).listCollections();

Expand Down Expand Up @@ -714,23 +718,17 @@ void listCollections_NullCollections() throws Exception {
}

@Test
void listCollections_Error() throws Exception {
void listCollections_SolrServerException_Propagates() throws Exception {
when(solrClient.request(any(), any())).thenThrow(new SolrServerException("Connection error"));

List<String> result = collectionService.listCollections();

assertNotNull(result);
assertTrue(result.isEmpty());
assertThrows(SolrServerException.class, () -> collectionService.listCollections());
}

@Test
void listCollections_IOError() throws Exception {
void listCollections_IOException_Propagates() throws Exception {
when(solrClient.request(any(), any())).thenThrow(new IOException("IO error"));

List<String> result = collectionService.listCollections();

assertNotNull(result);
assertTrue(result.isEmpty());
assertThrows(IOException.class, () -> collectionService.listCollections());
}

// Helper methods — mock the Solr Metrics API response format:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ void createAndIndexConferences() throws Exception {

@Test
@Order(1)
void collectionAppearsInList() {
void collectionAppearsInList() throws Exception {
List<String> collections = collectionService.listCollections();
boolean found = collections.contains(COLLECTION)
|| collections.stream().anyMatch(c -> c.startsWith(COLLECTION + "_shard"));
Expand Down