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

Network Cleanup: Isolate "persisted " queue from "main" queue #8312

Merged
merged 12 commits into from
Mar 28, 2022
3 changes: 2 additions & 1 deletion src/ONYXKEYS.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export default {
// Boolean flag set when workspace is being created
IS_CREATING_WORKSPACE: 'isCreatingWorkspace',

NETWORK_REQUEST_QUEUE: 'networkRequestQueue',
// Note: These are Persisted Requests - not all requests in the main queue as the key name might lead one to believe
PERSISTED_REQUESTS: 'networkRequestQueue',

// What the active route is for our navigator. Global route that determines what views to display.
CURRENT_URL: 'currentURL',
Expand Down
26 changes: 9 additions & 17 deletions src/libs/API.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import updateSessionAuthTokens from './actions/Session/updateSessionAuthTokens';
import setSessionLoadingAndError from './actions/Session/setSessionLoadingAndError';
import * as NetworkStore from './Network/NetworkStore';
import enhanceParameters from './Network/enhanceParameters';

let isAuthenticating;
import * as NetworkEvents from './Network/NetworkEvents';

/**
* Function used to handle expired auth tokens. It re-authenticates with the API and
Expand All @@ -27,13 +26,12 @@ let isAuthenticating;
function handleExpiredAuthToken(originalCommand, originalParameters, originalType) {
// When the authentication process is running, and more API requests will be requeued and they will
// be performed after authentication is done.
if (isAuthenticating) {
if (NetworkStore.isAuthenticating()) {
return Network.post(originalCommand, originalParameters, originalType);
}

// Prevent any more requests from being processed while authentication happens
Network.pauseRequestQueue();
isAuthenticating = true;
NetworkStore.setIsAuthenticating(true);
Copy link
Contributor

Choose a reason for hiding this comment

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

NAB: I think we can use NetworkStore.isAuthenticating() and NetworkStore.setAuthenticating(value) for this boolean value, no? https://airbnb.io/javascript/#accessors--boolean-prefix

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe - though unsure if we follow that guidance strictly. I had it like that but changed it as I couldn't think of a way to do that without changing the variable name here...

let isAuthenticating = false;

Copy link
Contributor

Choose a reason for hiding this comment

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

I wouldn't see a problem renaming it to authenticating since it's only used as a private variable and the NetworkStore is a small file, but that's just my opinion :)


// eslint-disable-next-line no-use-before-define
return reauthenticate(originalCommand)
Expand All @@ -50,9 +48,9 @@ function handleExpiredAuthToken(originalCommand, originalParameters, originalTyp
));
}

Network.registerLogHandler(() => Log);
NetworkEvents.registerLogHandler(() => Log);

Network.registerRequestHandler((queuedRequest, finalParameters) => {
NetworkEvents.registerRequestHandler((queuedRequest, finalParameters) => {
if (queuedRequest.command === 'Log') {
return;
}
Expand All @@ -65,11 +63,7 @@ Network.registerRequestHandler((queuedRequest, finalParameters) => {
});
});

Network.registerRequestSkippedHandler((parameters) => {
Log.hmmm('Trying to make a request when Network is not ready', parameters);
});

Network.registerResponseHandler((queuedRequest, response) => {
NetworkEvents.registerResponseHandler((queuedRequest, response) => {
if (queuedRequest.command !== 'Log') {
Log.info('Finished API request', false, {
command: queuedRequest.command,
Expand Down Expand Up @@ -114,7 +108,7 @@ Network.registerResponseHandler((queuedRequest, response) => {
queuedRequest.resolve(response);
});

Network.registerErrorHandler((queuedRequest, error) => {
NetworkEvents.registerErrorHandler((queuedRequest, error) => {
if (error.name === CONST.ERROR.REQUEST_CANCELLED) {
Log.info('[API] request canceled', false, queuedRequest);
return;
Expand Down Expand Up @@ -241,14 +235,12 @@ function reauthenticate(command = '') {

// The authentication process is finished so the network can be unpaused to continue
// processing requests
isAuthenticating = false;
Network.unpauseRequestQueue();
NetworkStore.setIsAuthenticating(false);
})

.catch((error) => {
// If authentication fails, then the network can be unpaused
Network.unpauseRequestQueue();
isAuthenticating = false;
NetworkStore.setIsAuthenticating(false);

// When a fetch() fails and the "API is offline" error is thrown we won't log the user out. Most likely they
// have a spotty connection and will need to try to reauthenticate when they come back online. We will
Expand Down
34 changes: 34 additions & 0 deletions src/libs/Network/NetworkEvents.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import CONST from '../../CONST';
import createCallback from '../createCallback';

const [getLogger, registerLogHandler] = createCallback();
const [triggerConnectivityResumed, onConnectivityResumed] = createCallback();
const [onRequest, registerRequestHandler] = createCallback();
Copy link
Contributor

Choose a reason for hiding this comment

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

I find this naming a bit confusing. We have two separate naming conventions both being used:

  1. onXYZ to emit the event and registerXYZHandler to register a handler for that event.
  2. triggerXYZ to emit the event and onXYZ to register a handler for that event.

We should choose one and be consistent. I would recommend 2, or alternatively could do triggerXYZ and registerXYZHandler

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 do the above ^ in any event, but you could also take this a step further and create a NETWORK_EVENTS constant like:

NETWORK_EVENTS = {
    REQUEST: 'request',
    RESPONSE: 'response',
    ERROR: 'error',
    CONNECTIVITY_RESUMED: 'connectivityResumed',
    CONNECTIVITY_CHECK: 'connectivityCheck',
};

And then refactor this lib like so:

const events = _.reduce(
    CONST.NETWORK_EVENTS,
    (memo, eventName) => ({
        ...memo,
        [eventName]: createCallback(),
    }),
    {},
);

function emit(name, payload) {
    const event = events[name];
    if (!event) {
        throw new Error(`The ${name} network event does not exist!`);
    }
    
    const eventTrigger = event[0];
    eventTrigger(payload);
}

function registerHandler(name, callback) {
    const event = events[name]
    if (!event) {
        throw new Error(`The ${name} network event does not exist!`);
    }
    
    const registerEventHandler = event[1];
    registerEventHandler(name, callback);
}

export {
    emit,
    registerHandler,
};

And then use the refactored lib like this:

NetworkEvents.registerHandler(CONST.NETWORK_EVENTS.ERROR, (error) => Log.alert(error.message));
NetworkEvents.emit(CONST.NETWORK_EVENTS.ERROR, {message: 'Some error message'});

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Solid idea. Can see how it cleans stuff up.

thought: It looks like we'd be able to register n handlers for a given event and that might lead one to make a mistake that they could add extra handlers

proposal: Let's clean up the names for now and do a proper EventEmitter class that can handle multiple callbacks in the next PR?

Copy link
Contributor

Choose a reason for hiding this comment

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

Let's clean up the names for now and do a proper EventEmitter class that can handle multiple callbacks in the next PR?

Sounds great! I'm happy to approve whatever level of cleanup you want to do 😄

const [onResponse, registerResponseHandler] = createCallback();
const [onError, registerErrorHandler] = createCallback();
const [triggerRecheckNeeded, registerConnectionCheckCallback] = createCallback();

/**
* @returns {Function} cancel timer
*/
function startRequestTimeoutTimer() {
// If request is still in processing after this time, we might be offline
const timerId = setTimeout(() => triggerRecheckNeeded(), CONST.NETWORK.MAX_PENDING_TIME_MS);
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
return () => clearTimeout(timerId);
}

export {
registerLogHandler,
getLogger,
triggerConnectivityResumed,
onConnectivityResumed,
onRequest,
registerRequestHandler,
onResponse,
registerResponseHandler,
onError,
registerErrorHandler,
triggerRecheckNeeded,
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
registerConnectionCheckCallback,
startRequestTimeoutTimer,
};
69 changes: 57 additions & 12 deletions src/libs/Network/NetworkStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,76 @@ import lodashGet from 'lodash/get';
import Onyx from 'react-native-onyx';
import _ from 'underscore';
import ONYXKEYS from '../../ONYXKEYS';
import * as NetworkEvents from './NetworkEvents';

let credentials;
let authToken;
let currentUserEmail;
let networkReady = false;
let hasReadRequiredData = false;
let authenticating = false;
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
let isOffline = false;
Copy link
Contributor

Choose a reason for hiding this comment

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

We can do all this later (as part of offline-first doc) if you prefer, but should we assume that we're offline unless we know otherwise?

Suggested change
let isOffline = false;
let isOffline = true;

Further, if we made that change, could we remove the self-proclaimed hack for checkRequiredData?

  1. We assume that we're offline until we know otherwise
  2. We won't know otherwise (and set isOffline = false unless Onyx has triggered the Onyx.connect callback for the NETWORK key
  3. So we don't have to check if Onyx has read the authToken and credentials from storage before clearing the network lib to make request

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good thoughts. I don't have a clear answer about which would be better from an Offline First Doc perspective and would have to weigh some pros/cons.

I don't think we can remove that hack if we do this anyway as there's no guarantee we won't set isOffline to true before the credentials and authToken are ready. It's maybe adding some delay, but not the same as the guarantee we have now.


/**
* @param {Boolean} ready
* @param {Boolean} val
*/
function setIsReady(ready) {
networkReady = ready;
function setHasReadRequiredDataFromStorage(val) {
hasReadRequiredData = val;
}

/**
* This is a hack to workaround the fact that Onyx may not yet have read these values from storage by the time Network starts processing requests.
* If the values are undefined we haven't read them yet. If they are null or have a value then we have and the network is "ready".
*/
function checkRequiredDataAndSetNetworkReady() {
function checkRequiredData() {
if (_.isUndefined(authToken) || _.isUndefined(credentials)) {
return;
}

setIsReady(true);
setHasReadRequiredDataFromStorage(true);
}

Onyx.connect({
key: ONYXKEYS.SESSION,
callback: (val) => {
authToken = lodashGet(val, 'authToken', null);
currentUserEmail = lodashGet(val, 'email', null);
checkRequiredDataAndSetNetworkReady();
checkRequiredData();
},
});

Onyx.connect({
key: ONYXKEYS.CREDENTIALS,
callback: (val) => {
credentials = val || null;
checkRequiredDataAndSetNetworkReady();
checkRequiredData();
},
});

// We subscribe to the online/offline status of the network to determine when we should fire off API calls
// vs queueing them for later.
Onyx.connect({
key: ONYXKEYS.NETWORK,
callback: (network) => {
if (!network) {
return;
}

// Client becomes online emit connectivity resumed event
if (isOffline && !network.isOffline) {
NetworkEvents.triggerConnectivityResumed();
}

isOffline = network.isOffline;
},
});

/**
* @returns {Boolean}
*/
function getIsOffline() {
return isOffline;
}

/**
* @returns {String}
*/
Expand Down Expand Up @@ -75,15 +103,32 @@ function getCurrentUserEmail() {
/**
* @returns {Boolean}
*/
function isReady() {
return networkReady;
function hasReadRequiredDataFromStorage() {
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
return hasReadRequiredData;
}

/**
* @param {Boolean} value
*/
function setIsAuthenticating(value) {
authenticating = value;
}

/**
* @returns {Boolean}
*/
function isAuthenticating() {
return authenticating;
}

export {
getAuthToken,
setAuthToken,
getCredentials,
getCurrentUserEmail,
isReady,
setIsReady,
hasReadRequiredDataFromStorage,
setHasReadRequiredDataFromStorage,
setIsAuthenticating,
isAuthenticating,
getIsOffline,
};
96 changes: 96 additions & 0 deletions src/libs/Network/PersistedRequestsQueue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import _ from 'underscore';
import Onyx from 'react-native-onyx';
import * as PersistedRequests from '../actions/PersistedRequests';
import * as NetworkStore from './NetworkStore';
import * as NetworkEvents from './NetworkEvents';
import CONST from '../../CONST';
import ONYXKEYS from '../../ONYXKEYS';
import * as ActiveClientManager from '../ActiveClientManager';
import processRequest from './processRequest';

let persistedRequestsQueueRunning = false;
roryabraham marked this conversation as resolved.
Show resolved Hide resolved

/**
* This method will get any persisted requests and fire them off in parallel to retry them.
* If we get any jsonCode besides 407 the request is a success. It doesn't make sense to
* continually retry things that have returned a response. However, we can retry any requests
* with known networking errors like "Failed to fetch".
*
* @returns {Promise}
*/
function process() {
const persistedRequests = PersistedRequests.getAll();

// This sanity check is also a recursion exit point
if (NetworkStore.getIsOffline() || _.isEmpty(persistedRequests)) {
return Promise.resolve();
}

const tasks = _.map(persistedRequests, request => processRequest(request)
.then((response) => {
if (response.jsonCode === CONST.JSON_CODE.NOT_AUTHENTICATED) {
NetworkEvents.getLogger().info('Persisted optimistic request needs authentication');
} else {
NetworkEvents.getLogger().info('Persisted optimistic request returned a valid jsonCode. Not retrying.');
}
NetworkEvents.onResponse(request, response);
PersistedRequests.remove(request);
})
.catch((error) => {
// If we are catching a known network error like "Failed to fetch" allow this request to be retried if we have retries left
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
if (error.message === CONST.ERROR.FAILED_TO_FETCH) {
const retryCount = PersistedRequests.incrementRetries(request);
NetworkEvents.getLogger().info('Persisted request failed', false, {retryCount, command: request.command, error: error.message});
if (retryCount >= CONST.NETWORK.MAX_REQUEST_RETRIES) {
NetworkEvents.getLogger().info('Request failed too many times removing from storage', false, {retryCount, command: request.command, error: error.message});
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
PersistedRequests.remove(request);
}
} else if (error.name === CONST.ERROR.REQUEST_CANCELLED) {
NetworkEvents.getLogger().info('Persisted request was cancelled. Not retrying.');
NetworkEvents.onError(request);
PersistedRequests.remove(request);
} else {
NetworkEvents.getLogger().alert(`${CONST.ERROR.ENSURE_BUGBOT} unknown error while retrying persisted request. Not retrying.`, {
command: request.command,
error: error.message,
});
PersistedRequests.remove(request);
}
}));

// Do a recursive call in case the queue is not empty after processing the current batch
return Promise.all(tasks)
.then(process);
}

function flush() {
if (persistedRequestsQueueRunning) {
return;
}

// ONYXKEYS.PERSISTED_REQUESTS is shared across clients, thus every client/tab will have a copy
// It is very important to only process the queue from leader client otherwise requests will be duplicated.
if (!ActiveClientManager.isClientTheLeader()) {
return;
}

persistedRequestsQueueRunning = true;

// Ensure persistedRequests are read from storage before proceeding with the queue
const connectionId = Onyx.connect({
Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry, it's probably just a me problem, but I'm confused how this is working and have a few questions:

  1. Why connect and disconnect instead of just relying on Onyx.connect to keep the persisted requests up-to-date? This feels like a hack b/c we're simulating Onyx.get here, though I'm sure it's a necessary one for now.
  2. What triggers the PersistedRequestsQueue to flush? The only usage I see is here, and that seems like it would only run on app init?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right. It's a hack that ensures we don't start processing the queue until the requests have been read from storage. This mostly only matters on app init so we could probably improve this further (as we are subscribing in PersistedRequests as well and could use that). We might be able to use the createOnReadyTask to workaround this for now (but I'm not gonna add that here).

What triggers the PersistedRequestsQueue to flush? The only usage I see is here, and that seems like it would only run on app init?

Main usage here and runs when we go from offline to online

// Flush the queue when the connection resumes
NetworkEvents.onConnectivityResumed(flush);

key: ONYXKEYS.PERSISTED_REQUESTS,
callback: () => {
Onyx.disconnect(connectionId);
process()
.finally(() => persistedRequestsQueueRunning = false);
},
});
}

// Flush the queue when the connection resumes
NetworkEvents.onConnectivityResumed(flush);

export {
// eslint-disable-next-line import/prefer-default-export
flush,
};
27 changes: 27 additions & 0 deletions src/libs/Network/RetryCounter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export default class RetryCounter {
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
constructor() {
this.retryMap = new Map();
}

clear() {
this.retryMap.clear();
}

/**
* @param {Object} request
* @returns {Number} retry count
*/
incrementRetries(request) {
const current = this.retryMap.get(request) || 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

TIL that objects can be keys for Maps ... but that seems strange / like it might have unexpected side-effects (honestly not sure). There very well might be no problem here, but maybe it would be clearer to use a request.ID or something instead of the full object as a key?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That makes sense to me, but at the same time I'm going to leave this since the code already existed and if it's confusing at least it's abstracted in this class for now :D

const next = current + 1;
this.retryMap.set(request, next);
return next;
}

/**
* @param {Object} request
*/
remove(request) {
this.retryMap.delete(request);
}
}
Loading