329 lines
11 KiB
JavaScript
329 lines
11 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/. */
|
|
|
|
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.
|
|
*/
|
|
|
|
/**
|
|
* 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<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,
|
|
}
|
|
);
|
|
},
|
|
};
|