diff --git a/ingest/views.py b/ingest/views.py index 1d47390..93fd2a9 100644 --- a/ingest/views.py +++ b/ingest/views.py @@ -498,8 +498,9 @@ class BaseIngestAPIView(View): # Copy/pasted from count_project_periods_and_act_on_it and adapted. Explaining comments from over there were not # kept; the comments below are specific to the adapations. thresholds = [(p, n, get_settings()[key]) for (p, n, key) in QUOTA_THRESHOLDS["Installation"]] + min_threshold = min([gte_threshold for (_, _, gte_threshold) in thresholds]) - if installation.quota_exceeded_until is not None and now < installation.quota_exceeded_until: + if cls.is_quota_still_exceeded(installation, now): return False # We don't do per-event-digest bookkeeping on the installation because doing so would tie us in further into @@ -508,14 +509,19 @@ class BaseIngestAPIView(View): # +1 because about-to-add and the installation-wide call precedes the per-project bookkeeping. digested_event_count = (Project.objects.aggregate(total=Sum("digested_event_count"))["total"] or 0) + 1 - if digested_event_count >= installation.next_quota_check: + if ((digested_event_count >= installation.next_quota_check) or + (installation.next_quota_check - digested_event_count > min_threshold)): + states = check_for_thresholds(Event.objects.all(), now, thresholds, 1) - until = max([below_from for (is_exceeded, below_from, _, _) in states if is_exceeded], default=None) + until, threshold_info = max( + [(below_from, ti) for (is_exceeded, below_from, _, ti) in states if is_exceeded], + default=(None, None)) check_again_after = max(1, min([check_after for (_, _, check_after, _) in states], default=1)) installation.quota_exceeded_until = until # note: never reset to None, but the `now <` will still just work + installation.quota_exceeded_reason = json.dumps(threshold_info) installation.next_quota_check = digested_event_count + check_again_after installation.save() # conditional in the if-statement because no per-digest bookkeeping on the installation @@ -637,7 +643,7 @@ class IngestEventAPIView(BaseIngestAPIView): ingested_at = datetime.now(timezone.utc) installation = Installation.objects.get() - if installation.quota_exceeded_until is not None and ingested_at < installation.quota_exceeded_until: + if self.is_quota_still_exceeded(installation, ingested_at): return HttpResponse(status=HTTP_429_TOO_MANY_REQUESTS) project = self.get_project_for_request(project_pk, request) @@ -712,7 +718,7 @@ class IngestEnvelopeAPIView(BaseIngestAPIView): # added complexity (conditional transactions both here and in digest_event) is not worth it for modes that are # non-production anyway. installation = Installation.objects.get() - if installation.quota_exceeded_until is not None and ingested_at < installation.quota_exceeded_until: + if self.is_quota_still_exceeded(installation, ingested_at): return HttpResponse(status=HTTP_429_TOO_MANY_REQUESTS) if "dsn" in envelope_headers: diff --git a/phonehome/migrations/0004_installation_quota_exceeded_reason.py b/phonehome/migrations/0004_installation_quota_exceeded_reason.py new file mode 100644 index 0000000..4a58402 --- /dev/null +++ b/phonehome/migrations/0004_installation_quota_exceeded_reason.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("phonehome", "0003_installation_ingest_quotas"), + ] + + operations = [ + migrations.AddField( + model_name="installation", + name="quota_exceeded_reason", + field=models.CharField(default="null", max_length=255), + ), + ] diff --git a/phonehome/migrations/0005_reset_quota_exceeded_until.py b/phonehome/migrations/0005_reset_quota_exceeded_until.py new file mode 100644 index 0000000..79384b8 --- /dev/null +++ b/phonehome/migrations/0005_reset_quota_exceeded_until.py @@ -0,0 +1,22 @@ +from django.db import migrations + + +def reset_quota_exceeded_until(apps, schema_editor): + # Reset the quota_exceeded_until field for all Installation records. Since `quota_exceeded_until` is an optimization + # (saves checkes) doing this is never "incorrect" (at the cost of one ingestion per project). + # We do it here to ensure that there are no records with a value of `quota_exceeded_until` but without a value for + # the new field `quota_exceeded_reason`. (from now on, the 2 will always be set together, but the field is new) + + Installation = apps.get_model("phonehome", "Installation") + Installation.objects.filter(quota_exceeded_until__isnull=False).update(quota_exceeded_until=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ("phonehome", "0004_installation_quota_exceeded_reason"), + ] + + operations = [ + migrations.RunPython(reset_quota_exceeded_until, migrations.RunPython.noop), + ] diff --git a/phonehome/models.py b/phonehome/models.py index bb88c6f..9b45610 100644 --- a/phonehome/models.py +++ b/phonehome/models.py @@ -19,6 +19,7 @@ class Installation(models.Model): # ingestion/digestion quota email_quota_usage = models.TextField(null=False, default='{"per_month": {}}') quota_exceeded_until = models.DateTimeField(null=True, blank=True) + quota_exceeded_reason = models.CharField(max_length=255, null=False, default="null") next_quota_check = models.PositiveIntegerField(null=False, default=0) @classmethod