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 */
11function 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 */
25Cache.DB_NAME = 'image-loader';
26
27/**
28 * Cache database version.
29 * @type {number}
30 * @const
31 */
32Cache.DB_VERSION = 11;
33
34/**
35 * Memory limit for images data in bytes.
36 *
37 * @const
38 * @type {number}
39 */
40Cache.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 */
49Cache.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 */
57Cache.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});
64};
65
66/**
67 * Initializes the cache database.
68 * @param {function()} callback Completion callback.
69 */
70Cache.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);
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 */
107Cache.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 */
124Cache.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 */
155Cache.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 */
220Cache.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 = {key: key,
228                         timestamp: timestamp,
229                         size: data.length,
230                         lastLoadTimestamp: Date.now()};
231    var dataEntry = {key: key,
232                     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 */
261Cache.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('Incosistent 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 */
345Cache.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