diff --git a/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt b/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt index a7aa25c652..e1fd438847 100644 --- a/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt +++ b/components/nimbus/android/src/main/java/org/mozilla/experiments/nimbus/Nimbus.kt @@ -179,6 +179,65 @@ open class Nimbus( } } + public val updates = object { + private val lock = ReentrantLock() + private val callbackMap: MutableMap Unit>> = mutableSetOf() + + @AnyThread + public fun register(featureId: String, callback: () -> Unit) { + lock.runBlock { + callbackMap + .getOrPut(featureId, arrayListOf) + .add(callback) + } + } + + @AnyThread + public fun unregister(featureId: String, callback: () -> Unit) { + lock.runBlock { + callbackMap.get(featureId)?.run { remove(callback) } + } + } + + @AnyThread + public fun notifyChanged(events: List) { + if (events.isEmpty()) { + return + } + + val featureIds: MutableSet = mutableSetOf() + + for (event in events) { + for (featureId in event.featureIds) { + featureIds.add(featureId) + } + } + + notifyFeatures(featureIds) + } + + @AnyThread + public fun notifyFeatures(featureIds: Set) { + val toUpdate = arrayListOf() + + lock.runBlock { + for (featureId in featureIds) { + callbackMap.get(featureId)?.also { callbacks -> + for (callback of callbacks) { + toUpdate.add(callback) + } + } + } + } + + scope.launch(Dispatchers.Main) { + for (callback of toUpdate) { + callback() + } + } + } + } + init { NullVariables.instance.setContext(context) @@ -279,7 +338,7 @@ open class Nimbus( @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun initializeOnThisThread() = withCatchAll("initialize") { nimbusClient.initialize() - postEnrolmentCalculation() + postEnrolmentCalculation(true) } override fun fetchExperiments() { @@ -344,7 +403,8 @@ open class Nimbus( NimbusHealth.applyPendingExperimentsTime.accumulateSingleSample(time) recordExperimentTelemetryEvents(events!!) // Get the experiments to record in telemetry - postEnrolmentCalculation() + postEnrolmentCalculation(false) + updates.notifyChanged(events!!) } catch (e: NimbusException.InvalidExperimentFormat) { reportError("Invalid experiment format", e) } @@ -379,11 +439,22 @@ open class Nimbus( } @WorkerThread - private fun postEnrolmentCalculation() { - nimbusClient.getActiveExperiments().let { - recordExperimentTelemetry(it) + private fun postEnrolmentCalculation(initial: Bool) { + nimbusClient.getActiveExperiments().also { experiments -> + recordExperimentTelemetry(experiments) updateObserver { observer -> - observer.onUpdatesApplied(it) + observer.onUpdatesApplied(experiments) + } + + if (initial) { + val featureIds = mutableSetOf() + for (experiment in experiments) { + for (featureId in experiment.featureIds) { + featureIds.add(featureId) + } + } + + updates.notifyFeatures(featureIds) } } } @@ -431,7 +502,8 @@ open class Nimbus( val enrolmentChanges = nimbusClient.setExperimentParticipation(active) if (enrolmentChanges.isNotEmpty()) { recordExperimentTelemetryEvents(enrolmentChanges) - postEnrolmentCalculation() + postEnrolmentCalculation(false) + updates.notifyChanged(enrolmentChanges) } } @@ -442,7 +514,8 @@ open class Nimbus( val enrolmentChanges = nimbusClient.setRolloutParticipation(active) if (enrolmentChanges.isNotEmpty()) { recordExperimentTelemetryEvents(enrolmentChanges) - postEnrolmentCalculation() + postEnrolmentCalculation(false) + updates.notifyChanged(enrolmentChanges) } } diff --git a/components/nimbus/src/enrollment.rs b/components/nimbus/src/enrollment.rs index 5e73f36906..edb07b6216 100644 --- a/components/nimbus/src/enrollment.rs +++ b/components/nimbus/src/enrollment.rs @@ -227,7 +227,7 @@ impl ExperimentEnrollment { &enrollment.slug, &enrollment ); if matches!(enrollment.status, EnrollmentStatus::Enrolled { .. }) { - out_enrollment_events.push(enrollment.get_change_event()) + out_enrollment_events.push(enrollment.get_change_event(experiment)) } enrollment }) @@ -246,6 +246,7 @@ impl ExperimentEnrollment { branch_slug: branch_slug.to_string(), reason: Some("does-not-exist".to_string()), change: EnrollmentChangeEventType::EnrollFailed, + feature_ids: experiment.get_feature_ids(), }); return Err(NimbusError::NoSuchBranch( @@ -257,7 +258,7 @@ impl ExperimentEnrollment { slug: experiment.slug.clone(), status: EnrollmentStatus::new_enrolled(EnrolledReason::OptIn, branch_slug), }; - out_enrollment_events.push(enrollment.get_change_event()); + out_enrollment_events.push(enrollment.get_change_event(experiment)); Ok(enrollment) } @@ -287,7 +288,7 @@ impl ExperimentEnrollment { &self.slug, &self, updated_enrollment ); if matches!(updated_enrollment.status, EnrollmentStatus::Enrolled { .. }) { - out_enrollment_events.push(updated_enrollment.get_change_event()); + out_enrollment_events.push(updated_enrollment.get_change_event(experiment)); } updated_enrollment } @@ -303,7 +304,7 @@ impl ExperimentEnrollment { self.maybe_revert_all_gecko_pref_states(gecko_pref_store); let updated_enrollment = self.disqualify_from_enrolled(DisqualifiedReason::OptOut); - out_enrollment_events.push(updated_enrollment.get_change_event()); + out_enrollment_events.push(updated_enrollment.get_change_event(experiment)); updated_enrollment } else if !updated_experiment.has_branch(branch) { // The branch we were in disappeared! @@ -311,7 +312,7 @@ impl ExperimentEnrollment { self.maybe_revert_all_gecko_pref_states(gecko_pref_store); let updated_enrollment = self.disqualify_from_enrolled(DisqualifiedReason::Error); - out_enrollment_events.push(updated_enrollment.get_change_event()); + out_enrollment_events.push(updated_enrollment.get_change_event(experiment)); updated_enrollment } else if matches!(reason, EnrolledReason::OptIn) { // we check if we opted-in an experiment, if so @@ -341,7 +342,7 @@ impl ExperimentEnrollment { EnrollmentStatus::Error { .. } => { let updated_enrollment = self.disqualify_from_enrolled(DisqualifiedReason::Error); - out_enrollment_events.push(updated_enrollment.get_change_event()); + out_enrollment_events.push(updated_enrollment.get_change_event(experiment)); updated_enrollment } EnrollmentStatus::NotEnrolled { @@ -359,7 +360,7 @@ impl ExperimentEnrollment { ); let updated_enrollment = self.disqualify_from_enrolled(DisqualifiedReason::NotTargeted); - out_enrollment_events.push(updated_enrollment.get_change_event()); + out_enrollment_events.push(updated_enrollment.get_change_event(experiment)); updated_enrollment } EnrollmentStatus::NotEnrolled { @@ -369,7 +370,7 @@ impl ExperimentEnrollment { // let updated_enrollment = self.disqualify_from_enrolled(DisqualifiedReason::NotSelected); - out_enrollment_events.push(updated_enrollment.get_change_event()); + out_enrollment_events.push(updated_enrollment.get_change_event(experiment)); updated_enrollment } EnrollmentStatus::NotEnrolled { .. } @@ -455,6 +456,7 @@ impl ExperimentEnrollment { #[cfg_attr(not(feature = "stateful"), allow(unused))] pub(crate) fn on_explicit_opt_out( &self, + experiment: &Experiment, out_enrollment_events: &mut Vec, #[cfg(feature = "stateful")] gecko_pref_store: Option<&GeckoPrefStore>, ) -> ExperimentEnrollment { @@ -464,7 +466,7 @@ impl ExperimentEnrollment { self.maybe_revert_all_gecko_pref_states(gecko_pref_store); let enrollment = self.disqualify_from_enrolled(DisqualifiedReason::OptOut); - out_enrollment_events.push(enrollment.get_change_event()); + out_enrollment_events.push(enrollment.get_change_event(experiment)); enrollment } EnrollmentStatus::NotEnrolled { .. } => Self { @@ -994,6 +996,7 @@ impl<'a> EnrollmentsEvolver<'a> { branch_slug: "N/A".to_string(), reason: Some("feature-conflict".to_string()), change: EnrollmentChangeEventType::EnrollFailed, + feature_ids: next_experiment.get_feature_ids(), }) } // Whether it's our experiment or not that is using these features, no further enrollment can @@ -1152,6 +1155,7 @@ impl<'a> EnrollmentsEvolver<'a> { )?), // Experiment deleted remotely. (Some(_), None, Some(enrollment)) => enrollment.on_experiment_ended( + prev_experiment, #[cfg(feature = "stateful")] gecko_pref_store, out_enrollment_events, @@ -1466,6 +1470,7 @@ pub struct EnrollmentChangeEvent { pub branch_slug: String, pub reason: Option, pub change: EnrollmentChangeEventType, + pub feature_ids: Vec, } impl EnrollmentChangeEvent { diff --git a/components/nimbus/src/nimbus.udl b/components/nimbus/src/nimbus.udl index eedea4bc27..c3ce44b062 100644 --- a/components/nimbus/src/nimbus.udl +++ b/components/nimbus/src/nimbus.udl @@ -73,6 +73,8 @@ dictionary EnrollmentChangeEvent { string branch_slug; string? reason; EnrollmentChangeEventType change; + + sequence feature_ids; }; enum EnrollmentChangeEventType { @@ -234,7 +236,7 @@ interface NimbusClient { string dbpath, MetricsHandler metrics_handler, GeckoPrefHandler? gecko_pref_handler, - NimbusServerSettings? remote_settings_info + NimbusServerSettings? remote_settings_info, ); /// Initializes the database and caches enough information so that the diff --git a/components/nimbus/src/stateful/enrollment.rs b/components/nimbus/src/stateful/enrollment.rs index a55f5317b7..e553bde761 100644 --- a/components/nimbus/src/stateful/enrollment.rs +++ b/components/nimbus/src/stateful/enrollment.rs @@ -128,6 +128,7 @@ pub fn opt_in_with_branch( branch_slug: branch.to_string(), reason: Some("does-not-exist".to_string()), change: EnrollmentChangeEventType::EnrollFailed, + feature_ids: exp.get_feature_ids(), }); } @@ -153,6 +154,7 @@ pub fn opt_out( branch_slug: "N/A".to_string(), reason: Some("does-not-exist".to_string()), change: EnrollmentChangeEventType::UnenrollFailed, + feature_ids: vec![], }); } @@ -184,6 +186,7 @@ pub fn unenroll_for_pref( branch_slug: "N/A".to_string(), reason: Some("does-not-exist".to_string()), change: EnrollmentChangeEventType::UnenrollFailed, + feature_ids: vec![], }); }