From 5442f288d4c6c3973dd92ac141172a9f0e53a28f Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 13 Mar 2026 21:39:31 +0300 Subject: [PATCH 1/3] fix: add selectinload to user lock queries to prevent MissingGreenlet lock_user_for_update, subtract_user_balance, and add_user_balance use select(User).with_for_update().populate_existing which expires loaded relationships. Added selectinload for subscription, user_promo_groups and promo_group to prevent lazy-load in async context. --- app/database/crud/user.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index daf69f1f..5e72dee5 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -415,9 +415,18 @@ async def lock_user_for_update(db: AsyncSession, user: User) -> User: Returns the refreshed user object with current DB values. Must be called within an active transaction before modifying balance_kopeks. + Eagerly loads key relationships to avoid MissingGreenlet in async context. """ result = await db.execute( - select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True) + select(User) + .where(User.id == user.id) + .options( + selectinload(User.subscription), + selectinload(User.user_promo_groups), + selectinload(User.promo_group), + ) + .with_for_update() + .execution_options(populate_existing=True) ) return result.scalar_one() @@ -434,8 +443,17 @@ async def add_user_balance( ) -> bool: try: # Lock the user row to prevent concurrent balance race conditions + # Eagerly load key relationships to avoid MissingGreenlet in async context locked_result = await db.execute( - select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True) + select(User) + .where(User.id == user.id) + .options( + selectinload(User.subscription), + selectinload(User.user_promo_groups), + selectinload(User.promo_group), + ) + .with_for_update() + .execution_options(populate_existing=True) ) user = locked_result.scalar_one() @@ -534,8 +552,17 @@ async def subtract_user_balance( ) # Lock the user row to prevent concurrent balance race conditions + # Eagerly load key relationships to avoid MissingGreenlet in async context locked_result = await db.execute( - select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True) + select(User) + .where(User.id == user.id) + .options( + selectinload(User.subscription), + selectinload(User.user_promo_groups), + selectinload(User.promo_group), + ) + .with_for_update() + .execution_options(populate_existing=True) ) user = locked_result.scalar_one() From 14dceaa39ff9faa1c9205483653014a1c5ac73fb Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 13 Mar 2026 21:39:39 +0300 Subject: [PATCH 2/3] fix: silence PARTICIPANT_ID_INVALID error in channel subscription check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle PARTICIPANT_ID_INVALID same as 'user not found' — expected for users who authenticated via Telegram Login Widget but never interacted with the bot or channel directly. --- app/services/channel_subscription_service.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/services/channel_subscription_service.py b/app/services/channel_subscription_service.py index ec7bd810..5751b5f7 100644 --- a/app/services/channel_subscription_service.py +++ b/app/services/channel_subscription_service.py @@ -274,8 +274,9 @@ class ChannelSubscriptionService: ) return False # Fail-closed -- bot cannot verify membership except TelegramBadRequest as e: - if 'user not found' in str(e).lower(): - return False # User never interacted with bot in that context + err_msg = str(e).lower() + if 'user not found' in err_msg or 'participant_id_invalid' in err_msg: + return False # User never interacted with bot/channel logger.error('Bad request checking channel', channel_id=channel_id, error=str(e)) return False # Fail-closed except TelegramNetworkError: From 3306e029021c396e13774a205225beece4fbbcfb Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sat, 14 Mar 2026 00:14:42 +0300 Subject: [PATCH 3/3] fix: add nested selectinload and referrer eager loading to prevent MissingGreenlet Added selectinload(UserPromoGroup.promo_group) nested under user_promo_groups to prevent lazy-load in get_primary_promo_group(). Added selectinload(User.referrer) for format_referrer_info(). Broadened except clause in format_referrer_info as safety net. --- app/database/crud/user.py | 9 ++++++--- app/utils/user_utils.py | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 5e72dee5..f2ddb7f6 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -422,8 +422,9 @@ async def lock_user_for_update(db: AsyncSession, user: User) -> User: .where(User.id == user.id) .options( selectinload(User.subscription), - selectinload(User.user_promo_groups), + selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), selectinload(User.promo_group), + selectinload(User.referrer), ) .with_for_update() .execution_options(populate_existing=True) @@ -449,8 +450,9 @@ async def add_user_balance( .where(User.id == user.id) .options( selectinload(User.subscription), - selectinload(User.user_promo_groups), + selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), selectinload(User.promo_group), + selectinload(User.referrer), ) .with_for_update() .execution_options(populate_existing=True) @@ -558,8 +560,9 @@ async def subtract_user_balance( .where(User.id == user.id) .options( selectinload(User.subscription), - selectinload(User.user_promo_groups), + selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), selectinload(User.promo_group), + selectinload(User.referrer), ) .with_for_update() .execution_options(populate_existing=True) diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py index b59a62a1..74b5b2db 100644 --- a/app/utils/user_utils.py +++ b/app/utils/user_utils.py @@ -24,6 +24,8 @@ def format_referrer_info(user: User) -> str: try: # Проверяем, является ли referrer обычным объектом или InstrumentedList + # getattr default does NOT catch MissingGreenlet (not an AttributeError), + # so we wrap in try/except to handle lazy-load failures in async context. referrer = getattr(user, 'referrer', None) # Если referrer это InstrumentedList или None, то возвращаем информацию по ID @@ -39,8 +41,9 @@ def format_referrer_info(user: User) -> str: return f'ID {referrer_telegram_id or referred_by_id}' - except (AttributeError, TypeError): - # Если возникла ошибка при обращении к атрибутам, просто возвращаем ID + except Exception: + # MissingGreenlet is not a subclass of AttributeError/TypeError, + # so we must catch broadly to handle lazy-load failures in async context. return f'ID {referred_by_id} (ошибка загрузки)'