Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions docs/experimenter/openapi-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1647,11 +1647,11 @@
},
"enrollmentEndDate": {
"type": "string",
"format": "date"
"readOnly": true
},
"endDate": {
"type": "string",
"format": "date"
"readOnly": true
},
"proposedDuration": {
"type": "string",
Expand Down Expand Up @@ -1711,8 +1711,6 @@
"channel",
"bucketConfig",
"startDate",
"enrollmentEndDate",
"endDate",
"publishedDate"
]
},
Expand Down
6 changes: 2 additions & 4 deletions docs/experimenter/swagger-ui.html
Original file line number Diff line number Diff line change
Expand Up @@ -1659,11 +1659,11 @@
},
"enrollmentEndDate": {
"type": "string",
"format": "date"
"readOnly": true
},
"endDate": {
"type": "string",
"format": "date"
"readOnly": true
},
"proposedDuration": {
"type": "string",
Expand Down Expand Up @@ -1723,8 +1723,6 @@
"channel",
"bucketConfig",
"startDate",
"enrollmentEndDate",
"endDate",
"publishedDate"
]
},
Expand Down
25 changes: 22 additions & 3 deletions experimenter/experimenter/experiments/api/v8/serializers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import contextlib
import datetime
import json

from django.conf import settings
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions experimenter/experimenter/experiments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
47 changes: 47 additions & 0 deletions experimenter/experimenter/jetstream/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Comment thread
yashikakhurana marked this conversation as resolved.
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
76 changes: 76 additions & 0 deletions experimenter/experimenter/jetstream/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
4 changes: 4 additions & 0 deletions experimenter/experimenter/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
Comment thread
yashikakhurana marked this conversation as resolved.
}
CELERY_TASK_ROUTES = {
"experimenter.kinto.tasks.*": {"queue": "remote_settings"},
Expand Down
Loading