diff --git a/Dockerfile b/Dockerfile index b68ba278..52312e56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -98,7 +98,7 @@ USER appuser EXPOSE 8085 -# Gate Coolify's rolling cutover on dependencies being ready for application traffic. +# Gate Coolify's rolling cutover on the JVM accepting traffic; external dependencies report separately. HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \ CMD curl --fail --silent --show-error http://localhost:${PORT:-8085}/actuator/health/readiness || exit 1 diff --git a/src/main/java/com/williamcallahan/javachat/config/QdrantHealthIndicator.java b/src/main/java/com/williamcallahan/javachat/config/QdrantHealthIndicator.java index 9eaf6f96..da8ce354 100644 --- a/src/main/java/com/williamcallahan/javachat/config/QdrantHealthIndicator.java +++ b/src/main/java/com/williamcallahan/javachat/config/QdrantHealthIndicator.java @@ -1,8 +1,10 @@ package com.williamcallahan.javachat.config; import com.williamcallahan.javachat.service.ExternalServiceHealth; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.actuate.health.Status; import org.springframework.stereotype.Component; /** @@ -20,14 +22,19 @@ public class QdrantHealthIndicator implements HealthIndicator { private static final String DETAIL_KEY_NEXT_CHECK_IN = "nextCheckIn"; private final ExternalServiceHealth externalServiceHealth; + private final ObjectProvider indexInitializerProvider; /** * Creates the health indicator with a reference to the external service health monitor. * * @param externalServiceHealth the service health monitor + * @param indexInitializerProvider provides initialization state outside the test profile */ - public QdrantHealthIndicator(ExternalServiceHealth externalServiceHealth) { + public QdrantHealthIndicator( + ExternalServiceHealth externalServiceHealth, + ObjectProvider indexInitializerProvider) { this.externalServiceHealth = externalServiceHealth; + this.indexInitializerProvider = indexInitializerProvider; } /** @@ -43,6 +50,14 @@ public Health health() { // Trigger retry checks when unhealthy and backoff has elapsed. externalServiceHealth.triggerRetryIfDue(ExternalServiceHealth.SERVICE_QDRANT); + QdrantIndexInitializer indexInitializer = indexInitializerProvider.getIfAvailable(); + if (indexInitializer != null) { + Health initializationHealth = indexInitializer.initializationHealth(); + if (!Status.UP.equals(initializationHealth.getStatus())) { + return initializationHealth; + } + } + ExternalServiceHealth.HealthSnapshot healthSnapshot = externalServiceHealth.getHealthSnapshot(ExternalServiceHealth.SERVICE_QDRANT); diff --git a/src/main/java/com/williamcallahan/javachat/config/QdrantIndexInitializer.java b/src/main/java/com/williamcallahan/javachat/config/QdrantIndexInitializer.java index 87502e4b..b351f1e9 100644 --- a/src/main/java/com/williamcallahan/javachat/config/QdrantIndexInitializer.java +++ b/src/main/java/com/williamcallahan/javachat/config/QdrantIndexInitializer.java @@ -6,7 +6,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.williamcallahan.javachat.service.EmbeddingClient; import java.time.Duration; -import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -14,41 +13,38 @@ import java.util.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.actuate.health.Health; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.event.EventListener; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestClientResponseException; import org.springframework.web.client.RestTemplate; /** * Ensures Qdrant collections and indexes exist and match the configured embedding model. * - *

This initializer is intentionally strict: - * - If a collection is reachable and mismatched (dimensions / hybrid config), startup fails. - * - If payload index creation is enabled and cannot be applied, startup fails. - * - *

This behavior prevents silent ingestion/retrieval failures when switching embedding providers - * or adopting hybrid (dense + sparse) collection schemas.

+ *

Transient outages defer creation and trigger retries; reachable configuration and schema defects fail.

*/ @org.springframework.context.annotation.Profile("!test") @Component public class QdrantIndexInitializer { private static final Logger log = LoggerFactory.getLogger(QdrantIndexInitializer.class); - private static final int CONNECT_TIMEOUT_SECONDS = 15; private static final int READ_TIMEOUT_SECONDS = 30; - + private static final long INITIALIZATION_RETRY_MILLIS = 30_000L; private static final String SCHEMA_TYPE_KEYWORD = "keyword"; private static final String SCHEMA_TYPE_INTEGER = "integer"; - private static final String VECTOR_DISTANCE_COSINE = "Cosine"; private static final String SPARSE_MODIFIER_IDF = "idf"; private static final List REQUIRED_PAYLOAD_INDEXES = List.of( @@ -74,16 +70,9 @@ public class QdrantIndexInitializer { private final RestTemplate restTemplate; private final EmbeddingClient embeddingClient; private final ObjectMapper objectMapper; + private volatile QdrantInitializationState initializationState = QdrantInitializationState.PENDING; - /** - * Creates the initializer with strict startup validation of Qdrant collections and indexes. - * - * @param qdrantRestConnection shared Qdrant REST connection details - * @param appProperties application configuration for collection and vector names - * @param restTemplateBuilder Spring-managed builder for REST calls to Qdrant - * @param embeddingClient embedding client used to resolve expected vector dimensions - * @param objectMapper JSON mapper used to build Qdrant request payloads - */ + /** Creates the initializer with strict validation of reachable Qdrant schemas. */ public QdrantIndexInitializer( QdrantRestConnection qdrantRestConnection, AppProperties appProperties, @@ -104,129 +93,178 @@ public QdrantIndexInitializer( .build(); } - /** - * Ensures configured collections exist and required payload indexes are present at startup. - */ + /** Ensures configured collections exist and required payload indexes are present at startup. */ @EventListener(ApplicationReadyEvent.class) public void ensureCollectionsAndIndexes() { + attemptInitialization(true); + } + + /** Retries initialization after transient Qdrant transport failures without restarting the JVM. */ + @Scheduled(initialDelay = INITIALIZATION_RETRY_MILLIS, fixedDelay = INITIALIZATION_RETRY_MILLIS) + void retryPendingInitialization() { + attemptInitialization(false); + } + + Health initializationHealth() { + return switch (initializationState) { + case READY -> Health.up().withDetail("initialization", "ready").build(); + case PENDING -> + Health.down().withDetail("initialization", "pending").build(); + case FAILED -> Health.down().withDetail("initialization", "failed").build(); + }; + } + + private synchronized void attemptInitialization(boolean startupAttempt) { + if (initializationState != QdrantInitializationState.PENDING) { + return; + } + try { + initializeCollectionsAndIndexes(); + initializationState = QdrantInitializationState.READY; + log.info("[QDRANT] Collection initialization ready"); + } catch (QdrantUnavailableException unavailableException) { + if (startupAttempt) { + log.warn( + "[QDRANT] Collection initialization deferred because Qdrant is unavailable: {}", + unavailableException.getMessage()); + } else { + log.debug( + "[QDRANT] Collection initialization still pending because Qdrant is unavailable: {}", + unavailableException.getMessage()); + } + } catch (RuntimeException configurationOrSchemaException) { + initializationState = QdrantInitializationState.FAILED; + if (startupAttempt) { + throw configurationOrSchemaException; + } + log.error( + "[QDRANT] Collection initialization failed after connectivity recovered; manual correction is required", + configurationOrSchemaException); + } + } + + private void initializeCollectionsAndIndexes() { AppProperties.Qdrant qdrant = appProperties.getQdrant(); List collections = qdrant.getCollections().all(); String denseVectorName = qdrant.getDenseVectorName(); String sparseVectorName = qdrant.getSparseVectorName(); + Map collectionBaseUrls = new LinkedHashMap<>(); if (collections.isEmpty()) { throw new IllegalStateException("app.qdrant.collections must not be empty"); } if (qdrant.isEnsureCollections()) { - ensureHybridCollectionsExist(collections, denseVectorName, sparseVectorName); + ensureHybridCollectionsExist(collections, denseVectorName, sparseVectorName, collectionBaseUrls); } - validateCollections(collections, denseVectorName, sparseVectorName); + validateCollections(collections, denseVectorName, sparseVectorName, collectionBaseUrls); if (!qdrant.isEnsurePayloadIndexes()) { log.info("[QDRANT] Skipping payload index ensure (app.qdrant.ensure-payload-indexes=false)"); return; } - ensurePayloadIndexes(collections); + ensurePayloadIndexes(collections, collectionBaseUrls); } private void ensureHybridCollectionsExist( - List collections, String denseVectorName, String sparseVectorName) { + List collections, + String denseVectorName, + String sparseVectorName, + Map collectionBaseUrls) { int dimensions = embeddingClient.dimensions(); HttpHeaders headers = jsonHeaders(); for (String collection : collections) { if (collection == null || collection.isBlank()) { throw new IllegalStateException("Qdrant collection name must not be blank"); } - if (collectionExists(collection, headers)) { - continue; + CollectionEndpoint endpoint = discoverCollectionEndpoint(collection, headers); + if (!endpoint.exists()) { + createHybridCollection( + new CollectionRestTarget(endpoint.baseUrl(), collection, headers), + new HybridCollectionSchema(denseVectorName, sparseVectorName, dimensions)); } - createHybridCollection(collection, denseVectorName, sparseVectorName, dimensions, headers); + collectionBaseUrls.put(collection, endpoint.baseUrl()); } } - private boolean collectionExists(String collection, HttpHeaders headers) { - String path = "/collections/" + collection; - boolean observedNotFound = false; - for (String base : qdrantRestConnection.candidateRestBaseUrls()) { - String url = base + path; + private CollectionEndpoint discoverCollectionEndpoint(String collection, HttpHeaders headers) { + List candidateBaseUrls = qdrantRestConnection.candidateRestBaseUrls(); + String createBaseUrl = candidateBaseUrls.getFirst(); + RuntimeException fatalFailure = null; + QdrantUnavailableException unavailableFailure = null; + for (String baseUrl : candidateBaseUrls) { try { - restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), String.class); - log.info("[QDRANT] Collection present (collection={})", collection); - return true; + restTemplate.exchange( + baseUrl + "/collections/" + collection, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class); + log.info("[QDRANT] Collection present (collection={}, base={})", collection, baseUrl); + return new CollectionEndpoint(baseUrl, true); } catch (HttpClientErrorException.NotFound notFoundException) { - observedNotFound = true; - log.debug("[QDRANT] Collection lookup returned 404 (collection={}, base={})", collection, base); - } catch (RuntimeException exception) { - log.debug( - "[QDRANT] Collection existence check failed (exceptionType={})", - exception.getClass().getSimpleName()); + log.debug("[QDRANT] Collection missing on candidate (collection={}, base={})", collection, baseUrl); + } catch (RestClientResponseException responseException) { + RuntimeException failure = + classifyHttpFailure("look up Qdrant collection '" + collection + "'", responseException); + if (failure instanceof QdrantUnavailableException unavailable) { + unavailableFailure = unavailable; + } else { + fatalFailure = failure; + } + } catch (ResourceAccessException transportException) { + unavailableFailure = unavailable("look up Qdrant collection '" + collection + "'", transportException); } } - if (observedNotFound) { - log.debug("[QDRANT] Collection missing after probing candidate base URLs (collection={})", collection); + if (fatalFailure != null) { + throw fatalFailure; + } + if (unavailableFailure != null) { + throw unavailableFailure; } - return false; + log.debug("[QDRANT] Collection missing on every candidate (collection={})", collection); + return new CollectionEndpoint(createBaseUrl, false); } - private void createHybridCollection( - String collection, String denseVectorName, String sparseVectorName, int dimensions, HttpHeaders headers) { - Objects.requireNonNull(collection, "collection"); - if (collection.isBlank()) { - throw new IllegalArgumentException("collection must not be blank"); - } - if (denseVectorName == null || denseVectorName.isBlank()) { - throw new IllegalArgumentException("denseVectorName must not be blank"); - } - if (sparseVectorName == null || sparseVectorName.isBlank()) { - throw new IllegalArgumentException("sparseVectorName must not be blank"); - } - if (dimensions <= 0) { - throw new IllegalArgumentException("Embedding dimensions must be positive"); + private void createHybridCollection(CollectionRestTarget target, HybridCollectionSchema schema) { + if (schema.denseVectorName() == null + || schema.denseVectorName().isBlank() + || schema.sparseVectorName() == null + || schema.sparseVectorName().isBlank() + || schema.dimensions() <= 0) { + throw new IllegalArgumentException("Qdrant vector names and embedding dimensions must be configured"); } - ObjectNodeBuilder body = new ObjectNodeBuilder(objectMapper); - body.putObject("vectors") - .putObject(denseVectorName) - .put("size", dimensions) + ObjectNode requestBody = objectMapper.createObjectNode(); + requestBody + .putObject("vectors") + .putObject(schema.denseVectorName()) + .put("size", schema.dimensions()) .put("distance", VECTOR_DISTANCE_COSINE); - body.putObject("sparse_vectors").putObject(sparseVectorName).put("modifier", SPARSE_MODIFIER_IDF); - body.put("on_disk_payload", true); - - String path = "/collections/" + collection; - RestClientResponseException lastHttp = null; - RuntimeException lastRuntime = null; - for (String base : qdrantRestConnection.candidateRestBaseUrls()) { - String url = base + path; - try { - ResponseEntity qdrantResponse = restTemplate.exchange( - url, HttpMethod.PUT, new HttpEntity<>(body.root(), headers), String.class); - HttpStatusCode qdrantStatusCode = qdrantResponse.getStatusCode(); - if (!qdrantStatusCode.is2xxSuccessful()) { - throw new IllegalStateException( - "Qdrant create collection returned HTTP " + qdrantStatusCode.value()); - } - log.info("[QDRANT] Created hybrid collection (collection={})", collection); - return; - } catch (RestClientResponseException httpError) { - lastHttp = httpError; - } catch (RuntimeException runtimeError) { - lastRuntime = runtimeError; - } - } - - if (lastHttp != null) { - throw new IllegalStateException( - "Failed to create Qdrant collection '" + collection + "' (HTTP " - + lastHttp.getStatusCode().value() + ")", - lastHttp); + requestBody + .putObject("sparse_vectors") + .putObject(schema.sparseVectorName()) + .put("modifier", SPARSE_MODIFIER_IDF); + requestBody.put("on_disk_payload", true); + + String collectionUrl = target.baseUrl() + "/collections/" + target.collection(); + try { + restTemplate.exchange( + collectionUrl, HttpMethod.PUT, new HttpEntity<>(requestBody, target.headers()), String.class); + log.info("[QDRANT] Created hybrid collection (collection={})", target.collection()); + } catch (RestClientResponseException responseException) { + throw classifyHttpFailure("create Qdrant collection '" + target.collection() + "'", responseException); + } catch (ResourceAccessException transportException) { + throw unavailable("create Qdrant collection '" + target.collection() + "'", transportException); } - throw new IllegalStateException("Failed to create Qdrant collection '" + collection + "'", lastRuntime); } - private void validateCollections(List collections, String denseVectorName, String sparseVectorName) { + private void validateCollections( + List collections, + String denseVectorName, + String sparseVectorName, + Map collectionBaseUrls) { int expectedDimensions = embeddingClient.dimensions(); if (expectedDimensions <= 0) { throw new IllegalStateException("Embedding model dimensions must be positive"); @@ -236,7 +274,16 @@ private void validateCollections(List collections, String denseVectorNam } HttpHeaders headers = jsonHeaders(); for (String collection : collections) { - JsonNode info = fetchCollectionInfo(collection, headers); + String baseUrl = collectionBaseUrls.get(collection); + if (baseUrl == null) { + CollectionEndpoint endpoint = discoverCollectionEndpoint(collection, headers); + if (!endpoint.exists()) { + throw new IllegalStateException("Qdrant collection '" + collection + "' does not exist"); + } + baseUrl = endpoint.baseUrl(); + collectionBaseUrls.put(collection, baseUrl); + } + JsonNode info = fetchCollectionInfo(baseUrl, collection, headers); int actualDimensions = extractDenseVectorDimensions(info, denseVectorName); if (actualDimensions != expectedDimensions) { throw new IllegalStateException("Qdrant collection dimension mismatch for '" @@ -257,38 +304,22 @@ private void validateCollections(List collections, String denseVectorNam } } - private JsonNode fetchCollectionInfo(String collection, HttpHeaders headers) { + private JsonNode fetchCollectionInfo(String baseUrl, String collection, HttpHeaders headers) { Objects.requireNonNull(collection, "collection"); - String path = "/collections/" + collection; - - RestClientResponseException lastHttp = null; - RuntimeException lastRuntime = null; - for (String base : qdrantRestConnection.candidateRestBaseUrls()) { - String url = base + path; - try { - ResponseEntity response = - restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), JsonNode.class); - if (!response.getStatusCode().is2xxSuccessful()) { - throw new IllegalStateException("Qdrant collection info returned HTTP " + response.getStatusCode()); - } - JsonNode body = response.getBody(); - if (body == null || body.isNull()) { - throw new IllegalStateException("Qdrant collection info was null for collection=" + collection); - } - return body; - } catch (RestClientResponseException httpError) { - lastHttp = httpError; - } catch (RuntimeException runtimeError) { - lastRuntime = runtimeError; + String collectionUrl = baseUrl + "/collections/" + collection; + try { + ResponseEntity qdrantResponse = + restTemplate.exchange(collectionUrl, HttpMethod.GET, new HttpEntity<>(headers), JsonNode.class); + JsonNode collectionInfo = qdrantResponse.getBody(); + if (collectionInfo == null || collectionInfo.isNull()) { + throw new IllegalStateException("Qdrant collection info was null for collection=" + collection); } + return collectionInfo; + } catch (RestClientResponseException responseException) { + throw classifyHttpFailure("fetch Qdrant collection info for '" + collection + "'", responseException); + } catch (ResourceAccessException transportException) { + throw unavailable("fetch Qdrant collection info for '" + collection + "'", transportException); } - if (lastHttp != null) { - throw new IllegalStateException( - "Failed to fetch Qdrant collection info for '" + collection + "' (HTTP " - + lastHttp.getStatusCode().value() + ")", - lastHttp); - } - throw new IllegalStateException("Failed to fetch Qdrant collection info for '" + collection + "'", lastRuntime); } private int extractDenseVectorDimensions(JsonNode collectionInfo, String denseVectorName) { @@ -326,23 +357,21 @@ private boolean hasSparseVectorConfig(JsonNode collectionInfo, String sparseVect } JsonNode sparseVectors = collectionInfo.path("result").path("config").path("params").path("sparse_vectors"); - if (sparseVectors.isMissingNode() || sparseVectors.isNull() || !sparseVectors.isObject()) { - return false; - } - return sparseVectors.has(sparseVectorName); + return sparseVectors.isObject() && sparseVectors.has(sparseVectorName); } - private void ensurePayloadIndexes(List collections) { + private void ensurePayloadIndexes(List collections, Map collectionBaseUrls) { HttpHeaders headers = jsonHeaders(); for (String collection : collections) { - Map existingPayloadIndexTypes = readExistingPayloadIndexTypes(collection, headers); + String baseUrl = Objects.requireNonNull(collectionBaseUrls.get(collection), "collectionBaseUrl"); + CollectionRestTarget target = new CollectionRestTarget(baseUrl, collection, headers); + Map existingPayloadIndexTypes = readExistingPayloadIndexTypes(baseUrl, collection, headers); int createdIndexCount = 0; int alreadyPresentIndexCount = 0; for (PayloadIndexSpec payloadIndexSpec : REQUIRED_PAYLOAD_INDEXES) { String existingType = existingPayloadIndexTypes.get(payloadIndexSpec.fieldName()); if (existingType == null || existingType.isBlank()) { - ensurePayloadIndex( - collection, payloadIndexSpec.fieldName(), payloadIndexSpec.schemaType(), headers); + ensurePayloadIndex(target, payloadIndexSpec); createdIndexCount++; continue; } @@ -367,19 +396,15 @@ private void ensurePayloadIndexes(List collections) { } } - private Map readExistingPayloadIndexTypes(String collection, HttpHeaders headers) { - JsonNode collectionInfo = fetchCollectionInfo(collection, headers); + private Map readExistingPayloadIndexTypes(String baseUrl, String collection, HttpHeaders headers) { + JsonNode collectionInfo = fetchCollectionInfo(baseUrl, collection, headers); JsonNode payloadSchemaNode = collectionInfo.path("result").path("payload_schema"); if (payloadSchemaNode.isMissingNode() || payloadSchemaNode.isNull() || !payloadSchemaNode.isObject()) { return Map.of(); } LinkedHashMap payloadIndexTypes = new LinkedHashMap<>(); - // Using properties() method (Jackson 2.13+) instead of deprecated fields() - for (Iterator> it = - payloadSchemaNode.properties().iterator(); - it.hasNext(); ) { - Map.Entry fieldEntry = it.next(); + for (Map.Entry fieldEntry : payloadSchemaNode.properties()) { String payloadDataType = extractPayloadDataType(fieldEntry.getValue()); if (!payloadDataType.isBlank()) { payloadIndexTypes.put(fieldEntry.getKey(), payloadDataType); @@ -400,49 +425,40 @@ private String extractPayloadDataType(JsonNode payloadFieldSchema) { } } JsonNode fallbackTypeNode = payloadFieldSchema.path("type"); - if (fallbackTypeNode.isTextual()) { - return fallbackTypeNode.asText().trim().toLowerCase(Locale.ROOT); - } - return ""; + return fallbackTypeNode.isTextual() ? fallbackTypeNode.asText().trim().toLowerCase(Locale.ROOT) : ""; } - private void ensurePayloadIndex(String collection, String field, String schemaType, HttpHeaders headers) { - PayloadIndexRequest body = new PayloadIndexRequest(field, Map.of("type", schemaType)); - String path = "/collections/" + collection + "/index"; - - RestClientResponseException lastHttp = null; - RuntimeException lastRuntime = null; - for (String base : qdrantRestConnection.candidateRestBaseUrls()) { - String url = base + path; - try { - ResponseEntity response = - restTemplate.exchange(url, HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); - if (!response.getStatusCode().is2xxSuccessful()) { - throw new IllegalStateException("Qdrant payload index ensure returned HTTP " - + response.getStatusCode().value()); - } - log.info("[QDRANT] Ensured payload index (collection={}, field={})", collection, field); - return; - } catch (RestClientResponseException httpError) { - lastHttp = httpError; - } catch (RuntimeException runtimeError) { - lastRuntime = runtimeError; - } + private void ensurePayloadIndex(CollectionRestTarget target, PayloadIndexSpec indexSpec) { + String collection = target.collection(); + String fieldName = indexSpec.fieldName(); + PayloadIndexRequest indexRequest = new PayloadIndexRequest(fieldName, Map.of("type", indexSpec.schemaType())); + String indexUrl = target.baseUrl() + "/collections/" + collection + "/index"; + String operation = "ensure Qdrant payload index '%s' for collection '%s'".formatted(fieldName, collection); + try { + restTemplate.exchange( + indexUrl, HttpMethod.PUT, new HttpEntity<>(indexRequest, target.headers()), String.class); + log.info("[QDRANT] Ensured payload index (collection={}, field={})", collection, fieldName); + } catch (RestClientResponseException responseException) { + throw classifyHttpFailure(operation, responseException); + } catch (ResourceAccessException transportException) { + throw unavailable(operation, transportException); } + } - if (lastHttp != null) { - throw new IllegalStateException( - "Failed to ensure payload index (collection=" - + collection - + ", field=" - + field - + ", HTTP " - + lastHttp.getStatusCode().value() - + ")", - lastHttp); + private RuntimeException classifyHttpFailure( + String qdrantOperation, RestClientResponseException responseException) { + HttpStatusCode statusCode = responseException.getStatusCode(); + if (statusCode.is5xxServerError() + || statusCode.value() == HttpStatus.REQUEST_TIMEOUT.value() + || statusCode.value() == HttpStatus.TOO_MANY_REQUESTS.value()) { + return unavailable(qdrantOperation, responseException); } - throw new IllegalStateException( - "Failed to ensure payload index (collection=" + collection + ", field=" + field + ")", lastRuntime); + return new IllegalStateException( + "Failed to " + qdrantOperation + " (HTTP " + statusCode.value() + ")", responseException); + } + + private QdrantUnavailableException unavailable(String qdrantOperation, RuntimeException transportException) { + return new QdrantUnavailableException("Failed to " + qdrantOperation, transportException); } private HttpHeaders jsonHeaders() { @@ -461,27 +477,23 @@ private record PayloadIndexRequest( private record PayloadIndexSpec(String fieldName, String schemaType) {} - /** - * Small helper to build a JSON object without exposing maps across the initializer. - */ - private static final class ObjectNodeBuilder { - private final ObjectNode root; + private record CollectionEndpoint(String baseUrl, boolean exists) {} - private ObjectNodeBuilder(ObjectMapper mapper) { - this.root = mapper.createObjectNode(); - } + private record CollectionRestTarget(String baseUrl, String collection, HttpHeaders headers) {} - ObjectNode root() { - return root; - } + private record HybridCollectionSchema(String denseVectorName, String sparseVectorName, int dimensions) {} - ObjectNodeBuilder put(String key, boolean value) { - root.put(key, value); - return this; - } + /** Initialization lifecycle. */ + private enum QdrantInitializationState { + PENDING, + READY, + FAILED + } - ObjectNode putObject(String key) { - return root.putObject(key); + /** Signals a transient Qdrant failure. */ + private static final class QdrantUnavailableException extends RuntimeException { + private QdrantUnavailableException(String message, RuntimeException cause) { + super(message, cause); } } } diff --git a/src/main/java/com/williamcallahan/javachat/config/QdrantRestConnection.java b/src/main/java/com/williamcallahan/javachat/config/QdrantRestConnection.java index 70cc65b0..5d8e04e4 100644 --- a/src/main/java/com/williamcallahan/javachat/config/QdrantRestConnection.java +++ b/src/main/java/com/williamcallahan/javachat/config/QdrantRestConnection.java @@ -84,8 +84,8 @@ public String restBaseUrl() { * Returns candidate REST base URLs ordered by likelihood, for discovery scenarios. * *

Under TLS the canonical URL is sufficient. Without TLS the list includes the - * default REST port, the mapped port, and the raw configured port as fallback so - * that the caller can probe multiple endpoints when the exact port is uncertain. + * default REST port and the mapped REST port. The raw configured port is excluded + * when it is a gRPC endpoint because REST probes cannot establish collection state there. * * @return ordered, deduplicated list of candidate base URLs */ @@ -96,7 +96,6 @@ public List candidateRestBaseUrls() { LinkedHashSet candidates = new LinkedHashSet<>(); candidates.add(buildBaseUrl(HTTP_SCHEME, QDRANT_REST_PORT)); candidates.add(buildBaseUrl(HTTP_SCHEME, mapGrpcPortToRestPort(configuredPort))); - candidates.add(buildBaseUrl(HTTP_SCHEME, configuredPort)); return List.copyOf(candidates); } diff --git a/src/main/java/com/williamcallahan/javachat/config/SecurityConfig.java b/src/main/java/com/williamcallahan/javachat/config/SecurityConfig.java index ba13232e..0fed24ea 100644 --- a/src/main/java/com/williamcallahan/javachat/config/SecurityConfig.java +++ b/src/main/java/com/williamcallahan/javachat/config/SecurityConfig.java @@ -29,6 +29,7 @@ public class SecurityConfig { private static final String HEALTH_ENDPOINT = "/actuator/health"; private static final String LIVENESS_ENDPOINT = "/actuator/health/liveness"; private static final String READINESS_ENDPOINT = "/actuator/health/readiness"; + private static final String DEPENDENCIES_ENDPOINT = "/actuator/health/dependencies"; private static final String PROMETHEUS_ENDPOINT = "/actuator/prometheus"; /** @@ -63,7 +64,11 @@ public SecurityFilterChain managementSecurityFilterChain( http.securityMatcher(EndpointRequest.toAnyEndpoint()) .cors(c -> c.configurationSource(corsConfigurationSource)) .authorizeHttpRequests(auth -> auth.requestMatchers( - HEALTH_ENDPOINT, LIVENESS_ENDPOINT, READINESS_ENDPOINT, PROMETHEUS_ENDPOINT) + HEALTH_ENDPOINT, + LIVENESS_ENDPOINT, + READINESS_ENDPOINT, + DEPENDENCIES_ENDPOINT, + PROMETHEUS_ENDPOINT) .permitAll() .anyRequest() .denyAll()) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 27c44297..8678596f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -147,7 +147,8 @@ app.cors.max-age-seconds=3600 management.endpoints.web.exposure.include=health,prometheus management.endpoint.health.probes.enabled=true management.endpoint.health.group.liveness.include=livenessState -management.endpoint.health.group.readiness.include=readinessState,qdrant,embeddingModelKeepAlive +management.endpoint.health.group.readiness.include=readinessState +management.endpoint.health.group.dependencies.include=qdrant,embeddingModelKeepAlive management.info.build.enabled=true management.info.env.enabled=true info.application.name=${spring.application.name} diff --git a/src/test/java/com/williamcallahan/javachat/JavaChatApplicationTests.java b/src/test/java/com/williamcallahan/javachat/JavaChatApplicationTests.java index ca5ec3c0..8477e350 100644 --- a/src/test/java/com/williamcallahan/javachat/JavaChatApplicationTests.java +++ b/src/test/java/com/williamcallahan/javachat/JavaChatApplicationTests.java @@ -1,20 +1,23 @@ package com.williamcallahan.javachat; import static org.hamcrest.Matchers.containsString; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doThrow; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.williamcallahan.javachat.config.QdrantHealthIndicator; import com.williamcallahan.javachat.service.EmbeddingClient; import com.williamcallahan.javachat.service.EmbeddingModelKeepAlive; +import com.williamcallahan.javachat.service.EmbeddingServiceUnavailableException; import io.qdrant.client.QdrantClient; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Answers; import org.springframework.ai.chat.model.ChatModel; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.Status; import org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -29,7 +32,8 @@ "spring.ai.openai.api-key=test", "spring.ai.openai.chat.api-key=test", "management.endpoint.health.probes.enabled=true", - "management.endpoint.health.group.readiness.include=readinessState,qdrant,embeddingModelKeepAlive", + "management.endpoint.health.group.readiness.include=readinessState", + "management.endpoint.health.group.dependencies.include=qdrant,embeddingModelKeepAlive", "spring.ai.vectorstore.qdrant.host=localhost", "spring.ai.vectorstore.qdrant.use-tls=false", "spring.ai.vectorstore.qdrant.port=8086" @@ -50,12 +54,17 @@ class JavaChatApplicationTests { @MockitoBean QdrantClient qdrantClient; - @MockitoBean + @Autowired EmbeddingModelKeepAlive embeddingModelKeepAlive; + @Autowired + QdrantHealthIndicator qdrantHealthIndicator; + @BeforeEach - void reportEmbeddingDependencyUnavailable() { - when(embeddingModelKeepAlive.health()).thenReturn(Health.down().build()); + void reportExternalDependenciesUnavailable() { + doThrow(new EmbeddingServiceUnavailableException("provider unavailable for readiness test")) + .when(embeddingClient) + .warmUp(); } @Test @@ -64,12 +73,18 @@ void contextLoads() {} @Test void exposesOnlyOperationalActuatorSurfaces() throws Exception { mockMvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk()); - mockMvc.perform(get("/actuator/health/readiness")).andExpect(status().isServiceUnavailable()); mockMvc.perform(get("/actuator/prometheus")) .andExpect(status().isOk()) .andExpect(content().string(containsString("jvm_"))); - mockMvc.perform(get("/actuator/health")).andExpect(status().isServiceUnavailable()); mockMvc.perform(get("/actuator/metrics")).andExpect(status().isForbidden()); mockMvc.perform(get("/actuator/info")).andExpect(status().isForbidden()); } + + @Test + void externalDependencyFailuresRemainObservableWithoutBlockingReadiness() throws Exception { + assertEquals(Status.DOWN, embeddingModelKeepAlive.health().getStatus()); + assertEquals(Status.DOWN, qdrantHealthIndicator.health().getStatus()); + mockMvc.perform(get("/actuator/health/readiness")).andExpect(status().isOk()); + mockMvc.perform(get("/actuator/health/dependencies")).andExpect(status().isServiceUnavailable()); + } } diff --git a/src/test/java/com/williamcallahan/javachat/config/QdrantHealthIndicatorTest.java b/src/test/java/com/williamcallahan/javachat/config/QdrantHealthIndicatorTest.java index b1da548b..b0b3c40b 100644 --- a/src/test/java/com/williamcallahan/javachat/config/QdrantHealthIndicatorTest.java +++ b/src/test/java/com/williamcallahan/javachat/config/QdrantHealthIndicatorTest.java @@ -9,6 +9,7 @@ import com.williamcallahan.javachat.service.ExternalServiceHealth; import java.time.Duration; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.Status; @@ -20,7 +21,8 @@ class QdrantHealthIndicatorTest { @Test void health_triggersRetryEvaluationBeforeSnapshotRendering() { ExternalServiceHealth externalServiceHealth = mock(ExternalServiceHealth.class); - QdrantHealthIndicator qdrantHealthIndicator = new QdrantHealthIndicator(externalServiceHealth); + QdrantHealthIndicator qdrantHealthIndicator = + new QdrantHealthIndicator(externalServiceHealth, absentIndexInitializerProvider()); ExternalServiceHealth.HealthSnapshot unhealthySnapshot = new ExternalServiceHealth.HealthSnapshot( ExternalServiceHealth.SERVICE_QDRANT, false, "Unhealthy (checking now...)", Duration.ZERO); @@ -32,4 +34,39 @@ void health_triggersRetryEvaluationBeforeSnapshotRendering() { verify(externalServiceHealth, times(1)).triggerRetryIfDue(ExternalServiceHealth.SERVICE_QDRANT); assertEquals(Status.DOWN, health.getStatus()); } + + @Test + void health_remainsDownUntilCollectionInitializationCompletes() { + ExternalServiceHealth externalServiceHealth = mock(ExternalServiceHealth.class); + QdrantIndexInitializer indexInitializer = mock(QdrantIndexInitializer.class); + when(indexInitializer.initializationHealth()) + .thenReturn( + Health.down().withDetail("initialization", "pending").build()); + QdrantHealthIndicator qdrantHealthIndicator = + new QdrantHealthIndicator(externalServiceHealth, indexInitializerProvider(indexInitializer)); + + Health health = qdrantHealthIndicator.health(); + + assertEquals(Status.DOWN, health.getStatus()); + assertEquals("pending", health.getDetails().get("initialization")); + verify(externalServiceHealth, times(1)).triggerRetryIfDue(ExternalServiceHealth.SERVICE_QDRANT); + } + + private ObjectProvider absentIndexInitializerProvider() { + return new ObjectProvider<>() { + @Override + public QdrantIndexInitializer getIfAvailable() { + return null; + } + }; + } + + private ObjectProvider indexInitializerProvider(QdrantIndexInitializer indexInitializer) { + return new ObjectProvider<>() { + @Override + public QdrantIndexInitializer getIfAvailable() { + return indexInitializer; + } + }; + } } diff --git a/src/test/java/com/williamcallahan/javachat/config/QdrantIndexInitializerTest.java b/src/test/java/com/williamcallahan/javachat/config/QdrantIndexInitializerTest.java new file mode 100644 index 00000000..87120e97 --- /dev/null +++ b/src/test/java/com/williamcallahan/javachat/config/QdrantIndexInitializerTest.java @@ -0,0 +1,347 @@ +package com.williamcallahan.javachat.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.williamcallahan.javachat.service.EmbeddingClient; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.actuate.health.Status; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +/** Verifies that Qdrant initialization separates confirmed absence from transport unavailability. */ +class QdrantIndexInitializerTest { + private static final int EMBEDDING_DIMENSIONS = 1_536; + private static final int MISMATCHED_COLLECTION_DIMENSIONS = 768; + private static final String QDRANT_REST_BASE_URL = "http://qdrant.test:6333"; + private static final String QDRANT_FALLBACK_BASE_URL = "http://qdrant.test:7444"; + + private final Logger initializerLogger = (Logger) LoggerFactory.getLogger(QdrantIndexInitializer.class); + private final ListAppender initializerEvents = new ListAppender<>(); + private boolean initializerLoggerAdditive; + + @BeforeEach + void captureInitializerEvents() { + initializerLoggerAdditive = initializerLogger.isAdditive(); + initializerLogger.setAdditive(false); + initializerEvents.start(); + initializerLogger.addAppender(initializerEvents); + } + + @AfterEach + void stopCapturingInitializerEvents() { + initializerLogger.detachAppender(initializerEvents); + initializerLogger.setAdditive(initializerLoggerAdditive); + initializerEvents.stop(); + } + + @Test + void transportFailureDefersInitializationWithoutCreatingCollection() { + InitializerHarness initializerHarness = newInitializer(true); + String firstCollectionUrl = + collectionUrl(initializerHarness.collectionName().get(0)); + initializerHarness + .qdrantServer() + .expect(once(), requestTo(firstCollectionUrl)) + .andExpect(method(HttpMethod.GET)) + .andRespond(ignoredRequest -> { + throw new ResourceAccessException("Qdrant DNS unavailable for test"); + }); + + initializerHarness.initializer().ensureCollectionsAndIndexes(); + + assertEquals( + Status.DOWN, + initializerHarness.initializer().initializationHealth().getStatus()); + assertEquals( + "pending", + initializerHarness + .initializer() + .initializationHealth() + .getDetails() + .get("initialization")); + assertEquals(1, eventCount(Level.WARN, "Collection initialization deferred")); + initializerHarness.qdrantServer().verify(); + } + + @Test + void defaultRestNotFoundCreatesWithoutProbingConfiguredGrpcPort() { + InitializerHarness initializerHarness = newInitializer(true, 6334); + String firstCollectionUrl = + collectionUrl(initializerHarness.collectionName().get(0)); + initializerHarness + .qdrantServer() + .expect(once(), requestTo(firstCollectionUrl)) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + initializerHarness + .qdrantServer() + .expect(once(), requestTo(firstCollectionUrl)) + .andExpect(method(HttpMethod.PUT)) + .andRespond(withStatus(HttpStatus.SERVICE_UNAVAILABLE)); + + initializerHarness.initializer().ensureCollectionsAndIndexes(); + + assertEquals( + Status.DOWN, + initializerHarness.initializer().initializationHealth().getStatus()); + initializerHarness.qdrantServer().verify(); + } + + @Test + void tooManyRequestsDefersInitializationWithoutCreatingCollection() { + InitializerHarness initializerHarness = newInitializer(true); + initializerHarness + .qdrantServer() + .expect( + once(), + requestTo(collectionUrl( + initializerHarness.collectionName().get(0)))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS)); + + initializerHarness.initializer().ensureCollectionsAndIndexes(); + + assertEquals( + Status.DOWN, + initializerHarness.initializer().initializationHealth().getStatus()); + assertEquals( + "pending", + initializerHarness + .initializer() + .initializationHealth() + .getDetails() + .get("initialization")); + initializerHarness.qdrantServer().verify(); + } + + @Test + void mixedMissingAndTransportFailureDoesNotCreateCollection() { + InitializerHarness initializerHarness = newInitializer(true, 7444); + String collectionName = initializerHarness.collectionName().get(0); + initializerHarness + .qdrantServer() + .expect(once(), requestTo(collectionUrl(collectionName))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + initializerHarness + .qdrantServer() + .expect(once(), requestTo(collectionUrl(QDRANT_FALLBACK_BASE_URL, collectionName))) + .andExpect(method(HttpMethod.GET)) + .andRespond(ignoredRequest -> { + throw new ResourceAccessException("fallback transport unavailable for test"); + }); + + initializerHarness.initializer().ensureCollectionsAndIndexes(); + + assertEquals( + Status.DOWN, + initializerHarness.initializer().initializationHealth().getStatus()); + initializerHarness.qdrantServer().verify(); + } + + @Test + void fallbackCandidateSuccessWinsAndValidationUsesThatBase() { + InitializerHarness initializerHarness = newInitializer(true, 7444); + List collectionName = initializerHarness.collectionName(); + initializerHarness + .qdrantServer() + .expect(once(), requestTo(collectionUrl(collectionName.get(0)))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.SERVICE_UNAVAILABLE)); + initializerHarness + .qdrantServer() + .expect(once(), requestTo(collectionUrl(QDRANT_FALLBACK_BASE_URL, collectionName.get(0)))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(collectionMetadataJson(EMBEDDING_DIMENSIONS), MediaType.APPLICATION_JSON)); + for (int collectionIndex = 1; collectionIndex < collectionName.size(); collectionIndex++) { + expectCollectionGet( + initializerHarness, + QDRANT_REST_BASE_URL, + collectionName.get(collectionIndex), + collectionMetadataJson(EMBEDDING_DIMENSIONS)); + } + expectCollectionGet( + initializerHarness, + QDRANT_FALLBACK_BASE_URL, + collectionName.get(0), + collectionMetadataJson(EMBEDDING_DIMENSIONS)); + for (int collectionIndex = 1; collectionIndex < collectionName.size(); collectionIndex++) { + expectCollectionGet( + initializerHarness, + QDRANT_REST_BASE_URL, + collectionName.get(collectionIndex), + collectionMetadataJson(EMBEDDING_DIMENSIONS)); + } + + initializerHarness.initializer().ensureCollectionsAndIndexes(); + + assertEquals( + Status.UP, + initializerHarness.initializer().initializationHealth().getStatus()); + initializerHarness.qdrantServer().verify(); + } + + @Test + void pendingInitializationRecoversAfterQdrantReturns() { + InitializerHarness initializerHarness = newInitializer(false); + List collectionName = initializerHarness.collectionName(); + initializerHarness + .qdrantServer() + .expect(once(), requestTo(collectionUrl(collectionName.get(0)))) + .andExpect(method(HttpMethod.GET)) + .andRespond(ignoredRequest -> { + throw new ResourceAccessException("Qdrant temporarily unavailable for test"); + }); + for (String configuredCollection : collectionName) { + expectCollectionGet( + initializerHarness, + QDRANT_REST_BASE_URL, + configuredCollection, + collectionMetadataJson(EMBEDDING_DIMENSIONS)); + expectCollectionGet( + initializerHarness, + QDRANT_REST_BASE_URL, + configuredCollection, + collectionMetadataJson(EMBEDDING_DIMENSIONS)); + } + + initializerHarness.initializer().ensureCollectionsAndIndexes(); + initializerHarness.initializer().retryPendingInitialization(); + + assertEquals( + Status.UP, + initializerHarness.initializer().initializationHealth().getStatus()); + initializerHarness.qdrantServer().verify(); + } + + @Test + void reachableSchemaMismatchRemainsTerminal() { + InitializerHarness initializerHarness = newInitializer(false); + initializerHarness + .qdrantServer() + .expect( + once(), + requestTo(collectionUrl( + initializerHarness.collectionName().get(0)))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess( + collectionMetadataJson(MISMATCHED_COLLECTION_DIMENSIONS), MediaType.APPLICATION_JSON)); + initializerHarness + .qdrantServer() + .expect( + once(), + requestTo(collectionUrl( + initializerHarness.collectionName().get(0)))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess( + collectionMetadataJson(MISMATCHED_COLLECTION_DIMENSIONS), MediaType.APPLICATION_JSON)); + + assertThrows(IllegalStateException.class, initializerHarness.initializer()::ensureCollectionsAndIndexes); + + assertEquals( + Status.DOWN, + initializerHarness.initializer().initializationHealth().getStatus()); + assertEquals( + "failed", + initializerHarness + .initializer() + .initializationHealth() + .getDetails() + .get("initialization")); + initializerHarness.initializer().retryPendingInitialization(); + initializerHarness.qdrantServer().verify(); + } + + private InitializerHarness newInitializer(boolean ensureCollections) { + return newInitializer(ensureCollections, 6333); + } + + private InitializerHarness newInitializer(boolean ensureCollections, int configuredPort) { + AppProperties appProperties = new AppProperties(); + appProperties.getQdrant().setEnsureCollections(ensureCollections); + appProperties.getQdrant().setEnsurePayloadIndexes(false); + EmbeddingClient embeddingClient = mock(EmbeddingClient.class); + when(embeddingClient.dimensions()).thenReturn(EMBEDDING_DIMENSIONS); + AtomicReference initializedRestTemplate = new AtomicReference<>(); + RestTemplateBuilder restTemplateBuilder = + new RestTemplateBuilder().additionalCustomizers(initializedRestTemplate::set); + QdrantIndexInitializer initializer = new QdrantIndexInitializer( + new QdrantRestConnection("qdrant.test", configuredPort, false, ""), + appProperties, + restTemplateBuilder, + embeddingClient, + new ObjectMapper()); + MockRestServiceServer qdrantServer = + MockRestServiceServer.bindTo(initializedRestTemplate.get()).build(); + return new InitializerHarness( + initializer, + qdrantServer, + appProperties.getQdrant().getCollections().all()); + } + + private String collectionUrl(String collectionName) { + return collectionUrl(QDRANT_REST_BASE_URL, collectionName); + } + + private String collectionUrl(String baseUrl, String collectionName) { + return baseUrl + "/collections/" + collectionName; + } + + private void expectCollectionGet( + InitializerHarness initializerHarness, String baseUrl, String collectionName, String responseBody) { + initializerHarness + .qdrantServer() + .expect(once(), requestTo(collectionUrl(baseUrl, collectionName))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON)); + } + + private String collectionMetadataJson(int denseVectorDimensions) { + return """ + { + "result": { + "config": { + "params": { + "vectors": {"dense": {"size": %d}}, + "sparse_vectors": {"bm25": {}} + } + }, + "payload_schema": {} + } + } + """.formatted(denseVectorDimensions); + } + + private long eventCount(Level level, String messageFragment) { + return initializerEvents.list.stream() + .filter(loggingEvent -> loggingEvent.getLevel().equals(level)) + .filter(loggingEvent -> loggingEvent.getFormattedMessage().contains(messageFragment)) + .count(); + } + + private record InitializerHarness( + QdrantIndexInitializer initializer, MockRestServiceServer qdrantServer, List collectionName) {} +} diff --git a/src/test/java/com/williamcallahan/javachat/config/QdrantRestConnectionTest.java b/src/test/java/com/williamcallahan/javachat/config/QdrantRestConnectionTest.java index 1c7aa522..8280eb79 100644 --- a/src/test/java/com/williamcallahan/javachat/config/QdrantRestConnectionTest.java +++ b/src/test/java/com/williamcallahan/javachat/config/QdrantRestConnectionTest.java @@ -50,16 +50,21 @@ void candidateRestBaseUrls_withTls_returnsSingleCanonicalUrl() { @Test void candidateRestBaseUrls_withoutTlsAndDefaultPort_returnsDeduplicated() { QdrantRestConnection connection = newConnection("localhost", 6334, false); - assertIterableEquals( - List.of("http://localhost:6333", "http://localhost:6334"), connection.candidateRestBaseUrls()); + assertIterableEquals(List.of("http://localhost:6333"), connection.candidateRestBaseUrls()); } @Test - void candidateRestBaseUrls_withDockerPort_includesRestAndGrpcFallback() { + void candidateRestBaseUrls_withDockerPort_excludesGrpcEndpoint() { QdrantRestConnection connection = newConnection("localhost", 8086, false); assertIterableEquals( - List.of("http://localhost:6333", "http://localhost:8087", "http://localhost:8086"), - connection.candidateRestBaseUrls()); + List.of("http://localhost:6333", "http://localhost:8087"), connection.candidateRestBaseUrls()); + } + + @Test + void candidateRestBaseUrls_withConfiguredRestPort_keepsRestFallback() { + QdrantRestConnection connection = newConnection("localhost", 7444, false); + assertIterableEquals( + List.of("http://localhost:6333", "http://localhost:7444"), connection.candidateRestBaseUrls()); } @Test