mixpanel-browser
Version:
The official Mixpanel JavaScript browser client library
282 lines (261 loc) • 10.1 kB
JavaScript
import { SharedLock } from './shared-lock';
import { cheap_guid, console_with_prefix, localStorageSupported, JSONParse, JSONStringify, _ } from './utils'; // eslint-disable-line camelcase
var logger = console_with_prefix('batch');
/**
* RequestQueue: queue for batching API requests with localStorage backup for retries.
* Maintains an in-memory queue which represents the source of truth for the current
* page, but also writes all items out to a copy in the browser's localStorage, which
* can be read on subsequent pageloads and retried. For batchability, all the request
* items in the queue should be of the same type (events, people updates, group updates)
* so they can be sent in a single request to the same API endpoint.
*
* LocalStorage keying and locking: In order for reloads and subsequent pageloads of
* the same site to access the same persisted data, they must share the same localStorage
* key (for instance based on project token and queue type). Therefore access to the
* localStorage entry is guarded by an asynchronous mutex (SharedLock) to prevent
* simultaneously open windows/tabs from overwriting each other's data (which would lead
* to data loss in some situations).
* @constructor
*/
var RequestQueue = function(storageKey, options) {
options = options || {};
this.storageKey = storageKey;
this.storage = options.storage || window.localStorage;
this.reportError = options.errorReporter || _.bind(logger.error, logger);
this.lock = new SharedLock(storageKey, {storage: this.storage});
this.pid = options.pid || null; // pass pid to test out storage lock contention scenarios
this.memQueue = [];
};
/**
* Add one item to queues (memory and localStorage). The queued entry includes
* the given item along with an auto-generated ID and a "flush-after" timestamp.
* It is expected that the item will be sent over the network and dequeued
* before the flush-after time; if this doesn't happen it is considered orphaned
* (e.g., the original tab where it was enqueued got closed before it could be
* sent) and the item can be sent by any tab that finds it in localStorage.
*
* The final callback param is called with a param indicating success or
* failure of the enqueue operation; it is asynchronous because the localStorage
* lock is asynchronous.
*/
RequestQueue.prototype.enqueue = function(item, flushInterval, cb) {
var queueEntry = {
'id': cheap_guid(),
'flushAfter': new Date().getTime() + flushInterval * 2,
'payload': item
};
this.lock.withLock(_.bind(function lockAcquired() {
var succeeded;
try {
var storedQueue = this.readFromStorage();
storedQueue.push(queueEntry);
succeeded = this.saveToStorage(storedQueue);
if (succeeded) {
// only add to in-memory queue when storage succeeds
this.memQueue.push(queueEntry);
}
} catch(err) {
this.reportError('Error enqueueing item', item);
succeeded = false;
}
if (cb) {
cb(succeeded);
}
}, this), _.bind(function lockFailure(err) {
this.reportError('Error acquiring storage lock', err);
if (cb) {
cb(false);
}
}, this), this.pid);
};
/**
* Read out the given number of queue entries. If this.memQueue
* has fewer than batchSize items, then look for "orphaned" items
* in the persisted queue (items where the 'flushAfter' time has
* already passed).
*/
RequestQueue.prototype.fillBatch = function(batchSize) {
var batch = this.memQueue.slice(0, batchSize);
if (batch.length < batchSize) {
// don't need lock just to read events; localStorage is thread-safe
// and the worst that could happen is a duplicate send of some
// orphaned events, which will be deduplicated on the server side
var storedQueue = this.readFromStorage();
if (storedQueue.length) {
// item IDs already in batch; don't duplicate out of storage
var idsInBatch = {}; // poor man's Set
_.each(batch, function(item) { idsInBatch[item['id']] = true; });
for (var i = 0; i < storedQueue.length; i++) {
var item = storedQueue[i];
if (new Date().getTime() > item['flushAfter'] && !idsInBatch[item['id']]) {
item.orphaned = true;
batch.push(item);
if (batch.length >= batchSize) {
break;
}
}
}
}
}
return batch;
};
/**
* Remove items with matching 'id' from array (immutably)
* also remove any item without a valid id (e.g., malformed
* storage entries).
*/
var filterOutIDsAndInvalid = function(items, idSet) {
var filteredItems = [];
_.each(items, function(item) {
if (item['id'] && !idSet[item['id']]) {
filteredItems.push(item);
}
});
return filteredItems;
};
/**
* Remove items with matching IDs from both in-memory queue
* and persisted queue
*/
RequestQueue.prototype.removeItemsByID = function(ids, cb) {
var idSet = {}; // poor man's Set
_.each(ids, function(id) { idSet[id] = true; });
this.memQueue = filterOutIDsAndInvalid(this.memQueue, idSet);
var removeFromStorage = _.bind(function() {
var succeeded;
try {
var storedQueue = this.readFromStorage();
storedQueue = filterOutIDsAndInvalid(storedQueue, idSet);
succeeded = this.saveToStorage(storedQueue);
// an extra check: did storage report success but somehow
// the items are still there?
if (succeeded) {
storedQueue = this.readFromStorage();
for (var i = 0; i < storedQueue.length; i++) {
var item = storedQueue[i];
if (item['id'] && !!idSet[item['id']]) {
this.reportError('Item not removed from storage');
return false;
}
}
}
} catch(err) {
this.reportError('Error removing items', ids);
succeeded = false;
}
return succeeded;
}, this);
this.lock.withLock(function lockAcquired() {
var succeeded = removeFromStorage();
if (cb) {
cb(succeeded);
}
}, _.bind(function lockFailure(err) {
var succeeded = false;
this.reportError('Error acquiring storage lock', err);
if (!localStorageSupported(this.storage, true)) {
// Looks like localStorage writes have stopped working sometime after
// initialization (probably full), and so nobody can acquire locks
// anymore. Consider it temporarily safe to remove items without the
// lock, since nobody's writing successfully anyway.
succeeded = removeFromStorage();
if (!succeeded) {
// OK, we couldn't even write out the smaller queue. Try clearing it
// entirely.
try {
this.storage.removeItem(this.storageKey);
} catch(err) {
this.reportError('Error clearing queue', err);
}
}
}
if (cb) {
cb(succeeded);
}
}, this), this.pid);
};
// internal helper for RequestQueue.updatePayloads
var updatePayloads = function(existingItems, itemsToUpdate) {
var newItems = [];
_.each(existingItems, function(item) {
var id = item['id'];
if (id in itemsToUpdate) {
var newPayload = itemsToUpdate[id];
if (newPayload !== null) {
item['payload'] = newPayload;
newItems.push(item);
}
} else {
// no update
newItems.push(item);
}
});
return newItems;
};
/**
* Update payloads of given items in both in-memory queue and
* persisted queue. Items set to null are removed from queues.
*/
RequestQueue.prototype.updatePayloads = function(itemsToUpdate, cb) {
this.memQueue = updatePayloads(this.memQueue, itemsToUpdate);
this.lock.withLock(_.bind(function lockAcquired() {
var succeeded;
try {
var storedQueue = this.readFromStorage();
storedQueue = updatePayloads(storedQueue, itemsToUpdate);
succeeded = this.saveToStorage(storedQueue);
} catch(err) {
this.reportError('Error updating items', itemsToUpdate);
succeeded = false;
}
if (cb) {
cb(succeeded);
}
}, this), _.bind(function lockFailure(err) {
this.reportError('Error acquiring storage lock', err);
if (cb) {
cb(false);
}
}, this), this.pid);
};
/**
* Read and parse items array from localStorage entry, handling
* malformed/missing data if necessary.
*/
RequestQueue.prototype.readFromStorage = function() {
var storageEntry;
try {
storageEntry = this.storage.getItem(this.storageKey);
if (storageEntry) {
storageEntry = JSONParse(storageEntry);
if (!_.isArray(storageEntry)) {
this.reportError('Invalid storage entry:', storageEntry);
storageEntry = null;
}
}
} catch (err) {
this.reportError('Error retrieving queue', err);
storageEntry = null;
}
return storageEntry || [];
};
/**
* Serialize the given items array to localStorage.
*/
RequestQueue.prototype.saveToStorage = function(queue) {
try {
this.storage.setItem(this.storageKey, JSONStringify(queue));
return true;
} catch (err) {
this.reportError('Error saving queue', err);
return false;
}
};
/**
* Clear out queues (memory and localStorage).
*/
RequestQueue.prototype.clear = function() {
this.memQueue = [];
this.storage.removeItem(this.storageKey);
};
export { RequestQueue };