1
0
Fork 0
firefox/toolkit/components/nimbus/lib/Migrations.sys.mjs
Daniel Baumann 5e9a113729
Adding upstream version 140.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-25 09:37:52 +02:00

396 lines
12 KiB
JavaScript

/* 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<Phase, string>} */
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<Phase, Migration[]>}
*/
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),
],
},
};