RAG/sync_service.py

476 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Background service for syncing MySQL data to ChromaDB
"""
import asyncio
from datetime import datetime
from typing import Set, List, Dict
from loguru import logger
from config import settings, DatabaseConfig
from database import MySQLSync
from rag import VectorStoreManager, DocumentProcessor
class SyncService:
"""Service for synchronizing MySQL data to ChromaDB"""
def __init__(self):
self.db_configs = settings.get_database_configs()
# Check if all configured databases exist before proceeding
# This will check each database using its own connection info (if specified)
logger.info("Checking if all configured databases exist...")
all_exist, missing_databases = MySQLSync.check_databases_exist(self.db_configs)
if not all_exist:
error_msg = (
f"Error: The following databases do not exist or cannot be accessed:\n"
f" {', '.join(missing_databases)}\n"
f"Please check:\n"
f" 1. All databases exist on their respective MySQL servers\n"
f" 2. MySQL servers are running and accessible\n"
f" 3. MySQL usernames and passwords are correct\n"
f" 4. Firewall rules allow connections to MySQL ports"
)
logger.error(error_msg)
raise RuntimeError(error_msg)
logger.info(f"✓ All {len(self.db_configs)} configured database(s) exist")
self.mysql_syncs: Dict[str, MySQLSync] = {}
self.vector_store_manager = VectorStoreManager()
self.document_processor = DocumentProcessor()
self.last_sync_times: Dict[str, datetime] = {}
self.synced_doc_ids: Dict[str, Set[str]] = {}
self._running = False
self._sync_in_progress = False # Flag to prevent concurrent syncs
self._auto_sync_task = None # Reference to auto sync task to prevent multiple instances
# Initialize MySQL connection - create separate connection for each database
# This avoids connection state issues when running in thread pool
for db_config in self.db_configs:
try:
# Create separate connection for each database to avoid connection state conflicts
# when running in thread pool (connections are not thread-safe)
mysql_sync = MySQLSync(db_config)
self.mysql_syncs[db_config.name] = mysql_sync
self.last_sync_times[db_config.name] = None
self.synced_doc_ids[db_config.name] = set()
except Exception as e:
logger.error(f"Failed to initialize MySQL sync for {db_config.name}: {e}")
raise
async def sync_all(self, force: bool = False):
"""
Sync all documents from all MySQL databases to ChromaDB
Args:
force: If True, re-process all documents even if they exist (default: False)
"""
# Prevent concurrent syncs
if self._sync_in_progress:
logger.warning("Sync already in progress, skipping this request")
return
self._sync_in_progress = True
sync_start_time = datetime.now()
try:
logger.info(f"[同步] 开始全量同步: {len(self.db_configs)} 个数据库")
# Run all synchronous operations in thread pool to avoid blocking event loop
import asyncio
loop = asyncio.get_event_loop()
def sync_work():
"""Synchronous work that runs in thread pool"""
# Get existing document IDs from ChromaDB to avoid re-processing
existing_doc_ids = set()
if not force:
existing_doc_ids = self.vector_store_manager.get_existing_doc_ids()
all_chunked_docs = [] # 存储所有分块后的文档
total_docs = 0
skipped_docs_count = 0
processed_databases = 0
# Sync from each database
for db_index, db_config in enumerate(self.db_configs, 1):
try:
mysql_sync = self.mysql_syncs[db_config.name]
mysql_docs = mysql_sync.fetch_all_documents()
if not mysql_docs:
processed_databases += 1
continue
# 从配置中获取内容列
content_columns = db_config.content_columns
# 获取每个数据源对应 ChromaDB metadata 中的 “content_column”
db_content_column_str = self.vector_store_manager.get_specific_db_source_metadata(db_config.database+'_'+db_config.table_name)
if db_content_column_str:
existing_db_content_columns = [col.strip() for col in db_content_column_str.split(",")]
# 获取 existing_doc_content_columns 与 content_columns 差异项
diff_columns = set(content_columns) - set(existing_db_content_columns)
else: # 说明 ChromaDB 中无该数据源的 metadata需全量处理无需过滤
diff_columns = set(content_columns)
# Filter out documents that already exist (if not forcing)
db_skipped_count = 0
if not force:
id_column = db_config.id_column
# 若 diff_columns为空说明无新增列则需过滤ChromaDB中已存在的doc
if not diff_columns:
new_mysql_docs = []
for doc in mysql_docs:
doc_id = str(doc.get(id_column, ""))
# 生成唯一文档标识符数据库标识名称_表名_文档ID{db_source}_{table_name}_{id}
unique_doc_id = f"{db_config.name}_{db_config.table_name}_{doc_id}"
if unique_doc_id not in existing_doc_ids:
new_mysql_docs.append(doc)
else:
db_skipped_count += 1
skipped_docs_count += 1
if not new_mysql_docs:
self.synced_doc_ids[db_config.name] = {
str(doc.get(id_column)) for doc in mysql_docs
}
self.last_sync_times[db_config.name] = datetime.now()
processed_databases += 1
continue
mysql_docs = new_mysql_docs
elif db_content_column_str:
self.vector_store_manager.delete_documents_by_source(db_config.database+'_'+db_config.table_name)
# Process and chunk documents
documents = self.document_processor.process_documents(mysql_docs, db_config)
chunked_docs = self.document_processor.chunk_documents(documents)
all_chunked_docs.extend(chunked_docs)
# Update synced document IDs for this database
id_column = db_config.id_column
self.synced_doc_ids[db_config.name] = {
str(doc.get(id_column)) for doc in mysql_docs
}
self.last_sync_times[db_config.name] = datetime.now()
total_docs += len(mysql_docs)
processed_databases += 1
except Exception as e:
logger.error(f"[同步] 数据库 {db_config.name} 同步失败: {e}")
processed_databases += 1
continue
return all_chunked_docs, total_docs, skipped_docs_count, processed_databases
# Run synchronous work in thread pool
all_chunked_docs, total_docs, skipped_docs_count, processed_databases = await loop.run_in_executor(None, sync_work)
# Add all documents to vector store (run in thread pool to avoid blocking event loop)
if all_chunked_docs:
await loop.run_in_executor(
None,
self.vector_store_manager.add_documents,
all_chunked_docs,
not force # skip_existing true
)
sync_duration = (datetime.now() - sync_start_time).total_seconds()
logger.info(
f"[同步] 完成: {processed_databases}/{len(self.db_configs)} 个数据库, "
f"{total_docs} 个新文档, {len(all_chunked_docs)} 个分块"
+ (f", 跳过 {skipped_docs_count} 个已存在" if skipped_docs_count > 0 else "")
+ f", 耗时 {sync_duration:.1f}"
)
else:
sync_duration = (datetime.now() - sync_start_time).total_seconds()
if skipped_docs_count > 0:
logger.info(f"[同步] 完成: 所有 {skipped_docs_count} 个文档已存在, 耗时 {sync_duration:.1f}")
else:
logger.warning(f"[同步] 没有文档需要同步, 耗时 {sync_duration:.1f}")
except Exception as e:
sync_duration = (datetime.now() - sync_start_time).total_seconds()
logger.error(f"[同步进度] ✗ 同步过程中出错 (耗时: {sync_duration:.1f} 秒): {e}")
raise
finally:
self._sync_in_progress = False
async def sync_incremental(self):
"""Sync only new/updated documents from MySQL to ChromaDB"""
# Prevent concurrent syncs
if self._sync_in_progress:
logger.warning("Sync already in progress, skipping incremental sync")
return
self._sync_in_progress = True
try:
logger.info(f"Starting incremental sync from {len(self.db_configs)} MySQL database(s) to ChromaDB")
# Run all synchronous operations in thread pool
import asyncio
loop = asyncio.get_event_loop()
def sync_work():
"""Synchronous work that runs in thread pool"""
all_chunked_docs = []
total_docs = 0
# Sync from each database
for db_config in self.db_configs:
try:
mysql_sync = self.mysql_syncs[db_config.name]
last_sync_time = self.last_sync_times.get(db_config.name)
# Fetch new documents
mysql_docs = mysql_sync.fetch_new_documents(last_sync_time)
if not mysql_docs:
logger.debug(f"No new documents in database: {db_config.name}")
continue
# Process and chunk documents
documents = self.document_processor.process_documents(mysql_docs, db_config)
chunked_docs = self.document_processor.chunk_documents(documents)
all_chunked_docs.extend(chunked_docs)
# Update synced document IDs for this database
id_column = db_config.id_column
new_doc_ids = {str(doc.get(id_column)) for doc in mysql_docs}
self.synced_doc_ids[db_config.name].update(new_doc_ids)
self.last_sync_times[db_config.name] = datetime.now()
total_docs += len(mysql_docs)
logger.info(f"Incremental sync: {len(chunked_docs)} chunks from {len(mysql_docs)} documents in database: {db_config.name}")
except Exception as e:
logger.error(f"Error during incremental sync for database {db_config.name}: {e}")
# Continue with other databases
continue
return all_chunked_docs, total_docs
# Run synchronous work in thread pool
all_chunked_docs, total_docs = await loop.run_in_executor(None, sync_work)
# Add all new documents to vector store
if all_chunked_docs:
# Run in thread pool to avoid blocking event loop
await loop.run_in_executor(
None,
self.vector_store_manager.add_documents,
all_chunked_docs,
False # skip_existing不跳过已存在的文档默认是更新了内容
)
logger.info(f"Incremental sync completed: {len(all_chunked_docs)} chunks from {total_docs} documents across {len(self.db_configs)} database(s)")
else:
logger.info("No new documents to sync")
except Exception as e:
logger.error(f"Error during incremental sync: {e}")
raise
finally:
self._sync_in_progress = False
async def start_auto_sync_with_recovery(self, skip_initial_sync: bool = False):
"""
Start automatic periodic sync in background with error recovery.
If the sync service stops due to an error, it will automatically restart.
This method runs continuously until stop_auto_sync() is called.
Args:
skip_initial_sync: If True, skip the initial sync_all() call.
Use this when initial sync is already done elsewhere.
"""
if not settings.AUTO_SYNC:
logger.info("Auto sync is disabled")
return
# Prevent multiple auto sync service instances
if self._running:
logger.warning("Auto sync service is already running, skipping duplicate start request")
return
# Check if there's an existing auto sync task still running
if self._auto_sync_task is not None and not self._auto_sync_task.done():
logger.warning("Auto sync task is still running, skipping duplicate start request")
return
max_restart_attempts = 10 # Maximum number of restart attempts
restart_delay = 60 # Wait 60 seconds before restarting after an error
restart_count = 0
self._running = True
logger.info(f"Auto sync service with error recovery started (interval: {settings.SYNC_INTERVAL}s)")
while self._running and restart_count < max_restart_attempts:
try:
# Store the task reference to prevent multiple instances
self._auto_sync_task = asyncio.create_task(
self._run_auto_sync_loop(skip_initial_sync)
)
await self._auto_sync_task
# If we reach here, the loop exited normally (not due to error)
logger.info("Auto sync loop exited normally")
break
except Exception as e:
restart_count += 1
logger.error(
f"Auto sync service stopped due to error (restart attempt {restart_count}/{max_restart_attempts}): {e}",
exc_info=True
)
if restart_count >= max_restart_attempts:
logger.error(f"Auto sync service failed after {max_restart_attempts} restart attempts. Stopping auto sync.")
self._running = False
break
if not self._running:
logger.info("Auto sync service stop requested, not restarting")
break
logger.info(f"Waiting {restart_delay}s before restarting auto sync service...")
await asyncio.sleep(restart_delay)
logger.info(f"Restarting auto sync service (attempt {restart_count + 1}/{max_restart_attempts})...")
# Reset skip_initial_sync after first attempt (only skip on first start)
skip_initial_sync = False
logger.info("Auto sync service with recovery stopped")
self._running = False
async def start_auto_sync(self, skip_initial_sync: bool = False):
"""
Start automatic periodic sync in background.
This method runs continuously until stop_auto_sync() is called.
For production use, prefer start_auto_sync_with_recovery() which includes error recovery.
Args:
skip_initial_sync: If True, skip the initial sync_all() call.
Use this when initial sync is already done elsewhere.
"""
await self._run_auto_sync_loop(skip_initial_sync)
async def _run_auto_sync_loop(self, skip_initial_sync: bool = False):
"""
Internal method that runs the auto sync loop.
This is separated so it can be called by both start_auto_sync and start_auto_sync_with_recovery.
Args:
skip_initial_sync: If True, skip the initial sync_all() call.
"""
if not settings.AUTO_SYNC:
logger.info("Auto sync is disabled")
return
logger.info(f"Auto sync loop started (interval: {settings.SYNC_INTERVAL}s)")
# Initial sync (only if not skipped)
if not skip_initial_sync:
logger.info("Performing initial sync in auto sync service...")
await self.sync_all()
else:
logger.info("Skipping initial sync in auto sync service (already done elsewhere)")
# Periodic incremental sync
sync_count = 0
last_sync_start_time = None
while self._running:
try:
logger.info(f"Auto sync waiting {settings.SYNC_INTERVAL}s before next sync (count: {sync_count})...")
await asyncio.sleep(settings.SYNC_INTERVAL)
if not self._running:
logger.info("Auto sync stopped, exiting loop")
break
# Check if another sync is in progress (e.g., initial sync or previous incremental sync still running)
# Wait for it to complete before starting incremental sync (no timeout - wait indefinitely)
wait_interval = 10 # Check every 10 seconds
waited_time = 0
while self._sync_in_progress:
logger.info(f"Another sync is in progress, waiting... (waited {waited_time}s, will wait until completion)")
await asyncio.sleep(wait_interval)
waited_time += wait_interval
# Log warning if waiting for a very long time (for monitoring purposes)
if waited_time % 300 == 0: # Every 5 minutes
logger.info(f"Still waiting for sync to complete... (waited {waited_time}s / {waited_time // 60} minutes)")
# If last sync started more than 2 hours ago and is still running, log warning
if last_sync_start_time is not None:
time_since_last_sync = (datetime.now() - last_sync_start_time).total_seconds()
if time_since_last_sync > 7200: # 2 hours
logger.warning(f"Last incremental sync has been running for {time_since_last_sync / 3600:.1f} hours, this might indicate a slow sync. Continuing to wait...")
if waited_time > 0:
logger.info(f"Previous sync completed, waited {waited_time}s / {waited_time // 60} minutes")
if not self._running:
logger.info("Auto sync stopped during wait, exiting loop")
break
# Record sync start time for monitoring
last_sync_start_time = datetime.now()
sync_count += 1
logger.info(f"Running periodic incremental sync #{sync_count}...")
# Execute incremental sync - it will check _sync_in_progress internally
try:
await self.sync_incremental()
logger.info(f"Incremental sync #{sync_count} completed successfully")
except Exception as sync_error:
logger.error(f"Incremental sync #{sync_count} failed: {sync_error}", exc_info=True)
# Reset sync in progress flag if it was set (in case of unexpected error)
if self._sync_in_progress:
logger.warning("Resetting _sync_in_progress flag due to error in incremental sync")
self._sync_in_progress = False
# Continue to next cycle even if this sync failed
logger.info("Continuing to next sync cycle despite error...")
continue
except asyncio.CancelledError:
logger.info("Auto sync task was cancelled")
break
except Exception as e:
logger.error(f"Unexpected error in auto sync loop: {e}", exc_info=True)
# Reset sync in progress flag if it was set (in case of unexpected error)
if self._sync_in_progress:
logger.warning("Resetting _sync_in_progress flag due to unexpected error in auto sync loop")
self._sync_in_progress = False
# Continue running even if there's an error
logger.info("Continuing auto sync loop despite error...")
continue
logger.info(f"Auto sync loop stopped (total syncs: {sync_count})")
def stop_auto_sync(self):
"""Stop automatic sync"""
if not self._running:
logger.info("Auto sync service is not running")
return
self._running = False
logger.info("Auto sync service stop requested")
# Wait for current sync to complete (if any) before stopping
# This prevents stopping in the middle of a sync operation
if self._sync_in_progress:
logger.info("Waiting for current sync to complete before stopping auto sync service...")
import time
max_wait = 300 # Wait up to 5 minutes
waited = 0
while self._sync_in_progress and waited < max_wait:
time.sleep(2)
waited += 2
if self._sync_in_progress:
logger.warning(f"Sync still in progress after waiting {waited}s, forcing stop")
else:
logger.info("Current sync completed, auto sync service stopped")
else:
logger.info("Auto sync service stopped")
def close(self):
"""Close MySQL connections"""
self.stop_auto_sync()
for mysql_sync in self.mysql_syncs.values():
if hasattr(mysql_sync, 'close'):
mysql_sync.close()