Disable view source for Developer Tools.
[chromium-blink-merge.git] / chrome / browser / resources / image_loader / cache.js
blobdde287c1a0a63285d0d647d40ffe6a5c8247f312
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.
5 'use strict';
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;
20 /**
21  * Cache database name.
22  * @type {string}
23  * @const
24  */
25 Cache.DB_NAME = 'image-loader';
27 /**
28  * Cache database version.
29  * @type {number}
30  * @const
31  */
32 Cache.DB_VERSION = 11;
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.
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.
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({url: request.url,
59                          scale: request.scale,
60                          width: request.width,
61                          height: request.height,
62                          maxWidth: request.maxWidth,
63                          maxHeight: request.maxHeight});
66 /**
67  * Initializes the cache database.
68  * @param {function()} callback Completion callback.
69  */
70 Cache.prototype.initialize = function(callback) {
71   // Establish a connection to the database or (re)create it if not available
72   // or not up to date. After changing the database's schema, increment
73   // Cache.DB_VERSION to force database recreating.
74   var openRequest = window.webkitIndexedDB.open(Cache.DB_NAME,
75                                                 Cache.DB_VERSION);
77   openRequest.onsuccess = function(e) {
78     this.db_ = e.target.result;
79     callback();
80   }.bind(this);
82   openRequest.onerror = callback;
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   };
99 /**
100  * Sets size of the cache.
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');
112   settingsStore.put({key: 'size', value: size});  // Update asynchronously.
116  * Fetches current size of the cache.
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');
131   sizeRequest.onsuccess = function(e) {
132     if (e.target.result)
133       onSuccess(e.target.result.value);
134     else
135       onSuccess(0);
136   };
138   sizeRequest.onerror = function() {
139     console.error('Failed to fetch size from the database.');
140     onFailure();
141   };
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.
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');
160   // Check if the requested size is smaller than the cache size.
161   if (size > Cache.MEMORY_LIMIT) {
162     onFailure();
163     return;
164   }
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     }
174     var bytesToEvict = Math.max(size, Cache.EVICTION_CHUNK_SIZE);
176     // Fetch all metadata.
177     var metadataEntries = [];
178     var metadataStore = transaction.objectStore('metadata');
179     var dataStore = transaction.objectStore('data');
181     var onEntriesFetched = function() {
182       metadataEntries.sort(function(a, b) {
183         return b.lastLoadTimestamp - a.lastLoadTimestamp;
184       });
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       }
195       this.setCacheSize_(cacheSize - totalEvicted + size, transaction);
196     }.bind(this);
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);
209   this.fetchCacheSize_(onCacheSize, onFailure, transaction);
213  * Saves an image in the cache.
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   }
226   var onNotFoundInCache = function() {
227     var metadataEntry = {key: key,
228                          timestamp: timestamp,
229                          size: data.length,
230                          lastLoadTimestamp: Date.now()};
231     var dataEntry = {key: key,
232                      data: data};
234     var transaction = this.db_.transaction(['settings', 'metadata', 'data'],
235                                           'readwrite');
236     var metadataStore = transaction.objectStore('metadata');
237     var dataStore = transaction.objectStore('data');
239     var onCacheEvicted = function() {
240       metadataStore.put(metadataEntry);  // Add asynchronously.
241       dataStore.put(dataEntry);  // Add asynchronously.
242     };
244     // Make sure there is enough space in the cache.
245     this.evictCache_(data.length, onCacheEvicted, function() {}, transaction);
246   }.bind(this);
248   // Check if the image is already in cache. If not, then save it to cache.
249   this.loadImage(key, timestamp, function() {}, onNotFoundInCache);
253  * Loads an image from the cache (if available) or returns null.
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   }
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);
275   var metadataEntry = null;
276   var metadataReceived = false;
277   var dataEntry = null;
278   var dataReceived = false;
280   var onPartialSuccess = function() {
281     // Check if all sub-requests have finished.
282     if (!metadataReceived || !dataReceived)
283       return;
285     // Check if both entries are available or both unavailable.
286     if (!!metadataEntry != !!dataEntry) {
287       console.warn('Incosistent cache database.');
288       onFailure();
289       return;
290     }
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);
309   metadataRequest.onsuccess = function(e) {
310     if (e.target.result)
311       metadataEntry = e.target.result;
312     metadataReceived = true;
313     onPartialSuccess();
314   };
316   dataRequest.onsuccess = function(e) {
317     if (e.target.result)
318       dataEntry = e.target.result;
319     dataReceived = true;
320     onPartialSuccess();
321   };
323   metadataRequest.onerror = function() {
324     console.error('Failed to fetch metadata from the database.');
325     metadataReceived = true;
326     onPartialSuccess();
327   };
329   dataRequest.onerror = function() {
330     console.error('Failed to fetch image data from the database.');
331     dataReceived = true;
332     onPartialSuccess();
333   };
337  * Removes the image from the cache.
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   }
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');
357   var cacheSize = null;
358   var cacheSizeReceived = false;
359   var metadataEntry = null;
360   var metadataReceived = false;
362   var onPartialSuccess = function() {
363     if (!cacheSizeReceived || !metadataReceived)
364       return;
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     }
374     if (opt_onSuccess)
375       opt_onSuccess();
377     this.setCacheSize_(cacheSize - metadataEntry.size, transaction);
378     metadataStore.delete(key);  // Delete asynchronously.
379     dataStore.delete(key);  // Delete asynchronously.
380   }.bind(this);
382   var onCacheSizeFailure = function() {
383     cacheSizeReceived = true;
384   };
386   var onCacheSizeSuccess = function(result) {
387     cacheSize = result;
388     cacheSizeReceived = true;
389     onPartialSuccess();
390   };
392   // Fetch the current cache size.
393   this.fetchCacheSize_(onCacheSizeSuccess, onCacheSizeFailure, transaction);
395   // Receive image's metadata.
396   var metadataRequest = metadataStore.get(key);
398   metadataRequest.onsuccess = function(e) {
399     if (e.target.result)
400       metadataEntry = e.target.result;
401     metadataReceived = true;
402     onPartialSuccess();
403   };
405   metadataRequest.onerror = function() {
406     console.error('Failed to remove an image.');
407     metadataReceived = true;
408     onPartialSuccess();
409   };