fetchQueue.js

var $ = require('jquery');

/**
 * @typedef {object} geo.fetchQueue.spec
 * @property {number} [size=6] The maximum number of concurrent deferred
 *   objects.
 * @property {number} [track=600] The number of objects that are tracked that
 *   trigger checking if any of them have been abandoned.  The fetch queue can
 *   grow to the greater of this size and the number of items that are still
 *   needed.  Setting this to a low number will increase processing time, to a
 *   high number can increase memory.  Ideally, it should reflect the number of
 *   items that are kept in memory elsewhere.  If `needed` is `null`, this is
 *   ignored.
 * @property {function} [needed=null] If set, this function is passed a
 *   Deferred object and must return a truthy value if the object is still
 *   needed.
 */

/**
 * This class implements a queue for Deferred objects.  Whenever one of the
 * objects in the queue completes (resolved or rejected), another item in the
 * queue is processed.  The number of concurrently processing items can be
 * adjusted.
 *
 * At this time (2018-11-02) most major browsers support 6 concurrent requests
 * from any given server, so, when using the queue for tile images, the number
 * of concurrent requests should be 6 * (number of subdomains serving tiles).
 *
 * @class
 * @alias geo.fetchQueue
 * @param {geo.fetchQueue.spec} [options] A configuration object for the queue.
 * @returns {geo.fetchQueue}
 */
var fetchQueue = function (options) {
  if (!(this instanceof fetchQueue)) {
    return new fetchQueue(options);
  }

  options = options || {};
  this._size = options.size || 6;
  this._initialSize = options.initialSize || 0;
  this._track = options.track || 600;
  this._initialTrack = this._track;
  this._needed = options.needed || null;
  this._batch = false;

  var m_this = this,
      m_next_batch = 1;

  /**
   * Get/set the maximum concurrent deferred object size.
   * @property {number} size The maximum number of deferred objects.
   * @name geo.fetchQueue#size
   */
  Object.defineProperty(this, 'size', {
    get: function () { return m_this._size; },
    set: function (n) {
      m_this._size = n;
      if (m_this._initialSize > 1 && n < m_this._initialSize) {
        m_this._initialSize = n;
      }
      m_this.next_item();
    }
  });

  /**
   * Get/set the initial maximum concurrent deferred object size.
   * @property {number} initialSize The initial maximum number of deferred
   *    objects.  `0` to use `size`.
   * @name geo.fetchQueue#initialSize
   */
  Object.defineProperty(this, 'initialSize', {
    get: function () { return m_this._initialSize; },
    set: function (n) {
      m_this._initialSize = n;
      m_this.next_item();
    }
  });

  /**
   * Get/set the track size.  This is used to determine when to check if
   * entries can be discarded.
   * @property {number} track The number of entries to track without checking
   *    for discards.
   * @name geo.fetchQueue#track
   */
  Object.defineProperty(this, 'track', {
    get: function () { return m_this._track; },
    set: function (n) { m_this._track = n; }
  });

  /**
   * Get/set the initial track size.  Unless changed, this is the value used
   * for track on class initialization.
   * @property {number} initialTrack The number of entries to track without
   *    checking for discards.
   * @name geo.fetchQueue#intitialTrack
   */
  Object.defineProperty(this, 'initialTrack', {
    get: function () { return m_this._initialTrack; },
    set: function (n) { m_this._initialTrack = n; }
  });

  /**
   * Get the current queue size.  Read only.
   * @property {number} length The current queue size.
   * @name geo.fetchQueue#length
   */
  Object.defineProperty(this, 'length', {
    get: function () { return m_this._queue.length; }
  });

  /**
   * Get the current number of processing items.  Read only.
   * @property {number} processing The current number of processing items.
   * @name geo.fetchQueue#processing
   */
  Object.defineProperty(this, 'processing', {
    get: function () { return m_this._processing; }
  });

  /**
   * Remove all items from the queue.
   *
   * @returns {this}
   */
  this.clear = function () {
    m_this._queue = [];
    m_this._processing = 0;
    return m_this;
  };

  /**
   * Add a Deferred object to the queue.
   *
   * @param {jQuery.Deferred} defer Deferred object to add to the queue.
   * @param {function} callback A function to call when the item's turn is
   *  granted.
   * @param {boolean} atEnd If falsy, add the item to the front of the queue
   *  if batching is turned off or at the end of the current batch if it is
   *  turned on.  If truthy, always add the item to the end of the queue.
   * @returns {jQuery.Deferred} The deferred object that was passed to the
   *  function.
   */
  this.add = function (defer, callback, atEnd) {
    if (defer.__fetchQueue) {
      var pos = $.inArray(defer, m_this._queue);
      if (pos >= 0) {
        // m_this._queue.splice(pos, 1);
        m_this._addToQueue(defer, atEnd, pos);
        return defer;
      }
    }
    var wait = $.Deferred();
    var process = $.Deferred();
    wait.done(function () {
      $.when(callback.call(defer)).always(process.resolve);
    }).fail(process.resolve);
    defer.__fetchQueue = wait;
    m_this._addToQueue(defer, atEnd);
    $.when(wait, process).always(function () {
      if (m_this._processing > 0) {
        m_this._processing -= 1;
      }
      m_this._initialSize = 0;
      m_this.next_item();
    }).promise(defer);
    m_this.next_item();
    return defer;
  };

  /**
   * Add an item to the queue.  If batches are being used, add it at after
   * other items in the same batch.
   *
   * @param {jQuery.Deferred} defer Deferred object to add to the queue.
   * @param {boolean} atEnd If falsy, add the item to the front of the queue
   *  if batching is turned off or at the end of the current batch if it is
   *  turned on.  If truthy, always add the item to the end of the queue.
   * @param {number} [pos] If specified, the current location in the queue of
   *   the object being added.  This avoids having to splice, push, or unshift
   *   the queue.
   */
  this._addToQueue = function (defer, atEnd, pos) {
    let move = atEnd ? m_this._queue.length - 1 : 0;
    defer.__fetchQueue._batch = m_this._batch;
    if (!atEnd && m_this._batch) {
      for (move = 0; move < m_this._queue.length - (pos === undefined ? 0 : 1); move += 1) {
        if (m_this._queue[move].__fetchQueue._batch !== m_this._batch) {
          break;
        }
      }
    }
    if (pos === undefined) {
      if (atEnd) {
        m_this._queue.push(defer);
      } else if (!move) {
        m_this._queue.unshift(defer);
      } else {
        m_this._queue.splice(move, 0, defer);
      }
    } else if (pos !== move) {
      const dir = pos < move ? 1 : -1;
      for (let i = pos; i !== move; i += dir) {
        m_this._queue[i] = m_this._queue[i + dir];
      }
      m_this._queue[move] = defer;
    }
  };

  /**
   * Get the position of a deferred object in the queue.
   *
   * @param {jQuery.Deferred} defer Deferred object to get the position of.
   * @returns {number} -1 if not in the queue, or the position in the queue.
   */
  this.get = function (defer) {
    return $.inArray(defer, m_this._queue);
  };

  /**
   * Remove a Deferred object from the queue.
   *
   * @param {jQuery.Deferred} defer Deferred object to add to the queue.
   * @returns {boolean} `true` if the object was removed.
   */
  this.remove = function (defer) {
    var pos = $.inArray(defer, m_this._queue);
    if (pos >= 0) {
      m_this._queue.splice(pos, 1);
      return true;
    }
    return false;
  };

  /**
   * Start a new batch or clear using batches.
   *
   * @param {boolean} start Truthy to start a new batch, falsy to turn off
   *   using batches.  `undefined` to return the current state of batches.
   * @returns {number|boolean|this} `false` if batches are turned off, the
   *   batch number if turned on, or `this` if setting the batch.
   */
  this.batch = function (start) {
    if (start === undefined) {
      return m_this._batch;
    }
    if (!start) {
      m_this._batch = false;
    } else {
      m_this._batch = m_next_batch;
      m_next_batch += 1;
    }
    return m_this;
  };

  /**
   * Check if any items are queued and if the processing allotment is not
   * full.  If so, process more items.
   */
  this.next_item = function () {
    if (m_this._innextitem) {
      return;
    }
    m_this._innextitem = true;
    /* if the queue is greater than the track size, check each item to see
     * if it is still needed. */
    if (m_this._queue.length > m_this._track && m_this._needed) {
      for (var i = m_this._queue.length - 1; i >= 0; i -= 1) {
        if (!m_this._needed(m_this._queue[i])) {
          var discard = m_this._queue.splice(i, 1)[0];
          m_this._processing += 1;
          discard.__fetchQueue.reject();
          delete discard.__fetchQueue;
        }
      }
    }
    while (m_this._processing < (m_this._initialSize || m_this._size) && m_this._queue.length) {
      var defer = m_this._queue.shift();
      if (defer.__fetchQueue) {
        m_this._processing += 1;
        var needed = m_this._needed ? m_this._needed(defer) : true;
        if (needed) {
          defer.__fetchQueue.resolve();
        } else {
          defer.__fetchQueue.reject();
        }
        delete defer.__fetchQueue;
      }
    }
    m_this._innextitem = false;
  };

  this.clear();
  return this;
};

module.exports = fetchQueue;