| // Copyright 2013 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| interface MetadataEntry { |
| key: string; |
| |
| /** Last modification timestamp. */ |
| timestamp: number; |
| width: number; |
| height: number; |
| ifd?: string; |
| size: number; // data.length. |
| lastLoadTimestamp: number; // from Date.now(). |
| data?: string; |
| } |
| |
| /** |
| * Persistent cache storing images in an indexed database on the hard disk. |
| */ |
| export class ImageCache { |
| /** |
| * IndexedDB database handle. |
| */ |
| private db_: null|IDBDatabase = null; |
| |
| /** |
| * Initializes the cache database. |
| * @param callback Completion callback. |
| */ |
| initialize(callback: VoidCallback) { |
| // 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 |
| // DB_VERSION to force database recreating. |
| const openRequest = indexedDB.open(DB_NAME, DB_VERSION); |
| |
| openRequest.onsuccess = () => { |
| this.db_ = openRequest.result; |
| callback(); |
| }; |
| |
| openRequest.onerror = callback; |
| |
| openRequest.onupgradeneeded = () => { |
| console.info('Cache database creating or upgrading.'); |
| const db = openRequest.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 size Size in bytes. |
| * @param transaction Transaction to be reused. If not provided, then a new |
| * one is created. |
| */ |
| private setCacheSize_(size: number, transaction?: IDBTransaction) { |
| transaction = |
| 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 onSuccess Callback to return the size. |
| * @param onFailure Failure callback. |
| * @param transaction Transaction to be reused. If not |
| * provided, then a new one is created. |
| */ |
| private fetchCacheSize_( |
| onSuccess: (a: number) => void, onFailure: VoidCallback, |
| transaction?: IDBTransaction) { |
| transaction = transaction || |
| this.db_!.transaction(['settings', 'metadata', 'data'], 'readwrite'); |
| const settingsStore = transaction.objectStore('settings'); |
| const sizeRequest = settingsStore.get('size'); |
| |
| sizeRequest.onsuccess = () => { |
| const result = sizeRequest.result; |
| if (result) { |
| onSuccess(result.value); |
| } else { |
| onSuccess(0); |
| } |
| }; |
| |
| sizeRequest.onerror = () => { |
| console.warn('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 size Requested size. |
| * @param onSuccess Success callback. |
| * @param onFailure Failure callback. |
| * @param dbTransaction Transaction to be reused. If not provided, then a new |
| * one is created. |
| */ |
| private evictCache_( |
| size: number, onSuccess: VoidCallback, onFailure: VoidCallback, |
| dbTransaction?: IDBTransaction) { |
| const transaction = dbTransaction || |
| this.db_!.transaction(['settings', 'metadata', 'data'], 'readwrite'); |
| |
| // Check if the requested size is smaller than the cache size. |
| if (size > MEMORY_LIMIT) { |
| onFailure(); |
| return; |
| } |
| |
| const onCacheSize = (cacheSize: number) => { |
| if (size < MEMORY_LIMIT - cacheSize) { |
| // Enough space, no need to evict. |
| this.setCacheSize_(cacheSize + size, transaction); |
| onSuccess(); |
| return; |
| } |
| |
| let bytesToEvict = Math.max(size, EVICTION_CHUNK_SIZE); |
| |
| // Fetch all metadata. |
| const metadataEntries: MetadataEntry[] = []; |
| const metadataStore = transaction.objectStore('metadata'); |
| const dataStore = transaction.objectStore('data'); |
| |
| const onEntriesFetched = () => { |
| metadataEntries.sort((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); |
| }; |
| |
| const cursor = metadataStore.openCursor(); |
| cursor.onsuccess = () => { |
| const result = cursor.result; |
| if (result) { |
| metadataEntries.push(result.value); |
| result.continue(); |
| } else { |
| onEntriesFetched(); |
| } |
| }; |
| }; |
| |
| this.fetchCacheSize_(onCacheSize, onFailure, transaction); |
| } |
| |
| /** |
| * Saves an image in the cache. |
| * |
| * @param key Cache key. |
| * @param timestamp Last modification timestamp. Used to detect if the image |
| * cache entry is out of date. |
| * @param width Image width. |
| * @param height Image height. |
| * @param ifd Image ifd, null if none. |
| * @param data Image data. |
| */ |
| saveImage( |
| key: string, timestamp: number, width: number, height: number, |
| ifd: string|undefined, data: string) { |
| if (!this.db_) { |
| console.warn('Cache database not available.'); |
| return; |
| } |
| |
| const onNotFoundInCache = () => { |
| const metadataEntry: 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 = () => { |
| metadataStore.put(metadataEntry); // Add asynchronously. |
| dataStore.put(dataEntry); // Add asynchronously. |
| }; |
| |
| // Make sure there is enough space in the cache. |
| this.evictCache_(data.length, onCacheEvicted, () => {}, transaction); |
| }; |
| |
| // Check if the image is already in cache. If not, then save it to |
| // cache. |
| this.loadImage(key, timestamp, () => {}, onNotFoundInCache); |
| } |
| |
| /** |
| * Loads an image from the cache. |
| * |
| * @param key Cache key. |
| * @param timestamp Last modification timestamp. If different than the one in |
| * cache, then the entry will be invalidated. |
| * @param onSuccess Success callback. |
| * @param onFailure Failure callback. |
| */ |
| loadImage( |
| key: string, timestamp: number, |
| onSuccess: |
| (width: number, height: number, ifd?:|string, data?: string) => void, |
| onFailure: VoidCallback) { |
| 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: MetadataEntry|null = null; |
| let metadataReceived = false; |
| let dataEntry: MetadataEntry|null = null; |
| let dataReceived = false; |
| |
| const onPartialSuccess = () => { |
| // 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, () => {}, () => {}, 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); |
| } |
| }; |
| |
| metadataRequest.onsuccess = () => { |
| if (metadataRequest.result) { |
| metadataEntry = metadataRequest.result; |
| } |
| metadataReceived = true; |
| onPartialSuccess(); |
| }; |
| |
| dataRequest.onsuccess = () => { |
| if (dataRequest.result) { |
| dataEntry = dataRequest.result; |
| } |
| dataReceived = true; |
| onPartialSuccess(); |
| }; |
| |
| metadataRequest.onerror = () => { |
| console.warn('Failed to fetch metadata from the database.'); |
| metadataReceived = true; |
| onPartialSuccess(); |
| }; |
| |
| dataRequest.onerror = () => { |
| console.warn('Failed to fetch image data from the database.'); |
| dataReceived = true; |
| onPartialSuccess(); |
| }; |
| } |
| |
| /** |
| * Removes the image from the cache. |
| * |
| * @param key Cache key. |
| * @param onSuccess Success callback. |
| * @param onFailure Failure callback. |
| * @param transaction Transaction to be reused. If not provided, then a new |
| * one is created. |
| */ |
| removeImage( |
| key: string, onSuccess?: VoidCallback, onFailure?: VoidCallback, |
| transaction?: IDBTransaction) { |
| if (!this.db_) { |
| console.warn('Cache database not available.'); |
| return; |
| } |
| |
| transaction = transaction || |
| this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite'); |
| const metadataStore = transaction.objectStore('metadata'); |
| const dataStore = transaction.objectStore('data'); |
| |
| let cacheSize: number|null = null; |
| let cacheSizeReceived = false; |
| let metadataEntry: MetadataEntry|null = null; |
| let metadataReceived = false; |
| |
| const onPartialSuccess = () => { |
| if (!cacheSizeReceived || !metadataReceived) { |
| return; |
| } |
| |
| // If either cache size or metadata entry is not available, then it is an |
| // error. |
| if (cacheSize === null || !metadataEntry) { |
| if (onFailure) { |
| onFailure(); |
| } |
| return; |
| } |
| |
| if (onSuccess) { |
| onSuccess(); |
| } |
| |
| this.setCacheSize_(cacheSize - metadataEntry.size, transaction); |
| metadataStore.delete(key); // Delete asynchronously. |
| dataStore.delete(key); // Delete asynchronously. |
| }; |
| |
| const onCacheSizeFailure = () => { |
| cacheSizeReceived = true; |
| }; |
| |
| const onCacheSizeSuccess = (result: number) => { |
| 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 = () => { |
| if (metadataRequest.result) { |
| metadataEntry = metadataRequest.result; |
| } |
| metadataReceived = true; |
| onPartialSuccess(); |
| }; |
| |
| metadataRequest.onerror = () => { |
| console.warn('Failed to remove an image.'); |
| metadataReceived = true; |
| onPartialSuccess(); |
| }; |
| } |
| } |
| |
| /** |
| * Cache database name. |
| */ |
| const DB_NAME = 'image-loader'; |
| |
| /** |
| * Cache database version. |
| */ |
| const DB_VERSION = 16; |
| |
| /** |
| * Memory limit for images data in bytes. |
| */ |
| const MEMORY_LIMIT = 250 * 1024 * 1024; // 250 MB. |
| |
| /** |
| * Minimal amount of memory freed per eviction. Used to limit number |
| * of evictions which are expensive. |
| */ |
| const EVICTION_CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB. |