/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", FirefoxLabs: "resource://nimbus/FirefoxLabs.sys.mjs", NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", NimbusTelemetry: "resource://nimbus/lib/Telemetry.sys.mjs", ProfilesDatastoreService: "moz-src:///toolkit/profile/ProfilesDatastoreService.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "log", () => { const { Logger } = ChromeUtils.importESModule( "resource://messaging-system/lib/Logger.sys.mjs" ); return new Logger("NimbusMigrations"); }); /** * A named migration. * * @typedef {object} Migration * * @property {string} name The name of the migration. This will be reported in * telemetry. * * @property {function(): void} fn The migration implementation. */ /** * Construct a {@link Migration} with a specific name. * * @param {string} name The name of the migration. * @param {function(): void} fn The migration function. * * @returns {Migration} The migration. */ function migration(name, fn) { return { name, fn }; } const Phase = Object.freeze({ INIT_STARTED: "init-started", AFTER_STORE_INITIALIZED: "after-store-initialized", AFTER_REMOTE_SETTINGS_UPDATE: "after-remote-settings-update", }); /** * An initialization phase. * * @typedef {typeof Phase[keyof typeof Phase]} Phase */ export const LEGACY_NIMBUS_MIGRATION_PREF = "nimbus.migrations.latest"; /** @type {Record} */ export const NIMBUS_MIGRATION_PREFS = Object.fromEntries( Object.entries(Phase).map(([, v]) => [v, `nimbus.migrations.${v}`]) ); export const LABS_MIGRATION_FEATURE_MAP = { "auto-pip": "firefox-labs-auto-pip", "urlbar-ime-search": "firefox-labs-urlbar-ime-search", "jpeg-xl": "firefox-labs-jpeg-xl", }; /** * Migrate from the legacy migration state to multi-phase migration state. * * Previously there was only a single set of migrations that ran at the end of * `ExperimentAPI.init()`, which is now the "after-remote-settings-update" phase. */ function migrateMultiphase() { const latestMigration = Services.prefs.getIntPref( LEGACY_NIMBUS_MIGRATION_PREF, -1 ); if (latestMigration >= 0) { Services.prefs.setIntPref( NIMBUS_MIGRATION_PREFS[Phase.AFTER_REMOTE_SETTINGS_UPDATE], latestMigration ); Services.prefs.clearUserPref(LEGACY_NIMBUS_MIGRATION_PREF); } } function migrateNoop() { // This migration intentionally left blank. // // Bug 1956080 added `migrateEnrollmentsToSql` but the actual Nimbus profile // ID added in that bug wasn't persistent (see bug 1969994) and reset every // startup. Therefore the rows created by the first run of // `migrateEnrollmentsToSql` are no longer associated with any profile and the // `migrateEnrollmentsToSql` migration needs to run again with a new profile // ID. A seperate migration was added in `ProfilesDatastoreService` to delete all // entries from the `NimbusEnrollments` table. // // To prevent the `migrateEnrollmentsToSql` migration from running twice on a // new client, this migration takes the place of the original // `migrateEnrollmentsToSql`. } async function migrateEnrollmentsToSql() { if ( !Services.prefs.getBoolPref( "nimbus.profilesdatastoreservice.enabled", false ) ) { // We are in an xpcshell test that has not initialized the // ProfilesDatastoreService. // // TODO(bug 1967779): require the ProfilesDatastoreService to be initialized // and remove this check. return; } const profileId = lazy.ExperimentAPI.profileId; // This migration runs before the ExperimentManager is fully initialized. We // need to initialize *just* the ExperimentStore so that we can copy its // enrollments to the SQL database. This must occur *before* the // ExperimentManager is initialized because that may cause unenrollments and // those enrollments need to exist in both the ExperimentStore and SQL // database. const enrollments = await lazy.ExperimentAPI.manager.store.getAll(); // Likewise, the set of all recipes is const { recipes } = await lazy.ExperimentAPI._rsLoader.getRecipesFromAllCollections(); const recipesBySlug = new Map(recipes.map(r => [r.slug, r])); const rows = enrollments.map(enrollment => { const { active, slug, source } = enrollment; let recipe; if (source === "rs-loader") { recipe = recipesBySlug.get(slug); } if (!recipe) { // If this enrollment is not from the RemoteSettingsExperimentLoader or // the experiment has since ended we re-create as much of the recipe as we // can from the enrollment. // // We are early in Nimbus startup and we have not yet called // ExperimentManager.onStartup. When the ExperimentManager is initialized // later in ExperimentAPI.init() it may cause unenrollments due to state // being changed (e.g., if studies have been disabled). To process those // unenrollments, there needs to be an enrollment record // in the database for *every* enrollment in the JSON store *and* each // needs to have a valid `recipe` field because in bug 1956082 we will // stop using ExperimentStoreData.json as the source-of-truth and rely // entirely on the NimbusEnrollments table. recipe = { slug, userFacingName: enrollment.userFacingName, userFacingDescription: enrollment.userFacingDescription, featureIds: enrollment.featureIds, isRollout: enrollment.isRollout ?? false, localizations: enrollment.localizations ?? null, isFirefoxLabsOptIn: enrollment.isFirefoxLabsOptIn ?? false, firefoxLabsTitle: enrollment.firefoxLabsTitle ?? false, firefoxLabsDescription: enrollment.firefoxLabsDescription ?? null, firefoxLabsDescriptionLinks: enrollment.firefoxLabsDescriptionLinks ?? null, firefoxLabsGroup: enrollment.firefoxLabsGroup ?? null, requiresRestart: enrollment.requiresRestart ?? false, branches: [ { ...enrollment.branch, ratio: enrollment.branch.ratio ?? 1, }, ], }; } return { profileId, slug, branchSlug: enrollment.branch.slug, recipe: recipe ? JSON.stringify(recipe) : null, active, unenrollReason: active ? null : enrollment.unenrollReason, lastSeen: enrollment.lastSeen ?? new Date().toJSON(), setPrefs: enrollment.prefs ? JSON.stringify(enrollment.prefs) : null, prefFlips: enrollment.prefFlips ? JSON.stringify(enrollment.prefFlips) : null, source, }; }); const conn = await lazy.ProfilesDatastoreService.getConnection(); await conn.executeTransaction(async () => { for (const row of rows) { await conn.execute( ` INSERT INTO NimbusEnrollments VALUES( null, :profileId, :slug, :branchSlug, jsonb(:recipe), :active, :unenrollReason, :lastSeen, jsonb(:setPrefs), jsonb(:prefFlips), :source );`, row ); } }); } /** * Migrate the pre-Nimbus Firefox Labs experiences into Nimbus enrollments. * * Previously Firefox Labs had a one-to-one correlation between Labs Experiments * and prefs being set. If any of those prefs are set, attempt to enroll in the * corresponding live Nimbus rollout. * * Once these rollouts end (i.e., because the features are generally available * and no longer in Labs) they can be removed from {@link * LABS_MIGRATION_FEATURE_MAP} and once that map is empty this migration can be * replaced with a no-op. */ async function migrateFirefoxLabsEnrollments() { const bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( Ci.nsIBackgroundTasks ); if (bts?.isBackgroundTaskMode) { // This migration does not apply to background task mode. return; } await lazy.ExperimentAPI._rsLoader.finishedUpdating(); await lazy.ExperimentAPI._rsLoader.withUpdateLock( async () => { const labs = await lazy.FirefoxLabs.create(); let didEnroll = false; for (const [feature, slug] of Object.entries( LABS_MIGRATION_FEATURE_MAP )) { const pref = lazy.NimbusFeatures[feature].manifest.variables.enabled.setPref.pref; if (!labs.get(slug)) { // If the recipe is not available then either it is no longer live or // the targeting did not match. continue; } if (!Services.prefs.getBoolPref(pref, false)) { // Only enroll if the migration pref is set. continue; } await labs.enroll(slug, "control"); // We need to overwrite the original pref value stored in the // ExperimentStore so that unenrolling will disable the feature. const enrollment = lazy.ExperimentAPI.manager.store.get(slug); if (!enrollment) { lazy.log.error(`Enrollment with ${slug} should exist but does not`); continue; } if (!enrollment.active) { lazy.log.error( `Enrollment with slug ${slug} should be active but is not.` ); continue; } const prefEntry = enrollment.prefs?.find(entry => entry.name === pref); if (!prefEntry) { lazy.log.error( `Enrollment with slug ${slug} does not set pref ${pref}` ); continue; } didEnroll = true; prefEntry.originalValue = false; } if (didEnroll) { // Trigger a save of the ExperimentStore since we've changed some data // structures without using set(). // We do not have to sync these changes to child processes because the // data is only used in the parent process. lazy.ExperimentAPI.manager.store._store.saveSoon(); } }, { mode: "shared" } ); } export class MigrationError extends Error { static Reason = Object.freeze({ UNKNOWN: "unknown", }); constructor(reason) { super(`Migration error: ${reason}`); this.reason = reason; } } export const NimbusMigrations = { Phase, migration, /** * Apply any outstanding migrations for the given phase. * * The first migration in the phase to report an error will halt the * application of further migrations in the phase. * * @param {Phase} phase The phase of migrations to apply. * */ async applyMigrations(phase) { const phasePref = NIMBUS_MIGRATION_PREFS[phase]; const latestMigration = Services.prefs.getIntPref(phasePref, -1); let lastSuccess = latestMigration; lazy.log.debug( `applyMigrations ${phase}: latestMigration = ${latestMigration}` ); for (let i = latestMigration + 1; i < this.MIGRATIONS[phase].length; i++) { const migration = this.MIGRATIONS[phase][i]; lazy.log.debug( `applyMigrations ${phase}: applying migration ${i}: ${migration.name}` ); try { await migration.fn(); } catch (e) { lazy.log.error( `applyMigrations: error running migration ${i} (${migration.name}): ${e}` ); let reason = MigrationError.Reason.UNKNOWN; if (e instanceof MigrationError) { reason = e.reason; } lazy.NimbusTelemetry.recordMigration(migration.name, reason); break; } lastSuccess = i; lazy.log.debug( `applyMigrations: applied migration ${i}: ${migration.name}` ); lazy.NimbusTelemetry.recordMigration(migration.name); } if (latestMigration != lastSuccess) { Services.prefs.setIntPref(phasePref, lastSuccess); } }, /** * @type {Record} */ MIGRATIONS: { [Phase.INIT_STARTED]: [ migration("multi-phase-migrations", migrateMultiphase), ], [Phase.AFTER_STORE_INITIALIZED]: [ migration("noop", migrateNoop), migration("import-enrollments-to-sql", migrateEnrollmentsToSql), ], [Phase.AFTER_REMOTE_SETTINGS_UPDATE]: [ migration("firefox-labs-enrollments", migrateFirefoxLabsEnrollments), ], }, };