From d7036023893b9743df4859cf10df9f42c2ca72e5 Mon Sep 17 00:00:00 2001 From: kl3inIT Date: Wed, 29 Jul 2026 18:48:09 +0700 Subject: [PATCH 1/4] fix(graph-rag): fan telemetry out to every event sink Both wiring sites resolved GraphRagEventSink with ObjectProvider.getIfAvailable(), which throws NoUniqueBeanDefinitionException as soon as a second sink bean exists. Registering any backend beside the OpenTelemetry adapter would have failed worker startup and retrieval configuration rather than emitting to both. GraphRagEventSink.composite fans one event out to every sink and isolates their failures: a broken backend no longer drops the event for the others. The first failure is rethrown with the rest suppressed, so the existing caller guards that treat emission as non-critical keep their behavior. Verified: :components:graph-rag-core:test --tests "com.orgmemory.graphrag.observability.*", plus :core:compileJava and :apps:worker:compileJava. Co-Authored-By: Claude Opus 5 (1M context) --- .../worker/graph/GraphIndexingProcessor.java | 2 +- .../observability/GraphRagEventSink.java | 38 ++++++++++ .../observability/GraphRagEventSinkTests.java | 74 +++++++++++++++++++ ...aphRagKnowledgeRetrievalConfiguration.java | 2 +- 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/apps/worker/src/main/java/com/orgmemory/worker/graph/GraphIndexingProcessor.java b/apps/worker/src/main/java/com/orgmemory/worker/graph/GraphIndexingProcessor.java index adfb4b2c5..5a3b4c4aa 100644 --- a/apps/worker/src/main/java/com/orgmemory/worker/graph/GraphIndexingProcessor.java +++ b/apps/worker/src/main/java/com/orgmemory/worker/graph/GraphIndexingProcessor.java @@ -79,7 +79,7 @@ class GraphIndexingProcessor { embeddingModels, routes, properties, - eventSinks.getIfAvailable(() -> GraphRagEventSink.NO_OP)); + GraphRagEventSink.composite(eventSinks.orderedStream().toList())); } GraphIndexingProcessor( diff --git a/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java b/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java index 944a5fc0e..d0b4c9b68 100644 --- a/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java +++ b/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java @@ -2,6 +2,7 @@ import java.time.Duration; import java.time.Instant; +import java.util.List; import java.util.Objects; import java.util.UUID; import java.util.regex.Pattern; @@ -20,6 +21,43 @@ public interface GraphRagEventSink { void emit(GraphRagEvent event); + /** + * Fans one event out to every sink so an application can observe the same + * stage through more than one backend. + * + *

Sinks fail independently: one failing backend still lets the others + * receive the event. The first failure is rethrown with the remaining ones + * suppressed, so a caller that already treats emission as non-critical keeps + * that behavior and a caller that does not still learns something broke. + */ + static GraphRagEventSink composite(List sinks) { + List delegates = + List.copyOf(Objects.requireNonNull(sinks, "sinks")); + if (delegates.isEmpty()) { + return NO_OP; + } + if (delegates.size() == 1) { + return delegates.getFirst(); + } + return event -> { + RuntimeException failure = null; + for (GraphRagEventSink delegate : delegates) { + try { + delegate.emit(event); + } catch (RuntimeException sinkFailure) { + if (failure == null) { + failure = sinkFailure; + } else { + failure.addSuppressed(sinkFailure); + } + } + } + if (failure != null) { + throw failure; + } + }; + } + record GraphRagEvent( UUID operationId, UUID organizationId, diff --git a/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java b/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java index e1e3e8b6f..8031fc164 100644 --- a/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java +++ b/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java @@ -1,9 +1,13 @@ package com.orgmemory.graphrag.observability; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -25,6 +29,76 @@ void rejectsUnboundedFailureDiagnostics() { "provider failed: raw payload")); } + @Test + void deliversOneEventToEveryConfiguredBackend() { + var otel = new RecordingSink(); + var meters = new RecordingSink(); + + GraphRagEventSink.composite(List.of(otel, meters)) + .emit(event(GraphRagEventSink.Outcome.SUCCEEDED, null, null)); + + assertEquals(1, otel.received.size()); + assertEquals(1, meters.received.size()); + } + + @Test + void oneFailingBackendDoesNotSilenceTheOthers() { + var failing = new RecordingSink(new IllegalStateException("collector down")); + var healthy = new RecordingSink(); + + GraphRagEventSink composite = GraphRagEventSink.composite(List.of(failing, healthy)); + var event = event(GraphRagEventSink.Outcome.SUCCEEDED, null, null); + + assertThrows(IllegalStateException.class, () -> composite.emit(event)); + assertEquals(1, healthy.received.size(), "a broken backend must not drop the rest"); + } + + @Test + void reportsEveryFailureRatherThanOnlyTheFirst() { + var first = new RecordingSink(new IllegalStateException("first")); + var second = new RecordingSink(new IllegalStateException("second")); + + GraphRagEventSink composite = GraphRagEventSink.composite(List.of(first, second)); + var event = event(GraphRagEventSink.Outcome.SUCCEEDED, null, null); + + RuntimeException failure = + assertThrows(IllegalStateException.class, () -> composite.emit(event)); + assertEquals(1, failure.getSuppressed().length); + } + + @Test + void anApplicationWithoutBackendsEmitsNothingRatherThanFailing() { + assertSame(GraphRagEventSink.NO_OP, GraphRagEventSink.composite(List.of())); + } + + @Test + void doesNotWrapASingleBackend() { + var only = new RecordingSink(); + assertSame(only, GraphRagEventSink.composite(List.of(only))); + } + + private static final class RecordingSink implements GraphRagEventSink { + + private final List received = new ArrayList<>(); + private final RuntimeException failure; + + private RecordingSink() { + this(null); + } + + private RecordingSink(RuntimeException failure) { + this.failure = failure; + } + + @Override + public void emit(GraphRagEvent event) { + if (failure != null) { + throw failure; + } + received.add(event); + } + } + private static GraphRagEventSink.GraphRagEvent event( GraphRagEventSink.Outcome outcome, String routeFingerprint, diff --git a/core/src/main/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalConfiguration.java b/core/src/main/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalConfiguration.java index a0ab68417..aa970e148 100644 --- a/core/src/main/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalConfiguration.java +++ b/core/src/main/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalConfiguration.java @@ -40,6 +40,6 @@ GraphRagKnowledgeRetrievalService graphRagKnowledgeRetrievalService( policy, audit, retrievalProperties, - eventSinks.getIfAvailable(() -> GraphRagEventSink.NO_OP)); + GraphRagEventSink.composite(eventSinks.orderedStream().toList())); } } From fd495d01aee95f1e1f04d7f1436edbca1ae58f7e Mon Sep 17 00:00:00 2001 From: kl3inIT Date: Wed, 29 Jul 2026 19:11:29 +0700 Subject: [PATCH 2/4] test(graph-rag): prove the storage adapters still auto-configure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every existing test in these modules constructs an adapter directly, so none of them notices when Spring stops loading the module. The registration file is plain text no compiler checks: renaming or moving an auto-configuration class silently removes every port it contributes while the suite stays green. Each adapter now asserts that it remains discoverable through META-INF/spring/…AutoConfiguration.imports, and that its ports appear under the conditions it declares. OpenSearch and Neo4j additionally assert they claim nothing until an operator enables them, because loading either one takes the storage ports away from PostgreSQL. The OpenSearch wiring assertion lives with the container-backed tests: its publication store creates an index while the bean is built, so it cannot be proven against mocks. Neo4j gained an assertion that an enabled adapter without a password fails startup rather than reaching the database unauthenticated. Verified: :integrations:graph-rag-postgres:test, :integrations:graph-rag-opensearch:test, :integrations:graph-rag-neo4j:test, compileJava, :core:test. Co-Authored-By: Claude Opus 5 (1M context) --- integrations/graph-rag-neo4j/build.gradle.kts | 3 + .../Neo4jGraphRagAutoConfigurationTests.java | 115 ++++++++++++++++++ .../graph-rag-opensearch/build.gradle.kts | 2 + ...nSearchGraphRagAutoConfigurationTests.java | 54 ++++++++ ...ProjectionPublicationIntegrationTests.java | 55 +++++++++ .../graph-rag-postgres/build.gradle.kts | 3 + ...ostgresGraphRagAutoConfigurationTests.java | 100 +++++++++++++++ 7 files changed, 332 insertions(+) create mode 100644 integrations/graph-rag-neo4j/src/test/java/com/orgmemory/graphrag/neo4j/Neo4jGraphRagAutoConfigurationTests.java create mode 100644 integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchGraphRagAutoConfigurationTests.java create mode 100644 integrations/graph-rag-postgres/src/test/java/com/orgmemory/graphrag/postgres/PostgresGraphRagAutoConfigurationTests.java diff --git a/integrations/graph-rag-neo4j/build.gradle.kts b/integrations/graph-rag-neo4j/build.gradle.kts index d5fe0e73c..1a6c9bea1 100644 --- a/integrations/graph-rag-neo4j/build.gradle.kts +++ b/integrations/graph-rag-neo4j/build.gradle.kts @@ -10,6 +10,9 @@ dependencies { testImplementation(project(":components:graph-rag-testkit")) testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.mockito:mockito-core") + testImplementation("org.springframework.boot:spring-boot-test") + testImplementation("org.assertj:assertj-core") testImplementation("org.testcontainers:testcontainers-neo4j") testImplementation("org.testcontainers:testcontainers-junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/integrations/graph-rag-neo4j/src/test/java/com/orgmemory/graphrag/neo4j/Neo4jGraphRagAutoConfigurationTests.java b/integrations/graph-rag-neo4j/src/test/java/com/orgmemory/graphrag/neo4j/Neo4jGraphRagAutoConfigurationTests.java new file mode 100644 index 000000000..ad2617cfb --- /dev/null +++ b/integrations/graph-rag-neo4j/src/test/java/com/orgmemory/graphrag/neo4j/Neo4jGraphRagAutoConfigurationTests.java @@ -0,0 +1,115 @@ +package com.orgmemory.graphrag.neo4j; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import com.orgmemory.graphrag.storage.GraphStore; +import com.orgmemory.graphrag.storage.ProjectionPublicationStore; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.annotation.ImportCandidates; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Neo4j takes the graph port away from whichever adapter would otherwise hold + * it, so the same two questions matter as for OpenSearch: it must stay inert + * until an operator enables it, and enabling it must actually produce the + * store rather than fail somewhere Spring reports as a missing bean. + */ +class Neo4jGraphRagAutoConfigurationTests { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(Neo4jGraphRagAutoConfiguration.class)) + .withUserConfiguration(CollaboratorConfiguration.class); + + @Test + void staysDiscoverableWithoutAnApplicationNamingIt() { + assertTrue( + registeredAutoConfigurations() + .contains(Neo4jGraphRagAutoConfiguration.class.getName()), + "META-INF/spring/…AutoConfiguration.imports no longer names this class, " + + "so enabling the property would silently do nothing"); + } + + @Test + void claimsNoGraphPortUntilAnOperatorAsksForIt() { + runner.run(context -> assertTrue( + context.getBeansOfType(GraphStore.class).isEmpty(), + "classpath presence must not displace the canonical graph store")); + } + + @Test + void contributesTheGraphStoreOnceEnabled() { + runner.withPropertyValues( + "orgmemory.graph-rag.neo4j.enabled=true", + "orgmemory.graph-rag.neo4j.password=test-password") + .run(context -> assertInstanceOf( + Neo4jGraphStore.class, context.getBean(GraphStore.class))); + } + + @Test + void refusesToStartRatherThanReachNeo4jUnauthenticated() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(Neo4jGraphRagAutoConfiguration.class)) + .withUserConfiguration(PublicationConfiguration.class) + .withPropertyValues("orgmemory.graph-rag.neo4j.enabled=true") + .run(context -> assertInstanceOf( + IllegalArgumentException.class, + rootCause(context.getStartupFailure()), + "an enabled adapter without a password must fail startup")); + } + + private static Throwable rootCause(Throwable failure) { + Throwable cause = failure; + while (cause != null && cause.getCause() != null) { + cause = cause.getCause(); + } + return cause; + } + + private static List registeredAutoConfigurations() { + var names = new ArrayList(); + ImportCandidates.load( + AutoConfiguration.class, + Neo4jGraphRagAutoConfigurationTests.class.getClassLoader()) + .forEach(names::add); + return names; + } + + /** + * The collaborators an application already owns before this adapter loads. + * Supplying the driver and {@link Neo4jOperations} keeps a live Neo4j out of + * a unit test; the adapter backs off both because they are conditional on a + * missing bean. + */ + @Configuration(proxyBeanMethods = false) + static class CollaboratorConfiguration extends PublicationConfiguration { + + @Bean + Driver neo4jDriver() { + return mock(Driver.class); + } + + @Bean + Neo4jOperations neo4jOperations() { + return mock(Neo4jOperations.class); + } + } + + /** Only the port the adapter expects an application to already provide. */ + @Configuration(proxyBeanMethods = false) + static class PublicationConfiguration { + + @Bean + ProjectionPublicationStore projectionPublicationStore() { + return mock(ProjectionPublicationStore.class); + } + } +} diff --git a/integrations/graph-rag-opensearch/build.gradle.kts b/integrations/graph-rag-opensearch/build.gradle.kts index 571049ee4..3d5e73bb6 100644 --- a/integrations/graph-rag-opensearch/build.gradle.kts +++ b/integrations/graph-rag-opensearch/build.gradle.kts @@ -12,6 +12,8 @@ dependencies { testImplementation(project(":components:graph-rag-testkit")) testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.springframework.boot:spring-boot-test") + testImplementation("org.assertj:assertj-core") testImplementation("org.testcontainers:testcontainers") testImplementation("org.testcontainers:testcontainers-junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchGraphRagAutoConfigurationTests.java b/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchGraphRagAutoConfigurationTests.java new file mode 100644 index 000000000..ff6555d05 --- /dev/null +++ b/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchGraphRagAutoConfigurationTests.java @@ -0,0 +1,54 @@ +package com.orgmemory.graphrag.opensearch; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.orgmemory.graphrag.storage.ContentStore; +import com.orgmemory.graphrag.storage.GraphStore; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.annotation.ImportCandidates; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * OpenSearch takes the storage ports away from PostgreSQL when it loads, so the + * property that turns it on is a production-shaped decision rather than a tuning + * knob. These are the two questions that need no server to answer: Spring can + * still find the adapter, and the adapter stays out of the way until asked for. + * {@link OpenSearchProjectionPublicationIntegrationTests} covers what wiring it + * up actually produces, because these beans reach OpenSearch as they are built. + */ +class OpenSearchGraphRagAutoConfigurationTests { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(OpenSearchGraphRagAutoConfiguration.class)); + + @Test + void staysDiscoverableWithoutAnApplicationNamingIt() { + assertTrue( + registeredAutoConfigurations() + .contains(OpenSearchGraphRagAutoConfiguration.class.getName()), + "META-INF/spring/…AutoConfiguration.imports no longer names this class, " + + "so enabling the property would silently do nothing"); + } + + @Test + void claimsNoPortUntilAnOperatorAsksForIt() { + runner.run(context -> assertTrue( + context.getBeansOfType(GraphStore.class).isEmpty() + && context.getBeansOfType(ContentStore.class).isEmpty(), + "classpath presence must not displace the canonical PostgreSQL adapter")); + } + + private static List registeredAutoConfigurations() { + var names = new ArrayList(); + ImportCandidates.load( + AutoConfiguration.class, + OpenSearchGraphRagAutoConfigurationTests.class.getClassLoader()) + .forEach(names::add); + return names; + } +} diff --git a/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchProjectionPublicationIntegrationTests.java b/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchProjectionPublicationIntegrationTests.java index e7e275fc3..5e55a8651 100644 --- a/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchProjectionPublicationIntegrationTests.java +++ b/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchProjectionPublicationIntegrationTests.java @@ -1,6 +1,7 @@ package com.orgmemory.graphrag.opensearch; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -17,6 +18,7 @@ import com.orgmemory.graphrag.model.RelationOrientation; import com.orgmemory.graphrag.port.GraphRevisionContributions; import com.orgmemory.graphrag.storage.ContentStore; +import com.orgmemory.graphrag.storage.GraphStore; import com.orgmemory.graphrag.storage.LexicalIndex; import com.orgmemory.graphrag.storage.ProjectionBatch; import com.orgmemory.graphrag.storage.ProjectionKind; @@ -36,6 +38,10 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.junit.jupiter.Container; @@ -608,6 +614,55 @@ void pplAppliesAuthorizedEvidenceFilterAtEveryTraversalHop() { assertTrue(ppl.successfulExecutions() > executionsBefore); } + /** + * The rest of this class builds adapters directly, which proves they work but + * not that Spring produces them. Enabling the adapter has to contribute every + * port it declares, because each one it claims is a port PostgreSQL no longer + * gets to serve. + */ + @Test + void autoConfigurationContributesEveryPortItClaimsOnceEnabled() { + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(OpenSearchGraphRagAutoConfiguration.class)) + .withUserConfiguration(ObjectMapperConfiguration.class) + .withPropertyValues( + "orgmemory.graph-rag.opensearch.enabled=true", + "orgmemory.graph-rag.opensearch.endpoint=http://%s:%d" + .formatted( + opensearch.getHost(), + opensearch.getMappedPort(9200)), + "orgmemory.graph-rag.opensearch.index-prefix=orgmemory-wiring-test") + .run(context -> { + assertInstanceOf( + OpenSearchProjectionPublicationStore.class, + context.getBean( + com.orgmemory.graphrag.storage.ProjectionPublicationStore + .class)); + assertInstanceOf( + OpenSearchContentStore.class, context.getBean(ContentStore.class)); + assertInstanceOf( + OpenSearchGraphStore.class, context.getBean(GraphStore.class)); + assertInstanceOf( + OpenSearchLexicalIndex.class, context.getBean(LexicalIndex.class)); + assertInstanceOf( + OpenSearchVectorIndex.class, context.getBean(VectorIndex.class)); + assertInstanceOf( + OpenSearchProcessingStatusIndex.class, + context.getBean(ProcessingStatusIndex.class)); + }); + } + + /** The collaborator an application already owns before this adapter loads. */ + @Configuration(proxyBeanMethods = false) + static class ObjectMapperConfiguration { + + @Bean + ObjectMapper wiringTestObjectMapper() { + return new ObjectMapper(); + } + } + private static ProjectionBatch batch( ProjectionNamespace namespace, String key, diff --git a/integrations/graph-rag-postgres/build.gradle.kts b/integrations/graph-rag-postgres/build.gradle.kts index f6fad813c..43c269459 100644 --- a/integrations/graph-rag-postgres/build.gradle.kts +++ b/integrations/graph-rag-postgres/build.gradle.kts @@ -13,6 +13,9 @@ dependencies { testImplementation(project(":core")) testImplementation(project(":components:graph-rag-testkit")) testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.mockito:mockito-core") + testImplementation("org.springframework.boot:spring-boot-test") + testImplementation("org.assertj:assertj-core") testImplementation("org.flywaydb:flyway-core") testImplementation("org.flywaydb:flyway-database-postgresql") testImplementation("org.testcontainers:testcontainers-postgresql") diff --git a/integrations/graph-rag-postgres/src/test/java/com/orgmemory/graphrag/postgres/PostgresGraphRagAutoConfigurationTests.java b/integrations/graph-rag-postgres/src/test/java/com/orgmemory/graphrag/postgres/PostgresGraphRagAutoConfigurationTests.java new file mode 100644 index 000000000..9094bbfd8 --- /dev/null +++ b/integrations/graph-rag-postgres/src/test/java/com/orgmemory/graphrag/postgres/PostgresGraphRagAutoConfigurationTests.java @@ -0,0 +1,100 @@ +package com.orgmemory.graphrag.postgres; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import com.orgmemory.graphrag.cache.ModelInvocationCache; +import com.orgmemory.graphrag.cache.RetrievalResultCache; +import com.orgmemory.graphrag.storage.ContentStore; +import com.orgmemory.graphrag.storage.GraphStore; +import com.orgmemory.graphrag.storage.LexicalIndex; +import com.orgmemory.graphrag.storage.ProjectionPublicationStore; +import com.orgmemory.graphrag.storage.VectorIndex; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.annotation.ImportCandidates; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Every other test in this module builds an adapter directly, so none of them + * notices when Spring stops loading the module at all. The registration file is + * plain text that no compiler checks, and the ports are contributed under + * conditions that a property or an ordering change can quietly invert. These + * tests fail in that case instead of leaving it for production to discover. + */ +class PostgresGraphRagAutoConfigurationTests { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(PostgresGraphRagAutoConfiguration.class)) + .withUserConfiguration(CollaboratorConfiguration.class); + + @Test + void staysDiscoverableWithoutAnApplicationNamingIt() { + assertTrue( + registeredAutoConfigurations() + .contains(PostgresGraphRagAutoConfiguration.class.getName()), + "META-INF/spring/…AutoConfiguration.imports no longer names this class, " + + "so Spring Boot will never load the PostgreSQL adapter"); + } + + @Test + void ownsTheCanonicalPortsWithoutAnyOptIn() { + runner.run(context -> { + assertInstanceOf( + PostgresProjectionPublicationStore.class, + context.getBean(ProjectionPublicationStore.class)); + assertInstanceOf(PostgresContentStore.class, context.getBean(ContentStore.class)); + assertInstanceOf(PostgresGraphStore.class, context.getBean(GraphStore.class)); + assertInstanceOf(PostgresLexicalIndex.class, context.getBean(LexicalIndex.class)); + assertInstanceOf(PostgresVectorIndex.class, context.getBean(VectorIndex.class)); + assertInstanceOf( + PostgresModelInvocationCache.class, + context.getBean(ModelInvocationCache.class)); + assertInstanceOf( + PostgresRetrievalResultCache.class, + context.getBean(RetrievalResultCache.class)); + }); + } + + @Test + void leavesEveryPortUnclaimedWhenTurnedOff() { + runner.withPropertyValues("orgmemory.graph-rag.postgres.enabled=false") + .run(context -> assertTrue( + context.getBeansOfType(GraphStore.class).isEmpty() + && context.getBeansOfType(ContentStore.class).isEmpty(), + "disabling the adapter must not leave a half-wired store")); + } + + private static List registeredAutoConfigurations() { + var names = new ArrayList(); + ImportCandidates.load( + AutoConfiguration.class, + PostgresGraphRagAutoConfigurationTests.class.getClassLoader()) + .forEach(names::add); + return names; + } + + /** The collaborators an application already owns before this adapter loads. */ + @Configuration(proxyBeanMethods = false) + static class CollaboratorConfiguration { + + @Bean + NamedParameterJdbcTemplate namedParameterJdbcTemplate() { + return mock(NamedParameterJdbcTemplate.class); + } + + @Bean + PlatformTransactionManager transactionManager() { + return mock(PlatformTransactionManager.class); + } + } +} From 474896144dfb087041a2de21a67720d7fd448799 Mon Sep 17 00:00:00 2001 From: kl3inIT Date: Wed, 29 Jul 2026 19:14:03 +0700 Subject: [PATCH 3/4] docs: reconcile secure-graph-rag with the observability wiring change Telemetry fan-out and the storage adapters' auto-configuration coverage are current behavior, so the spec and its mirrored test matrix carry them and both reconciliation commits move forward. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/domains/secure-graph-rag.md | 6 +++++- docs/tests/domains/secure-graph-rag.md | 11 ++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/specs/domains/secure-graph-rag.md b/docs/specs/domains/secure-graph-rag.md index 9d390a94c..9f6ece893 100644 --- a/docs/specs/domains/secure-graph-rag.md +++ b/docs/specs/domains/secure-graph-rag.md @@ -5,7 +5,7 @@ Source: `components/graph-rag-core`, `components/graph-rag-testkit`, `core/src/main/java/com/orgmemory/core/knowledge`, and `apps/web/src/features/knowledge`. -Reconciled: `2026-07-29-polyglot-apps-workspace (7acda3a)`. +Reconciled: `2026-07-29-graph-rag-observability-wiring (fd495d0)`. ## Current Contract @@ -140,6 +140,10 @@ Reconciled: `2026-07-29-polyglot-apps-workspace (7acda3a)`. - Payload-free OpenTelemetry stages separate keyword planning/cache status, embedding, hashed per-snapshot retrieval, consolidation, authorization and provider-only reranking duration. +- Indexing and retrieval fan one stage event out to every registered + `GraphRagEventSink`, so an application may observe the same stage through more + than one backend. Sinks fail independently and emission never controls + indexing or retrieval availability. ## Graph Explorer diff --git a/docs/tests/domains/secure-graph-rag.md b/docs/tests/domains/secure-graph-rag.md index b8f0cd49f..67078e3c4 100644 --- a/docs/tests/domains/secure-graph-rag.md +++ b/docs/tests/domains/secure-graph-rag.md @@ -6,7 +6,7 @@ Source: `components/graph-rag-core/src/test`, `core/src/test/java/com/orgmemory/core/knowledge`, and `apps/web/test/e2e`. -Reconciled: `2026-07-29-polyglot-apps-workspace (7acda3a)`. +Reconciled: `2026-07-29-graph-rag-observability-wiring (fd495d0)`. ## Automated @@ -50,6 +50,15 @@ Reconciled: `2026-07-29-polyglot-apps-workspace (7acda3a)`. - OpenTelemetry adapter tests prove the closed payload-free attribute set, original stage timing, cache status, model-route fingerprint and hashed scope fingerprint. +- Event-sink composition tests prove every registered backend receives the + event, that one failing backend neither silences the others nor hides its own + failure, and that an application with no backend emits nothing. +- Storage adapter auto-configuration tests prove PostgreSQL, OpenSearch and + Neo4j stay discoverable through their registration files, that PostgreSQL owns + the canonical ports without an opt-in, that OpenSearch and Neo4j claim no port + until enabled, and that an enabled Neo4j without a password fails startup. + The OpenSearch wiring assertion runs against the container because its + publication store creates an index while the bean is built. ## Verification From 86ae6bc02a369471f2b77c87523eb6902988d1ba Mon Sep 17 00:00:00 2001 From: kl3inIT Date: Wed, 29 Jul 2026 20:48:31 +0700 Subject: [PATCH 4/4] fix(graph-rag): keep composite fan-out safe when sinks share a failure Review caught a real defect in the composite: Throwable.addSuppressed rejects self-suppression, so two sinks raising one shared exception instance made the fan-out throw IllegalArgumentException from inside telemetry rather than reporting the sink failure. An identity guard skips the redundant suppression. The assertions the review flagged as too weak now hold their claims: the composite test keeps both exception references and proves which one propagates and which is suppressed; Neo4j asserts the password branch by message rather than accepting any validation failure; and both disabled-path tests assert every port the adapter would claim rather than a sample of two. Verified: the graph-rag-core, postgres, neo4j and opensearch auto-configuration and event-sink tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../observability/GraphRagEventSink.java | 4 ++- .../observability/GraphRagEventSinkTests.java | 32 ++++++++++++++++--- docs/tests/domains/secure-graph-rag.md | 4 ++- .../Neo4jGraphRagAutoConfigurationTests.java | 17 +++++++--- ...nSearchGraphRagAutoConfigurationTests.java | 21 +++++++++--- ...ostgresGraphRagAutoConfigurationTests.java | 18 ++++++++--- 6 files changed, 77 insertions(+), 19 deletions(-) diff --git a/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java b/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java index d0b4c9b68..885cebf09 100644 --- a/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java +++ b/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java @@ -47,7 +47,9 @@ static GraphRagEventSink composite(List sinks) { } catch (RuntimeException sinkFailure) { if (failure == null) { failure = sinkFailure; - } else { + } else if (failure != sinkFailure) { + // Throwable.addSuppressed rejects self-suppression, and two + // sinks can raise one shared instance. failure.addSuppressed(sinkFailure); } } diff --git a/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java b/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java index 8031fc164..2422b07d9 100644 --- a/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java +++ b/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java @@ -55,15 +55,37 @@ void oneFailingBackendDoesNotSilenceTheOthers() { @Test void reportsEveryFailureRatherThanOnlyTheFirst() { - var first = new RecordingSink(new IllegalStateException("first")); - var second = new RecordingSink(new IllegalStateException("second")); + var firstFailure = new IllegalStateException("first"); + var secondFailure = new IllegalStateException("second"); - GraphRagEventSink composite = GraphRagEventSink.composite(List.of(first, second)); + GraphRagEventSink composite = GraphRagEventSink.composite( + List.of(new RecordingSink(firstFailure), new RecordingSink(secondFailure))); var event = event(GraphRagEventSink.Outcome.SUCCEEDED, null, null); - RuntimeException failure = + RuntimeException thrown = assertThrows(IllegalStateException.class, () -> composite.emit(event)); - assertEquals(1, failure.getSuppressed().length); + + assertSame(firstFailure, thrown, "the first failure is the one that propagates"); + assertEquals(1, thrown.getSuppressed().length); + assertSame(secondFailure, thrown.getSuppressed()[0]); + } + + @Test + void survivesTwoBackendsRaisingOneSharedFailure() { + var shared = new IllegalStateException("collector down"); + + GraphRagEventSink composite = GraphRagEventSink.composite( + List.of(new RecordingSink(shared), new RecordingSink(shared))); + var event = event(GraphRagEventSink.Outcome.SUCCEEDED, null, null); + + RuntimeException thrown = + assertThrows(IllegalStateException.class, () -> composite.emit(event)); + + assertSame(shared, thrown); + assertEquals( + 0, + thrown.getSuppressed().length, + "Throwable.addSuppressed rejects self-suppression, so it must not be called"); } @Test diff --git a/docs/tests/domains/secure-graph-rag.md b/docs/tests/domains/secure-graph-rag.md index 67078e3c4..c2c742e1c 100644 --- a/docs/tests/domains/secure-graph-rag.md +++ b/docs/tests/domains/secure-graph-rag.md @@ -52,7 +52,9 @@ Reconciled: `2026-07-29-graph-rag-observability-wiring (fd495d0)`. fingerprint. - Event-sink composition tests prove every registered backend receives the event, that one failing backend neither silences the others nor hides its own - failure, and that an application with no backend emits nothing. + failure, that the first failure is the one propagated with later ones + suppressed, that two backends raising one shared failure instance do not trip + self-suppression, and that an application with no backend emits nothing. - Storage adapter auto-configuration tests prove PostgreSQL, OpenSearch and Neo4j stay discoverable through their registration files, that PostgreSQL owns the canonical ports without an opt-in, that OpenSearch and Neo4j claim no port diff --git a/integrations/graph-rag-neo4j/src/test/java/com/orgmemory/graphrag/neo4j/Neo4jGraphRagAutoConfigurationTests.java b/integrations/graph-rag-neo4j/src/test/java/com/orgmemory/graphrag/neo4j/Neo4jGraphRagAutoConfigurationTests.java index ad2617cfb..b250fc47b 100644 --- a/integrations/graph-rag-neo4j/src/test/java/com/orgmemory/graphrag/neo4j/Neo4jGraphRagAutoConfigurationTests.java +++ b/integrations/graph-rag-neo4j/src/test/java/com/orgmemory/graphrag/neo4j/Neo4jGraphRagAutoConfigurationTests.java @@ -1,5 +1,6 @@ package com.orgmemory.graphrag.neo4j; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -60,10 +61,18 @@ void refusesToStartRatherThanReachNeo4jUnauthenticated() { .withConfiguration(AutoConfigurations.of(Neo4jGraphRagAutoConfiguration.class)) .withUserConfiguration(PublicationConfiguration.class) .withPropertyValues("orgmemory.graph-rag.neo4j.enabled=true") - .run(context -> assertInstanceOf( - IllegalArgumentException.class, - rootCause(context.getStartupFailure()), - "an enabled adapter without a password must fail startup")); + .run(context -> { + Throwable cause = rootCause(context.getStartupFailure()); + assertInstanceOf( + IllegalArgumentException.class, + cause, + "an enabled adapter without a password must fail startup"); + assertEquals( + "Neo4j password must be configured when the adapter is enabled", + cause.getMessage(), + "the password branch must be what fails, not another " + + "validation rule"); + }); } private static Throwable rootCause(Throwable failure) { diff --git a/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchGraphRagAutoConfigurationTests.java b/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchGraphRagAutoConfigurationTests.java index ff6555d05..f6cea1943 100644 --- a/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchGraphRagAutoConfigurationTests.java +++ b/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchGraphRagAutoConfigurationTests.java @@ -4,6 +4,10 @@ import com.orgmemory.graphrag.storage.ContentStore; import com.orgmemory.graphrag.storage.GraphStore; +import com.orgmemory.graphrag.storage.LexicalIndex; +import com.orgmemory.graphrag.storage.ProcessingStatusIndex; +import com.orgmemory.graphrag.storage.ProjectionPublicationStore; +import com.orgmemory.graphrag.storage.VectorIndex; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; @@ -35,12 +39,21 @@ void staysDiscoverableWithoutAnApplicationNamingIt() { + "so enabling the property would silently do nothing"); } + /** Every port {@link OpenSearchGraphRagAutoConfiguration} contributes when enabled. */ + private static final List> CLAIMED_PORTS = List.of( + ProjectionPublicationStore.class, + ContentStore.class, + GraphStore.class, + LexicalIndex.class, + VectorIndex.class, + ProcessingStatusIndex.class); + @Test void claimsNoPortUntilAnOperatorAsksForIt() { - runner.run(context -> assertTrue( - context.getBeansOfType(GraphStore.class).isEmpty() - && context.getBeansOfType(ContentStore.class).isEmpty(), - "classpath presence must not displace the canonical PostgreSQL adapter")); + runner.run(context -> CLAIMED_PORTS.forEach(port -> assertTrue( + context.getBeansOfType(port).isEmpty(), + "%s must stay with the canonical PostgreSQL adapter until OpenSearch is enabled" + .formatted(port.getSimpleName())))); } private static List registeredAutoConfigurations() { diff --git a/integrations/graph-rag-postgres/src/test/java/com/orgmemory/graphrag/postgres/PostgresGraphRagAutoConfigurationTests.java b/integrations/graph-rag-postgres/src/test/java/com/orgmemory/graphrag/postgres/PostgresGraphRagAutoConfigurationTests.java index 9094bbfd8..8139f5c3f 100644 --- a/integrations/graph-rag-postgres/src/test/java/com/orgmemory/graphrag/postgres/PostgresGraphRagAutoConfigurationTests.java +++ b/integrations/graph-rag-postgres/src/test/java/com/orgmemory/graphrag/postgres/PostgresGraphRagAutoConfigurationTests.java @@ -32,6 +32,16 @@ */ class PostgresGraphRagAutoConfigurationTests { + /** Every port this adapter contributes outside a servlet application. */ + private static final List> CANONICAL_PORTS = List.of( + ProjectionPublicationStore.class, + ContentStore.class, + GraphStore.class, + LexicalIndex.class, + VectorIndex.class, + ModelInvocationCache.class, + RetrievalResultCache.class); + private final ApplicationContextRunner runner = new ApplicationContextRunner() .withConfiguration( AutoConfigurations.of(PostgresGraphRagAutoConfiguration.class)) @@ -68,10 +78,10 @@ void ownsTheCanonicalPortsWithoutAnyOptIn() { @Test void leavesEveryPortUnclaimedWhenTurnedOff() { runner.withPropertyValues("orgmemory.graph-rag.postgres.enabled=false") - .run(context -> assertTrue( - context.getBeansOfType(GraphStore.class).isEmpty() - && context.getBeansOfType(ContentStore.class).isEmpty(), - "disabling the adapter must not leave a half-wired store")); + .run(context -> CANONICAL_PORTS.forEach(port -> assertTrue( + context.getBeansOfType(port).isEmpty(), + "disabling the adapter must not leave %s wired" + .formatted(port.getSimpleName())))); } private static List registeredAutoConfigurations() {