fix: handle StaleDataError in webhook user.deleted server counter decrement

When a user is deleted from the panel, the subscription may already be
cascade-deleted by the time the webhook handler tries to decrement
server counters. This caused StaleDataError followed by
PendingRollbackError when accessing subscription.id in the error handler.

- Save subscription.id before DB operations to avoid lazy load after rollback
- Catch StaleDataError explicitly and rollback the session
- Re-fetch subscription/user after potential rollback in _handle_user_deleted
- Skip subscription cleanup if it was already cascade-deleted
This commit is contained in:
Fringg
2026-02-11 18:35:36 +03:00
parent 640da34736
commit c30c2feee1
2 changed files with 43 additions and 7 deletions

View File

@@ -6,6 +6,7 @@ from typing import Optional
from sqlalchemy import and_, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.exc import StaleDataError
from app.config import settings
from app.database.crud.notification import clear_notifications
@@ -625,6 +626,9 @@ async def decrement_subscription_server_counts(
if not subscription:
return
# Save ID before any DB operations that might invalidate the ORM object
sub_id = subscription.id
server_ids: set[int] = set()
if subscription_servers is not None:
@@ -633,12 +637,12 @@ async def decrement_subscription_server_counts(
server_ids.add(sub_server.server_squad_id)
else:
try:
ids_from_links = await get_subscription_server_ids(db, subscription.id)
ids_from_links = await get_subscription_server_ids(db, sub_id)
server_ids.update(ids_from_links)
except Exception as error:
logger.error(
'⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s',
subscription.id,
sub_id,
error,
)
@@ -652,7 +656,7 @@ async def decrement_subscription_server_counts(
except Exception as error:
logger.error(
'⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s',
subscription.id,
sub_id,
error,
)
@@ -663,11 +667,18 @@ async def decrement_subscription_server_counts(
from app.database.crud.server_squad import remove_user_from_servers
await remove_user_from_servers(db, sorted(server_ids))
except StaleDataError:
logger.warning(
'⚠️ Подписка %s уже удалена (StaleDataError), пропускаем декремент серверов %s',
sub_id,
list(server_ids),
)
await db.rollback()
except Exception as error:
logger.error(
'⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s',
list(server_ids),
subscription.id,
sub_id,
error,
)

View File

@@ -574,18 +574,43 @@ class RemnaWaveWebhookService:
async def _handle_user_deleted(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
user_id = user.id
sub_id = subscription.id if subscription else None
if subscription:
self._stamp_webhook_update(subscription)
# Decrement server counters BEFORE clearing connected_squads
await decrement_subscription_server_counts(db, subscription)
# Re-fetch after potential rollback inside decrement_subscription_server_counts
try:
await db.refresh(subscription)
except Exception:
# Subscription was cascade-deleted, re-fetch user and skip subscription updates
logger.warning(
'Webhook: subscription %s already deleted for user %s, skipping subscription cleanup',
sub_id,
user_id,
)
subscription = None
try:
await db.refresh(user)
except Exception:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if not user:
logger.error('Webhook: user %s not found after rollback', user_id)
return
if subscription:
if subscription.status != SubscriptionStatus.EXPIRED.value:
subscription.status = SubscriptionStatus.EXPIRED.value
logger.info(
'Webhook: subscription %s marked expired (user deleted in panel) for user %s',
subscription.id,
user.id,
sub_id,
user_id,
)
# Clear subscription data — panel user no longer exists
@@ -596,7 +621,7 @@ class RemnaWaveWebhookService:
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
# Remove SubscriptionServer link rows
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription.id))
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
# Clear remnawave linkage
if user.remnawave_uuid: