diff --git a/orga/changelog/fix-dexie-connection-refcount.md b/orga/changelog/fix-dexie-connection-refcount.md new file mode 100644 index 00000000000..693050584fa --- /dev/null +++ b/orga/changelog/fix-dexie-connection-refcount.md @@ -0,0 +1 @@ +- FIX dexie RxStorage: repair the reference counting in `dexie-helper.ts` so that closing the last storage instance closes the underlying Dexie connection and evicts the state cache entry. Before, the connections leaked and re-creating a database with the same name after the underlying IndexedDB was deleted failed with `DatabaseClosedError` (and `DB8` on retry). [#8793](https://github.com/pubkey/rxdb/issues/8793) diff --git a/src/plugins/storage-dexie/dexie-helper.ts b/src/plugins/storage-dexie/dexie-helper.ts index 87a602b07c4..88a143af25f 100644 --- a/src/plugins/storage-dexie/dexie-helper.ts +++ b/src/plugins/storage-dexie/dexie-helper.ts @@ -59,21 +59,44 @@ export function getDexieDbWithTables( booleanIndexes: getBooleanIndexes(schema) }; })(); - DEXIE_STATE_DB_BY_NAME.set(dexieDbName, state); - REF_COUNT_PER_DEXIE_DB.set(state, 0); + /** + * Key the initial count on value, not on state. + * This callback runs synchronously inside of the assignment + * of state, so state is still unassigned at this point. + */ + REF_COUNT_PER_DEXIE_DB.set(value, 0); return value; } ); + /** + * Count one reference per storage instance. + * getDexieDbWithTables() is called once per storage instance + * and closeDexieDb() once per storage instance close, + * so the counting stays balanced. + */ + REF_COUNT_PER_DEXIE_DB.set(state, (REF_COUNT_PER_DEXIE_DB.get(state) ?? 0) + 1); return state; } export async function closeDexieDb(statePromise: DexieStorageInternals) { const state = await statePromise; - const prevCount = REF_COUNT_PER_DEXIE_DB.get(statePromise); - const newCount = (prevCount as any) - 1; - if (newCount === 0) { + const prevCount = REF_COUNT_PER_DEXIE_DB.get(statePromise) ?? 0; + const newCount = prevCount - 1; + if (newCount <= 0) { state.dexieDb.close(); REF_COUNT_PER_DEXIE_DB.delete(statePromise); + /** + * Also evict the state cache entry. + * Without this, re-creating a database with the same name + * after the underlying IndexedDB was deleted would receive + * the cached, closed Dexie instance and fail with + * DatabaseClosedError. See #8793 + */ + for (const [cachedName, cachedState] of DEXIE_STATE_DB_BY_NAME.entries()) { + if (cachedState === statePromise) { + DEXIE_STATE_DB_BY_NAME.delete(cachedName); + } + } } else { REF_COUNT_PER_DEXIE_DB.set(statePromise, newCount); } diff --git a/test/unit/bug-report.test.ts b/test/unit/bug-report.test.ts index 540a2cdc14e..20d516f0f1f 100644 --- a/test/unit/bug-report.test.ts +++ b/test/unit/bug-report.test.ts @@ -9,7 +9,6 @@ * - 'npm run test:browser' so it runs in the browser */ import assert from 'assert'; -import AsyncTestUtil from 'async-test-util'; import config from './config.ts'; import { @@ -17,27 +16,36 @@ import { randomToken } from '../../plugins/core/index.mjs'; import { - isNode + isNode, + isDeno } from '../../plugins/test-utils/index.mjs'; +import { + indexedDB as fakeIndexedDB +} from 'fake-indexeddb'; + describe('bug-report.test.js', () => { + /** + * Reproduces https://github.com/pubkey/rxdb/issues/8793 + * + * The reference counting in dexie-helper.ts never closes the + * underlying Dexie/IndexedDB connections on database close and + * never evicts the DEXIE_STATE_DB_BY_NAME cache entry. + * When the application afterwards deletes the IndexedDB databases + * (for example on logout, to remove user data from a shared device), + * the leaked connections get force-closed by the delete request. + * Re-creating a database with the same name in the same JS context + * then receives the cached, closed Dexie instance and fails with + * DatabaseClosedError. + */ it('should fail because it reproduces the bug', async function () { - - /** - * If your test should only run in nodejs or only run in the browser, - * you should comment in the return operator and adapt the if statement. - */ - if ( - !isNode // runs only in node - // isNode // runs only in the browser - ) { - // return; - } - - if (!config.storage.hasMultiInstance) { + // the defect is located in the dexie storage, other storages are not affected + if (config.storage.name !== 'dexie') { return; } - // create a schema + // in node and deno the dexie tests run on fake-indexeddb, see config.ts + const idb: any = (isNode || isDeno) ? fakeIndexedDB : (globalThis as any).indexedDB; + const mySchema = { version: 0, primaryKey: 'passportId', @@ -61,81 +69,77 @@ describe('bug-report.test.js', () => { } }; - /** - * Always generate a random database-name - * to ensure that different test runs do not affect each other. - */ const name = randomToken(10); + const collectionName = 'mycollection'; - // create a database + // create a database, insert a document, close the database again const db = await createRxDatabase({ name, - /** - * By calling config.storage.getStorage(), - * we can ensure that all variations of RxStorage are tested in the CI. - */ - storage: config.storage.getStorage(), - eventReduce: true, - ignoreDuplicate: true + storage: config.storage.getStorage() }); - // create a collection const collections = await db.addCollections({ - mycollection: { + [collectionName]: { schema: mySchema } }); - - // insert a document - await collections.mycollection.insert({ + await collections[collectionName].insert({ passportId: 'foobar', firstName: 'Bob', lastName: 'Kelso', age: 56 }); + await db.close(); /** - * to simulate the event-propagation over multiple browser-tabs, - * we create the same database again + * Delete the underlying per-collection IndexedDB databases, + * like an application does on logout to remove user data + * from the device. Because close() did not release the Dexie + * connections, this delete request first gets blocked and the + * connections are force-closed to resume it. */ - const dbInOtherTab = await createRxDatabase({ + const dexieDbNames = [ + 'rxdb-dexie-' + name + '--' + mySchema.version + '--_rxdb_internal', + 'rxdb-dexie-' + name + '--' + mySchema.version + '--' + collectionName + ]; + await Promise.all( + dexieDbNames.map(dexieDbName => new Promise((res, rej) => { + const deleteRequest = idb.deleteDatabase(dexieDbName); + deleteRequest.onsuccess = () => res(); + deleteRequest.onerror = () => rej(deleteRequest.error); + })) + ); + + /** + * Re-create a database with the same name in the same JS context. + * Without the fix this throws Dexie's DatabaseClosedError + * ("Database has been closed") because the state cache still + * holds the force-closed Dexie instance. + */ + const db2 = await createRxDatabase({ name, - storage: config.storage.getStorage(), - eventReduce: true, - ignoreDuplicate: true + storage: config.storage.getStorage() }); - // create a collection - const collectionInOtherTab = await dbInOtherTab.addCollections({ - mycollection: { + const collections2 = await db2.addCollections({ + [collectionName]: { schema: mySchema } }); - - // find the document in the other tab - const myDocument = await collectionInOtherTab.mycollection - .findOne() - .where('firstName') - .eq('Bob') - .exec(); - - /* - * assert things, - * here your tests should fail to show that there is a bug - */ + await collections2[collectionName].insert({ + passportId: 'foobar', + firstName: 'Bob', + lastName: 'Kelso', + age: 56 + }); + const myDocument = await collections2[collectionName] + .findOne({ + selector: { + firstName: 'Bob' + } + }) + .exec(true); assert.strictEqual(myDocument.age, 56); - - // you can also wait for events - const emitted: any[] = []; - const sub = collectionInOtherTab.mycollection - .findOne().$ - .subscribe(doc => { - emitted.push(doc); - }); - await AsyncTestUtil.waitUntil(() => emitted.length === 1); - // clean up afterwards - sub.unsubscribe(); - db.close(); - dbInOtherTab.close(); + await db2.remove(); }); });