chromium/ui/file_manager/image_loader/cache.ts

// 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.