Browse Source

ref(backup): Use a more exact datetime serializer (#52771)

The default DjangoJSONEncoder inherits from the odd behavior of Python's
own `isoformat` serializer: it includes milliseconds in the output in
all cases EXCEPT those where the value of the milliseconds are `.000`,
in which case it removes them completely. This makes it difficult and
flaky to do simple string comparison between two dates. Instead, a new
wrapper class is introduced to ensure that we always retain the
milliseconds on all date serializations.
Alex Zaslavsky 1 year ago
parent
commit
10cae05ac0

+ 20 - 0
fixtures/backup/datetime-formatting.json

@@ -0,0 +1,20 @@
+[
+  {
+    "model": "sites.site",
+    "pk": 1,
+    "fields": {
+      "domain": "example.com",
+      "name": "example.com"
+    }
+  },
+  {
+    "model": "sentry.option",
+    "pk": 1,
+    "fields": {
+      "key": "sentry:latest_version",
+      "last_updated": "2023-06-22T00:00:00.000Z",
+      "last_updated_by": "unknown",
+      "value": "\"23.6.1\""
+    }
+  }
+]

+ 1 - 1
fixtures/backup/fresh-install.json

@@ -166,7 +166,7 @@
   "pk": 1,
   "fields": {
     "date_updated": "2023-06-22T23:00:00.123Z",
-    "date_added": "2023-06-22T22:59:57.073Z",
+    "date_added": "2023-06-22T22:59:57.000Z",
     "user": [
       "testing@example.com"
     ],

+ 24 - 2
src/sentry/runner/commands/backup.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from datetime import datetime, timedelta, timezone
 from difflib import unified_diff
 from io import StringIO
 from typing import NamedTuple, NewType
@@ -7,6 +8,8 @@ from typing import NamedTuple, NewType
 import click
 from django.apps import apps
 from django.core import management, serializers
+from django.core.serializers import serialize
+from django.core.serializers.json import DjangoJSONEncoder
 from django.db import IntegrityError, connection, transaction
 
 from sentry.runner.decorators import configuration
@@ -230,6 +233,20 @@ def sort_dependencies():
     return model_list
 
 
+UTC_0 = timezone(timedelta(hours=0))
+
+
+class DatetimeSafeDjangoJSONEncoder(DjangoJSONEncoder):
+    """A wrapper around the default `DjangoJSONEncoder` that always retains milliseconds, even when
+    their implicit value is `.000`. This is necessary because the ECMA-262 compatible
+    `DjangoJSONEncoder` drops these by default."""
+
+    def default(self, obj):
+        if isinstance(obj, datetime):
+            return obj.astimezone(UTC_0).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
+        return super().default(obj)
+
+
 @click.command()
 @click.argument("dest", default="-", type=click.File("w"))
 @click.option("--silent", "-q", default=False, is_flag=True, help="Silence all debug output.")
@@ -263,6 +280,11 @@ def export(dest, silent, indent, exclude):
 
     if not silent:
         click.echo(">> Beginning export", err=True)
-    serializers.serialize(
-        "json", yield_objects(), indent=indent, stream=dest, use_natural_foreign_keys=True
+    serialize(
+        "json",
+        yield_objects(),
+        indent=indent,
+        stream=dest,
+        use_natural_foreign_keys=True,
+        cls=DatetimeSafeDjangoJSONEncoder,
     )

+ 5 - 0
tests/sentry/backup/test_correctness.py

@@ -40,3 +40,8 @@ def test_bad_fresh_install_validation(tmp_path):
     with pytest.raises(ValidationError) as excinfo:
         import_export_then_validate(tmp_path, "fresh-install.json")
     assert len(excinfo.value.info.findings) == 2
+
+
+@django_db_all(transaction=True, reset_sequences=True)
+def test_datetime_formatting(tmp_path):
+    import_export_then_validate(tmp_path, "datetime-formatting.json")