From 642f52d383742c33a3509ef1478326b1530b3cb7 Mon Sep 17 00:00:00 2001 From: Tomasz Palys Date: Wed, 2 Oct 2024 15:04:37 +0200 Subject: [PATCH] [lib] Create a queue and use it to process queued operations Summary: It is possible that we need an updated state when processing the next operation, so we need to make sure that the new state is propagated. https://linear.app/comm/issue/ENG-9470/mitigate-risks-of-effects-running-on-outdated-data Test Plan: Tested that this diff doesn't introduce a regression: 1. Receiving an edit entry operation before create entry 2. Receiving a change thread settings operation before create thread 3. Receiving a change thread subscription before adding as a member Each of these produces the correct state and clears the Redux queue. Also tested that we have a bug that this diff fixes. Created three operations that were processed in the following order: 1. Change thread name at timestamp T+1 2. Change thread description at T+1 3. Create thread at T Before this diff, there were robotexts displayed for each of these, but the thread name wasn't changed. After this diff both thread name and description are updated. Reviewers: kamil, ashoat Reviewed By: ashoat Differential Revision: https://phab.comm.dev/D13572 --- lib/hooks/actions-queue.js | 44 ++++++ .../dm-ops/dm-ops-queue-handler.react.js | 141 ++++++++++++------ 2 files changed, 142 insertions(+), 43 deletions(-) create mode 100644 lib/hooks/actions-queue.js diff --git a/lib/hooks/actions-queue.js b/lib/hooks/actions-queue.js new file mode 100644 index 0000000000..6d49f1bcd5 --- /dev/null +++ b/lib/hooks/actions-queue.js @@ -0,0 +1,44 @@ +// @flow + +import * as React from 'react'; + +type MessageQueueHook = { + +enqueue: (items: $ReadOnlyArray) => void, +}; + +function useActionsQueue( + performAction: (item: T) => mixed | Promise, +): MessageQueueHook { + const [queue, setQueue] = React.useState<$ReadOnlyArray>([]); + const isProcessing = React.useRef(false); + + const process = React.useCallback( + async (action: T) => { + isProcessing.current = true; + try { + await performAction(action); + } finally { + isProcessing.current = false; + setQueue(currentQueue => currentQueue.slice(1)); + } + }, + [performAction], + ); + + const enqueue = React.useCallback( + (items: $ReadOnlyArray) => + setQueue(prevQueue => [...prevQueue, ...items]), + [], + ); + + React.useEffect(() => { + if (isProcessing.current || queue.length === 0) { + return; + } + void process(queue[0]); + }, [process, queue]); + + return { enqueue }; +} + +export { useActionsQueue }; diff --git a/lib/shared/dm-ops/dm-ops-queue-handler.react.js b/lib/shared/dm-ops/dm-ops-queue-handler.react.js index 8883e72ec5..6b845c7f4b 100644 --- a/lib/shared/dm-ops/dm-ops-queue-handler.react.js +++ b/lib/shared/dm-ops/dm-ops-queue-handler.react.js @@ -4,6 +4,7 @@ import * as React from 'react'; import { dmOperationSpecificationTypes } from './dm-op-utils.js'; import { useProcessDMOperation } from './process-dm-ops.js'; +import { useActionsQueue } from '../../hooks/actions-queue.js'; import { messageInfoSelector } from '../../selectors/chat-selectors.js'; import { entryInfoSelector, @@ -16,13 +17,28 @@ import { clearQueuedThreadDMOpsActionType, pruneDMOpsQueueActionType, } from '../../types/dm-ops.js'; -import type { OperationsQueue } from '../../types/dm-ops.js'; +import type { DMOperation } from '../../types/dm-ops.js'; +import type { BaseAction } from '../../types/redux-types.js'; import { useDispatch, useSelector } from '../../utils/redux-utils.js'; const PRUNING_FREQUENCY = 60 * 60 * 1000; const FIRST_PRUNING_DELAY = 10 * 60 * 1000; const QUEUED_OPERATION_TTL = 3 * 24 * 60 * 60 * 1000; +type QueueItem = + | { + +type: 'operation', + +operation: DMOperation, + } + | { + +type: 'action', + +action: BaseAction, + } + | { + +type: 'function', + +itemFunction: () => mixed, + }; + function DMOpsQueueHandler(): React.Node { const dispatch = useDispatch(); @@ -54,24 +70,31 @@ function DMOpsQueueHandler(): React.Node { ); const processDMOperation = useProcessDMOperation(); - const processOperationsQueue = React.useCallback( - (queue: OperationsQueue) => { - for (const dmOp of queue) { - void processDMOperation({ + + const processItem = React.useCallback( + async (item: QueueItem) => { + if (item.type === 'operation') { + await processDMOperation({ // This is `INBOUND` because we assume that when generating // `dmOperationSpecificationTypes.OUBOUND` it should be possible // to be processed and never queued up. type: dmOperationSpecificationTypes.INBOUND, - op: dmOp.operation, + op: item.operation, // There is no metadata, because messages were confirmed when // adding to the queue. metadata: null, }); + } else if (item.type === 'action') { + dispatch(item.action); + } else { + item.itemFunction(); } }, - [processDMOperation], + [dispatch, processDMOperation], ); + const { enqueue } = useActionsQueue(processItem); + React.useEffect(() => { const prevThreadInfos = prevThreadInfosRef.current; prevThreadInfosRef.current = threadInfos; @@ -80,15 +103,23 @@ function DMOpsQueueHandler(): React.Node { if (!threadInfos[threadID] || prevThreadInfos[threadID]) { continue; } - processOperationsQueue(queuedThreadOperations[threadID]); - dispatch({ - type: clearQueuedThreadDMOpsActionType, - payload: { - threadID, + enqueue([ + ...queuedThreadOperations[threadID].map(item => ({ + type: 'operation', + operation: item.operation, + })), + { + type: 'action', + action: { + type: clearQueuedThreadDMOpsActionType, + payload: { + threadID, + }, + }, }, - }); + ]); } - }, [dispatch, processOperationsQueue, queuedThreadOperations, threadInfos]); + }, [dispatch, enqueue, queuedThreadOperations, threadInfos]); const messageInfos = useSelector(messageInfoSelector); const prevMessageInfosRef = React.useRef({}); @@ -105,15 +136,23 @@ function DMOpsQueueHandler(): React.Node { if (!messageInfos[messageID] || prevMessageInfos[messageID]) { continue; } - processOperationsQueue(queuedMessageOperations[messageID]); - dispatch({ - type: clearQueuedMessageDMOpsActionType, - payload: { - messageID, + enqueue([ + ...queuedMessageOperations[messageID].map(item => ({ + type: 'operation', + operation: item.operation, + })), + { + type: 'action', + action: { + type: clearQueuedMessageDMOpsActionType, + payload: { + messageID, + }, + }, }, - }); + ]); } - }, [dispatch, messageInfos, processOperationsQueue, queuedMessageOperations]); + }, [dispatch, enqueue, messageInfos, queuedMessageOperations]); const entryInfos = useSelector(entryInfoSelector); const prevEntryInfosRef = React.useRef({}); @@ -130,15 +169,23 @@ function DMOpsQueueHandler(): React.Node { if (!entryInfos[entryID] || prevEntryInfos[entryID]) { continue; } - processOperationsQueue(queuedEntryOperations[entryID]); - dispatch({ - type: clearQueuedEntryDMOpsActionType, - payload: { - entryID, + enqueue([ + ...queuedEntryOperations[entryID].map(item => ({ + type: 'operation', + operation: item.operation, + })), + { + type: 'action', + action: { + type: clearQueuedEntryDMOpsActionType, + payload: { + entryID, + }, + }, }, - }); + ]); } - }, [dispatch, entryInfos, processOperationsQueue, queuedEntryOperations]); + }, [dispatch, enqueue, entryInfos, queuedEntryOperations]); const queuedMembershipOperations = useSelector( state => state.queuedDMOperations.membershipQueue, @@ -169,24 +216,32 @@ function DMOpsQueueHandler(): React.Node { runningMembershipOperations.current.get(threadID)?.add(member.id); - processOperationsQueue(queuedMembershipOperations[threadID][member.id]); - - dispatch({ - type: clearQueuedMembershipDMOpsActionType, - payload: { - threadID, - userID: member.id, + enqueue([ + ...queuedMembershipOperations[threadID][member.id].map(item => ({ + type: 'operation', + operation: item.operation, + })), + { + type: 'action', + action: { + type: clearQueuedMembershipDMOpsActionType, + payload: { + threadID, + userID: member.id, + }, + }, }, - }); - runningMembershipOperations.current.get(threadID)?.delete(member.id); + { + type: 'function', + itemFunction: () => + runningMembershipOperations.current + .get(threadID) + ?.delete(member.id), + }, + ]); } } - }, [ - dispatch, - processOperationsQueue, - queuedMembershipOperations, - threadInfos, - ]); + }, [dispatch, enqueue, queuedMembershipOperations, threadInfos]); return null; }