fix: устранить root cause MissingGreenlet в автоплатежах через refetch по id

Трейс показал: subscription.user падает на lazy-load → pool._checkout →
do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не
поддерживает sync-lazy-load для relationships. Причина рассинхрона:
lock_user_for_pricing делает populate_existing=True + selectinload(
User.subscriptions).selectinload(Subscription.tariff), что разгружает
Subscription.user backref для сестринских подписок того же user.
Последующее обращение sub.user у другой подписки падает.

Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию
делаем fresh refetch через async select с eager load user+tariff+
promo_group. Никаких lazy access в горячем пути. В except используем
локально захваченные id вместо getattr(subscription, ...), чтобы
логирование не падало каскадом на expired объекте.
This commit is contained in:
c0mrade
2026-04-19 12:08:34 +03:00
parent db79cc9eb0
commit 3b03c253cc
+34 -6
View File
@@ -1202,13 +1202,39 @@ class MonitoringService:
processed_count = 0
failed_count = 0
for subscription in autopay_subscriptions:
# Захватываем (sub_id, user_id) ДО цикла, пока сессия ещё свежая.
# В цикле каждую итерацию делаем refetch subscription+user через
# async-запрос: это единственный безопасный способ избежать
# MissingGreenlet при sync-lazy-load, который SQLAlchemy 2.0 async
# session не поддерживает (напр. lock_user_for_pricing c
# populate_existing=True разгружает Subscription.user backref).
autopay_pairs: list[tuple[int, int]] = [(s.id, s.user_id) for s in autopay_subscriptions]
for sub_id_local, sub_user_id_local in autopay_pairs:
try:
# Refetch subscription с eager load user/tariff —
# никаких lazy access по ходу итерации.
refetch_result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user).options(
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
),
selectinload(Subscription.tariff),
)
.where(Subscription.id == sub_id_local)
)
subscription = refetch_result.scalar_one_or_none()
if subscription is None:
continue
from app.database.crud.subscription import is_recently_updated_by_webhook
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск автоплатежа подписки : обновлена вебхуком недавно', subscription_id=subscription.id
'Пропуск автоплатежа подписки : обновлена вебхуком недавно',
subscription_id=subscription.id,
)
continue
@@ -1417,15 +1443,17 @@ class MonitoringService:
)
except Exception as sub_error:
failed_count += 1
# Используем локально захваченные id — subscription-объект
# может быть expired после чужого rollback'а.
logger.error(
'Ошибка автопродления отдельной подписки',
subscription_id=getattr(subscription, 'id', None),
user_id=getattr(subscription, 'user_id', None),
subscription_id=sub_id_local,
user_id=sub_user_id_local,
error=sub_error,
exc_info=True,
)
# Сессия могла «протухнуть» (MissingGreenlet, rollback после commit и т.п.) —
# откатываемся, чтобы следующая итерация работала с чистой сессией.
# Сессия могла остаться с aborted-транзакцией — откатываем,
# чтобы следующая итерация начала refetch на чистой сессии.
try:
await db.rollback()
except Exception as rollback_error: