From 3b03c253cc1603fe54fddcbc8bc374ec2c71bdfc Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sun, 19 Apr 2026 12:08:34 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=83=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20root=20cause=20MissingGreenlet=20=D0=B2=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=D0=B5=D0=B6?= =?UTF-8?q?=D0=B0=D1=85=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20refetch=20?= =?UTF-8?q?=D0=BF=D0=BE=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Трейс показал: 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 объекте. --- app/services/monitoring_service.py | 40 +++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 6535982b..4264902f 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -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: