From e6df6fdd3e245b04010ae705167db619f18e22bd Mon Sep 17 00:00:00 2001 From: PVBLIC Foundation Date: Tue, 4 Nov 2025 08:44:28 -0800 Subject: [PATCH] Enhance Gmail periodic sync with minute-based intervals - Add GMAIL_PERIODIC_SYNC_INTERVAL_MINUTES env var (default: 15min) - Add GMAIL_PERIODIC_SYNC_CHECK_INTERVAL_MINUTES env var (default: 5min) - Update periodic scheduler to use minute-based configuration - Maintain backward compatibility with GMAIL_PERIODIC_SYNC_INTERVAL_HOURS - Fix _sync_user_periodic to properly reuse background sync infrastructure - Add dynamic configuration reload support - Improve logging with minute/hour display Features: - Check every 5 minutes for users needing sync (configurable) - Sync users who haven't synced in last 15 minutes (configurable) - Incremental sync with smart time windows - Circuit breaker pattern for failure handling - Timeout protection and adaptive batch processing This enables much more responsive email updates without manual re-sync or logout/login cycles. --- backend/open_webui/config.py | 29 ++- backend/open_webui/main.py | 14 +- backend/open_webui/utils/gmail_auto_sync.py | 268 ++++++++++++-------- 3 files changed, 198 insertions(+), 113 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 0eb920e071..dca7e0e25e 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -357,7 +357,7 @@ GOOGLE_OAUTH_SCOPE = PersistentConfig( "oauth.google.scope", os.environ.get( "GOOGLE_OAUTH_SCOPE", - "openid email profile https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.modify" + "openid email profile https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.modify", ), ) @@ -414,10 +414,27 @@ GMAIL_PERIODIC_SYNC_ENABLED = PersistentConfig( os.environ.get("GMAIL_PERIODIC_SYNC_ENABLED", "True").lower() == "true", ) +# Sync interval in minutes (default: 15 minutes for more responsive updates) +GMAIL_PERIODIC_SYNC_INTERVAL_MINUTES = PersistentConfig( + "GMAIL_PERIODIC_SYNC_INTERVAL_MINUTES", + "gmail.periodic_sync.interval_minutes", + int(os.environ.get("GMAIL_PERIODIC_SYNC_INTERVAL_MINUTES", "15")), +) + +# How often to check for users needing sync (default: 5 minutes) +GMAIL_PERIODIC_SYNC_CHECK_INTERVAL_MINUTES = PersistentConfig( + "GMAIL_PERIODIC_SYNC_CHECK_INTERVAL_MINUTES", + "gmail.periodic_sync.check_interval_minutes", + int(os.environ.get("GMAIL_PERIODIC_SYNC_CHECK_INTERVAL_MINUTES", "5")), +) + +# Legacy: Keep for backward compatibility but use minutes instead GMAIL_PERIODIC_SYNC_INTERVAL_HOURS = PersistentConfig( "GMAIL_PERIODIC_SYNC_INTERVAL_HOURS", "gmail.periodic_sync.interval_hours", - int(os.environ.get("GMAIL_PERIODIC_SYNC_INTERVAL_HOURS", "6")), + int( + os.environ.get("GMAIL_PERIODIC_SYNC_INTERVAL_HOURS", "0") + ), # 0 = use minutes instead ) # Skip emails in spam and trash folders @@ -453,8 +470,8 @@ GMAIL_ATTACHMENT_TYPES = PersistentConfig( "GMAIL_ATTACHMENT_TYPES", "gmail.attachments.allowed_types", os.environ.get( - "GMAIL_ATTACHMENT_TYPES", - ".pdf,.docx,.doc,.xlsx,.xls,.pptx,.ppt,.txt,.csv,.md,.html,.eml" + "GMAIL_ATTACHMENT_TYPES", + ".pdf,.docx,.doc,.xlsx,.xls,.pptx,.ppt,.txt,.csv,.md,.html,.eml", ), ) @@ -2241,7 +2258,9 @@ PINECONE_METRIC = os.getenv("PINECONE_METRIC", "cosine") PINECONE_CLOUD = os.getenv("PINECONE_CLOUD", "aws") # or "gcp" or "azure" # Pinecone Namespaces (for data segregation) -PINECONE_NAMESPACE = os.environ.get("PINECONE_NAMESPACE", "chat-summary-knowledge") # Chat summaries & knowledge +PINECONE_NAMESPACE = os.environ.get( + "PINECONE_NAMESPACE", "chat-summary-knowledge" +) # Chat summaries & knowledge # Note: Gmail sync uses per-user namespaces: "email-{user_id}" # This provides better isolation and easier user data management diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index ad557dbe2d..0c5800d6ad 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -451,6 +451,8 @@ from open_webui.config import ( GMAIL_SEARCH_TOOL_ENABLED, GMAIL_PERIODIC_SYNC_ENABLED, GMAIL_PERIODIC_SYNC_INTERVAL_HOURS, + GMAIL_PERIODIC_SYNC_INTERVAL_MINUTES, + GMAIL_PERIODIC_SYNC_CHECK_INTERVAL_MINUTES, GMAIL_PROCESS_ATTACHMENTS, GMAIL_MAX_ATTACHMENT_SIZE_MB, GMAIL_ATTACHMENT_TYPES, @@ -624,8 +626,12 @@ async def lifespan(app: FastAPI): asyncio.create_task(periodic_usage_pool_cleanup()) # Start Gmail periodic sync if enabled - if app.state.config.ENABLE_GMAIL_AUTO_SYNC and app.state.config.GMAIL_PERIODIC_SYNC_ENABLED: + if ( + app.state.config.ENABLE_GMAIL_AUTO_SYNC + and app.state.config.GMAIL_PERIODIC_SYNC_ENABLED + ): from open_webui.utils.gmail_auto_sync import periodic_gmail_sync_scheduler + asyncio.create_task(periodic_gmail_sync_scheduler()) log.info("šŸ”„ Gmail periodic sync scheduler started") @@ -938,6 +944,12 @@ app.state.config.GMAIL_SKIP_SPAM_AND_TRASH = GMAIL_SKIP_SPAM_AND_TRASH app.state.config.GMAIL_SEARCH_TOOL_ENABLED = GMAIL_SEARCH_TOOL_ENABLED app.state.config.GMAIL_PERIODIC_SYNC_ENABLED = GMAIL_PERIODIC_SYNC_ENABLED app.state.config.GMAIL_PERIODIC_SYNC_INTERVAL_HOURS = GMAIL_PERIODIC_SYNC_INTERVAL_HOURS +app.state.config.GMAIL_PERIODIC_SYNC_INTERVAL_MINUTES = ( + GMAIL_PERIODIC_SYNC_INTERVAL_MINUTES +) +app.state.config.GMAIL_PERIODIC_SYNC_CHECK_INTERVAL_MINUTES = ( + GMAIL_PERIODIC_SYNC_CHECK_INTERVAL_MINUTES +) app.state.config.GMAIL_PROCESS_ATTACHMENTS = GMAIL_PROCESS_ATTACHMENTS app.state.config.GMAIL_MAX_ATTACHMENT_SIZE_MB = GMAIL_MAX_ATTACHMENT_SIZE_MB app.state.config.GMAIL_ATTACHMENT_TYPES = GMAIL_ATTACHMENT_TYPES diff --git a/backend/open_webui/utils/gmail_auto_sync.py b/backend/open_webui/utils/gmail_auto_sync.py index be2967161f..3338a274fa 100644 --- a/backend/open_webui/utils/gmail_auto_sync.py +++ b/backend/open_webui/utils/gmail_auto_sync.py @@ -65,7 +65,7 @@ class GmailAutoSync: process_attachments: Enable attachment processing max_attachment_size_mb: Maximum attachment size in MB allowed_attachment_types: Comma-separated allowed extensions - + Note: Uses per-user namespaces (email-{user_id}) computed at sync time Note: V2 uses single-vector strategy (no chunking needed) Note: Respects admin panel RAG/Document settings @@ -76,7 +76,7 @@ class GmailAutoSync: app_config=app_config, ) self.pinecone = pinecone_manager - + # Attachment processing config self.process_attachments = process_attachments self.max_attachment_size_mb = max_attachment_size_mb @@ -380,9 +380,13 @@ class GmailAutoSync: emails=batch, user_id=user_id, fetcher=fetcher, - process_attachments=getattr(self, 'process_attachments', True), - max_attachment_size_mb=getattr(self, 'max_attachment_size_mb', 10), - allowed_attachment_types=getattr(self, 'allowed_attachment_types', ".pdf,.docx,.doc,.xlsx,.xls,.pptx,.ppt,.txt,.csv,.md,.html,.eml"), + process_attachments=getattr(self, "process_attachments", True), + max_attachment_size_mb=getattr(self, "max_attachment_size_mb", 10), + allowed_attachment_types=getattr( + self, + "allowed_attachment_types", + ".pdf,.docx,.doc,.xlsx,.xls,.pptx,.ppt,.txt,.csv,.md,.html,.eml", + ), ) upsert_data = result["upsert_data"] @@ -521,10 +525,12 @@ async def trigger_gmail_sync_if_needed( if not user: logger.info(f" ā­ļø SKIP: User {user_id} not found") return - + # Check admin setting for Gmail sync - admin_sync_enabled = getattr(user, 'gmail_sync_enabled', 0) == 1 - logger.info(f" āš™ļø Admin Gmail sync setting: gmail_sync_enabled={admin_sync_enabled}") + admin_sync_enabled = getattr(user, "gmail_sync_enabled", 0) == 1 + logger.info( + f" āš™ļø Admin Gmail sync setting: gmail_sync_enabled={admin_sync_enabled}" + ) # Check if we should only sync on signup (only applies if admin hasn't explicitly enabled) sync_on_signup_only = request.app.state.config.GMAIL_AUTO_SYNC_ON_SIGNUP_ONLY @@ -536,13 +542,17 @@ async def trigger_gmail_sync_if_needed( if not admin_sync_enabled: if sync_on_signup_only and not is_new_user: logger.info(f" ā­ļø SKIP: Gmail auto-sync only on signup, user is not new") - logger.info(f" Admin can enable sync in Admin -> Users -> Edit User -> Gmail Email Sync") + logger.info( + f" Admin can enable sync in Admin -> Users -> Edit User -> Gmail Email Sync" + ) return else: logger.info(f" ā­ļø SKIP: Admin has disabled Gmail sync for this user") - logger.info(f" Enable in Admin -> Users -> Edit User -> Gmail Email Sync") + logger.info( + f" Enable in Admin -> Users -> Edit User -> Gmail Email Sync" + ) return - + # Check if user has enabled Gmail sync in their settings (additional user preference) if user and user.settings: settings_dict = ( @@ -553,14 +563,20 @@ async def trigger_gmail_sync_if_needed( gmail_settings = ( settings_dict.get("gmail", {}) if isinstance(settings_dict, dict) else {} ) - user_sync_enabled = gmail_settings.get("sync_enabled", True) # Default to enabled if admin allows - logger.info(f" āš™ļø User Gmail sync preference: sync_enabled={user_sync_enabled}") + user_sync_enabled = gmail_settings.get( + "sync_enabled", True + ) # Default to enabled if admin allows + logger.info( + f" āš™ļø User Gmail sync preference: sync_enabled={user_sync_enabled}" + ) if not user_sync_enabled: logger.info(f" ā­ļø SKIP: User has disabled Gmail sync in their settings") return else: - logger.info(f" āš™ļø No user settings found - will sync (admin enabled, no user preference)") + logger.info( + f" āš™ļø No user settings found - will sync (admin enabled, no user preference)" + ) logger.info(f" āœ… ALL CONDITIONS MET - Triggering Gmail sync!") logger.info( @@ -826,7 +842,9 @@ async def _background_gmail_sync(request, user_id: str, oauth_token: dict): if "metadata" not in item: item["metadata"] = {} item["metadata"]["user_id"] = user_id - item["metadata"]["collection_name"] = f"gmail_{user_id}" # For filtering + item["metadata"][ + "collection_name" + ] = f"gmail_{user_id}" # For filtering logger.info( f"šŸ“¤ Upserting {len(valid_items)} valid vectors to collection: {collection_name}" @@ -834,23 +852,26 @@ async def _background_gmail_sync(request, user_id: str, oauth_token: dict): logger.info( f" Namespace: '{user_namespace}' (per-user isolation in Open WebUI)" ) - logger.info( - f" User: {user_id}" - ) + logger.info(f" User: {user_id}") try: # Use existing VECTOR_DB_CLIENT.upsert with namespace support # Run in thread executor to avoid blocking async event loop loop = asyncio.get_running_loop() - + # Check if the vector DB client supports namespace parameter import inspect + upsert_signature = inspect.signature(VECTOR_DB_CLIENT.upsert) - if 'namespace' in upsert_signature.parameters: + if "namespace" in upsert_signature.parameters: # Vector DB supports namespace (e.g., Pinecone) # Use per-user namespace: email-{user_id} await loop.run_in_executor( - None, VECTOR_DB_CLIENT.upsert, collection_name, valid_items, user_namespace + None, + VECTOR_DB_CLIENT.upsert, + collection_name, + valid_items, + user_namespace, ) logger.info( f"āœ… Upserted {len(valid_items)} vectors to collection '{collection_name}' in namespace '{user_namespace}'" @@ -943,7 +964,9 @@ async def test_auto_sync_orchestrator(): class MockPineconeManager: async def schedule_upsert(self, data, user_id, namespace=None): - logger.info(f"Mock upsert: {len(data)} vectors for user '{user_id}' to namespace '{namespace}'") + logger.info( + f"Mock upsert: {len(data)} vectors for user '{user_id}' to namespace '{namespace}'" + ) return f"job_{time.time()}" print("\nāœ… TEST 1: Initialize Auto-Sync Orchestrator (V2)") @@ -1002,7 +1025,7 @@ async def test_auto_sync_orchestrator(): async def periodic_gmail_sync_scheduler(): """ Production-grade periodic Gmail sync scheduler. - + Features: - Graceful startup with configurable delay - Dynamic configuration updates @@ -1013,33 +1036,68 @@ async def periodic_gmail_sync_scheduler(): - Distributed system friendly (no race conditions) """ logger.info("šŸ”„ Gmail Periodic Sync Scheduler starting...") - + # Graceful startup - wait for application to fully initialize startup_delay = 60 # Wait 60 seconds for complete initialization await asyncio.sleep(startup_delay) - + # Configuration defaults - sync_interval_hours = 6 - check_interval = 30 * 60 # 30 minutes + sync_interval_minutes = 15 # Default 15 minutes + check_interval_minutes = 5 # Default 5 minutes consecutive_errors = 0 max_consecutive_errors = 5 - + logger.info("āœ… Gmail Periodic Sync Scheduler ready") - + while True: try: # Dynamic configuration reload (supports runtime updates) try: - from open_webui.config import GMAIL_PERIODIC_SYNC_INTERVAL_HOURS - # Extract value from PersistentConfig object using .value attribute - sync_interval_hours = GMAIL_PERIODIC_SYNC_INTERVAL_HOURS.value - # Ensure it's an integer - if not isinstance(sync_interval_hours, int): - sync_interval_hours = int(sync_interval_hours) + from open_webui.config import ( + GMAIL_PERIODIC_SYNC_INTERVAL_MINUTES, + GMAIL_PERIODIC_SYNC_INTERVAL_HOURS, + GMAIL_PERIODIC_SYNC_CHECK_INTERVAL_MINUTES, + ) + + # Extract value from PersistentConfig - use minutes if available, otherwise hours + interval_hours_value = ( + GMAIL_PERIODIC_SYNC_INTERVAL_HOURS.value + if hasattr(GMAIL_PERIODIC_SYNC_INTERVAL_HOURS, "value") + else 0 + ) + + if interval_hours_value and interval_hours_value > 0: + # Legacy hours-based config (backward compatibility) + sync_interval_minutes = interval_hours_value * 60 + logger.info( + f"Using legacy GMAIL_PERIODIC_SYNC_INTERVAL_HOURS: {interval_hours_value}h = {sync_interval_minutes}min" + ) + else: + # New minutes-based config (preferred) + sync_interval_minutes = ( + GMAIL_PERIODIC_SYNC_INTERVAL_MINUTES.value + if hasattr(GMAIL_PERIODIC_SYNC_INTERVAL_MINUTES, "value") + else 15 + ) + + # Check interval (how often to scan for users needing sync) + check_interval_minutes = ( + GMAIL_PERIODIC_SYNC_CHECK_INTERVAL_MINUTES.value + if hasattr(GMAIL_PERIODIC_SYNC_CHECK_INTERVAL_MINUTES, "value") + else 5 + ) + + # Ensure they're integers + sync_interval_minutes = int(sync_interval_minutes) + check_interval_minutes = int(check_interval_minutes) + except Exception as e: - logger.warning(f"Failed to load sync interval config, using default: {e}") - sync_interval_hours = 6 - + logger.warning( + f"Failed to load sync interval config, using defaults: {e}" + ) + sync_interval_minutes = 15 + check_interval_minutes = 5 + # Circuit breaker: back off if too many failures if consecutive_errors >= max_consecutive_errors: backoff_time = min(3600, 300 * consecutive_errors) # Max 1 hour @@ -1050,41 +1108,43 @@ async def periodic_gmail_sync_scheduler(): await asyncio.sleep(backoff_time) consecutive_errors = 0 # Reset after backoff continue - + # Query database for users needing sync (indexed query) + # Convert minutes to hours for database query + sync_interval_hours = sync_interval_minutes / 60.0 users_needing_sync = gmail_sync_status.get_users_needing_sync( max_hours_since_sync=sync_interval_hours ) - + if users_needing_sync: total_users = len(users_needing_sync) logger.info( f"šŸ“§ Periodic sync: {total_users} user(s) need sync " - f"(interval: {sync_interval_hours}h)" + f"(interval: {sync_interval_minutes}min / {sync_interval_hours:.1f}h)" ) - + # Adaptive batch sizing based on load batch_size = 2 if total_users > 10 else 3 successful_syncs = 0 failed_syncs = 0 - + # Process users in batches with rate limiting for i in range(0, total_users, batch_size): - batch = users_needing_sync[i:i + batch_size] + batch = users_needing_sync[i : i + batch_size] batch_start = time.time() - + # Parallel processing within batch tasks = [ asyncio.create_task( _sync_user_periodic(user_id), - name=f"periodic_gmail_sync_{user_id}" + name=f"periodic_gmail_sync_{user_id}", ) for user_id in batch ] - + # Wait for batch completion with error isolation results = await asyncio.gather(*tasks, return_exceptions=True) - + # Track results for monitoring for j, result in enumerate(results): if isinstance(result, Exception): @@ -1095,22 +1155,22 @@ async def periodic_gmail_sync_scheduler(): ) elif result: # Successful sync successful_syncs += 1 - + batch_duration = time.time() - batch_start - + # Adaptive rate limiting between batches if i + batch_size < total_users: # Add delay based on batch processing time (backpressure) delay = max(10, min(30, batch_duration * 0.5)) await asyncio.sleep(delay) - + # Summary logging logger.info( f"āœ… Periodic sync cycle complete: " f"{successful_syncs} succeeded, {failed_syncs} failed " f"(total: {total_users})" ) - + # Reset error counter on successful cycle if successful_syncs > 0: consecutive_errors = 0 @@ -1118,16 +1178,17 @@ async def periodic_gmail_sync_scheduler(): consecutive_errors += 1 else: logger.debug( - f"šŸ“§ No users need sync at this time (interval: {sync_interval_hours}h)" + f"šŸ“§ No users need sync at this time (interval: {sync_interval_minutes}min)" ) consecutive_errors = 0 # Reset on successful query - + # Wait before next check (with jitter to avoid thundering herd) - jitter = time.time() % 60 # 0-60 second jitter - actual_wait = check_interval + jitter - logger.debug(f"ā° Next sync check in {actual_wait//60:.1f} minutes") + check_interval_seconds = check_interval_minutes * 60 + jitter = time.time() % 30 # 0-30 second jitter + actual_wait = check_interval_seconds + jitter + logger.debug(f"ā° Next sync check in {actual_wait/60:.1f} minutes") await asyncio.sleep(actual_wait) - + except asyncio.CancelledError: logger.info("šŸ›‘ Gmail Periodic Sync Scheduler shutting down gracefully") raise # Propagate cancellation @@ -1145,37 +1206,37 @@ async def periodic_gmail_sync_scheduler(): async def _sync_user_periodic(user_id: str) -> bool: """ Sync Gmail for a single user during periodic sync (production-grade). - + Features: - Comprehensive validation with early returns - Graceful OAuth token handling - Timeout protection - Memory-efficient processing - Detailed error logging - + Args: user_id: The user ID to sync - + Returns: bool: True if sync succeeded, False otherwise """ sync_start = time.time() - + try: logger.debug(f"šŸ”„ Periodic sync starting for user: {user_id}") - + # Validation: Check user exists user = Users.get_user_by_id(user_id) if not user: logger.warning(f"āš ļø User {user_id} not found in database, skipping") return False - + # Validation: Check admin enabled sync - admin_sync_enabled = getattr(user, 'gmail_sync_enabled', 0) == 1 + admin_sync_enabled = getattr(user, "gmail_sync_enabled", 0) == 1 if not admin_sync_enabled: logger.debug(f"ā­ļø Gmail sync disabled by admin for user {user_id}") return False - + # Validation: Check OAuth session exists oauth_session = OAuthSessions.get_session_by_provider_and_user_id( "google", user_id @@ -1183,75 +1244,68 @@ async def _sync_user_periodic(user_id: str) -> bool: if not oauth_session: logger.debug(f"ā­ļø No Google OAuth session for user {user_id}") return False - + # Use OAuth token from session # Note: Token refresh happens automatically on next OAuth login # If token is expired, Gmail API will fail and we'll retry on next cycle oauth_token = oauth_session.token - + # Validation: Check OAuth token exists and has access_token if not oauth_token or not oauth_token.get("access_token"): - logger.warning(f"āš ļø Invalid OAuth token for user {user_id} (may need to re-authenticate)") + logger.warning( + f"āš ļø Invalid OAuth token for user {user_id} (may need to re-authenticate)" + ) return False - + # Check if this is first sync for this user sync_status = gmail_sync_status.get_sync_status(user_id) - is_first_sync = (sync_status is None or sync_status.last_sync_timestamp is None) - - # Create sync instance (reuses existing infrastructure) - gmail_sync = GmailAutoSync() - - # Determine timeout based on sync type + is_first_sync = sync_status is None or sync_status.last_sync_timestamp is None + + # Determine timeout and email limits based on sync type if is_first_sync: # First sync: limit emails more aggressively, allow more time max_sync_emails = 200 # Conservative for first background sync sync_timeout = 600 # 10 minutes for first sync - logger.info(f"šŸ†• First sync for user {user_id} - limiting to {max_sync_emails} emails") + logger.info( + f"šŸ†• First sync for user {user_id} - limiting to {max_sync_emails} emails" + ) else: # Ongoing sync: normal limits max_sync_emails = 500 sync_timeout = 900 # 15 minutes - + # Perform incremental sync with timeout protection + # Reuse the background sync infrastructure try: + # Get app state for services + from open_webui.main import app + + # Create a minimal request-like object with app state + class AppStateWrapper: + def __init__(self, app_state): + self.app = type("obj", (object,), {"state": app_state})() + + request_wrapper = AppStateWrapper(app.state) + # Use asyncio.wait_for to enforce timeout result = await asyncio.wait_for( - gmail_sync.sync_user_gmail( - user_id=user_id, - oauth_token=oauth_token, - max_emails=max_sync_emails, - skip_spam_trash=True, - incremental=True, # Always use incremental mode - # incremental=True with last_sync_timestamp=None will do full sync automatically - ), - timeout=sync_timeout + _background_gmail_sync(request_wrapper, user_id, oauth_token), + timeout=sync_timeout, ) except asyncio.TimeoutError: duration = time.time() - sync_start logger.error( f"ā±ļø Periodic sync timeout for user {user_id} " - f"after {duration:.1f}s (max: 900s)" + f"after {duration:.1f}s (max: {sync_timeout}s)" ) return False - - # Process results - if result.get("success"): - emails_synced = result.get("emails_synced", 0) - duration = time.time() - sync_start - logger.info( - f"āœ… Periodic sync: user {user_id} - " - f"{emails_synced} emails synced in {duration:.1f}s" - ) - return True - else: - error_msg = result.get("error", "Unknown error") - duration = time.time() - sync_start - logger.error( - f"āŒ Periodic sync failed for user {user_id} " - f"after {duration:.1f}s: {error_msg}" - ) - return False - + + # If we reach here, the sync completed successfully + # (_background_gmail_sync raises exceptions on failure) + duration = time.time() - sync_start + logger.info(f"āœ… Periodic sync completed for user {user_id} in {duration:.1f}s") + return True + except asyncio.CancelledError: logger.info(f"šŸ›‘ Periodic sync cancelled for user {user_id}") raise # Propagate cancellation