forked from mirrors/gecko-dev
		
	
		
			
				
	
	
		
			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,
 | 
						|
      }
 | 
						|
    );
 | 
						|
  },
 | 
						|
};
 |