diff --git a/docs/experimenter/openapi-schema.json b/docs/experimenter/openapi-schema.json index 6e4ad500b..482f543cf 100644 --- a/docs/experimenter/openapi-schema.json +++ b/docs/experimenter/openapi-schema.json @@ -1647,11 +1647,11 @@ }, "enrollmentEndDate": { "type": "string", - "format": "date" + "readOnly": true }, "endDate": { "type": "string", - "format": "date" + "readOnly": true }, "proposedDuration": { "type": "string", @@ -1711,8 +1711,6 @@ "channel", "bucketConfig", "startDate", - "enrollmentEndDate", - "endDate", "publishedDate" ] }, diff --git a/docs/experimenter/swagger-ui.html b/docs/experimenter/swagger-ui.html index 10cc5e59b..0fda98474 100644 --- a/docs/experimenter/swagger-ui.html +++ b/docs/experimenter/swagger-ui.html @@ -1659,11 +1659,11 @@ }, "enrollmentEndDate": { "type": "string", - "format": "date" + "readOnly": true }, "endDate": { "type": "string", - "format": "date" + "readOnly": true }, "proposedDuration": { "type": "string", @@ -1723,8 +1723,6 @@ "channel", "bucketConfig", "startDate", - "enrollmentEndDate", - "endDate", "publishedDate" ] }, diff --git a/experimenter/experimenter/experiments/api/v8/serializers.py b/experimenter/experimenter/experiments/api/v8/serializers.py index a15ae6f19..94af90da4 100644 --- a/experimenter/experimenter/experiments/api/v8/serializers.py +++ b/experimenter/experimenter/experiments/api/v8/serializers.py @@ -1,4 +1,5 @@ import contextlib +import datetime import json from django.conf import settings @@ -106,10 +107,10 @@ class NimbusExperimentSerializer(serializers.ModelSerializer): outcomes = serializers.SerializerMethodField() segments = serializers.SerializerMethodField() startDate = serializers.DateField(source="start_date") - enrollmentEndDate = serializers.DateField(source="actual_enrollment_end_date") - endDate = serializers.DateField(source="end_date") + enrollmentEndDate = serializers.SerializerMethodField() + endDate = serializers.SerializerMethodField() proposedDuration = serializers.ReadOnlyField(source="proposed_duration") - proposedEnrollment = serializers.ReadOnlyField(source="proposed_enrollment") + proposedEnrollment = serializers.SerializerMethodField() referenceBranch = serializers.SerializerMethodField() featureValidationOptOut = serializers.ReadOnlyField( source="is_client_schema_disabled" @@ -168,6 +169,24 @@ class Meta: "requiresRestart", ) + def get_enrollmentEndDate(self, obj): + enrollment_end = obj.actual_enrollment_end_date + if obj.is_holdback and not obj.end_date and enrollment_end: + return enrollment_end.isoformat() + return enrollment_end.isoformat() if enrollment_end else None + + def get_endDate(self, obj): + enrollment_end = obj.actual_enrollment_end_date + if obj.is_holdback and not obj.end_date and enrollment_end: + return (enrollment_end + datetime.timedelta(days=21)).isoformat() + return obj.end_date.isoformat() if obj.end_date else None + + def get_proposedEnrollment(self, obj): + enrollment_end = obj.actual_enrollment_end_date + if obj.is_holdback and not obj.end_date and enrollment_end and obj.start_date: + return (enrollment_end - obj.start_date).days + return obj.proposed_enrollment + def get_application(self, obj): return self.get_appId(obj) diff --git a/experimenter/experimenter/experiments/models.py b/experimenter/experimenter/experiments/models.py index 75af68d3f..547c7ef3f 100644 --- a/experimenter/experimenter/experiments/models.py +++ b/experimenter/experimenter/experiments/models.py @@ -3286,6 +3286,7 @@ class Messages: COMPLETED = "Experiment is complete" RESULTS_UPDATED = "Experiment results updated" MONITORING_DATA_UPDATED = "Experiment monitoring data updated" + HOLDBACK_ENROLLMENT_UPDATED = "Holdback enrollment period updated" EXPIRED_FROM_PREVIEW = "Expired from preview collection after 30 days" REMOVED_FROM_PREVIEW = "Removed from preview collection" PUSHED_TO_PREVIEW = "Pushed to preview collection" diff --git a/experimenter/experimenter/experiments/tests/api/v8/test_serializers.py b/experimenter/experimenter/experiments/tests/api/v8/test_serializers.py index 1656fb868..8b76e09cb 100644 --- a/experimenter/experimenter/experiments/tests/api/v8/test_serializers.py +++ b/experimenter/experimenter/experiments/tests/api/v8/test_serializers.py @@ -419,6 +419,29 @@ def test_localized_localizations_json(self, l10n_json, expected): else: self.assertEqual(serializer.data["localizations"], expected) + def test_holdback_serializer_overrides(self): + today = datetime.date.today() + start = today - datetime.timedelta(days=50) + enrollment_end = today - datetime.timedelta(days=21) + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_holdback=True, + _start_date=start, + _enrollment_end_date=enrollment_end, + ) + serializer = NimbusExperimentSerializer(experiment) + data = serializer.data + + self.assertEqual(data["enrollmentEndDate"], enrollment_end.isoformat()) + self.assertEqual( + data["endDate"], + (enrollment_end + datetime.timedelta(days=21)).isoformat(), + ) + self.assertEqual( + data["proposedEnrollment"], + (enrollment_end - start).days, + ) + def _experiment_data_without_branches_and_featureIds( self, experiment_data, min_required_version ) -> dict[str, Any]: diff --git a/experimenter/experimenter/jetstream/tasks.py b/experimenter/experimenter/jetstream/tasks.py index 2dc1b0396..31f83bc11 100644 --- a/experimenter/experimenter/jetstream/tasks.py +++ b/experimenter/experimenter/jetstream/tasks.py @@ -196,3 +196,50 @@ def fetch_monitoring_data(): metrics.incr("fetch_monitoring_data.failed") logger.exception(f"Fatal error in fetch_monitoring_data task: {e}") raise + + +HOLDBACK_OBSERVATION_DAYS = 21 + + +@app.task +@metrics.timer_decorator("update_holdback_enrollment_period") +def update_holdback_enrollment_period(): + metrics.incr("update_holdback_enrollment_period.started") + try: + today = timezone.now().date() + now = timezone.now() + enrollment_end = today - dt.timedelta(days=HOLDBACK_OBSERVATION_DAYS) + + experiments = NimbusExperiment.objects.filter( + is_holdback=True, + status=NimbusExperiment.Status.LIVE, + _end_date=None, + ).exclude(_start_date=None) + + updated_count = 0 + for experiment in experiments: + if enrollment_end <= experiment.start_date: + continue + + NimbusExperiment.objects.filter(pk=experiment.pk).update( + _enrollment_end_date=enrollment_end, + do_rerun=True, + do_rerun_timestamp=now, + ) + experiment.refresh_from_db() + generate_nimbus_changelog( + experiment, + get_kinto_user(), + message=NimbusChangeLog.Messages.HOLDBACK_ENROLLMENT_UPDATED, + ) + updated_count += 1 + + logger.info( + f"update_holdback_enrollment_period: updated {updated_count} experiments" + ) + metrics.incr("update_holdback_enrollment_period.completed") + + except Exception as e: + metrics.incr("update_holdback_enrollment_period.failed") + logger.exception(f"Fatal error in update_holdback_enrollment_period: {e}") + raise diff --git a/experimenter/experimenter/jetstream/tests/test_tasks.py b/experimenter/experimenter/jetstream/tests/test_tasks.py index a7a950462..282d222cf 100644 --- a/experimenter/experimenter/jetstream/tests/test_tasks.py +++ b/experimenter/experimenter/jetstream/tests/test_tasks.py @@ -3949,3 +3949,79 @@ def test_result_is_cached(self): get_featmon_slugs() get_featmon_slugs() mock_read.assert_called_once() + + +class TestUpdateHoldbackEnrollmentPeriod(TestCase): + def test_sets_enrollment_end_date_and_do_rerun(self): + today = datetime.date.today() + start = today - datetime.timedelta(days=50) + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_holdback=True, + _start_date=start, + proposed_enrollment=14, + proposed_duration=84, + ) + tasks.update_holdback_enrollment_period() + experiment.refresh_from_db() + + expected_enrollment_end = today - datetime.timedelta(days=21) + self.assertEqual(experiment._enrollment_end_date, expected_enrollment_end) + self.assertTrue(experiment.do_rerun) + self.assertIsNotNone(experiment.do_rerun_timestamp) + + def test_skips_experiment_started_within_observation_period(self): + today = datetime.date.today() + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_holdback=True, + _start_date=today - datetime.timedelta(days=10), + proposed_enrollment=14, + proposed_duration=84, + ) + tasks.update_holdback_enrollment_period() + experiment.refresh_from_db() + + self.assertIsNone(experiment._enrollment_end_date) + self.assertFalse(experiment.do_rerun) + + def test_skips_ended_holdback(self): + today = datetime.date.today() + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_holdback=True, + _start_date=today - datetime.timedelta(days=50), + proposed_enrollment=14, + proposed_duration=84, + ) + experiment._end_date = today - datetime.timedelta(days=1) + experiment.save(update_fields=["_end_date"]) + tasks.update_holdback_enrollment_period() + experiment.refresh_from_db() + + self.assertIsNone(experiment._enrollment_end_date) + self.assertFalse(experiment.do_rerun) + + def test_skips_non_holdback_experiments(self): + today = datetime.date.today() + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_holdback=False, + _start_date=today - datetime.timedelta(days=50), + proposed_enrollment=14, + proposed_duration=84, + ) + tasks.update_holdback_enrollment_period() + experiment.refresh_from_db() + + self.assertFalse(experiment.do_rerun) + + def test_raises_on_unexpected_error(self): + with ( + patch( + "experimenter.jetstream.tasks.NimbusExperiment.objects.filter", + side_effect=Exception("db error"), + ), + self.assertRaises(Exception, msg="db error"), + ): + tasks.update_holdback_enrollment_period() diff --git a/experimenter/experimenter/settings.py b/experimenter/experimenter/settings.py index 9c90f8e08..54d073eab 100644 --- a/experimenter/experimenter/settings.py +++ b/experimenter/experimenter/settings.py @@ -421,6 +421,10 @@ "task": "experimenter.experiments.tasks.warm_api_caches", "schedule": config("API_CACHE_WARMING_INTERVAL", default=3600, cast=int), }, + "update_holdback_enrollment_period": { + "task": "experimenter.jetstream.tasks.update_holdback_enrollment_period", + "schedule": crontab(minute=0, hour=7), + }, } CELERY_TASK_ROUTES = { "experimenter.kinto.tasks.*": {"queue": "remote_settings"},