// Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * Persistent cache storing images in an indexed database on the hard disk. * @constructor */ function Cache() { /** * IndexedDB database handle. * @type {IDBDatabase} * @private */ this.db_ = null; } /** * Cache database name. * @type {string} * @const */ Cache.DB_NAME = 'image-loader'; /** * Cache database version. * @type {number} * @const */ Cache.DB_VERSION = 11; /** * Memory limit for images data in bytes. * * @const * @type {number} */ Cache.MEMORY_LIMIT = 250 * 1024 * 1024; // 250 MB. /** * Minimal amount of memory freed per eviction. Used to limit number of * evictions which are expensive. * * @const * @type {number} */ Cache.EVICTION_CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB. /** * Creates a cache key. * * @param {Object} request Request options. * @return {string} Cache key. */ Cache.createKey = function(request) { return JSON.stringify({url: request.url, scale: request.scale, width: request.width, height: request.height, maxWidth: request.maxWidth, maxHeight: request.maxHeight}); }; /** * Initializes the cache database. * @param {function()} callback Completion callback. */ Cache.prototype.initialize = function(callback) { // Establish a connection to the database or (re)create it if not available // or not up to date. After changing the database's schema, increment // Cache.DB_VERSION to force database recreating. var openRequest = window.webkitIndexedDB.open(Cache.DB_NAME, Cache.DB_VERSION); openRequest.onsuccess = function(e) { this.db_ = e.target.result; callback(); }.bind(this); openRequest.onerror = callback; openRequest.onupgradeneeded = function(e) { console.info('Cache database creating or upgrading.'); var db = e.target.result; if (db.objectStoreNames.contains('metadata')) db.deleteObjectStore('metadata'); if (db.objectStoreNames.contains('data')) db.deleteObjectStore('data'); if (db.objectStoreNames.contains('settings')) db.deleteObjectStore('settings'); db.createObjectStore('metadata', {keyPath: 'key'}); db.createObjectStore('data', {keyPath: 'key'}); db.createObjectStore('settings', {keyPath: 'key'}); }; }; /** * Sets size of the cache. * * @param {number} size Size in bytes. * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not * provided, then a new one is created. * @private */ Cache.prototype.setCacheSize_ = function(size, opt_transaction) { var transaction = opt_transaction || this.db_.transaction(['settings'], 'readwrite'); var settingsStore = transaction.objectStore('settings'); settingsStore.put({key: 'size', value: size}); // Update asynchronously. }; /** * Fetches current size of the cache. * * @param {function(number)} onSuccess Callback to return the size. * @param {function()} onFailure Failure callback. * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not * provided, then a new one is created. * @private */ Cache.prototype.fetchCacheSize_ = function( onSuccess, onFailure, opt_transaction) { var transaction = opt_transaction || this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite'); var settingsStore = transaction.objectStore('settings'); var sizeRequest = settingsStore.get('size'); sizeRequest.onsuccess = function(e) { if (e.target.result) onSuccess(e.target.result.value); else onSuccess(0); }; sizeRequest.onerror = function() { console.error('Failed to fetch size from the database.'); onFailure(); }; }; /** * Evicts the least used elements in cache to make space for a new image and * updates size of the cache taking into account the upcoming item. * * @param {number} size Requested size. * @param {function()} onSuccess Success callback. * @param {function()} onFailure Failure callback. * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not * provided, then a new one is created. * @private */ Cache.prototype.evictCache_ = function( size, onSuccess, onFailure, opt_transaction) { var transaction = opt_transaction || this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite'); // Check if the requested size is smaller than the cache size. if (size > Cache.MEMORY_LIMIT) { onFailure(); return; } var onCacheSize = function(cacheSize) { if (size < Cache.MEMORY_LIMIT - cacheSize) { // Enough space, no need to evict. this.setCacheSize_(cacheSize + size, transaction); onSuccess(); return; } var bytesToEvict = Math.max(size, Cache.EVICTION_CHUNK_SIZE); // Fetch all metadata. var metadataEntries = []; var metadataStore = transaction.objectStore('metadata'); var dataStore = transaction.objectStore('data'); var onEntriesFetched = function() { metadataEntries.sort(function(a, b) { return b.lastLoadTimestamp - a.lastLoadTimestamp; }); var totalEvicted = 0; while (bytesToEvict > 0) { var entry = metadataEntries.pop(); totalEvicted += entry.size; bytesToEvict -= entry.size; metadataStore.delete(entry.key); // Remove asynchronously. dataStore.delete(entry.key); // Remove asynchronously. } this.setCacheSize_(cacheSize - totalEvicted + size, transaction); }.bind(this); metadataStore.openCursor().onsuccess = function(e) { var cursor = event.target.result; if (cursor) { metadataEntries.push(cursor.value); cursor.continue(); } else { onEntriesFetched(); } }; }.bind(this); this.fetchCacheSize_(onCacheSize, onFailure, transaction); }; /** * Saves an image in the cache. * * @param {string} key Cache key. * @param {string} data Image data. * @param {number} timestamp Last modification timestamp. Used to detect * if the cache entry becomes out of date. */ Cache.prototype.saveImage = function(key, data, timestamp) { if (!this.db_) { console.warn('Cache database not available.'); return; } var onNotFoundInCache = function() { var metadataEntry = {key: key, timestamp: timestamp, size: data.length, lastLoadTimestamp: Date.now()}; var dataEntry = {key: key, data: data}; var transaction = this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite'); var metadataStore = transaction.objectStore('metadata'); var dataStore = transaction.objectStore('data'); var onCacheEvicted = function() { metadataStore.put(metadataEntry); // Add asynchronously. dataStore.put(dataEntry); // Add asynchronously. }; // Make sure there is enough space in the cache. this.evictCache_(data.length, onCacheEvicted, function() {}, transaction); }.bind(this); // Check if the image is already in cache. If not, then save it to cache. this.loadImage(key, timestamp, function() {}, onNotFoundInCache); }; /** * Loads an image from the cache (if available) or returns null. * * @param {string} key Cache key. * @param {number} timestamp Last modification timestamp. If different * that the one in cache, then the entry will be invalidated. * @param {function(<string>)} onSuccess Success callback with the image's data. * @param {function()} onFailure Failure callback. */ Cache.prototype.loadImage = function(key, timestamp, onSuccess, onFailure) { if (!this.db_) { console.warn('Cache database not available.'); onFailure(); return; } var transaction = this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite'); var metadataStore = transaction.objectStore('metadata'); var dataStore = transaction.objectStore('data'); var metadataRequest = metadataStore.get(key); var dataRequest = dataStore.get(key); var metadataEntry = null; var metadataReceived = false; var dataEntry = null; var dataReceived = false; var onPartialSuccess = function() { // Check if all sub-requests have finished. if (!metadataReceived || !dataReceived) return; // Check if both entries are available or both unavailable. if (!!metadataEntry != !!dataEntry) { console.warn('Inconsistent cache database.'); onFailure(); return; } // Process the responses. if (!metadataEntry) { // The image not found. onFailure(); } else if (metadataEntry.timestamp != timestamp) { // The image is not up to date, so remove it. this.removeImage(key, function() {}, function() {}, transaction); onFailure(); } else { // The image is available. Update the last load time and return the // image data. metadataEntry.lastLoadTimestamp = Date.now(); metadataStore.put(metadataEntry); // Added asynchronously. onSuccess(dataEntry.data); } }.bind(this); metadataRequest.onsuccess = function(e) { if (e.target.result) metadataEntry = e.target.result; metadataReceived = true; onPartialSuccess(); }; dataRequest.onsuccess = function(e) { if (e.target.result) dataEntry = e.target.result; dataReceived = true; onPartialSuccess(); }; metadataRequest.onerror = function() { console.error('Failed to fetch metadata from the database.'); metadataReceived = true; onPartialSuccess(); }; dataRequest.onerror = function() { console.error('Failed to fetch image data from the database.'); dataReceived = true; onPartialSuccess(); }; }; /** * Removes the image from the cache. * * @param {string} key Cache key. * @param {function()=} opt_onSuccess Success callback. * @param {function()=} opt_onFailure Failure callback. * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not * provided, then a new one is created. */ Cache.prototype.removeImage = function( key, opt_onSuccess, opt_onFailure, opt_transaction) { if (!this.db_) { console.warn('Cache database not available.'); return; } var transaction = opt_transaction || this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite'); var metadataStore = transaction.objectStore('metadata'); var dataStore = transaction.objectStore('data'); var cacheSize = null; var cacheSizeReceived = false; var metadataEntry = null; var metadataReceived = false; var onPartialSuccess = function() { if (!cacheSizeReceived || !metadataReceived) return; // If either cache size or metadata entry is not available, then it is // an error. if (cacheSize === null || !metadataEntry) { if (opt_onFailure) onFailure(); return; } if (opt_onSuccess) opt_onSuccess(); this.setCacheSize_(cacheSize - metadataEntry.size, transaction); metadataStore.delete(key); // Delete asynchronously. dataStore.delete(key); // Delete asynchronously. }.bind(this); var onCacheSizeFailure = function() { cacheSizeReceived = true; }; var onCacheSizeSuccess = function(result) { cacheSize = result; cacheSizeReceived = true; onPartialSuccess(); }; // Fetch the current cache size. this.fetchCacheSize_(onCacheSizeSuccess, onCacheSizeFailure, transaction); // Receive image's metadata. var metadataRequest = metadataStore.get(key); metadataRequest.onsuccess = function(e) { if (e.target.result) metadataEntry = e.target.result; metadataReceived = true; onPartialSuccess(); }; metadataRequest.onerror = function() { console.error('Failed to remove an image.'); metadataReceived = true; onPartialSuccess(); }; };