Coverage for src/qdrant_loader/core/pipeline/orchestrator.py: 80%

346 statements  

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

1"""Main orchestrator for the ingestion pipeline.""" 

2 

3import traceback 

4from collections.abc import AsyncIterator 

5from datetime import datetime 

6 

7from qdrant_loader.config import Settings, SourcesConfig 

8from qdrant_loader.connectors.base import ConnectorConfigurationError 

9from qdrant_loader.connectors.factory import get_connector_instance 

10from qdrant_loader.core.document import Document 

11from qdrant_loader.core.project_manager import ProjectManager 

12from qdrant_loader.core.qdrant_manager import QdrantManager 

13from qdrant_loader.core.state.state_change_detector import StateChangeDetector 

14from qdrant_loader.core.state.state_manager import StateManager 

15from qdrant_loader.core.worker.handlers import PermanentJobError 

16from qdrant_loader.utils.logging import LoggingConfig 

17from qdrant_loader.utils.sensitive import sanitize_exception_message 

18 

19from .document_pipeline import DocumentPipeline 

20from .source_filter import SourceFilter 

21from .source_processor import SourceProcessor 

22from .workers.upsert_worker import PipelineResult 

23 

24logger = LoggingConfig.get_logger(__name__) 

25 

26 

27def _safe_document_size(doc: Document) -> int: 

28 """Best-effort byte size of a document for metrics purposes.""" 

29 try: 

30 return int(doc.metadata.get("size", 0)) 

31 except (TypeError, ValueError, AttributeError): 

32 return 0 

33 

34 

35class PipelineComponents: 

36 """Container for pipeline components.""" 

37 

38 def __init__( 

39 self, 

40 document_pipeline: DocumentPipeline, 

41 source_processor: SourceProcessor, 

42 source_filter: SourceFilter, 

43 state_manager: StateManager, 

44 qdrant_manager: QdrantManager, 

45 ): 

46 self.document_pipeline = document_pipeline 

47 self.source_processor = source_processor 

48 self.source_filter = source_filter 

49 self.state_manager = state_manager 

50 self.qdrant_manager = qdrant_manager 

51 

52 

53class PipelineOrchestrator: 

54 """Main orchestrator for the ingestion pipeline.""" 

55 

56 def __init__( 

57 self, 

58 settings: Settings, 

59 components: PipelineComponents, 

60 project_manager: ProjectManager | None = None, 

61 ): 

62 self.settings = settings 

63 self.components = components 

64 self.project_manager = project_manager 

65 self.last_pipeline_result = None 

66 

67 async def _stream_batches_from_sources( 

68 self, 

69 filtered_config: SourcesConfig, 

70 batch_size: int = 256, 

71 since: datetime | None = None, 

72 project_id: str | None = None, 

73 seen_uris: set[str] | None = None, 

74 resume: bool = True, 

75 force: bool = False, 

76 ) -> AsyncIterator[list[Document]]: 

77 """Stream source documents in bounded micro-batches. 

78 

79 This helper collects documents from each source type and yields 

80 batches of a fixed size, keeping memory usage bounded. 

81 """ 

82 batch: list[Document] = [] 

83 

84 # Note: previous implementation contained vestigial async inner 

85 # helpers `_flush_batch` and `_append_document` which attempted to 

86 # yield from inside non-generator contexts. These were dead code 

87 # and confusing. Batching is handled inline in `_process_source_type`. 

88 

89 async def _process_source_type(source_type_name: str, source_configs): 

90 if not source_configs: 

91 return 

92 

93 # Tracks the checkpoint cursor of the last document with checkpoint 

94 # info, so a batch is flushed before crossing a page boundary 

95 # (see _process_source_type body below for why this matters). 

96 last_cursor_value = None 

97 # True once a size-based mid-page flush has fired for the current page. 

98 # Used to emit the warning only once per overflowing page. 

99 page_has_overflowed = False 

100 

101 async def connector_factory_with_checkpoint(src_config): 

102 # Determine if we should attempt to resume from a checkpoint 

103 checkpoint_cursor = None 

104 try: 

105 if resume and not force and project_id is not None: 

106 # Lazy import to avoid cycles 

107 from qdrant_loader.core.state.checkpoint_manager import ( 

108 CheckpointManager, 

109 ) 

110 

111 async with ( 

112 await self.components.state_manager.get_session() as session 

113 ): 

114 cp_mgr = CheckpointManager(session) 

115 cp = await cp_mgr.get_checkpoint( 

116 project_id, source_type_name, src_config.source 

117 ) 

118 if cp: 

119 checkpoint_cursor = cp.cursor_value 

120 except Exception: 

121 # On any failure retrieving checkpoint, log and continue without it 

122 logger.debug( 

123 "Checkpoint lookup failed, proceeding without checkpoint", 

124 source_type=source_type_name, 

125 source=src_config.source, 

126 ) 

127 

128 return get_connector_instance( 

129 src_config, checkpoint_cursor=checkpoint_cursor 

130 ) 

131 

132 async for ( 

133 document 

134 ) in self.components.source_processor.stream_source_documents( 

135 source_configs, 

136 connector_factory_with_checkpoint, 

137 source_type_name, 

138 since=since, 

139 ): 

140 # Inject project metadata when running with project context 

141 if project_id and self.project_manager: 

142 try: 

143 document.metadata = ( 

144 self.project_manager.inject_project_metadata( 

145 project_id, document.metadata 

146 ) 

147 ) 

148 except Exception: 

149 # Don't let metadata injection break streaming; log and continue 

150 logger.debug( 

151 "Project metadata injection failed for document", 

152 document_id=document.id, 

153 project_id=project_id, 

154 ) 

155 

156 # Track seen URIs for potential post-stream reconciliation 

157 if seen_uris is not None: 

158 try: 

159 uri = f"{document.source_type}:{document.source}:{document.url.rstrip('/') }" 

160 seen_uris.add(uri) 

161 except Exception: 

162 pass 

163 

164 # Flush the batch before crossing a checkpoint page boundary so 

165 # that a saved checkpoint never covers a partially-upserted page 

166 # (a batch never spans two different page cursors). 

167 doc_metadata = getattr(document, "metadata", None) or {} 

168 cp_info = ( 

169 doc_metadata.get("__ingestion_checkpoint") 

170 if isinstance(doc_metadata, dict) 

171 else None 

172 ) 

173 if isinstance(cp_info, dict) and cp_info: 

174 cursor_value = cp_info.get("cursor_value") 

175 if ( 

176 last_cursor_value is not None 

177 and cursor_value != last_cursor_value 

178 and batch 

179 ): 

180 # Page boundary: all docs for the previous cursor are 

181 # accumulated; safe to save the checkpoint now. 

182 yield batch.copy() 

183 batch.clear() 

184 page_has_overflowed = False # reset for the new page 

185 last_cursor_value = cursor_value 

186 

187 batch.append(document) 

188 if len(batch) >= batch_size: 

189 # A size-based flush that fires while we are still inside a 

190 # page (same cursor_value across docs) must NOT carry 

191 # __ingestion_checkpoint. Saving the page token here would 

192 # cause resume to skip the page tail on crash (Jira WS-2 

193 # regression: 100 issues × avg attachments > batch_size=256). 

194 if last_cursor_value is not None: 

195 if not page_has_overflowed: 

196 logger.warning( 

197 "Source page exceeds batch_size; stripping " 

198 "__ingestion_checkpoint from mid-page flush to " 

199 "prevent resume from skipping the page tail", 

200 source_type=source_type_name, 

201 page_cursor=last_cursor_value, 

202 batch_size=batch_size, 

203 ) 

204 page_has_overflowed = True 

205 for doc in batch: 

206 doc_meta = getattr(doc, "metadata", None) 

207 if isinstance(doc_meta, dict): 

208 doc_meta.pop("__ingestion_checkpoint", None) 

209 yield batch.copy() 

210 batch.clear() 

211 

212 if filtered_config.confluence: 

213 async for yielded_batch in _process_source_type( 

214 "Confluence", filtered_config.confluence 

215 ): 

216 yield yielded_batch 

217 

218 if filtered_config.git: 

219 async for yielded_batch in _process_source_type("Git", filtered_config.git): 

220 yield yielded_batch 

221 

222 if filtered_config.jira: 

223 async for yielded_batch in _process_source_type( 

224 "Jira", filtered_config.jira 

225 ): 

226 yield yielded_batch 

227 

228 if filtered_config.publicdocs: 

229 async for yielded_batch in _process_source_type( 

230 "PublicDocs", filtered_config.publicdocs 

231 ): 

232 yield yielded_batch 

233 

234 if filtered_config.localfile: 

235 async for yielded_batch in _process_source_type( 

236 "LocalFile", filtered_config.localfile 

237 ): 

238 yield yielded_batch 

239 

240 if batch: 

241 yield batch 

242 

243 async def process_documents( 

244 self, 

245 sources_config: SourcesConfig | None = None, 

246 source_type: str | None = None, 

247 source: str | None = None, 

248 project_id: str | None = None, 

249 force: bool = False, 

250 since: datetime | None = None, 

251 resume: bool = True, 

252 ) -> int: 

253 """Main entry point for document processing. 

254 

255 Args: 

256 sources_config: Sources configuration to use (for backward compatibility) 

257 source_type: Filter by source type 

258 source: Filter by specific source name 

259 project_id: Process documents for a specific project 

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

261 since: Only stream documents updated after this timestamp (connector-level 

262 filtering). Connectors that do not yet support time-based filtering will 

263 fall back to full fetch with hash-based change detection. 

264 resume: Whether to resume from the last checkpoint when available. 

265 

266 Returns: 

267 Number of documents successfully processed. 

268 """ 

269 logger.info("🚀 Starting document ingestion") 

270 self.last_pipeline_result = None 

271 

272 try: 

273 if sources_config: 

274 # Use provided sources config (backward compatibility) 

275 logger.debug("Using provided sources configuration") 

276 filtered_config = self.components.source_filter.filter_sources( 

277 sources_config, source_type, source 

278 ) 

279 current_project_id = None 

280 elif project_id: 

281 # Use project-specific sources configuration 

282 if not self.project_manager: 

283 raise ValueError( 

284 "Project manager not available for project-specific processing" 

285 ) 

286 

287 project_context = self.project_manager.get_project_context(project_id) 

288 if ( 

289 not project_context 

290 or not project_context.config 

291 or not project_context.config.sources 

292 ): 

293 raise ValueError( 

294 f"Project '{project_id}' not found or has no configuration" 

295 ) 

296 

297 logger.debug(f"Using project configuration for project: {project_id}") 

298 project_sources_config = project_context.config.sources 

299 filtered_config = self.components.source_filter.filter_sources( 

300 project_sources_config, source_type, source 

301 ) 

302 current_project_id = project_id 

303 else: 

304 # Process all projects 

305 if not self.project_manager: 

306 raise ValueError( 

307 "Project manager not available and no sources configuration provided" 

308 ) 

309 

310 logger.debug("Processing all projects") 

311 return await self._process_all_projects( 

312 source_type, source, force, since 

313 ) 

314 

315 # Check if filtered config is empty 

316 if source_type and not any( 

317 [ 

318 filtered_config.git, 

319 filtered_config.confluence, 

320 filtered_config.jira, 

321 filtered_config.publicdocs, 

322 filtered_config.localfile, 

323 ] 

324 ): 

325 raise ValueError(f"No sources found for type '{source_type}'") 

326 

327 # Fail fast when destination collection is unavailable. Without this 

328 # preflight, a job with no changed documents would incorrectly return 

329 # success even while Qdrant is misconfigured/unreachable. 

330 try: 

331 await self.components.qdrant_manager.assert_collection_accessible() 

332 except Exception as e: 

333 raise PermanentJobError( 

334 f"Qdrant collection is unavailable: {sanitize_exception_message(e)}" 

335 ) from e 

336 

337 # Stream documents in bounded micro-batches and process each batch 

338 total_documents = 0 

339 processed_count = 0 

340 aggregated_result = PipelineResult() 

341 batch_count = 0 

342 counted_success_doc_ids: set[str] = set() 

343 counted_failed_doc_ids: set[str] = set() 

344 checkpoint_sources_to_clear: set[tuple[str, str]] = set() 

345 streamed_checkpoint_sources: set[tuple[str, str]] = set() 

346 

347 if not force and not self.components.state_manager._initialized: 

348 logger.debug("Initializing state manager for change detection") 

349 await self.components.state_manager.initialize() 

350 

351 change_detector = None 

352 if not force: 

353 change_detector = await StateChangeDetector( 

354 self.components.state_manager 

355 ).__aenter__() 

356 

357 try: 

358 stream_iter = self._stream_batches_from_sources( 

359 filtered_config, 

360 256, 

361 since, 

362 project_id=current_project_id, 

363 resume=resume, 

364 force=force, 

365 ) 

366 

367 async for batch in stream_iter: 

368 total_documents += len(batch) 

369 batch_count += 1 

370 

371 for doc in batch: 

372 metadata = getattr(doc, "metadata", None) or {} 

373 cp_info = ( 

374 metadata.get("__ingestion_checkpoint") 

375 if isinstance(metadata, dict) 

376 else None 

377 ) 

378 if isinstance(cp_info, dict) and cp_info: 

379 streamed_checkpoint_sources.add( 

380 (doc.source_type, doc.source) 

381 ) 

382 

383 if not force and change_detector is not None: 

384 batch = await change_detector.classify_batch( 

385 batch, filtered_config, current_project_id 

386 ) 

387 

388 if not batch: 

389 continue 

390 

391 async def _persist_as_completed( 

392 doc: Document, success: bool 

393 ) -> None: 

394 if not success: 

395 return 

396 await self._persist_single_document_state( 

397 doc, current_project_id 

398 ) 

399 

400 batch_result = ( 

401 await self.components.document_pipeline.process_batch( 

402 batch, 

403 current_project_id, 

404 on_document_complete=_persist_as_completed, 

405 ) 

406 ) 

407 aggregated_result.success_count += batch_result.success_count 

408 aggregated_result.error_count += batch_result.failure_count 

409 aggregated_result.errors.extend(batch_result.errors) 

410 new_success_doc_ids = ( 

411 batch_result.successfully_processed_documents 

412 - counted_success_doc_ids 

413 ) 

414 counted_success_doc_ids.update(new_success_doc_ids) 

415 aggregated_result.processed_document_count = len( 

416 counted_success_doc_ids 

417 ) 

418 

419 new_failed_doc_ids = ( 

420 batch_result.failed_document_ids - counted_failed_doc_ids 

421 ) 

422 counted_failed_doc_ids.update(new_failed_doc_ids) 

423 aggregated_result.failed_document_count = len( 

424 counted_failed_doc_ids 

425 ) 

426 

427 if batch_result.successfully_processed_documents: 

428 await self._update_document_states( 

429 batch, 

430 batch_result.successfully_processed_documents, 

431 current_project_id, 

432 ) 

433 batch_counted_doc_ids: set[str] = set() 

434 for doc in batch: 

435 if ( 

436 doc.id in new_success_doc_ids 

437 and doc.id not in batch_counted_doc_ids 

438 ): 

439 batch_counted_doc_ids.add(doc.id) 

440 processed_count += 1 

441 aggregated_result.total_size_bytes += ( 

442 _safe_document_size(doc) 

443 ) 

444 # Persist checkpoints found on documents (WS-2). 

445 # Save once per source with the furthest-advanced cursor 

446 # in this batch, not once per document. 

447 if resume and current_project_id is not None and not force: 

448 try: 

449 from qdrant_loader.core.state.checkpoint_manager import ( 

450 Checkpoint, 

451 CheckpointManager, 

452 ) 

453 

454 # Iteration order is the streaming order, so the 

455 # last cp_info seen per source is the furthest 

456 # along (later cursor overwrites earlier ones). 

457 checkpoints_to_save: dict[ 

458 tuple[str, str], Checkpoint 

459 ] = {} 

460 for doc in batch: 

461 if ( 

462 doc.id 

463 not in batch_result.successfully_processed_documents 

464 ): 

465 continue 

466 metadata = getattr(doc, "metadata", None) or {} 

467 cp_info = ( 

468 metadata.get("__ingestion_checkpoint") 

469 if isinstance(metadata, dict) 

470 else None 

471 ) 

472 if not isinstance(cp_info, dict) or not cp_info: 

473 continue 

474 key = (doc.source_type, doc.source) 

475 checkpoints_to_save[key] = Checkpoint( 

476 project_id=current_project_id, 

477 source_type=doc.source_type, 

478 source=doc.source, 

479 cursor_kind=cp_info.get("cursor_kind"), 

480 cursor_value=cp_info.get("cursor_value"), 

481 batch_index=cp_info.get("batch_index", 0), 

482 ) 

483 checkpoint_sources_to_clear.add(key) 

484 

485 if checkpoints_to_save: 

486 async with ( 

487 await self.components.state_manager.get_session() as session 

488 ): 

489 cp_mgr = CheckpointManager(session) 

490 for checkpoint in checkpoints_to_save.values(): 

491 await cp_mgr.save_checkpoint(checkpoint) 

492 except Exception as e: 

493 logger.error( 

494 "Failed to persist checkpoint after batch", 

495 error=str(e), 

496 error_type=type(e).__name__, 

497 ) 

498 

499 if total_documents == 0 and not force: 

500 logger.warning( 

501 "⚠️ EMPTY SNAPSHOT in non-force mode. About to enter change detection " 

502 "which may classify existing corpus as deleted if source API returned partial/null results. " 

503 "This is a known risk (WS-3: add explicit snapshot_is_complete signal or per-source enable_deletion_detection). " 

504 "Proceeding carefully." 

505 ) 

506 

507 if total_documents == 0 and force: 

508 logger.info("✅ No documents found from sources") 

509 return 0 

510 

511 sources_to_clear = ( 

512 checkpoint_sources_to_clear | streamed_checkpoint_sources 

513 ) 

514 

515 # On a clean successful run, clear any saved checkpoints for 

516 # the project/sources processed (prevents stale resume state). 

517 if ( 

518 resume 

519 and current_project_id is not None 

520 and not force 

521 and aggregated_result.error_count == 0 

522 and sources_to_clear 

523 ): 

524 try: 

525 from qdrant_loader.core.state.checkpoint_manager import ( 

526 CheckpointManager, 

527 ) 

528 

529 async with ( 

530 await self.components.state_manager.get_session() as session 

531 ): 

532 cp_mgr = CheckpointManager(session) 

533 for stype, src in sources_to_clear: 

534 try: 

535 await cp_mgr.clear_checkpoint( 

536 current_project_id, stype, src 

537 ) 

538 except Exception as e: 

539 logger.warning( 

540 "Failed to clear checkpoint for source", 

541 source_type=stype, 

542 source=src, 

543 error=str(e), 

544 ) 

545 except Exception as e: 

546 logger.warning( 

547 "Failed to clear checkpoints after successful run", 

548 error=str(e), 

549 ) 

550 

551 if not force and processed_count == 0: 

552 self.last_pipeline_result = aggregated_result 

553 if aggregated_result.error_count > 0: 

554 logger.error( 

555 "No documents were successfully processed", 

556 error_count=aggregated_result.error_count, 

557 ) 

558 raise PermanentJobError( 

559 self._format_indexing_failure_message(aggregated_result) 

560 ) 

561 else: 

562 logger.info("No new or updated documents to process") 

563 return 0 

564 

565 self.last_pipeline_result = aggregated_result 

566 if aggregated_result.error_count > 0: 

567 logger.error( 

568 f"Ingestion completed with failures: " 

569 f"{aggregated_result.success_count} succeeded, " 

570 f"{aggregated_result.error_count} failed", 

571 error_count=aggregated_result.error_count, 

572 ) 

573 raise PermanentJobError( 

574 self._format_indexing_failure_message(aggregated_result) 

575 ) 

576 logger.info( 

577 f"✅ Ingestion completed: {aggregated_result.success_count} chunks processed successfully" 

578 ) 

579 return processed_count 

580 finally: 

581 if change_detector is not None: 

582 await change_detector.__aexit__(None, None, None) 

583 

584 except Exception as e: 

585 logger.error( 

586 f"❌ Pipeline orchestration failed: {sanitize_exception_message(e)}", 

587 error_type=type(e).__name__, 

588 sanitized_traceback=sanitize_exception_message(traceback.format_exc()), 

589 ) 

590 raise 

591 

592 @staticmethod 

593 def _format_indexing_failure_message(result: PipelineResult) -> str: 

594 """Build an error message summarizing chunk-level indexing failures.""" 

595 error_summary = "; ".join( 

596 sanitize_exception_message(error) for error in result.errors[:5] 

597 ) 

598 return ( 

599 f"{result.error_count} chunk(s) failed to index into Qdrant " 

600 f"(out of {result.success_count + result.error_count}): {error_summary}" 

601 ) 

602 

603 async def _process_all_projects( 

604 self, 

605 source_type: str | None = None, 

606 source: str | None = None, 

607 force: bool = False, 

608 since: datetime | None = None, 

609 ) -> int: 

610 """Process documents from all configured projects.""" 

611 if not self.project_manager: 

612 raise ValueError("Project manager not available") 

613 

614 total_processed_count = 0 

615 aggregated_result = PipelineResult() 

616 failed_projects: list[str] = [] 

617 project_ids = self.project_manager.list_project_ids() 

618 

619 logger.info(f"Processing {len(project_ids)} projects") 

620 

621 for project_id in project_ids: 

622 try: 

623 logger.debug(f"Processing project: {project_id}") 

624 project_documents = await self.process_documents( 

625 project_id=project_id, 

626 source_type=source_type, 

627 source=source, 

628 force=force, 

629 since=since, 

630 ) 

631 project_result = self.last_pipeline_result 

632 total_processed_count += project_documents 

633 

634 if project_result is not None: 

635 aggregated_result.success_count += project_result.success_count 

636 aggregated_result.error_count += project_result.error_count 

637 aggregated_result.processed_document_count += ( 

638 project_result.processed_document_count 

639 ) 

640 aggregated_result.failed_document_count += ( 

641 project_result.failed_document_count 

642 ) 

643 aggregated_result.total_size_bytes += ( 

644 project_result.total_size_bytes 

645 ) 

646 aggregated_result.errors.extend(project_result.errors) 

647 

648 logger.debug( 

649 f"Processed {project_documents} documents from project: {project_id}" 

650 ) 

651 except PermanentJobError: 

652 # PermanentJobError from process_documents should propagate 

653 raise 

654 except ConnectorConfigurationError as e: 

655 logger.error( 

656 f"Configuration error in project {project_id}: " 

657 f"{sanitize_exception_message(e)}. " 

658 "Skipping this project — check connector settings.", 

659 error_type=type(e).__name__, 

660 sanitized_traceback=sanitize_exception_message( 

661 traceback.format_exc() 

662 ), 

663 ) 

664 aggregated_result.errors.append( 

665 f"Configuration error in project {project_id}: " 

666 f"{sanitize_exception_message(e)}" 

667 ) 

668 failed_projects.append(project_id) 

669 continue 

670 except Exception as e: 

671 safe_error = sanitize_exception_message(e) 

672 sanitized_traceback = sanitize_exception_message(traceback.format_exc()) 

673 aggregated_result.error_count += 1 

674 aggregated_result.errors.append( 

675 "project_id=" 

676 f"{project_id}; " 

677 "error_type=" 

678 f"{type(e).__name__}; " 

679 "message=" 

680 f"{safe_error}; " 

681 "traceback=" 

682 f"{sanitized_traceback}" 

683 ) 

684 logger.error( 

685 f"Failed to process project {project_id}: {safe_error}", 

686 error_type=type(e).__name__, 

687 sanitized_traceback=sanitized_traceback, 

688 ) 

689 failed_projects.append(project_id) 

690 # Continue processing other projects 

691 continue 

692 

693 self.last_pipeline_result = aggregated_result 

694 

695 total_count = len(project_ids) 

696 failed_count = len(failed_projects) 

697 success_count = total_count - failed_count 

698 if failed_count > 0: 

699 logger.warning( 

700 f"Completed processing projects: {success_count}/{total_count} succeeded, " 

701 f"{failed_count} failed. Check errors above for details.", 

702 total_projects=total_count, 

703 successful_projects=success_count, 

704 failed_projects=failed_count, 

705 ) 

706 else: 

707 logger.info( 

708 f"Completed processing all projects: {total_processed_count} total documents" 

709 ) 

710 return total_processed_count 

711 

712 async def _detect_document_changes( 

713 self, 

714 documents: list[Document], 

715 filtered_config: SourcesConfig, 

716 project_id: str | None = None, 

717 ) -> list[Document]: 

718 """Detect changes in documents and return only new/updated ones.""" 

719 

720 logger.debug(f"Starting change detection for {len(documents)} documents") 

721 

722 try: 

723 # Ensure state manager is initialized before use 

724 if not self.components.state_manager._initialized: 

725 logger.debug("Initializing state manager for change detection") 

726 await self.components.state_manager.initialize() 

727 

728 async with StateChangeDetector( 

729 self.components.state_manager 

730 ) as change_detector: 

731 changes = await change_detector.detect_changes( 

732 documents, filtered_config 

733 ) 

734 

735 new_documents = list(changes.get("new") or []) 

736 updated_documents = list(changes.get("updated") or []) 

737 deleted_documents = list(changes.get("deleted") or []) 

738 

739 logger.info( 

740 f"🔍 Change detection: {len(new_documents)} new, " 

741 f"{len(updated_documents)} updated, " 

742 f"{len(deleted_documents)} deleted" 

743 ) 

744 

745 if deleted_documents: 

746 await self._process_deleted_documents( 

747 deleted_documents, 

748 project_id, 

749 ) 

750 

751 documents_to_process = new_documents + updated_documents 

752 

753 if not documents_to_process and deleted_documents: 

754 logger.info( 

755 "No new or updated documents to process, " 

756 "but deleted documents were handled" 

757 ) 

758 

759 return documents_to_process 

760 

761 except Exception as e: 

762 logger.error( 

763 f"Error during change detection: {sanitize_exception_message(e)}", 

764 error_type=type(e).__name__, 

765 ) 

766 raise 

767 

768 async def _process_deleted_documents( 

769 self, 

770 deleted_documents: list[Document], 

771 project_id: str | None = None, 

772 ) -> None: 

773 """Process deleted documents by updating state and removing points from Qdrant.""" 

774 if not deleted_documents: 

775 return 

776 

777 logger.info(f"Processing {len(deleted_documents)} deleted documents") 

778 

779 if not self.components.state_manager._initialized: 

780 logger.debug("Initializing state manager for deleted document processing") 

781 await self.components.state_manager.initialize() 

782 

783 # Use an atomic operation that marks state and deletes points together. 

784 try: 

785 deleted_ids = ( 

786 await self.components.state_manager.mark_documents_deleted_atomic( 

787 deleted_documents, self.components.qdrant_manager, project_id 

788 ) 

789 ) 

790 if deleted_ids: 

791 logger.info( 

792 f"Deleted {len(deleted_ids)} document points from Qdrant and updated state" 

793 ) 

794 except Exception as e: 

795 logger.error( 

796 f"Failed to process deleted documents atomically: {sanitize_exception_message(e)}", 

797 error_type=type(e).__name__, 

798 ) 

799 raise 

800 

801 async def _persist_single_document_state( 

802 self, 

803 document: Document, 

804 project_id: str | None = None, 

805 ) -> None: 

806 """Persist one document's state as soon as it finishes, not at batch end. 

807 

808 Streaming batches are bounded at up to 256 documents and state was 

809 previously only committed once the *entire* batch finished 

810 chunking/embedding/upserting. If the process was interrupted partway 

811 through such a batch, documents already durably upserted to Qdrant 

812 had no ``DocumentStateRecord`` yet, so a resume would treat them as 

813 new and reprocess them. Called from ``UpsertWorker`` the moment a 

814 document's last chunk is accounted for, this closes that gap. The 

815 end-of-batch ``_update_document_states`` call still runs afterwards 

816 as a safety net (idempotent) for anything this callback missed. 

817 """ 

818 try: 

819 if not self.components.state_manager._initialized: 

820 await self.components.state_manager.initialize() 

821 await self.components.state_manager.update_document_states_batch( 

822 [document], project_id 

823 ) 

824 except Exception as e: 

825 logger.error( 

826 f"Failed to persist incremental document state for {document.id}: " 

827 f"{sanitize_exception_message(e)}", 

828 error_type=type(e).__name__, 

829 ) 

830 

831 async def _update_document_states( 

832 self, 

833 documents: list[Document], 

834 successfully_processed_doc_ids: set, 

835 project_id: str | None = None, 

836 ): 

837 """Update document states for successfully processed documents.""" 

838 successfully_processed_docs = [ 

839 doc for doc in documents if doc.id in successfully_processed_doc_ids 

840 ] 

841 

842 logger.debug( 

843 f"Updating document states for {len(successfully_processed_docs)} documents" 

844 ) 

845 

846 # Ensure state manager is initialized before use 

847 if not self.components.state_manager._initialized: 

848 logger.debug("Initializing state manager for document state updates") 

849 await self.components.state_manager.initialize() 

850 

851 if not successfully_processed_docs: 

852 return 

853 

854 # One session/commit for the whole batch instead of one per document 

855 # (each document's write is still isolated via a SAVEPOINT, so a 

856 # single failure doesn't affect the others' results below). 

857 results = await self.components.state_manager.update_document_states_batch( 

858 successfully_processed_docs, project_id 

859 ) 

860 for doc, _record, error in results: 

861 if error is None: 

862 logger.debug(f"Updated document state for {doc.id}") 

863 else: 

864 logger.error( 

865 f"Failed to update document state for {doc.id}: {sanitize_exception_message(error)}", 

866 error_type=type(error).__name__, 

867 )