summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs
blob: 433c8566034f1e0141218b294f59bb1e39579c33 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
/* 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,
      }
    );
  },
};