blob: dcf213d69f18b16e265fcb7cc6a5e121410470af [file] [log] [blame]
// 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.
/**
* Persistent cache storing images in an indexed database on the hard disk.
* @constructor
*/
export function ImageCache() {
/**
* IndexedDB database handle.
* @type {IDBDatabase}
* @private
*/
this.db_ = null;
}
/**
* Cache database name.
* @type {string}
* @const
*/
ImageCache.DB_NAME = 'image-loader';
/**
* Cache database version.
* @type {number}
* @const
*/
ImageCache.DB_VERSION = 15;
/**
* Memory limit for images data in bytes.
*
* @const
* @type {number}
*/
ImageCache.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}
*/
ImageCache.EVICTION_CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB.
/**
* Initializes the cache database.
* @param {function()} callback Completion callback.
*/
ImageCache.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
// ImageCache.DB_VERSION to force database recreating.
const openRequest =
window.indexedDB.open(ImageCache.DB_NAME, ImageCache.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.');
const 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
*/
ImageCache.prototype.setCacheSize_ = function(size, opt_transaction) {
const transaction =
opt_transaction || this.db_.transaction(['settings'], 'readwrite');
const 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
*/
ImageCache.prototype.fetchCacheSize_ = function(
onSuccess, onFailure, opt_transaction) {
const transaction = opt_transaction ||
this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
const settingsStore = transaction.objectStore('settings');
const 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
*/
ImageCache.prototype.evictCache_ = function(
size, onSuccess, onFailure, opt_transaction) {
const transaction = opt_transaction ||
this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
// Check if the requested size is smaller than the cache size.
if (size > ImageCache.MEMORY_LIMIT) {
onFailure();
return;
}
const onCacheSize = function(cacheSize) {
if (size < ImageCache.MEMORY_LIMIT - cacheSize) {
// Enough space, no need to evict.
this.setCacheSize_(cacheSize + size, transaction);
onSuccess();
return;
}
let bytesToEvict = Math.max(size, ImageCache.EVICTION_CHUNK_SIZE);
// Fetch all metadata.
const metadataEntries = [];
const metadataStore = transaction.objectStore('metadata');
const dataStore = transaction.objectStore('data');
const onEntriesFetched = function() {
metadataEntries.sort(function(a, b) {
return b.lastLoadTimestamp - a.lastLoadTimestamp;
});
let totalEvicted = 0;
while (bytesToEvict > 0) {
const 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) {
const cursor = e.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 {number} timestamp Last modification timestamp. Used to detect
* if the image cache entry is out of date.
* @param {number} width Image width.
* @param {number} height Image height.
* @param {?string} ifd Image ifd, null if none.
* @param {string} data Image data.
*/
ImageCache.prototype.saveImage = function(
key, timestamp, width, height, ifd, data) {
if (!this.db_) {
console.warn('Cache database not available.');
return;
}
const onNotFoundInCache = function() {
const metadataEntry = {
key: key,
timestamp: timestamp,
width: width,
height: height,
ifd: ifd,
size: data.length,
lastLoadTimestamp: Date.now(),
};
const dataEntry = {key: key, data: data};
const transaction =
this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
const metadataStore = transaction.objectStore('metadata');
const dataStore = transaction.objectStore('data');
const 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.
*
* @param {string} key Cache key.
* @param {number} timestamp Last modification timestamp. If different
* than the one in cache, then the entry will be invalidated.
* @param {function(number, number, ?string, string)} onSuccess Success
* callback with the image width, height, ?ifd, and data.
* @param {function()} onFailure Failure callback.
*/
ImageCache.prototype.loadImage = function(
key, timestamp, onSuccess, onFailure) {
if (!this.db_) {
console.warn('Cache database not available.');
onFailure();
return;
}
const transaction =
this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
const metadataStore = transaction.objectStore('metadata');
const dataStore = transaction.objectStore('data');
const metadataRequest = metadataStore.get(key);
const dataRequest = dataStore.get(key);
let metadataEntry = null;
let metadataReceived = false;
let dataEntry = null;
let dataReceived = false;
const 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(
metadataEntry.width, metadataEntry.height, metadataEntry.ifd,
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.
*/
ImageCache.prototype.removeImage = function(
key, opt_onSuccess, opt_onFailure, opt_transaction) {
if (!this.db_) {
console.warn('Cache database not available.');
return;
}
const transaction = opt_transaction ||
this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
const metadataStore = transaction.objectStore('metadata');
const dataStore = transaction.objectStore('data');
let cacheSize = null;
let cacheSizeReceived = false;
let metadataEntry = null;
let metadataReceived = false;
const 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) {
opt_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);
const onCacheSizeFailure = function() {
cacheSizeReceived = true;
};
const onCacheSizeSuccess = function(result) {
cacheSize = result;
cacheSizeReceived = true;
onPartialSuccess();
};
// Fetch the current cache size.
this.fetchCacheSize_(onCacheSizeSuccess, onCacheSizeFailure, transaction);
// Receive image's metadata.
const 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();
};
};