Source: util/scheduler.js

/**
 * Provides a way of scheduling a job that can be backed off. A schedule can be configured
 * to automatically start backing off when the user blurs the application or is inactive.
 *
 * **Web Resource:** com.atlassian.bitbucket.server.bitbucket-web-api:scheduler
 *
 * @module bitbucket/util/scheduler
 * @namespace bitbucket/util/scheduler
 */
import $ from 'jquery';
import { debounce } from 'lodash';

/**
 * @memberOf bitbucket/util/scheduler
 * @type {number}
 */
export const SECOND = 1000;

/**
 * @memberOf bitbucket/util/scheduler
 * @type {number}
 */
export const MINUTE = 60 * SECOND;

/**
 * @type {number}
 * @private
 */
const ACTIVITY_DEBOUNCE_TIME = 100;

/**
 * The range of jitter we will add to each run
 * @type {{min: number, max: number}}
 * @private
 */
const JITTER_RANGE = {
    min: 50,
    max: SECOND,
};

/**
 * Provides a way of scheduling a job that can be backed off. A schedule can be configured
 * to automatically start backing off when the user blurs the application or is inactive.
 *
 * User activity means scrolling, mouse movement, and keyboard input.
 *
 * When a user becomes active again after being inactive for a specified period of time
 * (the `inactivityTime`) then the schedule will start up again after the given `immediateTime`.
 *
 * @example
 * // ES5
 *
 * define('bitbucket/plugin/my-plugin', [
 *     'bitbucket/util/scheduler'
 * ], function(
 *     Scheduler
 * ) {
 *     var schedule = new Scheduler({
 *         backoff: {
 *             onBlur: true,
 *             onInactive: true,
 *         },
 *         maxInterval: 10 * Scheduler.MINUTE,
 *         interval: 30 * Scheduler.SECOND,
 *         job: function() {
 *             return getSomeDataFromServer(arg1, arg2);
 *         }
 *     });
 *
 *     schedule.start();
 * });
 *
 * @example
 * // ES2015+
 *
 * import Schedule, { MINUTE, SECOND } from 'bitbucket/util/scheduler';
 *
 * const schedule = new Scheduler({
 *     backoff: {
 *         onBlur: true,
 *         onInactive: true,
 *     },
 *     maxInterval: 10 * MINUTE,
 *     interval: 30 * SECOND,
 *     job: () => getSomeDataFromServer(arg1, arg2),
 * });
 *
 * schedule.start();
 *
 *
 * @memberOf bitbucket/util/scheduler
 */
class Scheduler {
    /**
     * @callback Schedulerjob
     * The Scheduler's job should return a {Deferred}.
     * If the Deferred is abortable it will be aborted when the schedule is stopped.
     *
     * @memberOf bitbucket/util/scheduler.Scheduler
     * @returns {Deferred}
     */

    /**
     * Schedule a schedule
     * @param {Object} [schedule={@link bitbucket/util/scheduler.Scheduler.defaults Scheduler.defaults}] - the schedule
     * @param {Object} [schedule.backoff] - the backoff configuration
     * @param {boolean} [schedule.backoff.onBlur] - should back off on blur?
     * @param {boolean} [schedule.backoff.onInactive] - should back off on inactivity?
     * @param {boolean} [schedule.jitter] - when enabled a random range is added to the scheduler when it starts
     *                  to reduce the likelihood that clients will all reconnect at the same time after an outage.
     * @param {number} [schedule.interval] - how often to run the schedule
     * @param {number} [schedule.immediateTime] - how long to wait to run a schedule "immediately". This is a buffer time
     *                 taken to wait when starting the schedule
     * @param {number} [schedule.maxInterval] - the max time the schedule will back off to.
     * @param {number} [schedule.inactivityTime] - the time at which a user is considered inactive.
     * @param {Schedulerjob} schedule.job -  the function that will be invoked every time the schedule runs. The scheduler
     *                                       will wait for the job function's Deferred to settle before continuing.
     */
    constructor(schedule = {}) {
        this.schedule = {
            ...Scheduler.defaults,
            ...schedule,
        };

        if (typeof schedule.job !== 'function') {
            throw new Error(`A schedule's job must be a function.`);
        }
        if (schedule.interval < 1) {
            throw new Error(`A schedule's interval must be a positive number.`);
        }

        this.currentInterval = this.schedule.interval;
        this._destroyables = [];
        this.lastActiveTime = Date.now();

        const throttledActivityHandler = debounce(() => {
            const wasInactive = Date.now() - this.lastActiveTime > this.schedule.inactivityTime;

            if (wasInactive) {
                this.start(true);
            } else {
                this._setActiveTime();
            }
        }, ACTIVITY_DEBOUNCE_TIME);

        const blurHandler = () => this._setActiveTime();

        const $window = $(window);
        const activityEvents = [];

        if (this.schedule.backoff.onBlur === true) {
            if (!document.hasFocus()) {
                this.stop();
            }

            activityEvents.push('focus.scheduler');

            $window.on('blur.scheduler', blurHandler);
            this._destroyables.push(() => $window.off('blur.scheduler', blurHandler));
        }

        if (this.schedule.backoff.onInactive === true) {
            activityEvents.push('mousemove.scheduler', 'keydown.scheduler', 'scroll.scheduler');
        }

        $window.on(activityEvents.join(' '), throttledActivityHandler);
        this._destroyables.push(() =>
            $window.off(activityEvents.join(' '), throttledActivityHandler)
        );
    }

    /**
     * Set the last active time to now.
     * @private
     */
    _setActiveTime() {
        this.lastActiveTime = Date.now();
    }

    /**
     * Get the jitter amount
     * @returns {number}
     * @private
     */
    _getJitter() {
        const { jitter } = this.schedule;

        if (!jitter) {
            return 0;
        }
        const diff = JITTER_RANGE.max - JITTER_RANGE.min;

        return Math.floor(JITTER_RANGE.min + diff * Math.random());
    }

    /**
     * Get the backoff time based on the last time the user was active and the current level of backoff
     * @returns {number}
     */
    getBackoffTime() {
        const lastActiveDiff = Date.now() - this.lastActiveTime;

        // if the time since last active is more than 2 intervals ago then double the interval or limit to max interval
        if (lastActiveDiff >= Math.max(this.currentInterval, this.schedule.interval * 2)) {
            return Math.min(this.currentInterval * 2, this.schedule.maxInterval);
        }

        return this.schedule.interval;
    }

    /**
     * Run with a given timeout and execute the configured job.
     * The schedule will be rescheduled once the job's Deferred settles.
     *
     * @param {boolean} [immediate=false]
     * @param {number} [jitterAmount=0]
     */
    run(immediate = false, jitterAmount = 0) {
        if (!this.running || this._destroyed) {
            return;
        }
        this.currentInterval = this.getBackoffTime();
        let timeout = immediate ? this.schedule.immediateTime : this.currentInterval;
        timeout += jitterAmount;
        this.runTimer = setTimeout(() => {
            this.jobDeferred = this.schedule.job();
            this.jobDeferred.always(() => {
                if (this.running) {
                    this.run();
                }
            });
        }, timeout);
    }

    /**
     * Stop the scheduler. This will abort any in-flight jobs if they are abortable and stop the current run.
     */
    stop() {
        // if a previous deferred is still unresolved and is abortable, do so now to avoid re-requesting
        if (this.jobDeferred && this.jobDeferred.abort) {
            this.jobDeferred.abort();
            this.jobDeferred = null;
        }
        clearTimeout(this.runTimer);
        this.running = false;
    }

    /**
     * Start the scheduler.
     * Starting a scheduler implies that the user is currently active and optionally immediately starts the run rather
     * than waiting for the timer to reach its first interval. This immediate run will start after the schedule's `immediateTime`.
     * When starting a scheduler an explicit stop is issued to prevent rogue deferreds/timers from firing.
     *
     * @param {boolean} [immediate]
     */
    start(immediate) {
        this.stop(); // make sure there aren't rogue deferreds/timers - always stop before starting
        this._setActiveTime();
        this.running = true;
        this._currentJitter = this._getJitter();
        this.run(immediate, this._currentJitter);
    }

    /**
     * Stop the scheduler and remove its installed event handlers.
     */
    destroy() {
        this.stop();
        this._destroyables.forEach((fn) => fn());
        this._destroyed = true;
    }
}

/**
 * @type {Object}
 * @property {Object} [backoff] - the backoff configuration
 * @property {boolean} [backoff.onBlur=true] - should back off on blur?
 * @property {boolean} [backoff.onInactive=true] - should back off on inactivity?
 * @property {boolean} [jitter=true] - when enabled a random range is added to the scheduler when it starts
 *                     to reduce the likelihood that clients will all reconnect at the same time after an outage.
 * @property {number} [interval={@link bitbucket/util/scheduler.exports.SECOND 10 * SECOND}] - how often to run the schedule (milliseconds)
 * @property {number} [immediateTime={@link bitbucket/util/scheduler.exports.SECOND SECOND}] - how long to wait to run a schedule "immediately".
 *                    This is a buffer time taken to wait when starting the schedule (milliseconds).
 * @property {number} [maxInterval={@link bitbucket/util/scheduler.exports.MINUTE 5 * MINUTE}] - the max time the schedule will back off to. (milliseconds)
 * @property {number} [inactivityTime={@link bitbucket/util/scheduler.exports.MINUTE 2 * MINUTE}] - the time at which a user is considered inactive. (milliseconds)
 */
Scheduler.defaults = {
    immediateTime: SECOND,
    backoff: {
        onBlur: true,
        onInactive: true,
    },
    jitter: true,
    interval: 10 * SECOND,
    maxInterval: 5 * MINUTE,
    inactivityTime: 2 * MINUTE,
};

export default Scheduler;