summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs')
-rw-r--r--toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs350
1 files changed, 350 insertions, 0 deletions
diff --git a/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs b/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs
new file mode 100644
index 0000000000..52204d2fa5
--- /dev/null
+++ b/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs
@@ -0,0 +1,350 @@
+/* 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/. */
+
+import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs",
+ IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs",
+ PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
+});
+
+const log = LogManager.getLogger("recipe-runner");
+
+/**
+ * PreferenceRollouts store info about an active or expired preference rollout.
+ * @typedef {object} PreferenceRollout
+ * @property {string} slug
+ * Unique slug of the experiment
+ * @property {string} state
+ * The current state of the rollout: "active", "rolled-back", "graduated".
+ * Active means that Normandy is actively managing therollout. Rolled-back
+ * means that the rollout was previously active, but has been rolled back for
+ * this user. Graduated means that the built-in default now matches the
+ * rollout value, and so Normandy is no longer managing the preference.
+ * @property {Array<PreferenceSpec>} preferences
+ * An array of preferences specifications involved in the rollout.
+ * @property {string} enrollmentId
+ * A random ID generated at time of enrollment. It should be included on all
+ * telemetry related to this rollout. It should not be re-used by other
+ * rollouts, or any other purpose. May be null on old rollouts.
+ */
+
+/**
+ * PreferenceSpec describe how a preference should change during a rollout.
+ * @typedef {object} PreferenceSpec
+ * @property {string} preferenceName
+ * The preference to modify.
+ * @property {string} preferenceType
+ * Type of the preference being set.
+ * @property {string|integer|boolean} value
+ * The value to change the preference to.
+ * @property {string|integer|boolean} previousValue
+ * The value the preference would have on the default branch if this rollout
+ * were not active.
+ */
+
+const STARTUP_PREFS_BRANCH = "app.normandy.startupRolloutPrefs.";
+const DB_NAME = "normandy-preference-rollout";
+const STORE_NAME = "preference-rollouts";
+const DB_VERSION = 1;
+
+/**
+ * Create a new connection to the database.
+ */
+function openDatabase() {
+ return lazy.IndexedDB.open(DB_NAME, DB_VERSION, db => {
+ db.createObjectStore(STORE_NAME, {
+ keyPath: "slug",
+ });
+ });
+}
+
+/**
+ * Cache the database connection so that it is shared among multiple operations.
+ */
+let databasePromise;
+function getDatabase() {
+ if (!databasePromise) {
+ databasePromise = openDatabase();
+ }
+ return databasePromise;
+}
+
+/**
+ * Get a transaction for interacting with the rollout store.
+ *
+ * @param {IDBDatabase} db
+ * @param {String} mode Either "readonly" or "readwrite"
+ *
+ * NOTE: Methods on the store returned by this function MUST be called
+ * synchronously, otherwise the transaction with the store will expire.
+ * This is why the helper takes a database as an argument; if we fetched the
+ * database in the helper directly, the helper would be async and the
+ * transaction would expire before methods on the store were called.
+ */
+function getStore(db, mode) {
+ if (!mode) {
+ throw new Error("mode is required");
+ }
+ return db.objectStore(STORE_NAME, mode);
+}
+
+export var PreferenceRollouts = {
+ STATE_ACTIVE: "active",
+ STATE_ROLLED_BACK: "rolled-back",
+ STATE_GRADUATED: "graduated",
+
+ // A set of rollout slugs that are obsolete based on the code in this build of
+ // Firefox. This may include things like the preference no longer being
+ // applicable, or the feature changing in such a way that Normandy's automatic
+ // graduation system cannot detect that the rollout should hand off to the
+ // built-in code.
+ GRADUATION_SET: new Set([
+ "pref-webrender-intel-rollout-70-release",
+ "bug-1703186-rollout-http3-support-release-88-89",
+ "rollout-doh-nightly-rollout-to-all-us-desktop-users-nightly-74-80-bug-1613481",
+ "rollout-doh-beta-rollout-to-all-us-desktop-users-v2-beta-74-80-bug-1613489",
+ "rollout-doh-us-staged-rollout-to-all-us-desktop-users-release-73-77-bug-1586331",
+ "bug-1648229-rollout-comcast-steering-rollout-release-78-80",
+ "bug-1732206-rollout-fission-release-rollout-release-94-95",
+ "bug-1745237-rollout-fission-beta-96-97-rollout-beta-96-97",
+ "bug-1750601-rollout-doh-steering-in-canada-staggered-starting-for-release-97-98",
+ "bug-1758988-rollout-doh-enablment-to-new-countries-staggered-st-release-98-100",
+ "bug-1758818-rollout-enabling-doh-in-new-countries-staggered-sta-release-98-100",
+ ]),
+
+ /**
+ * Update the rollout database with changes that happened during early startup.
+ * @param {object} rolloutPrefsChanged Map from pref name to previous pref value
+ */
+ async recordOriginalValues(originalPreferences) {
+ for (const rollout of await this.getAllActive()) {
+ let shouldSaveRollout = false;
+
+ // Count the number of preferences in this rollout that are now redundant.
+ let prefMatchingDefaultCount = 0;
+
+ for (const prefSpec of rollout.preferences) {
+ const builtInDefault = originalPreferences[prefSpec.preferenceName];
+ if (prefSpec.value === builtInDefault) {
+ prefMatchingDefaultCount++;
+ }
+ // Store the current built-in default. That way, if the preference is
+ // rolled back during the current session (ie, until the browser is
+ // shut down), the correct value will be used.
+ if (prefSpec.previousValue !== builtInDefault) {
+ prefSpec.previousValue = builtInDefault;
+ shouldSaveRollout = true;
+ }
+ }
+
+ if (prefMatchingDefaultCount === rollout.preferences.length) {
+ // Firefox's builtin defaults have caught up to the rollout, making all
+ // of the rollout's changes redundant, so graduate the rollout.
+ await this.graduate(rollout, "all-prefs-match");
+ // `this.graduate` writes the rollout to the db, so we don't need to do it anymore.
+ shouldSaveRollout = false;
+ }
+
+ if (shouldSaveRollout) {
+ const db = await getDatabase();
+ await getStore(db, "readwrite").put(rollout);
+ }
+ }
+ },
+
+ async init() {
+ lazy.CleanupManager.addCleanupHandler(() => this.saveStartupPrefs());
+
+ for (const rollout of await this.getAllActive()) {
+ if (this.GRADUATION_SET.has(rollout.slug)) {
+ await this.graduate(rollout, "in-graduation-set");
+ continue;
+ }
+ lazy.TelemetryEnvironment.setExperimentActive(
+ rollout.slug,
+ rollout.state,
+ {
+ type: "normandy-prefrollout",
+ enrollmentId:
+ rollout.enrollmentId ||
+ lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+ }
+ },
+
+ /** When Telemetry is disabled, clear all identifiers from the stored rollouts. */
+ async onTelemetryDisabled() {
+ const rollouts = await this.getAll();
+ for (const rollout of rollouts) {
+ rollout.enrollmentId = lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER;
+ }
+ await this.updateMany(rollouts);
+ },
+
+ /**
+ * Test wrapper that temporarily replaces the stored rollout data with fake
+ * data for testing.
+ */
+ withTestMock({
+ graduationSet = new Set(),
+ rollouts: prefRollouts = [],
+ } = {}) {
+ return testFunction => {
+ return async args => {
+ let db = await getDatabase();
+ const oldData = await getStore(db, "readonly").getAll();
+ await getStore(db, "readwrite").clear();
+ await Promise.all(prefRollouts.map(r => this.add(r)));
+ const oldGraduationSet = this.GRADUATION_SET;
+ this.GRADUATION_SET = graduationSet;
+
+ try {
+ await testFunction({ ...args, prefRollouts });
+ } finally {
+ this.GRADUATION_SET = oldGraduationSet;
+ db = await getDatabase();
+ await getStore(db, "readwrite").clear();
+ const store = getStore(db, "readwrite");
+ await Promise.all(oldData.map(d => store.add(d)));
+ }
+ };
+ };
+ },
+
+ /**
+ * Add a new rollout
+ * @param {PreferenceRollout} rollout
+ */
+ async add(rollout) {
+ if (!rollout.enrollmentId) {
+ throw new Error("Rollout must have an enrollment ID");
+ }
+ const db = await getDatabase();
+ return getStore(db, "readwrite").add(rollout);
+ },
+
+ /**
+ * Update an existing rollout
+ * @param {PreferenceRollout} rollout
+ * @throws If a matching rollout does not exist.
+ */
+ async update(rollout) {
+ if (!(await this.has(rollout.slug))) {
+ throw new Error(
+ `Tried to update ${rollout.slug}, but it doesn't already exist.`
+ );
+ }
+ const db = await getDatabase();
+ return getStore(db, "readwrite").put(rollout);
+ },
+
+ /**
+ * Update many existing rollouts. More efficient than calling `update` many
+ * times in a row.
+ * @param {Array<PreferenceRollout>} rollouts
+ * @throws If any of the passed rollouts have a slug that doesn't exist in the database already.
+ */
+ async updateMany(rollouts) {
+ // Don't touch the database if there is nothing to do
+ if (!rollouts.length) {
+ return;
+ }
+
+ // Both of the below operations use .map() instead of a normal loop becaues
+ // once we get the object store, we can't let it expire by spinning the
+ // event loop. This approach queues up all the interactions with the store
+ // immediately, preventing it from expiring too soon.
+
+ const db = await getDatabase();
+ let store = await getStore(db, "readonly");
+ await Promise.all(
+ rollouts.map(async ({ slug }) => {
+ let existingRollout = await store.get(slug);
+ if (!existingRollout) {
+ throw new Error(`Tried to update ${slug}, but it doesn't exist.`);
+ }
+ })
+ );
+
+ // awaiting spun the event loop, so the store is now invalid. Get a new
+ // store. This is also a chance to get it in readwrite mode.
+ store = await getStore(db, "readwrite");
+ await Promise.all(rollouts.map(rollout => store.put(rollout)));
+ },
+
+ /**
+ * Test whether there is a rollout in storage with the given slug.
+ * @param {string} slug
+ * @returns {boolean}
+ */
+ async has(slug) {
+ const db = await getDatabase();
+ const rollout = await getStore(db, "readonly").get(slug);
+ return !!rollout;
+ },
+
+ /**
+ * Get a rollout by slug
+ * @param {string} slug
+ */
+ async get(slug) {
+ const db = await getDatabase();
+ return getStore(db, "readonly").get(slug);
+ },
+
+ /** Get all rollouts in the database. */
+ async getAll() {
+ const db = await getDatabase();
+ return getStore(db, "readonly").getAll();
+ },
+
+ /** Get all rollouts in the "active" state. */
+ async getAllActive() {
+ const rollouts = await this.getAll();
+ return rollouts.filter(rollout => rollout.state === this.STATE_ACTIVE);
+ },
+
+ /**
+ * Save in-progress preference rollouts in a sub-branch of the normandy prefs.
+ * On startup, we read these to set the rollout values.
+ */
+ async saveStartupPrefs() {
+ const prefBranch = Services.prefs.getBranch(STARTUP_PREFS_BRANCH);
+ for (const pref of prefBranch.getChildList("")) {
+ prefBranch.clearUserPref(pref);
+ }
+
+ for (const rollout of await this.getAllActive()) {
+ for (const prefSpec of rollout.preferences) {
+ lazy.PrefUtils.setPref(
+ STARTUP_PREFS_BRANCH + prefSpec.preferenceName,
+ prefSpec.value
+ );
+ }
+ }
+ },
+
+ async graduate(rollout, reason) {
+ log.debug(`Graduating rollout: ${rollout.slug}`);
+ rollout.state = this.STATE_GRADUATED;
+ const db = await getDatabase();
+ await getStore(db, "readwrite").put(rollout);
+ lazy.TelemetryEvents.sendEvent(
+ "graduate",
+ "preference_rollout",
+ rollout.slug,
+ {
+ reason,
+ enrollmentId:
+ rollout.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+ },
+};