// (C) Copyright 2015 Martin Dougiamas // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Injectable } from '@angular/core'; import { Network } from '@ionic-native/network'; import { CoreAppProvider } from './app'; import { CoreConfigProvider } from './config'; import { CoreLoggerProvider } from './logger'; import { CoreUtilsProvider } from './utils/utils'; import { CoreConstants } from '../core/constants'; import { SQLiteDB } from '../classes/sqlitedb'; export interface CoreCronHandler { name: string; // Handler's name. getInterval?(): number; // Returns handler's interval in milliseconds. Defaults to CoreCronDelegate.DEFAULT_INTERVAL. usesNetwork?(): boolean; // Whether the process uses network or not. True if not defined. isSync?(): boolean; // Whether it's a synchronization process or not. True if not defined. canManualSync?(): boolean; // Whether the sync can be executed manually. Call isSync if not defined. execute?(siteId?: string): Promise; // Execute the process. Receives ID of site affected, undefined for all sites. // Important: If the promise is rejected then this function will be called again // often, it shouldn't be abused. running: boolean; // Whether the handler is running. Used internally by the provider, there's no need to set it. timeout: number; // Timeout ID for the handler scheduling. Used internally by the provider, there's no need to set it. }; /* * Service to handle cron processes. The registered processes will be executed every certain time. */ @Injectable() export class CoreCronDelegate { // Constants. public static DEFAULT_INTERVAL = 3600000; // Default interval is 1 hour. public static MIN_INTERVAL = 300000; // Minimum interval is 5 minutes. public static DESKTOP_MIN_INTERVAL = 60000; // Minimum interval in desktop is 1 minute. public static MAX_TIME_PROCESS = 120000; // Max time a process can block the queue. Defaults to 2 minutes. // Variables for database. protected CRON_TABLE = 'cron'; protected tableSchema = { name: this.CRON_TABLE, columns: [ { name: 'id', type: 'TEXT', primaryKey: true }, { name: 'value', type: 'INTEGER' } ] }; protected logger; protected appDB: SQLiteDB; protected handlers: {[s: string]: CoreCronHandler} = {}; protected queuePromise = Promise.resolve(); constructor(logger: CoreLoggerProvider, private appProvider: CoreAppProvider, private configProvider: CoreConfigProvider, private utils: CoreUtilsProvider, network: Network) { this.logger = logger.getInstance('CoreCronDelegate'); this.appDB = this.appProvider.getDB(); this.appDB.createTableFromSchema(this.tableSchema); // When the app is re-connected, start network handlers that were stopped. network.onConnect().subscribe(() => { this.startNetworkHandlers(); }); } /** * Try to execute a handler. It will schedule the next execution once done. * If the handler cannot be executed or it fails, it will be re-executed after mmCoreCronMinInterval. * * @param {string} name Name of the handler. * @param {boolean} [force] Wether the execution is forced (manual sync). * @param {string} [siteId] Site ID. If not defined, all sites. * @return {Promise} Promise resolved if handler is executed successfully, rejected otherwise. */ protected checkAndExecuteHandler(name: string, force?: boolean, siteId?: string) : Promise { if (!this.handlers[name] || !this.handlers[name].execute) { // Invalid handler. this.logger.debug('Cannot execute handler because is invalid: ' + name); return Promise.reject(null); } let usesNetwork = this.handlerUsesNetwork(name), isSync = !force && this.isHandlerSync(name), promise; if (usesNetwork && !this.appProvider.isOnline()) { // Offline, stop executing. this.logger.debug('Cannot execute handler because device is offline: ' + name); this.stopHandler(name); return Promise.reject(null); } if (isSync) { // Check network connection. promise = this.configProvider.get(CoreConstants.settingsSyncOnlyOnWifi, false).then((syncOnlyOnWifi) => { return !syncOnlyOnWifi || !this.appProvider.isNetworkAccessLimited(); }); } else { promise = Promise.resolve(true); } return promise.then((execute: boolean) => { if (!execute) { // Cannot execute in this network connection, retry soon. this.logger.debug('Cannot execute handler because device is using limited connection: ' + name); this.scheduleNextExecution(name, CoreCronDelegate.MIN_INTERVAL); return Promise.reject(null); } // Add the execution to the queue. this.queuePromise = this.queuePromise.catch(() => { // Ignore errors in previous handlers. }).then(() => { return this.executeHandler(name, siteId).then(() => { this.logger.debug(`Execution of handler '${name}' was a success.`); return this.setHandlerLastExecutionTime(name, Date.now()).then(() => { this.scheduleNextExecution(name); }); }, () => { // Handler call failed. Retry soon. this.logger.debug(`Execution of handler '${name}' failed.`); this.scheduleNextExecution(name, CoreCronDelegate.MIN_INTERVAL); return Promise.reject(null); }); }); return this.queuePromise; }); } /** * Run a handler, cancelling the execution if it takes more than MAX_TIME_PROCESS. * * @param {string} name Name of the handler. * @param {string} [siteId] Site ID. If not defined, all sites. * @return {Promise} Promise resolved when the handler finishes or reaches max time, rejected if it fails. */ protected executeHandler(name: string, siteId?: string) : Promise { return new Promise((resolve, reject) => { let cancelTimeout; this.logger.debug('Executing handler: ' + name); // Wrap the call in Promise.resolve to make sure it's a promise. Promise.resolve(this.handlers[name].execute(siteId)).then(resolve).catch(reject).finally(() => { clearTimeout(cancelTimeout); }); cancelTimeout = setTimeout(() => { // The handler took too long. Resolve because we don't want to retry soon. this.logger.debug(`Resolving execution of handler '${name}' because it took too long.`); resolve(); }, CoreCronDelegate.MAX_TIME_PROCESS); }); } /** * Force execution of synchronization cron tasks without waiting for the scheduled time. * Please notice that some tasks may not be executed depending on the network connection and sync settings. * * @param {string} [siteId] Site ID. If not defined, all sites. * @return {Promise} Promise resolved if all handlers are executed successfully, rejected otherwise. */ forceSyncExecution(siteId?: string) : Promise { let promises = []; for (let name in this.handlers) { let handler = this.handlers[name]; if (this.isHandlerManualSync(name)) { // Mark the handler as running (it might be running already). handler.running = true; // Cancel pending timeout. clearTimeout(handler.timeout); delete handler.timeout; // Now force the execution of the handler. promises.push(this.checkAndExecuteHandler(name, true, siteId)); } } return this.utils.allPromises(promises); } /** * Get a handler's interval. * * @param {string} name Handler's name. * @return {number} Handler's interval. */ protected getHandlerInterval(name) : number { if (!this.handlers[name] || !this.handlers[name].getInterval) { // Invalid, return default. return CoreCronDelegate.DEFAULT_INTERVAL; } // Don't allow intervals lower than the minimum. const minInterval = this.appProvider.isDesktop() ? CoreCronDelegate.DESKTOP_MIN_INTERVAL : CoreCronDelegate.MIN_INTERVAL, handlerInterval = this.handlers[name].getInterval(); if (!handlerInterval) { return CoreCronDelegate.DEFAULT_INTERVAL; } else { return Math.max(minInterval, handlerInterval); } } /** * Get a handler's last execution ID. * * @param {string} name Handler's name. * @return {string} Handler's last execution ID. */ protected getHandlerLastExecutionId(name: string) : string { return 'last_execution_' + name; } /** * Get a handler's last execution time. If not defined, return 0. * * @param {string} name Handler's name. * @return {Promise} Promise resolved with the handler's last execution time. */ protected getHandlerLastExecutionTime(name: string) : Promise { const id = this.getHandlerLastExecutionId(name); return this.appDB.getRecord(this.CRON_TABLE, {id: id}).then((entry) => { const time = parseInt(entry.value, 10); return isNaN(time) ? 0 : time; }).catch(() => { return 0; // Not set, return 0. }); } /** * Check if a handler uses network. Defaults to true. * * @param {string} name Handler's name. * @return {boolean} True if handler uses network or not defined, false otherwise. */ protected handlerUsesNetwork(name: string) : boolean { if (!this.handlers[name] || !this.handlers[name].usesNetwork) { // Invalid, return default. return true; } return this.handlers[name].usesNetwork(); } /** * Check if there is any manual sync handler registered. * * @return {boolean} Whether it has at least 1 manual sync handler. */ hasManualSyncHandlers() : boolean { for (let name in this.handlers) { if (this.isHandlerManualSync(name)) { return true; } } return false; } /** * Check if there is any sync handler registered. * * @return {boolean} Whether it has at least 1 sync handler. */ hasSyncHandlers() : boolean { for (let name in this.handlers) { if (this.isHandlerSync(name)) { return true; } } return false; } /** * Check if a handler can be manually synced. Defaults will use isSync instead. * * @param {string} name Handler's name. * @return {boolean} True if handler is a sync process and can be manually executed or not defined, false otherwise. */ protected isHandlerManualSync(name: string) : boolean { if (!this.handlers[name] || !this.handlers[name].canManualSync) { // Invalid, return default. return this.isHandlerSync(name); } return this.handlers[name].canManualSync(); } /** * Check if a handler is a sync process. Defaults to true. * * @param {string} name Handler's name. * @return {boolean} True if handler is a sync process or not defined, false otherwise. */ protected isHandlerSync(name) : boolean { if (!this.handlers[name] || !this.handlers[name].isSync) { // Invalid, return default. return true; } return this.handlers[name].isSync(); } /** * Register a handler to be executed every certain time. * * @param {CoreCronHandler} handler The handler to register. */ register(handler: CoreCronHandler) : void { if (!handler || !handler.name) { // Invalid handler. return; } if (typeof this.handlers[handler.name] != 'undefined') { this.logger.debug(`The cron handler '${name}' is already registered.`); return; } this.logger.debug.debug(`Register handler '${name}' in cron.`); handler.running = false; this.handlers[handler.name] = handler; // Start the handler. this.startHandler(name); } /** * Schedule a next execution for a handler. * * @param {string} name Name of the handler. * @param {number} [time] Time to the next execution. If not supplied it will be calculated using the last execution and * the handler's interval. This param should be used only if it's really necessary. */ protected scheduleNextExecution(name: string, time?: number) : void { if (!this.handlers[name]) { // Invalid handler. return; } if (this.handlers[name].timeout) { // There's already a pending timeout. return; } let promise; if (time) { promise = Promise.resolve(time); } else { // Get last execution time to check when do we need to execute it. promise = this.getHandlerLastExecutionTime(name).then((lastExecution) => { const interval = this.getHandlerInterval(name), nextExecution = lastExecution + interval; return nextExecution - Date.now(); }); } promise.then((nextExecution) => { this.logger.debug(`Scheduling next execution of handler '${name}' in '${nextExecution}' ms`); if (nextExecution < 0) { nextExecution = 0; // Big negative numbers aren't executed immediately. } this.handlers[name].timeout = setTimeout(() => { delete this.handlers[name].timeout; this.checkAndExecuteHandler(name); }, nextExecution); }); } /** * Set a handler's last execution time. * * @param {string} name Handler's name. * @param {number} time Time to set. * @return {Promise} Promise resolved when the execution time is saved. */ protected setHandlerLastExecutionTime(name: string, time: number) : Promise { const id = this.getHandlerLastExecutionId(name), entry = { id: id, value: time } return this.appDB.insertOrUpdateRecord(this.CRON_TABLE, entry, {id: id}); } /** * Start running a handler periodically. * * @param {string} name Name of the handler. */ protected startHandler(name) : void { if (!this.handlers[name]) { // Invalid handler. this.logger.debug(`Cannot start handler '${name}', is invalid.`); return; } if (this.handlers[name].running) { this.logger.debug(`Handler '${name}', is already running.`); return; } this.handlers[name].running = true; this.scheduleNextExecution(name); } /** * Start running periodically the handlers that use network. */ startNetworkHandlers() : void { for (let name in this.handlers) { if (this.handlerUsesNetwork(name)) { this.startHandler(name); } } } /** * Stop running a handler periodically. * * @param {string} name Name of the handler. */ protected stopHandler(name) { if (!this.handlers[name]) { // Invalid handler. this.logger.debug(`Cannot stop handler '${name}', is invalid.`); return; } if (!this.handlers[name].running) { this.logger.debug(`Cannot stop handler '${name}', it's not running.`); return; } this.handlers[name].running = false; clearTimeout(this.handlers[name].timeout); delete this.handlers[name].timeout; } }