Coverage for src/qdrant_loader/core/async_ingestion_pipeline.py: 90%

126 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-20 10:15 +0000

1"""Refactored async ingestion pipeline using the new modular architecture.""" 

2 

3import asyncio 

4from pathlib import Path 

5 

6from qdrant_loader.config import Settings, SourcesConfig 

7from qdrant_loader.core.monitoring import prometheus_metrics 

8from qdrant_loader.core.monitoring.ingestion_metrics import IngestionMonitor 

9from qdrant_loader.core.project_manager import ProjectManager 

10from qdrant_loader.core.qdrant_manager import QdrantManager 

11from qdrant_loader.core.state.state_manager import StateManager 

12from qdrant_loader.utils.logging import LoggingConfig 

13from qdrant_loader.utils.sensitive import sanitize_exception_message 

14 

15from .pipeline import ( 

16 PipelineComponentsFactory, 

17 PipelineConfig, 

18 PipelineOrchestrator, 

19 ResourceManager, 

20) 

21 

22logger = LoggingConfig.get_logger(__name__) 

23 

24 

25class AsyncIngestionPipeline: 

26 """Async ingestion pipeline using modular architecture. 

27 

28 This class provides a streamlined interface for the modular pipeline 

29 architecture, handling document ingestion and processing workflows. 

30 """ 

31 

32 def __init__( 

33 self, 

34 settings: Settings, 

35 qdrant_manager: QdrantManager, 

36 state_manager: StateManager | None = None, 

37 max_chunk_workers: int | None = None, 

38 max_embed_workers: int | None = None, 

39 max_upsert_workers: int | None = None, 

40 queue_size: int | None = None, 

41 upsert_batch_size: int | None = None, 

42 enable_metrics: bool = False, 

43 metrics_dir: Path | None = None, # New parameter for workspace support 

44 ): 

45 """Initialize the async ingestion pipeline. 

46 

47 Args: 

48 settings: Application settings 

49 qdrant_manager: QdrantManager instance 

50 state_manager: Optional state manager 

51 

52 max_chunk_workers: Maximum number of chunking workers. Defaults to 

53 ``settings.global_config.concurrency.max_chunk_workers``. 

54 max_embed_workers: Maximum number of embedding workers. Defaults to 

55 ``settings.global_config.concurrency.max_embed_workers``. 

56 max_upsert_workers: Maximum number of upsert workers. Defaults to 

57 ``settings.global_config.concurrency.max_upsert_workers``. 

58 queue_size: Queue size for workers. Defaults to 

59 ``settings.global_config.concurrency.queue_size``. 

60 upsert_batch_size: Batch size for upserts. Defaults to 

61 ``settings.global_config.concurrency.upsert_batch_size``. 

62 enable_metrics: Whether to enable metrics server 

63 metrics_dir: Custom metrics directory (for workspace support) 

64 """ 

65 self.settings = settings 

66 self.qdrant_manager = qdrant_manager 

67 

68 # Validate that global configuration is available for pipeline operation. 

69 if not settings.global_config: 

70 raise ValueError( 

71 "Global configuration not available. Please check your configuration file." 

72 ) 

73 

74 # Explicit constructor args win; otherwise fall back to the user's 

75 # settings.yaml (global.concurrency), so these knobs are actually 

76 # reachable from configuration instead of being stuck at a literal. 

77 concurrency = settings.global_config.concurrency 

78 self.pipeline_config = PipelineConfig( 

79 max_chunk_workers=( 

80 max_chunk_workers 

81 if max_chunk_workers is not None 

82 else concurrency.max_chunk_workers 

83 ), 

84 max_embed_workers=( 

85 max_embed_workers 

86 if max_embed_workers is not None 

87 else concurrency.max_embed_workers 

88 ), 

89 max_upsert_workers=( 

90 max_upsert_workers 

91 if max_upsert_workers is not None 

92 else concurrency.max_upsert_workers 

93 ), 

94 queue_size=queue_size if queue_size is not None else concurrency.queue_size, 

95 upsert_batch_size=( 

96 upsert_batch_size 

97 if upsert_batch_size is not None 

98 else concurrency.upsert_batch_size 

99 ), 

100 enable_metrics=enable_metrics, 

101 ) 

102 

103 # Create resource manager to handle cleanup and signal handling. 

104 self.resource_manager = ResourceManager() 

105 self.resource_manager.register_signal_handlers() 

106 

107 # Create state manager instance if not provided by caller. 

108 self.state_manager = state_manager or StateManager( 

109 settings.global_config.state_management 

110 ) 

111 

112 # Initialize project manager to support multi-project configurations. 

113 if not settings.global_config.qdrant: 

114 raise ValueError( 

115 "Qdrant configuration is required for project manager initialization" 

116 ) 

117 

118 self.project_manager = ProjectManager( 

119 projects_config=settings.projects_config, 

120 global_collection_name=settings.global_config.qdrant.collection_name, 

121 ) 

122 

123 # Create pipeline components using factory 

124 factory = PipelineComponentsFactory() 

125 self.components = factory.create_components( 

126 settings=settings, 

127 config=self.pipeline_config, 

128 qdrant_manager=qdrant_manager, 

129 state_manager=self.state_manager, 

130 resource_manager=self.resource_manager, 

131 ) 

132 

133 # Create orchestrator with project manager support 

134 self.orchestrator = PipelineOrchestrator( 

135 settings, self.components, self.project_manager 

136 ) 

137 

138 # Initialize performance monitor with custom or default metrics directory 

139 if metrics_dir: 

140 # Use provided metrics directory (workspace mode) 

141 # Accept both Path and str inputs 

142 final_metrics_dir = ( 

143 metrics_dir if isinstance(metrics_dir, Path) else Path(metrics_dir) 

144 ) 

145 else: 

146 # Use default metrics directory 

147 final_metrics_dir = Path.cwd() / "metrics" 

148 

149 final_metrics_dir.mkdir(parents=True, exist_ok=True) 

150 logger.info(f"Initializing metrics directory at {final_metrics_dir}") 

151 self.monitor = IngestionMonitor(str(final_metrics_dir.absolute())) 

152 

153 # Start metrics server if enabled 

154 if enable_metrics: 

155 prometheus_metrics.start_metrics_server() 

156 

157 logger.info("AsyncIngestionPipeline initialized with new modular architecture") 

158 

159 # Track cleanup state to prevent duplicate cleanup 

160 self._cleanup_performed = False 

161 

162 async def initialize(self): 

163 """Initialize the pipeline.""" 

164 logger.debug("Starting pipeline initialization") 

165 

166 try: 

167 # Ensure the Qdrant collection and its payload indexes exist. 

168 # create_collection() is idempotent: it returns early when the 

169 # collection already exists, but now also ensures indexes are 

170 # present on existing collections (required for filter-based deletes). 

171 await asyncio.to_thread(self.qdrant_manager.create_collection) 

172 

173 # Initialize state manager first 

174 if not self.state_manager.is_initialized: 

175 logger.debug("Initializing state manager") 

176 await self.state_manager.initialize() 

177 

178 # Initialize project manager 

179 if not self.project_manager._initialized: 

180 logger.debug("Initializing project manager") 

181 # Prefer direct use of session factory to match existing tests/mocks 

182 session_factory = getattr(self.state_manager, "_session_factory", None) 

183 if session_factory is None: 

184 logger.error( 

185 "State manager session factory is not available during initialization", 

186 suggestion="Check database configuration and ensure proper state manager setup", 

187 ) 

188 raise RuntimeError("State manager session factory is not available") 

189 

190 try: 

191 async with session_factory() as session: # type: ignore 

192 await self.project_manager.initialize(session) 

193 logger.debug("Project manager initialization completed") 

194 

195 except Exception as e: 

196 logger.error( 

197 "Failed to initialize project manager during pipeline startup", 

198 error=sanitize_exception_message(e), 

199 error_type=type(e).__name__, 

200 suggestion="Check database connectivity and project configuration", 

201 ) 

202 raise 

203 except Exception as e: 

204 logger.error( 

205 "Pipeline initialization failed during startup sequence", 

206 error=sanitize_exception_message(e), 

207 error_type=type(e).__name__, 

208 suggestion="Check configuration, database connectivity, and system resources", 

209 ) 

210 raise 

211 

212 async def process_documents( 

213 self, 

214 sources_config: SourcesConfig | None = None, 

215 source_type: str | None = None, 

216 source: str | None = None, 

217 project_id: str | None = None, 

218 force: bool = False, 

219 resume: bool = True, 

220 ) -> int: 

221 """Process documents from all configured sources. 

222 

223 Args: 

224 sources_config: Sources configuration to use (deprecated, use project_id instead) 

225 source_type: Filter by source type 

226 source: Filter by specific source name 

227 project_id: Process documents for a specific project 

228 force: Force processing of all documents, bypassing change detection 

229 

230 Returns: 

231 Number of documents successfully processed. 

232 """ 

233 # Ensure the pipeline is initialized 

234 await self.initialize() 

235 

236 # Reset metrics for new run 

237 self.monitor.clear_metrics() 

238 self.monitor.start_operation( 

239 "ingestion_process", 

240 metadata={ 

241 "source_type": source_type, 

242 "source": source, 

243 "project_id": project_id, 

244 "force": force, 

245 }, 

246 ) 

247 

248 processed_count = ( 

249 0 # Initialize to avoid UnboundLocalError in exception handler 

250 ) 

251 try: 

252 logger.debug("Starting document processing with new pipeline architecture") 

253 

254 # Use the orchestrator to process documents with project support 

255 processed_count = await self.orchestrator.process_documents( 

256 sources_config=sources_config, 

257 source_type=source_type, 

258 source=source, 

259 project_id=project_id, 

260 force=force, 

261 resume=resume, 

262 ) 

263 

264 # Update metrics 

265 if processed_count: 

266 pipeline_result = getattr( 

267 self.orchestrator, "last_pipeline_result", None 

268 ) 

269 total_chunks = getattr(pipeline_result, "success_count", 0) 

270 total_size_bytes = getattr(pipeline_result, "total_size_bytes", 0) 

271 

272 self.monitor.start_batch( 

273 "document_batch", 

274 batch_size=processed_count, 

275 metadata={ 

276 "source_type": source_type, 

277 "source": source, 

278 "project_id": project_id, 

279 "force": force, 

280 }, 

281 ) 

282 # Note: Success/error counts are handled internally by the new architecture 

283 self.monitor.end_batch( 

284 "document_batch", 

285 processed_count, 

286 0, 

287 [], 

288 total_chunks=total_chunks, 

289 total_size_bytes=total_size_bytes, 

290 ) 

291 

292 self.monitor.end_operation("ingestion_process") 

293 

294 logger.debug( 

295 f"Document processing completed. Processed {processed_count} documents" 

296 ) 

297 return processed_count 

298 

299 except Exception as e: 

300 safe_error = sanitize_exception_message(e) 

301 logger.error( 

302 "Document processing pipeline failed during ingestion", 

303 error=safe_error, 

304 error_type=type(e).__name__, 

305 documents_attempted=processed_count, 

306 suggestion="Check data source connectivity, document formats, and system resources", 

307 ) 

308 self.monitor.end_operation( 

309 "ingestion_process", success=False, error=safe_error 

310 ) 

311 raise 

312 

313 async def cleanup(self): 

314 """Clean up resources.""" 

315 if self._cleanup_performed: 

316 return 

317 

318 logger.info("Cleaning up pipeline resources") 

319 self._cleanup_performed = True 

320 

321 try: 

322 # Save metrics 

323 if hasattr(self, "monitor"): 

324 self.monitor.save_metrics() 

325 

326 # Stop metrics server 

327 try: 

328 prometheus_metrics.stop_metrics_server() 

329 except Exception as e: 

330 logger.warning( 

331 f"Error stopping metrics server: {sanitize_exception_message(e)}" 

332 ) 

333 

334 # Use resource manager for cleanup 

335 if hasattr(self, "resource_manager"): 

336 await self.resource_manager.cleanup() 

337 

338 logger.info("Pipeline cleanup completed") 

339 except Exception as e: 

340 logger.error( 

341 f"Error during pipeline cleanup: {sanitize_exception_message(e)}" 

342 ) 

343 

344 def __del__(self): 

345 """Destructor to ensure cleanup.""" 

346 try: 

347 # Can't await in __del__, so use the sync cleanup method 

348 self._sync_cleanup() 

349 except Exception as e: 

350 logger.error( 

351 f"Error in destructor cleanup: {sanitize_exception_message(e)}" 

352 ) 

353 

354 def _sync_cleanup(self): 

355 """Synchronous cleanup for destructor and signal handlers.""" 

356 if self._cleanup_performed: 

357 return 

358 

359 logger.info("Cleaning up pipeline resources (sync)") 

360 self._cleanup_performed = True 

361 

362 # Save metrics 

363 try: 

364 if hasattr(self, "monitor"): 

365 self.monitor.save_metrics() 

366 except Exception as e: 

367 logger.error(f"Error saving metrics: {sanitize_exception_message(e)}") 

368 

369 # Stop metrics server 

370 try: 

371 prometheus_metrics.stop_metrics_server() 

372 except Exception as e: 

373 logger.error( 

374 f"Error stopping metrics server: {sanitize_exception_message(e)}" 

375 ) 

376 

377 # Use resource manager sync cleanup 

378 try: 

379 if hasattr(self, "resource_manager"): 

380 self.resource_manager._cleanup() 

381 except Exception as e: 

382 logger.error( 

383 f"Error in resource manager cleanup: {sanitize_exception_message(e)}" 

384 ) 

385 

386 logger.info("Pipeline cleanup completed (sync)")