baja/comm/Batch.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Gareth Johnson
 */

/**
 * Defines {@link baja.comm.Batch}.
 * @module baja/comm/Batch
 */
define([ "bajaScript/baja/sys/BaseBajaObj",
        "bajaScript/baja/comm/BoxError",
        "bajaScript/baja/comm/BoxFrame",
        "bajaScript/sys",
        "bajaPromises" ], function (
         BaseBajaObj,
         BoxError,
         BoxFrame,
         baja,
         bajaPromises) {
  
  "use strict";
  
  var subclass = baja.subclass,

      REQUEST_MESSAGE_TYPE = 'rt',
      VALID_RESPONSE_MESSAGE_TYPE = 'rp',
      ERROR_MESSAGE_TYPE = 'e';

  /**
   * A Batch is used to "package up" a number of operations (such as setting a
   * Slot value or invoking an Action) into a single network call. Use a Batch
   * when you want to be absolutely certain that a group of operations will all
   * arrive at the server at the same time, be processed on their own (not
   * alongside any other operations that are not part of this Batch), and be
   * executed on the server synchronously.
   *
   * When interacting with a JACE, it is important to keep the number of
   * individual network calls low. This is because the JACE has to do some work
   * to receive an HTTP request, whether that request is one byte or 10,000
   * bytes. Therefore, by packaging requests into fewer network calls, we reduce
   * the CPU load on the JACE and improve response times in the browser.
   *
   * Previous to Niagara 4.10, it was _required_ to use a Batch to get
   * individual operations to package up into a single network call. However,
   * Batches can be easy to forget and add additional complexity to your code.
   * Therefore, starting in Niagara 4.10, BajaScript will _automatically_
   * package operations together. Using a technique called "implicit batching",
   * operations that occur chronologically close together in the browser will
   * get packaged up into a single network call. As of Niagara 4.10, you can
   * consider the use of Batches purely for network efficiency as deprecated -
   * BajaScript will make network calls efficient by default.
   *
   * When operations are batched together, it does not imply any sort of error
   * handling. A Batch is not a replacement for `Promise.all`. Its only job is
   * to cut down on network traffic. As long as the one network call
   * successfully goes out over the wire and receives a response, each operation
   * will still succeed or fail individually.
   *
   * Most operations in BajaScript can be batched together. To use a Batch,
   * you'll typically pass it in as an argument to a function that supports it.
   * Please see the examples.
   *
   * @class
   * @alias baja.comm.Batch
   * @extends baja.BaseBajaObj
   *
   * @example
   * <caption>Invoking two actions, using a Batch to make it explicit that the
   * actions should execute on the server at the same time.</caption>
   *
   * var batch = new baja.comm.Batch();
   *
   * var promise = Promise.all([
   *   myComp.invoke({ slot: 'thisActionWillSucceed', batch: batch })
   *     .then(function () { console.log('this message will show'); }),
   *   myComp2.invoke({ slot: 'thisActionWillFailOnTheStation', batch: batch })
   *     .catch(function (err) { console.log('this error will show: ' + err); })
   * ]);
   *
   * // Make a single network call that will invoke both Actions in one go.
   * // Just because the second action fails on the station and sends back an
   * // error message, the first action didn't fail along with it just
   * // because they shared a Batch.
   * batch.commit();
   * return promise;
   *
   * @example
   * <caption>batch.commit() returns its argument. Use this to improve the
   * brevity and readability of your code.</caption>
   *
   * var batch = new baja.comm.Batch();
   *
   * return batch.commit(Promise.all([
   *   comp.invoke({ slot: 'action1', batch: batch }),
   *   comp.invoke({ slot: 'action2', batch: batch })
   * ]));
   *
   * @example
   * <caption>Here, our only concern is network efficiency. We can call set()
   * as many times as we want. Because we queue everything up at the same time,
   * the set() operations will automatically batch together into a single
   * network call. If other operations also execute at the same time as these
   * set() calls, it's possible they could all also be packaged into the same
   * batch. Prior to Niagara 4.10, each un-batched set() would cause its own
   * network call.</caption>
   *
   * return Promise.all([
   *   comp.set({ slot: 'slot1', value: 'value1' }),
   *   comp.set({ slot: 'slot2', value: 'value2' }),
   *   comp.set({ slot: 'slot3', value: 'value3' })
   * ]);
   *
   * @example
   * <caption>Here, these two set() calls may not implicitly batch together
   * because they don't happen close to each other chronologically. They will
   * probably go up to the station in two separate network calls.</caption>
   *
   * return Promise.all([
   *   comp.set({ slot: 'slot1', value: 'value1' }),
   *   waitSeconds(2)
   *     .then(function () {
   *       return comp.set({ slot: 'slot2', value: 'value2' });
   *     });
   * ]);
   *
   * @example
   * <caption>The size of a WebSocket message is limited. If there are too many
   * batched operations to fit into a single WebSocket message, BajaScript will
   * automatically split the batch into multiple network calls. They will be
   * reassembled on the other end, and the batch will be processed as normal.
   * Prior to Niagara 4.10, exceeding the WebSocket message size would result in
   * an error.</caption>
   *
   * function addTenThousandSlots(comp) {
   *   var promises = [];
   *   for (var i = 0; i < 10000; ++i) {
   *     promises.push(comp.add({ slot: 'number?', value: i }));
   *   }
   *   return Promise.all(promises);
   * }
   */
  var Batch = function Batch() {
    this.$queue = [];
    this.$reserves = [];
    this.$committed = false;
    this.$async = true;
    this.$queueOnCommit = false;
  };

  subclass(Batch, BaseBajaObj);
      
  /**
   * Add a BOX request message to the Batch Buffer.
   * 
   * This method is used internally by BajaScript.
   *
   * @private
   *
   * @param {String} channel  the BOX Channel name.
   * @param {String} key  the BOX key in the Channel.
   * @param {Object} body  the object that will be encoded to JSON set over the network.
   * @param {baja.comm.Callback} callback  the callback. 'ok' or 'fail' is called on this object 
   * once network operations have completed.
   * @param {Boolean} [queueOnCommit] An optional flag that indicates whether the request should
   * be queued when committed. By default, requests are not queued when committed.
   */
  Batch.prototype.addReq = function (channel, key, body, callback, queueOnCommit) {
    var that = this,
        m;

    if (that.$committed) {
      throw new Error("Cannot add request to a committed Batch!");
    }
            
    m = {
      r: baja.comm.incrementRequestId(),
      t: "rt",
      c: channel,
      k: key,
      b: body
    };        
                    
    // Add messages
    that.$queue.push({ m: m, cb: callback });

    // If one message needs to be queued then queue everything in the batch.
    if (queueOnCommit) {
      that.$queueOnCommit = queueOnCommit;
    } 
  };
  
  /**
   * Add a Callback.
   * 
   * This adds a callback into the batch queue without a message to be sent. 
   * This is useful if a callback needs to be made halfway through batch 
   * processing.
   * 
   * Please note, this is a private method that's only recommended for use by 
   * Tridium developers!

   * @private
   *
   * @param {baja.comm.Callback} cb the callback
   */
  Batch.prototype.addCallback = function (cb) {
    if (this.$committed) {
      throw new Error("Cannot add callback to a committed Batch!");
    }
      
    // Add callback to outgoing messages
    this.$queue.push({ m: null, cb: cb });
  };
  
  function handleResponses(requests, responses) {
    if (!requests.length) {
      return;
    }
    
    var request = requests[0],
        requestMessage = request.m,
        requestMessageType = requestMessage && requestMessage.t,
        cb = request.cb;
    
    try {
      /**
       * When ok or fail has been called on this callback, process the next item in the queue
       * @ignore
       */
      cb.$batchNext = function () {
        cb.$batchNext = baja.noop;
        handleResponses(requests.slice(1), responses);
      };      
      

      // For callbacks that didn't have messages just call the ok handler
      if (requestMessageType !== REQUEST_MESSAGE_TYPE) {
        return tryOk(cb);
      }
      
      var requestMessageNumber = requestMessage && requestMessage.r,
          responseMessage = findByMessageNumber(responses, requestMessageNumber);

      if (responseMessage) {
        handleResponse(responseMessage, cb);
      } else {
        cb.fail(new Error('BOX Error: response not found for request: ' +
          JSON.stringify(requestMessage)));
      }
    } catch (failError) {
      baja.error(failError);
    }
  }
  
  function failAllRequests(requests, err) {
    if (!requests.length) {
      return;
    }
    
    var cb = requests[0].cb;
    
    try {
      /**
       * When ok or fail has been called on this callback, process the next item in the queue
       * @ignore
       */
      cb.$batchNext = function () {
        cb.$batchNext = baja.noop;
        failAllRequests(requests.slice(1), err);
      };
      
      cb.fail(err);
    } catch (failError) {
      baja.error(failError);
    }
  }

  function handleResponse(responseMessage, cb) {
    var responseType = responseMessage.t,
        responseBody = responseMessage.b;

    if (responseType === VALID_RESPONSE_MESSAGE_TYPE) {
      tryOk(cb, responseBody);
    } else if (responseType === ERROR_MESSAGE_TYPE) {
      cb.fail(toBoxError(responseMessage));
    } else {
      cb.fail(new Error('Unknown message type in BOX frame: ' +
        JSON.stringify(responseMessage)));
    }
  }
            
  /**
   * Sends all BOX requests queued up in this batch across the wire in one
   * network call.
   *
   * Once called, this batch can no longer have messages or callbacks added, or
   * be committed a second time.
   *
   * @param {*} [arg] an input argument to be returned directly - see example
   * @returns {*} input arg
   * @throws {Error} if already committed
   * 
   * @example
   * <caption>Returning the input arg enables a bit of API cleanliness when
   * returning promises.</caption>
   *
   * //instead of this...
   * var promise = doABunchOfNetworkCalls(batch);
   * batch.commit();
   * return promise;
   *
   * //do this:
   * return batch.commit(doABunchOfNetworkCalls(batch));
   */
  Batch.prototype.commit = function (arg) {
    // If BajaScript has fully stopped then don't send anymore comms requests...
    if (baja.isStopped()) {
      return arg;
    }

    var that = this;

    if (that.$committed) {
      throw new Error("Cannot commit batch that's already committed!");
    }

    that.$committed = true;
    
    if (!that.$reserves.length) {
      sendFrame(that.$queue, that.$sync, that.$queueOnCommit, that);
    } else {
      resolveAllReserves(this)
        .then(function (queues) {
          var allRequests = flatten(that.$queue.concat(queues));

          return sendFrame(allRequests, false, true, {
            ok: function (resp) {
              return handleResponses(allRequests, resp.m);
            },
            fail: function (err) {
              return that.fail(err);
            }
          });
        })
        .catch(baja.error);
    }
    
    return arg;
  };

  /**
   * @private
   * @returns {Array.<Object>} a copy of the Batch's messages array.
   */
  Batch.prototype.getMessages = function () {
    return this.$queue.slice(0);
  };  
  
  /**
   * Ok callback invoked once the network call has successfully completed.
   *
   * @private
   * @param {Object} resp the response JSON.
   */
  Batch.prototype.ok = function (resp) { 
    if (baja.isStopped()) {
      return;
    }
       
    if (!resp || resp.p !== "box") {
      this.fail("Invalid BOX Frame. Protocol is not BOX");
      return;
    }
    
    // Process the response
    handleResponses(this.$queue, resp.m);
  };
  
  /**
   * Fail callback invoked if the Batch fails to send, due to network error or
   * other unexpected condition. (This does _not_ include when a BOX operation
   * was sent to the server, failed there, and successfully returned us an
   * error.)
   *
   * @private
   * @param err  the cause of the error.
   */
  Batch.prototype.fail = function (err) { 
    if (baja.isStopping()) {
      return;
    }  
    
    // Fail all messages with error since the batch itself failed
    failAllRequests(this.$queue, err);
  };
        
  /**
   * @private
   * @returns {Boolean} true if this Batch object has no messages to send.
   */
  Batch.prototype.isEmpty = function () {
    return this.$queue.length === 0;
  };
  
  /**
   * @private
   * @returns {Boolean} true if this Batch has already been committed.
   */
  Batch.prototype.isCommitted = function () {
    return this.$committed;
  };

  /**
   * Reserve a new batch. If an input batch is given (either directly or as a
   * `batch` property of an object), it will create a new reserve batch off
   * of that batch. If no input batch is given, this will simply return a new
   * batch.
   * 
   * Use this when your function _might_ have an input batch parameter that you
   * want to make use of while still leaving the question of when to commit the
   * batch up to the caller.
   * 
   * @param {baja.comm.Batch|object} [params] If a batch is given, will reserve
   * a new batch off of the input batch. Can also be `params.batch`. Otherwise,
   * will create a new batch.
   * @param {baja.comm.Batch} [params.batch]
   * @returns {baja.comm.Batch} either a reserved, or a new batch. It is your
   * responsibility as the caller to `Batch.reserve` to commit this batch.
   */
  Batch.reserve = function (params) {
    var batch = params instanceof Batch ? params : params && params.batch;
    return batch ? batch.reserve() : new Batch();
  };

  /**
   * Creates a new batch that will prevent the current batch from sending any
   * network calls until the reserve batch itself has been committed. All
   * requests on all reserve batches, as well as the current batch, will be
   * condensed into a single network call and sent as soon as all reserve
   * batches are committed.
   *
   * Use this function when you need to add requests to a batch after completing
   * some other asynchronous operation. It signals to the original batch, "wait
   * for me!"
   *
   * The reserved batch's `commit()` function will not actually send any data.
   * It simply signals that your code has finished adding requests to it and
   * the original batch is clear to send its data.
   *
   * @returns {baja.comm.Batch}
   * @example
   * <caption>Given a set of relation knobs, I need to resolve each knob's
   * relation ORD and retrieve all relations from the source component.
   * I happen to know that the relation ORDs have already been resolved, so
   * resolving the ORD will not incur a network call, but retrieving relations
   * always does. Therefore, I want to reserve the batch to use on the
   * relations() call.</caption>
   *
   * function retrieveSourceComponentRelations(relationKnob, inpBatch) {
   *    var reserved = inpBatch.reserve();
   *    return relationKnob.getRelationOrd().get()
   *      .then(function (relationSourceComponent) {
   *        //if I had not reserved a batch, inpBatch would have already been committed
   *        //by the caller, and this would throw an error.
   *        var relationsPromise = relationSourceComponent.relations({ batch: reserved });
   *
   *        //calling commit() on the reserved batch notifies inpBatch that I'm done
   *        //adding operations, and inpBatch is clear to go ahead and send out the
   *        //network call.
   *        reserved.commit();
   *
   *        return relationsPromise;
   *      });
   * }
   *
   * //now all relations() calls will batch together into a single network call.
   * var batch = new baja.comm.Batch(),
   *     promise = Promise.all(allRelationKnobs.map(function (relationKnob) {
   *       return retrieveSourceComponentRelations(relationKnob, batch);
   *     }))
   *       .then(function (relationsResults) {
   *         relationsResults.forEach(function (relations) { handle(relations); });
   *       });
   * batch.commit();
   */
  Batch.prototype.reserve = function () {
    if (this.isCommitted()) {
      throw new Error('cannot reserve from a committed batch');
    }
    var reserve = new ReserveBatch();
    this.$reserves.push(reserve);
    return reserve;
  };

  function ReserveBatch() {
    Batch.apply(this, arguments);
    this.$df = bajaPromises.deferred();
  }
  ReserveBatch.prototype = Object.create(Batch.prototype);
  ReserveBatch.prototype.constructor = ReserveBatch;

  ReserveBatch.prototype.commit = function (arg) {
    this.$committed = true;
    this.$df.resolve(this.$queue.slice());
    return arg;
  };

  function flatten(arrays) {
    var a = [];
    for (var i = 0; i < arrays.length; i++) {
      a = a.concat(arrays[i]);
    }
    return a;
  }

  function sendFrame(queue, sync, queueOnCommit, cb) {
    if (!queue.length) {
      return;
    }

    var frame = new BoxFrame(queue);
    // Set hidden sync flag.
    frame.$sync = sync;
    // Set whether the frame should be queued or sent straight away.
    frame.$queue = queueOnCommit;
    frame.send(cb);
  }

  function getAllReserves(batch) {
    var reserves = batch.$reserves;
    for (var i = 0; i < reserves.length; i++) {
      reserves = reserves.concat(getAllReserves(reserves[i]));
    }
    return reserves;
  }
  
  function resolveAllReserves(batch) {
    var allReserves = getAllReserves(batch);
    return bajaPromises.all(allReserves.map(function (r) {
      return r.$df.promise();
    }))
      .then(function (queues) {
        //catch any new reserves that were added before all existing
        //reserves were committed
        if (getAllReserves(batch).length > allReserves.length) {
          return resolveAllReserves(batch);
        } else {
          return queues;
        }
      });
  }


  function tryOk(cb, arg) {
    try {
      cb.ok(arg);
    } catch (err) {
      cb.fail(err);
    }
  }

  function findByMessageNumber(responses, num) {
    for (var i = 0; i < responses.length; i++) {
      if (responses[i].r === num) { return responses[i]; }
    }
  }

  function toBoxError(responseMessage) {
    var exceptionType = responseMessage.et,
        body = responseMessage.b,
        isCommsFailure = responseMessage.cf,
        boxError = BoxError.decodeFromServer(exceptionType, body);

    if (isCommsFailure) {
      // If the comms have failed then don't bother trying to reconnect
      boxError.noReconnect = true;

      // Flag up some more information about the BOX Error
      boxError.sessionLimit = exceptionType === "BoxSessionLimitError";
      boxError.fatalFault = exceptionType === "BoxFatalFaultError";
      boxError.nonOperational = exceptionType === "BoxNonOperationalError";

      //TODO: do this OUTSIDE the batch
      baja.comm.serverCommFail(boxError);
    }

    return boxError;
  }

  return Batch;
});