Skip to content

Commit

Permalink
IndexedDB Tile Cache (#6720)
Browse files Browse the repository at this point in the history
Co-authored-by: = <[email protected]>
Co-authored-by: danieliborra <[email protected]>
Co-authored-by: Paul Connelly <[email protected]>
  • Loading branch information
4 people committed May 28, 2024
1 parent ebbd1da commit f52531c
Show file tree
Hide file tree
Showing 6 changed files with 357 additions and 3 deletions.
3 changes: 3 additions & 0 deletions common/api/frontend-tiles.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ export interface FrontendTilesOptions {
// @internal
enableEdges?: boolean;
maxLevelsToSkip?: number;
// @internal
useIndexedDBCache?: boolean;
}

// @internal
export const frontendTilesOptions: {
maxLevelsToSkip: number;
enableEdges: boolean;
useIndexedDBCache: boolean;
};

// @beta
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@itwin/frontend-tiles",
"comment": "Added IndexedDBCache.ts, and added useIndexedDBCache to FrontendTilesOptions",
"type": "none"
}
],
"packageName": "@itwin/frontend-tiles"
}
11 changes: 8 additions & 3 deletions extensions/frontend-tiles/src/BatchedTile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import { assert, BeTimePoint, ByteStream, Logger } from "@itwin/core-bentley";
import { Transform } from "@itwin/core-geometry";
import { ColorDef, Tileset3dSchema } from "@itwin/core-common";
import {
GraphicBranch, GraphicBuilder, IModelApp, RealityTileLoader, RenderSystem, Tile, TileBoundingBoxes, TileContent,
GraphicBranch, GraphicBuilder, IModelApp, RealityTileLoader, RenderSystem, Tile, TileBoundingBoxes, TileContent,
TileDrawArgs, TileParams, TileRequest, TileRequestChannel, TileTreeLoadStatus, TileUser, TileVisibility, Viewport,
} from "@itwin/core-frontend";
import { loggerCategory } from "./LoggerCategory";
import { BatchedTileTree } from "./BatchedTileTree";
import { frontendTilesOptions } from "./FrontendTiles";
import { IndexedDBCache, LocalCache, PassThroughCache } from "./IndexedDBCache";

/** @internal */
export interface BatchedTileParams extends TileParams {
Expand All @@ -29,6 +30,7 @@ export class BatchedTile extends Tile {
private readonly _unskippable: boolean;
/** Transform from the tile's local coordinate system to that of the tileset. */
public readonly transformToRoot?: Transform;
private readonly _localCache: LocalCache;

public get batchedTree(): BatchedTileTree {
return this.tree as BatchedTileTree;
Expand All @@ -49,6 +51,8 @@ export class BatchedTile extends Tile {
this._maximumSize = 0;
}

this._localCache = frontendTilesOptions.useIndexedDBCache ? new IndexedDBCache("BatchedTileCache") : new PassThroughCache();

if (!params.transformToRoot)
return;

Expand All @@ -57,6 +61,7 @@ export class BatchedTile extends Tile {
this.transformToRoot.multiplyRange(this.range, this.range);
if (this._contentRange)
this.transformToRoot.multiplyRange(this._contentRange, this._contentRange);

}

private get _batchedChildren(): BatchedTile[] | undefined {
Expand Down Expand Up @@ -137,8 +142,8 @@ export class BatchedTile extends Tile {
public override async requestContent(_isCanceled: () => boolean): Promise<TileRequest.Response> {
const url = new URL(this.contentId, this.batchedTree.reader.baseUrl);
url.search = this.batchedTree.reader.baseUrl.search;
const response = await fetch(url.toString());
return response.arrayBuffer();
const response = await this._localCache.fetch(url.pathname.toString(), fetch, url.toString());
return response;
}

public override async readContent(data: TileRequest.ResponseData, system: RenderSystem, isCanceled?: () => boolean): Promise<TileContent> {
Expand Down
9 changes: 9 additions & 0 deletions extensions/frontend-tiles/src/FrontendTiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ export interface FrontendTilesOptions {
* @beta
*/
enableCDN?: boolean;
/** Specifies whether to enable an IndexedDB database for use as a local cache.
* Requested tiles will then first be search for in the database, and if not found, fetched as normal.
* @internal
*/
useIndexedDBCache?: boolean;
}

/** Global configuration initialized by [[initializeFrontendTiles]].
Expand All @@ -223,6 +228,7 @@ export interface FrontendTilesOptions {
export const frontendTilesOptions = {
maxLevelsToSkip: 4,
enableEdges: false,
useIndexedDBCache: false,
};

/** Initialize the frontend-tiles package to obtain tiles for spatial views.
Expand All @@ -235,6 +241,9 @@ export function initializeFrontendTiles(options: FrontendTilesOptions): void {
if (options.enableEdges)
frontendTilesOptions.enableEdges = true;

if (options.useIndexedDBCache)
frontendTilesOptions.useIndexedDBCache = true;

const computeUrl = options.computeSpatialTilesetBaseUrl ?? (
async (iModel: IModelConnection) => obtainMeshExportTilesetUrl({ iModel, accessToken: await IModelApp.getAccessToken(), enableCDN: options.enableCDN })
);
Expand Down
182 changes: 182 additions & 0 deletions extensions/frontend-tiles/src/IndexedDBCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import { Logger } from "@itwin/core-bentley";
const loggerCategory = "IndexedDBCache";

/** @internal */
export interface LocalCache {
fetch(url: string, callback: (uniqueId: string) => Promise<Response>, callBackUrl?: string): Promise<ArrayBuffer>;
}

/** @internal */
export class PassThroughCache implements LocalCache {
public async fetch(uniqueId: string, callback: (url: string) => Promise<Response>, callBackUrl?: string): Promise<ArrayBuffer> {
if (callBackUrl) {
return (await callback(callBackUrl)).arrayBuffer();
}
return (await callback(uniqueId)).arrayBuffer();
}
}

/** @internal */
export class IndexedDBCache implements LocalCache{
private _db: any;
private _dbName: string;
private _expirationTime?: number;

public constructor(dbName: string, expirationTime?: number) {
this._dbName = dbName;
this._expirationTime = expirationTime ?? undefined;
}

protected async open(){

// need to return a promise so that we can wait for the db to open before using it
return new Promise(function (this: IndexedDBCache, resolve: any) {

// open the db
const openDB = window.indexedDB.open(this._dbName, 1);

openDB.onerror = () => {
Logger.logError(loggerCategory, "Error opening IndexedDB");
};

// this is the success callback for opening the db, it is called after onupgradeneeded
openDB.onsuccess = async (event) => {

const target: any = event.target;
if (target) {
this._db = target.result;
return resolve(target.result);
}
};

// This will get called when a new version is needed - including going from no database to first version
// So this is how we set up the specifics of the db structure
openDB.onupgradeneeded = async (event) => {
const target: any = event.target;

if (target)
this._db = target.result;

const initialObjectStore = this._db.createObjectStore("cache", { keyPath: "uniqueId" });
initialObjectStore.createIndex("content", "content", {unique: false});
initialObjectStore.createIndex("timeOfStorage", "timeOfStorage", {unique: false});
};
}.bind(this));
}

protected async close() {
await this._db.close();
}

protected async retrieveContent(uniqueId: string): Promise<ArrayBuffer | undefined> {
return new Promise(async (resolve) => {
await this.open();
const getTransaction = await this._db.transaction("cache", "readonly");
const storedResponse = await getTransaction.objectStore("cache").get(uniqueId);

// this is successful if the db was successfully searched - not only if a match was found
storedResponse.onsuccess = async () => {

if (storedResponse.result !== undefined) {

// if the content has an expiration time
if (this._expirationTime) {
// We want to know when the result was stored, and how long it's been since that point
const timeSince = Date.now() - storedResponse.result.timeOfStorage;

// If it's been greater than our time limit, delete it and return undefined.
if (timeSince > this._expirationTime) {
await this.deleteContent(uniqueId);
return resolve(undefined);
}
}
const content = storedResponse.result.content;
await this.close();
return resolve(content);
}

await this.close();
return resolve(undefined);
};

storedResponse.onerror = async () => {
Logger.logError(loggerCategory, "Error retrieving content from IndexedDB");
};
});
}

protected async deleteContent(uniqueId: string) {
return new Promise(async (resolve) => {
await this.open();
const deleteTransaction = await this._db.transaction("cache", "readwrite");
const requestDelete = await deleteTransaction.objectStore("cache").delete(uniqueId);

requestDelete.onerror = () => {
Logger.logError(loggerCategory, "Error deleting content from IndexedDB");
};

deleteTransaction.oncomplete = async () => {
await this.close();
return resolve(undefined);
};

deleteTransaction.onerror = async () => {
Logger.logError(loggerCategory, "Error deleting content from IndexedDB");
};
});
}

protected async addContent(uniqueId: string, content: ArrayBuffer) {
return new Promise(async (resolve) => {
await this.open();
const addTransaction = await this._db.transaction("cache", "readwrite");
const objectStore = await addTransaction.objectStore("cache");

const data = {
uniqueId,
content,
timeOfStorage: Date.now(),
};

const requestAdd = await objectStore.add(data);

requestAdd.onerror = () => {
Logger.logError(loggerCategory, "Error adding content to IndexedDB");
};

addTransaction.oncomplete = async () => {
await this.close();
return resolve(undefined);
};

addTransaction.onerror = async () => {
Logger.logError(loggerCategory, "Error adding content to IndexedDB in add transaction");
await this.close();
return resolve(undefined);
};
});
}

public async fetch(uniqueId: string, callback: (url: string) => Promise<Response>, callBackUrl?: string): Promise<ArrayBuffer> {
let response = await this.retrieveContent(uniqueId);

// If nothing was found in the db, fetch normally, then add that content to the db
if (response === undefined) {

// If necessary, use the callback url
if (callBackUrl)
response = await (await callback(callBackUrl)).arrayBuffer();
else
response = await (await callback(uniqueId)).arrayBuffer();

await this.addContent(uniqueId, response);
}

return response;
}
}

0 comments on commit f52531c

Please sign in to comment.