From 89ba05dd3e1e023ea97ca603f4eb030311c00725 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 21 Jun 2022 14:50:08 +0200 Subject: [PATCH] MOBILE-3817 core: Support updating WS data in background --- src/core/classes/site.ts | 204 +++++++++++++++++++++++++++---------- src/core/services/sites.ts | 7 ++ src/types/config.d.ts | 2 + 3 files changed, 162 insertions(+), 51 deletions(-) diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 7dd12b4e5..0fbc8c42d 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -676,22 +676,59 @@ export class CoreSite { const run = async () => { try { - let response: T | {exception?: string; errorcode?: string}; + let response: T | WSCachedError; + let cachedData: WSCachedData | undefined; try { - response = await this.getFromCache(method, data, preSets, false); + cachedData = await this.getFromCache(method, data, preSets, false); + response = cachedData.response; } catch { // Not found or expired, call WS. - response = await this.getFromWSOrEmergencyCache(method, data, preSets, wsPreSets); + response = await this.getFromWS(method, data, preSets, wsPreSets); } - if (('exception' in response && response.exception !== undefined) || - ('errorcode' in response && response.errorcode !== undefined)) { - throw new CoreWSError(response); + if ( + typeof response === 'object' && response !== null && + ( + ('exception' in response && response.exception !== undefined) || + ('errorcode' in response && response.errorcode !== undefined) + ) + ) { + subject.error(new CoreWSError(response)); + } else { + subject.next( response); } - subject.next( response); - subject.complete(); + if ( + preSets.updateInBackground && + !CoreConstants.CONFIG.disableCallWSInBackground && + cachedData && + !cachedData.expirationIgnored && + cachedData.expirationTime !== undefined && + Date.now() > cachedData.expirationTime + ) { + // Update the data in background. + setTimeout(async () => { + try { + preSets = { + ...preSets, + emergencyCache: false, + }; + + const newData = await this.getFromWS(method, data, preSets, wsPreSets); + + subject.next(newData); + } catch (error) { + // Ignore errors when updating in background. + this.logger.error('Error updating WS data in background', error); + } finally { + subject.complete(); + } + }); + } else { + // No need to update in background, complete the observable. + subject.complete(); + } } catch (error) { subject.error(error); } @@ -711,7 +748,7 @@ export class CoreSite { * @param wsPreSets Extra options related to the WS call. * @return Promise resolved with the response. */ - protected async getFromWSOrEmergencyCache( + protected async getFromWS( method: string, data: any, // eslint-disable-line @typescript-eslint/no-explicit-any preSets: CoreSiteWSPreSets, @@ -725,7 +762,7 @@ export class CoreSite { } try { - const response = await this.getFromWS(method, data, preSets, wsPreSets); + const response = await this.callOrEnqueueWS(method, data, preSets, wsPreSets); if (preSets.saveToCache) { this.saveToCache(method, data, response, preSets); @@ -820,11 +857,26 @@ export class CoreSite { } this.logger.debug(`WS call '${method}' failed. Trying to use the emergency cache.`); - preSets.omitExpires = true; - preSets.getFromCache = true; + preSets = { + ...preSets, + omitExpires: true, + getFromCache: true, + }; try { - return await this.getFromCache(method, data, preSets, true); + const cachedData = await this.getFromCache(method, data, preSets, true); + + if ( + typeof cachedData.response === 'object' && cachedData.response !== null && + ( + ('exception' in cachedData.response && cachedData.response.exception !== undefined) || + ('errorcode' in cachedData.response && cachedData.response.errorcode !== undefined) + ) + ) { + throw new CoreWSError(cachedData.response); + } + + return cachedData.response; } catch { if (useSilentError) { throw new CoreSilentError(error.message); @@ -844,7 +896,7 @@ export class CoreSite { * @param wsPreSets Extra options related to the WS call. * @return Promise resolved with the response. */ - protected async getFromWS( + protected async callOrEnqueueWS( method: string, data: any, // eslint-disable-line @typescript-eslint/no-explicit-any preSets: CoreSiteWSPreSets, @@ -1085,14 +1137,14 @@ export class CoreSite { * @param preSets Extra options. * @param emergency Whether it's an "emergency" cache call (WS call failed). * @param originalData Arguments to pass to the method before being converted to strings. - * @return Promise resolved with the WS response. + * @return Cached data. */ protected async getFromCache( method: string, data: any, // eslint-disable-line @typescript-eslint/no-explicit-any preSets: CoreSiteWSPreSets, emergency?: boolean, - ): Promise { + ): Promise> { if (!this.db || !preSets.getFromCache) { throw new CoreError('Get from cache is disabled.'); } @@ -1128,12 +1180,22 @@ export class CoreSite { const now = Date.now(); let expirationTime: number | undefined; - preSets.omitExpires = preSets.omitExpires || preSets.forceOffline || !CoreNetwork.isOnline(); + const forceCache = preSets.omitExpires || preSets.forceOffline || !CoreNetwork.isOnline(); - if (!preSets.omitExpires) { + if (!forceCache) { expirationTime = entry.expirationTime + this.getExpirationDelay(preSets.updateFrequency); - if (now > expirationTime) { + if (preSets.updateInBackground && !CoreConstants.CONFIG.disableCallWSInBackground) { + // Use a extended expiration time. + const extendedTime = entry.expirationTime + + (CoreConstants.CONFIG.callWSInBackgroundExpirationTime ?? CoreConstants.SECONDS_WEEK * 1000); + + if (now > extendedTime) { + this.logger.debug('Cached element found, but it is expired even for call WS in background.'); + + throw new CoreError('Cache entry is expired.'); + } + } else if (now > expirationTime) { this.logger.debug('Cached element found, but it is expired'); throw new CoreError('Cache entry is expired.'); @@ -1148,7 +1210,11 @@ export class CoreSite { this.logger.info(`Cached element found, id: ${id}. Expires in expires in ${expires} seconds`); } - return CoreTextUtils.parseJSON(entry.data, {}); + return { + response: CoreTextUtils.parseJSON(entry.data, {}), + expirationIgnored: forceCache, + expirationTime, + }; } throw new CoreError('Cache entry not valid.'); @@ -1591,41 +1657,46 @@ export class CoreSite { this.ongoingRequests[cacheId] = observable; - this.getFromCache(method, {}, cachePreSets, false).catch(async () => { - if (cachePreSets.forceOffline) { - // Don't call the WS, just fail. - throw new CoreError( - Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), - ); - } - - // Call the WS. - try { - const config = await this.requestPublicConfig(); - - if (cachePreSets.saveToCache) { - this.saveToCache(method, {}, config, cachePreSets); + this.getFromCache(method, {}, cachePreSets, false) + .then(cachedData => cachedData.response) + .catch(async () => { + if (cachePreSets.forceOffline) { + // Don't call the WS, just fail. + throw new CoreError( + Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), + ); } - return config; - } catch (error) { - cachePreSets.omitExpires = true; - cachePreSets.getFromCache = true; - + // Call the WS. try { - return await this.getFromCache(method, {}, cachePreSets, true); - } catch { - throw error; - } - } - }).then((response) => { - subject.next(response); - subject.complete(); + const config = await this.requestPublicConfig(); - return; - }).catch((error) => { - subject.error(error); - }); + if (cachePreSets.saveToCache) { + this.saveToCache(method, {}, config, cachePreSets); + } + + return config; + } catch (error) { + cachePreSets.omitExpires = true; + cachePreSets.getFromCache = true; + + try { + const cachedData = await this.getFromCache(method, {}, cachePreSets, true); + + return cachedData.response; + } catch { + throw error; + } + } + }).then((response) => { + // The app doesn't store exceptions for this call, it's safe to assume type CoreSitePublicConfigResponse. + subject.next( response); + subject.complete(); + + return; + }).catch((error) => { + subject.error(error); + }); return observable.toPromise(); } @@ -2425,6 +2496,12 @@ export type CoreSiteWSPreSets = { * can cause the request to fail (see PHP's max_input_vars). */ splitRequest?: CoreWSPreSetsSplitRequest; + + /** + * If true, the app will return cached data even if it's expired and then it'll call the WS in the background. + * Only enabled if CoreConstants.CONFIG.disableCallWSInBackground isn't true. + */ + updateInBackground?: boolean; }; /** @@ -2625,3 +2702,28 @@ export type CoreSiteStoreLastViewedOptions = { data?: string; // Other data. timeaccess?: number; // Accessed time. If not set, current time. }; + +/** + * Info about cached data. + */ +type WSCachedData = { + response: T | WSCachedError; // The WS response data, or an error if the WS returned an error and it was cached. + expirationIgnored: boolean; // Whether the expiration time was ignored. + expirationTime?: number; // Entry expiration time (only if not ignored). +}; + +/** + * Error data stored in cache. + */ +type WSCachedError = { + exception?: string; + errorcode?: string; +}; + +/** + * Observable returned when calling WebServices. + * If the request uses the "update in background" feature, it will return 2 values: first the cached one, and then the one + * coming from the server. After this, it will complete. + * Otherwise, it will only return 1 value, either coming from cache or from the server. After this, it will complete. + */ +export type WSObservable = Observable; diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index 67754f37b..c16638ac3 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -1796,6 +1796,12 @@ export class CoreSitesProvider { getFromCache: false, emergencyCache: false, }; + case CoreSitesReadingStrategy.UPDATE_IN_BACKGROUND: + return { + updateInBackground: true, + getFromCache: true, + saveToCache: true, + }; default: return {}; } @@ -2017,6 +2023,7 @@ export const enum CoreSitesReadingStrategy { PREFER_CACHE, ONLY_NETWORK, PREFER_NETWORK, + UPDATE_IN_BACKGROUND, } /** diff --git a/src/types/config.d.ts b/src/types/config.d.ts index f89ba6612..ceb4bb32c 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -71,4 +71,6 @@ export interface EnvironmentConfig { removeaccountonlogout?: boolean; // True to remove the account when the user clicks logout. Doesn't affect switch account. uselegacycompletion?: boolean; // Whether to use legacy completion by default in all course formats. toastDurations: Record; + disableCallWSInBackground?: boolean; // If true, disable calling WS in background. + callWSInBackgroundExpirationTime?: number; // Ms to consider an entry expired when calling WS in background. Default: 1 week. }