perf(organizations): cache and instrument API responses
This commit is contained in:
@@ -1,18 +1,22 @@
|
||||
"""Tests for core middleware"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from io import StringIO
|
||||
|
||||
from apps.core.middleware import (
|
||||
ApiCsrfExemptMiddleware,
|
||||
ApiSlashlessRouteMiddleware,
|
||||
OrganizationApiMetricsMiddleware,
|
||||
RequestIDMiddleware,
|
||||
RequestLoggingMiddleware,
|
||||
get_request_id,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
from organizations.models import Organization
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
|
||||
@@ -126,3 +130,70 @@ class ApiSlashlessRouteMiddlewareTest(APITestCase):
|
||||
self.middleware.process_request(request)
|
||||
|
||||
self.assertEqual(request.path_info, "/admin/login")
|
||||
|
||||
|
||||
class OrganizationApiMetricsMiddlewareTest(APITestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_middleware_is_enabled_for_organization_endpoint_metrics(self):
|
||||
self.assertIn(
|
||||
"apps.core.middleware.OrganizationApiMetricsMiddleware",
|
||||
settings.MIDDLEWARE,
|
||||
)
|
||||
|
||||
def test_logs_organization_endpoint_metrics_without_query_values(self):
|
||||
Organization.objects.create(name='ООО "Метрика"', inn="7707083893")
|
||||
|
||||
def get_response(request):
|
||||
Organization.objects.count()
|
||||
response = HttpResponse("ok", status=200)
|
||||
response["X-Cache"] = "MISS"
|
||||
return response
|
||||
|
||||
middleware = OrganizationApiMetricsMiddleware(get_response)
|
||||
request = self.factory.get(
|
||||
"/api/v2/organizations/",
|
||||
{"page": "1", "page_size": "20", "search": "7707083893"},
|
||||
)
|
||||
request.request_id = "metrics-request"
|
||||
request.user = type("AnonymousUser", (), {"is_authenticated": False})()
|
||||
|
||||
with self.assertLogs("organizations.api.metrics", level="INFO") as captured:
|
||||
response = middleware(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(captured.output), 1)
|
||||
_, payload = captured.output[0].split("organization_api_metrics ", 1)
|
||||
metrics = json.loads(payload)
|
||||
|
||||
self.assertEqual(metrics["request_id"], "metrics-request")
|
||||
self.assertEqual(metrics["method"], "GET")
|
||||
self.assertEqual(metrics["path"], "/api/v2/organizations/")
|
||||
self.assertEqual(metrics["status_code"], 200)
|
||||
self.assertEqual(metrics["cache"], "MISS")
|
||||
self.assertEqual(metrics["query_keys"], ["page", "page_size", "search"])
|
||||
self.assertGreaterEqual(metrics["db_query_count"], 1)
|
||||
self.assertGreater(metrics["duration_ms"], 0)
|
||||
self.assertGreater(metrics["response_size_bytes"], 0)
|
||||
self.assertNotIn("7707083893", captured.output[0])
|
||||
|
||||
def test_ignores_non_organization_api_paths(self):
|
||||
middleware = OrganizationApiMetricsMiddleware(
|
||||
lambda request: HttpResponse("ok", status=200)
|
||||
)
|
||||
request = self.factory.get("/api/v2/sources/")
|
||||
request.request_id = "metrics-request"
|
||||
request.user = type("AnonymousUser", (), {"is_authenticated": False})()
|
||||
|
||||
logger = logging.getLogger("organizations.api.metrics")
|
||||
stream = StringIO()
|
||||
handler = logging.StreamHandler(stream)
|
||||
logger.addHandler(handler)
|
||||
try:
|
||||
response = middleware(request)
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(stream.getvalue(), "")
|
||||
|
||||
@@ -11,7 +11,8 @@ from django.db import connection
|
||||
from django.test import override_settings
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
from organizations.models import Organization
|
||||
from organizations.cache import invalidate_organization_api_cache
|
||||
from organizations.models import Organization, OrganizationDataSnapshot
|
||||
from organizations.services import OrganizationDataSnapshotRefreshService
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -104,20 +105,23 @@ class OrganizationsApiV2Test(APITestCase):
|
||||
"exclude_data",
|
||||
):
|
||||
self.assertIn(expected_name, list_parameters)
|
||||
self.assertIn("по умолчанию true", list_parameters["has_registry"]["description"])
|
||||
self.assertIn(
|
||||
"по умолчанию true", list_parameters["has_registry"]["description"]
|
||||
)
|
||||
self.assertIn(
|
||||
"industrial_products",
|
||||
list_parameters["data"]["description"],
|
||||
)
|
||||
detail_parameters = {
|
||||
parameter["name"]: parameter
|
||||
for parameter in detail_operation["parameters"]
|
||||
parameter["name"]: parameter for parameter in detail_operation["parameters"]
|
||||
}
|
||||
self.assertEqual(detail_parameters["uid"]["type"], "string")
|
||||
self.assertEqual(detail_parameters["uid"]["format"], "uuid")
|
||||
self.assertIn("data", detail_parameters)
|
||||
self.assertIn("exclude_data", detail_parameters)
|
||||
self.assertIn("Пагинированный", list_operation["responses"]["200"]["description"])
|
||||
self.assertIn(
|
||||
"Пагинированный", list_operation["responses"]["200"]["description"]
|
||||
)
|
||||
self.assertIn(
|
||||
"Карточка организации",
|
||||
detail_operation["responses"]["200"]["description"],
|
||||
@@ -132,7 +136,9 @@ class OrganizationsApiV2Test(APITestCase):
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("api_v2:organizations:organizations-detail", args=[organization.uid])
|
||||
reverse(
|
||||
"api_v2:organizations:organizations-detail", args=[organization.uid]
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -148,13 +154,100 @@ class OrganizationsApiV2Test(APITestCase):
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("api_v2:organizations:organizations-detail", args=[organization.uid])
|
||||
reverse(
|
||||
"api_v2:organizations:organizations-detail", args=[organization.uid]
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["name"], 'АКЦИОНЕРНОЕ ОБЩЕСТВО "СЕВЕРНЫЙ МОСТ"')
|
||||
self.assertEqual(response.data["normalized_name"], 'АО "Северный Мост"')
|
||||
|
||||
def test_list_omits_full_snapshot_data_by_default_but_keeps_summary(self):
|
||||
organization = Organization.objects.create(
|
||||
name='ООО "Легкий список"',
|
||||
inn="7712345682",
|
||||
kpp="771201005",
|
||||
ogrn="1027700132200",
|
||||
)
|
||||
OrganizationDataSnapshot.objects.create(
|
||||
organization=organization,
|
||||
data={
|
||||
"industrial": [{"id": 1}],
|
||||
"fns_reports": [{"id": 2}, {"id": 3}],
|
||||
},
|
||||
registries=[],
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("api_v2:organizations:organizations-list"),
|
||||
{"inn": organization.inn, "has_registry": "false"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
item = response.data["data"][0]
|
||||
self.assertEqual(item["data"], {})
|
||||
self.assertEqual(
|
||||
item["data_sources"],
|
||||
[
|
||||
{"source": "fns_reports", "count": 2},
|
||||
{"source": "industrial", "count": 1},
|
||||
],
|
||||
)
|
||||
|
||||
def test_list_returns_snapshot_data_when_sources_are_requested(self):
|
||||
organization = Organization.objects.create(
|
||||
name='ООО "Явные данные"',
|
||||
inn="7712345683",
|
||||
kpp="771201006",
|
||||
ogrn="1027700132201",
|
||||
)
|
||||
OrganizationDataSnapshot.objects.create(
|
||||
organization=organization,
|
||||
data={
|
||||
"industrial": [{"id": 1}],
|
||||
"fns_reports": [{"id": 2}],
|
||||
},
|
||||
registries=[],
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("api_v2:organizations:organizations-list"),
|
||||
{
|
||||
"inn": organization.inn,
|
||||
"has_registry": "false",
|
||||
"data": "industrial",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data["data"][0]["data"],
|
||||
{"industrial": [{"id": 1}]},
|
||||
)
|
||||
|
||||
def test_detail_keeps_full_snapshot_data_by_default(self):
|
||||
organization = Organization.objects.create(
|
||||
name='ООО "Полная карточка"',
|
||||
inn="7712345684",
|
||||
kpp="771201007",
|
||||
ogrn="1027700132202",
|
||||
)
|
||||
OrganizationDataSnapshot.objects.create(
|
||||
organization=organization,
|
||||
data={"industrial": [{"id": 1}]},
|
||||
registries=[],
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"api_v2:organizations:organizations-detail", args=[organization.uid]
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["data"], {"industrial": [{"id": 1}]})
|
||||
|
||||
def test_list_keeps_data_source_summary_when_data_payload_is_excluded(self):
|
||||
organization = Organization.objects.create(
|
||||
name='ООО "Сводка данных"',
|
||||
@@ -201,7 +294,9 @@ class OrganizationsApiV2Test(APITestCase):
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("api_v2:organizations:organizations-detail", args=[organization.uid])
|
||||
reverse(
|
||||
"api_v2:organizations:organizations-detail", args=[organization.uid]
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -299,6 +394,70 @@ class OrganizationsApiV2Test(APITestCase):
|
||||
self.assertEqual(different_query_response["X-Cache"], "MISS")
|
||||
self.assertEqual(different_query_response.data["data"][0]["inn"], "7744444444")
|
||||
|
||||
def test_list_response_cache_is_invalidated_by_version_bump(self):
|
||||
Organization.objects.create(
|
||||
name='ООО "Версия кеша"',
|
||||
inn="7744444445",
|
||||
kpp="774401002",
|
||||
ogrn="1027700132445",
|
||||
)
|
||||
url = reverse("api_v2:organizations:organizations-list")
|
||||
params = {"inn": "7744444445", "has_registry": "false"}
|
||||
|
||||
first_response = self.client.get(url, params)
|
||||
second_response = self.client.get(url, params)
|
||||
invalidate_organization_api_cache()
|
||||
third_response = self.client.get(url, params)
|
||||
|
||||
self.assertEqual(first_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(first_response["X-Cache"], "MISS")
|
||||
self.assertEqual(second_response["X-Cache"], "HIT")
|
||||
self.assertEqual(third_response["X-Cache"], "MISS")
|
||||
|
||||
def test_source_update_invalidates_organization_cache(self):
|
||||
Organization.objects.create(
|
||||
name='ООО "Источник сброса"',
|
||||
inn="7744444446",
|
||||
kpp="774401003",
|
||||
ogrn="1027700132446",
|
||||
)
|
||||
url = reverse("api_v2:organizations:organizations-list")
|
||||
params = {"inn": "7744444446", "has_registry": "false"}
|
||||
|
||||
first_response = self.client.get(url, params)
|
||||
second_response = self.client.get(url, params)
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
ParserLoadLog.objects.create(
|
||||
source=ParserLoadLog.Source.FSTEC,
|
||||
batch_id=1,
|
||||
status=ParserLoadLog.Status.SUCCESS,
|
||||
)
|
||||
third_response = self.client.get(url, params)
|
||||
|
||||
self.assertEqual(first_response["X-Cache"], "MISS")
|
||||
self.assertEqual(second_response["X-Cache"], "HIT")
|
||||
self.assertEqual(third_response["X-Cache"], "MISS")
|
||||
|
||||
def test_registry_update_invalidates_organization_cache(self):
|
||||
Organization.objects.create(
|
||||
name='ООО "Реестр сброса"',
|
||||
inn="7744444447",
|
||||
kpp="774401004",
|
||||
ogrn="1027700132447",
|
||||
)
|
||||
url = reverse("api_v2:organizations:organizations-list")
|
||||
params = {"inn": "7744444447", "has_registry": "false"}
|
||||
|
||||
first_response = self.client.get(url, params)
|
||||
second_response = self.client.get(url, params)
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
RegisterFactory(name="Реестр для сброса кеша")
|
||||
third_response = self.client.get(url, params)
|
||||
|
||||
self.assertEqual(first_response["X-Cache"], "MISS")
|
||||
self.assertEqual(second_response["X-Cache"], "HIT")
|
||||
self.assertEqual(third_response["X-Cache"], "MISS")
|
||||
|
||||
def test_retrieve_response_is_cached(self):
|
||||
organization = Organization.objects.create(
|
||||
name='ООО "Деталь"',
|
||||
@@ -306,7 +465,9 @@ class OrganizationsApiV2Test(APITestCase):
|
||||
kpp="775501001",
|
||||
ogrn="1027700132555",
|
||||
)
|
||||
url = reverse("api_v2:organizations:organizations-detail", args=[organization.uid])
|
||||
url = reverse(
|
||||
"api_v2:organizations:organizations-detail", args=[organization.uid]
|
||||
)
|
||||
|
||||
first_response = self.client.get(url)
|
||||
organization.name = 'ООО "Изменено"'
|
||||
@@ -417,7 +578,13 @@ class OrganizationsApiV2Test(APITestCase):
|
||||
|
||||
response = self.client.get(
|
||||
reverse("api_v2:organizations:organizations-list"),
|
||||
{"inn": organization.inn},
|
||||
{
|
||||
"inn": organization.inn,
|
||||
"data": (
|
||||
"industrial,industrial_products,manufactures,inspections,"
|
||||
"procurements,procurements_44fz,fstec,fns_reports"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -525,9 +692,7 @@ class OrganizationsApiV2Test(APITestCase):
|
||||
"period_end": 500,
|
||||
},
|
||||
)
|
||||
self.assertTrue(
|
||||
all(isinstance(value, list) for value in item["data"].values())
|
||||
)
|
||||
self.assertTrue(all(isinstance(value, list) for value in item["data"].values()))
|
||||
|
||||
def test_filters_by_registry_and_has_registry(self):
|
||||
with_registry = Organization.objects.create(
|
||||
@@ -608,7 +773,9 @@ class OrganizationsApiV2Test(APITestCase):
|
||||
|
||||
self.assertEqual(default_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(default_response.data["meta"]["pagination"]["total_count"], 1)
|
||||
self.assertEqual(default_response.data["data"][0]["uid"], str(with_registry.uid))
|
||||
self.assertEqual(
|
||||
default_response.data["data"][0]["uid"], str(with_registry.uid)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
explicit_false_response.data["meta"]["pagination"]["total_count"],
|
||||
@@ -764,7 +931,9 @@ class OrganizationsApiV2Test(APITestCase):
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("api_v2:organizations:organizations-detail", args=[organization.uid]),
|
||||
reverse(
|
||||
"api_v2:organizations:organizations-detail", args=[organization.uid]
|
||||
),
|
||||
{"data": "unknown"},
|
||||
)
|
||||
|
||||
@@ -823,7 +992,9 @@ class OrganizationsApiV2Test(APITestCase):
|
||||
OrganizationDataSnapshotRefreshService.refresh(
|
||||
organization_uids=[str(organization.uid)],
|
||||
)
|
||||
url = reverse("api_v2:organizations:organizations-detail", args=[organization.uid])
|
||||
url = reverse(
|
||||
"api_v2:organizations:organizations-detail", args=[organization.uid]
|
||||
)
|
||||
with CaptureQueriesContext(connection) as captured:
|
||||
response = self.client.get(
|
||||
url,
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.apps import apps as django_apps
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from organizations.cache import get_organization_api_cache_version
|
||||
from organizations.models import Organization
|
||||
from organizations.tasks import refresh_all_organization_data_snapshots
|
||||
|
||||
@@ -15,7 +16,7 @@ from tests.apps.parsers.factories import IndustrialCertificateRecordFactory
|
||||
class OrganizationSnapshotTasksTest(TestCase):
|
||||
"""Checks Celery tasks that maintain API v2 organization snapshots."""
|
||||
|
||||
def test_refresh_all_task_rebuilds_snapshots_and_clears_api_cache(self):
|
||||
def test_refresh_all_task_rebuilds_snapshots_and_invalidates_api_cache(self):
|
||||
organization = Organization.objects.create(
|
||||
name='ООО "Снапшот"',
|
||||
inn="7800000401",
|
||||
@@ -26,14 +27,19 @@ class OrganizationSnapshotTasksTest(TestCase):
|
||||
ogrn=organization.ogrn,
|
||||
certificate_number="FULL-SNAPSHOT-CERT",
|
||||
)
|
||||
cache.set("api:v2:organizations:test", {"stale": True}, timeout=60)
|
||||
cache.set("unrelated:test", {"keep": True}, timeout=60)
|
||||
cache_version_before = get_organization_api_cache_version()
|
||||
|
||||
result = refresh_all_organization_data_snapshots(batch_size=10)
|
||||
|
||||
self.assertEqual(result["processed"], 1)
|
||||
self.assertEqual(result["created"], 1)
|
||||
self.assertEqual(result["updated"], 0)
|
||||
self.assertIsNone(cache.get("api:v2:organizations:test"))
|
||||
self.assertNotEqual(
|
||||
get_organization_api_cache_version(),
|
||||
cache_version_before,
|
||||
)
|
||||
self.assertEqual(cache.get("unrelated:test"), {"keep": True})
|
||||
snapshot = organization.data_snapshot
|
||||
self.assertEqual(
|
||||
snapshot.data["industrial"][0]["certificate_number"],
|
||||
|
||||
Reference in New Issue
Block a user