Skip to content

Commit a38e350

Browse files
OEvgenyCopilotCopilot
authored
perf: upsert fast path (#5796)
* perf: upsert fast path * Changelog * Fix tests * Update packages/core/src/reducers/activities/sort/upsert.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/core/src/reducers/activities/sort/upsert.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: defer Map cloning in upsert fast path until after collision check Agent-Logs-Url: https://github.com/microsoft/BotFramework-WebChat/sessions/30b9dd7c-b976-4ae3-9ea3-79247db38e40 Co-authored-by: OEvgeny <2841858+OEvgeny@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: OEvgeny <2841858+OEvgeny@users.noreply.github.com>
1 parent cf3f743 commit a38e350

File tree

5 files changed

+169
-13
lines changed

5 files changed

+169
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ Breaking changes in this release:
164164

165165
### Changed
166166

167+
- Added streaming fast path in activity upsert to skip recomputation for mid-stream revisions, in PR [#5796](https://github.com/microsoft/BotFramework-WebChat/pull/5796), by [@OEvgeny](https://github.com/OEvgeny)
167168
- Updated `useSuggestedActions` to return the activity the suggested actions originated from, in PR [#5255](https://github.com/microsoft/BotFramework-WebChat/issues/5255), by [@compulim](https://github.com/compulim)
168169
- Improved focus trap implementation by preserving focus state and removing sentinels, in PR [#5243](https://github.com/microsoft/BotFramework-WebChat/pull/5243), by [@OEvgeny](https://github.com/OEvgeny)
169170
- Reworked pre-chat activity layout to use author entity for improved consistency and flexibility, in PR [#5274](https://github.com/microsoft/BotFramework-WebChat/pull/5274), by [@OEvgeny](https://github.com/OEvgeny)

packages/base/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export { default as isForbiddenPropertyName } from './isForbiddenPropertyName';
44
export { default as iterateEquals } from './iterateEquals';
55
export { type OneOrMany } from './OneOrMany';
66
export { default as singleToArray } from './singleToArray';
7+
export { default as toSpliced } from './toSpliced';
78
export { default as warnOnce } from './warnOnce';
89
export { default as withResolvers, type PromiseWithResolvers } from './withResolvers';
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// @ts-expect-error: no types available
2+
import coreJSToSpliced from 'core-js-pure/features/array/to-spliced.js';
3+
4+
export default function toSpliced<T>(array: readonly T[], start: number, deleteCount: number, ...items: T[]): T[] {
5+
return coreJSToSpliced(array, start, deleteCount, ...items);
6+
}

packages/core/src/reducers/activities/sort/private/insertSorted.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
// @ts-ignore No @types/core-js-pure
2-
import { default as toSpliced_ } from 'core-js-pure/features/array/to-spliced.js';
3-
4-
// The Node.js version we are using for CI/CD does not support Array.prototype.toSpliced yet.
5-
function toSpliced<T>(array: readonly T[], start: number, deleteCount: number, ...items: T[]): T[] {
6-
return toSpliced_(array, start, deleteCount, ...items);
7-
}
1+
import { toSpliced } from '@msinternal/botframework-webchat-base/utils';
82

93
/**
104
* Inserts a single item into a sorted array.

packages/core/src/reducers/activities/sort/upsert.ts

Lines changed: 160 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import computeSortedActivities from './private/computeSortedActivities';
66
import getLogicalTimestamp from './private/getLogicalTimestamp';
77
import getPartGroupingMetadataMap from './private/getPartGroupingMetadataMap';
88
import insertSorted from './private/insertSorted';
9+
import { toSpliced } from '@msinternal/botframework-webchat-base/utils';
910
import { getLocalIdFromActivity } from './property/LocalId';
1011
import { queryPositionFromActivity, setPositionInActivity } from './property/Position';
1112
import {
@@ -41,6 +42,8 @@ import {
4142
// - Always copy timestamp, except when it's a livestream of 2...N-1 revision
4243
// - Part grouping timestamp is copied from upserting entry (either livestream session or activity)
4344

45+
const POSITION_INCREMENT = 1_000;
46+
4447
const INITIAL_STATE = Object.freeze({
4548
activityIdToLocalIdMap: Object.freeze(new Map()),
4649
activityMap: Object.freeze(new Map()),
@@ -58,16 +61,170 @@ const INITIAL_STATE = Object.freeze({
5861
// - Duplicate timestamps: activities without timestamp can't be sort deterministically with quick sort
5962

6063
function upsert(ponyfill: Pick<GlobalScopePonyfill, 'Date'>, state: State, activity: Activity): State {
64+
const activityLocalId = getLocalIdFromActivity(activity);
65+
const logicalTimestamp = getLogicalTimestamp(activity, ponyfill);
66+
const activityLivestreamingMetadata = getActivityLivestreamingMetadata(activity);
67+
68+
// #region Streaming fast path
69+
//
70+
// For revision 2..N-1 of an existing, non-finalized livestream session without HowTo grouping:
71+
// avoid the heavier full rebuild path, including sortedChatHistoryList recomputation,
72+
// computeSortedActivities, and full position resequencing. This is still not constant-time:
73+
// the fast path continues to clone Maps and update sortedActivities, but it avoids the
74+
// broader recomputation required for the general case.
75+
if (activityLivestreamingMetadata) {
76+
const sessionId = activityLivestreamingMetadata.sessionId as LivestreamSessionId;
77+
const existingSession = state.livestreamSessionMap.get(sessionId);
78+
const finalized = activityLivestreamingMetadata.type === 'final activity';
79+
80+
if (
81+
existingSession &&
82+
!existingSession.finalized &&
83+
!finalized &&
84+
!getPartGroupingMetadataMap(activity).has('HowTo')
85+
) {
86+
// Defer all Map cloning until after the position-collision check succeeds.
87+
// First build the next session entry (needed to determine insertIndex), then
88+
// locate the insertion point in sortedActivities, compute the new position,
89+
// and only clone Maps when we know the fast path will be taken.
90+
91+
// 1. Compute the next session entry (needed to find insertIndex).
92+
// Timestamp is NOT updated for rev 2..N-1 (only for first and final).
93+
const nextSessionEntry: LivestreamSessionMapEntry = {
94+
activities: Object.freeze(
95+
insertSorted<LivestreamSessionMapEntryActivityEntry>(
96+
existingSession.activities,
97+
Object.freeze({
98+
activityLocalId,
99+
logicalTimestamp,
100+
sequenceNumber: activityLivestreamingMetadata.sequenceNumber,
101+
type: 'activity'
102+
}),
103+
({ sequenceNumber: x }, { sequenceNumber: y }) =>
104+
typeof x === 'undefined' || typeof y === 'undefined'
105+
? // eslint-disable-next-line no-magic-numbers
106+
-1
107+
: x - y
108+
)
109+
),
110+
finalized: false,
111+
logicalTimestamp: existingSession.logicalTimestamp
112+
};
113+
114+
// 2. sortedActivities: find the insertion point before cloning anything.
115+
// The session's activities are sorted by sequence number via insertSorted.
116+
// Find where the new activity landed in that list and locate the correct
117+
// insertion point in sortedActivities relative to its session neighbors.
118+
const newIndexInSession = nextSessionEntry.activities.findIndex(
119+
entry => entry.activityLocalId === activityLocalId
120+
);
121+
122+
const successorInSession =
123+
newIndexInSession + 1 < nextSessionEntry.activities.length
124+
? nextSessionEntry.activities[newIndexInSession + 1]
125+
: undefined;
126+
127+
let insertIndex = state.sortedActivities.length;
128+
129+
if (successorInSession) {
130+
// Insert before the successor activity in sortedActivities.
131+
for (let i = 0; i < state.sortedActivities.length; i++) {
132+
// eslint-disable-next-line security/detect-object-injection
133+
if (getLocalIdFromActivity(state.sortedActivities[i]!) === successorInSession.activityLocalId) {
134+
insertIndex = i;
135+
break;
136+
}
137+
}
138+
} else {
139+
// New activity is last in the session; insert after the previous last activity.
140+
// eslint-disable-next-line no-magic-numbers
141+
const prevLastSessionActivity = existingSession.activities.at(-1);
142+
143+
if (prevLastSessionActivity) {
144+
for (let i = state.sortedActivities.length - 1; i >= 0; i--) {
145+
// eslint-disable-next-line security/detect-object-injection
146+
if (getLocalIdFromActivity(state.sortedActivities[i]!) === prevLastSessionActivity.activityLocalId) {
147+
insertIndex = i + 1;
148+
break;
149+
}
150+
}
151+
}
152+
}
153+
154+
// 3. Position: assign the new activity a position based on its neighbors.
155+
const prevPosition =
156+
insertIndex > 0 ? (queryPositionFromActivity(state.sortedActivities[insertIndex - 1]!) ?? 0) : 0;
157+
158+
const nextSiblingPosition =
159+
insertIndex < state.sortedActivities.length
160+
? queryPositionFromActivity(state.sortedActivities[+insertIndex]!)
161+
: undefined;
162+
163+
let newPosition = prevPosition + POSITION_INCREMENT;
164+
165+
// Squeeze if the default increment would collide with the next sibling.
166+
if (typeof nextSiblingPosition !== 'undefined' && newPosition >= nextSiblingPosition) {
167+
newPosition = prevPosition + 1;
168+
}
169+
170+
// If position is valid (no collision), clone Maps and return fast path result.
171+
// Otherwise fall through to slow path for full re-sequencing.
172+
if (typeof nextSiblingPosition === 'undefined' || newPosition < nextSiblingPosition) {
173+
const positionedActivity = setPositionInActivity(activity, newPosition);
174+
175+
// 4. activityIdToLocalIdMap: reuse if no activity.id, copy + add otherwise.
176+
let nextActivityIdToLocalIdMap = state.activityIdToLocalIdMap;
177+
178+
if (typeof activity.id !== 'undefined') {
179+
nextActivityIdToLocalIdMap = new Map(state.activityIdToLocalIdMap);
180+
nextActivityIdToLocalIdMap.set(activity.id, activityLocalId);
181+
}
182+
183+
// 5. activityMap: +1 entry with the positioned activity.
184+
const nextActivityMap = new Map(state.activityMap);
185+
186+
nextActivityMap.set(
187+
activityLocalId,
188+
Object.freeze({ activity: positionedActivity, activityLocalId, logicalTimestamp, type: 'activity' as const })
189+
);
190+
191+
// 6. clientActivityIdToLocalIdMap: reuse if no clientActivityID, copy + add otherwise.
192+
const { clientActivityID } = activity.channelData;
193+
let nextClientActivityIdToLocalIdMap = state.clientActivityIdToLocalIdMap;
194+
195+
if (typeof clientActivityID !== 'undefined') {
196+
nextClientActivityIdToLocalIdMap = new Map(state.clientActivityIdToLocalIdMap);
197+
nextClientActivityIdToLocalIdMap.set(clientActivityID, activityLocalId);
198+
}
199+
200+
// 7. livestreamSessionMap: record the updated session.
201+
const nextLivestreamSessionMap = new Map(state.livestreamSessionMap);
202+
203+
nextLivestreamSessionMap.set(sessionId, Object.freeze(nextSessionEntry));
204+
205+
return Object.freeze({
206+
activityIdToLocalIdMap: Object.freeze(nextActivityIdToLocalIdMap),
207+
activityMap: Object.freeze(nextActivityMap),
208+
clientActivityIdToLocalIdMap: Object.freeze(nextClientActivityIdToLocalIdMap),
209+
howToGroupingMap: state.howToGroupingMap,
210+
livestreamSessionMap: Object.freeze(nextLivestreamSessionMap),
211+
sortedActivities: Object.freeze(toSpliced(state.sortedActivities, insertIndex, 0, positionedActivity)),
212+
sortedChatHistoryList: state.sortedChatHistoryList
213+
} satisfies State);
214+
}
215+
}
216+
}
217+
218+
// #endregion
219+
220+
// Slow path: full recalculation for non-streaming, first/final revisions, reorders, or HowTo grouping.
61221
const nextActivityIdToLocalIdMap = new Map(state.activityIdToLocalIdMap);
62222
const nextActivityMap = new Map(state.activityMap);
63223
const nextClientActivityIdToLocalIdMap = new Map(state.clientActivityIdToLocalIdMap);
64224
const nextLivestreamSessionMap = new Map(state.livestreamSessionMap);
65225
const nextHowToGroupingMap = new Map(state.howToGroupingMap);
66226
let nextSortedChatHistoryList = Array.from(state.sortedChatHistoryList);
67227

68-
const activityLocalId = getLocalIdFromActivity(activity);
69-
const logicalTimestamp = getLogicalTimestamp(activity, ponyfill);
70-
71228
if (typeof activity.id !== 'undefined') {
72229
nextActivityIdToLocalIdMap.set(activity.id, activityLocalId);
73230
}
@@ -96,8 +253,6 @@ function upsert(ponyfill: Pick<GlobalScopePonyfill, 'Date'>, state: State, activ
96253

97254
// #region Livestreaming
98255

99-
const activityLivestreamingMetadata = getActivityLivestreamingMetadata(activity);
100-
101256
if (activityLivestreamingMetadata) {
102257
const sessionId = activityLivestreamingMetadata.sessionId as LivestreamSessionId;
103258

@@ -279,7 +434,6 @@ function upsert(ponyfill: Pick<GlobalScopePonyfill, 'Date'>, state: State, activ
279434
// #region Sequence sorted activities
280435

281436
let lastPosition = 0;
282-
const POSITION_INCREMENT = 1_000;
283437

284438
for (
285439
let index = 0, { length: nextSortedActivitiesLength } = nextSortedActivities;

0 commit comments

Comments
 (0)