refactor: improve startup and sync performance

This commit is contained in:
3252a8
2026-05-20 23:04:29 +03:00
parent 8192eaf55b
commit a7f298743d
12 changed files with 973 additions and 97 deletions
+6 -1
View File
@@ -17,6 +17,11 @@ REDIS_URL=redis://redis:6379/0 #
REDIS_KEY_PREFIX=remnawave-tg-shop # Prefix for Redis keys
WEBAPP_ME_CACHE_TTL_SECONDS=15 # Short TTL for /api/me payload cache
WEBAPP_DEVICES_CACHE_TTL_SECONDS=5 # Short TTL for /api/devices payload cache
PANEL_USER_CACHE_TTL_SECONDS=5 # Short TTL for Remnawave /users/{uuid} cache
PANEL_DEVICES_CACHE_TTL_SECONDS=5 # Short TTL for Remnawave user devices cache
PANEL_ALL_USERS_CACHE_TTL_SECONDS=5 # Short TTL for concurrent Remnawave full user scans
ADMIN_PANEL_STATS_CACHE_TTL_SECONDS=15 # Short TTL for admin panel stats fetched from Remnawave
PROFILE_SYNC_CACHE_TTL_SECONDS=900 # Minimum seconds between Telegram profile sync checks per user
WEBAPP_RATE_LIMIT_TTL_SECONDS=60 # Redis rate-limit window
WEBAPP_RATE_LIMIT_MAX_REQUESTS=30 # Requests per window/action/user/IP
WEBHOOK_QUEUE_NAME=webhook-events # Redis queue for heavy webhook processing
@@ -24,7 +29,7 @@ WEBHOOK_QUEUE_CONCURRENCY=4 #
WORKER_PANEL_SYNC_INTERVAL_SECONDS=900 # Worker panel sync interval
TARIFF_WORKER_LOCK_TTL_SECONDS=240 # Redis lock TTL for tariff tick
TARIFF_WORKER_TICK_SECONDS=300 # Tariff worker tick interval
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD=200 # Active subs threshold to bulk-fetch panel users
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD=50 # Active subs threshold to bulk-fetch panel users
# Localization and Display
DEFAULT_LANGUAGE="ru" # or "en"
+84 -41
View File
@@ -1,5 +1,10 @@
# ruff: noqa: F401,F403,F405,I001
import asyncio
from ._runtime import * # noqa: F403,F405
from bot.utils.ttl_cache import AsyncTTLCache
_ADMIN_PANEL_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
async def admin_me_route(request: web.Request) -> web.Response:
@@ -36,47 +41,7 @@ async def admin_stats_route(request: web.Request) -> web.Response:
panel_service = request.app.get("panel_service")
if panel_service is not None:
try:
system = await panel_service.get_system_stats()
bandwidth = await panel_service.get_bandwidth_stats()
panel_body: Dict[str, Any] = {
"system": system or {},
"bandwidth": bandwidth or {},
}
try:
nodes = await panel_service.get_nodes_statistics()
panel_body["nodes"] = nodes or {}
except Exception as exc_nodes: # pragma: no cover - optional endpoint
logger.debug("Panel nodes stats unavailable: %s", exc_nodes)
panel_body["nodes"] = {}
try:
today = datetime.now(timezone.utc).date()
start_d = today - timedelta(days=7)
nodes_bw = await panel_service.get_nodes_bandwidth_usage(
start=start_d.isoformat(),
end=today.isoformat(),
top_nodes_limit=64,
)
panel_body["nodes_bandwidth"] = nodes_bw or {}
except Exception as exc_nb: # pragma: no cover - optional endpoint
logger.debug("Panel nodes bandwidth range unavailable: %s", exc_nb)
panel_body["nodes_bandwidth"] = {}
try:
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
lookups = await panel_service.get_nodes_online_lookups()
for k, v in lookups.get("byUuid", {}).items():
online_map[k] = v
_enrich_bandwidth_nodes_with_online(
panel_body.get("nodes_bandwidth"),
online_map,
lookups.get("byName") or {},
)
except Exception as exc_merge: # pragma: no cover
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
payload["panel"] = panel_body
except Exception as exc:
logger.debug("Panel stats unavailable: %s", exc)
payload["panel"] = {"error": "unavailable"}
payload["panel"] = await _load_admin_panel_stats(request, settings, panel_service)
queue_manager = get_queue_manager()
if queue_manager:
@@ -87,3 +52,81 @@ async def admin_stats_route(request: web.Request) -> web.Response:
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
return _ok(payload)
async def _load_admin_panel_stats(
request: web.Request,
settings: Settings,
panel_service,
) -> Dict[str, Any]:
cache = _admin_panel_stats_cache(settings)
if cache is None:
return await _load_admin_panel_stats_uncached(panel_service)
return await cache.get_or_load("panel", lambda: _load_admin_panel_stats_uncached(panel_service))
def _admin_panel_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
ttl_seconds = int(getattr(settings, "ADMIN_PANEL_STATS_CACHE_TTL_SECONDS", 15) or 0)
if ttl_seconds <= 0:
return None
cache_key = (id(settings), ttl_seconds)
cache = _ADMIN_PANEL_STATS_CACHES.get(cache_key)
if cache is None:
cache = AsyncTTLCache(
ttl_seconds=ttl_seconds,
settings=settings,
namespace="admin:panel_stats",
)
_ADMIN_PANEL_STATS_CACHES[cache_key] = cache
return cache
async def _load_admin_panel_stats_uncached(panel_service) -> Dict[str, Any]:
try:
today = datetime.now(timezone.utc).date()
start_d = today - timedelta(days=7)
system, bandwidth, nodes, nodes_bw, lookups = await asyncio.gather(
_safe_panel_call(panel_service.get_system_stats(), "system stats"),
_safe_panel_call(panel_service.get_bandwidth_stats(), "bandwidth stats"),
_safe_panel_call(panel_service.get_nodes_statistics(), "nodes stats"),
_safe_panel_call(
panel_service.get_nodes_bandwidth_usage(
start=start_d.isoformat(),
end=today.isoformat(),
top_nodes_limit=64,
),
"nodes bandwidth range",
),
_safe_panel_call(panel_service.get_nodes_online_lookups(), "nodes online lookups"),
)
panel_body: Dict[str, Any] = {
"system": system or {},
"bandwidth": bandwidth or {},
"nodes": nodes or {},
"nodes_bandwidth": nodes_bw or {},
}
if isinstance(lookups, dict):
try:
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
for k, v in lookups.get("byUuid", {}).items():
online_map[k] = v
_enrich_bandwidth_nodes_with_online(
panel_body.get("nodes_bandwidth"),
online_map,
lookups.get("byName") or {},
)
except Exception as exc_merge: # pragma: no cover
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
return panel_body
except Exception as exc:
logger.debug("Panel stats unavailable: %s", exc)
return {"error": "unavailable"}
async def _safe_panel_call(awaitable, label: str) -> Any:
try:
return await awaitable
except Exception as exc: # pragma: no cover - optional panel endpoints
logger.debug("Panel %s unavailable: %s", label, exc)
return None
+230 -47
View File
@@ -1,11 +1,11 @@
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional, Union
from typing import Any, Optional, Union
from aiogram import Bot, Router, types
from aiogram.filters import Command
from sqlalchemy import or_, update
from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
@@ -13,7 +13,7 @@ from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from config.settings import Settings
from db.dal import panel_sync_dal, subscription_dal, user_dal
from db.models import Subscription
from db.models import Subscription, User
router = Router(name="admin_sync_router")
@@ -28,6 +28,136 @@ def _normalize_panel_email(value: Optional[str]) -> Optional[str]:
return email or None
def _coerce_panel_telegram_id(value: Any) -> Optional[int]:
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError):
logging.warning("Panel user has non-numeric telegramId: %r", value)
return None
def _normalize_description(value: Optional[str]) -> str:
return "\n".join((value or "").split()).strip()
def _description_matches(current: Optional[str], desired: str) -> bool:
return _normalize_description(current) == _normalize_description(desired)
def _datetime_matches(current: Optional[datetime], desired: datetime) -> bool:
if current is None:
return False
current_dt = current if current.tzinfo else current.replace(tzinfo=timezone.utc)
desired_dt = desired if desired.tzinfo else desired.replace(tzinfo=timezone.utc)
delta = current_dt.astimezone(timezone.utc) - desired_dt.astimezone(timezone.utc)
return abs(delta.total_seconds()) < 1
def _subscription_update_delta(
subscription: Subscription, desired: dict[str, Any]
) -> dict[str, Any]:
delta: dict[str, Any] = {}
for key, desired_value in desired.items():
current_value = getattr(subscription, key)
if key == "end_date":
if not _datetime_matches(current_value, desired_value):
delta[key] = desired_value
elif current_value != desired_value:
delta[key] = desired_value
return delta
async def _prefetch_sync_indexes(
session: AsyncSession, panel_users_data: list[dict[str, Any]]
) -> dict[str, Any]:
telegram_ids: set[int] = set()
panel_uuids: set[str] = set()
emails: set[str] = set()
panel_subscription_uuids: set[str] = set()
panel_uuids_by_telegram_id: dict[int, set[str]] = {}
for panel_user in panel_users_data:
telegram_id = _coerce_panel_telegram_id(panel_user.get("telegramId"))
panel_uuid = panel_user.get("uuid")
if telegram_id:
telegram_ids.add(telegram_id)
if panel_uuid:
panel_uuids_by_telegram_id.setdefault(telegram_id, set()).add(str(panel_uuid))
if panel_uuid:
panel_uuids.add(panel_uuid)
email = _normalize_panel_email(panel_user.get("email"))
if email:
emails.add(email)
panel_subscription_uuid = panel_user.get("subscriptionUuid") or panel_user.get("shortUuid")
if panel_subscription_uuid:
panel_subscription_uuids.add(panel_subscription_uuid)
users_by_telegram_id: dict[int, User] = {}
users_by_user_id: dict[int, User] = {}
users_by_panel_uuid: dict[str, User] = {}
users_by_email: dict[str, User] = {}
user_filters = []
if telegram_ids:
user_filters.append(User.telegram_id.in_(telegram_ids))
user_filters.append(User.user_id.in_(telegram_ids))
if panel_uuids:
user_filters.append(User.panel_user_uuid.in_(panel_uuids))
if emails:
user_filters.append(func.lower(User.email).in_(emails))
if user_filters:
result = await session.execute(select(User).where(or_(*user_filters)))
for user in result.scalars().unique().all():
if user.telegram_id is not None:
users_by_telegram_id[int(user.telegram_id)] = user
users_by_user_id[int(user.user_id)] = user
if user.panel_user_uuid:
users_by_panel_uuid[user.panel_user_uuid] = user
if user.email:
users_by_email[user.email.strip().lower()] = user
subscriptions_by_panel_uuid: dict[str, Subscription] = {}
if panel_subscription_uuids:
result = await session.execute(
select(Subscription).where(
Subscription.panel_subscription_uuid.in_(panel_subscription_uuids)
)
)
subscriptions_by_panel_uuid = {
str(sub.panel_subscription_uuid): sub
for sub in result.scalars().unique().all()
if sub.panel_subscription_uuid
}
active_subscriptions_by_user_panel: dict[tuple[int, str], Subscription] = {}
if panel_uuids:
result = await session.execute(
select(Subscription)
.where(
Subscription.panel_user_uuid.in_(panel_uuids),
Subscription.is_active.is_(True),
Subscription.end_date > datetime.now(timezone.utc),
)
.order_by(Subscription.end_date.desc())
)
for sub in result.scalars().unique().all():
active_subscriptions_by_user_panel.setdefault(
(int(sub.user_id), sub.panel_user_uuid), sub
)
return {
"users_by_telegram_id": users_by_telegram_id,
"users_by_user_id": users_by_user_id,
"users_by_panel_uuid": users_by_panel_uuid,
"users_by_email": users_by_email,
"subscriptions_by_panel_uuid": subscriptions_by_panel_uuid,
"active_subscriptions_by_user_panel": active_subscriptions_by_user_panel,
"panel_uuids_by_telegram_id": panel_uuids_by_telegram_id,
}
def _extract_lifetime_used_traffic_bytes(panel_user_data: dict) -> Optional[int]:
user_traffic = panel_user_data.get("userTraffic") or {}
raw_value = (
@@ -195,13 +325,23 @@ async def _perform_sync_impl(
total_panel_users = len(panel_users_data)
logging.info(f"Starting sync for {total_panel_users} panel users.")
sync_indexes = await _prefetch_sync_indexes(session, panel_users_data)
users_by_telegram_id = sync_indexes["users_by_telegram_id"]
users_by_user_id = sync_indexes["users_by_user_id"]
users_by_panel_uuid = sync_indexes["users_by_panel_uuid"]
users_by_email = sync_indexes["users_by_email"]
subscriptions_by_panel_uuid = sync_indexes["subscriptions_by_panel_uuid"]
active_subscriptions_by_user_panel = sync_indexes["active_subscriptions_by_user_panel"]
panel_uuids_by_telegram_id = sync_indexes["panel_uuids_by_telegram_id"]
for panel_user_dict in panel_users_data:
try:
panel_records_checked += 1
panel_uuid = panel_user_dict.get("uuid")
panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid")
telegram_id_from_panel = panel_user_dict.get("telegramId")
telegram_id_from_panel = _coerce_panel_telegram_id(
panel_user_dict.get("telegramId")
)
email_from_panel = _normalize_panel_email(panel_user_dict.get("email"))
if not panel_uuid:
@@ -218,20 +358,16 @@ async def _perform_sync_impl(
# First, try to find by telegram ID if available
if telegram_id_from_panel:
existing_user = await user_dal.get_user_by_telegram_id(
session, telegram_id_from_panel
)
if not existing_user:
existing_user = await user_dal.get_user_by_id(
session, telegram_id_from_panel
)
existing_user = users_by_telegram_id.get(
telegram_id_from_panel
) or users_by_user_id.get(telegram_id_from_panel)
if existing_user:
logging.debug(f"Found user by telegramId {telegram_id_from_panel}")
# If not found by telegram ID, try to find by panel UUID.
# The panel UUID is the strongest local link for subscription sync.
if not existing_user:
existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
existing_user = users_by_panel_uuid.get(panel_uuid)
if existing_user:
logging.debug(
f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}" # noqa: E501
@@ -248,7 +384,7 @@ async def _perform_sync_impl(
# Finally, fall back to email. This mainly catches panel users that
# were first imported as email-only identities.
if not existing_user and email_from_panel:
existing_user = await user_dal.get_user_by_email(session, email_from_panel)
existing_user = users_by_email.get(email_from_panel)
if existing_user:
logging.debug(f"Found user by email {email_from_panel}")
@@ -281,6 +417,12 @@ async def _perform_sync_impl(
)
existing_user = new_user
users_by_user_id[int(new_user.user_id)] = new_user
if new_user.telegram_id is not None:
users_by_telegram_id[int(new_user.telegram_id)] = new_user
users_by_panel_uuid[panel_uuid] = new_user
if email_from_panel:
users_by_email[email_from_panel] = new_user
except Exception as e_create:
sync_errors.append(
@@ -304,6 +446,9 @@ async def _perform_sync_impl(
f"Created new email user {new_user.user_id} from panel sync with UUID {panel_uuid}" # noqa: E501
)
existing_user = new_user
users_by_user_id[int(new_user.user_id)] = new_user
users_by_panel_uuid[panel_uuid] = new_user
users_by_email[email_from_panel] = new_user
except Exception as e_create_email:
sync_errors.append(
f"Error creating email user {email_from_panel}: {str(e_create_email)}" # noqa: E501
@@ -327,10 +472,26 @@ async def _perform_sync_impl(
# Update panel UUID if different
if existing_user.panel_user_uuid != panel_uuid:
existing_user.panel_user_uuid = panel_uuid
user_was_updated = True
users_uuid_updated += 1
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
linked_uuid = existing_user.panel_user_uuid
linked_uuid_still_present = bool(
telegram_id_from_panel
and linked_uuid
and str(linked_uuid)
in panel_uuids_by_telegram_id.get(telegram_id_from_panel, set())
)
if linked_uuid_still_present:
logging.warning(
"Sync: duplicate panel users share telegramId %s; keeping local panel UUID %s and skipping duplicate panel UUID %s.", # noqa: E501
telegram_id_from_panel,
linked_uuid,
panel_uuid,
)
else:
existing_user.panel_user_uuid = panel_uuid
user_was_updated = True
users_uuid_updated += 1
users_by_panel_uuid[panel_uuid] = existing_user
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
existing_user, email_was_bound = await _bind_panel_email_to_user(
session,
existing_user=existing_user,
@@ -339,9 +500,12 @@ async def _perform_sync_impl(
)
if email_was_bound:
user_was_updated = True
if email_from_panel:
users_by_email[email_from_panel] = existing_user
if telegram_id_from_panel and existing_user.telegram_id != telegram_id_from_panel:
existing_user.telegram_id = telegram_id_from_panel
user_was_updated = True
users_by_telegram_id[telegram_id_from_panel] = existing_user
lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict)
if (
@@ -369,7 +533,9 @@ async def _perform_sync_impl(
panel_user_dict.get("description") or ""
).strip()
desired_description = description_text.strip()
if desired_description and desired_description != current_panel_description:
if desired_description and not _description_matches(
current_panel_description, desired_description
):
await panel_service.update_user_details_on_panel(
panel_uuid,
{
@@ -427,28 +593,31 @@ async def _perform_sync_impl(
)
# Try to find subscription by its panel_subscription_uuid first (idempotent) # noqa: E501
existing_sub_by_uuid = (
await subscription_dal.get_subscription_by_panel_subscription_uuid(
session, subscription_uuid_from_panel
)
existing_sub_by_uuid = subscriptions_by_panel_uuid.get(
subscription_uuid_from_panel
)
if existing_sub_by_uuid:
# Atomic update of all relevant fields
await subscription_dal.update_subscription(
session,
existing_sub_by_uuid.subscription_id,
{
"user_id": actual_user_id,
"panel_user_uuid": panel_uuid,
"end_date": panel_expire_at,
"is_active": panel_status == "ACTIVE",
"status_from_panel": panel_status,
},
update_payload = {
"user_id": actual_user_id,
"panel_user_uuid": panel_uuid,
"end_date": panel_expire_at,
"is_active": panel_status == "ACTIVE",
"status_from_panel": panel_status,
}
update_delta = _subscription_update_delta(
existing_sub_by_uuid, update_payload
)
if update_delta:
# Atomic update of changed relevant fields
await subscription_dal.update_subscription(
session,
existing_sub_by_uuid.subscription_id,
update_delta,
)
subscriptions_updated += 1
user_was_updated = True
subscriptions_synced_count += 1
subscriptions_updated += 1
user_was_updated = True
logging.debug(
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} " # noqa: E501
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" # noqa: E501
@@ -471,6 +640,15 @@ async def _perform_sync_impl(
created_sub = await subscription_dal.upsert_subscription(
session, sub_payload
)
subscriptions_by_panel_uuid[subscription_uuid_from_panel] = (
created_sub
)
if created_sub.is_active and created_sub.end_date > datetime.now(
timezone.utc
):
active_subscriptions_by_user_panel[
(int(created_sub.user_id), created_sub.panel_user_uuid)
] = created_sub
subscriptions_synced_count += 1
subscriptions_created += 1
user_was_updated = True
@@ -480,22 +658,27 @@ async def _perform_sync_impl(
)
else:
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID # noqa: E501
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, actual_user_id, panel_uuid
active_sub = active_subscriptions_by_user_panel.get(
(actual_user_id, panel_uuid)
)
if active_sub:
await subscription_dal.update_subscription(
session,
active_sub.subscription_id,
{
"end_date": panel_expire_at,
"is_active": panel_status == "ACTIVE",
"status_from_panel": panel_status,
},
update_payload = {
"end_date": panel_expire_at,
"is_active": panel_status == "ACTIVE",
"status_from_panel": panel_status,
}
update_delta = _subscription_update_delta(
active_sub, update_payload
)
if update_delta:
await subscription_dal.update_subscription(
session,
active_sub.subscription_id,
update_delta,
)
subscriptions_updated += 1
user_was_updated = True
subscriptions_synced_count += 1
subscriptions_updated += 1
user_was_updated = True
logging.debug(
f"Updated active subscription {active_sub.subscription_id} "
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" # noqa: E501
+46
View File
@@ -1,4 +1,5 @@
import logging
import time
from typing import Any, Awaitable, Callable, Dict, Optional
from aiogram import BaseMiddleware
@@ -6,9 +7,13 @@ from aiogram.types import Update
from aiogram.types import User as TgUser
from sqlalchemy.ext.asyncio import AsyncSession
from bot.infra.redis import cache_get_json, cache_set_json, redis_key
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username, username_for_display
from config.settings import Settings
from db.dal import user_dal
_LOCAL_PROFILE_SYNC_CHECKS: Dict[int, float] = {}
class ProfileSyncMiddleware(BaseMiddleware):
async def __call__(
@@ -19,8 +24,12 @@ class ProfileSyncMiddleware(BaseMiddleware):
) -> Any:
session: AsyncSession = data.get("session")
tg_user: Optional[TgUser] = data.get("event_from_user")
settings: Optional[Settings] = data.get("settings")
if session and tg_user:
if settings and await _profile_sync_recently_checked(settings, int(tg_user.id)):
return await handler(event, data)
try:
db_user = await user_dal.get_user_by_telegram_id(session, tg_user.id)
if not db_user:
@@ -79,5 +88,42 @@ class ProfileSyncMiddleware(BaseMiddleware):
f"ProfileSyncMiddleware: Failed to sync profile for user {getattr(tg_user, 'id', 'N/A')}: {e}", # noqa: E501
exc_info=True,
)
finally:
if settings:
await _mark_profile_sync_checked(settings, int(tg_user.id))
return await handler(event, data)
async def _profile_sync_recently_checked(settings: Settings, telegram_id: int) -> bool:
ttl_seconds = int(getattr(settings, "PROFILE_SYNC_CACHE_TTL_SECONDS", 900) or 0)
if ttl_seconds <= 0:
return False
now = time.monotonic()
expires_at = _LOCAL_PROFILE_SYNC_CHECKS.get(telegram_id)
if expires_at and expires_at > now:
return True
key = redis_key(settings, "cache", "profile-sync", telegram_id)
try:
cached = await cache_get_json(settings, key)
except Exception:
cached = None
if cached:
_LOCAL_PROFILE_SYNC_CHECKS[telegram_id] = now + ttl_seconds
return True
return False
async def _mark_profile_sync_checked(settings: Settings, telegram_id: int) -> None:
ttl_seconds = int(getattr(settings, "PROFILE_SYNC_CACHE_TTL_SECONDS", 900) or 0)
if ttl_seconds <= 0:
return
_LOCAL_PROFILE_SYNC_CHECKS[telegram_id] = time.monotonic() + ttl_seconds
key = redis_key(settings, "cache", "profile-sync", telegram_id)
try:
await cache_set_json(settings, key, {"checked": True}, ttl_seconds)
except Exception:
pass
+73
View File
@@ -41,6 +41,24 @@ class PanelApiService:
settings=settings,
namespace="panel:hosts",
)
self._users_cache: AsyncTTLCache = AsyncTTLCache(
ttl_seconds=max(0, int(getattr(settings, "PANEL_USER_CACHE_TTL_SECONDS", 5) or 0)),
settings=settings,
namespace="panel:users",
)
self._devices_cache: AsyncTTLCache = AsyncTTLCache(
ttl_seconds=max(0, int(getattr(settings, "PANEL_DEVICES_CACHE_TTL_SECONDS", 5) or 0)),
settings=settings,
namespace="panel:devices",
)
self._all_users_cache: AsyncTTLCache = AsyncTTLCache(
ttl_seconds=max(
0,
int(getattr(settings, "PANEL_ALL_USERS_CACHE_TTL_SECONDS", 5) or 0),
),
settings=settings,
namespace="panel:all_users",
)
async def __aenter__(self):
"""Context manager entry"""
@@ -226,6 +244,18 @@ class PanelApiService:
async def get_all_panel_users(
self, page_size: int = 100, log_responses: bool = False
) -> Optional[List[Dict[str, Any]]]:
if log_responses or page_size != 100 or self._all_users_cache.ttl_seconds <= 0:
return await self._get_all_panel_users_uncached(
page_size=page_size, log_responses=log_responses
)
return await self._all_users_cache.get_or_load(
f"page_size:{page_size}",
lambda: self._get_all_panel_users_uncached(page_size=page_size, log_responses=False),
)
async def _get_all_panel_users_uncached(
self, page_size: int = 100, log_responses: bool = False
) -> Optional[List[Dict[str, Any]]]:
all_users = []
start_offset = 0
@@ -253,6 +283,16 @@ class PanelApiService:
async def get_user_by_uuid(
self, user_uuid: str, log_response: bool = False
) -> Optional[Dict[str, Any]]:
if log_response or self._users_cache.ttl_seconds <= 0:
return await self._get_user_by_uuid_uncached(user_uuid, log_response=log_response)
return await self._users_cache.get_or_load(
f"uuid:{user_uuid}",
lambda: self._get_user_by_uuid_uncached(user_uuid, log_response=False),
)
async def _get_user_by_uuid_uncached(
self, user_uuid: str, log_response: bool = False
) -> Optional[Dict[str, Any]]:
endpoint = f"/users/{user_uuid}"
full_response = await self._request("GET", endpoint, log_full_response=log_response)
@@ -422,6 +462,7 @@ class PanelApiService:
"POST", "/users", json=payload, log_full_response=log_response
)
if response and not response.get("error") and "response" in response:
self._invalidate_all_users_cache()
logging.info(
f"Panel user '{username_on_panel}' created successfully (UUID: {response.get('response', {}).get('uuid')})." # noqa: E501
)
@@ -443,6 +484,8 @@ class PanelApiService:
)
if full_response and not full_response.get("error") and "response" in full_response:
logging.debug("User %s details updated on panel.", user_uuid)
self._invalidate_user_cache(user_uuid)
self._invalidate_all_users_cache()
return full_response.get("response")
logging.error(
@@ -458,6 +501,8 @@ class PanelApiService:
response_data = await self._request("POST", endpoint, log_full_response=log_response)
if response_data and not response_data.get("error") and "response" in response_data:
self._invalidate_user_cache(user_uuid)
self._invalidate_all_users_cache()
actual_status = response_data.get("response", {}).get("status")
expected_status = "ACTIVE" if enable else "DISABLED"
if actual_status == expected_status:
@@ -494,11 +539,17 @@ class PanelApiService:
logging.info(
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted." # noqa: E501
)
self._invalidate_user_cache(user_uuid)
self._invalidate_devices_cache(user_uuid)
self._invalidate_all_users_cache()
return True
logging.error(f"Failed to delete user {user_uuid} on panel. Response: {response_data}")
return False
logging.info(f"Panel user {user_uuid} deleted successfully.")
self._invalidate_user_cache(user_uuid)
self._invalidate_devices_cache(user_uuid)
self._invalidate_all_users_cache()
return True
async def get_subscription_link(
@@ -513,6 +564,14 @@ class PanelApiService:
return base_sub_url
async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
if self._devices_cache.ttl_seconds <= 0:
return await self._get_user_devices_uncached(user_uuid)
return await self._devices_cache.get_or_load(
f"user:{user_uuid}",
lambda: self._get_user_devices_uncached(user_uuid),
)
async def _get_user_devices_uncached(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
endpoint = f"/hwid/devices/{user_uuid}"
response_data = await self._request("GET", endpoint, log_full_response=False)
if response_data and not response_data.get("error") and "response" in response_data:
@@ -525,6 +584,7 @@ class PanelApiService:
payload = {"userUuid": user_uuid, "hwid": hwid}
response_data = await self._request("POST", endpoint, json=payload, log_full_response=False)
if response_data and not response_data.get("error") and "response" in response_data:
self._invalidate_devices_cache(user_uuid)
return True
logging.error(
f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}" # noqa: E501
@@ -629,6 +689,19 @@ class PanelApiService:
def _invalidate_squad_caches(self) -> None:
self._squads_cache.invalidate()
def _invalidate_user_cache(self, user_uuid: Optional[str]) -> None:
if not user_uuid:
return
self._users_cache.invalidate(f"uuid:{user_uuid}")
def _invalidate_all_users_cache(self) -> None:
self._all_users_cache.invalidate()
def _invalidate_devices_cache(self, user_uuid: Optional[str]) -> None:
if not user_uuid:
return
self._devices_cache.invalidate(f"user:{user_uuid}")
async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]:
return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached)
+1 -1
View File
@@ -29,7 +29,7 @@ PREMIUM_WARNING_DEPLETED_LEVEL = PREMIUM_WARNING_LEVEL_OFFSET + 100
# to avoid an N+1 serial chain to the Remnawave panel each tick.
TARIFF_WORKER_BATCH_SIZE = 50
TARIFF_WORKER_PANEL_CONCURRENCY = 10
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 200
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
class TariffTrafficWorker:
+6 -1
View File
@@ -96,6 +96,11 @@ class Settings(BaseSettings):
REDIS_KEY_PREFIX: str = Field(default="remnawave-tg-shop")
WEBAPP_ME_CACHE_TTL_SECONDS: int = Field(default=15)
WEBAPP_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5)
PANEL_USER_CACHE_TTL_SECONDS: int = Field(default=5)
PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5)
PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5)
ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15)
PROFILE_SYNC_CACHE_TTL_SECONDS: int = Field(default=900)
WEBAPP_RATE_LIMIT_TTL_SECONDS: int = Field(default=60)
WEBAPP_RATE_LIMIT_MAX_REQUESTS: int = Field(default=30)
WEBHOOK_QUEUE_NAME: str = Field(default="webhook-events")
@@ -103,7 +108,7 @@ class Settings(BaseSettings):
WORKER_PANEL_SYNC_INTERVAL_SECONDS: int = Field(default=900)
TARIFF_WORKER_LOCK_TTL_SECONDS: int = Field(default=240)
TARIFF_WORKER_TICK_SECONDS: int = Field(default=300)
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD: int = Field(default=200)
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD: int = Field(default=50)
DEFAULT_LANGUAGE: str = Field(default="ru")
DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB")
+263 -6
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import argparse
import asyncio
import json
import math
import sys
import time
from pathlib import Path
@@ -16,7 +15,15 @@ for path in (str(BACKEND), str(ROOT)):
if path not in sys.path:
sys.path.insert(0, path)
import bot.app.web.subscription_webapp # noqa: E402,F401
from bot.app.web.admin_api_impl import stats as admin_stats_module # noqa: E402
from bot.handlers.admin.sync_admin import ( # noqa: E402
_description_matches,
_subscription_update_delta,
)
from bot.middlewares import profile_sync as profile_sync_module # noqa: E402
from bot.services import panel_api_service # noqa: E402
from bot.services.panel_api_service import PanelApiService # noqa: E402
from bot.services.tariff_worker import TariffTrafficWorker # noqa: E402
from bot.utils import config_link # noqa: E402
from bot.utils.config_link import prepare_config_links # noqa: E402
@@ -25,6 +32,14 @@ from bot.utils.ttl_cache import AsyncTTLCache # noqa: E402
DEFAULT_USER_SIZES = (200, 500, 1000, 5000, 10000)
def estimated_panel_user_pages(users: int, page_size: int = 100) -> int:
if users <= 0:
return 1
# get_all_panel_users stops on a short/empty page, so exact page multiples
# need one final empty-page request.
return users // page_size + 1
class FakePanel:
def __init__(self, users: int):
self.calls = 0
@@ -84,7 +99,7 @@ async def bench_premium_usage(users: int) -> dict:
async def bench_panel_user_prefetch(users: int) -> dict:
panel = FakeBulkPanel(users)
worker = TariffTrafficWorker(
settings=SimpleNamespace(TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD=200),
settings=SimpleNamespace(TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD=50),
session_factory=SimpleNamespace(),
panel_service=panel,
subscription_service=SimpleNamespace(),
@@ -98,7 +113,156 @@ async def bench_panel_user_prefetch(users: int) -> dict:
"service_calls": panel.calls,
"matched": len(by_uuid or {}),
"legacy_user_get_calls": users,
"estimated_bulk_http_pages_at_100": math.ceil(users / 100),
"estimated_bulk_http_pages_at_100": estimated_panel_user_pages(users),
}
async def bench_panel_sync_startup(users: int) -> dict:
end_date = "2026-06-20T12:00:00+00:00"
from datetime import datetime, timezone
parsed_end_date = datetime.fromisoformat(end_date).astimezone(timezone.utc)
started = time.perf_counter()
subscription_writes = 0
description_patches = 0
for index in range(users):
desired_description = f"user{index}@example.test\nusername_{index}"
current_description = f"user{index}@example.test username_{index}"
if not _description_matches(current_description, desired_description):
description_patches += 1
subscription = SimpleNamespace(
user_id=index,
panel_user_uuid=f"panel-{index}",
end_date=parsed_end_date,
is_active=True,
status_from_panel="ACTIVE",
)
delta = _subscription_update_delta(
subscription,
{
"user_id": index,
"panel_user_uuid": f"panel-{index}",
"end_date": parsed_end_date,
"is_active": True,
"status_from_panel": "ACTIVE",
},
)
if delta:
subscription_writes += 1
elapsed = time.perf_counter() - started
return {
"seconds": elapsed,
"panel_get_pages_estimate": estimated_panel_user_pages(users),
"legacy_user_lookup_queries_estimate": users * 3,
"optimized_user_lookup_queries_estimate": 1,
"legacy_subscription_lookup_queries_estimate": users,
"optimized_subscription_lookup_queries_estimate": 1,
"legacy_subscription_write_attempts": users,
"optimized_subscription_writes": subscription_writes,
"description_panel_patches": description_patches,
}
async def bench_panel_user_cache(users: int) -> dict:
settings = SimpleNamespace(
PANEL_API_URL="https://panel.example.test/api",
PANEL_API_KEY="key",
USER_HWID_DEVICE_LIMIT=None,
PANEL_USER_CACHE_TTL_SECONDS=60,
PANEL_DEVICES_CACHE_TTL_SECONDS=60,
PANEL_ALL_USERS_CACHE_TTL_SECONDS=60,
REDIS_URL=None,
REDIS_KEY_PREFIX="bench",
)
service = PanelApiService(settings)
calls = 0
async def fake_request(method, endpoint, log_full_response=False, **kwargs):
nonlocal calls
calls += 1
await asyncio.sleep(0.001)
return {"response": {"uuid": "panel-user", "username": "cached"}}
service._request = fake_request
started = time.perf_counter()
await asyncio.gather(*(service.get_user_by_uuid("panel-user") for _ in range(users)))
elapsed = time.perf_counter() - started
return {
"seconds": elapsed,
"panel_calls": calls,
"legacy_panel_calls": users,
}
async def bench_panel_all_users_cache(users: int) -> dict:
settings = SimpleNamespace(
PANEL_API_URL="https://panel.example.test/api",
PANEL_API_KEY="key",
USER_HWID_DEVICE_LIMIT=None,
PANEL_USER_CACHE_TTL_SECONDS=60,
PANEL_DEVICES_CACHE_TTL_SECONDS=60,
PANEL_ALL_USERS_CACHE_TTL_SECONDS=60,
REDIS_URL=None,
REDIS_KEY_PREFIX="bench",
)
service = PanelApiService(settings)
panel_users = [{"uuid": f"panel-{index}"} for index in range(users)]
calls = 0
async def fake_request(method, endpoint, log_full_response=False, **kwargs):
nonlocal calls
calls += 1
await asyncio.sleep(0.001)
params = kwargs.get("params") or {}
size = int(params.get("size", 100))
start = int(params.get("start", 0))
return {"response": {"users": panel_users[start : start + size]}}
service._request = fake_request
started = time.perf_counter()
first, second = await asyncio.gather(
service.get_all_panel_users(),
service.get_all_panel_users(),
)
elapsed = time.perf_counter() - started
pages = estimated_panel_user_pages(users)
return {
"seconds": elapsed,
"panel_calls": calls,
"legacy_panel_calls": pages * 2,
"users_first": len(first or []),
"users_second": len(second or []),
}
async def bench_panel_devices_cache(users: int) -> dict:
settings = SimpleNamespace(
PANEL_API_URL="https://panel.example.test/api",
PANEL_API_KEY="key",
USER_HWID_DEVICE_LIMIT=None,
PANEL_USER_CACHE_TTL_SECONDS=60,
PANEL_DEVICES_CACHE_TTL_SECONDS=60,
PANEL_ALL_USERS_CACHE_TTL_SECONDS=60,
REDIS_URL=None,
REDIS_KEY_PREFIX="bench",
)
service = PanelApiService(settings)
calls = 0
async def fake_request(method, endpoint, log_full_response=False, **kwargs):
nonlocal calls
calls += 1
await asyncio.sleep(0.001)
return {"response": [{"hwid": "device-1"}]}
service._request = fake_request
started = time.perf_counter()
await asyncio.gather(*(service.get_user_devices("panel-user") for _ in range(users)))
elapsed = time.perf_counter() - started
return {
"seconds": elapsed,
"panel_calls": calls,
"legacy_panel_calls": users,
}
@@ -132,6 +296,83 @@ async def bench_ttl_singleflight(users: int) -> dict:
}
class FakeAdminStatsPanel:
def __init__(self):
self.calls = {
"system": 0,
"bandwidth": 0,
"nodes": 0,
"nodes_bandwidth": 0,
"online": 0,
}
async def get_system_stats(self):
self.calls["system"] += 1
await asyncio.sleep(0.001)
return {"users": {"totalUsers": 10}}
async def get_bandwidth_stats(self):
self.calls["bandwidth"] += 1
await asyncio.sleep(0.001)
return {"current": 123}
async def get_nodes_statistics(self):
self.calls["nodes"] += 1
await asyncio.sleep(0.001)
return {"nodes": []}
async def get_nodes_bandwidth_usage(self, *, start: str, end: str, top_nodes_limit: int = 64):
self.calls["nodes_bandwidth"] += 1
await asyncio.sleep(0.001)
return {"topNodes": []}
async def get_nodes_online_lookups(self):
self.calls["online"] += 1
await asyncio.sleep(0.001)
return {"byUuid": {}, "byName": {}}
async def bench_admin_stats_cache(users: int) -> dict:
admin_stats_module._ADMIN_PANEL_STATS_CACHES.clear()
settings = SimpleNamespace(
ADMIN_PANEL_STATS_CACHE_TTL_SECONDS=60,
REDIS_URL=None,
REDIS_KEY_PREFIX="bench",
)
panel = FakeAdminStatsPanel()
started = time.perf_counter()
await asyncio.gather(
*(admin_stats_module._load_admin_panel_stats(None, settings, panel) for _ in range(users))
)
elapsed = time.perf_counter() - started
return {
"seconds": elapsed,
"panel_endpoint_calls": sum(panel.calls.values()),
"legacy_panel_endpoint_calls": users * len(panel.calls),
}
async def bench_profile_sync_guard(users: int) -> dict:
profile_sync_module._LOCAL_PROFILE_SYNC_CHECKS.clear()
settings = SimpleNamespace(
PROFILE_SYNC_CACHE_TTL_SECONDS=900,
REDIS_URL=None,
REDIS_KEY_PREFIX="bench",
)
allowed_checks = 0
started = time.perf_counter()
for _ in range(users):
if not await profile_sync_module._profile_sync_recently_checked(settings, 42):
allowed_checks += 1
await profile_sync_module._mark_profile_sync_checked(settings, 42)
elapsed = time.perf_counter() - started
return {
"seconds": elapsed,
"profile_checks_allowed": allowed_checks,
"legacy_profile_checks": users,
}
async def bench_crypt4(users: int) -> dict:
config_link._CRYPT4_LINK_CACHES.clear()
settings = SimpleNamespace(
@@ -175,9 +416,15 @@ async def run_suite(user_sizes: tuple[int, ...]) -> dict:
results: dict[str, dict] = {}
for users in user_sizes:
results[str(users)] = {
"panel_sync_startup": await bench_panel_sync_startup(users),
"panel_user_bulk_prefetch": await bench_panel_user_prefetch(users),
"panel_all_users_cache": await bench_panel_all_users_cache(users),
"panel_user_cache": await bench_panel_user_cache(users),
"panel_devices_cache": await bench_panel_devices_cache(users),
"premium_usage_1_node": await bench_premium_usage(users),
"ttl_cache_cold_single_key": await bench_ttl_singleflight(users),
"admin_stats_cache": await bench_admin_stats_cache(users),
"profile_sync_guard": await bench_profile_sync_guard(users),
"crypt4_same_link": await bench_crypt4(users),
}
return results
@@ -186,16 +433,26 @@ async def run_suite(user_sizes: tuple[int, ...]) -> dict:
def _print_table(results: dict[str, dict]) -> None:
print(
"users | bulk_pages_est | premium_usage_s | premium_panel_calls | "
"ttl_loader_calls | crypt4_panel_calls"
"sync_db_reads_est | sync_db_writes | user_cache_calls | "
"all_users_calls | device_cache_calls | admin_panel_calls | crypt4_panel_calls"
)
print("-" * 104)
print("-" * 154)
for users, data in results.items():
sync_optimized_reads = (
data["panel_sync_startup"]["optimized_user_lookup_queries_estimate"]
+ data["panel_sync_startup"]["optimized_subscription_lookup_queries_estimate"]
)
print(
f"{users:>5} | "
f"{data['panel_user_bulk_prefetch']['estimated_bulk_http_pages_at_100']:>14} | "
f"{data['premium_usage_1_node']['seconds']:>15.6f} | "
f"{data['premium_usage_1_node']['panel_calls']:>19} | "
f"{data['ttl_cache_cold_single_key']['loader_calls']:>16} | "
f"{sync_optimized_reads:>17} | "
f"{data['panel_sync_startup']['optimized_subscription_writes']:>14} | "
f"{data['panel_user_cache']['panel_calls']:>16} | "
f"{data['panel_all_users_cache']['panel_calls']:>15} | "
f"{data['panel_devices_cache']['panel_calls']:>18} | "
f"{data['admin_stats_cache']['panel_endpoint_calls']:>17} | "
f"{data['crypt4_same_link']['panel_calls']:>18}"
)
+59
View File
@@ -0,0 +1,59 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import bot.app.web.subscription_webapp # noqa: F401
from bot.app.web.admin_api_impl import stats as stats_module
class AdminPanelStatsCacheTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
stats_module._ADMIN_PANEL_STATS_CACHES.clear()
async def asyncTearDown(self):
stats_module._ADMIN_PANEL_STATS_CACHES.clear()
def _settings(self):
return SimpleNamespace(
ADMIN_PANEL_STATS_CACHE_TTL_SECONDS=15,
REDIS_URL="redis://redis:6379/0",
REDIS_KEY_PREFIX="shop",
)
def _panel_service(self):
return SimpleNamespace(
get_system_stats=AsyncMock(return_value={"users": {"totalUsers": 10}}),
get_bandwidth_stats=AsyncMock(return_value={"current": 123}),
get_nodes_statistics=AsyncMock(return_value={"nodes": []}),
get_nodes_bandwidth_usage=AsyncMock(return_value={"topNodes": []}),
get_nodes_online_lookups=AsyncMock(return_value={"byUuid": {}, "byName": {}}),
)
async def test_admin_panel_stats_are_cached_between_requests(self):
settings = self._settings()
panel_service = self._panel_service()
cache_store = {}
async def fake_get(_settings, key):
return cache_store.get(key)
async def fake_set(_settings, key, value, ttl):
cache_store[key] = value
with (
patch("bot.infra.redis.cache_get_json", fake_get),
patch("bot.infra.redis.cache_set_json", fake_set),
):
first = await stats_module._load_admin_panel_stats(None, settings, panel_service)
second = await stats_module._load_admin_panel_stats(None, settings, panel_service)
self.assertEqual(first, second)
panel_service.get_system_stats.assert_awaited_once()
panel_service.get_bandwidth_stats.assert_awaited_once()
panel_service.get_nodes_statistics.assert_awaited_once()
panel_service.get_nodes_bandwidth_usage.assert_awaited_once()
panel_service.get_nodes_online_lookups.assert_awaited_once()
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
from datetime import datetime, timedelta, timezone
from bot.handlers.admin.sync_admin import (
_coerce_panel_telegram_id,
_description_matches,
_subscription_update_delta,
)
from db.models import Subscription
def test_description_match_ignores_whitespace_shape():
assert _description_matches("email@example.com username", "email@example.com\nusername")
def test_panel_telegram_id_is_coerced_to_int():
assert _coerce_panel_telegram_id("12345") == 12345
assert _coerce_panel_telegram_id("") is None
def test_subscription_update_delta_skips_unchanged_fields():
end_date = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc)
subscription = Subscription(
user_id=1,
panel_user_uuid="panel-1",
panel_subscription_uuid="sub-1",
end_date=end_date,
is_active=True,
status_from_panel="ACTIVE",
)
assert (
_subscription_update_delta(
subscription,
{
"user_id": 1,
"panel_user_uuid": "panel-1",
"end_date": end_date + timedelta(milliseconds=500),
"is_active": True,
"status_from_panel": "ACTIVE",
},
)
== {}
)
def test_subscription_update_delta_returns_only_changed_fields():
end_date = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc)
subscription = Subscription(
user_id=1,
panel_user_uuid="panel-1",
panel_subscription_uuid="sub-1",
end_date=end_date,
is_active=True,
status_from_panel="ACTIVE",
)
assert _subscription_update_delta(
subscription,
{
"user_id": 2,
"panel_user_uuid": "panel-1",
"end_date": end_date + timedelta(seconds=2),
"is_active": False,
"status_from_panel": "EXPIRED",
},
) == {
"user_id": 2,
"end_date": end_date + timedelta(seconds=2),
"is_active": False,
"status_from_panel": "EXPIRED",
}
+60
View File
@@ -1,3 +1,4 @@
import asyncio
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
@@ -46,6 +47,65 @@ class PanelApiServiceLoggingTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(service._request.await_args.kwargs["log_full_response"])
async def test_get_user_by_uuid_uses_short_ttl_cache_and_update_invalidates(self):
service = self._make_service()
service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}})
first = await service.get_user_by_uuid("user-uuid")
second = await service.get_user_by_uuid("user-uuid")
self.assertEqual(first, {"uuid": "user-uuid"})
self.assertEqual(second, {"uuid": "user-uuid"})
self.assertEqual(service._request.await_count, 1)
await service.update_user_details_on_panel("user-uuid", {"description": "updated"})
await service.get_user_by_uuid("user-uuid")
self.assertEqual(service._request.await_count, 3)
async def test_get_user_devices_uses_short_ttl_cache_and_disconnect_invalidates(self):
service = self._make_service()
service._request = AsyncMock(return_value={"response": [{"hwid": "device-1"}]})
first = await service.get_user_devices("user-uuid")
second = await service.get_user_devices("user-uuid")
self.assertEqual(first, [{"hwid": "device-1"}])
self.assertEqual(second, [{"hwid": "device-1"}])
self.assertEqual(service._request.await_count, 1)
await service.disconnect_device("user-uuid", "device-1")
await service.get_user_devices("user-uuid")
self.assertEqual(service._request.await_count, 3)
async def test_get_all_panel_users_uses_singleflight_cache_and_update_invalidates(self):
service = self._make_service()
get_calls = 0
async def fake_request(method, endpoint, **kwargs):
nonlocal get_calls
if method == "GET":
get_calls += 1
return {"response": {"users": [{"uuid": "user-uuid"}]}}
return {"response": {"uuid": "user-uuid"}}
service._request = AsyncMock(side_effect=fake_request)
first, second = await asyncio.gather(
service.get_all_panel_users(),
service.get_all_panel_users(),
)
self.assertEqual(first, [{"uuid": "user-uuid"}])
self.assertEqual(second, [{"uuid": "user-uuid"}])
self.assertEqual(get_calls, 1)
await service.update_user_details_on_panel("user-uuid", {"description": "updated"})
await service.get_all_panel_users()
self.assertEqual(get_calls, 2)
if __name__ == "__main__":
unittest.main()
+74
View File
@@ -0,0 +1,74 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from bot.middlewares import profile_sync as profile_sync_module
from bot.middlewares.profile_sync import ProfileSyncMiddleware
class ProfileSyncMiddlewareCacheTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
profile_sync_module._LOCAL_PROFILE_SYNC_CHECKS.clear()
async def asyncTearDown(self):
profile_sync_module._LOCAL_PROFILE_SYNC_CHECKS.clear()
def _settings(self):
return SimpleNamespace(
PROFILE_SYNC_CACHE_TTL_SECONDS=900,
REDIS_URL="redis://redis:6379/0",
REDIS_KEY_PREFIX="shop",
)
async def test_profile_sync_skips_repeated_user_checks_inside_ttl(self):
middleware = ProfileSyncMiddleware()
handler = AsyncMock(return_value="ok")
event = SimpleNamespace()
tg_user = SimpleNamespace(
id=42,
username="alice",
first_name="Alice",
last_name="Smith",
)
db_user = SimpleNamespace(
user_id=42,
telegram_id=42,
username="alice",
first_name="Alice",
last_name="Smith",
email=None,
panel_user_uuid=None,
)
cache_store = {}
async def fake_get(_settings, key):
return cache_store.get(key)
async def fake_set(_settings, key, value, ttl):
cache_store[key] = value
data = {
"session": AsyncMock(),
"event_from_user": tg_user,
"settings": self._settings(),
}
with (
patch.object(profile_sync_module, "cache_get_json", fake_get),
patch.object(profile_sync_module, "cache_set_json", fake_set),
patch.object(
profile_sync_module.user_dal,
"get_user_by_telegram_id",
AsyncMock(return_value=db_user),
) as get_user,
):
first = await middleware(handler, event, data)
second = await middleware(handler, event, data)
self.assertEqual(first, "ok")
self.assertEqual(second, "ok")
get_user.assert_awaited_once()
self.assertEqual(handler.await_count, 2)
if __name__ == "__main__":
unittest.main()