Coverage for src/qdrant_loader/core/state/checkpoint_manager.py: 71%

98 statements  

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

1"""Checkpoint manager for resumable ingestion (WS-2 feature).""" 

2 

3from dataclasses import dataclass 

4from datetime import UTC, datetime 

5 

6from sqlalchemy import and_, select 

7from sqlalchemy.ext.asyncio import AsyncSession 

8 

9from qdrant_loader.core.state.models import IngestionCheckpoint 

10from qdrant_loader.utils.logging import LoggingConfig 

11 

12logger = LoggingConfig.get_logger(__name__) 

13 

14 

15@dataclass 

16class Checkpoint: 

17 """Represents a checkpoint for resumable ingestion.""" 

18 

19 project_id: str 

20 source_type: str 

21 source: str 

22 cursor_kind: str # page_token | jql_window | git_commit | since_ts 

23 cursor_value: str 

24 batch_index: int = 0 

25 updated_at: datetime | None = None 

26 

27 def to_dict(self) -> dict: 

28 """Convert checkpoint to dictionary for serialization.""" 

29 return { 

30 "project_id": self.project_id, 

31 "source_type": self.source_type, 

32 "source": self.source, 

33 "cursor_kind": self.cursor_kind, 

34 "cursor_value": self.cursor_value, 

35 "batch_index": self.batch_index, 

36 "updated_at": self.updated_at.isoformat() if self.updated_at else None, 

37 } 

38 

39 @classmethod 

40 def from_db(cls, db_record: IngestionCheckpoint) -> "Checkpoint": 

41 """Create a Checkpoint instance from database record.""" 

42 return cls( 

43 project_id=db_record.project_id, 

44 source_type=db_record.source_type, 

45 source=db_record.source, 

46 cursor_kind=db_record.cursor_kind, 

47 cursor_value=db_record.cursor_value, 

48 batch_index=db_record.batch_index, 

49 updated_at=db_record.updated_at, 

50 ) 

51 

52 

53class CheckpointManager: 

54 """Manages ingestion checkpoints for resumable runs.""" 

55 

56 def __init__(self, session: AsyncSession): 

57 """Initialize checkpoint manager. 

58 

59 Args: 

60 session: SQLAlchemy async session for database operations 

61 """ 

62 self.session = session 

63 

64 async def get_checkpoint( 

65 self, project_id: str, source_type: str, source: str 

66 ) -> Checkpoint | None: 

67 """Get the latest checkpoint for a source. 

68 

69 Args: 

70 project_id: Project ID 

71 source_type: Type of source (jira, confluence, etc.) 

72 source: Source name/identifier 

73 

74 Returns: 

75 Checkpoint if exists, None otherwise 

76 """ 

77 try: 

78 stmt = select(IngestionCheckpoint).where( 

79 and_( 

80 IngestionCheckpoint.project_id == project_id, 

81 IngestionCheckpoint.source_type == source_type, 

82 IngestionCheckpoint.source == source, 

83 ) 

84 ) 

85 result = await self.session.execute(stmt) 

86 db_record = result.scalar_one_or_none() 

87 

88 if db_record: 

89 checkpoint = Checkpoint.from_db(db_record) 

90 logger.info( 

91 "Found checkpoint for resumption", 

92 project_id=project_id, 

93 source_type=source_type, 

94 source=source, 

95 cursor_kind=checkpoint.cursor_kind, 

96 batch_index=checkpoint.batch_index, 

97 ) 

98 return checkpoint 

99 else: 

100 logger.debug( 

101 "No checkpoint found for source", 

102 project_id=project_id, 

103 source_type=source_type, 

104 source=source, 

105 ) 

106 return None 

107 except Exception as e: 

108 await self.session.rollback() 

109 logger.error( 

110 "Failed to retrieve checkpoint", 

111 project_id=project_id, 

112 source_type=source_type, 

113 source=source, 

114 error=str(e), 

115 error_type=type(e).__name__, 

116 ) 

117 raise 

118 

119 async def save_checkpoint(self, checkpoint: Checkpoint) -> None: 

120 """Save or update a checkpoint after successful batch upsert. 

121 

122 Args: 

123 checkpoint: Checkpoint to save 

124 

125 Raises: 

126 Exception: If database operation fails 

127 """ 

128 try: 

129 # Set updated_at to now if not provided 

130 if checkpoint.updated_at is None: 

131 checkpoint.updated_at = datetime.now(UTC) 

132 

133 # Try to fetch existing checkpoint 

134 stmt = select(IngestionCheckpoint).where( 

135 and_( 

136 IngestionCheckpoint.project_id == checkpoint.project_id, 

137 IngestionCheckpoint.source_type == checkpoint.source_type, 

138 IngestionCheckpoint.source == checkpoint.source, 

139 ) 

140 ) 

141 result = await self.session.execute(stmt) 

142 existing = result.scalar_one_or_none() 

143 

144 if existing: 

145 # Update existing checkpoint 

146 existing.cursor_kind = checkpoint.cursor_kind 

147 existing.cursor_value = checkpoint.cursor_value 

148 existing.batch_index = checkpoint.batch_index 

149 existing.updated_at = checkpoint.updated_at 

150 await self.session.merge(existing) 

151 logger.debug( 

152 "Updated checkpoint", 

153 project_id=checkpoint.project_id, 

154 source_type=checkpoint.source_type, 

155 source=checkpoint.source, 

156 cursor_value=checkpoint.cursor_value, 

157 batch_index=checkpoint.batch_index, 

158 ) 

159 else: 

160 # Create new checkpoint 

161 db_checkpoint = IngestionCheckpoint( 

162 project_id=checkpoint.project_id, 

163 source_type=checkpoint.source_type, 

164 source=checkpoint.source, 

165 cursor_kind=checkpoint.cursor_kind, 

166 cursor_value=checkpoint.cursor_value, 

167 batch_index=checkpoint.batch_index, 

168 updated_at=checkpoint.updated_at, 

169 ) 

170 self.session.add(db_checkpoint) 

171 logger.debug( 

172 "Created new checkpoint", 

173 project_id=checkpoint.project_id, 

174 source_type=checkpoint.source_type, 

175 source=checkpoint.source, 

176 cursor_value=checkpoint.cursor_value, 

177 batch_index=checkpoint.batch_index, 

178 ) 

179 

180 await self.session.commit() 

181 except Exception as e: 

182 await self.session.rollback() 

183 logger.error( 

184 "Failed to save checkpoint", 

185 project_id=checkpoint.project_id, 

186 source_type=checkpoint.source_type, 

187 source=checkpoint.source, 

188 error=str(e), 

189 error_type=type(e).__name__, 

190 ) 

191 raise 

192 

193 async def clear_checkpoint( 

194 self, project_id: str, source_type: str, source: str 

195 ) -> None: 

196 """Clear checkpoint after successful clean run completion. 

197 

198 Args: 

199 project_id: Project ID 

200 source_type: Type of source (jira, confluence, etc.) 

201 source: Source name/identifier 

202 

203 Raises: 

204 Exception: If database operation fails 

205 """ 

206 try: 

207 stmt = select(IngestionCheckpoint).where( 

208 and_( 

209 IngestionCheckpoint.project_id == project_id, 

210 IngestionCheckpoint.source_type == source_type, 

211 IngestionCheckpoint.source == source, 

212 ) 

213 ) 

214 result = await self.session.execute(stmt) 

215 db_record = result.scalar_one_or_none() 

216 

217 if db_record: 

218 await self.session.delete(db_record) 

219 await self.session.commit() 

220 logger.info( 

221 "Cleared checkpoint on successful completion", 

222 project_id=project_id, 

223 source_type=source_type, 

224 source=source, 

225 ) 

226 else: 

227 logger.debug( 

228 "No checkpoint to clear", 

229 project_id=project_id, 

230 source_type=source_type, 

231 source=source, 

232 ) 

233 except Exception as e: 

234 await self.session.rollback() 

235 logger.error( 

236 "Failed to clear checkpoint", 

237 project_id=project_id, 

238 source_type=source_type, 

239 source=source, 

240 error=str(e), 

241 error_type=type(e).__name__, 

242 ) 

243 raise 

244 

245 async def get_all_checkpoints(self) -> list[Checkpoint]: 

246 """Get all active checkpoints (useful for monitoring/debugging). 

247 

248 Returns: 

249 List of all checkpoints in the database 

250 """ 

251 try: 

252 stmt = select(IngestionCheckpoint) 

253 result = await self.session.execute(stmt) 

254 db_records = result.scalars().all() 

255 return [Checkpoint.from_db(record) for record in db_records] 

256 except Exception as e: 

257 await self.session.rollback() 

258 logger.error( 

259 "Failed to retrieve all checkpoints", 

260 error=str(e), 

261 error_type=type(e).__name__, 

262 ) 

263 raise 

264 

265 async def clear_all_checkpoints(self) -> None: 

266 """Clear all checkpoints (useful for admin/reset operations). 

267 

268 Raises: 

269 Exception: If database operation fails 

270 """ 

271 try: 

272 stmt = select(IngestionCheckpoint) 

273 result = await self.session.execute(stmt) 

274 records = result.scalars().all() 

275 for record in records: 

276 await self.session.delete(record) 

277 await self.session.commit() 

278 logger.info("Cleared all ingestion checkpoints", count=len(records)) 

279 except Exception as e: 

280 await self.session.rollback() 

281 logger.error( 

282 "Failed to clear all checkpoints", 

283 error=str(e), 

284 error_type=type(e).__name__, 

285 ) 

286 raise