From 6571cfc8eb89a4bfdce355e710a8f7816a123eb7 Mon Sep 17 00:00:00 2001 From: Yashika Khurana Date: Wed, 24 Jun 2026 15:41:04 -0700 Subject: [PATCH 01/10] feat(nimbus): add weekly Celery task to update holdback enrollment period and trigger Jetstream rerun --- experimenter/experimenter/jetstream/tasks.py | 49 ++++++++++++++++++++ experimenter/experimenter/settings.py | 4 ++ 2 files changed, 53 insertions(+) diff --git a/experimenter/experimenter/jetstream/tasks.py b/experimenter/experimenter/jetstream/tasks.py index 2dc1b0396..7615e05ff 100644 --- a/experimenter/experimenter/jetstream/tasks.py +++ b/experimenter/experimenter/jetstream/tasks.py @@ -196,3 +196,52 @@ def fetch_monitoring_data(): metrics.incr("fetch_monitoring_data.failed") logger.exception(f"Fatal error in fetch_monitoring_data task: {e}") raise + + +@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() + + experiments = NimbusExperiment.objects.filter( + is_holdback=True, + status=NimbusExperiment.Status.LIVE, + ).exclude(_start_date=None) + + updated_count = 0 + for experiment in experiments: + enrollment_end = experiment.actual_enrollment_end_date + if enrollment_end is None or today <= enrollment_end: + continue + + days_since_end = (today - enrollment_end).days + weeks_elapsed = max(1, days_since_end // 7) + new_enrollment_period = min( + experiment.proposed_enrollment + (weeks_elapsed * 7), + experiment.proposed_duration, + ) + + experiment.proposed_enrollment = new_enrollment_period + experiment.do_rerun = True + experiment.do_rerun_timestamp = now + experiment.save( + update_fields=[ + "proposed_enrollment", + "do_rerun", + "do_rerun_timestamp", + ] + ) + 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/settings.py b/experimenter/experimenter/settings.py index 9c90f8e08..03d6b03f9 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, day_of_week=1), + }, } CELERY_TASK_ROUTES = { "experimenter.kinto.tasks.*": {"queue": "remote_settings"}, From 0729b504bb3c2920f4d3c7a5d8dc6708ae9a3eb8 Mon Sep 17 00:00:00 2001 From: Yashika Khurana Date: Wed, 24 Jun 2026 15:49:08 -0700 Subject: [PATCH 02/10] test(nimbus): add tests for update_holdback_enrollment_period task --- .../jetstream/tests/test_tasks.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/experimenter/experimenter/jetstream/tests/test_tasks.py b/experimenter/experimenter/jetstream/tests/test_tasks.py index a7a950462..a32c06a4e 100644 --- a/experimenter/experimenter/jetstream/tests/test_tasks.py +++ b/experimenter/experimenter/jetstream/tests/test_tasks.py @@ -3949,3 +3949,82 @@ def test_result_is_cached(self): get_featmon_slugs() get_featmon_slugs() mock_read.assert_called_once() + + +class TestUpdateHoldbackEnrollmentPeriod(TestCase): + def _make_holdback(self, start_date, enrollment_days, duration_days, end_date=None): + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_holdback=True, + _start_date=start_date, + proposed_enrollment=enrollment_days, + proposed_duration=duration_days, + ) + if end_date: + experiment._computed_end_date = end_date + experiment.save(update_fields=["_computed_end_date"]) + return experiment + + def test_updates_enrollment_period_and_sets_do_rerun(self): + today = datetime.date.today() + start = today - datetime.timedelta(days=50) + # enrollment ended 22 days ago (14-day enrollment period started at start) + 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() + + # weeks_elapsed = 22 // 7 = 3 → 14 + 21 = 35 + self.assertEqual(experiment.proposed_enrollment, 35) + self.assertTrue(experiment.do_rerun) + self.assertIsNotNone(experiment.do_rerun_timestamp) + + def test_skips_experiment_still_in_enrollment(self): + today = datetime.date.today() + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_holdback=True, + _start_date=today - datetime.timedelta(days=5), + proposed_enrollment=14, + proposed_duration=84, + ) + original_enrollment = experiment.proposed_enrollment + tasks.update_holdback_enrollment_period() + experiment.refresh_from_db() + + self.assertEqual(experiment.proposed_enrollment, original_enrollment) + 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_caps_enrollment_at_proposed_duration(self): + today = datetime.date.today() + experiment = NimbusExperimentFactory.create_with_lifecycle( + NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, + is_holdback=True, + _start_date=today - datetime.timedelta(days=200), + proposed_enrollment=14, + proposed_duration=84, + ) + tasks.update_holdback_enrollment_period() + experiment.refresh_from_db() + + self.assertEqual(experiment.proposed_enrollment, 84) + self.assertTrue(experiment.do_rerun) From e85d07631867e4e94f51b88ef9ffddac4b91ea66 Mon Sep 17 00:00:00 2001 From: Yashika Khurana Date: Thu, 25 Jun 2026 08:48:07 -0700 Subject: [PATCH 03/10] fix(nimbus): use computed_enrollment_end_date in holdback task and fix test expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holdback experiments never pause enrollment so actual_enrollment_end_date is always None, causing the task to skip every experiment. Switch to computed_enrollment_end_date (start_date + proposed_enrollment) which correctly reflects when the initial enrollment window expires. Update test comment and expected value to match the corrected calculation (36 days since end → weeks_elapsed=5 → 14+35=49). --- experimenter/experimenter/jetstream/tasks.py | 2 +- experimenter/experimenter/jetstream/tests/test_tasks.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/experimenter/experimenter/jetstream/tasks.py b/experimenter/experimenter/jetstream/tasks.py index 7615e05ff..5078a4a7b 100644 --- a/experimenter/experimenter/jetstream/tasks.py +++ b/experimenter/experimenter/jetstream/tasks.py @@ -213,7 +213,7 @@ def update_holdback_enrollment_period(): updated_count = 0 for experiment in experiments: - enrollment_end = experiment.actual_enrollment_end_date + enrollment_end = experiment.computed_enrollment_end_date if enrollment_end is None or today <= enrollment_end: continue diff --git a/experimenter/experimenter/jetstream/tests/test_tasks.py b/experimenter/experimenter/jetstream/tests/test_tasks.py index a32c06a4e..635bc6c90 100644 --- a/experimenter/experimenter/jetstream/tests/test_tasks.py +++ b/experimenter/experimenter/jetstream/tests/test_tasks.py @@ -3968,7 +3968,7 @@ def _make_holdback(self, start_date, enrollment_days, duration_days, end_date=No def test_updates_enrollment_period_and_sets_do_rerun(self): today = datetime.date.today() start = today - datetime.timedelta(days=50) - # enrollment ended 22 days ago (14-day enrollment period started at start) + # enrollment ended 36 days ago (14-day enrollment period started at start) experiment = NimbusExperimentFactory.create_with_lifecycle( NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, is_holdback=True, @@ -3979,8 +3979,8 @@ def test_updates_enrollment_period_and_sets_do_rerun(self): tasks.update_holdback_enrollment_period() experiment.refresh_from_db() - # weeks_elapsed = 22 // 7 = 3 → 14 + 21 = 35 - self.assertEqual(experiment.proposed_enrollment, 35) + # weeks_elapsed = 36 // 7 = 5 → 14 + 35 = 49 + self.assertEqual(experiment.proposed_enrollment, 49) self.assertTrue(experiment.do_rerun) self.assertIsNotNone(experiment.do_rerun_timestamp) From 932752b4fab55615fde1dacc85a18f34fa5d6d7b Mon Sep 17 00:00:00 2001 From: Yashika Khurana Date: Mon, 29 Jun 2026 09:58:25 -0700 Subject: [PATCH 04/10] test(nimbus): add error path coverage for update_holdback_enrollment_period --- experimenter/experimenter/jetstream/tests/test_tasks.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/experimenter/experimenter/jetstream/tests/test_tasks.py b/experimenter/experimenter/jetstream/tests/test_tasks.py index 635bc6c90..a4a689004 100644 --- a/experimenter/experimenter/jetstream/tests/test_tasks.py +++ b/experimenter/experimenter/jetstream/tests/test_tasks.py @@ -4028,3 +4028,10 @@ def test_caps_enrollment_at_proposed_duration(self): self.assertEqual(experiment.proposed_enrollment, 84) self.assertTrue(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() From d286993f53beb6a44bf3e293b534f869d7945957 Mon Sep 17 00:00:00 2001 From: Yashika Khurana Date: Mon, 29 Jun 2026 11:02:14 -0700 Subject: [PATCH 05/10] style(nimbus): reformat test_raises_on_unexpected_error with parenthesized context managers --- .../experimenter/jetstream/tests/test_tasks.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/experimenter/experimenter/jetstream/tests/test_tasks.py b/experimenter/experimenter/jetstream/tests/test_tasks.py index a4a689004..d92a08e5f 100644 --- a/experimenter/experimenter/jetstream/tests/test_tasks.py +++ b/experimenter/experimenter/jetstream/tests/test_tasks.py @@ -4030,8 +4030,11 @@ def test_caps_enrollment_at_proposed_duration(self): self.assertTrue(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"): + with ( + patch( + "experimenter.jetstream.tasks.NimbusExperiment.objects.filter", + side_effect=Exception("db error"), + ), + self.assertRaises(Exception, msg="db error"), + ): tasks.update_holdback_enrollment_period() From 3f31ba2707e9f52993f51197c902980c0c163ed8 Mon Sep 17 00:00:00 2001 From: Yashika Khurana Date: Tue, 30 Jun 2026 15:44:01 -0700 Subject: [PATCH 06/10] fix(nimbus): rework holdback task and serializer based on review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run daily instead of weekly (holdbacks can launch any day) - Use today - 21 days as rolling enrollment_end (observation period) - Skip experiments with end_date set (actually ended holdbacks) - Update _enrollment_end_date instead of proposed_enrollment - Add changelog entry per updated experiment - Compute proposedEnrollment, enrollmentEndDate, endDate on-the-fly in v8 serializer for active holdbacks — real fields stay untouched - Add HOLDBACK_ENROLLMENT_UPDATED changelog message constant --- .../experiments/api/v8/serializers.py | 27 ++++++++++-- .../experimenter/experiments/models.py | 1 + experimenter/experimenter/jetstream/tasks.py | 24 ++++++----- .../jetstream/tests/test_tasks.py | 43 +++++++------------ experimenter/experimenter/settings.py | 2 +- 5 files changed, 54 insertions(+), 43 deletions(-) diff --git a/experimenter/experimenter/experiments/api/v8/serializers.py b/experimenter/experimenter/experiments/api/v8/serializers.py index a15ae6f19..ad8a63c5f 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,26 @@ class Meta: "requiresRestart", ) + def get_enrollmentEndDate(self, obj): + if obj.is_holdback and not obj._end_date and obj._enrollment_end_date: + return obj._enrollment_end_date + return obj.actual_enrollment_end_date + + def get_endDate(self, obj): + if obj.is_holdback and not obj._end_date and obj._enrollment_end_date: + return obj._enrollment_end_date + datetime.timedelta(days=21) + return obj.end_date + + def get_proposedEnrollment(self, obj): + if ( + obj.is_holdback + and not obj.end_date + and obj._enrollment_end_date + and obj.start_date + ): + return (obj._enrollment_end_date - 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/jetstream/tasks.py b/experimenter/experimenter/jetstream/tasks.py index 5078a4a7b..d13d4f9c9 100644 --- a/experimenter/experimenter/jetstream/tasks.py +++ b/experimenter/experimenter/jetstream/tasks.py @@ -198,6 +198,9 @@ def fetch_monitoring_data(): raise +HOLDBACK_OBSERVATION_DAYS = 21 + + @app.task @metrics.timer_decorator("update_holdback_enrollment_period") def update_holdback_enrollment_period(): @@ -205,35 +208,34 @@ def update_holdback_enrollment_period(): 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: - enrollment_end = experiment.computed_enrollment_end_date - if enrollment_end is None or today <= enrollment_end: + if enrollment_end <= experiment.start_date: continue - days_since_end = (today - enrollment_end).days - weeks_elapsed = max(1, days_since_end // 7) - new_enrollment_period = min( - experiment.proposed_enrollment + (weeks_elapsed * 7), - experiment.proposed_duration, - ) - - experiment.proposed_enrollment = new_enrollment_period + experiment._enrollment_end_date = enrollment_end experiment.do_rerun = True experiment.do_rerun_timestamp = now experiment.save( update_fields=[ - "proposed_enrollment", + "_enrollment_end_date", "do_rerun", "do_rerun_timestamp", ] ) + generate_nimbus_changelog( + experiment, + get_kinto_user(), + message=NimbusChangeLog.Messages.HOLDBACK_ENROLLMENT_UPDATED, + ) updated_count += 1 logger.info( diff --git a/experimenter/experimenter/jetstream/tests/test_tasks.py b/experimenter/experimenter/jetstream/tests/test_tasks.py index d92a08e5f..282d222cf 100644 --- a/experimenter/experimenter/jetstream/tests/test_tasks.py +++ b/experimenter/experimenter/jetstream/tests/test_tasks.py @@ -3952,23 +3952,9 @@ def test_result_is_cached(self): class TestUpdateHoldbackEnrollmentPeriod(TestCase): - def _make_holdback(self, start_date, enrollment_days, duration_days, end_date=None): - experiment = NimbusExperimentFactory.create_with_lifecycle( - NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, - is_holdback=True, - _start_date=start_date, - proposed_enrollment=enrollment_days, - proposed_duration=duration_days, - ) - if end_date: - experiment._computed_end_date = end_date - experiment.save(update_fields=["_computed_end_date"]) - return experiment - - def test_updates_enrollment_period_and_sets_do_rerun(self): + def test_sets_enrollment_end_date_and_do_rerun(self): today = datetime.date.today() start = today - datetime.timedelta(days=50) - # enrollment ended 36 days ago (14-day enrollment period started at start) experiment = NimbusExperimentFactory.create_with_lifecycle( NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, is_holdback=True, @@ -3979,55 +3965,56 @@ def test_updates_enrollment_period_and_sets_do_rerun(self): tasks.update_holdback_enrollment_period() experiment.refresh_from_db() - # weeks_elapsed = 36 // 7 = 5 → 14 + 35 = 49 - self.assertEqual(experiment.proposed_enrollment, 49) + 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_still_in_enrollment(self): + 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=5), + _start_date=today - datetime.timedelta(days=10), proposed_enrollment=14, proposed_duration=84, ) - original_enrollment = experiment.proposed_enrollment tasks.update_holdback_enrollment_period() experiment.refresh_from_db() - self.assertEqual(experiment.proposed_enrollment, original_enrollment) + self.assertIsNone(experiment._enrollment_end_date) self.assertFalse(experiment.do_rerun) - def test_skips_non_holdback_experiments(self): + def test_skips_ended_holdback(self): today = datetime.date.today() experiment = NimbusExperimentFactory.create_with_lifecycle( NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, - is_holdback=False, + 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_caps_enrollment_at_proposed_duration(self): + def test_skips_non_holdback_experiments(self): today = datetime.date.today() experiment = NimbusExperimentFactory.create_with_lifecycle( NimbusExperimentFactory.Lifecycles.LIVE_ENROLLING, - is_holdback=True, - _start_date=today - datetime.timedelta(days=200), + 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.assertEqual(experiment.proposed_enrollment, 84) - self.assertTrue(experiment.do_rerun) + self.assertFalse(experiment.do_rerun) def test_raises_on_unexpected_error(self): with ( diff --git a/experimenter/experimenter/settings.py b/experimenter/experimenter/settings.py index 03d6b03f9..54d073eab 100644 --- a/experimenter/experimenter/settings.py +++ b/experimenter/experimenter/settings.py @@ -423,7 +423,7 @@ }, "update_holdback_enrollment_period": { "task": "experimenter.jetstream.tasks.update_holdback_enrollment_period", - "schedule": crontab(minute=0, hour=7, day_of_week=1), + "schedule": crontab(minute=0, hour=7), }, } CELERY_TASK_ROUTES = { From c83d3b8bbaf2cb9b6f8723145b74aafcd80c2589 Mon Sep 17 00:00:00 2001 From: Yashika Khurana Date: Tue, 30 Jun 2026 16:00:58 -0700 Subject: [PATCH 07/10] chore(docs): update OpenAPI schema and Swagger UI --- docs/experimenter/openapi-schema.json | 6 ++---- docs/experimenter/swagger-ui.html | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) 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" ] }, From 5ae8bf21de39dca5409c30225c936695c881f9bf Mon Sep 17 00:00:00 2001 From: Yashika Khurana Date: Tue, 30 Jun 2026 16:03:26 -0700 Subject: [PATCH 08/10] fix(nimbus): use queryset update to avoid protected attribute access on _enrollment_end_date --- experimenter/experimenter/jetstream/tasks.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/experimenter/experimenter/jetstream/tasks.py b/experimenter/experimenter/jetstream/tasks.py index d13d4f9c9..31f83bc11 100644 --- a/experimenter/experimenter/jetstream/tasks.py +++ b/experimenter/experimenter/jetstream/tasks.py @@ -221,16 +221,12 @@ def update_holdback_enrollment_period(): if enrollment_end <= experiment.start_date: continue - experiment._enrollment_end_date = enrollment_end - experiment.do_rerun = True - experiment.do_rerun_timestamp = now - experiment.save( - update_fields=[ - "_enrollment_end_date", - "do_rerun", - "do_rerun_timestamp", - ] + 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(), From 5e9abd2256a44a7abf4524451b3610e065656cec Mon Sep 17 00:00:00 2001 From: Yashika Khurana Date: Tue, 30 Jun 2026 16:07:32 -0700 Subject: [PATCH 09/10] fix(nimbus): use public properties and ISO format strings in holdback serializer methods --- .../experiments/api/v8/serializers.py | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/experimenter/experimenter/experiments/api/v8/serializers.py b/experimenter/experimenter/experiments/api/v8/serializers.py index ad8a63c5f..94af90da4 100644 --- a/experimenter/experimenter/experiments/api/v8/serializers.py +++ b/experimenter/experimenter/experiments/api/v8/serializers.py @@ -170,23 +170,21 @@ class Meta: ) def get_enrollmentEndDate(self, obj): - if obj.is_holdback and not obj._end_date and obj._enrollment_end_date: - return obj._enrollment_end_date - return obj.actual_enrollment_end_date + 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): - if obj.is_holdback and not obj._end_date and obj._enrollment_end_date: - return obj._enrollment_end_date + datetime.timedelta(days=21) - return obj.end_date + 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): - if ( - obj.is_holdback - and not obj.end_date - and obj._enrollment_end_date - and obj.start_date - ): - return (obj._enrollment_end_date - obj.start_date).days + 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): From 593685628c39cc8ae330e4aec922e2293efe3fe1 Mon Sep 17 00:00:00 2001 From: Yashika Khurana Date: Tue, 30 Jun 2026 16:12:09 -0700 Subject: [PATCH 10/10] test(nimbus): add coverage for holdback serializer method overrides --- .../tests/api/v8/test_serializers.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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]: