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