Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

De-dupe ReconnectApp in the persisted requests queue #47913

Merged
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e388597
Updating reconnectApp from the persisted requests queue
gedu Aug 22, 2024
6f278c0
Merge branch 'main' into gedu/replace_reconnectapp_most_updated
gedu Aug 28, 2024
615a8ba
Applying comments + unit tests
gedu Aug 29, 2024
8b505ab
Adding comment
gedu Aug 29, 2024
be72fec
Adding test to Sequential Queue
gedu Aug 30, 2024
6e40d66
Fixed TS error
gedu Aug 30, 2024
31bf286
removed the .only from test
gedu Aug 30, 2024
7f1b772
Merge branch 'main' into gedu/replace_reconnectapp_most_updated
gedu Sep 3, 2024
45bf5a6
Fixes and moving the ongoing request to PersistedRequests
gedu Sep 10, 2024
3620da6
Fixed APITest
gedu Sep 10, 2024
1d9550c
Merge branch 'main' into gedu/replace_reconnectapp_most_updated
gedu Sep 10, 2024
d07d912
Fixed tests and code
gedu Sep 11, 2024
7170e14
Fixed lint issues
gedu Sep 11, 2024
69463bb
Merge branch 'main' into gedu/replace_reconnectapp_most_updated
gedu Sep 13, 2024
b9d19f0
Tests to App.reconnectApp + some minors
gedu Sep 16, 2024
dc6aa40
Cleanup
gedu Sep 17, 2024
2f0e209
Merge branch 'main' into gedu/replace_reconnectapp_most_updated
gedu Sep 17, 2024
b6b893b
Fixed some deprecated eslints
gedu Sep 17, 2024
1fcd87d
Rolling back change
gedu Sep 17, 2024
b9cb098
Merge branch 'main' into gedu/replace_reconnectapp_most_updated
gedu Sep 17, 2024
3b21e41
disable deprecation fields
gedu Sep 17, 2024
919e254
Removed hack, seems we don't need it anymore
gedu Sep 18, 2024
1c5c49b
Removed unused key
gedu Sep 18, 2024
9ff5edd
Added the chance to save the ongoing request into Onyx for later proc…
gedu Sep 24, 2024
d3bf574
Merge branch 'main' into gedu/replace_reconnectapp_most_updated
gedu Sep 24, 2024
2bc9f32
fixed eslint error
gedu Sep 24, 2024
4b309bf
Merge branch 'main' into gedu/replace_reconnectapp_most_updated
gedu Sep 25, 2024
cae7b49
Merge branch 'main' into gedu/replace_reconnectapp_most_updated
gedu Sep 26, 2024
2792cfe
Merge branch 'main' into gedu/replace_reconnectapp_most_updated
gedu Sep 27, 2024
703957d
Fixed comments
gedu Sep 27, 2024
7d90d66
replaced with unshift
gedu Sep 27, 2024
1e0d4ed
removed duplicated rule
gedu Oct 1, 2024
ccadfd6
Merge branch 'main' into gedu/replace_reconnectapp_most_updated
gedu Oct 2, 2024
0877308
Fixed some eslint issues
gedu Oct 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/libs/API/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as Request from '@libs/Request';
import * as PersistedRequests from '@userActions/PersistedRequests';
import CONST from '@src/CONST';
import type OnyxRequest from '@src/types/onyx/Request';
import type {PaginatedRequest, PaginationConfig} from '@src/types/onyx/Request';
import type {PaginatedRequest, PaginationConfig, RequestConflictResolver} from '@src/types/onyx/Request';
import type Response from '@src/types/onyx/Response';
import type {ApiCommand, ApiRequestCommandParameters, ApiRequestType, CommandOfType, ReadCommand, SideEffectRequestCommand, WriteCommand} from './types';

Expand Down Expand Up @@ -45,7 +45,13 @@ type OnyxData = {
/**
* Prepare the request to be sent. Bind data together with request metadata and apply optimistic Onyx data.
*/
function prepareRequest<TCommand extends ApiCommand>(command: TCommand, type: ApiRequestType, params: ApiRequestCommandParameters[TCommand], onyxData: OnyxData = {}): OnyxRequest {
function prepareRequest<TCommand extends ApiCommand>(
command: TCommand,
type: ApiRequestType,
params: ApiRequestCommandParameters[TCommand],
onyxData: OnyxData = {},
conflictResolver: RequestConflictResolver = {},
): OnyxRequest {
Log.info('[API] Preparing request', false, {command, type});

const {optimisticData, ...onyxDataWithoutOptimisticData} = onyxData;
Expand All @@ -71,6 +77,7 @@ function prepareRequest<TCommand extends ApiCommand>(command: TCommand, type: Ap
command,
data,
...onyxDataWithoutOptimisticData,
...conflictResolver,
};

if (isWriteRequest) {
Expand Down Expand Up @@ -116,9 +123,14 @@ function processRequest(request: OnyxRequest, type: ApiRequestType): Promise<voi
* @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
* @param [onyxData.finallyData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200 or jsonCode !== 200.
*/
function write<TCommand extends WriteCommand>(command: TCommand, apiCommandParameters: ApiRequestCommandParameters[TCommand], onyxData: OnyxData = {}): void {
function write<TCommand extends WriteCommand>(
command: TCommand,
apiCommandParameters: ApiRequestCommandParameters[TCommand],
onyxData: OnyxData = {},
conflictResolver: RequestConflictResolver = {},
): void {
Log.info('[API] Called API write', false, {command, ...apiCommandParameters});
const request = prepareRequest(command, CONST.API_REQUEST_TYPE.WRITE, apiCommandParameters, onyxData);
const request = prepareRequest(command, CONST.API_REQUEST_TYPE.WRITE, apiCommandParameters, onyxData, conflictResolver);
processRequest(request, CONST.API_REQUEST_TYPE.WRITE);
}

Expand Down
34 changes: 26 additions & 8 deletions src/libs/Network/SequentialQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ let isReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise?.();

let isSequentialQueueRunning = false;
let currentRequest: Promise<void> | null = null;
let currentRequest: OnyxRequest | null = null;
let currentRequestPromise: Promise<void> | null = null;
let isQueuePaused = false;

/**
Expand Down Expand Up @@ -81,8 +82,9 @@ function process(): Promise<void> {

const requestToProcess = persistedRequests[0];

currentRequest = requestToProcess;
// 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.
currentRequest = Request.processWithMiddleware(requestToProcess, true)
currentRequestPromise = Request.processWithMiddleware(requestToProcess, true)
.then((response) => {
// A response might indicate that the queue should be paused. This happens when a gap in onyx updates is detected between the client and the server and
// that gap needs resolved before the queue can continue.
Expand Down Expand Up @@ -112,7 +114,7 @@ function process(): Promise<void> {
});
});

return currentRequest;
return currentRequestPromise;
}

function flush() {
Expand Down Expand Up @@ -161,6 +163,8 @@ function flush() {
resolveIsReadyPromise?.();
}
currentRequest = null;
currentRequestPromise = null;

// The queue can be paused when we sync the data with backend so we should only update the Onyx data when the queue is empty
if (PersistedRequests.getAll().length === 0) {
flushOnyxUpdatesQueue();
Expand Down Expand Up @@ -196,9 +200,23 @@ function isPaused(): boolean {
// Flush the queue when the connection resumes
NetworkStore.onReconnection(flush);

function push(request: OnyxRequest) {
// Add request to Persisted Requests so that it can be retried if it fails
PersistedRequests.save(request);
function push(newRequest: OnyxRequest) {
// If a request is already being processed, ignore it when looking for potentially conflicting requests
const requests = PersistedRequests.getAll().filter((persistedRequest) => persistedRequest !== currentRequest);
gedu marked this conversation as resolved.
Show resolved Hide resolved

const {checkAndFixConflictingRequest} = newRequest;
if (checkAndFixConflictingRequest) {
const {conflictAction} = checkAndFixConflictingRequest(requests);

if (conflictAction.type === 'push') {
PersistedRequests.save(newRequest);
} else if (conflictAction.type === 'replace') {
PersistedRequests.update(conflictAction.index, newRequest);
}
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
} else {
// Add request to Persisted Requests so that it can be retried if it fails
PersistedRequests.save(newRequest);
}

// If we are offline we don't need to trigger the queue to empty as it will happen when we come back online
if (NetworkStore.isOffline()) {
Expand All @@ -215,10 +233,10 @@ function push(request: OnyxRequest) {
}

function getCurrentRequest(): Promise<void> {
if (currentRequest === null) {
if (currentRequestPromise === null) {
return Promise.resolve();
}
return currentRequest;
return currentRequestPromise;
}

/**
Expand Down
20 changes: 19 additions & 1 deletion src/libs/actions/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,25 @@ function reconnectApp(updateIDFrom: OnyxEntry<number> = 0) {
params.updateIDFrom = updateIDFrom;
}

API.write(WRITE_COMMANDS.RECONNECT_APP, params, getOnyxDataForOpenOrReconnect());
API.write(WRITE_COMMANDS.RECONNECT_APP, params, getOnyxDataForOpenOrReconnect(), {
checkAndFixConflictingRequest: (persistedRequests) => {
const index = persistedRequests.findIndex((request) => request.command === WRITE_COMMANDS.RECONNECT_APP);
if (index === -1) {
return {
conflictAction: {
type: 'push',
},
};
}

return {
conflictAction: {
type: 'replace',
index,
},
};
},
});
});
}

Expand Down
7 changes: 4 additions & 3 deletions src/libs/actions/PersistedRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ function getLength(): number {
}

function save(requestToPersist: Request) {
const requests = [...persistedRequests, requestToPersist];
persistedRequests = requests;
Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests).then(() => {
// If the command is not in the keepLastInstance array, add the new request as usual
persistedRequests = [...persistedRequests, requestToPersist];

Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests).then(() => {
Log.info(`[SequentialQueue] '${requestToPersist.command}' command queued. Queue length is ${getLength()}`);
});
}
Expand Down
60 changes: 58 additions & 2 deletions src/types/onyx/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,64 @@ type RequestData = {
shouldSkipWebProxy?: boolean;
};

/**
* Model of a conflict request that has to be updated, in the request queue.
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
*/
type ConflictRequestUpdate = {
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
/**
* The action to take in case of a conflict.
*/
type: 'replace';

/**
* The index of the request in the queue to update.
*/
index: number;
};

/**
* Model of a conflict request that has to be saved at the end the request queue.
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
*/
type ConflictRequestSave = {
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
/**
* The action to take in case of a conflict.
*/
type: 'push';
};

/**
* Model of a conflict request that no need to be updated or saved, in the request queue.
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
*/
type ConflictRequestNoAction = {
/**
* The action to take in case of a conflict.
*/
type: 'noAction';
};

/**
* An object that has the request and the action to take in case of a conflict.
*/
type ConflictActionData = {
/**
* The action to take in case of a conflict.
*/
conflictAction: ConflictRequestUpdate | ConflictRequestSave | ConflictRequestNoAction;
};

/**
* An object that describes how a new write request can identify any queued requests that may conflict with or be undone by the new request,
* and how to resolve those conflicts.
*/
type RequestConflictResolver = {
/**
* A function that checks if a new request conflicts with any existing requests in the queue.
*/
checkAndFixConflictingRequest?: (persistedRequest: Request[]) => ConflictActionData;
};

/** Model of requests sent to the API */
type Request = RequestData & OnyxData;
type Request = RequestData & OnyxData & RequestConflictResolver;

/**
* An object used to describe how a request can be paginated.
Expand Down Expand Up @@ -85,4 +141,4 @@ type PaginatedRequest = Request &
};

export default Request;
export type {OnyxData, RequestType, PaginationConfig, PaginatedRequest};
export type {OnyxData, RequestType, PaginationConfig, PaginatedRequest, RequestConflictResolver, ConflictActionData};
148 changes: 148 additions & 0 deletions tests/unit/SequentialQueueTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import Onyx from 'react-native-onyx';
import * as PersistedRequests from '@userActions/PersistedRequests';
import ONYXKEYS from '@src/ONYXKEYS';
import * as SequentialQueue from '../../src/libs/Network/SequentialQueue';
import type Request from '../../src/types/onyx/Request';
import type {ConflictActionData} from '../../src/types/onyx/Request';
import * as TestHelper from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';

const request: Request = {
command: 'ReconnectApp',
successData: [{key: 'userMetadata', onyxMethod: 'set', value: {accountID: 1234}}],
failureData: [{key: 'userMetadata', onyxMethod: 'set', value: {}}],
};

describe('SequentialQueue', () => {
beforeAll(() => {
Onyx.init({
keys: ONYXKEYS,
});
});
beforeEach(() => {
global.fetch = TestHelper.getGlobalFetchMock();
return Onyx.clear().then(waitForBatchedUpdates);
});

it('should push one request and persist one', () => {
SequentialQueue.push(request);
expect(PersistedRequests.getLength()).toBe(1);
});

it('should push two requests and persist two', () => {
SequentialQueue.push(request);
SequentialQueue.push(request);
expect(PersistedRequests.getLength()).toBe(2);
});

it('should push two requests with conflict resolution and replace', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should also add a test that queues other unrelated requests and make sure that the replaced request is in the same index as the conflicting ReconnectApp request.

i.e: [OpenReport, AddComment, ReconnectApp, OpenReport]

becomes [OpenReport, AddComment, ReconnectApp, OpenReport], with ReconnectApp still at index 2 for example

SequentialQueue.push(request);
const requestWithConflictResolution: Request = {
command: 'ReconnectApp',
data: {accountID: 56789},
checkAndFixConflictingRequest: (persistedRequests) => {
// should be one instance of ReconnectApp, get the index to replace it later
const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp');
if (index === -1) {
return {conflictAction: {type: 'push'}};
}

return {
conflictAction: {type: 'replace', index},
};
},
};
SequentialQueue.push(requestWithConflictResolution);
expect(PersistedRequests.getLength()).toBe(1);
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
});

it('should push two requests with conflict resolution and push', () => {
SequentialQueue.push(request);
const requestWithConflictResolution: Request = {
command: 'ReconnectApp',
data: {accountID: 56789},
checkAndFixConflictingRequest: () => {
return {conflictAction: {type: 'push'}};
},
};
SequentialQueue.push(requestWithConflictResolution);
expect(PersistedRequests.getLength()).toBe(2);
});

it('should push two requests with conflict resolution and noAction', () => {
SequentialQueue.push(request);
const requestWithConflictResolution: Request = {
command: 'ReconnectApp',
data: {accountID: 56789},
checkAndFixConflictingRequest: () => {
return {conflictAction: {type: 'noAction'}};
},
};
SequentialQueue.push(requestWithConflictResolution);
expect(PersistedRequests.getLength()).toBe(1);
});

it('should add a new request even if a similar one is ongoing', async () => {
// .push at the end flush the queue
SequentialQueue.push(request);

// wait for Onyx.connect execute the callback and start processing the queue
await Promise.resolve();

const requestWithConflictResolution: Request = {
command: 'ReconnectApp',
data: {accountID: 56789},
checkAndFixConflictingRequest: (persistedRequests) => {
// should be one instance of ReconnectApp, get the index to replace it later
const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp');
if (index === -1) {
return {conflictAction: {type: 'push'}};
}

return {
conflictAction: {type: 'replace', index},
};
},
};

SequentialQueue.push(requestWithConflictResolution);
expect(PersistedRequests.getLength()).toBe(2);
});

it('should replace request request in queue while a similar one is ongoing', async () => {
// .push at the end flush the queue
SequentialQueue.push(request);

// wait for Onyx.connect execute the callback and start processing the queue
await Promise.resolve();

const conflicyResolver = (persistedRequests: Request[]): ConflictActionData => {
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
// should be one instance of ReconnectApp, get the index to replace it later
const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp');
if (index === -1) {
return {conflictAction: {type: 'push'}};
}

return {
conflictAction: {type: 'replace', index},
};
};

const requestWithConflictResolution: Request = {
command: 'ReconnectApp',
data: {accountID: 56789},
checkAndFixConflictingRequest: conflicyResolver,
};

const requestWithConflictResolution2: Request = {
command: 'ReconnectApp',
data: {accountID: 56789},
checkAndFixConflictingRequest: conflicyResolver,
};

SequentialQueue.push(requestWithConflictResolution);
SequentialQueue.push(requestWithConflictResolution2);

expect(PersistedRequests.getLength()).toBe(2);
});
});
Loading