Skip to content

Migration memory leak: old collection meta doc not deleted + migratePromise() fire-and-forget causes orphaned subscriptions #7791

@OskarD

Description

@OskarD

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:

  1. migrationNeeded() finds the orphaned old meta doc → returns true
  2. migratePromise() fires startMigration() (fire-and-forget)
  3. migratePromise() enters Promise.race on this.$ for DONE/ERROR
  4. A stale DONE status doc from the previous migration run still exists in the internal store (we observed revision 25,227 after many restarts)
  5. this.$ immediately emits the stale DONE → migratePromise() resolves in ~1ms
  6. Meanwhile, the orphaned startMigration() continues running in the background, creating storage instances, replication state, and persistent observeSingle/shareReplay subscriptions that are never cleaned up
  7. The orphaned startMigration() tries to delete the old meta doc again, fails again with _rev conflict
  8. 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

  1. Create a collection with autoMigrate: false and schema version > 0
  2. Call migratePromise() — migration completes successfully
  3. Restart the app
  4. migrationNeeded() still returns true (Bug 1)
  5. migratePromise() resolves instantly but leaks subscriptions (Bug 2)
  6. Memory grows continuously

Suggested Fixes

  1. Bug 1: Re-fetch oldCollectionMeta with a fresh _rev before attempting deletion, or use bulkWrite with the current document state instead of the stale reference from the start of startMigration().

  2. Bug 2: await the startMigration() call in migratePromise(), or restructure so that migratePromise() doesn't race against a stale status doc.

  3. Cleanup: Ensure the observeSingle subscription on this.$ 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');
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions