🐛 fix(identity): адрес установки виден соседним процессам сразу, а не через 10 секунд

Кэш адреса установки живёт в каждом процессе gunicorn по 10 секунд. Мастер
первого запуска и смена адреса в «Настройках» сбрасывали его только в том
процессе, где выполнялись: соседний отвечал «Invalid host» на первый же
запрос после мастера, и приложение показывало «Ошибка загрузки» до
обновления страницы. Промах по хосту теперь перечитывает строку настроек,
но не чаще раза в секунду на процесс, чтобы поток запросов с чужим Host не
превращался в поток запросов к базе.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5.1 committed 2026-09-12 23:18:43 +03:00
1 parent cd50cce5a8
commit 5b49904d6a
3 files changed
+85 -3

No files matched your search

@@ -72,8 +72,12 @@ class InstanceSettings(models.Model):
_CACHE_TTL_SECONDS = 10.0
# Перечитать кэш при промахе по хосту можно не чаще раза в секунду на процесс:
# иначе поток запросов с чужим Host превращался бы в поток запросов к базе.
_MISS_REFRESH_SECONDS = 1.0
_lock = threading.Lock()
_cached: tuple[float, tuple[str, str]] | None = None
_last_miss_refresh = 0.0
def invalidate_cache() -> None:
@@ -112,6 +116,31 @@ def accepted_hosts() -> tuple[str, ...]:
return tuple(host for host in _hosts() if host)
def host_is_accepted(host: str) -> bool:
"""Свой ли это адрес — с перечитыванием кэша при промахе.
Кэш живёт в каждом процессе gunicorn отдельно. Мастер первого запуска или
смена адреса в «Настройках» сбрасывают его только там, где выполнялись;
соседний процесс до 10 секунд отвечал бы «Invalid host» на адрес, который
установка уже считает своим. Поэтому промах — повод перечитать строку, но
не чаще раза в секунду.
"""
global _last_miss_refresh
if not host:
return False
known = {normalize_domain(item) for item in accepted_hosts()}
if host in known:
return True
now = time.monotonic()
with _lock:
if now - _last_miss_refresh < _MISS_REFRESH_SECONDS:
return False
_last_miss_refresh = now
invalidate_cache()
return host in {normalize_domain(item) for item in accepted_hosts()}
def default_language() -> str:
"""Язык установки: экраны до входа и умолчание для организаций.
@@ -12,9 +12,12 @@
from __future__ import annotations
import time
from django.core.exceptions import ValidationError
from django.test import TestCase
from chatballs.identity import instance_settings
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.instance_settings import (
InstanceSettings,
@@ -201,3 +204,51 @@ class InstanceSettingsAccessTests(TestCase):
f"/api/v1/organizations/{self.result.organization.public_id}/company/administration/instance/"
)
self.assertEqual(response.status_code, 404)
class StaleHostCacheTests(TestCase):
"""Адрес, записанный мастером в одном процессе, принимает и соседний.
Кэш адреса живёт в каждом процессе gunicorn по 10 секунд. Соседний процесс
с устаревшим кэшем отвечал «Invalid host» на первый же запрос после
мастера — в интерфейсе это «Ошибка загрузки», исчезавшая после обновления
страницы. Промах по хосту теперь перечитывает кэш.
"""
def setUp(self) -> None:
self.result = bootstrap_owner(email="cache-owner@example.com", password=PASSWORD)
self.client = TenantAPIClient()
self.client.force_authenticate(self.result.owner)
# Строка настроек должна существовать: update() ниже её не создаёт.
InstanceSettings.load()
self.addCleanup(invalidate_cache)
def _stale_cache_with_no_host(self) -> None:
# Соседний процесс: только что прочитал пустой адрес, TTL ещё не вышел.
instance_settings._cached = (time.monotonic(), ("", ""))
instance_settings._last_miss_refresh = 0.0
def test_host_written_by_another_process_is_accepted_at_once(self) -> None:
self._stale_cache_with_no_host()
# Запись мимо save(): invalidate_cache() в этом процессе не вызывается,
# как и в реальности, где мастер отработал в другом воркере.
InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).update(
public_host="crm.example.test"
)
response = self.client.get("/api/v1/auth/session/", HTTP_HOST="crm.example.test")
self.assertEqual(response.status_code, 200, response.content)
def test_unknown_host_does_not_reread_more_than_once_a_second(self) -> None:
InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).update(
public_host="crm.example.test"
)
self._stale_cache_with_no_host()
self.assertEqual(self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example").status_code, 400)
# Первый промах перечитал кэш и уже знает настоящий адрес.
self.assertEqual(set(accepted_hosts()), {"crm.example.test"})
# Второй промах в ту же секунду базу не трогает: кэш подменён, но не перечитан.
instance_settings._cached = (time.monotonic(), ("", ""))
self.assertEqual(self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example").status_code, 400)
self.assertEqual(accepted_hosts(), ())
@@ -41,11 +41,13 @@ class SupportPortalHostBoundaryMiddleware:
return True
# Адреса, которые человек задал сам: тот, на котором прошли мастер, и
# предыдущий — чтобы смена адреса в «Настройках» не выбрасывала того,
# кто её делает, до того как новый домен вообще заработал.
from chatballs.identity.instance_settings import accepted_hosts
# кто её делает, до того как новый домен вообще заработал. Промах
# перечитывает кэш: соседний процесс gunicorn мог ещё не увидеть адрес,
# который мастер записал секунду назад.
from chatballs.identity.instance_settings import host_is_accepted
try:
if host and host in {normalize_domain(item) for item in accepted_hosts()}:
if host_is_accepted(host):
return True
except Exception:
pass