Skip to content

Commit f694a46

Browse files
fix(langfuse): close partially initialized spans (#605)
1 parent 6f9ef46 commit f694a46

4 files changed

Lines changed: 72 additions & 38 deletions

File tree

‎data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/langfuse/NodeTracingLifecycleListener.java‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,12 @@ public void before(String nodeId, Map<String, Object> state, RunnableConfig conf
102102
return;
103103
}
104104

105+
Span span = null;
105106
try {
106107
int attempt = attemptCounters.computeIfAbsent(counterKey(threadId, nodeId), k -> new AtomicInteger())
107108
.incrementAndGet();
108109

109-
Span span = tracer.spanBuilder(nodeId)
110+
span = tracer.spanBuilder(nodeId)
110111
.setSpanKind(SpanKind.INTERNAL)
111112
.setParent(io.opentelemetry.context.Context.current().with(rootSpan))
112113
.startSpan();
@@ -122,9 +123,19 @@ public void before(String nodeId, Map<String, Object> state, RunnableConfig conf
122123
LangfuseService.registerActiveAccumulator(threadId);
123124

124125
activeNodes.computeIfAbsent(threadId, k -> new ArrayDeque<>()).push(new ActiveNode(nodeId, span, snapshot));
126+
// Ownership is now held by activeNodes and transferred to after/onError.
127+
span = null;
125128
}
126129
catch (Exception e) {
127130
log.warn("Failed to start Langfuse span for node {}", nodeId, e);
131+
if (span != null) {
132+
try {
133+
span.end();
134+
}
135+
catch (Exception endException) {
136+
log.warn("Failed to clean up partially started Langfuse span for node {}", nodeId, endException);
137+
}
138+
}
128139
}
129140
}
130141

‎data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/controller/GraphControllerTest.java‎

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
import org.springframework.http.codec.ServerSentEvent;
3030
import org.springframework.http.server.reactive.ServerHttpResponse;
3131
import org.springframework.http.HttpHeaders;
32-
import reactor.core.publisher.Flux;
3332
import reactor.core.publisher.Sinks;
33+
import reactor.test.StepVerifier;
3434

3535
import java.time.Duration;
3636

@@ -64,11 +64,16 @@ private void stubResponseHeaders() {
6464
@Test
6565
void streamSearch_validRequest_invokesGraphServiceAndReturnsFlux() {
6666
stubResponseHeaders();
67+
doAnswer(invocation -> {
68+
Sinks.Many<ServerSentEvent<GraphNodeResponse>> sink = invocation.getArgument(0);
69+
sink.tryEmitComplete();
70+
return null;
71+
}).when(graphService).graphStreamProcess(any(Sinks.Many.class), any(GraphRequest.class));
6772

68-
Flux<ServerSentEvent<GraphNodeResponse>> result = graphController.streamSearch("agent-1", "conversation-1",
69-
"thread-1", "show me sales data", false, null, false, false, serverHttpResponse);
70-
71-
assertNotNull(result);
73+
StepVerifier
74+
.create(graphController.streamSearch("agent-1", "conversation-1", "thread-1", "show me sales data", false,
75+
null, false, false, serverHttpResponse))
76+
.verifyComplete();
7277

7378
ArgumentCaptor<GraphRequest> requestCaptor = ArgumentCaptor.forClass(GraphRequest.class);
7479
verify(graphService).graphStreamProcess(any(Sinks.Many.class), requestCaptor.capture());
@@ -79,16 +84,17 @@ void streamSearch_validRequest_invokesGraphServiceAndReturnsFlux() {
7984
assertEquals("thread-1", captured.getThreadId());
8085
assertEquals("show me sales data", captured.getQuery());
8186
assertFalse(captured.isHumanFeedback());
87+
verify(httpHeaders).add("Cache-Control", "no-cache");
88+
verify(httpHeaders).add("Connection", "keep-alive");
89+
verify(httpHeaders).add("Access-Control-Allow-Origin", "*");
8290
}
8391

8492
@Test
8593
void streamSearch_humanFeedback_passesHumanFeedbackParams() {
8694
stubResponseHeaders();
8795

88-
Flux<ServerSentEvent<GraphNodeResponse>> result = graphController.streamSearch("agent-1", "conversation-2",
89-
"thread-2", "approve this plan", true, "looks good", false, false, serverHttpResponse);
90-
91-
assertNotNull(result);
96+
graphController.streamSearch("agent-1", "conversation-2", "thread-2", "approve this plan", true, "looks good",
97+
false, false, serverHttpResponse);
9298

9399
ArgumentCaptor<GraphRequest> requestCaptor = ArgumentCaptor.forClass(GraphRequest.class);
94100
verify(graphService).graphStreamProcess(any(Sinks.Many.class), requestCaptor.capture());
@@ -103,10 +109,8 @@ void streamSearch_humanFeedback_passesHumanFeedbackParams() {
103109
void streamSearch_nl2sqlOnly_setsNl2sqlOnlyFlag() {
104110
stubResponseHeaders();
105111

106-
Flux<ServerSentEvent<GraphNodeResponse>> result = graphController.streamSearch("agent-1", "conversation-3",
107-
null, "SELECT query", false, null, false, true, serverHttpResponse);
108-
109-
assertNotNull(result);
112+
graphController.streamSearch("agent-1", "conversation-3", null, "SELECT query", false, null, false, true,
113+
serverHttpResponse);
110114

111115
ArgumentCaptor<GraphRequest> requestCaptor = ArgumentCaptor.forClass(GraphRequest.class);
112116
verify(graphService).graphStreamProcess(any(Sinks.Many.class), requestCaptor.capture());

‎data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/chat/SessionTitleServiceTest.java‎

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@
2525
import org.springframework.ai.chat.model.ChatResponse;
2626
import reactor.core.publisher.Flux;
2727

28+
import java.util.concurrent.CountDownLatch;
2829
import java.util.concurrent.ExecutorService;
2930
import java.util.concurrent.Executors;
3031
import java.util.concurrent.TimeUnit;
31-
import java.util.concurrent.atomic.AtomicReference;
3232

33+
import static org.junit.jupiter.api.Assertions.assertTrue;
3334
import static org.mockito.ArgumentMatchers.*;
3435
import static org.mockito.Mockito.*;
3536

@@ -77,7 +78,7 @@ void scheduleTitleGeneration_sessionNotFound_doesNotCallRename() throws Exceptio
7778

7879
service.scheduleTitleGeneration("session-1", "hello");
7980
executorService.shutdown();
80-
executorService.awaitTermination(5, TimeUnit.SECONDS);
81+
assertTrue(executorService.awaitTermination(5, TimeUnit.SECONDS), "the title task must actually finish");
8182

8283
verify(chatSessionService).findBySessionId("session-1");
8384
verify(chatSessionService, never()).renameSession(anyString(), anyString());
@@ -90,7 +91,7 @@ void scheduleTitleGeneration_sessionHasCustomTitle_skips() throws Exception {
9091

9192
service.scheduleTitleGeneration("session-1", "hello");
9293
executorService.shutdown();
93-
executorService.awaitTermination(5, TimeUnit.SECONDS);
94+
assertTrue(executorService.awaitTermination(5, TimeUnit.SECONDS), "the title task must actually finish");
9495

9596
verify(chatSessionService, never()).renameSession(anyString(), anyString());
9697
}
@@ -106,40 +107,44 @@ void scheduleTitleGeneration_generatesAndPersistsTitle() throws Exception {
106107

107108
service.scheduleTitleGeneration("session-1", "hello world");
108109
executorService.shutdown();
109-
executorService.awaitTermination(5, TimeUnit.SECONDS);
110+
assertTrue(executorService.awaitTermination(5, TimeUnit.SECONDS), "the title task must actually finish");
110111

111112
verify(chatSessionService).renameSession(eq("session-1"), eq("Generated Title"));
112113
verify(sessionEventPublisher).publishTitleUpdated(eq(1), eq("session-1"), eq("Generated Title"));
113114
}
114115

115116
@Test
116117
void scheduleTitleGeneration_duplicateSessionId_skipsSecondCall() throws Exception {
117-
AtomicReference<ChatSession> sessionRef = new AtomicReference<>(
118-
ChatSession.builder().id("session-1").agentId(1).title("\u65b0\u4f1a\u8bdd").build());
119-
when(chatSessionService.findBySessionId("session-1")).thenAnswer(invocation -> sessionRef.get());
120-
121-
doAnswer(invocation -> {
122-
String newTitle = invocation.getArgument(1);
123-
ChatSession session = sessionRef.get();
124-
ChatSession newSession = ChatSession.builder()
125-
.title(newTitle)
126-
.id(session.getId())
127-
.agentId(session.getAgentId())
128-
.build();
129-
sessionRef.set(newSession);
130-
return null;
131-
}).when(chatSessionService).renameSession(anyString(), anyString());
118+
ChatSession session = ChatSession.builder().id("session-1").agentId(1).title("\u65b0\u4f1a\u8bdd").build();
119+
CountDownLatch firstTaskStarted = new CountDownLatch(1);
120+
CountDownLatch allowFirstTaskToFinish = new CountDownLatch(1);
121+
when(chatSessionService.findBySessionId("session-1")).thenAnswer(invocation -> {
122+
firstTaskStarted.countDown();
123+
if (!allowFirstTaskToFinish.await(5, TimeUnit.SECONDS)) {
124+
throw new IllegalStateException("timed out waiting to finish the first title task");
125+
}
126+
return session;
127+
});
132128

133129
Flux<ChatResponse> chatResponseFlux = Flux.empty();
134130
when(llmService.call(anyString(), anyString())).thenReturn(chatResponseFlux);
135131
when(llmService.toStringFlux(chatResponseFlux)).thenReturn(Flux.just("Title"));
136132

137133
service.scheduleTitleGeneration("session-1", "hello");
138-
service.scheduleTitleGeneration("session-1", "hello again");
134+
try {
135+
assertTrue(firstTaskStarted.await(5, TimeUnit.SECONDS), "the first title task must actually start");
136+
service.scheduleTitleGeneration("session-1", "hello again");
137+
}
138+
finally {
139+
allowFirstTaskToFinish.countDown();
140+
}
139141
executorService.shutdown();
140-
executorService.awaitTermination(5, TimeUnit.SECONDS);
142+
assertTrue(executorService.awaitTermination(5, TimeUnit.SECONDS), "the title task must actually finish");
141143

142-
verify(chatSessionService, atMostOnce()).renameSession(anyString(), anyString());
144+
verify(chatSessionService, times(1)).findBySessionId("session-1");
145+
verify(llmService, times(1)).call(anyString(), anyString());
146+
verify(chatSessionService, times(1)).renameSession("session-1", "Title");
147+
verify(sessionEventPublisher, times(1)).publishTitleUpdated(1, "session-1", "Title");
143148
}
144149

145150
}

‎data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/langfuse/NodeTracingLifecycleListenerTest.java‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -550,8 +550,16 @@ void exceptionWhileRecordingErrorIsSwallowedAndSpanEnded() {
550550

551551
@Test
552552
void discardThread_isSafeForUnknownAndNullThreadId() {
553+
Map<String, Object> state = new HashMap<>();
554+
listener.before(SQL_GENERATE_NODE, state, config, 1L);
555+
553556
assertDoesNotThrow(() -> listener.discardThread("never-seen"));
554557
assertDoesNotThrow(() -> listener.discardThread(null));
558+
559+
state.put(SQL_GENERATE_OUTPUT, "SELECT 1");
560+
listener.after(SQL_GENERATE_NODE, state, config, 2L);
561+
assertEquals(StatusCode.OK, onlyNodeSpan().getStatus().getStatusCode(),
562+
"discarding an unknown thread must not disturb another thread's active span");
555563
}
556564

557565
/**
@@ -630,10 +638,11 @@ void onErrorWithoutBefore_isIgnored() {
630638
}
631639

632640
/**
633-
* span 自身在结束阶段抛异常时,catch 分支必须兜住,且仍要 end()。
641+
* span 创建后、登记到活动栈之前初始化失败时,catch 分支必须兜住并 end(),否则该 span 永远不会被
642+
* after/onError/discardThread 找到。
634643
*/
635644
@Test
636-
void exceptionWhileFinishingSpanIsSwallowed() {
645+
void exceptionWhileInitializingSpanIsSwallowedAndSpanEnded() {
637646
Tracer flakyTracer = mock(Tracer.class);
638647
io.opentelemetry.api.trace.SpanBuilder builder = mock(io.opentelemetry.api.trace.SpanBuilder.class);
639648
Span flakySpan = mock(Span.class);
@@ -650,6 +659,8 @@ void exceptionWhileFinishingSpanIsSwallowed() {
650659
flakyListener.before(SQL_GENERATE_NODE, Map.of(), config, 1L);
651660
flakyListener.after(SQL_GENERATE_NODE, Map.of(SQL_GENERATE_OUTPUT, "x"), config, 2L);
652661
});
662+
663+
org.mockito.Mockito.verify(flakySpan).end();
653664
}
654665

655666
/**
@@ -717,6 +728,9 @@ void exceptionFromTracerIsSwallowed() {
717728

718729
assertDoesNotThrow(() -> brokenListener.before(INTENT_RECOGNITION_NODE, Map.of(INPUT_KEY, "q"), config, 1L));
719730
assertDoesNotThrow(() -> brokenListener.after(INTENT_RECOGNITION_NODE, Map.of(), config, 2L));
731+
732+
org.mockito.Mockito.verify(brokenTracer).spanBuilder(INTENT_RECOGNITION_NODE);
733+
assertTrue(nodeSpans().isEmpty(), "a failed tracer must not leave a phantom node span");
720734
}
721735

722736
// --- helpers ---

0 commit comments

Comments
 (0)