Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fire closed event when IndexedDB closes unexpectedly #3218

Merged
merged 9 commits into from
Mar 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions spec/unit/stores/indexeddb-store-worker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import "fake-indexeddb/auto";

import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
import { IndexedDBStoreWorker } from "../../../src/store/indexeddb-store-worker";
import { defer } from "../../../src/utils";

function setupWorker(worker: IndexedDBStoreWorker): void {
worker.onMessage({ data: { command: "setupWorker", args: [] } } as any);
worker.onMessage({ data: { command: "connect", seq: 1 } } as any);
}

describe("IndexedDBStore Worker", () => {
it("should pass 'closed' event via postMessage", async () => {
const deferred = defer<void>();
const postMessage = jest.fn().mockImplementation(({ seq, command }) => {
if (seq === 1 && command === "cmd_success") {
deferred.resolve();
}
});
const worker = new IndexedDBStoreWorker(postMessage);
setupWorker(worker);

await deferred.promise;

// @ts-ignore - private field access
(worker.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
expect(postMessage).toHaveBeenCalledWith({
command: "closed",
});
});
});
88 changes: 88 additions & 0 deletions spec/unit/stores/indexeddb.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,92 @@ describe("IndexedDBStore", () => {

await expect(store.isNewlyCreated()).resolves.toBeFalsy();
});

it("should emit 'closed' if database is unexpectedly closed", async () => {
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
});
await store.startup();

const deferred = defer<void>();
store.on("closed", deferred.resolve);

// @ts-ignore - private field access
(store.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
await deferred.promise;
});

it("should use remote backend if workerFactory passed", async () => {
const deferred = defer<void>();
class MockWorker {
postMessage(data: any) {
if (data.command === "setupWorker") {
deferred.resolve();
}
}
}

const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => new MockWorker() as Worker,
});
store.startup();
await deferred.promise;
});

it("remote worker should pass closed event", async () => {
const worker = new (class MockWorker {
postMessage(data: any) {}
})() as Worker;

const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => worker,
});
store.startup();

const deferred = defer<void>();
store.on("closed", deferred.resolve);
(worker as any).onmessage({ data: { command: "closed" } });
await deferred.promise;
});

it("remote worker should pass command failures", async () => {
const worker = new (class MockWorker {
private onmessage!: (data: any) => void;
postMessage(data: any) {
if (data.command === "setupWorker" || data.command === "connect") {
this.onmessage({
data: {
command: "cmd_success",
seq: data.seq,
},
});
return;
}

this.onmessage({
data: {
command: "cmd_fail",
seq: data.seq,
error: new Error("Test"),
},
});
}
})() as unknown as Worker;

const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => worker,
});
await expect(store.startup()).rejects.toThrow("Test");
});
});
7 changes: 4 additions & 3 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ export interface ISavedSync {
export interface IStore {
readonly accountData: Map<string, MatrixEvent>; // type : content

// XXX: The indexeddb store exposes a non-standard emitter for the "degraded" event
// for when it falls back to being a memory store due to errors.
on?: (event: EventEmitterEvents | "degraded", handler: (...args: any[]) => void) => void;
// XXX: The indexeddb store exposes a non-standard emitter for:
// "degraded" event for when it falls back to being a memory store due to errors.
// "closed" event for when the database closes unexpectedly
on?: (event: EventEmitterEvents | "degraded" | "closed", handler: (...args: any[]) => void) => void;

/** @returns whether or not the database was newly created in this session. */
isNewlyCreated(): Promise<boolean>;
Expand Down
2 changes: 1 addition & 1 deletion src/store/indexeddb-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { IEvent, IStateEventWithRoomId, IStoredClientOpts, ISyncResponse } from
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";

export interface IIndexedDBBackend {
connect(): Promise<void>;
connect(onClose?: () => void): Promise<void>;
syncToDatabase(userTuples: UserTuple[]): Promise<void>;
isNewlyCreated(): Promise<boolean>;
setSyncData(syncData: ISyncResponse): Promise<void>;
Expand Down
12 changes: 10 additions & 2 deletions src/store/indexeddb-local-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
* grant permission.
* @returns Promise which resolves if successfully connected.
*/
public connect(): Promise<void> {
public connect(onClose?: () => void): Promise<void> {
if (!this.disconnected) {
logger.log(`LocalIndexedDBStoreBackend.connect: already connected or connecting`);
return Promise.resolve();
Expand Down Expand Up @@ -188,7 +188,15 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
// add a poorly-named listener for when deleteDatabase is called
// so we can close our db connections.
this.db.onversionchange = (): void => {
this.db?.close();
this.db?.close(); // this does not call onclose
this.disconnected = true;
this.db = undefined;
onClose?.();
};
this.db.onclose = (): void => {
this.disconnected = true;
this.db = undefined;
onClose?.();
};

await this.init();
Expand Down
9 changes: 7 additions & 2 deletions src/store/indexeddb-remote-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
// Once we start connecting, we keep the promise and re-use it
// if we try to connect again
private startPromise?: Promise<void>;
// Callback for when the IndexedDB gets closed unexpectedly
private onClose?(): void;

/**
* An IndexedDB store backend where the actual backend sits in a web
Expand All @@ -48,7 +50,8 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
* grant permission.
* @returns Promise which resolves if successfully connected.
*/
public connect(): Promise<void> {
public connect(onClose?: () => void): Promise<void> {
this.onClose = onClose;
return this.ensureStarted().then(() => this.doCmd("connect"));
}

Expand Down Expand Up @@ -171,7 +174,9 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
private onWorkerMessage = (ev: MessageEvent): void => {
const msg = ev.data;

if (msg.command == "cmd_success" || msg.command == "cmd_fail") {
if (msg.command == "closed") {
this.onClose?.();
} else if (msg.command == "cmd_success" || msg.command == "cmd_fail") {
if (msg.seq === undefined) {
logger.error("Got reply from worker with no seq");
return;
Expand Down
10 changes: 8 additions & 2 deletions src/store/indexeddb-store-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export class IndexedDBStoreWorker {
*/
public constructor(private readonly postMessage: InstanceType<typeof Worker>["postMessage"]) {}

private onClose = (): void => {
this.postMessage.call(null, {
command: "closed",
});
};

/**
* Passes a message event from the main script into the class. This method
* can be directly assigned to the web worker `onmessage` variable.
Expand All @@ -57,7 +63,7 @@ export class IndexedDBStoreWorker {
*/
public onMessage = (ev: MessageEvent): void => {
const msg: ICmd = ev.data;
let prom;
let prom: Promise<any> | undefined;

switch (msg.command) {
case "setupWorker":
Expand All @@ -67,7 +73,7 @@ export class IndexedDBStoreWorker {
prom = Promise.resolve();
break;
case "connect":
prom = this.backend?.connect();
prom = this.backend?.connect(this.onClose);
break;
case "isNewlyCreated":
prom = this.backend?.isNewlyCreated();
Expand Down
12 changes: 11 additions & 1 deletion src/store/indexeddb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ interface IOpts extends IBaseOpts {
}

type EventHandlerMap = {
// Fired when an IDB command fails on a degradable path, and the store falls back to MemoryStore
// This signals the potential for data volatility.
degraded: (e: Error) => void;
// Fired when the IndexedDB gets closed unexpectedly, for example, if the underlying storage is removed or
// if the user clears the database in the browser's history preferences.
closed: () => void;
};

export class IndexedDBStore extends MemoryStore {
Expand Down Expand Up @@ -127,7 +132,7 @@ export class IndexedDBStore extends MemoryStore {

logger.log(`IndexedDBStore.startup: connecting to backend`);
return this.backend
.connect()
.connect(this.onClose)
.then(() => {
logger.log(`IndexedDBStore.startup: loading presence events`);
return this.backend.getUserPresenceEvents();
Expand All @@ -142,9 +147,14 @@ export class IndexedDBStore extends MemoryStore {
this.userModifiedMap[u.userId] = u.getLastModifiedTime();
this.storeUser(u);
});
this.startedUp = true;
});
}

private onClose = (): void => {
this.emitter.emit("closed");
};

/**
* @returns Promise which resolves with a sync response to restore the
* client state to where it was at the last save, or null if there
Expand Down