diff --git a/Source/Core/DoublyLinkedList.js b/Source/Core/DoublyLinkedList.js index c73ff464338c..fe3ef7c67aad 100644 --- a/Source/Core/DoublyLinkedList.js +++ b/Source/Core/DoublyLinkedList.js @@ -1,6 +1,6 @@ define([ - '../Core/defined', - '../Core/defineProperties' + './defined', + './defineProperties' ], function( defined, defineProperties) { @@ -29,6 +29,11 @@ define([ this.next = next; } + /** + * Adds the item to the end of the list + * @param {Object} [item] + * @return {DoublyLinkedListNode} + */ DoublyLinkedList.prototype.add = function(item) { var node = new DoublyLinkedListNode(item, this.tail, undefined); @@ -36,7 +41,6 @@ define([ this.tail.next = node; this.tail = node; } else { - // Insert into empty linked list this.head = node; this.tail = node; } @@ -46,6 +50,43 @@ define([ return node; }; + /** + * Adds the item to the front of the list + * @param {Object} [item] + * @return {DoublyLinkedListNode} + */ + DoublyLinkedList.prototype.addFront = function(item) { + var node = new DoublyLinkedListNode(item, undefined, this.head); + + if (defined(this.head)) { + this.head.previous = node; + this.head = node; + } else { + this.head = node; + this.tail = node; + } + + ++this._length; + + return node; + }; + + /** + * Moves the given node to the front of the list + * @param {DoublyLinkedListNode} node + */ + DoublyLinkedList.prototype.moveToFront = function(node) { + if (!defined(node) || this.head === node) { + return; + } + + remove(this, node); + node.next = this.head; + node.previous = undefined; + this.head.previous = node; + this.head = node; + }; + function remove(list, node) { if (defined(node.previous) && defined(node.next)) { node.previous.next = node.next; @@ -68,6 +109,10 @@ define([ node.previous = undefined; } + /** + * Removes the given node from the list + * @param {DoublyLinkedListNode} node + */ DoublyLinkedList.prototype.remove = function(node) { if (!defined(node)) { return; @@ -78,6 +123,43 @@ define([ --this._length; }; + /** + * Removes all nodes after the start index (inclusive) + * @param {Number} startIndex The index of the first node to remove + */ + DoublyLinkedList.prototype.removeAfter = function(startIndex) { + var currentLength = this._length; + if (!defined(startIndex) || startIndex >= currentLength) { + return; + } + + if (startIndex === 0) { + this.head = undefined; + this.tail = undefined; + this._length = 0; + return; + } + + if (startIndex === currentLength - 1) { + this.remove(this.tail); + return; + } + + var node = this.head; + for (var i = 0; i < startIndex; ++i) { + node = node.next; + } + + node.previous.next = undefined; + this.tail = node.previous; + this._length = startIndex; + }; + + /** + * Moves nextNode after node + * @param {DoublyLinkedListNode} node + * @param {DoublyLinkedListNode} nextNode + */ DoublyLinkedList.prototype.splice = function(node, nextNode) { if (node === nextNode) { return; diff --git a/Source/Core/LRUCache.js b/Source/Core/LRUCache.js new file mode 100644 index 000000000000..9478801d6146 --- /dev/null +++ b/Source/Core/LRUCache.js @@ -0,0 +1,174 @@ +define([ + './defined', + './defineProperties', + './getTimestamp', + './DeveloperError', + './DoublyLinkedList' + ], function( + defined, + defineProperties, + getTimestamp, + DeveloperError, + DoublyLinkedList) { + 'use strict'; + + /** + * A cache for storing key-value pairs + * @param {Number} [capacity] The capacity of the cache. If undefined, the size will be unlimited. + * @param {Number} [expiration] The number of milliseconds before an item in the cache expires and will be discarded when LRUCache.prune is called. If undefined, items do not expire. + * @alias LRUCache + * @constructor + * @private + */ + function LRUCache(capacity, expiration) { + this._list = new DoublyLinkedList(); + this._hash = {}; + this._hasCapacity = defined(capacity); + this._capacity = capacity; + + this._hasExpiration = defined(expiration); + this._expiration = expiration; + this._interval = undefined; + } + + defineProperties(LRUCache.prototype, { + /** + * Gets the cache length + * @memberof LRUCache.prototype + * @type {Number} + * @readonly + */ + length : { + get : function() { + return this._list.length; + } + } + }); + + /** + * Retrieves the value associated with the provided key. + * + * @param {String|Number} key The key whose value is to be retrieved. + * @returns {Object} The associated value, or undefined if the key does not exist in the collection. + */ + LRUCache.prototype.get = function(key) { + //>>includeStart('debug', pragmas.debug); + if (typeof key !== 'string' && typeof key !== 'number') { + throw new DeveloperError('key is required to be a string or number.'); + } + //>>includeEnd('debug'); + var list = this._list; + var node = this._hash[key]; + if (defined(node)) { + list.moveToFront(node); + var item = node.item; + item.touch(); + return item.value; + } + }; + + /** + * Associates the provided key with the provided value. If the key already + * exists, it is overwritten with the new value. + * + * @param {String|Number} key A unique identifier. + * @param {Object} value The value to associate with the provided key. + */ + LRUCache.prototype.set = function(key, value) { + //>>includeStart('debug', pragmas.debug); + if (typeof key !== 'string' && typeof key !== 'number') { + throw new DeveloperError('key is required to be a string or number.'); + } + //>>includeEnd('debug'); + var hash = this._hash; + var list = this._list; + + var node = hash[key]; + var item; + if (!defined(node)) { + item = new Item(key, value); + node = list.addFront(item); + hash[key] = node; + if (this._hasExpiration) { + LRUCache._checkExpiration(this); + } + if (this._hasCapacity && list.length > this._capacity) { + var tail = list.tail; + delete this._hash[tail.item.key]; + list.remove(tail); + } + } else { + item = node.item; + item.value = value; + item.touch(); + list.moveToFront(node); + } + }; + + function prune(cache) { + var currentTime = getTimestamp(); + var pruneAfter = currentTime - cache._expiration; + + var list = cache._list; + var node = list.tail; + var index = list.length; + while (defined(node) && node.item.timestamp < pruneAfter) { + node = node.previous; + index--; + } + + if (node === list.tail) { + return; + } + + if (!defined(node)) { + node = list.head; + } else { + node = node.next; + } + + while (defined(node)) { + delete cache._hash[node.item.key]; + node = node.next; + } + + list.removeAfter(index); + } + + function checkExpiration(cache) { + if (defined(cache._interval)) { + return; + } + function loop() { + if (!cache._hasExpiration || cache.length === 0) { + clearInterval(cache._interval); + cache._interval = undefined; + return; + } + + prune(cache); + + if (cache.length === 0) { + clearInterval(cache._interval); + cache._interval = undefined; + } + } + cache._interval = setInterval(loop, 1000); + } + + function Item(key, value) { + this.key = key; + this.value = value; + this.timestamp = getTimestamp(); + } + + Item.prototype.touch = function() { + this.timestamp = getTimestamp(); + }; + + //exposed for testing + LRUCache._checkExpiration = checkExpiration; + LRUCache._prune = prune; + + return LRUCache; +}); diff --git a/Source/Core/sampleTerrain.js b/Source/Core/sampleTerrain.js index 08b1bea181d4..3d2d9e8af403 100644 --- a/Source/Core/sampleTerrain.js +++ b/Source/Core/sampleTerrain.js @@ -1,11 +1,17 @@ define([ '../ThirdParty/when', - './Check' + './defined', + './Check', + './LRUCache' ], function( when, - Check) { + defined, + Check, + LRUCache) { 'use strict'; + var cache = new LRUCache(256, 10000); + /** * Initiates a terrain height query for an array of {@link Cartographic} positions by * requesting tiles from a terrain provider, sampling, and interpolating. The interpolation @@ -86,8 +92,18 @@ define([ var tilePromises = []; for (i = 0; i < tileRequests.length; ++i) { var tileRequest = tileRequests[i]; - var requestPromise = tileRequest.terrainProvider.requestTileGeometry(tileRequest.x, tileRequest.y, tileRequest.level); - var tilePromise = when(requestPromise, createInterpolateFunction(tileRequest), createMarkFailedFunction(tileRequest)); + var cacheKey = tileRequest.x + '-' + tileRequest.y + '-' + tileRequest.level; + var requestPromise; + var cachedTilePromise = cache.get(cacheKey); + if (defined(cachedTilePromise)) { + requestPromise = cachedTilePromise; + } else { + requestPromise = tileRequest.terrainProvider.requestTileGeometry(tileRequest.x, tileRequest.y, tileRequest.level); + cache.set(cacheKey, requestPromise); + } + var tilePromise = requestPromise + .then(createInterpolateFunction(tileRequest)) + .otherwise(createMarkFailedFunction(tileRequest)); tilePromises.push(tilePromise); } diff --git a/Specs/Core/DoublyLinkedListSpec.js b/Specs/Core/DoublyLinkedListSpec.js index 1874ccc7fff4..da5ba6b7bcf6 100644 --- a/Specs/Core/DoublyLinkedListSpec.js +++ b/Specs/Core/DoublyLinkedListSpec.js @@ -63,6 +63,77 @@ defineSuite([ expect(node2.next).toEqual(node3); }); + it('adds to the front of the list', function() { + var list = new DoublyLinkedList(); + var node = list.addFront(1); + + // node + // ^ ^ + // | | + // head tail + expect(list.head).toEqual(node); + expect(list.tail).toEqual(node); + expect(list.length).toEqual(1); + + expect(node).toBeDefined(); + expect(node.item).toEqual(1); + expect(node.previous).not.toBeDefined(); + expect(node.next).not.toBeDefined(); + + var node2 = list.addFront(2); + + // node2 <-> node + // ^ ^ + // | | + // head tail + expect(list.head).toEqual(node2); + expect(list.tail).toEqual(node); + expect(list.length).toEqual(2); + + expect(node2).toBeDefined(); + expect(node2.item).toEqual(2); + expect(node2.previous).toBeUndefined(); + expect(node2.next).toBe(node); + + expect(node.previous).toBe(node2); + expect(node.next).toBeUndefined(node2); + + var node3 = list.addFront(3); + + // node3 <-> node2 <-> node + // ^ ^ + // | | + // head tail + expect(list.head).toEqual(node3); + expect(list.tail).toEqual(node); + expect(list.length).toEqual(3); + + expect(node3).toBeDefined(); + expect(node3.item).toEqual(3); + expect(node3.previous).toBeUndefined(); + expect(node3.next).toBe(node2); + + expect(node2.previous).toEqual(node3); + expect(node2.next).toEqual(node); + }); + + it('moves node to the front of the list', function() { + var list = new DoublyLinkedList(); + var node = list.add(1); + var node2 = list.add(2); + var node3 = list.add(3); + var node4 = list.add(4); + + list.moveToFront(node); + expectOrder(list, [node, node2, node3, node4]); + + list.moveToFront(node4); + expectOrder(list, [node4, node, node2, node3]); + + list.moveToFront(node2); + expectOrder(list, [node2, node4, node, node3]); + }); + it('removes from a list with one item', function() { var list = new DoublyLinkedList(); var node = list.add(1); @@ -122,6 +193,62 @@ defineSuite([ expect(list.length).toEqual(1); }); + it('removeAfter removes nothing', function() { + var list = new DoublyLinkedList(); + var node = list.add(1); + + list.removeAfter(undefined); + + expect(list.head).toEqual(node); + expect(list.tail).toEqual(node); + expect(list.length).toEqual(1); + + list.removeAfter(list.length); + + expect(list.head).toEqual(node); + expect(list.tail).toEqual(node); + expect(list.length).toEqual(1); + }); + + it('removeAfter removes all', function() { + var list = new DoublyLinkedList(); + list.add(1); + list.add(2); + + list.removeAfter(0); + + expect(list.head).toBeUndefined(); + expect(list.tail).toBeUndefined(); + expect(list.length).toEqual(0); + }); + + it('removeAfter removes tail', function() { + var list = new DoublyLinkedList(); + var node = list.add(1); + list.add(2); + + list.removeAfter(1); + + expect(list.head).toEqual(node); + expect(list.tail).toEqual(node); + expect(list.length).toEqual(1); + }); + + it('removeAfter removes nodes after index (inclusive)', function() { + var list = new DoublyLinkedList(); + var node1 = list.add(1); + var node2 = list.add(2); + list.add(3); + list.add(4); + list.add(5); + + list.removeAfter(2); + + expect(list.head).toEqual(node1); + expect(list.tail).toEqual(node2); + expect(list.length).toEqual(2); + }); + function expectOrder(list, nodes) { // Assumes at least one node is in the list var length = nodes.length; diff --git a/Specs/Core/LRUCacheSpec.js b/Specs/Core/LRUCacheSpec.js new file mode 100644 index 000000000000..c53c723fbc5a --- /dev/null +++ b/Specs/Core/LRUCacheSpec.js @@ -0,0 +1,122 @@ +defineSuite([ + 'Core/LRUCache', + 'Core/getTimestamp' + ], function( + LRUCache, + getTimestamp) { + 'use strict'; + + it('can manipulate values', function() { + var cache = new LRUCache(); + + expect(cache.get('key1')).toBeUndefined(); + + cache.set('key1', 1); + cache.set('key2', 2); + cache.set('key3', 3); + + expect(cache.get('key1')).toEqual(1); + expect(cache.get('key2')).toEqual(2); + expect(cache.get('key3')).toEqual(3); + + cache.set('key2', 4); + expect(cache.get('key2')).toEqual(4); + }); + + it('set throws with undefined key', function() { + var cache = new LRUCache(); + expect(function() { + cache.set(undefined, 1); + }).toThrowDeveloperError(); + }); + + it('get throws with undefined key', function() { + var cache = new LRUCache(); + expect(function() { + cache.get(undefined); + }).toThrowDeveloperError(); + }); + + it('Overflows correctly when capacity is set', function() { + var cache = new LRUCache(3); + + expect(cache.get('key1')).toBeUndefined(); + + cache.set('key1', 1); + cache.set('key2', 2); + cache.set('key3', 3); + + expect(cache.get('key1')).toEqual(1); //[3, 2, 1] + expect(cache.get('key2')).toEqual(2); + expect(cache.get('key3')).toEqual(3); + + cache.set('key4', 4); //[4, 3, 2] + expect(cache.get('key1')).toBeUndefined(); + expect(cache.get('key2')).toEqual(2); + expect(cache.get('key3')).toEqual(3); + expect(cache.get('key4')).toEqual(4); + + //set moves to the front of the list + cache.set('key2', 22); //[2, 4, 3] + expect(cache.get('key3')).toEqual(3); + expect(cache.get('key2')).toEqual(22); + expect(cache.get('key4')).toEqual(4); + + cache.set('key3', 3); //[3, 2, 4] + expect(cache.get('key1')).toBeUndefined(); + expect(cache.get('key2')).toEqual(22); + expect(cache.get('key3')).toEqual(3); + expect(cache.get('key4')).toEqual(4); + + //get moves to the front of the list + cache.get('key4'); //[4, 3, 2] + cache.set('key1', 1); //[1, 3, 4] + expect(cache.get('key1')).toEqual(1); + expect(cache.get('key2')).toBeUndefined(); + expect(cache.get('key3')).toEqual(3); + expect(cache.get('key4')).toEqual(4); + }); + + function spinWait(milliseconds) { + var endTime = getTimestamp() + milliseconds; + /*eslint-disable no-empty*/ + while (getTimestamp() < endTime) { + } + /*eslint-enable no-empty*/ + } + + it('prune has no effect when no expiration is set', function() { + var cache = new LRUCache(3); + cache.set('key1', 1); + cache.set('key2', 2); + cache.set('key3', 3); + + spinWait(3); + + LRUCache._prune(cache); + + expect(cache.get('key1')).toEqual(1); + expect(cache.get('key2')).toEqual(2); + expect(cache.get('key3')).toEqual(3); + }); + + it('prune removes expired entries', function() { + spyOn(LRUCache, '_checkExpiration'); + + var cache = new LRUCache(3, 10); + cache.set('key1', 1); + cache.set('key2', 2); + + spinWait(10); + + cache.set('key3', 3); + + LRUCache._prune(cache); + + expect(cache.get('key1')).toBeUndefined(); + expect(cache.get('key2')).toBeUndefined(); + expect(cache.get('key3')).toEqual(3); + + expect(cache._list.length).toBe(1); + }); +});