/* 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} preferences * An array of preferences specifications involved in the rollout. */ /** * 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", } ); } }, /** * 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) { 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} 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, } ); }, };