/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ this.EXPORTED_SYMBOLS = ['GlodaCollection', 'GlodaCollectionManager']; var Cc = Components.classes; var Ci = Components.interfaces; var Cr = Components.results; var Cu = Components.utils; Cu.import("resource:///modules/gloda/log4moz.js"); var LOG = Log4Moz.repository.getLogger("gloda.collection"); /** * @namespace Central registry and logic for all collections. * * The collection manager is a singleton that has the following tasks: * - Let views of objects (nouns) know when their objects have changed. For * example, an attribute has changed due to user action. * - Let views of objects based on queries know when new objects match their * query, or when their existing objects no longer match due to changes. * - Caching/object-identity maintenance. It is ideal if we only ever have * one instance of an object at a time. (More specifically, only one instance * per database row 'id'.) The collection mechanism lets us find existing * instances to this end. Caching can be directly integrated by being treated * as a special collection. */ var GlodaCollectionManager = { _collectionsByNoun: {}, _cachesByNoun: {}, /** * Registers the existence of a collection with the collection manager. This * is done using a weak reference so that the collection can go away if it * wants to. */ registerCollection: function gloda_colm_registerCollection(aCollection) { let collections; let nounID = aCollection.query._nounDef.id; if (!(nounID in this._collectionsByNoun)) collections = this._collectionsByNoun[nounID] = []; else { // purge dead weak references while we're at it collections = this._collectionsByNoun[nounID].filter(function (aRef) { return aRef.get(); }); this._collectionsByNoun[nounID] = collections; } collections.push(Cu.getWeakReference(aCollection)); }, getCollectionsForNounID: function gloda_colm_getCollectionsForNounID(aNounID){ if (!(aNounID in this._collectionsByNoun)) return []; // generator would be nice, but I suspect get() is too expensive to use // twice (guard/predicate and value) let weakCollections = this._collectionsByNoun[aNounID]; let collections = []; for (let iColl = 0; iColl < weakCollections.length; iColl++) { let collection = weakCollections[iColl].get(); if (collection) collections.push(collection); } return collections; }, defineCache: function gloda_colm_defineCache(aNounDef, aCacheSize) { this._cachesByNoun[aNounDef.id] = new GlodaLRUCacheCollection(aNounDef, aCacheSize); }, /** * Attempt to locate an instance of the object of the given noun type with the * given id. Counts as a cache hit if found. (And if it was't in a cache, * but rather a collection, it is added to the cache.) */ cacheLookupOne: function gloda_colm_cacheLookupOne(aNounID, aID, aDoCache) { let cache = this._cachesByNoun[aNounID]; if (cache) { if (aID in cache._idMap) { let item = cache._idMap[aID]; return cache.hit(item); } } if (aDoCache === false) cache = null; for (let collection of this.getCollectionsForNounID(aNounID)) { if (aID in collection._idMap) { let item = collection._idMap[aID]; if (cache) cache.add([item]); return item; } } return null; }, /** * Lookup multiple nouns by ID from the cache/existing collections. * * @param aNounID The kind of noun identified by its ID. * @param aIDMap A dictionary/map whose keys must be gloda noun ids for the * given noun type and whose values are ignored. * @param aTargetMap An object to hold the noun id's (key) and noun instances * (value) for the noun instances that were found available in memory * because they were cached or in existing query collections. * @param [aDoCache=true] Should we add any items to the cache that we found * in collections that were in memory but not in the cache? You would * likely want to pass false if you are only updating in-memory * representations rather than performing a new query. * * @return [The number that were found, the number that were not found, * a dictionary whose keys are the ids of noun instances that * were not found.] */ cacheLookupMany: function gloda_colm_cacheLookupMany(aNounID, aIDMap, aTargetMap, aDoCache) { let foundCount = 0, notFoundCount = 0, notFound = {}; let cache = this._cachesByNoun[aNounID]; if (cache) { for (let key in aIDMap) { let cacheValue = cache._idMap[key]; if (cacheValue === undefined) { notFoundCount++; notFound[key] = null; } else { foundCount++; aTargetMap[key] = cacheValue; cache.hit(cacheValue); } } } if (aDoCache === false) cache = null; for (let collection of this.getCollectionsForNounID(aNounID)) { for (let key in notFound) { let collValue = collection._idMap[key]; if (collValue !== undefined) { aTargetMap[key] = collValue; delete notFound[key]; foundCount++; notFoundCount--; if (cache) cache.add([collValue]); } } } return [foundCount, notFoundCount, notFound]; }, /** * Friendlier version of |cacheLookupMany|; takes a list of ids and returns * an object whose keys and values are the gloda id's and instances of the * instances that were found. We don't tell you who we didn't find. The * assumption is this is being used for in-memory updates where we only need * to tweak what is in memory. */ cacheLookupManyList: function gloda_colm_cacheLookupManyList(aNounID, aIds) { let checkMap = {}, targetMap = {}; for (let id of aIds) { checkMap[id] = null; } // do not promote found items into the cache this.cacheLookupMany(aNounID, checkMap, targetMap, false); return targetMap; }, /** * Attempt to locate an instance of the object of the given noun type with the * given id. Counts as a cache hit if found. (And if it was't in a cache, * but rather a collection, it is added to the cache.) */ cacheLookupOneByUniqueValue: function gloda_colm_cacheLookupOneByUniqueValue(aNounID, aUniqueValue, aDoCache) { let cache = this._cachesByNoun[aNounID]; if (cache) { if (aUniqueValue in cache._uniqueValueMap) { let item = cache._uniqueValueMap[aUniqueValue]; return cache.hit(item); } } if (aDoCache === false) cache = null; for (let collection of this.getCollectionsForNounID(aNounID)) { if (aUniqueValue in collection._uniqueValueMap) { let item = collection._uniqueValueMap[aUniqueValue]; if (cache) cache.add([item]); return item; } } return null; }, /** * Checks whether the provided item with the given id is actually a duplicate * of an instance that already exists in the cache/a collection. If it is, * the pre-existing instance is returned and counts as a cache hit. If it * is not, the passed-in instance is added to the cache and returned. */ cacheLoadUnifyOne: function gloda_colm_cacheLoadUnifyOne(aItem) { let items = [aItem]; this.cacheLoadUnify(aItem.NOUN_ID, items); return items[0]; }, /** * Given a list of items, check if any of them already have duplicate, * canonical, instances in the cache or collections. Items with pre-existing * instances are replaced by those instances in the provided list, and each * counts as a cache hit. Items without pre-existing instances are added * to the cache and left intact. */ cacheLoadUnify: function gloda_colm_cacheLoadUnify(aNounID, aItems, aCacheIfMissing) { let cache = this._cachesByNoun[aNounID]; if (aCacheIfMissing === undefined) aCacheIfMissing = true; // track the items we haven't yet found in a cache/collection (value) and // their index in aItems (key). We're somewhat abusing the dictionary // metaphor with the intent of storing tuples here. We also do it because // it allows random-access deletion theoretically without cost. (Since // we delete during iteration, that may be wrong, but it sounds like the // semantics still work?) let unresolvedIndexToItem = {}; let numUnresolved = 0; if (cache) { for (let iItem = 0; iItem < aItems.length; iItem++) { let item = aItems[iItem]; if (item.id in cache._idMap) { let realItem = cache._idMap[item.id]; // update the caller's array with the reference to the 'real' item aItems[iItem] = realItem; cache.hit(realItem); } else { unresolvedIndexToItem[iItem] = item; numUnresolved++; } } // we're done if everyone was a hit. if (numUnresolved == 0) return; } else { for (let iItem = 0; iItem < aItems.length; iItem++) { unresolvedIndexToItem[iItem] = aItems[iItem]; } numUnresolved = aItems.length; } let needToCache = []; // next, let's fall back to our collections for (let collection of this.getCollectionsForNounID(aNounID)) { for (let [iItem, item] in Iterator(unresolvedIndexToItem)) { if (item.id in collection._idMap) { let realItem = collection._idMap[item.id]; // update the caller's array to now have the 'real' object aItems[iItem] = realItem; // flag that we need to cache this guy (we use an inclusive cache) needToCache.push(realItem); // we no longer need to resolve this item... delete unresolvedIndexToItem[iItem]; // stop checking collections if we got everybody if (--numUnresolved == 0) break; } } } // anything left in unresolvedIndexToItem should be added to the cache // unless !aCacheIfMissing. plus, we already have 'needToCache' if (cache && aCacheIfMissing) { cache.add(needToCache.concat(Object.keys(unresolvedIndexToItem). map(key => unresolvedIndexToItem[key]))); } return aItems; }, cacheCommitDirty: function glod_colm_cacheCommitDirty() { for (let id in this._cachesByNoun) { let cache = this._cachesByNoun[id]; cache.commitDirty(); } }, /** * Notifies the collection manager that an item has been loaded and should * be cached, assuming caching is active. */ itemLoaded: function gloda_colm_itemsLoaded(aItem) { let cache = this._cachesByNoun[aItem.NOUN_ID]; if (cache) { cache.add([aItem]); } }, /** * Notifies the collection manager that multiple items has been loaded and * should be cached, assuming caching is active. */ itemsLoaded: function gloda_colm_itemsLoaded(aNounID, aItems) { let cache = this._cachesByNoun[aNounID]; if (cache) { cache.add(aItems); } }, /** * This should be called when items are added to the global database. This * should generally mean during indexing by indexers or an attribute * provider. * We walk all existing collections for the given noun type and add the items * to the collection if the item meets the query that defines the collection. */ itemsAdded: function gloda_colm_itemsAdded(aNounID, aItems) { let cache = this._cachesByNoun[aNounID]; if (cache) { cache.add(aItems); } for (let collection of this.getCollectionsForNounID(aNounID)) { let addItems = aItems.filter(item => collection.query.test(item)); if (addItems.length) collection._onItemsAdded(addItems); } }, /** * This should be called when items in the global database are modified. For * example, as a result of indexing. This should generally only be called * by indexers or by attribute providers. * We walk all existing collections for the given noun type. For items * currently included in each collection but should no longer be (per the * collection's defining query) we generate onItemsRemoved events. For items * not currently included in the collection but should now be, we generate * onItemsAdded events. For items included that still match the query, we * generate onItemsModified events. */ itemsModified: function gloda_colm_itemsModified(aNounID, aItems) { for (let collection of this.getCollectionsForNounID(aNounID)) { let added = [], modified = [], removed = []; for (let item of aItems) { if (item.id in collection._idMap) { // currently in... but should it still be there? if (collection.query.test(item)) modified.push(item); // yes, keep it // oy, so null queries really don't want any notifications, and they // sorta fit into our existing model, except for the removal bit. // so we need a specialized check for them, and we're using the // frozen attribute to this end. else if (!collection.query.frozen) removed.push(item); // no, bin it } else if (collection.query.test(item)) // not in, should it be? added.push(item); // yep, add it } if (added.length) collection._onItemsAdded(added); if (modified.length) collection._onItemsModified(modified); if (removed.length) collection._onItemsRemoved(removed); } }, /** * This should be called when items in the global database are permanently-ish * deleted. (This is distinct from concepts like message deletion which may * involved trash folders or other modified forms of existence. Deleted * means the data is gone and if it were to come back, it would come back * via an itemsAdded event.) * We walk all existing collections for the given noun type. For items * currently in the collection, we generate onItemsRemoved events. * * @param aItemIds A list of item ids that are being deleted. */ itemsDeleted: function gloda_colm_itemsDeleted(aNounID, aItemIds) { // cache let cache = this._cachesByNoun[aNounID]; if (cache) { for (let itemId of aItemIds) { if (itemId in cache._idMap) cache.deleted(cache._idMap[itemId]); } } // collections for (let collection of this.getCollectionsForNounID(aNounID)) { let removeItems = aItemIds.filter(itemId => itemId in collection._idMap). map(itemId => collection._idMap[itemId]); if (removeItems.length) collection._onItemsRemoved(removeItems); } }, /** * Like |itemsDeleted| but for the case where the deletion is based on an * attribute that SQLite can more efficiently check than we can and where the * cost of scanning the in-memory items is presumably much cheaper than * trying to figure out what actually got deleted. * * Since we are doing an in-memory walk, this is obviously O(n) where n is the * number of noun instances of a given type in-memory. We are assuming this * is a reasonable number of things and that this type of deletion call is * not going to happen all that frequently. If these assumptions are wrong, * callers are advised to re-think the whole situation. * * @param aNounID Type of noun we are talking about here. * @param aFilter A filter function that returns true when the item should be * thought of as deleted, or false if the item is still good. Screw this * up and you will get some seriously wacky bugs, yo. */ itemsDeletedByAttribute: function gloda_colm_itemsDeletedByAttribute( aNounID, aFilter) { // cache let cache = this._cachesByNoun[aNounID]; if (cache) { for (let id in cache._idMap) { let item = cache._idMap[id]; if (aFilter(item)) cache.deleted(item); } } // collections for (let collection of this.getCollectionsForNounID(aNounID)) { let removeItems = collection.items.filter(aFilter); if (removeItems.length) collection._onItemsRemoved(removeItems); } }, }; /** * @class A current view of the set of first-class nouns meeting a given query. * Assuming a listener is present, events are * generated when new objects meet the query, existing objects no longer meet * the query, or existing objects have experienced a change in attributes that * does not affect their ability to be present (but the listener may care about * because it is exposing those attributes). * @constructor */ function GlodaCollection(aNounDef, aItems, aQuery, aListener, aMasterCollection) { // if aNounDef is null, we are just being invoked for subclassing if (aNounDef === undefined) return; this._nounDef = aNounDef; // should we also maintain a unique value mapping... if (this._nounDef.usesUniqueValue) this._uniqueValueMap = {}; this.pendingItems = []; this._pendingIdMap = {}; this.items = []; this._idMap = {}; // force the listener to null for our call to _onItemsAdded; no events for // the initial load-out. this._listener = null; if (aItems && aItems.length) this._onItemsAdded(aItems); this.query = aQuery || null; if (this.query) { this.query.collection = this; if (this.query.options.stashColumns) this.stashedColumns = {}; } this._listener = aListener || null; this.deferredCount = 0; this.resolvedCount = 0; if (aMasterCollection) { this.masterCollection = aMasterCollection.masterCollection; } else { this.masterCollection = this; /** a dictionary of dictionaries. at the top level, the keys are noun IDs. * each of these sub-dictionaries maps the IDs of desired noun instances to * the actual instance, or null if it has not yet been loaded. */ this.referencesByNounID = {}; /** * a dictionary of dictionaries. at the top level, the keys are noun IDs. * each of the sub-dictionaries maps the IDs of the _recognized parent * noun_ to the list of children, or null if the list has not yet been * populated. * * So if we have a noun definition A with ID 1 who is the recognized parent * noun of noun definition B with ID 2, AND we have an instance A(1) with * two children B(10), B(11), then an example might be: {2: {1: [10, 11]}}. */ this.inverseReferencesByNounID = {}; this.subCollections = {}; } } GlodaCollection.prototype = { get listener() { return this._listener; }, set listener(aListener) { this._listener = aListener; }, /** * If this collection still has a query associated with it, drop the query * and replace it with an 'explicit query'. This means that the Collection * Manager will not attempt to match new items indexed to the system against * our query criteria. * Once you call this method, your collection's listener will no longer * receive onItemsAdded notifications that are not the result of your * initial database query. It will, however, receive onItemsModified * notifications if items in the collection are re-indexed. */ becomeExplicit: function gloda_coll_becomeExplicit() { if (!(this.query instanceof this._nounDef.explicitQueryClass)) { this.query = new this._nounDef.explicitQueryClass(this); } }, /** * Clear the contents of this collection. This only makes sense for explicit * collections or wildcard collections. (Actual query-based collections * should represent the state of the query, so unless we're going to delete * all the items, clearing the collection would violate that constraint.) */ clear: function gloda_coll_clear() { this._idMap = {}; if (this._uniqueValueMap) this._uniqueValueMap = {}; this.items = []; }, _onItemsAdded: function gloda_coll_onItemsAdded(aItems) { this.items.push.apply(this.items, aItems); if (this._uniqueValueMap) { for (let item of this.items) { this._idMap[item.id] = item; this._uniqueValueMap[item.uniqueValue] = item; } } else { for (let item of this.items) { this._idMap[item.id] = item; } } if (this._listener) { try { this._listener.onItemsAdded(aItems, this); } catch (ex) { LOG.error("caught exception from listener in onItemsAdded: " + ex.fileName + ":" + ex.lineNumber + ": " + ex); } } }, _onItemsModified: function gloda_coll_onItemsModified(aItems) { if (this._listener) { try { this._listener.onItemsModified(aItems, this); } catch (ex) { LOG.error("caught exception from listener in onItemsModified: " + ex.fileName + ":" + ex.lineNumber + ": " + ex); } } }, /** * Given a list of items that definitely no longer belong in this collection, * remove them from the collection and notify the listener. The 'tricky' * part is that we need to remove the deleted items from our list of items. */ _onItemsRemoved: function gloda_coll_onItemsRemoved(aItems) { // we want to avoid the O(n^2) deletion performance case, and deletion // should be rare enough that the extra cost of building the deletion map // should never be a real problem. let deleteMap = {}; // build the delete map while also nuking from our id map/unique value map for (let item of aItems) { deleteMap[item.id] = true; delete this._idMap[item.id]; if (this._uniqueValueMap) delete this._uniqueValueMap[item.uniqueValue]; } let items = this.items; // in-place filter. probably needless optimization. let iWrite=0; for (let iRead = 0; iRead < items.length; iRead++) { let item = items[iRead]; if (!(item.id in deleteMap)) items[iWrite++] = item; } items.splice(iWrite); if (this._listener) { try { this._listener.onItemsRemoved(aItems, this); } catch (ex) { LOG.error("caught exception from listener in onItemsRemoved: " + ex.fileName + ":" + ex.lineNumber + ": " + ex); } } }, _onQueryCompleted: function gloda_coll_onQueryCompleted() { this.query.completed = true; if (this._listener && this._listener.onQueryCompleted) this._listener.onQueryCompleted(this); } }; /** * Create an LRU cache collection for the given noun with the given size. * @constructor */ function GlodaLRUCacheCollection(aNounDef, aCacheSize) { GlodaCollection.call(this, aNounDef, null, null, null); this._head = null; // aka oldest! this._tail = null; // aka newest! this._size = 0; // let's keep things sane, and simplify our logic a little... if (aCacheSize < 32) aCacheSize = 32; this._maxCacheSize = aCacheSize; } /** * @class A LRU-discard cache. We use a doubly linked-list for the eviction * tracking. Since we require that there is at most one LRU-discard cache per * noun class, we simplify our lives by adding our own attributes to the * cached objects. * @augments GlodaCollection */ GlodaLRUCacheCollection.prototype = new GlodaCollection; GlodaLRUCacheCollection.prototype.add = function cache_add(aItems) { for (let item of aItems) { if (item.id in this._idMap) { // DEBUGME so, we're dealing with this, but it shouldn't happen. need // trace-debuggage. continue; } this._idMap[item.id] = item; if (this._uniqueValueMap) this._uniqueValueMap[item.uniqueValue] = item; item._lruPrev = this._tail; // we do have to make sure that we will set _head the first time we insert // something if (this._tail !== null) this._tail._lruNext = item; else this._head = item; item._lruNext = null; this._tail = item; this._size++; } while (this._size > this._maxCacheSize) { let item = this._head; // we never have to deal with the possibility of needing to make _head/_tail // null. this._head = item._lruNext; this._head._lruPrev = null; // (because we are nice, we will delete the properties...) delete item._lruNext; delete item._lruPrev; // nuke from our id map delete this._idMap[item.id]; if (this._uniqueValueMap) delete this._uniqueValueMap[item.uniqueValue]; // flush dirty items to disk (they may not have this attribute, in which // case, this returns false, which is fine.) if (item.dirty) { this._nounDef.objUpdate.call(this._nounDef.datastore, item); delete item.dirty; } this._size--; } }; GlodaLRUCacheCollection.prototype.hit = function cache_hit(aItem) { // don't do anything in the 0 or 1 items case, or if we're already // the last item if ((this._head === this._tail) || (this._tail === aItem)) return aItem; // - unlink the item if (aItem._lruPrev !== null) aItem._lruPrev._lruNext = aItem._lruNext; else this._head = aItem._lruNext; // (_lruNext cannot be null) aItem._lruNext._lruPrev = aItem._lruPrev; // - link it in to the end this._tail._lruNext = aItem; aItem._lruPrev = this._tail; aItem._lruNext = null; // update tail tracking this._tail = aItem; return aItem; }; GlodaLRUCacheCollection.prototype.deleted = function cache_deleted(aItem) { // unlink the item if (aItem._lruPrev !== null) aItem._lruPrev._lruNext = aItem._lruNext; else this._head = aItem._lruNext; if (aItem._lruNext !== null) aItem._lruNext._lruPrev = aItem._lruPrev; else this._tail = aItem._lruPrev; // (because we are nice, we will delete the properties...) delete aItem._lruNext; delete aItem._lruPrev; // nuke from our id map delete this._idMap[aItem.id]; if (this._uniqueValueMap) delete this._uniqueValueMap[aItem.uniqueValue]; this._size--; }; /** * If any of the cached items are dirty, commit them, and make them no longer * dirty. */ GlodaLRUCacheCollection.prototype.commitDirty = function cache_commitDirty() { // we can only do this if there is an update method available... if (!this._nounDef.objUpdate) return; for (let iItem in this._idMap) { let item = this._idMap[iItem]; if (item.dirty) { LOG.debug("flushing dirty: " + item); this._nounDef.objUpdate.call(this._nounDef.datastore, item); delete item.dirty; } } };