Bug 1624309 - Add persistent storage for ExperimentStore r=k88hudson
☠☠ backed out by 54789c4c8fcb ☠ ☠
authorAndrei Oprea <andrei.br92@gmail.com>
Thu, 16 Apr 2020 17:16:23 +0000
changeset 524574 d64c9eb2f326524738c7889c1bc953455b6921a5
parent 524573 c4b9a9cfe19910239554102b9f95c18cfb7cfb39
child 524575 84a01c8388d5b4a5cd11f29e2107469f2decb6c8
push id37323
push userdluca@mozilla.com
push dateFri, 17 Apr 2020 16:25:55 +0000
treeherdermozilla-central@b4b1d6f91ef0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersk88hudson
bugs1624309
milestone77.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1624309 - Add persistent storage for ExperimentStore r=k88hudson Differential Revision: https://phabricator.services.mozilla.com/D68215
toolkit/components/messaging-system/experiments/ExperimentAPI.jsm
toolkit/components/messaging-system/experiments/ExperimentManager.jsm
toolkit/components/messaging-system/experiments/ExperimentStore.jsm
toolkit/components/messaging-system/lib/SharedDataMap.jsm
toolkit/components/messaging-system/test/MSTestUtils.jsm
toolkit/components/messaging-system/test/unit/test_ExperimentAPI.js
toolkit/components/messaging-system/test/unit/test_ExperimentManager_enroll.js
toolkit/components/messaging-system/test/unit/test_ExperimentManager_lifecycle.js
toolkit/components/messaging-system/test/unit/test_ExperimentManager_unenroll.js
toolkit/components/messaging-system/test/unit/test_ExperimentStore.js
toolkit/components/messaging-system/test/unit/test_SharedDataMap.js
toolkit/components/messaging-system/test/unit/xpcshell.ini
--- a/toolkit/components/messaging-system/experiments/ExperimentAPI.jsm
+++ b/toolkit/components/messaging-system/experiments/ExperimentAPI.jsm
@@ -1,25 +1,31 @@
 /* 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/. */
 
 "use strict";
 
 const EXPORTED_SYMBOLS = ["ExperimentAPI"];
 
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
 const { XPCOMUtils } = ChromeUtils.import(
   "resource://gre/modules/XPCOMUtils.jsm"
 );
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   ExperimentStore:
     "resource://messaging-system/experiments/ExperimentStore.jsm",
+  ExperimentManager:
+    "resource://messaging-system/experiments/ExperimentManager.jsm",
 });
 
+const IS_MAIN_PROCESS =
+  Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
+
 const ExperimentAPI = {
   /**
    * @returns {Promise} Resolves when the API has synchronized to the main store
    */
   ready() {
     return this._store.ready();
   },
 
@@ -47,10 +53,10 @@ const ExperimentAPI = {
    * @returns {any} The selected value of the active branch of the experiment
    */
   getValue(options) {
     return this.getExperiment(options)?.branch.value;
   },
 };
 
 XPCOMUtils.defineLazyGetter(ExperimentAPI, "_store", function() {
-  return new ExperimentStore();
+  return IS_MAIN_PROCESS ? ExperimentManager.store : new ExperimentStore();
 });
--- a/toolkit/components/messaging-system/experiments/ExperimentManager.jsm
+++ b/toolkit/components/messaging-system/experiments/ExperimentManager.jsm
@@ -35,27 +35,28 @@ const TELEMETRY_EXPERIMENT_TYPE_PREFIX =
 // Also included in telemetry
 const DEFAULT_EXPERIMENT_TYPE = "messaging_experiment";
 
 /**
  * A module for processes Experiment recipes, choosing and storing enrollment state,
  * and sending experiment-related Telemetry.
  */
 class _ExperimentManager {
-  constructor({ id = "experimentmanager", storeId } = {}) {
+  constructor({ id = "experimentmanager", store } = {}) {
     this.id = id;
-    this.store = new ExperimentStore(storeId);
+    this.store = store || new ExperimentStore();
     this.slugsSeenInThisSession = new Set();
     this.log = LogManager.getLogger("ExperimentManager");
   }
 
   /**
    * Runs on startup, including before first run
    */
   async onStartup() {
+    await this.store.init();
     const restoredExperiments = this.store.getAllActive();
 
     for (const experiment of restoredExperiments) {
       this.setExperimentActive(experiment);
     }
   }
 
   /**
--- a/toolkit/components/messaging-system/experiments/ExperimentStore.jsm
+++ b/toolkit/components/messaging-system/experiments/ExperimentStore.jsm
@@ -12,31 +12,31 @@ const EXPORTED_SYMBOLS = ["ExperimentSto
 
 const { SharedDataMap } = ChromeUtils.import(
   "resource://messaging-system/lib/SharedDataMap.jsm"
 );
 
 const DEFAULT_STORE_ID = "ExperimentStoreData";
 
 class ExperimentStore extends SharedDataMap {
-  constructor(sharedDataKey) {
-    super(sharedDataKey || DEFAULT_STORE_ID);
+  constructor(sharedDataKey, options) {
+    super(sharedDataKey || DEFAULT_STORE_ID, options);
   }
 
   /**
    * Given a group identifier, find an active experiment that matches that group identifier.
    * For example, getExperimentForGroup("B") would return an experiment with groups ["A", "B", "C"]
    * This assumes, for now, that there is only one active experiment per group per browser.
    *
    * @param {string} group
    * @returns {Enrollment|undefined} An active experiment if it exists
    * @memberof ExperimentStore
    */
   getExperimentForGroup(group) {
-    for (const [, experiment] of this._map) {
+    for (const experiment of this.getAll()) {
       if (experiment.active && experiment.branch.groups?.includes(group)) {
         return experiment;
       }
     }
     return undefined;
   }
 
   /**
@@ -45,32 +45,32 @@ class ExperimentStore extends SharedData
    * @param {Array<string>} groups
    * @returns {boolean} Does an active experiment exist for that group?
    * @memberof ExperimentStore
    */
   hasExperimentForGroups(groups) {
     if (!groups || !groups.length) {
       return false;
     }
-    for (const [, experiment] of this._map) {
+    for (const experiment of this.getAll()) {
       if (
         experiment.active &&
         experiment.branch.groups?.filter(g => groups.includes(g)).length
       ) {
         return true;
       }
     }
     return false;
   }
 
   /**
    * @returns {Enrollment[]}
    */
   getAll() {
-    return [...this._map.values()];
+    return Object.values(this._data);
   }
 
   /**
    * @returns {Enrollment[]}
    */
   getAllActive() {
     return this.getAll().filter(experiment => experiment.active);
   }
--- a/toolkit/components/messaging-system/lib/SharedDataMap.jsm
+++ b/toolkit/components/messaging-system/lib/SharedDataMap.jsm
@@ -11,82 +11,107 @@ ChromeUtils.defineModuleGetter(
   this,
   "PromiseUtils",
   "resource://gre/modules/PromiseUtils.jsm"
 );
 
 const IS_MAIN_PROCESS =
   Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
 
+ChromeUtils.defineModuleGetter(
+  this,
+  "JSONFile",
+  "resource://gre/modules/JSONFile.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+
 class SharedDataMap {
-  constructor(sharedDataKey) {
+  constructor(sharedDataKey, options = { isParent: IS_MAIN_PROCESS }) {
     this._sharedDataKey = sharedDataKey;
-    this._isParent = IS_MAIN_PROCESS;
-    this._isReady = this.isParent;
+    this._isParent = options.isParent;
+    this._isReady = false;
     this._readyDeferred = PromiseUtils.defer();
-    this._map = null;
+    this._data = null;
 
     if (this.isParent) {
-      this._map = new Map();
-      this._syncToChildren({ flush: true });
-      this._checkIfReady();
+      // Lazy-load JSON file that backs Storage instances.
+      XPCOMUtils.defineLazyGetter(this, "_store", () => {
+        const path =
+          options.path || // Only used in tests
+          OS.Path.join(OS.Constants.Path.profileDir, `${sharedDataKey}.json`);
+        const store = new JSONFile({ path });
+        return store;
+      });
     } else {
       this._syncFromParent();
       Services.cpmm.sharedData.addEventListener("change", this);
     }
   }
 
+  async init(runSync = false) {
+    if (!this._isReady && this.isParent) {
+      if (runSync) {
+        this._store.ensureDataReady();
+      } else {
+        await this._store.load();
+      }
+      this._data = this._store.data;
+      this._syncToChildren({ flush: true });
+      this._checkIfReady();
+    }
+  }
+
   get sharedDataKey() {
     return this._sharedDataKey;
   }
 
   get isParent() {
     return this._isParent;
   }
 
   ready() {
     return this._readyDeferred.promise;
   }
 
   get(key) {
-    return this._map.get(key);
+    return this._data[key];
   }
 
   set(key, value) {
     if (!this.isParent) {
       throw new Error(
         "Setting values from within a content process is not allowed"
       );
     }
-    this._map.set(key, value);
+    this._store.data[key] = value;
+    this._store.saveSoon();
     this._syncToChildren();
   }
 
   has(key) {
-    return this._map.has(key);
-  }
-
-  toObject() {
-    return Object.fromEntries(this._map);
+    return Boolean(this._data[key]);
   }
 
   _syncToChildren({ flush = false } = {}) {
-    Services.ppmm.sharedData.set(this.sharedDataKey, this._map);
+    Services.ppmm.sharedData.set(this.sharedDataKey, this._data);
     if (flush) {
       Services.ppmm.sharedData.flush();
     }
   }
 
   _syncFromParent() {
-    this._map = Services.cpmm.sharedData.get(this.sharedDataKey);
+    this._data = Services.cpmm.sharedData.get(this.sharedDataKey);
     this._checkIfReady();
   }
 
   _checkIfReady() {
-    if (!this._isReady && this._map) {
+    if (!this._isReady && this._data) {
       this._isReady = true;
       this._readyDeferred.resolve();
     }
   }
 
   handleEvent(event) {
     if (event.type === "change") {
       if (event.changedKeys.includes(this.sharedDataKey)) {
--- a/toolkit/components/messaging-system/test/MSTestUtils.jsm
+++ b/toolkit/components/messaging-system/test/MSTestUtils.jsm
@@ -8,25 +8,32 @@ const { _ExperimentManager } = ChromeUti
   "resource://messaging-system/experiments/ExperimentManager.jsm"
 );
 const { ExperimentStore } = ChromeUtils.import(
   "resource://messaging-system/experiments/ExperimentStore.jsm"
 );
 const { NormandyUtils } = ChromeUtils.import(
   "resource://normandy/lib/NormandyUtils.jsm"
 );
+const { FileTestUtils } = ChromeUtils.import(
+  "resource://testing-common/FileTestUtils.jsm"
+);
+const PATH = FileTestUtils.getTempFile("shared-data-map").path;
 
 const EXPORTED_SYMBOLS = ["ExperimentFakes"];
 
 const ExperimentFakes = {
-  manager() {
-    return new _ExperimentManager({ storeId: "FakeStore" });
+  manager(store) {
+    return new _ExperimentManager({ store: store || this.store() });
   },
   store() {
-    return new ExperimentStore("FakeStore");
+    return new ExperimentStore("FakeStore", { path: PATH, isParent: true });
+  },
+  childStore() {
+    return new ExperimentStore("FakeStore", { isParent: false });
   },
   experiment(slug, props = {}) {
     return {
       slug,
       active: true,
       enrollmentId: NormandyUtils.generateUuid(),
       branch: { slug: "treatment", value: { title: "hello" } },
       ...props,
--- a/toolkit/components/messaging-system/test/unit/test_ExperimentAPI.js
+++ b/toolkit/components/messaging-system/test/unit/test_ExperimentAPI.js
@@ -1,68 +1,108 @@
 "use strict";
 
 const { ExperimentAPI } = ChromeUtils.import(
   "resource://testing-common/ExperimentAPI.jsm"
 );
 const { ExperimentFakes } = ChromeUtils.import(
   "resource://testing-common/MSTestUtils.jsm"
 );
+const { FileTestUtils } = ChromeUtils.import(
+  "resource://testing-common/FileTestUtils.jsm"
+);
+const { TestUtils } = ChromeUtils.import(
+  "resource://testing-common/TestUtils.jsm"
+);
 
 /**
  * #getExperiment
  */
 add_task(async function test_getExperiment_slug() {
+  const sandbox = sinon.createSandbox();
   const manager = ExperimentFakes.manager();
   const expected = ExperimentFakes.experiment("foo");
+
+  await manager.onStartup();
+
+  sandbox.stub(ExperimentAPI, "_store").get(() => ExperimentFakes.childStore());
+
   manager.store.addExperiment(expected);
 
-  Assert.equal(
-    ExperimentAPI.getExperiment(
-      { slug: "foo" },
-      expected,
-      "should return an experiment by slug"
-    )
+  // Wait to sync to child
+  await TestUtils.waitForCondition(
+    () => ExperimentAPI.getExperiment({ slug: "foo" }),
+    "Wait for child to sync"
   );
+
+  Assert.deepEqual(
+    ExperimentAPI.getExperiment({ slug: "foo" }),
+    expected,
+    "should return an experiment by slug"
+  );
+
+  sandbox.restore();
 });
+
 add_task(async function test_getExperiment_group() {
+  const sandbox = sinon.createSandbox();
   const manager = ExperimentFakes.manager();
   const expected = ExperimentFakes.experiment("foo", {
     branch: { slug: "treatment", value: { title: "hi" }, groups: ["blue"] },
   });
+
+  await manager.onStartup();
+
+  sandbox.stub(ExperimentAPI, "_store").get(() => ExperimentFakes.childStore());
+
   manager.store.addExperiment(expected);
 
-  Assert.equal(
-    ExperimentAPI.getExperiment(
-      { group: "blue" },
-      expected,
-      "should return an experiment by slug"
-    )
+  // Wait to sync to child
+  await TestUtils.waitForCondition(
+    () => ExperimentAPI.getExperiment({ group: "blue" }),
+    "Wait for child to sync"
   );
+
+  Assert.deepEqual(
+    ExperimentAPI.getExperiment({ group: "blue" }),
+    expected,
+    "should return an experiment by slug"
+  );
+
+  sandbox.restore();
 });
 
 /**
  * #getValue
  */
 add_task(async function test_getValue() {
+  const sandbox = sinon.createSandbox();
   const manager = ExperimentFakes.manager();
   const value = { title: "hi" };
   const expected = ExperimentFakes.experiment("foo", {
     branch: { slug: "treatment", value },
   });
+
+  await manager.onStartup();
+
+  sandbox.stub(ExperimentAPI, "_store").get(() => ExperimentFakes.childStore());
+
   manager.store.addExperiment(expected);
 
-  Assert.deepEqual(
-    ExperimentAPI.getValue(
-      { slug: "foo" },
-      value,
-      "should return an experiment value by slug"
-    )
+  await TestUtils.waitForCondition(
+    () => ExperimentAPI.getExperiment({ slug: "foo" }),
+    "Wait for child to sync"
   );
 
   Assert.deepEqual(
-    ExperimentAPI.getValue(
-      { slug: "doesnotexist" },
-      undefined,
-      "should return undefined if the experiment is not found"
-    )
+    ExperimentAPI.getValue({ slug: "foo" }),
+    value,
+    "should return an experiment value by slug"
   );
+
+  Assert.equal(
+    ExperimentAPI.getValue({ slug: "doesnotexist" }),
+    undefined,
+    "should return undefined if the experiment is not found"
+  );
+
+  sandbox.restore();
 });
--- a/toolkit/components/messaging-system/test/unit/test_ExperimentManager_enroll.js
+++ b/toolkit/components/messaging-system/test/unit/test_ExperimentManager_enroll.js
@@ -9,16 +9,18 @@ const { NormandyTestUtils } = ChromeUtil
 
 /**
  * The normal case: Enrollment of a new experiment
  */
 add_task(async function test_add_to_store() {
   const manager = ExperimentFakes.manager();
   const recipe = ExperimentFakes.recipe("foo");
 
+  await manager.onStartup();
+
   await manager.enroll(recipe);
   const experiment = manager.store.get("foo");
 
   Assert.ok(experiment, "should add an experiment with slug foo");
   Assert.ok(
     recipe.branches.includes(experiment.branch),
     "should choose a branch from the recipe.branches"
   );
@@ -27,20 +29,24 @@ add_task(async function test_add_to_stor
     NormandyTestUtils.isUuid(experiment.enrollmentId),
     "should add a valid enrollmentId"
   );
 });
 
 add_task(
   async function test_setExperimentActive_sendEnrollmentTelemetry_called() {
     const manager = ExperimentFakes.manager();
-    const sandbox = sinon.sandbox.create();
+    const sandbox = sinon.createSandbox();
     sandbox.spy(manager, "setExperimentActive");
     sandbox.spy(manager, "sendEnrollmentTelemetry");
 
+    await manager.onStartup();
+
+    await manager.onStartup();
+
     await manager.enroll(ExperimentFakes.recipe("foo"));
     const experiment = manager.store.get("foo");
 
     Assert.equal(
       manager.setExperimentActive.calledWith(experiment),
       true,
       "should call setExperimentActive after an enrollment"
     );
@@ -55,19 +61,21 @@ add_task(
 
 /**
  * Failure cases:
  * - slug conflict
  * - group conflict
  */
 add_task(async function test_failure_name_conflict() {
   const manager = ExperimentFakes.manager();
-  const sandbox = sinon.sandbox.create();
+  const sandbox = sinon.createSandbox();
   sandbox.spy(manager, "sendFailureTelemetry");
 
+  await manager.onStartup();
+
   // simulate adding a previouly enrolled experiment
   manager.store.addExperiment(ExperimentFakes.experiment("foo"));
 
   await Assert.rejects(
     manager.enroll(ExperimentFakes.recipe("foo")),
     /An experiment with the slug "foo" already exists/,
     "should throw if a conflicting experiment exists"
   );
@@ -80,19 +88,21 @@ add_task(async function test_failure_nam
     ),
     true,
     "should send failure telemetry if a conflicting experiment exists"
   );
 });
 
 add_task(async function test_failure_group_conflict() {
   const manager = ExperimentFakes.manager();
-  const sandbox = sinon.sandbox.create();
+  const sandbox = sinon.createSandbox();
   sandbox.spy(manager, "sendFailureTelemetry");
 
+  await manager.onStartup();
+
   // Two conflicting branches that both have the group "pink"
   // These should not be allowed to exist simultaneously.
   const existingBranch = {
     slug: "treatment",
     groups: ["red", "pink"],
     value: { title: "hello" },
   };
   const newBranch = {
--- a/toolkit/components/messaging-system/test/unit/test_ExperimentManager_lifecycle.js
+++ b/toolkit/components/messaging-system/test/unit/test_ExperimentManager_lifecycle.js
@@ -11,25 +11,29 @@ const { ExperimentFakes } = ChromeUtils.
 );
 
 /**
  * onStartup()
  * - should set call setExperimentActive for each active experiment
  */
 add_task(async function test_onStartup_setExperimentActive_called() {
   const manager = ExperimentFakes.manager();
-  const sandbox = sinon.sandbox.create();
+  const sandbox = sinon.createSandbox();
+  const experiments = [];
   sandbox.stub(manager, "setExperimentActive");
+  sandbox.stub(manager.store, "init").resolves();
+  sandbox.stub(manager.store, "getAll").returns(experiments);
 
   const active = ["foo", "bar"].map(ExperimentFakes.experiment);
 
   const inactive = ["baz", "qux"].map(slug =>
     ExperimentFakes.experiment(slug, { active: false })
   );
-  [...active, ...inactive].forEach(exp => manager.store.addExperiment(exp));
+
+  [...active, ...inactive].forEach(exp => experiments.push(exp));
 
   await manager.onStartup();
 
   active.forEach(exp =>
     Assert.equal(
       manager.setExperimentActive.calledWith(exp),
       true,
       `should call setExperimentActive for active experiment: ${exp.slug}`
@@ -49,79 +53,85 @@ add_task(async function test_onStartup_s
  * onRecipe()
  * - should add recipe slug to .slugsSeenInThisSession
  * - should call .enroll() if the recipe hasn't been seen before;
  * - should call .update() if the Enrollment already exists in the store;
  * - should skip enrollment if recipe.isEnrollmentPaused is true
  */
 add_task(async function test_onRecipe_track_slug() {
   const manager = ExperimentFakes.manager();
-  const sandbox = sinon.sandbox.create();
+  const sandbox = sinon.createSandbox();
   sandbox.spy(manager, "enroll");
   sandbox.spy(manager, "updateEnrollment");
 
   const fooRecipe = ExperimentFakes.recipe("foo");
 
+  await manager.onStartup();
   // The first time a recipe has seen;
   await manager.onRecipe(fooRecipe);
 
   Assert.equal(
     manager.slugsSeenInThisSession.has("foo"),
     true,
     "should add slug to slugsSeenInThisSession"
   );
 });
 
 add_task(async function test_onRecipe_enroll() {
   const manager = ExperimentFakes.manager();
-  const sandbox = sinon.sandbox.create();
+  const sandbox = sinon.createSandbox();
   sandbox.spy(manager, "enroll");
   sandbox.spy(manager, "updateEnrollment");
 
   const fooRecipe = ExperimentFakes.recipe("foo");
 
+  await manager.onStartup();
   await manager.onRecipe(fooRecipe);
 
   Assert.equal(
     manager.enroll.calledWith(fooRecipe),
     true,
     "should call .enroll() the first time a recipe is seen"
   );
   Assert.equal(
     manager.store.has("foo"),
     true,
     "should add recipe to the store"
   );
 });
 
 add_task(async function test_onRecipe_update() {
   const manager = ExperimentFakes.manager();
-  const sandbox = sinon.sandbox.create();
+  const sandbox = sinon.createSandbox();
   sandbox.spy(manager, "enroll");
   sandbox.spy(manager, "updateEnrollment");
 
   const fooRecipe = ExperimentFakes.recipe("foo");
 
+  await manager.onStartup();
+
   await manager.onRecipe(fooRecipe);
   // Call again after recipe has already been enrolled
   await manager.onRecipe(fooRecipe);
 
   Assert.equal(
     manager.updateEnrollment.calledWith(fooRecipe),
     true,
     "should call .updateEnrollment() if the recipe has already been enrolled"
   );
 });
 
 add_task(async function test_onRecipe_isEnrollmentPaused() {
   const manager = ExperimentFakes.manager();
-  const sandbox = sinon.sandbox.create();
+  const sandbox = sinon.createSandbox();
   sandbox.spy(manager, "enroll");
   sandbox.spy(manager, "updateEnrollment");
 
+  await manager.onStartup();
+
   const pausedRecipe = ExperimentFakes.recipe("xyz", {
     isEnrollmentPaused: true,
   });
   await manager.onRecipe(pausedRecipe);
   Assert.equal(
     manager.enroll.calledWith(pausedRecipe),
     false,
     "should skip enrollment for recipes that are paused"
@@ -147,25 +157,26 @@ add_task(async function test_onRecipe_is
 
 /**
  * onFinalize()
  * - should unenroll experiments that weren't seen in the current session
  */
 
 add_task(async function test_onFinalize_unenroll() {
   const manager = ExperimentFakes.manager();
-  const sandbox = sinon.sandbox.create();
+  const sandbox = sinon.createSandbox();
   sandbox.spy(manager, "unenroll");
 
+  await manager.onStartup();
+
   // Add an experiment to the store without calling .onRecipe
   // This simulates an enrollment having happened in the past.
   manager.store.addExperiment(ExperimentFakes.experiment("foo"));
 
   // Simulate adding some other recipes
-  await manager.onStartup();
   await manager.onRecipe(ExperimentFakes.recipe("bar"));
   await manager.onRecipe(ExperimentFakes.recipe("baz"));
 
   // Finalize
   manager.onFinalize();
 
   Assert.equal(
     manager.unenroll.callCount,
--- a/toolkit/components/messaging-system/test/unit/test_ExperimentManager_unenroll.js
+++ b/toolkit/components/messaging-system/test/unit/test_ExperimentManager_unenroll.js
@@ -23,45 +23,51 @@ registerCleanupFunction(() => {
 /**
  * Normal unenrollment:
  * - set .active to false
  * - set experiment inactive in telemetry
  * - send unrollment event
  */
 add_task(async function test_set_inactive() {
   const manager = ExperimentFakes.manager();
+
+  await manager.onStartup();
   manager.store.addExperiment(ExperimentFakes.experiment("foo"));
 
   manager.unenroll("foo", { reason: "some-reason" });
 
   Assert.equal(
     manager.store.get("foo").active,
     false,
     "should set .active to false"
   );
 });
 
 add_task(async function test_setExperimentInactive_called() {
   globalSandbox.reset();
   const manager = ExperimentFakes.manager();
   const experiment = ExperimentFakes.experiment("foo");
+
+  await manager.onStartup();
   manager.store.addExperiment(experiment);
 
   manager.unenroll("foo", { reason: "some-reason" });
 
   Assert.ok(
     TelemetryEnvironment.setExperimentInactive.calledWith("foo"),
     "should call TelemetryEnvironment.setExperimentInactive with slug"
   );
 });
 
 add_task(async function test_send_unenroll_event() {
   globalSandbox.reset();
   const manager = ExperimentFakes.manager();
   const experiment = ExperimentFakes.experiment("foo");
+
+  await manager.onStartup();
   manager.store.addExperiment(experiment);
 
   manager.unenroll("foo", { reason: "some-reason" });
 
   Assert.ok(TelemetryEvents.sendEvent.calledOnce);
   Assert.deepEqual(
     TelemetryEvents.sendEvent.firstCall.args,
     [
@@ -77,16 +83,18 @@ add_task(async function test_send_unenro
     "should send an unenrollment ping with the slug, reason, branch slug, and enrollmentId"
   );
 });
 
 add_task(async function test_undefined_reason() {
   globalSandbox.reset();
   const manager = ExperimentFakes.manager();
   const experiment = ExperimentFakes.experiment("foo");
+
+  await manager.onStartup();
   manager.store.addExperiment(experiment);
 
   manager.unenroll("foo");
 
   const options = TelemetryEvents.sendEvent.firstCall?.args[3];
   Assert.ok(
     "reason" in options,
     "options object with .reason should be the fourth param"
--- a/toolkit/components/messaging-system/test/unit/test_ExperimentStore.js
+++ b/toolkit/components/messaging-system/test/unit/test_ExperimentStore.js
@@ -4,28 +4,32 @@ const { ExperimentFakes } = ChromeUtils.
   "resource://testing-common/MSTestUtils.jsm"
 );
 
 add_task(async function test_getExperimentForGroup() {
   const store = ExperimentFakes.store();
   const experiment = ExperimentFakes.experiment("foo", {
     branch: { slug: "variant", groups: ["green"] },
   });
+
+  await store.init();
   store.addExperiment(ExperimentFakes.experiment("bar"));
   store.addExperiment(experiment);
 
   Assert.equal(
     store.getExperimentForGroup("green"),
     experiment,
     "should return a matching experiment for the given group"
   );
 });
 
 add_task(async function test_hasExperimentForGroups() {
   const store = ExperimentFakes.store();
+
+  await store.init();
   store.addExperiment(
     ExperimentFakes.experiment("foo", {
       branch: { slug: "variant", groups: ["green"] },
     })
   );
   store.addExperiment(
     ExperimentFakes.experiment("foo2", {
       branch: { slug: "variant", groups: ["yellow", "orange"] },
@@ -65,16 +69,18 @@ add_task(async function test_hasExperime
     store.hasExperimentForGroups(["blue", "red"]),
     false,
     "should return false if none of the experiments have the given groups"
   );
 });
 
 add_task(async function test_getAll_getAllActive() {
   const store = ExperimentFakes.store();
+
+  await store.init();
   ["foo", "bar", "baz"].forEach(slug =>
     store.addExperiment(ExperimentFakes.experiment(slug, { active: false }))
   );
   store.addExperiment(ExperimentFakes.experiment("qux", { active: true }));
 
   Assert.deepEqual(
     store.getAll().map(e => e.slug),
     ["foo", "bar", "baz", "qux"],
@@ -85,25 +91,29 @@ add_task(async function test_getAll_getA
     ["qux"],
     ".getAllActive() should return all experiments that are active"
   );
 });
 
 add_task(async function test_addExperiment() {
   const store = ExperimentFakes.store();
   const exp = ExperimentFakes.experiment("foo");
+
+  await store.init();
   store.addExperiment(exp);
 
   Assert.equal(store.get("foo"), exp, "should save experiment by slug");
 });
 
 add_task(async function test_updateExperiment() {
   const experiment = Object.freeze(
     ExperimentFakes.experiment("foo", { value: true, active: true })
   );
   const store = ExperimentFakes.store();
+
+  await store.init();
   store.addExperiment(experiment);
   store.updateExperiment("foo", { active: false });
 
   const actual = store.get("foo");
   Assert.equal(actual.active, false, "should change updated props");
   Assert.equal(actual.value, true, "should not update other props");
 });
new file mode 100644
--- /dev/null
+++ b/toolkit/components/messaging-system/test/unit/test_SharedDataMap.js
@@ -0,0 +1,130 @@
+const { SharedDataMap } = ChromeUtils.import(
+  "resource://messaging-system/lib/SharedDataMap.jsm"
+);
+const { FileTestUtils } = ChromeUtils.import(
+  "resource://testing-common/FileTestUtils.jsm"
+);
+const { TestUtils } = ChromeUtils.import(
+  "resource://testing-common/TestUtils.jsm"
+);
+
+const PATH = FileTestUtils.getTempFile("shared-data-map").path;
+
+function with_sharedDataMap(test) {
+  let testTask = async () => {
+    const sandbox = sinon.createSandbox();
+    const instance = new SharedDataMap("xpcshell", {
+      path: PATH,
+      isParent: true,
+    });
+    try {
+      await test({ instance, sandbox });
+    } finally {
+      sandbox.restore();
+    }
+  };
+
+  // Copy the name of the test function to identify the test
+  Object.defineProperty(testTask, "name", { value: test.name });
+  add_task(testTask);
+}
+
+with_sharedDataMap(function test_sync({ instance, sandbox }) {
+  instance.init(true);
+
+  instance.set("foo", "bar");
+
+  Assert.equal(instance.get("foo"), "bar", "It should retrieve a string value");
+});
+
+with_sharedDataMap(async function test_async({ instance, sandbox }) {
+  const spy = sandbox.spy(instance._store, "load");
+  await instance.init();
+
+  instance.set("foo", "bar");
+
+  Assert.equal(spy.callCount, 1, "Should init async");
+  Assert.equal(instance.get("foo"), "bar", "It should retrieve a string value");
+});
+
+with_sharedDataMap(function test_saveSoon({ instance, sandbox }) {
+  instance.init(true);
+  const stub = sandbox.stub(instance._store, "saveSoon");
+
+  instance.set("foo", "bar");
+
+  Assert.equal(stub.callCount, 1, "Should call save soon when setting a value");
+});
+
+with_sharedDataMap(async function test_childInit({ instance, sandbox }) {
+  sandbox.stub(instance, "isParent").get(() => false);
+  const stubA = sandbox.stub(instance._store, "ensureDataReady");
+  const stubB = sandbox.stub(instance._store, "load");
+
+  await instance.init(true);
+
+  Assert.equal(
+    stubA.callCount,
+    0,
+    "It should not try to initialize sync from child"
+  );
+  Assert.equal(
+    stubB.callCount,
+    0,
+    "It should not try to initialize async from child"
+  );
+});
+
+with_sharedDataMap(async function test_parentChildSync_synchronously({
+  instance: parentInstance,
+  sandbox,
+}) {
+  parentInstance.init(true);
+  parentInstance.set("foo", { bar: 1 });
+
+  const childInstance = new SharedDataMap("xpcshell", {
+    path: PATH,
+    isParent: false,
+  });
+
+  await parentInstance.ready();
+  await childInstance.ready();
+
+  await TestUtils.waitForCondition(
+    () => childInstance.get("foo"),
+    "Wait for child to sync"
+  );
+
+  Assert.deepEqual(
+    childInstance.get("foo"),
+    parentInstance.get("foo"),
+    "Parent and child should be in sync"
+  );
+});
+
+with_sharedDataMap(async function test_parentChildSync_async({
+  instance: parentInstance,
+  sandbox,
+}) {
+  const childInstance = new SharedDataMap("xpcshell", {
+    path: PATH,
+    isParent: false,
+  });
+
+  await parentInstance.init();
+  parentInstance.set("foo", { bar: 1 });
+
+  await parentInstance.ready();
+  await childInstance.ready();
+
+  await TestUtils.waitForCondition(
+    () => childInstance.get("foo"),
+    "Wait for child to sync"
+  );
+
+  Assert.deepEqual(
+    childInstance.get("foo"),
+    parentInstance.get("foo"),
+    "Parent and child should be in sync"
+  );
+});
--- a/toolkit/components/messaging-system/test/unit/xpcshell.ini
+++ b/toolkit/components/messaging-system/test/unit/xpcshell.ini
@@ -1,10 +1,11 @@
 [DEFAULT]
 head = head.js
 tags = messaging-system
 firefox-appdir = browser
 
-[test_ExperimentAPI.js]
 [test_ExperimentManager_enroll.js]
 [test_ExperimentManager_lifecycle.js]
 [test_ExperimentManager_unenroll.js]
 [test_ExperimentStore.js]
+[test_SharedDataMap.js]
+[test_ExperimentAPI.js]