/**
* @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;
});