Skip to content

Commit 3f3869a

Browse files
authored
Merge pull request #39024 from Expensify/Rory-GenericConflictingRequests
Create generic network layer logic for conflicting write requests
2 parents 7603719 + be315d6 commit 3f3869a

10 files changed

+124
-110
lines changed

src/libs/API/index.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as Pusher from '@libs/Pusher/pusher';
77
import * as Request from '@libs/Request';
88
import CONST from '@src/CONST';
99
import type OnyxRequest from '@src/types/onyx/Request';
10+
import type {RequestConflictResolver} from '@src/types/onyx/Request';
1011
import type Response from '@src/types/onyx/Response';
1112
import pkg from '../../../package.json';
1213
import type {ApiRequest, ApiRequestCommandParameters, ReadCommand, SideEffectRequestCommand, WriteCommand} from './types';
@@ -51,8 +52,14 @@ type OnyxData = {
5152
* @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
5253
* @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
5354
* @param [onyxData.finallyData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200 or jsonCode !== 200.
55+
* @param [conflictResolver] - callbacks used in special cases to detect and handle conflicting requests in the sequential queue
5456
*/
55-
function write<TCommand extends WriteCommand>(command: TCommand, apiCommandParameters: ApiRequestCommandParameters[TCommand], onyxData: OnyxData = {}) {
57+
function write<TCommand extends WriteCommand>(
58+
command: TCommand,
59+
apiCommandParameters: ApiRequestCommandParameters[TCommand],
60+
onyxData: OnyxData = {},
61+
conflictResolver: RequestConflictResolver = {},
62+
) {
5663
Log.info('Called API write', false, {command, ...apiCommandParameters});
5764
const {optimisticData, ...onyxDataWithoutOptimisticData} = onyxData;
5865

@@ -83,6 +90,7 @@ function write<TCommand extends WriteCommand>(command: TCommand, apiCommandParam
8390
canCancel: true,
8491
},
8592
...onyxDataWithoutOptimisticData,
93+
...conflictResolver,
8694
};
8795

8896
// Write commands can be saved and retried, so push it to the SequentialQueue

src/libs/API/parameters/OpenReportParams.ts

-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ type OpenReportParams = {
77
shouldRetry?: boolean;
88
createdReportActionID?: string;
99
clientLastReadTime?: string;
10-
idempotencyKey?: string;
1110
groupChatAdminLogins?: string;
1211
reportName?: string;
1312
chatType?: string;

src/libs/API/parameters/ReconnectAppParams.ts

-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ type ReconnectAppParams = {
22
mostRecentReportActionLastModified?: string;
33
updateIDFrom?: number;
44
policyIDList: string[];
5-
idempotencyKey?: string;
65
};
76

87
export default ReconnectAppParams;

src/libs/Network/SequentialQueue.ts

-46
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import Onyx from 'react-native-onyx';
22
import * as ActiveClientManager from '@libs/ActiveClientManager';
3-
import {WRITE_COMMANDS} from '@libs/API/types';
43
import * as Request from '@libs/Request';
54
import * as RequestThrottle from '@libs/RequestThrottle';
65
import * as PersistedRequests from '@userActions/PersistedRequests';
@@ -46,47 +45,6 @@ function flushOnyxUpdatesQueue() {
4645
QueuedOnyxUpdates.flushQueue();
4746
}
4847

49-
/**
50-
* Identifies and removes conflicting requests from the queue
51-
*/
52-
function reconcileRequests(persistedRequests: OnyxRequest[], commands: string[]) {
53-
const requestsByActionID: Record<string, OnyxRequest[]> = {};
54-
55-
// Group requests by reportActionID
56-
persistedRequests.forEach((request) => {
57-
const {data} = request;
58-
const reportActionID = data?.reportActionID as string;
59-
if (reportActionID) {
60-
if (!requestsByActionID[reportActionID]) {
61-
requestsByActionID[reportActionID] = [];
62-
}
63-
requestsByActionID[reportActionID].push(request);
64-
}
65-
});
66-
67-
// Process requests with conflicting actions
68-
Object.values(requestsByActionID).forEach((requests) => {
69-
const conflictingRequests: OnyxRequest[] = [];
70-
commands.forEach((command) => {
71-
const conflictingRequest = requests.find((request) => request.command === command);
72-
if (conflictingRequest) {
73-
conflictingRequests.push(conflictingRequest);
74-
}
75-
});
76-
77-
if (conflictingRequests.length > 1) {
78-
// Remove all requests as they cancel each other out
79-
conflictingRequests.forEach((request) => {
80-
// Perform action: Remove request from persisted requests
81-
const index = persistedRequests.findIndex((req) => req === request);
82-
if (index !== -1) {
83-
persistedRequests.splice(index, 1);
84-
}
85-
});
86-
}
87-
});
88-
}
89-
9048
/**
9149
* Process any persisted requests, when online, one at a time until the queue is empty.
9250
*
@@ -106,10 +64,6 @@ function process(): Promise<void> {
10664
return Promise.resolve();
10765
}
10866

109-
// Remove conflicting requests from the queue to avoid processing them
110-
const commands = [WRITE_COMMANDS.ADD_COMMENT, WRITE_COMMANDS.DELETE_COMMENT];
111-
reconcileRequests(persistedRequests, commands);
112-
11367
const requestToProcess = persistedRequests[0];
11468

11569
// Set the current request to a promise awaiting its processing so that getCurrentRequest can be used to take some action after the current request has processed.

src/libs/Network/enhanceParameters.ts

-3
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,5 @@ export default function enhanceParameters(command: string, parameters: Record<st
3636

3737
finalParameters.isFromDevEnv = Environment.isDevelopment();
3838

39-
// idempotencyKey declared in JS is front-end-only. We delete it here so it doesn't interfere with idempotency in other layers.
40-
delete finalParameters.idempotencyKey;
41-
4239
return finalParameters;
4340
}

src/libs/actions/App.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,7 @@ function openApp() {
222222
function reconnectApp(updateIDFrom: OnyxEntry<number> = 0) {
223223
console.debug(`[OnyxUpdates] App reconnecting with updateIDFrom: ${updateIDFrom}`);
224224
getPolicyParamsForOpenOrReconnect().then((policyParams) => {
225-
const params: ReconnectAppParams = {
226-
...policyParams,
227-
idempotencyKey: `${WRITE_COMMANDS.RECONNECT_APP}`,
228-
};
225+
const params: ReconnectAppParams = policyParams;
229226

230227
// When the app reconnects we do a fast "sync" of the LHN and only return chats that have new messages. We achieve this by sending the most recent reportActionID.
231228
// we have locally. And then only update the user about chats with messages that have occurred after that reportActionID.
@@ -242,7 +239,9 @@ function reconnectApp(updateIDFrom: OnyxEntry<number> = 0) {
242239
params.updateIDFrom = updateIDFrom;
243240
}
244241

245-
API.write(WRITE_COMMANDS.RECONNECT_APP, params, getOnyxDataForOpenOrReconnect());
242+
API.write(WRITE_COMMANDS.RECONNECT_APP, params, getOnyxDataForOpenOrReconnect(), {
243+
getConflictingRequests: (persistedRequests) => persistedRequests.filter((request) => request?.command === WRITE_COMMANDS.RECONNECT_APP),
244+
});
246245
});
247246
}
248247

src/libs/actions/PersistedRequests.ts

+30-9
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,38 @@ function clear() {
1818
}
1919

2020
function save(requestToPersist: Request) {
21-
const requests = [...persistedRequests];
22-
const existingRequestIndex = requests.findIndex((request) => request.data?.idempotencyKey && request.data?.idempotencyKey === requestToPersist.data?.idempotencyKey);
23-
if (existingRequestIndex > -1) {
24-
// Merge the new request into the existing one, keeping its place in the queue
25-
requests.splice(existingRequestIndex, 1, requestToPersist);
26-
} else {
27-
// If not, push the new request to the end of the queue
28-
requests.push(requestToPersist);
21+
const requests = [...persistedRequests, requestToPersist];
22+
23+
// identify and handle any existing requests that conflict with the new one
24+
const {getConflictingRequests, handleConflictingRequest, shouldIncludeCurrentRequest} = requestToPersist;
25+
if (getConflictingRequests) {
26+
// Get all the requests, potentially including the one we're adding, which will always be at the end of the array
27+
const potentiallyConflictingRequests = shouldIncludeCurrentRequest ? requests : requests.slice(0, requests.length - 1);
28+
29+
// Identify conflicting requests according to logic bound to the new request
30+
const conflictingRequests = getConflictingRequests(potentiallyConflictingRequests);
31+
32+
conflictingRequests.forEach((conflictingRequest) => {
33+
// delete the conflicting request
34+
const index = requests.findIndex((req) => req === conflictingRequest);
35+
if (index !== -1) {
36+
requests.splice(index, 1);
37+
}
38+
39+
// Allow the new request to perform any additional cleanup for a cancelled request
40+
handleConflictingRequest?.(conflictingRequest);
41+
});
2942
}
30-
persistedRequests = requests;
3143

44+
// Don't try to serialize conflict resolution functions
45+
persistedRequests = requests.map((request) => {
46+
delete request.getConflictingRequests;
47+
delete request.handleConflictingRequest;
48+
delete request.shouldIncludeCurrentRequest;
49+
return request;
50+
});
51+
52+
// Save the updated set of requests
3253
Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests);
3354
}
3455

src/libs/actions/Report.ts

+35-6
Original file line numberDiff line numberDiff line change
@@ -642,7 +642,6 @@ function openReport(
642642
emailList: participantLoginList ? participantLoginList.join(',') : '',
643643
accountIDList: participantAccountIDList ? participantAccountIDList.join(',') : '',
644644
parentReportActionID,
645-
idempotencyKey: `${SIDE_EFFECT_REQUEST_COMMANDS.OPEN_REPORT}_${reportID}`,
646645
};
647646

648647
if (ReportUtils.isGroupChat(newReportObject)) {
@@ -664,7 +663,8 @@ function openReport(
664663
}
665664

666665
// If we are creating a new report, we need to add the optimistic report data and a report action
667-
if (!isEmptyObject(newReportObject)) {
666+
const isCreatingNewReport = !isEmptyObject(newReportObject);
667+
if (isCreatingNewReport) {
668668
// Change the method to set for new reports because it doesn't exist yet, is faster,
669669
// and we need the data to be available when we navigate to the chat page
670670
optimisticData[0].onyxMethod = Onyx.METHOD.SET;
@@ -748,7 +748,6 @@ function openReport(
748748

749749
// Add the createdReportActionID parameter to the API call
750750
parameters.createdReportActionID = optimisticCreatedAction.reportActionID;
751-
parameters.idempotencyKey = `${parameters.idempotencyKey}_NewReport_${optimisticCreatedAction.reportActionID}`;
752751

753752
// If we are creating a thread, ensure the report action has childReportID property added
754753
if (newReportObject.parentReportID && parentReportActionID) {
@@ -774,7 +773,19 @@ function openReport(
774773
});
775774
} else {
776775
// eslint-disable-next-line rulesdir/no-multiple-api-calls
777-
API.write(WRITE_COMMANDS.OPEN_REPORT, parameters, {optimisticData, successData, failureData});
776+
API.write(
777+
WRITE_COMMANDS.OPEN_REPORT,
778+
parameters,
779+
{optimisticData, successData, failureData},
780+
{
781+
getConflictingRequests: (persistedRequests) =>
782+
// requests conflict only if:
783+
// 1. they are OpenReport commands
784+
// 2. they have the same reportID
785+
// 3. they are not creating a report - all calls to OpenReport that create a report will be unique and have a unique createdReportActionID
786+
persistedRequests.filter((request) => request.command === WRITE_COMMANDS.OPEN_REPORT && request.data?.reportID === reportID && !request.data?.createdReportActionID),
787+
},
788+
);
778789
}
779790
}
780791

@@ -1188,14 +1199,15 @@ function deleteReportComment(reportID: string, reportAction: ReportAction) {
11881199
return;
11891200
}
11901201

1202+
const isDeletedParentAction = ReportActionsUtils.isThreadParentMessage(reportAction, reportID);
11911203
const deletedMessage: Message[] = [
11921204
{
11931205
translationKey: '',
11941206
type: 'COMMENT',
11951207
html: '',
11961208
text: '',
11971209
isEdited: true,
1198-
isDeletedParentAction: ReportActionsUtils.isThreadParentMessage(reportAction, reportID),
1210+
isDeletedParentAction,
11991211
},
12001212
];
12011213
const optimisticReportActions: NullishDeep<ReportActions> = {
@@ -1292,7 +1304,24 @@ function deleteReportComment(reportID: string, reportAction: ReportAction) {
12921304
};
12931305

12941306
CachedPDFPaths.clearByKey(reportActionID);
1295-
API.write(WRITE_COMMANDS.DELETE_COMMENT, parameters, {optimisticData, successData, failureData});
1307+
1308+
API.write(
1309+
WRITE_COMMANDS.DELETE_COMMENT,
1310+
parameters,
1311+
{optimisticData, successData, failureData},
1312+
{
1313+
getConflictingRequests: (persistedRequests) => {
1314+
const conflictingCommands = (
1315+
isDeletedParentAction
1316+
? [WRITE_COMMANDS.UPDATE_COMMENT]
1317+
: [WRITE_COMMANDS.ADD_COMMENT, WRITE_COMMANDS.ADD_ATTACHMENT, WRITE_COMMANDS.UPDATE_COMMENT, WRITE_COMMANDS.DELETE_COMMENT]
1318+
) as string[];
1319+
return persistedRequests.filter((request) => conflictingCommands.includes(request.command) && request.data?.reportActionID === reportActionID);
1320+
},
1321+
handleConflictingRequest: () => Onyx.update(successData),
1322+
shouldIncludeCurrentRequest: !isDeletedParentAction,
1323+
},
1324+
);
12961325
}
12971326

12981327
/**

src/types/onyx/Request.ts

+31-4
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,40 @@ type RequestData = {
1919
successData?: OnyxUpdate[];
2020
failureData?: OnyxUpdate[];
2121
finallyData?: OnyxUpdate[];
22-
idempotencyKey?: string;
23-
22+
getConflictingRequests?: (persistedRequests: Request[]) => Request[];
23+
handleConflictingRequest?: (persistedRequest: Request) => unknown;
2424
resolve?: (value: Response) => void;
2525
reject?: (value?: unknown) => void;
2626
};
2727

28-
type Request = RequestData & OnyxData;
28+
type RequestConflictResolver = {
29+
/**
30+
* A callback that's provided with all the currently serialized functions in the sequential queue.
31+
* Should return a subset of the requests passed in that conflict with the new request.
32+
* Any conflicting requests will be cancelled and removed from the queue.
33+
*
34+
* @example - In ReconnectApp, you'd only want to have one instance of that command serialized to run on reconnect. The callback for that might look like this:
35+
* (persistedRequests) => persistedRequests.filter((request) => request.command === 'ReconnectApp')
36+
* */
37+
getConflictingRequests?: (persistedRequests: Request[]) => Request[];
38+
39+
/**
40+
* Should the requests provided to getConflictingRequests include the new request?
41+
* This is useful if the new request and an existing request cancel eachother out completely.
42+
*
43+
* @example - In DeleteComment, if you're deleting an optimistic comment, you'd want to cancel the optimistic AddComment call AND the DeleteComment call.
44+
* */
45+
shouldIncludeCurrentRequest?: boolean;
46+
47+
/**
48+
* Callback to handle a single conflicting request.
49+
* This is useful if you need to clean up some optimistic data for a request that was queue but never sent.
50+
* In these cases the optimisticData will be applied immediately, but the successData, failureData, and/or finallyData will never be applied unless you do it manually in this callback.
51+
*/
52+
handleConflictingRequest?: (persistedRequest: Request) => unknown;
53+
};
54+
55+
type Request = RequestData & OnyxData & RequestConflictResolver;
2956

3057
export default Request;
31-
export type {OnyxData, RequestType};
58+
export type {OnyxData, RequestType, RequestConflictResolver};

tests/unit/PersistedRequests.ts

+15-34
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@ import type Request from '../../src/types/onyx/Request';
33

44
const request: Request = {
55
command: 'OpenReport',
6-
data: {
7-
idempotencyKey: 'OpenReport_1',
8-
},
96
successData: [{key: 'reportMetadata_1', onyxMethod: 'merge', value: {}}],
107
failureData: [{key: 'reportMetadata_2', onyxMethod: 'merge', value: {}}],
118
};
@@ -20,44 +17,28 @@ afterEach(() => {
2017
});
2118

2219
describe('PersistedRequests', () => {
23-
it('save a new request with an idempotency key which currently exists in the PersistedRequests array', () => {
20+
it('save a request without conflicts', () => {
2421
PersistedRequests.save(request);
25-
expect(PersistedRequests.getAll().length).toBe(1);
22+
expect(PersistedRequests.getAll().length).toBe(2);
2623
});
2724

28-
it('save a new request with a new idempotency key', () => {
25+
it('save a new request with conflict resolution', () => {
26+
const handleConflictingRequest = jest.fn();
2927
const newRequest = {
30-
command: 'OpenReport',
31-
data: {
32-
idempotencyKey: 'OpenReport_2',
33-
},
28+
command: 'ThingA',
29+
getConflictingRequests: (requests: Request[]) => requests,
30+
handleConflictingRequest,
3431
};
35-
PersistedRequests.save(newRequest);
36-
expect(PersistedRequests.getAll().length).toBe(2);
37-
});
38-
39-
it('replace a request existing in the PersistedRequests array with a new one', () => {
40-
const newRequest: Request = {
41-
command: 'OpenReport',
42-
data: {
43-
idempotencyKey: 'OpenReport_1',
44-
},
45-
successData: [{key: 'reportMetadata_3', onyxMethod: 'merge', value: {}}],
46-
failureData: [{key: 'reportMetadata_4', onyxMethod: 'merge', value: {}}],
32+
const secondRequest = {
33+
command: 'ThingB',
34+
getConflictingRequests: (requests: Request[]) => requests,
35+
shouldIncludeCurrentRequest: true,
4736
};
48-
4937
PersistedRequests.save(newRequest);
50-
51-
const persistedRequests = PersistedRequests.getAll();
52-
53-
expect(persistedRequests.length).toBe(1);
54-
55-
const mergedRequest = persistedRequests[0];
56-
57-
expect(mergedRequest.successData?.length).toBe(1);
58-
expect(mergedRequest.failureData?.length).toBe(1);
59-
expect(mergedRequest.successData?.[0]?.key).toBe('reportMetadata_3');
60-
expect(mergedRequest.failureData?.[0]?.key).toBe('reportMetadata_4');
38+
PersistedRequests.save(secondRequest);
39+
expect(PersistedRequests.getAll().length).toBe(1);
40+
expect(handleConflictingRequest).toHaveBeenCalledWith(request);
41+
expect(handleConflictingRequest).toHaveBeenCalledTimes(1);
6142
});
6243

6344
it('remove a request from the PersistedRequests array', () => {

0 commit comments

Comments
 (0)