Skip to content
Open
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
1 change: 1 addition & 0 deletions orga/changelog/fix-dexie-connection-refcount.md
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 28 additions & 5 deletions src/plugins/storage-dexie/dexie-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
138 changes: 71 additions & 67 deletions test/unit/bug-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,43 @@
* - '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 {
createRxDatabase,
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',
Expand All @@ -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<void>((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();
});
});
Loading