This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new e1577bd31 fix(ai): page the attach replay through the whole backlog 
(#6389)
e1577bd31 is described below

commit e1577bd3106603e1ad1fe724a0cbdd0b3ad61257
Author: 0 <[email protected]>
AuthorDate: Mon Oct 19 20:51:51 2026 +0800

    fix(ai): page the attach replay through the whole backlog (#6389)
    
    Verified against `rocketmq-studio` at 3d1641bad before merging: the defect 
is still present on the
    current trunk and the change merges cleanly.
    
    Maintainer follow-ups on top of the contributor commit:
    - docs(ai): promise the lowest limit rows from the seq window
    
    Each follow-up commit message records the review finding it addresses.
    
    Locally verified: targeted suites green on the integration branch; a full 
backend and frontend
    build plus test pass runs on the trunk once this batch lands.
---
 .../ops/ai/conversation/AiEventRepository.java     |  4 +-
 .../studio/ops/ai/conversation/AiRunService.java   | 49 +++++++++++++++++-----
 .../ops/ai/conversation/AiRunServiceTest.java      | 27 ++++++++++++
 3 files changed, 68 insertions(+), 12 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiEventRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiEventRepository.java
index c35d169c0..f6868e8e1 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiEventRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiEventRepository.java
@@ -33,7 +33,9 @@ public interface AiEventRepository {
     int maxSeq(Long conversationId);
 
     /**
-     * Events with seq strictly greater than afterSeq, capped at limit, 
ascending by seq.
+     * The lowest {@code limit} events whose seq is strictly greater than 
afterSeq, ascending by
+     * seq — a page of the backlog, not "whatever fits", so a caller that 
walks a cursor can rely
+     * on it never skipping a row it has not seen.
      *
      * <p>Deliberately does NOT use SQL ORDER BY. The payload column is 
MEDIUMTEXT, and any
      * sort MySQL cannot satisfy from an index materialises the whole value 
into
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
index b43d8e176..0d8787516 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
@@ -103,6 +103,13 @@ public class AiRunService {
     static final String REFUSED_INVALID_CODE = "ai.request.invalid";
     static final String REFUSED_CODE = "ai.run.refused";
 
+    /**
+     * Ceiling on the pages one attach may replay: {@value #MAX_REPLAY_PAGES} 
pages of
+     * {@value AiConversationService#DEFAULT_TIMELINE_LIMIT} rows. Far beyond 
any real backlog; exists
+     * so a misbehaving repository that keeps returning full pages cannot pin 
the request thread.
+     */
+    private static final int MAX_REPLAY_PAGES = 25;
+
     static final String OVERLOADED_MESSAGE = "AI chat capacity is temporarily 
exhausted";
     static final String OVERLOADED_HINT = "Wait for the active answer to 
finish, then retry.";
 
@@ -295,19 +302,32 @@ public class AiRunService {
         // rows the run persisted while the reconnecting client was catching 
up.
         boolean live = registry.attach(run.getId(), session);
         AgentEventProjector projector = new AgentEventProjector(run.getId());
+        // The replay pages through the whole backlog, not just the first 
slice: the query is capped at
+        // DEFAULT_TIMELINE_LIMIT rows, and a tool-heavy turn that kept 
writing while the client was
+        // away produces more than one page of it. Stopping after the first 
page would strand the rows
+        // in between — already persisted, so their live frames are gone, and 
beyond the client's own
+        // cursor, so it would never fetch them either.
+        int cursor = Math.max(0, afterSeq);
         boolean terminalReplayed = false;
-        List<RmqAiEvent> rows;
+        int replayedRows = 0;
         try {
-            rows = 
eventRepository.findByConversationIdAfterSeq(run.getConversationId(),
-                    Math.max(0, afterSeq), 
AiConversationService.DEFAULT_TIMELINE_LIMIT);
-            for (RmqAiEvent row : rows) {
-                session.noteWatermark(row.getSeq());
-                Optional<TimelineEvent> event = 
AiEventCodec.read(objectMapper, row);
-                if (event.isEmpty()) {
-                    continue;
+            for (int page = 0; page < MAX_REPLAY_PAGES; page++) {
+                List<RmqAiEvent> rows = 
eventRepository.findByConversationIdAfterSeq(run.getConversationId(),
+                        cursor, AiConversationService.DEFAULT_TIMELINE_LIMIT);
+                for (RmqAiEvent row : rows) {
+                    session.noteWatermark(row.getSeq());
+                    cursor = row.getSeq();
+                    Optional<TimelineEvent> event = 
AiEventCodec.read(objectMapper, row);
+                    if (event.isEmpty()) {
+                        continue;
+                    }
+                    terminalReplayed = terminalReplayed || event.get() 
instanceof TimelineEvent.RunStatus;
+                    projector.replay(event.get(), 
run.getDurationMs()).ifPresent(session::sendReplayed);
+                }
+                replayedRows += rows.size();
+                if (rows.size() < 
AiConversationService.DEFAULT_TIMELINE_LIMIT) {
+                    break;
                 }
-                terminalReplayed = terminalReplayed || event.get() instanceof 
TimelineEvent.RunStatus;
-                projector.replay(event.get(), 
run.getDurationMs()).ifPresent(session::sendReplayed);
             }
         } catch (RuntimeException exception) {
             // The session this method created never reaches a response now, 
so no transport
@@ -316,6 +336,13 @@ public class AiRunService {
             session.complete();
             throw exception;
         }
+        // A short page means the backlog is exhausted; running out of pages 
with every one of
+        // them full means the replay was truncated. Say so, because a reload 
that silently
+        // dropped the older events looks like a complete one.
+        if (replayedRows >= MAX_REPLAY_PAGES * 
AiConversationService.DEFAULT_TIMELINE_LIMIT) {
+            log.warn("attach replay of run {} hit the {} page ceiling after {} 
rows; older events are not replayed",
+                    run.getId(), MAX_REPLAY_PAGES, replayedRows);
+        }
         session.finishReplay();
         if (!live) {
             // Nothing is generating here any more, so no further frame will 
ever arrive. A run reaped by
@@ -329,7 +356,7 @@ public class AiRunService {
             session.complete();
         }
         log.debug("attached an observer to agent run {} after seq {} ({} 
replayed row(s), live={})",
-                run.getId(), afterSeq, rows.size(), live);
+                run.getId(), afterSeq, replayedRows, live);
         return session.emitter();
     }
 
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
index 00a1a38f7..1aaf16866 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
@@ -44,6 +44,7 @@ import java.time.Clock;
 import java.time.Duration;
 import java.time.Instant;
 import java.time.ZoneOffset;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -598,6 +599,32 @@ class AiRunServiceTest {
         assertThat(emitters.get(0).completed()).isTrue();
     }
 
+    @Test
+    void attachShouldReplayTheWholeBacklogBeyondOnePageTest() {
+        // A run that kept writing while the client was away can hold more 
than one page of backlog
+        // (a page is 200 rows and a tool-heavy turn writes several rows per 
call). The replay has to
+        // page through all of it: stopping after the first page would strand 
the rows in between,
+        // whose live frames were published before this observer existed and 
whose rows lie beyond
+        // the cursor the client already holds.
+        RmqAiRun finished = AiRunTestSupport.run(RUN_ID, CONVERSATION_ID, 1, 
RunStatus.COMPLETED);
+        when(runRepository.findById(RUN_ID)).thenReturn(Optional.of(finished));
+        List<RmqAiEvent> firstPage = new ArrayList<>();
+        for (int seq = 1; seq <= 200; seq++) {
+            firstPage.add(event(seq, "text", 
"{\"type\":\"text\",\"text\":\"row " + seq + "\"}"));
+        }
+        when(eventRepository.findByConversationIdAfterSeq(CONVERSATION_ID, 0, 
200)).thenReturn(firstPage);
+        when(eventRepository.findByConversationIdAfterSeq(CONVERSATION_ID, 
200, 200)).thenReturn(List.of(
+                event(201, "text", "{\"type\":\"text\",\"text\":\"the last 
row\"}"),
+                event(202, "run_status", 
"{\"type\":\"run_status\",\"status\":\"COMPLETED\"}")));
+
+        service.attach(RUN_ID, 0);
+
+        String text = emitters.get(0).eventText();
+        assertThat(text).contains("row 1").contains("the last row");
+        assertThat(text).contains("\"type\":\"run_finished\"");
+        assertThat(emitters.get(0).completed()).isTrue();
+    }
+
     @Test
     void 
attachShouldHonourTheCursorAndSynthesiseATerminalFrameForAReapedRunTest() {
         // A run reaped at startup or by the orphan sweep has no run_status 
row at all — there was no sink

Reply via email to