-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Description
Summary
Two bugs in the migration-schema plugin combine to create a severe memory leak (~100 MB/min) that worsens on every app restart. Related to but distinct from #7786.
RxDB version: 16.21.1
Environment: Chromium-based desktop app (Overwolf), autoMigrate: false, calling migratePromise() manually
Bug 1: Old collection meta doc not deleted after migration (_rev conflict)
After startMigration() successfully migrates all documents, it attempts to delete the old collection meta doc (e.g., unified_hero_sessions-1) via writeSingle():
// rx-migration-state.js, startMigration()
await writeSingle(this.database.internalStore, {
previous: oldCollectionMeta,
document: Object.assign({}, oldCollectionMeta, { _deleted: true })
}, 'rx-migration-remove-collection-meta');This fails with a _rev conflict because oldCollectionMeta was fetched at the start of startMigration() and the internal store document has been modified since then (by updateStatus() calls or other internal writes). The conflict is caught and silently ignored when isConflict.documentInDb._deleted is truthy, but in this case the doc is not deleted — it just has a stale _rev.
Result: The old collection meta doc persists forever. On every subsequent restart, migrationNeeded() (which calls mustMigrate() → getOldCollectionMeta()) finds this doc and returns true, even though migration already completed.
Bug 2: migratePromise() calls startMigration() without await
// rx-migration-state.js
_proto.migratePromise = async function migratePromise(batchSize) {
this.startMigration(batchSize); // <-- fire-and-forget, no await
var must = await this.mustMigrate;
if (!must) { return { status: 'DONE', ... }; }
var result = await Promise.race([
firstValueFrom(this.$.pipe(filter(d => d.status === 'DONE'))),
firstValueFrom(this.$.pipe(filter(d => d.status === 'ERROR')))
]);
// ...
};startMigration() is called without await. Then migratePromise() enters a Promise.race on this.$ (an observeSingle + shareReplay pipeline on the internal store) waiting for DONE or ERROR status.
When combined with Bug 1, the following happens on every restart after the first migration:
migrationNeeded()finds the orphaned old meta doc → returnstruemigratePromise()firesstartMigration()(fire-and-forget)migratePromise()entersPromise.raceonthis.$for DONE/ERROR- A stale DONE status doc from the previous migration run still exists in the internal store (we observed revision 25,227 after many restarts)
this.$immediately emits the stale DONE →migratePromise()resolves in ~1ms- Meanwhile, the orphaned
startMigration()continues running in the background, creating storage instances, replication state, and persistentobserveSingle/shareReplaysubscriptions that are never cleaned up - The orphaned
startMigration()tries to delete the old meta doc again, fails again with_revconflict - On next restart, the cycle repeats — accumulating leaked subscriptions
Memory Leak Mechanism
The RxMigrationState constructor creates a persistent subscription:
this.$ = observeSingle(this.database.internalStore, this.statusDocId)
.pipe(filter(d => !!d), map(d => ensureNotFalsy(d).data), shareReplay(RXJS_SHARE_REPLAY_DEFAULTS));This subscription on internalStore.changeStream() is never unsubscribed. Combined with this.replicationState never being assigned (#7786), cancel() cannot clean up the replication either. Each restart creates new instances of these subscriptions, leading to ~100 MB/min memory growth.
Reproduction
- Create a collection with
autoMigrate: falseand schema version > 0 - Call
migratePromise()— migration completes successfully - Restart the app
migrationNeeded()still returnstrue(Bug 1)migratePromise()resolves instantly but leaks subscriptions (Bug 2)- Memory grows continuously
Suggested Fixes
-
Bug 1: Re-fetch
oldCollectionMetawith a fresh_revbefore attempting deletion, or usebulkWritewith the current document state instead of the stale reference from the start ofstartMigration(). -
Bug 2:
awaitthestartMigration()call inmigratePromise(), or restructure so thatmigratePromise()doesn't race against a stale status doc. -
Cleanup: Ensure the
observeSinglesubscription onthis.$is unsubscribed when migration completes or is canceled.
Workaround
Before calling migrationNeeded(), manually delete orphaned old collection meta docs from the internal store using bulkWrite (not writeSingle) with the current document as previous. Only do this when a DONE migration status doc exists for the current schema version, confirming migration already completed:
const statusDocId = `rx-migration-status|${col.name}-v-${col.schema.version}`;
const statusDocs = await internalStore.findDocumentsById([statusDocId], false);
const hasDoneStatus = statusDocs[0]?.data?.status === 'DONE';
if (hasDoneStatus) {
const docId = `collection|${col.name}-${oldVersion}`;
const docs = await internalStore.findDocumentsById([docId], false);
if (docs[0]) {
await internalStore.bulkWrite([{
previous: docs[0],
document: { ...docs[0], _deleted: true },
}], 'cleanup-old-collection-meta');
}
}