Skip to content

Commit b3ab512

Browse files
committed
fix(messagehistory): prevent timestamp collision in ChatMessage ID
Port timestamp collision fix from Python redis-vl (caa9621): - Add 8-character UUID suffix to auto-generated entry_id - Change ID format from 'session:timestamp' to 'session:timestamp:uuid' - Add comprehensive unit tests for unique ID generation When multiple messages are added rapidly (e.g., via addMessages()), they can receive the same timestamp, resulting in identical IDs. Since Redis uses these IDs as keys, newer messages would overwrite older ones. The UUID suffix ensures uniqueness even with identical timestamps. Fixes message loss issue when storing multiple messages simultaneously.
1 parent f4931e2 commit b3ab512

File tree

2 files changed

+176
-1
lines changed

2 files changed

+176
-1
lines changed

core/src/main/java/com/redis/vl/extensions/messagehistory/ChatMessage.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.redis.vl.utils.Utils;
66
import java.util.HashMap;
77
import java.util.Map;
8+
import java.util.UUID;
89
import lombok.AllArgsConstructor;
910
import lombok.Builder;
1011
import lombok.Data;
@@ -51,8 +52,11 @@ public Map<String, Object> toDict() {
5152
}
5253

5354
// Generate entry_id if not set
55+
// Add UUID suffix to prevent timestamp collisions when creating
56+
// multiple messages rapidly (e.g., in addMessages or store)
5457
if (entryId == null) {
55-
entryId = sessionTag + ":" + timestamp;
58+
String uniqueSuffix = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
59+
entryId = sessionTag + ":" + timestamp + ":" + uniqueSuffix;
5660
}
5761

5862
Map<String, Object> data = new HashMap<>();
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package com.redis.vl.extensions.messagehistory;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.redis.vl.utils.Utils;
6+
import java.util.ArrayList;
7+
import java.util.HashSet;
8+
import java.util.List;
9+
import java.util.Map;
10+
import java.util.Set;
11+
import java.util.UUID;
12+
import org.junit.jupiter.api.DisplayName;
13+
import org.junit.jupiter.api.Test;
14+
15+
/** Unit tests for ChatMessage */
16+
@DisplayName("ChatMessage Tests")
17+
class ChatMessageTest {
18+
19+
@Test
20+
@DisplayName("Should create ChatMessage with all fields")
21+
void shouldCreateChatMessageWithAllFields() {
22+
ChatMessage message =
23+
ChatMessage.builder()
24+
.role("user")
25+
.content("Hello, world!")
26+
.sessionTag("session123")
27+
.timestamp(1234567890.123)
28+
.toolCallId("tool456")
29+
.build();
30+
31+
assertThat(message.getRole()).isEqualTo("user");
32+
assertThat(message.getContent()).isEqualTo("Hello, world!");
33+
assertThat(message.getSessionTag()).isEqualTo("session123");
34+
assertThat(message.getTimestamp()).isEqualTo(1234567890.123);
35+
assertThat(message.getToolCallId()).isEqualTo("tool456");
36+
}
37+
38+
@Test
39+
@DisplayName("Should generate entry_id with UUID suffix when not set")
40+
void shouldGenerateEntryIdWithUuidSuffix() {
41+
String sessionTag = "session123";
42+
Double timestamp = Utils.currentTimestamp();
43+
44+
ChatMessage message =
45+
ChatMessage.builder()
46+
.role("user")
47+
.content("Test message")
48+
.sessionTag(sessionTag)
49+
.timestamp(timestamp)
50+
.build();
51+
52+
Map<String, Object> dict = message.toDict();
53+
String entryId = (String) dict.get("entry_id");
54+
55+
// Verify ID format is session:timestamp:uuid
56+
assertThat(entryId).startsWith(sessionTag + ":" + timestamp + ":");
57+
58+
// Verify the UUID suffix is 8 hex characters
59+
String[] parts = entryId.split(":");
60+
assertThat(parts).hasSize(3);
61+
assertThat(parts[2]).hasSize(8);
62+
assertThat(parts[2]).matches("[0-9a-f]{8}");
63+
}
64+
65+
@Test
66+
@DisplayName("Should generate unique IDs for rapidly created messages with same timestamp")
67+
void shouldGenerateUniqueIdsForRapidlyCreatedMessages() {
68+
String sessionTag = UUID.randomUUID().toString();
69+
Double timestamp = Utils.currentTimestamp();
70+
71+
// Create multiple messages with the same session and timestamp
72+
List<ChatMessage> messages = new ArrayList<>();
73+
for (int i = 0; i < 10; i++) {
74+
ChatMessage msg =
75+
ChatMessage.builder()
76+
.role("user")
77+
.content("Message " + i)
78+
.sessionTag(sessionTag)
79+
.timestamp(timestamp)
80+
.build();
81+
messages.add(msg);
82+
}
83+
84+
// All IDs should be unique
85+
Set<String> ids = new HashSet<>();
86+
for (ChatMessage msg : messages) {
87+
Map<String, Object> dict = msg.toDict();
88+
String entryId = (String) dict.get("entry_id");
89+
ids.add(entryId);
90+
}
91+
92+
assertThat(ids).as("All message IDs should be unique").hasSize(messages.size());
93+
94+
// All IDs should start with the same session:timestamp prefix
95+
String expectedPrefix = sessionTag + ":" + timestamp + ":";
96+
for (ChatMessage msg : messages) {
97+
Map<String, Object> dict = msg.toDict();
98+
String entryId = (String) dict.get("entry_id");
99+
assertThat(entryId).startsWith(expectedPrefix);
100+
}
101+
}
102+
103+
@Test
104+
@DisplayName("Should preserve explicit entry_id when set")
105+
void shouldPreserveExplicitEntryId() {
106+
String explicitId = "my-custom-id-123";
107+
108+
ChatMessage message =
109+
ChatMessage.builder()
110+
.role("user")
111+
.content("Test message")
112+
.sessionTag("session123")
113+
.entryId(explicitId)
114+
.build();
115+
116+
Map<String, Object> dict = message.toDict();
117+
String entryId = (String) dict.get("entry_id");
118+
119+
// Explicit ID should be preserved
120+
assertThat(entryId).isEqualTo(explicitId);
121+
}
122+
123+
@Test
124+
@DisplayName("Should generate timestamp when not set")
125+
void shouldGenerateTimestampWhenNotSet() {
126+
ChatMessage message =
127+
ChatMessage.builder().role("user").content("Test message").sessionTag("session123").build();
128+
129+
Map<String, Object> dict = message.toDict();
130+
Double timestamp = (Double) dict.get("timestamp");
131+
132+
assertThat(timestamp).isNotNull();
133+
assertThat(timestamp).isPositive();
134+
}
135+
136+
@Test
137+
@DisplayName("Should convert to and from dict correctly")
138+
void shouldConvertToAndFromDict() {
139+
ChatMessage original =
140+
ChatMessage.builder()
141+
.role("assistant")
142+
.content("Hello!")
143+
.sessionTag("session456")
144+
.timestamp(1234567890.0)
145+
.entryId("session456:1234567890.0:abc12345")
146+
.toolCallId("tool789")
147+
.build();
148+
149+
Map<String, Object> dict = original.toDict();
150+
ChatMessage restored = ChatMessage.fromDict(dict);
151+
152+
assertThat(restored.getRole()).isEqualTo(original.getRole());
153+
assertThat(restored.getContent()).isEqualTo(original.getContent());
154+
assertThat(restored.getSessionTag()).isEqualTo(original.getSessionTag());
155+
assertThat(restored.getTimestamp()).isEqualTo(original.getTimestamp());
156+
assertThat(restored.getEntryId()).isEqualTo(original.getEntryId());
157+
assertThat(restored.getToolCallId()).isEqualTo(original.getToolCallId());
158+
}
159+
160+
@Test
161+
@DisplayName("Should handle message without tool_call_id")
162+
void shouldHandleMessageWithoutToolCallId() {
163+
ChatMessage message =
164+
ChatMessage.builder().role("user").content("Test message").sessionTag("session123").build();
165+
166+
Map<String, Object> dict = message.toDict();
167+
168+
// tool_call_id should not be in dict when null
169+
assertThat(dict).doesNotContainKey("tool_call_id");
170+
}
171+
}

0 commit comments

Comments
 (0)