Home | History | Annotate | Download | only in image_loader
      1 // Copyright 2013 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 'use strict';
      6 
      7 /**
      8  * Persistent cache storing images in an indexed database on the hard disk.
      9  * @constructor
     10  */
     11 function Cache() {
     12   /**
     13    * IndexedDB database handle.
     14    * @type {IDBDatabase}
     15    * @private
     16    */
     17   this.db_ = null;
     18 }
     19 
     20 /**
     21  * Cache database name.
     22  * @type {string}
     23  * @const
     24  */
     25 Cache.DB_NAME = 'image-loader';
     26 
     27 /**
     28  * Cache database version.
     29  * @type {number}
     30  * @const
     31  */
     32 Cache.DB_VERSION = 11;
     33 
     34 /**
     35  * Memory limit for images data in bytes.
     36  *
     37  * @const
     38  * @type {number}
     39  */
     40 Cache.MEMORY_LIMIT = 250 * 1024 * 1024;  // 250 MB.
     41 
     42 /**
     43  * Minimal amount of memory freed per eviction. Used to limit number of
     44  * evictions which are expensive.
     45  *
     46  * @const
     47  * @type {number}
     48  */
     49 Cache.EVICTION_CHUNK_SIZE = 50 * 1024 * 1024;  // 50 MB.
     50 
     51 /**
     52  * Creates a cache key.
     53  *
     54  * @param {Object} request Request options.
     55  * @return {string} Cache key.
     56  */
     57 Cache.createKey = function(request) {
     58   return JSON.stringify({
     59     url: request.url,
     60     scale: request.scale,
     61     width: request.width,
     62     height: request.height,
     63     maxWidth: request.maxWidth,
     64     maxHeight: request.maxHeight});
     65 };
     66 
     67 /**
     68  * Initializes the cache database.
     69  * @param {function()} callback Completion callback.
     70  */
     71 Cache.prototype.initialize = function(callback) {
     72   // Establish a connection to the database or (re)create it if not available
     73   // or not up to date. After changing the database's schema, increment
     74   // Cache.DB_VERSION to force database recreating.
     75   var openRequest = window.indexedDB.open(Cache.DB_NAME, Cache.DB_VERSION);
     76 
     77   openRequest.onsuccess = function(e) {
     78     this.db_ = e.target.result;
     79     callback();
     80   }.bind(this);
     81 
     82   openRequest.onerror = callback;
     83 
     84   openRequest.onupgradeneeded = function(e) {
     85     console.info('Cache database creating or upgrading.');
     86     var db = e.target.result;
     87     if (db.objectStoreNames.contains('metadata'))
     88       db.deleteObjectStore('metadata');
     89     if (db.objectStoreNames.contains('data'))
     90       db.deleteObjectStore('data');
     91     if (db.objectStoreNames.contains('settings'))
     92       db.deleteObjectStore('settings');
     93     db.createObjectStore('metadata', {keyPath: 'key'});
     94     db.createObjectStore('data', {keyPath: 'key'});
     95     db.createObjectStore('settings', {keyPath: 'key'});
     96   };
     97 };
     98 
     99 /**
    100  * Sets size of the cache.
    101  *
    102  * @param {number} size Size in bytes.
    103  * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
    104  *     provided, then a new one is created.
    105  * @private
    106  */
    107 Cache.prototype.setCacheSize_ = function(size, opt_transaction) {
    108   var transaction = opt_transaction ||
    109       this.db_.transaction(['settings'], 'readwrite');
    110   var settingsStore = transaction.objectStore('settings');
    111 
    112   settingsStore.put({key: 'size', value: size});  // Update asynchronously.
    113 };
    114 
    115 /**
    116  * Fetches current size of the cache.
    117  *
    118  * @param {function(number)} onSuccess Callback to return the size.
    119  * @param {function()} onFailure Failure callback.
    120  * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
    121  *     provided, then a new one is created.
    122  * @private
    123  */
    124 Cache.prototype.fetchCacheSize_ = function(
    125     onSuccess, onFailure, opt_transaction) {
    126   var transaction = opt_transaction ||
    127       this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
    128   var settingsStore = transaction.objectStore('settings');
    129   var sizeRequest = settingsStore.get('size');
    130 
    131   sizeRequest.onsuccess = function(e) {
    132     if (e.target.result)
    133       onSuccess(e.target.result.value);
    134     else
    135       onSuccess(0);
    136   };
    137 
    138   sizeRequest.onerror = function() {
    139     console.error('Failed to fetch size from the database.');
    140     onFailure();
    141   };
    142 };
    143 
    144 /**
    145  * Evicts the least used elements in cache to make space for a new image and
    146  * updates size of the cache taking into account the upcoming item.
    147  *
    148  * @param {number} size Requested size.
    149  * @param {function()} onSuccess Success callback.
    150  * @param {function()} onFailure Failure callback.
    151  * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
    152  *     provided, then a new one is created.
    153  * @private
    154  */
    155 Cache.prototype.evictCache_ = function(
    156     size, onSuccess, onFailure, opt_transaction) {
    157   var transaction = opt_transaction ||
    158       this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
    159 
    160   // Check if the requested size is smaller than the cache size.
    161   if (size > Cache.MEMORY_LIMIT) {
    162     onFailure();
    163     return;
    164   }
    165 
    166   var onCacheSize = function(cacheSize) {
    167     if (size < Cache.MEMORY_LIMIT - cacheSize) {
    168       // Enough space, no need to evict.
    169       this.setCacheSize_(cacheSize + size, transaction);
    170       onSuccess();
    171       return;
    172     }
    173 
    174     var bytesToEvict = Math.max(size, Cache.EVICTION_CHUNK_SIZE);
    175 
    176     // Fetch all metadata.
    177     var metadataEntries = [];
    178     var metadataStore = transaction.objectStore('metadata');
    179     var dataStore = transaction.objectStore('data');
    180 
    181     var onEntriesFetched = function() {
    182       metadataEntries.sort(function(a, b) {
    183         return b.lastLoadTimestamp - a.lastLoadTimestamp;
    184       });
    185 
    186       var totalEvicted = 0;
    187       while (bytesToEvict > 0) {
    188         var entry = metadataEntries.pop();
    189         totalEvicted += entry.size;
    190         bytesToEvict -= entry.size;
    191         metadataStore.delete(entry.key);  // Remove asynchronously.
    192         dataStore.delete(entry.key);  // Remove asynchronously.
    193       }
    194 
    195       this.setCacheSize_(cacheSize - totalEvicted + size, transaction);
    196     }.bind(this);
    197 
    198     metadataStore.openCursor().onsuccess = function(e) {
    199       var cursor = event.target.result;
    200       if (cursor) {
    201         metadataEntries.push(cursor.value);
    202         cursor.continue();
    203       } else {
    204         onEntriesFetched();
    205       }
    206     };
    207   }.bind(this);
    208 
    209   this.fetchCacheSize_(onCacheSize, onFailure, transaction);
    210 };
    211 
    212 /**
    213  * Saves an image in the cache.
    214  *
    215  * @param {string} key Cache key.
    216  * @param {string} data Image data.
    217  * @param {number} timestamp Last modification timestamp. Used to detect
    218  *     if the cache entry becomes out of date.
    219  */
    220 Cache.prototype.saveImage = function(key, data, timestamp) {
    221   if (!this.db_) {
    222     console.warn('Cache database not available.');
    223     return;
    224   }
    225 
    226   var onNotFoundInCache = function() {
    227     var metadataEntry = {
    228       key: key,
    229       timestamp: timestamp,
    230       size: data.length,
    231       lastLoadTimestamp: Date.now()};
    232     var dataEntry = {key: key, data: data};
    233 
    234     var transaction = this.db_.transaction(['settings', 'metadata', 'data'],
    235                                            'readwrite');
    236     var metadataStore = transaction.objectStore('metadata');
    237     var dataStore = transaction.objectStore('data');
    238 
    239     var onCacheEvicted = function() {
    240       metadataStore.put(metadataEntry);  // Add asynchronously.
    241       dataStore.put(dataEntry);  // Add asynchronously.
    242     };
    243 
    244     // Make sure there is enough space in the cache.
    245     this.evictCache_(data.length, onCacheEvicted, function() {}, transaction);
    246   }.bind(this);
    247 
    248   // Check if the image is already in cache. If not, then save it to cache.
    249   this.loadImage(key, timestamp, function() {}, onNotFoundInCache);
    250 };
    251 
    252 /**
    253  * Loads an image from the cache (if available) or returns null.
    254  *
    255  * @param {string} key Cache key.
    256  * @param {number} timestamp Last modification timestamp. If different
    257  *     that the one in cache, then the entry will be invalidated.
    258  * @param {function(<string>)} onSuccess Success callback with the image's data.
    259  * @param {function()} onFailure Failure callback.
    260  */
    261 Cache.prototype.loadImage = function(key, timestamp, onSuccess, onFailure) {
    262   if (!this.db_) {
    263     console.warn('Cache database not available.');
    264     onFailure();
    265     return;
    266   }
    267 
    268   var transaction = this.db_.transaction(['settings', 'metadata', 'data'],
    269                                          'readwrite');
    270   var metadataStore = transaction.objectStore('metadata');
    271   var dataStore = transaction.objectStore('data');
    272   var metadataRequest = metadataStore.get(key);
    273   var dataRequest = dataStore.get(key);
    274 
    275   var metadataEntry = null;
    276   var metadataReceived = false;
    277   var dataEntry = null;
    278   var dataReceived = false;
    279 
    280   var onPartialSuccess = function() {
    281     // Check if all sub-requests have finished.
    282     if (!metadataReceived || !dataReceived)
    283       return;
    284 
    285     // Check if both entries are available or both unavailable.
    286     if (!!metadataEntry != !!dataEntry) {
    287       console.warn('Inconsistent cache database.');
    288       onFailure();
    289       return;
    290     }
    291 
    292     // Process the responses.
    293     if (!metadataEntry) {
    294       // The image not found.
    295       onFailure();
    296     } else if (metadataEntry.timestamp != timestamp) {
    297       // The image is not up to date, so remove it.
    298       this.removeImage(key, function() {}, function() {}, transaction);
    299       onFailure();
    300     } else {
    301       // The image is available. Update the last load time and return the
    302       // image data.
    303       metadataEntry.lastLoadTimestamp = Date.now();
    304       metadataStore.put(metadataEntry);  // Added asynchronously.
    305       onSuccess(dataEntry.data);
    306     }
    307   }.bind(this);
    308 
    309   metadataRequest.onsuccess = function(e) {
    310     if (e.target.result)
    311       metadataEntry = e.target.result;
    312     metadataReceived = true;
    313     onPartialSuccess();
    314   };
    315 
    316   dataRequest.onsuccess = function(e) {
    317     if (e.target.result)
    318       dataEntry = e.target.result;
    319     dataReceived = true;
    320     onPartialSuccess();
    321   };
    322 
    323   metadataRequest.onerror = function() {
    324     console.error('Failed to fetch metadata from the database.');
    325     metadataReceived = true;
    326     onPartialSuccess();
    327   };
    328 
    329   dataRequest.onerror = function() {
    330     console.error('Failed to fetch image data from the database.');
    331     dataReceived = true;
    332     onPartialSuccess();
    333   };
    334 };
    335 
    336 /**
    337  * Removes the image from the cache.
    338  *
    339  * @param {string} key Cache key.
    340  * @param {function()=} opt_onSuccess Success callback.
    341  * @param {function()=} opt_onFailure Failure callback.
    342  * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
    343  *     provided, then a new one is created.
    344  */
    345 Cache.prototype.removeImage = function(
    346     key, opt_onSuccess, opt_onFailure, opt_transaction) {
    347   if (!this.db_) {
    348     console.warn('Cache database not available.');
    349     return;
    350   }
    351 
    352   var transaction = opt_transaction ||
    353       this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
    354   var metadataStore = transaction.objectStore('metadata');
    355   var dataStore = transaction.objectStore('data');
    356 
    357   var cacheSize = null;
    358   var cacheSizeReceived = false;
    359   var metadataEntry = null;
    360   var metadataReceived = false;
    361 
    362   var onPartialSuccess = function() {
    363     if (!cacheSizeReceived || !metadataReceived)
    364       return;
    365 
    366     // If either cache size or metadata entry is not available, then it is
    367     // an error.
    368     if (cacheSize === null || !metadataEntry) {
    369       if (opt_onFailure)
    370         onFailure();
    371       return;
    372     }
    373 
    374     if (opt_onSuccess)
    375       opt_onSuccess();
    376 
    377     this.setCacheSize_(cacheSize - metadataEntry.size, transaction);
    378     metadataStore.delete(key);  // Delete asynchronously.
    379     dataStore.delete(key);  // Delete asynchronously.
    380   }.bind(this);
    381 
    382   var onCacheSizeFailure = function() {
    383     cacheSizeReceived = true;
    384   };
    385 
    386   var onCacheSizeSuccess = function(result) {
    387     cacheSize = result;
    388     cacheSizeReceived = true;
    389     onPartialSuccess();
    390   };
    391 
    392   // Fetch the current cache size.
    393   this.fetchCacheSize_(onCacheSizeSuccess, onCacheSizeFailure, transaction);
    394 
    395   // Receive image's metadata.
    396   var metadataRequest = metadataStore.get(key);
    397 
    398   metadataRequest.onsuccess = function(e) {
    399     if (e.target.result)
    400       metadataEntry = e.target.result;
    401     metadataReceived = true;
    402     onPartialSuccess();
    403   };
    404 
    405   metadataRequest.onerror = function() {
    406     console.error('Failed to remove an image.');
    407     metadataReceived = true;
    408     onPartialSuccess();
    409   };
    410 };
    411