mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
🐛 fix(identity): адрес установки виден соседним процессам сразу, а не через 10 секунд
Кэш адреса установки живёт в каждом процессе gunicorn по 10 секунд. Мастер первого запуска и смена адреса в «Настройках» сбрасывали его только в том процессе, где выполнялись: соседний отвечал «Invalid host» на первый же запрос после мастера, и приложение показывало «Ошибка загрузки» до обновления страницы. Промах по хосту теперь перечитывает строку настроек, но не чаще раза в секунду на процесс, чтобы поток запросов с чужим Host не превращался в поток запросов к базе. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user