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 b739b6a7b fix(ai): stamp gmt_modified when forgetting the runtime
session (#7050)
b739b6a7b is described below
commit b739b6a7bf58643932dd16cc0d0fcdd070a7568f
Author: wizcraft_kris <[email protected]>
AuthorDate: Mon Oct 26 16:46:05 2026 +0800
fix(ai): stamp gmt_modified when forgetting the runtime session (#7050)
* fix(ai): stamp gmt_modified when forgetting the runtime session
* test(ai): pin the executor clock so the forgotten-session stamp is
asserted
Both resume-recovery cases verified clearRuntimeSessionId with
any(LocalDateTime.class), which also passes a stamp read from the database
session's timezone — the exact skew the caller-supplied clock exists to
avoid.
Give the shared executor a fixed clock and assert the stamp it must carry:
replacing now(clock) with now() in AiRunExecutor now turns both cases red.
---
.../ops/ai/conversation/AiConversationRepository.java | 6 ++++--
.../studio/ops/ai/conversation/AiRunExecutor.java | 2 +-
.../MybatisPlusAiConversationRepository.java | 8 ++++++--
.../studio/ops/ai/conversation/AiRunExecutorTest.java | 17 ++++++++++++++---
.../ops/ai/conversation/AiTimelineRepositoryTest.java | 18 ++++++++++++++++--
5 files changed, 41 insertions(+), 10 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationRepository.java
index 98580081f..961971e8b 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationRepository.java
@@ -47,9 +47,11 @@ public interface AiConversationRepository {
/**
* Forgets the remembered {@code --resume} session id. A method of its own
because {@link #update}
- * writes non-null fields only, so it cannot express "forget this value".
+ * writes non-null fields only, so it cannot express "forget this value".
The caller supplies
+ * {@code now} so the write can stamp {@code gmt_modified} from its own
clock — the column is
+ * {@code ON UPDATE CURRENT_TIMESTAMP}, and the list in {@link #findPage}
orders by it.
*/
- void clearRuntimeSessionId(Long id);
+ void clearRuntimeSessionId(Long id, LocalDateTime now);
int deleteById(Long id);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutor.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutor.java
index f5e85e8e6..ac2206b3c 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutor.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutor.java
@@ -616,7 +616,7 @@ public class AiRunExecutor {
return;
}
try {
- conversationRepository.clearRuntimeSessionId(conversation.getId());
+ conversationRepository.clearRuntimeSessionId(conversation.getId(),
LocalDateTime.now(clock));
conversation.setRuntimeSessionId(null);
} catch (RuntimeException exception) {
log.warn("could not forget the runtime session id of conversation
{}: {}",
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/MybatisPlusAiConversationRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/MybatisPlusAiConversationRepository.java
index 3448dcf46..359849b94 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/MybatisPlusAiConversationRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/MybatisPlusAiConversationRepository.java
@@ -81,14 +81,18 @@ public class MybatisPlusAiConversationRepository implements
AiConversationReposi
}
@Override
- public void clearRuntimeSessionId(Long id) {
+ public void clearRuntimeSessionId(Long id, LocalDateTime now) {
if (id == null) {
return;
}
// updateById skips null fields, so "forget this value" has to name
the column itself.
+ // gmt_modified is named too: it is ON UPDATE CURRENT_TIMESTAMP, so a
SET clause without it
+ // lets the database stamp it in the session's timezone — the wrong
clock for the
+ // gmt_modified ordering findPage promises. The caller owns the clock,
as with the alert mappers.
conversationMapper.update(null, new UpdateWrapper<RmqAiConversation>()
.eq("id", id)
- .set("runtime_session_id", null));
+ .set("runtime_session_id", null)
+ .set("gmt_modified", now));
}
@Override
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutorTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutorTest.java
index f8e33c0bc..56004a90f 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutorTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutorTest.java
@@ -42,6 +42,9 @@ import org.mockito.quality.Strictness;
import java.time.Clock;
import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -86,6 +89,14 @@ class AiRunExecutorTest {
private static final long CONVERSATION_ID = 7L;
private static final long RUN_ID = 11L;
private static final String ENGINE = LlmConfigVO.ENGINE_CLAUDE_CODE;
+ /**
+ * The executor's clock, pinned so a write can be asserted against the
exact stamp it should
+ * carry: {@code any(LocalDateTime.class)} would also pass a stamp taken
from the database
+ * session's timezone, which is the bug this write exists to avoid.
+ */
+ private static final Instant FIXED_INSTANT =
Instant.parse("2026-09-18T10:00:00Z");
+ private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT,
ZoneOffset.UTC);
+ private static final LocalDateTime FIXED_NOW =
LocalDateTime.ofInstant(FIXED_INSTANT, ZoneOffset.UTC);
@Mock
private AiRunRepository runRepository;
@@ -155,7 +166,7 @@ class AiRunExecutorTest {
executor = new AiRunExecutor(new
AgentProviderRegistry(List.of(provider)), llmClient, promptEnhancer,
registry, runRepository, conversationRepository,
eventRepository, properties(), objectMapper,
AiRunTestSupport.directExecutor(),
AiRunTestSupport.recordingInto(emitters), scheduler,
- Clock.systemUTC());
+ FIXED_CLOCK);
conversation = AiRunTestSupport.conversation(CONVERSATION_ID,
"tester");
run = AiRunTestSupport.run(RUN_ID, CONVERSATION_ID, 1,
RunStatus.QUEUED);
}
@@ -432,7 +443,7 @@ class AiRunExecutorTest {
assertThat(provider.calls).isEqualTo(2);
assertThat(resumedSessions).containsExactly("gone-session", null);
assertThat(provider.lastPrompt).isEqualTo("hello");
- verify(conversationRepository).clearRuntimeSessionId(CONVERSATION_ID);
+
verify(conversationRepository).clearRuntimeSessionId(eq(CONVERSATION_ID),
eq(FIXED_NOW));
// The retry's session replaces the dead one, and the user is told the
context is gone.
assertThat(runRow().getStatus()).isEqualTo(RunStatus.COMPLETED.name());
assertThat(runRow().getRuntimeSessionId()).isEqualTo("new-session");
@@ -462,7 +473,7 @@ class AiRunExecutorTest {
// A retry that fails must not put the dead id back: every turn after
it would otherwise resume a
// session that is still gone. Null is the honest state, and the next
turn starts fresh.
assertThat(provider.calls).isEqualTo(2);
- verify(conversationRepository).clearRuntimeSessionId(CONVERSATION_ID);
+
verify(conversationRepository).clearRuntimeSessionId(eq(CONVERSATION_ID),
eq(FIXED_NOW));
assertThat(runRow().getStatus()).isEqualTo(RunStatus.FAILED.name());
assertThat(runRow().getRuntimeSessionId()).isNull();
assertThat(conversation.getRuntimeSessionId()).isNull();
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiTimelineRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiTimelineRepositoryTest.java
index b305858e4..67362d056 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiTimelineRepositoryTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiTimelineRepositoryTest.java
@@ -485,7 +485,7 @@ class AiTimelineRepositoryTest {
*/
@Test
void forgettingTheRuntimeSessionShouldNullTheColumnByNameTest() {
- conversations.clearRuntimeSessionId(CONVERSATION_ID);
+ conversations.clearRuntimeSessionId(CONVERSATION_ID,
LocalDateTime.parse("2026-09-18T10:00:00"));
UpdateWrapper<RmqAiConversation> update = capturedConversationUpdate();
assertThat(update.getSqlSet()).contains("runtime_session_id");
@@ -495,9 +495,23 @@ class AiTimelineRepositoryTest {
assertThat(update.getParamNameValuePairs().values()).contains(CONVERSATION_ID);
}
+ @Test
+ void
forgettingTheRuntimeSessionShouldStampGmtModifiedFromTheCallerClockTest() {
+ LocalDateTime now = LocalDateTime.parse("2026-09-18T10:00:00");
+
+ conversations.clearRuntimeSessionId(CONVERSATION_ID, now);
+
+ UpdateWrapper<RmqAiConversation> update = capturedConversationUpdate();
+ // gmt_modified is ON UPDATE CURRENT_TIMESTAMP: leaving it out of the
SET clause does not
+ // leave it alone, it hands the write to the database session's
timezone — and the list
+ // this table feeds orders by the column as if it carried one clock.
+ assertThat(update.getSqlSet()).contains("gmt_modified");
+ assertThat(update.getParamNameValuePairs()).containsValue(now);
+ }
+
@Test
void forgettingTheRuntimeSessionShouldNeedAnIdTest() {
- conversations.clearRuntimeSessionId(null);
+ conversations.clearRuntimeSessionId(null,
LocalDateTime.parse("2026-09-18T10:00:00"));
verify(conversationMapper, never()).update(any(), any());
}