Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class GraphIndexingProcessor {
embeddingModels,
routes,
properties,
eventSinks.getIfAvailable(() -> GraphRagEventSink.NO_OP));
GraphRagEventSink.composite(eventSinks.orderedStream().toList()));
}

GraphIndexingProcessor(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
* <p>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<GraphRagEventSink> sinks) {
List<GraphRagEventSink> 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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
if (failure != null) {
throw failure;
}
};
}

record GraphRagEvent(
UUID operationId,
UUID organizationId,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@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<GraphRagEvent> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ GraphRagKnowledgeRetrievalService graphRagKnowledgeRetrievalService(
policy,
audit,
retrievalProperties,
eventSinks.getIfAvailable(() -> GraphRagEventSink.NO_OP));
GraphRagEventSink.composite(eventSinks.orderedStream().toList()));
}
}
6 changes: 5 additions & 1 deletion docs/specs/domains/secure-graph-rag.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion docs/tests/domains/secure-graph-rag.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions integrations/graph-rag-neo4j/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> registeredAutoConfigurations() {
var names = new ArrayList<String>();
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);
}
}
}
2 changes: 2 additions & 0 deletions integrations/graph-rag-opensearch/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading