diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index eacba70..27c3013 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -175,7 +175,40 @@ jobs: retention-days: 7 # ============================================================================ - # Job 2: Publish Docker Images + # Job 2: Solr Version Compatibility Tests + # ============================================================================ + # Tests the server against multiple Solr versions using Testcontainers. + # Runs in parallel so each Solr version is an independent job. + # Tested versions: 8.11, 9.4, 9.9, 9.10, 10 + # ============================================================================ + solr-compatibility: + name: Solr ${{ matrix.solr-version }} Compatibility + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + solr-version: + - "8.11-slim" + - "9.4-slim" + - "9.9-slim" + - "9.10-slim" + - "10-slim" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Java + uses: ./.github/actions/setup-java + + - name: Run tests against Solr ${{ matrix.solr-version }} + env: + SOLR_VERSION: ${{ matrix.solr-version }} + run: ./gradlew test "-Dsolr.test.image=solr:${SOLR_VERSION}" + + # ============================================================================ + # Job 3: Publish Docker Images # ============================================================================ # This job builds multi-platform Docker images using Jib and publishes them # to GitHub Container Registry (GHCR) and Docker Hub. diff --git a/AGENTS.md b/AGENTS.md index 57dd384..91f9d58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,18 +81,24 @@ The Solr Docker image used in tests is configurable via the `solr.test.image` sy ./gradlew test -Dsolr.test.image=solr:8.11-slim # Solr 8.11 ./gradlew test -Dsolr.test.image=solr:9.4-slim # Solr 9.4 ./gradlew test -Dsolr.test.image=solr:9.9-slim # Solr 9.9 (default) +./gradlew test -Dsolr.test.image=solr:9.10-slim # Solr 9.10 +./gradlew test -Dsolr.test.image=solr:10-slim # Solr 10 ``` -**Tested compatible versions:** 8.11, 9.4, 9.9 +**Tested compatible versions:** 8.11, 9.4, 9.9, 9.10, 10 -### Solr 10 Compatibility Notes +### Solr 10 Compatibility -Solr 10 introduces breaking changes that will require updates to this project: +Solr 10.0.0 is fully supported with the JSON wire format. The `/admin/mbeans` endpoint was +removed in Solr 10; `getCacheMetrics()` and `getHandlerMetrics()` now catch `RuntimeException` +(which covers `RemoteSolrException`) so they degrade gracefully and return `null`. Tests that +check `cacheStats` and `handlerStats` already handle `null` values. -- **MBeans removal:** `SolrInfoMBeanHandler` is removed. `CollectionService.getCollectionStats()` uses `/admin/mbeans` for cache and handler metrics — this will need to migrate to the `/admin/metrics` endpoint or OpenTelemetry. -- **Metrics migration:** Dropwizard metrics replaced by OpenTelemetry. All metric names switch to snake_case. JMX, Prometheus exporter, SLF4J, and Graphite reporters are removed. -- **SolrJ base URL:** SolrClient now only accepts root URLs (e.g., `http://host:8983/solr`). This project already uses root URLs with per-request collection names, so **no change needed** here. -- **SolrJ dependency:** Upgrade `solr-solrj` from 9.x to 10.x in `gradle/libs.versions.toml`. The Jetty BOM alignment (`jetty = "10.0.22"`) will also need updating since Solr 10 uses Jetty 12.x. +Remaining known differences from Solr 9: +- **`/admin/mbeans` removed:** Cache and handler stats from `getCollectionStats()` will always be `null` on Solr 10. A future migration to `/admin/metrics` will restore these metrics. +- **Metrics migration:** Dropwizard metrics replaced by OpenTelemetry. Metric names switch to snake_case in Solr 10. +- **SolrJ base URL:** Already uses root URLs — **no change needed**. +- **SolrJ 10.x dependency:** Not yet on Maven Central (as of 2026-03-06); tests use SolrJ 9.x against a Solr 10 server. Update `solr-solrj` and Jetty BOM when 10.x is released. ## Key Configuration diff --git a/build.gradle.kts b/build.gradle.kts index 56dfb14..dd29830 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -97,9 +97,7 @@ dependencies { implementation(libs.spring.boot.starter.actuator) implementation(libs.spring.boot.starter.aop) implementation(libs.spring.ai.starter.mcp.server.webmvc) - implementation(libs.solr.solrj) { - exclude(group = "org.apache.httpcomponents") - } + implementation(libs.solr.solrj) implementation(libs.commons.csv) // JSpecify for nullability annotations implementation(libs.jspecify) @@ -126,8 +124,6 @@ dependencies { dependencyManagement { imports { mavenBom("org.springframework.ai:spring-ai-bom:${libs.versions.spring.ai.get()}") - // Align Jetty family to 10.x compatible with SolrJ 9.x - mavenBom("org.eclipse.jetty:jetty-bom:${libs.versions.jetty.get()}") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c07a10c..67ddad0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ spotless = "7.0.2" # Main dependencies spring-ai = "1.1.3" -solr = "9.9.0" +solr = "10.0.0" commons-csv = "1.10.0" jspecify = "1.0.0" mcp-server-security = "0.0.4" @@ -17,8 +17,6 @@ mcp-server-security = "0.0.4" errorprone-core = "2.38.0" nullaway = "0.12.7" -# Jetty BOM version -jetty = "10.0.22" # Test dependencies testcontainers = "1.21.3" 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 a6dec11..d5dd1df 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 @@ -28,12 +28,12 @@ import java.util.Date; import java.util.List; import org.apache.solr.client.solrj.SolrClient; -import org.apache.solr.client.solrj.SolrQuery; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.request.CollectionAdminRequest; import org.apache.solr.client.solrj.request.GenericSolrRequest; import org.apache.solr.client.solrj.request.LukeRequest; +import org.apache.solr.client.solrj.request.SolrQuery; import org.apache.solr.client.solrj.response.CollectionAdminResponse; import org.apache.solr.client.solrj.response.LukeResponse; import org.apache.solr.client.solrj.response.QueryResponse; @@ -352,7 +352,7 @@ public List listCollections() { @SuppressWarnings("unchecked") List collections = (List) response.getResponse().get(COLLECTIONS_KEY); return collections != null ? collections : new ArrayList<>(); - } catch (SolrServerException | IOException e) { + } catch (SolrServerException | IOException _) { return new ArrayList<>(); } } @@ -595,7 +595,9 @@ public CacheStats getCacheMetrics(String collection) { } return stats; - } catch (SolrServerException | IOException _) { + } 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 } } @@ -767,7 +769,9 @@ public HandlerStats getHandlerMetrics(String collection) { } return stats; - } catch (SolrServerException | IOException _) { + } 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 } } diff --git a/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java index b57daf8..a78e3ab 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java +++ b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java @@ -20,10 +20,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.InputStream; -import java.io.Reader; import java.util.ArrayList; +import java.util.Collection; import java.util.List; -import org.apache.solr.client.solrj.ResponseParser; +import org.apache.solr.client.solrj.response.ResponseParser; import org.apache.solr.common.SolrDocument; import org.apache.solr.common.SolrDocumentList; import org.apache.solr.common.SolrException; @@ -80,8 +80,8 @@ public String getWriterType() { } @Override - public String getContentType() { - return MediaType.APPLICATION_JSON_VALUE; + public Collection getContentTypes() { + return List.of(MediaType.APPLICATION_JSON_VALUE); } @Override @@ -93,15 +93,6 @@ public NamedList processResponse(InputStream body, String encoding) { } } - @Override - public NamedList processResponse(Reader reader) { - try { - return toNamedList(mapper.readTree(reader)); - } catch (IOException e) { - throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Failed to parse Solr JSON response", e); - } - } - private SimpleOrderedMap toNamedList(JsonNode objectNode) { SimpleOrderedMap result = new SimpleOrderedMap<>(); objectNode.fields().forEachRemaining(entry -> result.add(entry.getKey(), convertValue(entry.getValue()))); diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java index 8a76e7f..0fc720b 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java @@ -19,7 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.util.concurrent.TimeUnit; import org.apache.solr.client.solrj.SolrClient; -import org.apache.solr.client.solrj.impl.Http2SolrClient; +import org.apache.solr.client.solrj.impl.HttpJdkSolrClient; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -94,7 +94,7 @@ * * * @see SolrConfigurationProperties - * @see Http2SolrClient + * @see HttpJdkSolrClient * @see org.springframework.boot.context.properties.EnableConfigurationProperties */ @Configuration @@ -162,7 +162,7 @@ public class SolrConfig { * the injected Solr configuration properties containing connection * URL * @return configured SolrClient instance ready for use in application services - * @see Http2SolrClient.Builder + * @see HttpJdkSolrClient.Builder * @see SolrConfigurationProperties#url() */ @Bean @@ -190,7 +190,7 @@ SolrClient solrClient(SolrConfigurationProperties properties, JsonResponseParser } // Use with explicit base URL; JSON wire format replaces the JavaBin default - return new Http2SolrClient.Builder(url).withConnectionTimeout(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS) + return new HttpJdkSolrClient.Builder(url).withConnectionTimeout(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS) .withIdleTimeout(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS).withResponseParser(jsonResponseParser) .build(); } diff --git a/src/main/java/org/apache/solr/mcp/server/search/SearchService.java b/src/main/java/org/apache/solr/mcp/server/search/SearchService.java index 729f553..c29ccb6 100644 --- a/src/main/java/org/apache/solr/mcp/server/search/SearchService.java +++ b/src/main/java/org/apache/solr/mcp/server/search/SearchService.java @@ -23,8 +23,8 @@ import java.util.List; import java.util.Map; import org.apache.solr.client.solrj.SolrClient; -import org.apache.solr.client.solrj.SolrQuery; import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.request.SolrQuery; import org.apache.solr.client.solrj.response.FacetField; import org.apache.solr.client.solrj.response.QueryResponse; import org.apache.solr.common.SolrDocument; diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java index ce109c6..44247fb 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java @@ -19,7 +19,7 @@ import static org.junit.jupiter.api.Assertions.*; import org.apache.solr.client.solrj.SolrClient; -import org.apache.solr.client.solrj.impl.Http2SolrClient; +import org.apache.solr.client.solrj.impl.HttpJdkSolrClient; import org.apache.solr.mcp.server.TestcontainersConfiguration; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -49,9 +49,9 @@ void testSolrClientConfiguration() { // Verify that the SolrClient is using the correct URL // Note: SolrConfig normalizes the URL to have trailing slash, but - // Http2SolrClient removes + // HttpJdkSolrClient removes // it - var httpSolrClient = assertInstanceOf(Http2SolrClient.class, solrClient); + var httpSolrClient = assertInstanceOf(HttpJdkSolrClient.class, solrClient); String expectedUrl = "http://" + solrContainer.getHost() + ":" + solrContainer.getMappedPort(8983) + "/solr"; assertEquals(expectedUrl, httpSolrClient.getBaseURL()); } diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java index bca9219..2ca312f 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java @@ -20,7 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.solr.client.solrj.SolrClient; -import org.apache.solr.client.solrj.impl.Http2SolrClient; +import org.apache.solr.client.solrj.impl.HttpJdkSolrClient; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.springframework.beans.factory.annotation.Autowired; @@ -45,7 +45,7 @@ void testUrlNormalization(String inputUrl, String expectedUrl) throws Exception try (SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(objectMapper))) { assertNotNull(client); - var httpClient = assertInstanceOf(Http2SolrClient.class, client); + var httpClient = assertInstanceOf(HttpJdkSolrClient.class, client); assertEquals(expectedUrl, httpClient.getBaseURL()); } } diff --git a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceDirectTest.java b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceDirectTest.java index 8358b9f..ae38775 100644 --- a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceDirectTest.java +++ b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceDirectTest.java @@ -26,8 +26,8 @@ import java.util.List; import java.util.Map; import org.apache.solr.client.solrj.SolrClient; -import org.apache.solr.client.solrj.SolrQuery; import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.request.SolrQuery; import org.apache.solr.client.solrj.response.FacetField; import org.apache.solr.client.solrj.response.QueryResponse; import org.apache.solr.common.SolrDocument; diff --git a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java index ab336bd..1e8f8be 100644 --- a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java @@ -28,9 +28,9 @@ import java.util.Map; import java.util.OptionalDouble; import org.apache.solr.client.solrj.SolrClient; -import org.apache.solr.client.solrj.SolrQuery; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.request.CollectionAdminRequest; +import org.apache.solr.client.solrj.request.SolrQuery; import org.apache.solr.client.solrj.response.FacetField; import org.apache.solr.client.solrj.response.QueryResponse; import org.apache.solr.common.SolrDocument;