mixpanel-browser
Version:
The official Mixpanel JavaScript browser client library
367 lines (340 loc) • 13.9 kB
JavaScript
import { SharedLock } from './shared-lock';
import { batchedThrottle, cheap_guid, console_with_prefix, localStorageSupported, _ } from './utils'; // eslint-disable-line camelcase
import { window } from './window';
import { LocalStorageWrapper } from './storage/local-storage';
import { Promise } from './promise-polyfill';
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.usePersistence = options.usePersistence;
if (this.usePersistence) {
this.queueStorage = options.queueStorage || new LocalStorageWrapper();
this.lock = new SharedLock(storageKey, {
storage: options.sharedLockStorage || window.localStorage,
timeoutMS: options.sharedLockTimeoutMS,
});
}
this.reportError = options.errorReporter || _.bind(logger.error, logger);
this.pid = options.pid || null; // pass pid to test out storage lock contention scenarios
this.memQueue = [];
this.initialized = false;
if (options.enqueueThrottleMs) {
this.enqueuePersisted = batchedThrottle(_.bind(this._enqueuePersisted, this), options.enqueueThrottleMs);
} else {
this.enqueuePersisted = _.bind(function (queueEntry) {
return this._enqueuePersisted([queueEntry]);
}, this);
}
};
RequestQueue.prototype.ensureInit = function () {
if (this.initialized) {
return Promise.resolve();
}
return this.queueStorage
.init()
.then(_.bind(function () {
this.initialized = true;
}, this))
.catch(_.bind(function (err) {
this.reportError('Error initializing queue persistence. Disabling persistence', err);
this.initialized = true;
this.usePersistence = false;
}, this));
};
/**
* 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) {
var queueEntry = {
'id': cheap_guid(),
'flushAfter': new Date().getTime() + flushInterval * 2,
'payload': item
};
if (!this.usePersistence) {
this.memQueue.push(queueEntry);
return Promise.resolve(true);
} else {
return this.enqueuePersisted(queueEntry);
}
};
RequestQueue.prototype._enqueuePersisted = function (queueEntries) {
var enqueueItem = _.bind(function () {
return this.ensureInit()
.then(_.bind(function () {
return this.readFromStorage();
}, this))
.then(_.bind(function (storedQueue) {
return this.saveToStorage(storedQueue.concat(queueEntries));
}, this))
.then(_.bind(function (succeeded) {
// only add to in-memory queue when storage succeeds
if (succeeded) {
this.memQueue = this.memQueue.concat(queueEntries);
}
return succeeded;
}, this))
.catch(_.bind(function (err) {
this.reportError('Error enqueueing items', err, queueEntries);
return false;
}, this));
}, this);
return this.lock
.withLock(enqueueItem, this.pid)
.catch(_.bind(function (err) {
this.reportError('Error acquiring storage lock', err);
return false;
}, this));
};
/**
* 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 (this.usePersistence && 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
return this.ensureInit()
.then(_.bind(function () {
return this.readFromStorage();
}, this))
.then(_.bind(function (storedQueue) {
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;
}, this));
} else {
return Promise.resolve(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) {
var idSet = {}; // poor man's Set
_.each(ids, function (id) {
idSet[id] = true;
});
this.memQueue = filterOutIDsAndInvalid(this.memQueue, idSet);
if (!this.usePersistence) {
return Promise.resolve(true);
} else {
var removeFromStorage = _.bind(function () {
return this.ensureInit()
.then(_.bind(function () {
return this.readFromStorage();
}, this))
.then(_.bind(function (storedQueue) {
storedQueue = filterOutIDsAndInvalid(storedQueue, idSet);
return this.saveToStorage(storedQueue);
}, this))
.then(_.bind(function () {
return this.readFromStorage();
}, this))
.then(_.bind(function (storedQueue) {
// an extra check: did storage report success but somehow
// the items are still there?
for (var i = 0; i < storedQueue.length; i++) {
var item = storedQueue[i];
if (item['id'] && !!idSet[item['id']]) {
throw new Error('Item not removed from storage');
}
}
return true;
}, this))
.catch(_.bind(function (err) {
this.reportError('Error removing items', err, ids);
return false;
}, this));
}, this);
return this.lock
.withLock(removeFromStorage, this.pid)
.catch(_.bind(function (err) {
this.reportError('Error acquiring storage lock', err);
if (!localStorageSupported(this.lock.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.
return removeFromStorage()
.then(_.bind(function (success) {
if (!success) {
// OK, we couldn't even write out the smaller queue. Try clearing it
// entirely.
return this.queueStorage.removeItem(this.storageKey).then(function () {
return success;
});
}
return success;
}, this))
.catch(_.bind(function (err) {
this.reportError('Error clearing queue', err);
return false;
}, this));
} else {
return false;
}
}, this));
}
};
// 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) {
this.memQueue = updatePayloads(this.memQueue, itemsToUpdate);
if (!this.usePersistence) {
return Promise.resolve(true);
} else {
return this.lock
.withLock(_.bind(function lockAcquired() {
return this.ensureInit()
.then(_.bind(function () {
return this.readFromStorage();
}, this))
.then(_.bind(function (storedQueue) {
storedQueue = updatePayloads(storedQueue, itemsToUpdate);
return this.saveToStorage(storedQueue);
}, this))
.catch(_.bind(function (err) {
this.reportError('Error updating items', itemsToUpdate, err);
return false;
}, this));
}, this), this.pid)
.catch(_.bind(function (err) {
this.reportError('Error acquiring storage lock', err);
return false;
}, this));
}
};
/**
* Read and parse items array from localStorage entry, handling
* malformed/missing data if necessary.
*/
RequestQueue.prototype.readFromStorage = function () {
return this.ensureInit()
.then(_.bind(function () {
return this.queueStorage.getItem(this.storageKey);
}, this))
.then(_.bind(function (storageEntry) {
if (storageEntry) {
if (!_.isArray(storageEntry)) {
this.reportError('Invalid storage entry:', storageEntry);
storageEntry = null;
}
}
return storageEntry || [];
}, this))
.catch(_.bind(function (err) {
this.reportError('Error retrieving queue', err);
return [];
}, this));
};
/**
* Serialize the given items array to localStorage.
*/
RequestQueue.prototype.saveToStorage = function (queue) {
return this.ensureInit()
.then(_.bind(function () {
return this.queueStorage.setItem(this.storageKey, queue);
}, this))
.then(function () {
return true;
})
.catch(_.bind(function (err) {
this.reportError('Error saving queue', err);
return false;
}, this));
};
/**
* Clear out queues (memory and localStorage).
*/
RequestQueue.prototype.clear = function () {
this.memQueue = [];
if (this.usePersistence) {
return this.ensureInit()
.then(_.bind(function () {
return this.queueStorage.removeItem(this.storageKey);
}, this));
} else {
return Promise.resolve();
}
};
export { RequestQueue };