Bug 1910953 - Use a Web Lock to sequence deletions and creations of backups. r=backup-reviewers,fchasen

This allows us to avoid creating a backup while we're in the middle of deleting
one, and deleting a backup when we're in the middle of creating one.

An AbortController is used to clear the lock's request queue on shutdown
in the (unlikely) event that a whole slew of backup creation and deletion
requests have queued up.

Differential Revision: https://phabricator.services.mozilla.com/D218240
This commit is contained in:
Mike Conley
2024-08-06 13:54:35 +00:00
parent b93742c566
commit acf9e6ac1f

View File

@@ -648,6 +648,14 @@ export class BackupService extends EventTarget {
*/ */
#encState = undefined; #encState = undefined;
/**
* The AbortController used to abort any queued requests to create or delete
* backups that might be waiting on the WRITE_BACKUP_LOCK_NAME lock.
*
* @type {AbortController}
*/
#backupWriteAbortController = null;
/** /**
* The path of the default parent directory for saving backups. * The path of the default parent directory for saving backups.
* The current default is the Documents directory. * The current default is the Documents directory.
@@ -840,6 +848,16 @@ export class BackupService extends EventTarget {
return AppConstants.MOZ_APP_BASENAME + " Backup Recovery Storage"; return AppConstants.MOZ_APP_BASENAME + " Backup Recovery Storage";
} }
/**
* The name of the exclusive Web Lock that will be requested and held when
* creating or deleting a backup.
*
* @type {string}
*/
static get WRITE_BACKUP_LOCK_NAME() {
return "write-backup";
}
/** /**
* Returns a reference to a BackupService singleton. If this is the first time * Returns a reference to a BackupService singleton. If this is the first time
* that this getter is accessed, this causes the BackupService singleton to be * that this getter is accessed, this causes the BackupService singleton to be
@@ -897,6 +915,7 @@ export class BackupService extends EventTarget {
let { promise, resolve } = Promise.withResolvers(); let { promise, resolve } = Promise.withResolvers();
this.#postRecoveryPromise = promise; this.#postRecoveryPromise = promise;
this.#postRecoveryResolver = resolve; this.#postRecoveryResolver = resolve;
this.#backupWriteAbortController = new AbortController();
} }
/** /**
@@ -1069,205 +1088,221 @@ export class BackupService extends EventTarget {
return null; return null;
} }
this.#backupInProgress = true; return locks.request(
const backupTimer = Glean.browserBackup.totalBackupTime.start(); BackupService.WRITE_BACKUP_LOCK_NAME,
{ signal: this.#backupWriteAbortController.signal },
async () => {
this.#backupInProgress = true;
const backupTimer = Glean.browserBackup.totalBackupTime.start();
try {
lazy.logConsole.debug(`Creating backup for profile at ${profilePath}`);
let archiveDestFolderPath = await this.resolveArchiveDestFolderPath(
lazy.backupDirPref
);
lazy.logConsole.debug(
`Destination for archive: ${archiveDestFolderPath}`
);
let manifest = await this.#createBackupManifest();
// First, check to see if a `backups` directory already exists in the
// profile.
let backupDirPath = PathUtils.join(
profilePath,
BackupService.PROFILE_FOLDER_NAME,
BackupService.SNAPSHOTS_FOLDER_NAME
);
lazy.logConsole.debug("Creating backups folder");
// ignoreExisting: true is the default, but we're being explicit that it's
// okay if this folder already exists.
await IOUtils.makeDirectory(backupDirPath, {
ignoreExisting: true,
createAncestors: true,
});
let stagingPath = await this.#prepareStagingFolder(backupDirPath);
// Sort resources be priority.
let sortedResources = Array.from(this.#resources.values()).sort(
(a, b) => {
return b.priority - a.priority;
}
);
let encState = await this.loadEncryptionState(profilePath);
let encryptionEnabled = !!encState;
lazy.logConsole.debug("Encryption enabled: ", encryptionEnabled);
// Perform the backup for each resource.
for (let resourceClass of sortedResources) {
try { try {
lazy.logConsole.debug( lazy.logConsole.debug(
`Backing up resource with key ${resourceClass.key}. ` + `Creating backup for profile at ${profilePath}`
`Requires encryption: ${resourceClass.requiresEncryption}`
); );
if (resourceClass.requiresEncryption && !encryptionEnabled) { let archiveDestFolderPath = await this.resolveArchiveDestFolderPath(
lazy.logConsole.debug( lazy.backupDirPref
"Encryption is not currently enabled. Skipping." );
); lazy.logConsole.debug(
continue; `Destination for archive: ${archiveDestFolderPath}`
} );
let resourcePath = PathUtils.join(stagingPath, resourceClass.key); let manifest = await this.#createBackupManifest();
await IOUtils.makeDirectory(resourcePath);
// `backup` on each BackupResource should return us a ManifestEntry // First, check to see if a `backups` directory already exists in the
// that we eventually write to a JSON manifest file, but for now, // profile.
// we're just going to log it. let backupDirPath = PathUtils.join(
let manifestEntry = await new resourceClass().backup(
resourcePath,
profilePath, profilePath,
encryptionEnabled BackupService.PROFILE_FOLDER_NAME,
BackupService.SNAPSHOTS_FOLDER_NAME
);
lazy.logConsole.debug("Creating backups folder");
// ignoreExisting: true is the default, but we're being explicit that it's
// okay if this folder already exists.
await IOUtils.makeDirectory(backupDirPath, {
ignoreExisting: true,
createAncestors: true,
});
let stagingPath = await this.#prepareStagingFolder(backupDirPath);
// Sort resources be priority.
let sortedResources = Array.from(this.#resources.values()).sort(
(a, b) => {
return b.priority - a.priority;
}
); );
if (manifestEntry === undefined) { let encState = await this.loadEncryptionState(profilePath);
lazy.logConsole.error( let encryptionEnabled = !!encState;
`Backup of resource with key ${resourceClass.key} returned undefined lazy.logConsole.debug("Encryption enabled: ", encryptionEnabled);
as its ManifestEntry instead of null or an object`
); // Perform the backup for each resource.
} else { for (let resourceClass of sortedResources) {
lazy.logConsole.debug( try {
`Backup of resource with key ${resourceClass.key} completed`, lazy.logConsole.debug(
manifestEntry `Backing up resource with key ${resourceClass.key}. ` +
); `Requires encryption: ${resourceClass.requiresEncryption}`
manifest.resources[resourceClass.key] = manifestEntry; );
if (resourceClass.requiresEncryption && !encryptionEnabled) {
lazy.logConsole.debug(
"Encryption is not currently enabled. Skipping."
);
continue;
}
let resourcePath = PathUtils.join(stagingPath, resourceClass.key);
await IOUtils.makeDirectory(resourcePath);
// `backup` on each BackupResource should return us a ManifestEntry
// that we eventually write to a JSON manifest file, but for now,
// we're just going to log it.
let manifestEntry = await new resourceClass().backup(
resourcePath,
profilePath,
encryptionEnabled
);
if (manifestEntry === undefined) {
lazy.logConsole.error(
`Backup of resource with key ${resourceClass.key} returned undefined
as its ManifestEntry instead of null or an object`
);
} else {
lazy.logConsole.debug(
`Backup of resource with key ${resourceClass.key} completed`,
manifestEntry
);
manifest.resources[resourceClass.key] = manifestEntry;
}
} catch (e) {
lazy.logConsole.error(
`Failed to backup resource: ${resourceClass.key}`,
e
);
}
} }
} catch (e) {
lazy.logConsole.error( // Ensure that the manifest abides by the current schema, and log
`Failed to backup resource: ${resourceClass.key}`, // an error if somehow it doesn't. We'll want to collect telemetry for
e // this case to make sure it's not happening in the wild. We debated
// throwing an exception here too, but that's not meaningfully better
// than creating a backup that's not schema-compliant. At least in this
// case, a user so-inclined could theoretically repair the manifest
// to make it valid.
let manifestSchema = await BackupService.MANIFEST_SCHEMA;
let schemaValidationResult = lazy.JsonSchema.validate(
manifest,
manifestSchema
); );
if (!schemaValidationResult.valid) {
lazy.logConsole.error(
"Backup manifest does not conform to schema:",
manifest,
manifestSchema,
schemaValidationResult
);
// TODO: Collect telemetry for this case. (bug 1891817)
}
// Write the manifest to the staging folder.
let manifestPath = PathUtils.join(
stagingPath,
BackupService.MANIFEST_FILE_NAME
);
await IOUtils.writeJSON(manifestPath, manifest);
let renamedStagingPath = await this.#finalizeStagingFolder(
stagingPath
);
lazy.logConsole.log(
"Wrote backup to staging directory at ",
renamedStagingPath
);
// Record the total size of the backup staging directory
let totalSizeKilobytes = await BackupResource.getDirectorySize(
renamedStagingPath
);
let totalSizeBytesNearestMebibyte = MeasurementUtils.fuzzByteSize(
totalSizeKilobytes * BYTES_IN_KILOBYTE,
1 * BYTES_IN_MEBIBYTE
);
lazy.logConsole.debug(
"total staging directory size in bytes: " +
totalSizeBytesNearestMebibyte
);
Glean.browserBackup.totalBackupSize.accumulate(
totalSizeBytesNearestMebibyte / BYTES_IN_MEBIBYTE
);
let compressedStagingPath = await this.#compressStagingFolder(
renamedStagingPath,
backupDirPath
).finally(async () => {
await IOUtils.remove(renamedStagingPath, { recursive: true });
});
// Now create the single-file archive. For now, we'll stash this in the
// backups folder while it gets written. Once that's done, we'll attempt
// to move it to the user's configured backup path.
let archiveTmpPath = PathUtils.join(backupDirPath, "archive.html");
lazy.logConsole.log(
"Exporting single-file archive to ",
archiveTmpPath
);
await this.createArchive(
archiveTmpPath,
BackupService.ARCHIVE_TEMPLATE,
compressedStagingPath,
this.#encState,
manifest.meta
).finally(async () => {
await IOUtils.remove(compressedStagingPath);
});
// Record the size of the complete single-file archive
let archiveSizeKilobytes = await BackupResource.getFileSize(
archiveTmpPath
);
let archiveSizeBytesNearestMebibyte = MeasurementUtils.fuzzByteSize(
archiveSizeKilobytes * BYTES_IN_KILOBYTE,
1 * BYTES_IN_MEBIBYTE
);
lazy.logConsole.debug(
"backup archive size in bytes: " + archiveSizeBytesNearestMebibyte
);
Glean.browserBackup.compressedArchiveSize.accumulate(
archiveSizeBytesNearestMebibyte / BYTES_IN_MEBIBYTE
);
let archivePath = await this.finalizeSingleFileArchive(
archiveTmpPath,
archiveDestFolderPath,
manifest.meta
);
let nowSeconds = Math.floor(Date.now() / 1000);
Services.prefs.setIntPref(
LAST_BACKUP_TIMESTAMP_PREF_NAME,
nowSeconds
);
this.#_state.lastBackupDate = nowSeconds;
Glean.browserBackup.totalBackupTime.stopAndAccumulate(backupTimer);
return { manifest, archivePath };
} catch {
Glean.browserBackup.totalBackupTime.cancel(backupTimer);
return null;
} finally {
this.#backupInProgress = false;
} }
} }
);
// Ensure that the manifest abides by the current schema, and log
// an error if somehow it doesn't. We'll want to collect telemetry for
// this case to make sure it's not happening in the wild. We debated
// throwing an exception here too, but that's not meaningfully better
// than creating a backup that's not schema-compliant. At least in this
// case, a user so-inclined could theoretically repair the manifest
// to make it valid.
let manifestSchema = await BackupService.MANIFEST_SCHEMA;
let schemaValidationResult = lazy.JsonSchema.validate(
manifest,
manifestSchema
);
if (!schemaValidationResult.valid) {
lazy.logConsole.error(
"Backup manifest does not conform to schema:",
manifest,
manifestSchema,
schemaValidationResult
);
// TODO: Collect telemetry for this case. (bug 1891817)
}
// Write the manifest to the staging folder.
let manifestPath = PathUtils.join(
stagingPath,
BackupService.MANIFEST_FILE_NAME
);
await IOUtils.writeJSON(manifestPath, manifest);
let renamedStagingPath = await this.#finalizeStagingFolder(stagingPath);
lazy.logConsole.log(
"Wrote backup to staging directory at ",
renamedStagingPath
);
// Record the total size of the backup staging directory
let totalSizeKilobytes = await BackupResource.getDirectorySize(
renamedStagingPath
);
let totalSizeBytesNearestMebibyte = MeasurementUtils.fuzzByteSize(
totalSizeKilobytes * BYTES_IN_KILOBYTE,
1 * BYTES_IN_MEBIBYTE
);
lazy.logConsole.debug(
"total staging directory size in bytes: " +
totalSizeBytesNearestMebibyte
);
Glean.browserBackup.totalBackupSize.accumulate(
totalSizeBytesNearestMebibyte / BYTES_IN_MEBIBYTE
);
let compressedStagingPath = await this.#compressStagingFolder(
renamedStagingPath,
backupDirPath
).finally(async () => {
await IOUtils.remove(renamedStagingPath, { recursive: true });
});
// Now create the single-file archive. For now, we'll stash this in the
// backups folder while it gets written. Once that's done, we'll attempt
// to move it to the user's configured backup path.
let archiveTmpPath = PathUtils.join(backupDirPath, "archive.html");
lazy.logConsole.log("Exporting single-file archive to ", archiveTmpPath);
await this.createArchive(
archiveTmpPath,
BackupService.ARCHIVE_TEMPLATE,
compressedStagingPath,
this.#encState,
manifest.meta
).finally(async () => {
await IOUtils.remove(compressedStagingPath);
});
// Record the size of the complete single-file archive
let archiveSizeKilobytes = await BackupResource.getFileSize(
archiveTmpPath
);
let archiveSizeBytesNearestMebibyte = MeasurementUtils.fuzzByteSize(
archiveSizeKilobytes * BYTES_IN_KILOBYTE,
1 * BYTES_IN_MEBIBYTE
);
lazy.logConsole.debug(
"backup archive size in bytes: " + archiveSizeBytesNearestMebibyte
);
Glean.browserBackup.compressedArchiveSize.accumulate(
archiveSizeBytesNearestMebibyte / BYTES_IN_MEBIBYTE
);
let archivePath = await this.finalizeSingleFileArchive(
archiveTmpPath,
archiveDestFolderPath,
manifest.meta
);
let nowSeconds = Math.floor(Date.now() / 1000);
Services.prefs.setIntPref(LAST_BACKUP_TIMESTAMP_PREF_NAME, nowSeconds);
this.#_state.lastBackupDate = nowSeconds;
Glean.browserBackup.totalBackupTime.stopAndAccumulate(backupTimer);
return { manifest, archivePath };
} catch {
Glean.browserBackup.totalBackupTime.cancel(backupTimer);
return null;
} finally {
this.#backupInProgress = false;
}
} }
/** /**
@@ -3121,6 +3156,7 @@ export class BackupService extends EventTarget {
} }
case "quit-application-granted": { case "quit-application-granted": {
this.uninitBackupScheduler(); this.uninitBackupScheduler();
this.#backupWriteAbortController.abort();
break; break;
} }
} }
@@ -3279,38 +3315,44 @@ export class BackupService extends EventTarget {
* @returns {Promise<undefined>} * @returns {Promise<undefined>}
*/ */
async deleteLastBackup() { async deleteLastBackup() {
if (this.#_state.lastBackupFileName) { return locks.request(
let backupFilePath = PathUtils.join( BackupService.WRITE_BACKUP_LOCK_NAME,
lazy.backupDirPref, { signal: this.#backupWriteAbortController.signal },
this.#_state.lastBackupFileName async () => {
); if (this.#_state.lastBackupFileName) {
let backupFilePath = PathUtils.join(
lazy.backupDirPref,
this.#_state.lastBackupFileName
);
lazy.logConsole.log( lazy.logConsole.log(
"Attempting to delete last backup file at ", "Attempting to delete last backup file at ",
backupFilePath backupFilePath
); );
await IOUtils.remove(backupFilePath, { ignoreAbsent: true }); await IOUtils.remove(backupFilePath, { ignoreAbsent: true });
this.#_state.lastBackupDate = null; this.#_state.lastBackupDate = null;
Services.prefs.clearUserPref(LAST_BACKUP_TIMESTAMP_PREF_NAME); Services.prefs.clearUserPref(LAST_BACKUP_TIMESTAMP_PREF_NAME);
this.#_state.lastBackupFileName = ""; this.#_state.lastBackupFileName = "";
Services.prefs.clearUserPref(LAST_BACKUP_FILE_NAME_PREF_NAME); Services.prefs.clearUserPref(LAST_BACKUP_FILE_NAME_PREF_NAME);
this.stateUpdate(); this.stateUpdate();
} else { } else {
lazy.logConsole.log( lazy.logConsole.log(
"Not deleting last backup file, since none is known about." "Not deleting last backup file, since none is known about."
); );
} }
if (await IOUtils.exists(lazy.backupDirPref)) { if (await IOUtils.exists(lazy.backupDirPref)) {
// See if there are any other files lingering around in the destination // See if there are any other files lingering around in the destination
// folder. If not, delete that folder too. // folder. If not, delete that folder too.
let children = await IOUtils.getChildren(lazy.backupDirPref); let children = await IOUtils.getChildren(lazy.backupDirPref);
if (!children.length) { if (!children.length) {
await IOUtils.remove(lazy.backupDirPref); await IOUtils.remove(lazy.backupDirPref);
}
}
} }
} );
} }
} }