diff options
Diffstat (limited to 'toolkit/components/nimbus/lib/ExperimentStore.sys.mjs')
-rw-r--r-- | toolkit/components/nimbus/lib/ExperimentStore.sys.mjs | 485 |
1 files changed, 485 insertions, 0 deletions
diff --git a/toolkit/components/nimbus/lib/ExperimentStore.sys.mjs b/toolkit/components/nimbus/lib/ExperimentStore.sys.mjs new file mode 100644 index 0000000000..43f47524e2 --- /dev/null +++ b/toolkit/components/nimbus/lib/ExperimentStore.sys.mjs @@ -0,0 +1,485 @@ +/* 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 { SharedDataMap } from "resource://nimbus/lib/SharedDataMap.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FeatureManifest: "resource://nimbus/FeatureManifest.sys.mjs", + PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs", +}); + +const IS_MAIN_PROCESS = + Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT; + +// This branch is used to store experiment data +const SYNC_DATA_PREF_BRANCH = "nimbus.syncdatastore."; +// This branch is used to store remote rollouts +const SYNC_DEFAULTS_PREF_BRANCH = "nimbus.syncdefaultsstore."; +let tryJSONParse = data => { + try { + return JSON.parse(data); + } catch (e) {} + + return null; +}; +XPCOMUtils.defineLazyGetter(lazy, "syncDataStore", () => { + let experimentsPrefBranch = Services.prefs.getBranch(SYNC_DATA_PREF_BRANCH); + let defaultsPrefBranch = Services.prefs.getBranch(SYNC_DEFAULTS_PREF_BRANCH); + return { + _tryParsePrefValue(branch, pref) { + try { + return tryJSONParse(branch.getStringPref(pref, "")); + } catch (e) { + /* This is expected if we don't have anything stored */ + } + + return null; + }, + _trySetPrefValue(branch, pref, value) { + try { + branch.setStringPref(pref, JSON.stringify(value)); + } catch (e) { + console.error(e); + } + }, + _trySetTypedPrefValue(pref, value) { + let variableType = typeof value; + switch (variableType) { + case "boolean": + Services.prefs.setBoolPref(pref, value); + break; + case "number": + Services.prefs.setIntPref(pref, value); + break; + case "string": + Services.prefs.setStringPref(pref, value); + break; + case "object": + Services.prefs.setStringPref(pref, JSON.stringify(value)); + break; + } + }, + _clearBranchChildValues(prefBranch) { + const variablesBranch = Services.prefs.getBranch(prefBranch); + const prefChildList = variablesBranch.getChildList(""); + for (let variable of prefChildList) { + variablesBranch.clearUserPref(variable); + } + }, + /** + * Given a branch pref returns all child prefs and values + * { childPref: value } + * where value is parsed to the appropriate type + * + * @returns {Object[]} + */ + _getBranchChildValues(prefBranch, featureId) { + const branch = Services.prefs.getBranch(prefBranch); + const prefChildList = branch.getChildList(""); + let values = {}; + if (!prefChildList.length) { + return null; + } + for (const childPref of prefChildList) { + let prefName = `${prefBranch}${childPref}`; + let value = lazy.PrefUtils.getPref(prefName); + // Try to parse string values that could be stringified objects + if ( + lazy.FeatureManifest[featureId]?.variables[childPref]?.type === "json" + ) { + let parsedValue = tryJSONParse(value); + if (parsedValue) { + value = parsedValue; + } + } + values[childPref] = value; + } + + return values; + }, + get(featureId) { + let metadata = this._tryParsePrefValue(experimentsPrefBranch, featureId); + if (!metadata) { + return null; + } + let prefBranch = `${SYNC_DATA_PREF_BRANCH}${featureId}.`; + metadata.branch.feature.value = this._getBranchChildValues( + prefBranch, + featureId + ); + + return metadata; + }, + getDefault(featureId) { + let metadata = this._tryParsePrefValue(defaultsPrefBranch, featureId); + if (!metadata) { + return null; + } + let prefBranch = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.`; + metadata.branch.feature.value = this._getBranchChildValues( + prefBranch, + featureId + ); + + return metadata; + }, + set(featureId, value) { + /* If the enrollment branch has variables we store those separately + * in pref branches of appropriate type: + * { featureId: "foo", value: { enabled: true } } + * gets stored as `${SYNC_DATA_PREF_BRANCH}foo.enabled=true` + */ + if (value.branch?.feature?.value) { + for (let variable of Object.keys(value.branch.feature.value)) { + let prefName = `${SYNC_DATA_PREF_BRANCH}${featureId}.${variable}`; + this._trySetTypedPrefValue( + prefName, + value.branch.feature.value[variable] + ); + } + this._trySetPrefValue(experimentsPrefBranch, featureId, { + ...value, + branch: { + ...value.branch, + feature: { + ...value.branch.feature, + value: null, + }, + }, + }); + } else { + this._trySetPrefValue(experimentsPrefBranch, featureId, value); + } + }, + setDefault(featureId, enrollment) { + /* We store configuration variables separately in pref branches of + * appropriate type: + * (feature: "foo") { variables: { enabled: true } } + * gets stored as `${SYNC_DEFAULTS_PREF_BRANCH}foo.enabled=true` + */ + let { feature } = enrollment.branch; + for (let variable of Object.keys(feature.value)) { + let prefName = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.${variable}`; + this._trySetTypedPrefValue(prefName, feature.value[variable]); + } + this._trySetPrefValue(defaultsPrefBranch, featureId, { + ...enrollment, + branch: { + ...enrollment.branch, + feature: { + ...enrollment.branch.feature, + value: null, + }, + }, + }); + }, + getAllDefaultBranches() { + return defaultsPrefBranch.getChildList("").filter( + // Filter out remote defaults variable prefs + pref => !pref.includes(".") + ); + }, + delete(featureId) { + const prefBranch = `${SYNC_DATA_PREF_BRANCH}${featureId}.`; + this._clearBranchChildValues(prefBranch); + try { + experimentsPrefBranch.clearUserPref(featureId); + } catch (e) {} + }, + deleteDefault(featureId) { + let prefBranch = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.`; + this._clearBranchChildValues(prefBranch); + try { + defaultsPrefBranch.clearUserPref(featureId); + } catch (e) {} + }, + }; +}); + +const DEFAULT_STORE_ID = "ExperimentStoreData"; + +/** + * Returns all feature ids associated with the branch provided. + * Fallback for when `featureIds` was not persisted to disk. Can be removed + * after bug 1725240 has reached release. + * + * @param {Branch} branch + * @returns {string[]} + */ +function getAllBranchFeatureIds(branch) { + return featuresCompat(branch).map(f => f.featureId); +} + +function featuresCompat(branch) { + if (!branch || (!branch.feature && !branch.features)) { + return []; + } + let { features } = branch; + // In <=v1.5.0 of the Nimbus API, experiments had single feature + if (!features) { + features = [branch.feature]; + } + + return features; +} + +export class ExperimentStore extends SharedDataMap { + static SYNC_DATA_PREF_BRANCH = SYNC_DATA_PREF_BRANCH; + static SYNC_DEFAULTS_PREF_BRANCH = SYNC_DEFAULTS_PREF_BRANCH; + + constructor(sharedDataKey, options = { isParent: IS_MAIN_PROCESS }) { + super(sharedDataKey || DEFAULT_STORE_ID, options); + } + + async init() { + await super.init(); + + this.getAllActiveExperiments().forEach(({ slug, branch, featureIds }) => { + (featureIds || getAllBranchFeatureIds(branch)).forEach(featureId => + this._emitFeatureUpdate(featureId, "feature-experiment-loaded") + ); + }); + this.getAllActiveRollouts().forEach(({ featureIds }) => { + featureIds.forEach(featureId => + this._emitFeatureUpdate(featureId, "feature-rollout-loaded") + ); + }); + + Services.tm.idleDispatchToMainThread(() => this._cleanupOldRecipes()); + } + + /** + * Given a feature identifier, find an active experiment that matches that feature identifier. + * This assumes, for now, that there is only one active experiment per feature per browser. + * Does not activate the experiment (send an exposure event) + * + * @param {string} featureId + * @returns {Enrollment|undefined} An active experiment if it exists + * @memberof ExperimentStore + */ + getExperimentForFeature(featureId) { + return ( + this.getAllActiveExperiments().find( + experiment => + experiment.featureIds?.includes(featureId) || + // Supports <v1.3.0, which was when .featureIds was added + getAllBranchFeatureIds(experiment.branch).includes(featureId) + // Default to the pref store if data is not yet ready + ) || lazy.syncDataStore.get(featureId) + ); + } + + /** + * Check if an active experiment already exists for a feature. + * Does not activate the experiment (send an exposure event) + * + * @param {string} featureId + * @returns {boolean} Does an active experiment exist for that feature? + * @memberof ExperimentStore + */ + hasExperimentForFeature(featureId) { + if (!featureId) { + return false; + } + return !!this.getExperimentForFeature(featureId); + } + + /** + * @returns {Enrollment[]} + */ + getAll() { + let data = []; + try { + data = Object.values(this._data || {}); + } catch (e) { + console.error(e); + } + + return data; + } + + /** + * Returns all active experiments + * @returns {Enrollment[]} + */ + getAllActiveExperiments() { + return this.getAll().filter( + enrollment => enrollment.active && !enrollment.isRollout + ); + } + + /** + * Returns all active rollouts + * @returns {Enrollment[]} + */ + getAllActiveRollouts() { + return this.getAll().filter( + enrollment => enrollment.active && enrollment.isRollout + ); + } + + /** + * Query the store for the remote configuration of a feature + * @param {string} featureId The feature we want to query for + * @returns {{Rollout}|undefined} Remote defaults if available + */ + getRolloutForFeature(featureId) { + return ( + this.getAllActiveRollouts().find(r => r.featureIds.includes(featureId)) || + lazy.syncDataStore.getDefault(featureId) + ); + } + + /** + * Check if an active rollout already exists for a feature. + * Does not active the experiment (send an exposure event). + * + * @param {string} featureId + * @returns {boolean} Does an active rollout exist for that feature? + */ + hasRolloutForFeature(featureId) { + if (!featureId) { + return false; + } + return !!this.getRolloutForFeature(featureId); + } + + /** + * Remove inactive enrollments older than 6 months + */ + _cleanupOldRecipes() { + // Roughly six months + const threshold = 15552000000; + const nowTimestamp = new Date().getTime(); + const recipesToRemove = this.getAll().filter( + experiment => + !experiment.active && + // Flip the comparison here to catch scenarios in which lastSeen is + // invalid or undefined. The result with be a comparison with NaN + // which is always false + !(nowTimestamp - new Date(experiment.lastSeen).getTime() < threshold) + ); + this._removeEntriesByKeys(recipesToRemove.map(r => r.slug)); + } + + _emitUpdates(enrollment) { + this.emit(`update:${enrollment.slug}`, enrollment); + const featureIds = + enrollment.featureIds || getAllBranchFeatureIds(enrollment.branch); + const reason = enrollment.isRollout + ? "rollout-updated" + : "experiment-updated"; + + for (const featureId of featureIds) { + this._emitFeatureUpdate(featureId, reason); + } + } + + _emitFeatureUpdate(featureId, reason) { + this.emit(`featureUpdate:${featureId}`, reason); + } + + _onFeatureUpdate(featureId, callback) { + if (this._isReady) { + const hasExperiment = this.hasExperimentForFeature(featureId); + if (hasExperiment || this.hasRolloutForFeature(featureId)) { + callback( + `featureUpdate:${featureId}`, + hasExperiment ? "experiment-updated" : "rollout-updated" + ); + } + } + + this.on(`featureUpdate:${featureId}`, callback); + } + + _offFeatureUpdate(featureId, callback) { + this.off(`featureUpdate:${featureId}`, callback); + } + + /** + * Persists early startup experiments or rollouts + * @param {Enrollment} enrollment Experiment or rollout + */ + _updateSyncStore(enrollment) { + let features = featuresCompat(enrollment.branch); + for (let feature of features) { + if ( + lazy.FeatureManifest[feature.featureId]?.isEarlyStartup || + feature.isEarlyStartup + ) { + if (!enrollment.active) { + // Remove experiments on un-enroll, no need to check if it exists + if (enrollment.isRollout) { + lazy.syncDataStore.deleteDefault(feature.featureId); + } else { + lazy.syncDataStore.delete(feature.featureId); + } + } else { + let updateEnrollmentSyncStore = enrollment.isRollout + ? lazy.syncDataStore.setDefault.bind(lazy.syncDataStore) + : lazy.syncDataStore.set.bind(lazy.syncDataStore); + updateEnrollmentSyncStore(feature.featureId, { + ...enrollment, + branch: { + ...enrollment.branch, + feature, + // Only store the early startup feature + features: null, + }, + }); + } + } + } + } + + /** + * Add an enrollment and notify listeners + * @param {Enrollment} enrollment + */ + addEnrollment(enrollment) { + if (!enrollment || !enrollment.slug) { + throw new Error( + `Tried to add an experiment but it didn't have a .slug property.` + ); + } + + this.set(enrollment.slug, enrollment); + this._updateSyncStore(enrollment); + this._emitUpdates(enrollment); + } + + /** + * Merge new properties into the properties of an existing experiment + * @param {string} slug + * @param {Partial<Enrollment>} newProperties + */ + updateExperiment(slug, newProperties) { + const oldProperties = this.get(slug); + if (!oldProperties) { + throw new Error( + `Tried to update experiment ${slug} but it doesn't exist` + ); + } + const updatedExperiment = { ...oldProperties, ...newProperties }; + this.set(slug, updatedExperiment); + this._updateSyncStore(updatedExperiment); + this._emitUpdates(updatedExperiment); + } + + /** + * Test only helper for cleanup + * + * @param slugOrFeatureId Can be called with slug (which removes the SharedDataMap entry) or + * with featureId which removes the SyncDataStore entry for the feature + */ + _deleteForTests(slugOrFeatureId) { + super._deleteForTests(slugOrFeatureId); + lazy.syncDataStore.deleteDefault(slugOrFeatureId); + lazy.syncDataStore.delete(slugOrFeatureId); + } +} |