perf(chat): optimize O(N^2) sync merge loop with O(1) hash map lookups (#798)
Replaces the `indexOfFirst` linear scan over the unmerged incoming messages with a `HashMap` mapping message IDs to their index. This reduces the time complexity of the server message reconciliation loop from O(N^2) to O(N). Additionally, `removeAt()` calls on the unmerged list (which carry an O(N) penalty due to array shifting) were replaced by setting matched indices in a `MutableList<ChatMessage?>` to `null`.
This commit is contained in:
2 files changed
+22
-10
No files matched your search
@@ -2039,15 +2039,24 @@ class ChatViewModel(
|
|||||||
// → dropped. Use sameLogicalMessage (canonical
|
// → dropped. Use sameLogicalMessage (canonical
|
||||||
// result-key match) and always preserve any WS
|
// result-key match) and always preserve any WS
|
||||||
// message that has no REST counterpart.
|
// message that has no REST counterpart.
|
||||||
val unmatchedIncoming = incoming.toMutableList()
|
val unmatchedIncoming: MutableList<ChatMessage?> = incoming.toMutableList()
|
||||||
|
val incomingById = HashMap<String, Int>(unmatchedIncoming.size)
|
||||||
|
for (i in unmatchedIncoming.indices) {
|
||||||
|
val id = unmatchedIncoming[i]?.id
|
||||||
|
if (id != null && !incomingById.containsKey(id)) {
|
||||||
|
incomingById[id] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val mergedList = mutableListOf<ChatMessage>()
|
val mergedList = mutableListOf<ChatMessage>()
|
||||||
|
|
||||||
for (existing in current.messages) {
|
for (existing in current.messages) {
|
||||||
val existingServerIndex = serverMessageIndex(existing.id, sessionId)
|
val existingServerIndex = serverMessageIndex(existing.id, sessionId)
|
||||||
if (existingServerIndex != null) {
|
if (existingServerIndex != null) {
|
||||||
val matchIdx = unmatchedIncoming.indexOfFirst { it.id == existing.id }
|
val matchIdx = incomingById[existing.id]
|
||||||
if (matchIdx >= 0) {
|
if (matchIdx != null && unmatchedIncoming[matchIdx] != null) {
|
||||||
mergedList.add(unmatchedIncoming.removeAt(matchIdx))
|
mergedList.add(unmatchedIncoming[matchIdx]!!)
|
||||||
|
unmatchedIncoming[matchIdx] = null
|
||||||
} else {
|
} else {
|
||||||
mergedList.add(existing)
|
mergedList.add(existing)
|
||||||
}
|
}
|
||||||
@@ -2057,14 +2066,13 @@ class ChatViewModel(
|
|||||||
// toolName/content equality.
|
// toolName/content equality.
|
||||||
val matchIdx =
|
val matchIdx =
|
||||||
unmatchedIncoming.indexOfFirst { inc ->
|
unmatchedIncoming.indexOfFirst { inc ->
|
||||||
sameLogicalMessage(inc, existing)
|
inc != null && sameLogicalMessage(inc, existing)
|
||||||
}
|
}
|
||||||
if (matchIdx >= 0) {
|
if (matchIdx >= 0) {
|
||||||
// Prefer the WS copy (richer payload,
|
// Prefer the WS copy (richer payload,
|
||||||
// real tool name) when available.
|
// real tool name) when available.
|
||||||
val inc = unmatchedIncoming[matchIdx]
|
|
||||||
mergedList.add(existing)
|
mergedList.add(existing)
|
||||||
unmatchedIncoming.removeAt(matchIdx)
|
unmatchedIncoming[matchIdx] = null
|
||||||
} else {
|
} else {
|
||||||
// No REST counterpart (server hasn't
|
// No REST counterpart (server hasn't
|
||||||
// persisted yet) — KEEP the WS message.
|
// persisted yet) — KEEP the WS message.
|
||||||
@@ -2072,7 +2080,12 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mergedList.addAll(unmatchedIncoming)
|
|
||||||
|
for (inc in unmatchedIncoming) {
|
||||||
|
if (inc != null) {
|
||||||
|
mergedList.add(inc)
|
||||||
|
}
|
||||||
|
}
|
||||||
val merged = mergedList.distinctBy { it.id }
|
val merged = mergedList.distinctBy { it.id }
|
||||||
if (sameMessages(current.messages, merged)) current else current.copy(messages = merged)
|
if (sameMessages(current.messages, merged)) current else current.copy(messages = merged)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class ChatViewModelTest {
|
|||||||
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
|
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
|
|
||||||
every { AuthManager.getToken() } returns "test-token"
|
every { AuthManager.getToken() } returns "test-token"
|
||||||
|
every { AuthManager.getBaseUrl() } returns "http://test.local/"
|
||||||
every { AuthManager.getSelectedProfileId() } returns null
|
every { AuthManager.getSelectedProfileId() } returns null
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||||
@@ -1187,7 +1188,6 @@ class ChatViewModelTest {
|
|||||||
// unless another test class happened to init it earlier in the JVM
|
// unless another test class happened to init it earlier in the JVM
|
||||||
// (the order-dependent flakiness). Stub it for determinism — same
|
// (the order-dependent flakiness). Stub it for determinism — same
|
||||||
// pattern as E2eIntegrationTest / ApiClientTest.
|
// pattern as E2eIntegrationTest / ApiClientTest.
|
||||||
every { AuthManager.getBaseUrl() } returns "http://test.local/"
|
|
||||||
if (success) {
|
if (success) {
|
||||||
coEvery {
|
coEvery {
|
||||||
mockApi.getSessions(any(), any(), any())
|
mockApi.getSessions(any(), any(), any())
|
||||||
@@ -1518,7 +1518,6 @@ class ChatViewModelTest {
|
|||||||
// on the REST-success path (mockkObject spy fall-through → real
|
// on the REST-success path (mockkObject spy fall-through → real
|
||||||
// uninitialized AuthManager throws). Stub it — same pattern as
|
// uninitialized AuthManager throws). Stub it — same pattern as
|
||||||
// stubSession456Rests / E2eIntegrationTest.
|
// stubSession456Rests / E2eIntegrationTest.
|
||||||
every { AuthManager.getBaseUrl() } returns "http://test.local/"
|
|
||||||
|
|
||||||
// Stub the transcript fetch explicitly (relaxed mocks return null
|
// Stub the transcript fetch explicitly (relaxed mocks return null
|
||||||
// and muddy the retry path) and capture which session id it is
|
// and muddy the retry path) and capture which session id it is
|
||||||
|
|||||||
Reference in new issue
Block a user