Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
13ffee0
feat: add workarounds for database corruption scenarios
gauthierpetetin Jan 5, 2026
ba96097
Fix linting issues
gauthierpetetin Jan 5, 2026
8a521e8
Add showDatabaseCorruptionToast to snapshots and fixtures
gauthierpetetin Jan 5, 2026
5811292
Add showDatabaseCorruptionToast to Sentry state
gauthierpetetin Jan 5, 2026
8ec2ba1
Place showDatabaseCorruptionToast at the right location based on alph…
gauthierpetetin Jan 5, 2026
ea89449
Add missing showDatabaseCorruptionToast property
gauthierpetetin Jan 5, 2026
b91a0c4
Remove showDatabaseCorruptionToast from before-init snapshots
gauthierpetetin Jan 5, 2026
acd13e0
Merge branch 'main' into database-corruption-workarounds
gauthierpetetin Jan 5, 2026
957b08d
Improve logic that determines when to show the toast
gauthierpetetin Jan 6, 2026
04d4af6
Merge branch 'main' into database-corruption-workarounds
gauthierpetetin Jan 6, 2026
9d109d7
Consolidate vault recovery logic and respect validateVault flag
gauthierpetetin Jan 7, 2026
a0849b3
Rename variables
gauthierpetetin Jan 7, 2026
ed13ab3
Rename toast variables for more generic name
gauthierpetetin Jan 7, 2026
b3fa0af
Merge branch 'main' into database-corruption-workarounds
gauthierpetetin Jan 7, 2026
67776f7
Update comments
gauthierpetetin Jan 7, 2026
c6a7e67
Prevent backup failures
gauthierpetetin Jan 7, 2026
a6ac6dd
Update copies
gauthierpetetin Jan 7, 2026
04766ce
Update copy
gauthierpetetin Jan 7, 2026
d4bfbe3
feat: add instrumentation to database corruption issues
gauthierpetetin Jan 8, 2026
a29601f
Fix linting error
gauthierpetetin Jan 8, 2026
84b2ee7
Call notifySetFailed on every error
gauthierpetetin Jan 8, 2026
5d35173
Merge remote-tracking branch 'origin/database-corruption-workarounds'…
gauthierpetetin Jan 8, 2026
7a23cab
Merge branch 'main' into database-corruption-workarounds
gauthierpetetin Jan 9, 2026
988359a
Merge branch 'database-corruption-workarounds' into database-corrupti…
gauthierpetetin Jan 9, 2026
879b18f
Differentiate backup failures from local storage failures in Sentry
gauthierpetetin Jan 12, 2026
eb4558c
Track temporary set failures
gauthierpetetin Jan 13, 2026
03a318d
Merge branch 'main' into database-corruption-workarounds
gauthierpetetin Jan 13, 2026
7fbb7a7
Merge branch 'database-corruption-workarounds' into database-corrupti…
gauthierpetetin Jan 13, 2026
acd3adf
Merge remote-tracking branch 'origin/main' into database-corruption-w…
gauthierpetetin Jan 14, 2026
598145d
Fix state persistence inconsistency in backup code path
gauthierpetetin Jan 15, 2026
4f6547d
Merge branch 'main' into database-corruption-workarounds-instrumentation
gauthierpetetin Jan 15, 2026
1ca4d4d
Fix unit test
gauthierpetetin Jan 15, 2026
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
20 changes: 19 additions & 1 deletion app/scripts/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { finished } from 'readable-stream';
import log from 'loglevel';
import browser from 'webextension-polyfill';
import { isObject, hasProperty } from '@metamask/utils';
import { deriveStateFromMetadata } from '@metamask/base-controller';
import { ExtensionPortStream } from 'extension-port-stream';
import { withResolvers } from '../../shared/lib/promise-with-resolvers';
import { FirstTimeFlowType } from '../../shared/constants/onboarding';
Expand Down Expand Up @@ -1286,9 +1287,26 @@ export function setupController(
// already updated this one
return;
}
const state = controller.controllerMessenger.call(
// Get the state for this backed-up key using messenger.
// We filter to only persistent properties using deriveStateFromMetadata
// to match what ComposableObservableStore does in stateChange events.
// This ensures non-persistent properties (e.g., KeyringController's
// isUnlocked, keyrings, encryptionKey) are not written to storage.
const controllerConfig = controller.store.config[key];
if (!controllerConfig?.metadata) {
throw new Error(
`Cannot backup ${key}: controller metadata is required but not found. ` +
`All controllers in backedUpStateKeys must extend BaseController and define metadata.`,
);
}
const fullState = controller.controllerMessenger.call(
`${key}:getState`,
);
const state = deriveStateFromMetadata(
fullState,
controllerConfig.metadata,
'persist',
);
persistenceManager.update(key, state);
});
}
Copy link
Contributor Author

@gauthierpetetin gauthierpetetin Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a fix introduced in this commit: 598145d
Without this fix, should update from data state to split state E2E test was failing.


Adding MetaMetricsController to the list of backed up controllers (cf. discussion here) broke the should update from data state to split state E2E test.
It seems we were persisting the full state to storage (including non-persistent properties), instead of only persisting the persistent properties. This wasn’t visible before adding MetaMetricsController because the other backed up controllers (KeyringController and AppMetadataController) didn’t change very frequently, whereas MetaMetricsController changes more often. Persisting only persistent properties fixed the E2E test.

Expand Down
10 changes: 10 additions & 0 deletions app/scripts/lib/setup-initial-state-hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ globalThis.stateHooks.getPersistedState = async function () {
return await sentryLocalStore.get({ validateVault: false });
};

/**
* Get the backup state from IndexedDB.
* This is used as a fallback when primary storage is unavailable.
*
* @returns The backup state, or null if unavailable.
*/
globalThis.stateHooks.getBackupState = async function () {
return await sentryLocalStore.getBackup();
};

const persistedStateMask = {
data: SENTRY_BACKGROUND_STATE,
meta: {
Expand Down
21 changes: 19 additions & 2 deletions app/scripts/lib/setupSentry.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,16 @@ function getMetaMetricsEnabledFromPersistedState(persistedState) {
);
}

/**
* Returns whether MetaMetrics is enabled, given the backup state.
*
* @param {unknown} backupState - Backup state from IndexedDB
* @returns `true` if MetaMetrics is enabled in the backup, `false` otherwise.
*/
function getMetaMetricsEnabledFromBackupState(backupState) {
return Boolean(backupState?.MetaMetricsController?.participateInMetaMetrics);
}

function getSentryEnvironment() {
if (METAMASK_BUILD_TYPE === 'main') {
return METAMASK_ENVIRONMENT;
Expand Down Expand Up @@ -265,8 +275,15 @@ async function getMetaMetricsEnabled() {
const persistedState = await globalThis.stateHooks.getPersistedState();
return getMetaMetricsEnabledFromPersistedState(persistedState);
} catch (error) {
log('Error retrieving persisted state', error);
return false;
log('Error retrieving persisted state, falling back to backup', error);
// Primary storage failed (e.g., database corruption) - check the backup
try {
const backupState = await globalThis.stateHooks.getBackupState();
return getMetaMetricsEnabledFromBackupState(backupState);
} catch (backupError) {
log('Error retrieving backup state', backupError);
return false;
}
}
}

Expand Down
59 changes: 55 additions & 4 deletions app/scripts/lib/stores/persistence-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
import 'navigator.locks';
import log from 'loglevel';

import { captureException } from '../../../../shared/lib/sentry';
import {
captureException,
captureMessage,
} from '../../../../shared/lib/sentry';
import { MISSING_VAULT_ERROR } from '../../../../shared/constants/errors';
import { PersistenceManager } from './persistence-manager';
import ExtensionStore from './extension-store';
Expand Down Expand Up @@ -32,8 +35,10 @@ jest.mock('loglevel', () => ({
}));
jest.mock('../../../../shared/lib/sentry', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
}));
const mockedCaptureException = jest.mocked(captureException);
const mockedCaptureMessage = jest.mocked(captureMessage);

describe('PersistenceManager', () => {
let manager: PersistenceManager;
Expand Down Expand Up @@ -78,7 +83,10 @@ describe('PersistenceManager', () => {
mockStoreSet.mockRejectedValueOnce(error);

await manager.set({ appState: { broken: true } });
expect(mockedCaptureException).toHaveBeenCalledWith(error);
expect(mockedCaptureException).toHaveBeenCalledWith(error, {
tags: { 'persistence.error': 'set-failed' },
fingerprint: ['persistence-error', 'set-failed'],
});
expect(log.error).toHaveBeenCalledWith(
'error setting state in local store:',
error,
Expand Down Expand Up @@ -116,6 +124,43 @@ describe('PersistenceManager', () => {

expect(mockedCaptureException).toHaveBeenCalledTimes(2);
});

it('tracks recovery with captureMessage when store.set fails then succeeds', async () => {
manager.setMetadata({ version: 17 });

const error = new Error('store.set error');
mockStoreSet.mockRejectedValueOnce(error);

// First set fails
await manager.set({ appState: { broken: true } });

expect(mockedCaptureException).toHaveBeenCalledTimes(1);
expect(mockedCaptureMessage).not.toHaveBeenCalled();

// Second set succeeds - should trigger recovery tracking
mockStoreSet.mockResolvedValueOnce(undefined);
await manager.set({ appState: { fixed: true } });

expect(mockedCaptureMessage).toHaveBeenCalledTimes(1);
expect(mockedCaptureMessage).toHaveBeenCalledWith(
'Data persistence recovered after temporary failure',
{
level: 'info',
tags: { 'persistence.event': 'set-recovered' },
fingerprint: ['persistence-event', 'set-recovered'],
},
);
});

it('does not track recovery if set never failed', async () => {
manager.setMetadata({ version: 17 });

// Set succeeds without prior failure
mockStoreSet.mockResolvedValueOnce(undefined);
await manager.set({ appState: { working: true } });

expect(mockedCaptureMessage).not.toHaveBeenCalled();
});
});

describe('get', () => {
Expand Down Expand Up @@ -264,7 +309,10 @@ describe('PersistenceManager', () => {

await manager.persist();

expect(mockedCaptureException).toHaveBeenCalledWith(error);
expect(mockedCaptureException).toHaveBeenCalledWith(error, {
tags: { 'persistence.error': 'persist-failed' },
fingerprint: ['persistence-error', 'persist-failed'],
});
expect(log.error).toHaveBeenCalledWith(
'error setting state in local store:',
error,
Expand Down Expand Up @@ -423,7 +471,10 @@ describe('PersistenceManager', () => {
// returns `undefined`
expect(await brokenManager.getBackup()).toBeUndefined();

expect(mockedCaptureException).toHaveBeenCalledWith(domException);
expect(mockedCaptureException).toHaveBeenCalledWith(domException, {
tags: { 'persistence.error': 'backup-db-open-failed' },
fingerprint: ['persistence-error', 'backup-db-open-failed'],
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Could not open backup database; automatic vault recovery will not be available.',
);
Expand Down
106 changes: 93 additions & 13 deletions app/scripts/lib/stores/persistence-manager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import log from 'loglevel';
import { isEmpty } from 'lodash';
import { RuntimeObject, hasProperty, isObject } from '@metamask/utils';
import { captureException } from '../../../../shared/lib/sentry';
import {
captureException,
captureMessage,
} from '../../../../shared/lib/sentry';
import { MISSING_VAULT_ERROR } from '../../../../shared/constants/errors';
import { getManifestFlags } from '../../../../shared/lib/manifestFlags';
import { IndexedDBStore } from './indexeddb-store';
Expand All @@ -22,12 +25,16 @@ export type Backup = {
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860
// eslint-disable-next-line @typescript-eslint/naming-convention
AppMetadataController?: unknown;
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860
// eslint-disable-next-line @typescript-eslint/naming-convention
MetaMetricsController?: unknown;
meta?: MetaData;
};

export const backedUpStateKeys = [
'KeyringController',
'AppMetadataController',
'MetaMetricsController',
] as const;

export type BackedUpStateKey = (typeof backedUpStateKeys)[number];
Expand Down Expand Up @@ -237,7 +244,13 @@ export class PersistenceManager {
error.message ===
'A mutation operation was attempted on a database that did not allow mutations.'
) {
captureException(error);
// Custom fingerprint prevents Sentry's deduplication from dropping
// this event when other persistence errors with the same underlying
// error message (e.g., "An unexpected error occurred") are reported.
captureException(error, {
tags: { 'persistence.error': 'backup-db-open-failed' },
fingerprint: ['persistence-error', 'backup-db-open-failed'],
});
console.warn(
'Could not open backup database; automatic vault recovery will not be available.',
);
Expand Down Expand Up @@ -326,6 +339,8 @@ export class PersistenceManager {
{ mode: 'exclusive', signal: abortController.signal },
async () => {
this.#currentLockAbortController = undefined;
// Track which operation failed to use the correct Sentry tag
let backupFailed = false;
try {
// atomically set all the keys
await this.#localStore.set({
Expand All @@ -339,19 +354,44 @@ export class PersistenceManager {
const stringifiedBackup = JSON.stringify(backup);
// and the backup has changed
if (this.#backup !== stringifiedBackup) {
// save it to the backup DB
await this.#backupDb?.set(backup);
this.#backup = stringifiedBackup;
// save it to the backup DB - wrapped in try-catch to differentiate
// backup failures from storage.local failures in Sentry
try {
await this.#backupDb?.set(backup);
this.#backup = stringifiedBackup;
} catch (backupErr) {
backupFailed = true;
throw backupErr;
}
}
}

if (this.#dataPersistenceFailing) {
this.#dataPersistenceFailing = false;
// Track recovery to understand how often failures are temporary.
// This helps answer: "Do set calls ever fail and then succeed in the same session?"
captureMessage(
'Data persistence recovered after temporary failure',
{
level: 'info',
tags: { 'persistence.event': 'set-recovered' },
fingerprint: ['persistence-event', 'set-recovered'],
},
);
}
} catch (err) {
if (!this.#dataPersistenceFailing) {
this.#dataPersistenceFailing = true;
captureException(err);
// Use different tags to differentiate storage.local vs IndexedDB backup failures.
const tag = backupFailed ? 'set-backup-failed' : 'set-failed';

// Custom fingerprint prevents Sentry's deduplication from dropping
// this event when other persistence errors with the same underlying
// error message (e.g., "An unexpected error occurred") are reported.
captureException(err, {
tags: { 'persistence.error': tag },
fingerprint: ['persistence-error', tag],
});
}
this.#notifySetFailed();
log.error('error setting state in local store:', err);
Expand Down Expand Up @@ -413,6 +453,8 @@ export class PersistenceManager {
{ mode: 'exclusive', signal: abortController.signal },
async () => {
this.#currentLockAbortController = undefined;
// Track which operation failed to use the correct Sentry tag
let backupFailed = false;
try {
const clone = structuredClone(this.#pendingPairs);
// reset the pendingPairs
Expand Down Expand Up @@ -441,17 +483,44 @@ export class PersistenceManager {
}
if (hasVault(partialState)) {
const backup = makeBackup(partialState, meta);
// save it to the backup DB
await this.#backupDb?.set(backup);
// save it to the backup DB - wrapped in try-catch to differentiate
// backup failures from storage.local failures in Sentry
try {
await this.#backupDb?.set(backup);
} catch (backupErr) {
backupFailed = true;
throw backupErr;
}
}

if (this.#dataPersistenceFailing) {
this.#dataPersistenceFailing = false;
// Track recovery to understand how often failures are temporary.
// This helps answer: "Do set calls ever fail and then succeed in the same session?"
captureMessage(
'Data persistence recovered after temporary failure',
{
level: 'info',
tags: { 'persistence.event': 'persist-recovered' },
fingerprint: ['persistence-event', 'persist-recovered'],
},
);
}
} catch (err) {
if (!this.#dataPersistenceFailing) {
this.#dataPersistenceFailing = true;
captureException(err);
// Use different tags to differentiate storage.local vs IndexedDB backup failures.
const tag = backupFailed
? 'persist-backup-failed'
: 'persist-failed';

// Custom fingerprint prevents Sentry's deduplication from dropping
// this event when other persistence errors with the same underlying
// error message (e.g., "An unexpected error occurred") are reported.
captureException(err, {
tags: { 'persistence.error': tag },
fingerprint: ['persistence-error', tag],
});
}
this.#notifySetFailed();
log.error('error setting state in local store:', err);
Expand Down Expand Up @@ -493,12 +562,19 @@ export class PersistenceManager {
])
.catch((error: Error): [Error, undefined] => [error, undefined]);

// Log the error if one occurred, but don't throw yet
// Log and capture the error if one occurred, but don't throw yet
if (localStoreError) {
log.error(
'Error retrieving the current state of the local store:',
localStoreError,
);
// Custom fingerprint prevents Sentry's deduplication from dropping
// this event when other persistence errors with the same underlying
// error message (e.g., "An unexpected error occurred") are reported.
captureException(localStoreError, {
tags: { 'persistence.error': 'get-failed' },
fingerprint: ['persistence-error', 'get-failed'],
});
}

if (validateVault) {
Expand Down Expand Up @@ -601,12 +677,16 @@ export class PersistenceManager {
if (!backupDb) {
return undefined;
}
const [KeyringController, AppMetadataController, meta] = await backupDb.get(
[...backedUpStateKeys, `meta`],
);
const [
KeyringController,
AppMetadataController,
MetaMetricsController,
meta,
] = await backupDb.get([...backedUpStateKeys, `meta`]);
return {
KeyringController,
AppMetadataController,
MetaMetricsController,
meta: meta as MetaData | undefined,
};
}
Expand Down
Loading