cache.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  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. * Persistent cache storing images in an indexed database on the hard disk.
  6. * @constructor
  7. */
  8. export function ImageCache() {
  9. /**
  10. * IndexedDB database handle.
  11. * @type {IDBDatabase}
  12. * @private
  13. */
  14. this.db_ = null;
  15. }
  16. /**
  17. * Cache database name.
  18. * @type {string}
  19. * @const
  20. */
  21. ImageCache.DB_NAME = 'image-loader';
  22. /**
  23. * Cache database version.
  24. * @type {number}
  25. * @const
  26. */
  27. ImageCache.DB_VERSION = 15;
  28. /**
  29. * Memory limit for images data in bytes.
  30. *
  31. * @const
  32. * @type {number}
  33. */
  34. ImageCache.MEMORY_LIMIT = 250 * 1024 * 1024; // 250 MB.
  35. /**
  36. * Minimal amount of memory freed per eviction. Used to limit number of
  37. * evictions which are expensive.
  38. *
  39. * @const
  40. * @type {number}
  41. */
  42. ImageCache.EVICTION_CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB.
  43. /**
  44. * Initializes the cache database.
  45. * @param {function()} callback Completion callback.
  46. */
  47. ImageCache.prototype.initialize = function(callback) {
  48. // Establish a connection to the database or (re)create it if not available
  49. // or not up to date. After changing the database's schema, increment
  50. // ImageCache.DB_VERSION to force database recreating.
  51. const openRequest =
  52. window.indexedDB.open(ImageCache.DB_NAME, ImageCache.DB_VERSION);
  53. openRequest.onsuccess = function(e) {
  54. this.db_ = e.target.result;
  55. callback();
  56. }.bind(this);
  57. openRequest.onerror = callback;
  58. openRequest.onupgradeneeded = function(e) {
  59. console.info('Cache database creating or upgrading.');
  60. const db = e.target.result;
  61. if (db.objectStoreNames.contains('metadata')) {
  62. db.deleteObjectStore('metadata');
  63. }
  64. if (db.objectStoreNames.contains('data')) {
  65. db.deleteObjectStore('data');
  66. }
  67. if (db.objectStoreNames.contains('settings')) {
  68. db.deleteObjectStore('settings');
  69. }
  70. db.createObjectStore('metadata', {keyPath: 'key'});
  71. db.createObjectStore('data', {keyPath: 'key'});
  72. db.createObjectStore('settings', {keyPath: 'key'});
  73. };
  74. };
  75. /**
  76. * Sets size of the cache.
  77. *
  78. * @param {number} size Size in bytes.
  79. * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
  80. * provided, then a new one is created.
  81. * @private
  82. */
  83. ImageCache.prototype.setCacheSize_ = function(size, opt_transaction) {
  84. const transaction =
  85. opt_transaction || this.db_.transaction(['settings'], 'readwrite');
  86. const settingsStore = transaction.objectStore('settings');
  87. settingsStore.put({key: 'size', value: size}); // Update asynchronously.
  88. };
  89. /**
  90. * Fetches current size of the cache.
  91. *
  92. * @param {function(number)} onSuccess Callback to return the size.
  93. * @param {function()} onFailure Failure callback.
  94. * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
  95. * provided, then a new one is created.
  96. * @private
  97. */
  98. ImageCache.prototype.fetchCacheSize_ = function(
  99. onSuccess, onFailure, opt_transaction) {
  100. const transaction = opt_transaction ||
  101. this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
  102. const settingsStore = transaction.objectStore('settings');
  103. const sizeRequest = settingsStore.get('size');
  104. sizeRequest.onsuccess = function(e) {
  105. if (e.target.result) {
  106. onSuccess(e.target.result.value);
  107. } else {
  108. onSuccess(0);
  109. }
  110. };
  111. sizeRequest.onerror = function() {
  112. console.warn('Failed to fetch size from the database.');
  113. onFailure();
  114. };
  115. };
  116. /**
  117. * Evicts the least used elements in cache to make space for a new image and
  118. * updates size of the cache taking into account the upcoming item.
  119. *
  120. * @param {number} size Requested size.
  121. * @param {function()} onSuccess Success callback.
  122. * @param {function()} onFailure Failure callback.
  123. * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
  124. * provided, then a new one is created.
  125. * @private
  126. */
  127. ImageCache.prototype.evictCache_ = function(
  128. size, onSuccess, onFailure, opt_transaction) {
  129. const transaction = opt_transaction ||
  130. this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
  131. // Check if the requested size is smaller than the cache size.
  132. if (size > ImageCache.MEMORY_LIMIT) {
  133. onFailure();
  134. return;
  135. }
  136. const onCacheSize = function(cacheSize) {
  137. if (size < ImageCache.MEMORY_LIMIT - cacheSize) {
  138. // Enough space, no need to evict.
  139. this.setCacheSize_(cacheSize + size, transaction);
  140. onSuccess();
  141. return;
  142. }
  143. let bytesToEvict = Math.max(size, ImageCache.EVICTION_CHUNK_SIZE);
  144. // Fetch all metadata.
  145. const metadataEntries = [];
  146. const metadataStore = transaction.objectStore('metadata');
  147. const dataStore = transaction.objectStore('data');
  148. const onEntriesFetched = function() {
  149. metadataEntries.sort(function(a, b) {
  150. return b.lastLoadTimestamp - a.lastLoadTimestamp;
  151. });
  152. let totalEvicted = 0;
  153. while (bytesToEvict > 0) {
  154. const entry = metadataEntries.pop();
  155. totalEvicted += entry.size;
  156. bytesToEvict -= entry.size;
  157. metadataStore.delete(entry.key); // Remove asynchronously.
  158. dataStore.delete(entry.key); // Remove asynchronously.
  159. }
  160. this.setCacheSize_(cacheSize - totalEvicted + size, transaction);
  161. }.bind(this);
  162. metadataStore.openCursor().onsuccess = function(e) {
  163. const cursor = e.target.result;
  164. if (cursor) {
  165. metadataEntries.push(cursor.value);
  166. cursor.continue();
  167. } else {
  168. onEntriesFetched();
  169. }
  170. };
  171. }.bind(this);
  172. this.fetchCacheSize_(onCacheSize, onFailure, transaction);
  173. };
  174. /**
  175. * Saves an image in the cache.
  176. *
  177. * @param {string} key Cache key.
  178. * @param {number} timestamp Last modification timestamp. Used to detect
  179. * if the image cache entry is out of date.
  180. * @param {number} width Image width.
  181. * @param {number} height Image height.
  182. * @param {?string} ifd Image ifd, null if none.
  183. * @param {string} data Image data.
  184. */
  185. ImageCache.prototype.saveImage = function(
  186. key, timestamp, width, height, ifd, data) {
  187. if (!this.db_) {
  188. console.warn('Cache database not available.');
  189. return;
  190. }
  191. const onNotFoundInCache = function() {
  192. const metadataEntry = {
  193. key: key,
  194. timestamp: timestamp,
  195. width: width,
  196. height: height,
  197. ifd: ifd,
  198. size: data.length,
  199. lastLoadTimestamp: Date.now(),
  200. };
  201. const dataEntry = {key: key, data: data};
  202. const transaction =
  203. this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
  204. const metadataStore = transaction.objectStore('metadata');
  205. const dataStore = transaction.objectStore('data');
  206. const onCacheEvicted = function() {
  207. metadataStore.put(metadataEntry); // Add asynchronously.
  208. dataStore.put(dataEntry); // Add asynchronously.
  209. };
  210. // Make sure there is enough space in the cache.
  211. this.evictCache_(data.length, onCacheEvicted, function() {}, transaction);
  212. }.bind(this);
  213. // Check if the image is already in cache. If not, then save it to cache.
  214. this.loadImage(key, timestamp, function() {}, onNotFoundInCache);
  215. };
  216. /**
  217. * Loads an image from the cache.
  218. *
  219. * @param {string} key Cache key.
  220. * @param {number} timestamp Last modification timestamp. If different
  221. * than the one in cache, then the entry will be invalidated.
  222. * @param {function(number, number, ?string, string)} onSuccess Success
  223. * callback with the image width, height, ?ifd, and data.
  224. * @param {function()} onFailure Failure callback.
  225. */
  226. ImageCache.prototype.loadImage = function(
  227. key, timestamp, onSuccess, onFailure) {
  228. if (!this.db_) {
  229. console.warn('Cache database not available.');
  230. onFailure();
  231. return;
  232. }
  233. const transaction =
  234. this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
  235. const metadataStore = transaction.objectStore('metadata');
  236. const dataStore = transaction.objectStore('data');
  237. const metadataRequest = metadataStore.get(key);
  238. const dataRequest = dataStore.get(key);
  239. let metadataEntry = null;
  240. let metadataReceived = false;
  241. let dataEntry = null;
  242. let dataReceived = false;
  243. const onPartialSuccess = function() {
  244. // Check if all sub-requests have finished.
  245. if (!metadataReceived || !dataReceived) {
  246. return;
  247. }
  248. // Check if both entries are available or both unavailable.
  249. if (!!metadataEntry != !!dataEntry) {
  250. console.warn('Inconsistent cache database.');
  251. onFailure();
  252. return;
  253. }
  254. // Process the responses.
  255. if (!metadataEntry) {
  256. // The image not found.
  257. onFailure();
  258. } else if (metadataEntry.timestamp != timestamp) {
  259. // The image is not up to date, so remove it.
  260. this.removeImage(key, function() {}, function() {}, transaction);
  261. onFailure();
  262. } else {
  263. // The image is available. Update the last load time and return the
  264. // image data.
  265. metadataEntry.lastLoadTimestamp = Date.now();
  266. metadataStore.put(metadataEntry); // Added asynchronously.
  267. onSuccess(
  268. metadataEntry.width, metadataEntry.height, metadataEntry.ifd,
  269. dataEntry.data);
  270. }
  271. }.bind(this);
  272. metadataRequest.onsuccess = function(e) {
  273. if (e.target.result) {
  274. metadataEntry = e.target.result;
  275. }
  276. metadataReceived = true;
  277. onPartialSuccess();
  278. };
  279. dataRequest.onsuccess = function(e) {
  280. if (e.target.result) {
  281. dataEntry = e.target.result;
  282. }
  283. dataReceived = true;
  284. onPartialSuccess();
  285. };
  286. metadataRequest.onerror = function() {
  287. console.warn('Failed to fetch metadata from the database.');
  288. metadataReceived = true;
  289. onPartialSuccess();
  290. };
  291. dataRequest.onerror = function() {
  292. console.warn('Failed to fetch image data from the database.');
  293. dataReceived = true;
  294. onPartialSuccess();
  295. };
  296. };
  297. /**
  298. * Removes the image from the cache.
  299. *
  300. * @param {string} key Cache key.
  301. * @param {function()=} opt_onSuccess Success callback.
  302. * @param {function()=} opt_onFailure Failure callback.
  303. * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
  304. * provided, then a new one is created.
  305. */
  306. ImageCache.prototype.removeImage = function(
  307. key, opt_onSuccess, opt_onFailure, opt_transaction) {
  308. if (!this.db_) {
  309. console.warn('Cache database not available.');
  310. return;
  311. }
  312. const transaction = opt_transaction ||
  313. this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
  314. const metadataStore = transaction.objectStore('metadata');
  315. const dataStore = transaction.objectStore('data');
  316. let cacheSize = null;
  317. let cacheSizeReceived = false;
  318. let metadataEntry = null;
  319. let metadataReceived = false;
  320. const onPartialSuccess = function() {
  321. if (!cacheSizeReceived || !metadataReceived) {
  322. return;
  323. }
  324. // If either cache size or metadata entry is not available, then it is
  325. // an error.
  326. if (cacheSize === null || !metadataEntry) {
  327. if (opt_onFailure) {
  328. opt_onFailure();
  329. }
  330. return;
  331. }
  332. if (opt_onSuccess) {
  333. opt_onSuccess();
  334. }
  335. this.setCacheSize_(cacheSize - metadataEntry.size, transaction);
  336. metadataStore.delete(key); // Delete asynchronously.
  337. dataStore.delete(key); // Delete asynchronously.
  338. }.bind(this);
  339. const onCacheSizeFailure = function() {
  340. cacheSizeReceived = true;
  341. };
  342. const onCacheSizeSuccess = function(result) {
  343. cacheSize = result;
  344. cacheSizeReceived = true;
  345. onPartialSuccess();
  346. };
  347. // Fetch the current cache size.
  348. this.fetchCacheSize_(onCacheSizeSuccess, onCacheSizeFailure, transaction);
  349. // Receive image's metadata.
  350. const metadataRequest = metadataStore.get(key);
  351. metadataRequest.onsuccess = function(e) {
  352. if (e.target.result) {
  353. metadataEntry = e.target.result;
  354. }
  355. metadataReceived = true;
  356. onPartialSuccess();
  357. };
  358. metadataRequest.onerror = function() {
  359. console.warn('Failed to remove an image.');
  360. metadataReceived = true;
  361. onPartialSuccess();
  362. };
  363. };