From ff68f0898a5dcacc8c250917901c2e0a280e2ab6 Mon Sep 17 00:00:00 2001 From: "detail-app[bot]" <180357370+detail-app[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:00:58 +0000 Subject: [PATCH] fix(qdrant): keep GitHub collection discovery pending after a validation interrupt An InterruptedException raised while validating a GitHub collection was converted to an IllegalStateException and fell through attemptDiscovery's catch (RuntimeException) arm, permanently parking the state machine at FAILED. The listing path already propagated the same interrupt as a checked exception and stayed PENDING, so the two halves of loadValidatedGitHubCollections handled one transient event in two non-equivalent ways, and the non-PENDING guard then short-circuited every @Scheduled retry with no in-bean recovery. - declare throws InterruptedException on validateGitHubCollection and drop the local catch that rethrew it as IllegalStateException, so the interrupt reaches attemptDiscovery's existing catch (InterruptedException) and keeps state PENDING like the listing path, with the interrupt status still restored --- .../QdrantGitHubCollectionDiscovery.java | 6 +- .../QdrantGitHubCollectionDiscoveryTest.java | 80 +++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/config/QdrantGitHubCollectionDiscovery.java b/src/main/java/com/williamcallahan/javachat/config/QdrantGitHubCollectionDiscovery.java index a49cbe65..afb0decc 100644 --- a/src/main/java/com/williamcallahan/javachat/config/QdrantGitHubCollectionDiscovery.java +++ b/src/main/java/com/williamcallahan/javachat/config/QdrantGitHubCollectionDiscovery.java @@ -290,7 +290,8 @@ private void validateGitHubCollection( ListenableFuture collectionInfoRequest, int expectedDimensions, String denseVectorName, - String sparseVectorName) { + String sparseVectorName) + throws InterruptedException { try { CollectionInfo collectionInfo = collectionInfoRequest.get(GRPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); @@ -345,9 +346,6 @@ private void validateGitHubCollection( + payloadIndex.getKey() + "'"); } } - } catch (InterruptedException _) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("GitHub collection validation interrupted for '" + collectionName + "'"); } catch (ExecutionException executionException) { if (isTransientGrpcFailure(executionException.getCause())) { throw new GitHubDiscoveryUnavailableException( diff --git a/src/test/java/com/williamcallahan/javachat/config/QdrantGitHubCollectionDiscoveryTest.java b/src/test/java/com/williamcallahan/javachat/config/QdrantGitHubCollectionDiscoveryTest.java index f26e693e..7b98e790 100644 --- a/src/test/java/com/williamcallahan/javachat/config/QdrantGitHubCollectionDiscoveryTest.java +++ b/src/test/java/com/williamcallahan/javachat/config/QdrantGitHubCollectionDiscoveryTest.java @@ -191,6 +191,86 @@ void discoveryCancelsCollectionListingAfterLocalTimeout() assertEquals("pending", discovery.discoveryHealth().getDetails().get("githubCollectionDiscovery")); } + @Test + void interruptionDuringValidationStaysPending() throws InterruptedException, ExecutionException, TimeoutException { + QdrantClient qdrantClient = mock(QdrantClient.class); + EmbeddingClient embeddingClient = mock(EmbeddingClient.class); + String activeCollection = GENERATION_PREFIX + "openai-java-chat"; + when(qdrantClient.listCollectionsAsync(any(java.time.Duration.class))) + .thenReturn(Futures.immediateFuture(java.util.List.of(activeCollection))); + ListenableFuture collectionInfoRequest = mock(); + when(qdrantClient.getCollectionInfoAsync(activeCollection)).thenReturn(collectionInfoRequest); + when(embeddingClient.dimensions()).thenReturn(EMBEDDING_DIMENSIONS); + when(collectionInfoRequest.get(anyLong(), eq(TimeUnit.SECONDS))) + .thenThrow(new InterruptedException("validation interrupted")); + when(collectionInfoRequest.isDone()).thenReturn(false); + + QdrantGitHubCollectionDiscovery discovery = + new QdrantGitHubCollectionDiscovery(qdrantClient, embeddingClient, new AppProperties()); + discovery.discoverGitHubCollections(); + + verify(collectionInfoRequest).cancel(true); + assertEquals(java.util.List.of(), discovery.getDiscoveredCollections()); + assertEquals(Status.DOWN, discovery.discoveryHealth().getStatus()); + assertEquals("pending", discovery.discoveryHealth().getDetails().get("githubCollectionDiscovery")); + } + + @Test + void interruptionDuringValidationRecoversOnRetry() + throws InterruptedException, ExecutionException, TimeoutException { + QdrantClient qdrantClient = mock(QdrantClient.class); + EmbeddingClient embeddingClient = mock(EmbeddingClient.class); + String activeCollection = GENERATION_PREFIX + "openai-java-chat"; + ListenableFuture interruptedCollectionInfoRequest = mock(); + when(qdrantClient.listCollectionsAsync(any(java.time.Duration.class))) + .thenReturn(Futures.immediateFuture(java.util.List.of(activeCollection))); + when(qdrantClient.getCollectionInfoAsync(activeCollection)) + .thenReturn(interruptedCollectionInfoRequest) + .thenReturn(Futures.immediateFuture(validCollectionInfo(EMBEDDING_DIMENSIONS))); + when(embeddingClient.dimensions()).thenReturn(EMBEDDING_DIMENSIONS); + when(interruptedCollectionInfoRequest.get(anyLong(), eq(TimeUnit.SECONDS))) + .thenThrow(new InterruptedException("validation interrupted")); + when(interruptedCollectionInfoRequest.isDone()).thenReturn(false); + + QdrantGitHubCollectionDiscovery discovery = + new QdrantGitHubCollectionDiscovery(qdrantClient, embeddingClient, new AppProperties()); + discovery.discoverGitHubCollections(); + assertEquals(Status.DOWN, discovery.discoveryHealth().getStatus()); + assertEquals("pending", discovery.discoveryHealth().getDetails().get("githubCollectionDiscovery")); + Thread.interrupted(); + + discovery.retryPendingDiscovery(); + assertEquals(java.util.List.of(activeCollection), discovery.getDiscoveredCollections()); + assertEquals(Status.UP, discovery.discoveryHealth().getStatus()); + assertEquals("ready", discovery.discoveryHealth().getDetails().get("githubCollectionDiscovery")); + } + + @Test + void interruptionDuringValidationRestoresInterruptStatus() + throws InterruptedException, ExecutionException, TimeoutException { + QdrantClient qdrantClient = mock(QdrantClient.class); + EmbeddingClient embeddingClient = mock(EmbeddingClient.class); + String activeCollection = GENERATION_PREFIX + "openai-java-chat"; + when(qdrantClient.listCollectionsAsync(any(java.time.Duration.class))) + .thenReturn(Futures.immediateFuture(java.util.List.of(activeCollection))); + ListenableFuture collectionInfoRequest = mock(); + when(qdrantClient.getCollectionInfoAsync(activeCollection)).thenReturn(collectionInfoRequest); + when(embeddingClient.dimensions()).thenReturn(EMBEDDING_DIMENSIONS); + when(collectionInfoRequest.get(anyLong(), eq(TimeUnit.SECONDS))) + .thenThrow(new InterruptedException("validation interrupted")); + when(collectionInfoRequest.isDone()).thenReturn(false); + + QdrantGitHubCollectionDiscovery discovery = + new QdrantGitHubCollectionDiscovery(qdrantClient, embeddingClient, new AppProperties()); + try { + discovery.discoverGitHubCollections(); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + assertEquals("pending", discovery.discoveryHealth().getDetails().get("githubCollectionDiscovery")); + } + private static CollectionInfo validCollectionInfo(int denseDimensions) { VectorParamsMap vectorParams = VectorParamsMap.newBuilder() .putMap(