Skip to content

Commit 58411ff

Browse files
Assem-UberCopilot
andauthored
feat: Hook for history grouper (#1089)
* hook for history grouper * Update src/views/workflow-history/hooks/__tests__/use-workflow-history-grouper.test.tsx Co-authored-by: Copilot <[email protected]> * reuse batch size as const --------- Co-authored-by: Copilot <[email protected]>
1 parent 2f464b6 commit 58411ff

File tree

3 files changed

+358
-0
lines changed

3 files changed

+358
-0
lines changed
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { renderHook, act } from '@testing-library/react';
2+
3+
import type { HistoryEvent } from '@/__generated__/proto-ts/uber/cadence/api/v1/HistoryEvent';
4+
5+
import {
6+
mockActivityEventGroup,
7+
mockDecisionEventGroup,
8+
} from '../../__fixtures__/workflow-history-event-groups';
9+
import {
10+
pendingActivityTaskStartEvent,
11+
pendingDecisionTaskStartEvent,
12+
} from '../../__fixtures__/workflow-history-pending-events';
13+
import HistoryEventsGrouper from '../../helpers/workflow-history-grouper';
14+
import type {
15+
GroupingProcessState,
16+
ProcessEventsParams,
17+
} from '../../helpers/workflow-history-grouper.types';
18+
import useWorkflowHistoryGrouper from '../use-workflow-history-grouper';
19+
20+
jest.mock('../../helpers/workflow-history-grouper');
21+
22+
jest.mock('../use-workflow-history-grouper.constants', () => ({
23+
BATCH_SIZE: 100,
24+
}));
25+
26+
describe(useWorkflowHistoryGrouper.name, () => {
27+
afterEach(() => {
28+
jest.clearAllMocks();
29+
});
30+
31+
it('should create HistoryEventsGrouper with default batchSize', () => {
32+
setup();
33+
34+
expect(HistoryEventsGrouper).toHaveBeenCalledWith({
35+
batchSize: 100, // called with the mocked BATCH_SIZE
36+
});
37+
});
38+
39+
it('should initialize with state from grouper.getState()', () => {
40+
const {
41+
result: { current },
42+
mockGrouperInstance,
43+
} = setup({
44+
initialState: {
45+
groups: {
46+
group1: mockActivityEventGroup,
47+
},
48+
processedEventsCount: 10,
49+
},
50+
});
51+
52+
expect(mockGrouperInstance.getState).toHaveBeenCalled();
53+
expect(current.eventGroups).toEqual({
54+
group1: mockActivityEventGroup,
55+
});
56+
expect(current.groupingState).toMatchObject({
57+
groups: {
58+
group1: mockActivityEventGroup,
59+
},
60+
processedEventsCount: 10,
61+
});
62+
});
63+
64+
it('should subscribe to grouper onChange', () => {
65+
const { mockGrouperInstance } = setup();
66+
67+
expect(mockGrouperInstance.onChange).toHaveBeenCalledWith(
68+
expect.any(Function)
69+
);
70+
});
71+
72+
it('should update groupingState when onChange callback is triggered', () => {
73+
const { result, getMockOnChangeCallback } = setup();
74+
75+
const newState = createMockState({
76+
groups: {
77+
group1: mockDecisionEventGroup,
78+
},
79+
processedEventsCount: 5,
80+
status: 'processing',
81+
});
82+
83+
act(() => {
84+
const mockOnChangeCallback = getMockOnChangeCallback();
85+
mockOnChangeCallback(newState);
86+
});
87+
88+
expect(result.current.groupingState).toEqual(newState);
89+
expect(result.current.eventGroups).toEqual(newState.groups);
90+
expect(result.current.isProcessing).toBe(true);
91+
});
92+
93+
it('should set isProcessing to false when status is idle', () => {
94+
const { result, getMockOnChangeCallback } = setup();
95+
96+
const idleState = createMockState({
97+
status: 'idle',
98+
});
99+
100+
act(() => {
101+
const mockOnChangeCallback = getMockOnChangeCallback();
102+
mockOnChangeCallback(idleState);
103+
});
104+
105+
expect(result.current.isProcessing).toBe(false);
106+
});
107+
108+
it('should set isProcessing to true when status is processing', () => {
109+
const { result, getMockOnChangeCallback } = setup();
110+
111+
const processingState = createMockState({
112+
status: 'processing',
113+
});
114+
115+
act(() => {
116+
const mockOnChangeCallback = getMockOnChangeCallback();
117+
mockOnChangeCallback(processingState);
118+
});
119+
120+
expect(result.current.isProcessing).toBe(true);
121+
});
122+
123+
it('should call grouper.updateEvents with provided events', () => {
124+
const {
125+
result: { current },
126+
mockGrouperInstance,
127+
} = setup();
128+
129+
const mockEvents: HistoryEvent[] = [
130+
{ eventId: '1', eventTime: null } as HistoryEvent,
131+
{ eventId: '2', eventTime: null } as HistoryEvent,
132+
];
133+
134+
act(() => {
135+
current.updateEvents(mockEvents);
136+
});
137+
138+
expect(mockGrouperInstance.updateEvents).toHaveBeenCalledWith(mockEvents);
139+
});
140+
141+
it('should call grouper.updatePendingEvents with provided params', async () => {
142+
const {
143+
result: { current },
144+
mockGrouperInstance,
145+
} = setup();
146+
147+
const params: ProcessEventsParams = {
148+
pendingStartActivities: [pendingActivityTaskStartEvent],
149+
pendingStartDecision: pendingDecisionTaskStartEvent,
150+
};
151+
152+
act(() => {
153+
current.updatePendingEvents(params);
154+
});
155+
156+
expect(mockGrouperInstance.updatePendingEvents).toHaveBeenCalledWith(
157+
params
158+
);
159+
});
160+
161+
it('should unsubscribe from onChange on unmount', () => {
162+
const { unmount, mockUnsubscribe } = setup();
163+
164+
expect(mockUnsubscribe).not.toHaveBeenCalled();
165+
166+
unmount();
167+
168+
expect(mockUnsubscribe).toHaveBeenCalled();
169+
});
170+
171+
it('should call grouper.destroy on unmount', () => {
172+
const { unmount, mockGrouperInstance } = setup();
173+
174+
expect(mockGrouperInstance.destroy).not.toHaveBeenCalled();
175+
176+
unmount();
177+
178+
expect(mockGrouperInstance.destroy).toHaveBeenCalled();
179+
});
180+
181+
it('should handle rapid event updates', () => {
182+
const {
183+
result: { current },
184+
mockGrouperInstance,
185+
} = setup();
186+
187+
const events1: HistoryEvent[] = [{ eventId: '1' } as HistoryEvent];
188+
const events2: HistoryEvent[] = [
189+
{ eventId: '1' } as HistoryEvent,
190+
{ eventId: '2' } as HistoryEvent,
191+
];
192+
const events3: HistoryEvent[] = [
193+
{ eventId: '1' } as HistoryEvent,
194+
{ eventId: '2' } as HistoryEvent,
195+
{ eventId: '3' } as HistoryEvent,
196+
];
197+
198+
act(() => {
199+
current.updateEvents(events1);
200+
current.updateEvents(events2);
201+
current.updateEvents(events3);
202+
});
203+
204+
expect(mockGrouperInstance.updateEvents).toHaveBeenCalledTimes(3);
205+
expect(mockGrouperInstance.updateEvents).toHaveBeenLastCalledWith(events3);
206+
});
207+
208+
it('should persist grouper instance across re-renders', () => {
209+
const { rerender } = setup();
210+
211+
expect(HistoryEventsGrouper).toHaveBeenCalledTimes(1);
212+
213+
rerender();
214+
rerender();
215+
rerender();
216+
217+
// Constructor should only be called once
218+
expect(HistoryEventsGrouper).toHaveBeenCalledTimes(1);
219+
});
220+
});
221+
222+
const createMockState = (
223+
overrides?: Partial<GroupingProcessState>
224+
): GroupingProcessState => ({
225+
groups: {},
226+
processedEventsCount: 0,
227+
remainingEventsCount: 0,
228+
status: 'idle',
229+
...overrides,
230+
});
231+
232+
function setup(options?: {
233+
initialState?: Partial<GroupingProcessState>;
234+
throttleMs?: number;
235+
}) {
236+
let mockOnChangeCallback: (state: GroupingProcessState) => void;
237+
const mockUnsubscribe = jest.fn();
238+
239+
const initialState = createMockState(options?.initialState);
240+
241+
// Spy on the prototype methods to create type-safe mocks
242+
const getStateSpy = jest
243+
.spyOn(HistoryEventsGrouper.prototype, 'getState')
244+
.mockReturnValue(initialState);
245+
246+
const onChangeSpy = jest
247+
.spyOn(HistoryEventsGrouper.prototype, 'onChange')
248+
.mockImplementation((callback) => {
249+
mockOnChangeCallback = callback;
250+
return mockUnsubscribe;
251+
});
252+
253+
const updateEventsSpy = jest.spyOn(
254+
HistoryEventsGrouper.prototype,
255+
'updateEvents'
256+
);
257+
258+
const updatePendingEventsSpy = jest.spyOn(
259+
HistoryEventsGrouper.prototype,
260+
'updatePendingEvents'
261+
);
262+
263+
const destroySpy = jest.spyOn(HistoryEventsGrouper.prototype, 'destroy');
264+
265+
// Render the hook (constructor will create instance with spied methods)
266+
const hookResult = renderHook(() =>
267+
useWorkflowHistoryGrouper(options?.throttleMs ?? 0)
268+
);
269+
270+
return {
271+
...hookResult,
272+
mockGrouperInstance: {
273+
getState: getStateSpy,
274+
onChange: onChangeSpy,
275+
updateEvents: updateEventsSpy,
276+
updatePendingEvents: updatePendingEventsSpy,
277+
destroy: destroySpy,
278+
},
279+
getMockOnChangeCallback: () => mockOnChangeCallback,
280+
mockUnsubscribe,
281+
};
282+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const BATCH_SIZE = 300;
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useCallback, useEffect, useRef } from 'react';
2+
3+
import type { HistoryEvent } from '@/__generated__/proto-ts/uber/cadence/api/v1/HistoryEvent';
4+
import useThrottledState from '@/hooks/use-throttled-state';
5+
6+
import HistoryEventsGrouper from '../helpers/workflow-history-grouper';
7+
import type {
8+
GroupingProcessState,
9+
ProcessEventsParams,
10+
} from '../helpers/workflow-history-grouper.types';
11+
12+
import { BATCH_SIZE } from './use-workflow-history-grouper.constants';
13+
14+
/**
15+
* Hook for grouping workflow history events using the HistoryEventsGrouper.
16+
*/
17+
export default function useWorkflowHistoryGrouper(throttleMs = 2000) {
18+
const grouperRef = useRef<HistoryEventsGrouper | null>(null);
19+
20+
if (!grouperRef.current) {
21+
grouperRef.current = new HistoryEventsGrouper({
22+
batchSize: BATCH_SIZE,
23+
});
24+
}
25+
26+
const [groupingState, setGroupingState] =
27+
useThrottledState<GroupingProcessState>(
28+
grouperRef.current.getState(),
29+
throttleMs,
30+
{
31+
leading: true,
32+
trailing: true,
33+
}
34+
);
35+
36+
useEffect(() => {
37+
if (!grouperRef.current) return;
38+
39+
const unsubscribe = grouperRef.current.onChange((state) => {
40+
const setImmediate = state.processedEventsCount < BATCH_SIZE;
41+
setGroupingState(() => state, setImmediate);
42+
});
43+
44+
return () => unsubscribe();
45+
}, [setGroupingState]);
46+
47+
useEffect(() => {
48+
return () => {
49+
grouperRef.current?.destroy();
50+
};
51+
}, []);
52+
53+
const updateEvents = useCallback((newEvents: HistoryEvent[]) => {
54+
if (!grouperRef.current) {
55+
return;
56+
}
57+
58+
grouperRef.current.updateEvents(newEvents);
59+
}, []);
60+
61+
const updatePendingEvents = useCallback((params: ProcessEventsParams) => {
62+
if (!grouperRef.current) {
63+
return;
64+
}
65+
grouperRef.current.updatePendingEvents(params);
66+
}, []);
67+
68+
return {
69+
eventGroups: groupingState?.groups ?? {},
70+
isProcessing: groupingState?.status === 'processing',
71+
groupingState,
72+
updateEvents,
73+
updatePendingEvents,
74+
};
75+
}

0 commit comments

Comments
 (0)