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..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 @@ -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,45 @@ 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 if (failure != sinkFailure) { + // Throwable.addSuppressed rejects self-suppression, and two + // sinks can raise one shared instance. + 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..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 @@ -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,98 @@ 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 firstFailure = new IllegalStateException("first"); + var secondFailure = new IllegalStateException("second"); + + GraphRagEventSink composite = GraphRagEventSink.composite( + List.of(new RecordingSink(firstFailure), new RecordingSink(secondFailure))); + var event = event(GraphRagEventSink.Outcome.SUCCEEDED, null, null); + + RuntimeException thrown = + assertThrows(IllegalStateException.class, () -> composite.emit(event)); + + 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 + 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())); } } 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..c2c742e1c 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,17 @@ 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, 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 + 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 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..b250fc47b --- /dev/null +++ b/integrations/graph-rag-neo4j/src/test/java/com/orgmemory/graphrag/neo4j/Neo4jGraphRagAutoConfigurationTests.java @@ -0,0 +1,124 @@ +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; + +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 -> { + 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) { + 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..f6cea1943 --- /dev/null +++ b/integrations/graph-rag-opensearch/src/test/java/com/orgmemory/graphrag/opensearch/OpenSearchGraphRagAutoConfigurationTests.java @@ -0,0 +1,67 @@ +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 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; +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"); + } + + /** 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 -> 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() { + 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..8139f5c3f --- /dev/null +++ b/integrations/graph-rag-postgres/src/test/java/com/orgmemory/graphrag/postgres/PostgresGraphRagAutoConfigurationTests.java @@ -0,0 +1,110 @@ +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 { + + /** 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)) + .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 -> CANONICAL_PORTS.forEach(port -> assertTrue( + context.getBeansOfType(port).isEmpty(), + "disabling the adapter must not leave %s wired" + .formatted(port.getSimpleName())))); + } + + 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); + } + } +}