Coverage for src/dataknobs_data/migration/migrator.py: 10%

164 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-17 16:10 -0500

1""" 

2Enhanced data migrator with streaming support. 

3""" 

4 

5import asyncio 

6import concurrent.futures 

7from typing import Callable, Iterator, Optional, Union, List 

8 

9from dataknobs_data.database import AsyncDatabase, SyncDatabase 

10from dataknobs_data.query import Query 

11from dataknobs_data.records import Record 

12from dataknobs_data.streaming import StreamConfig, StreamResult 

13 

14from .transformer import Transformer 

15from .migration import Migration 

16from .progress import MigrationProgress 

17 

18 

19class Migrator: 

20 """ 

21 Data migration orchestrator with streaming support. 

22  

23 Provides memory-efficient migration between databases using streaming, 

24 with support for transformations, progress tracking, and parallel processing. 

25 """ 

26 

27 def migrate( 

28 self, 

29 source: SyncDatabase, 

30 target: SyncDatabase, 

31 transform: Optional[Union[Transformer, Migration]] = None, 

32 query: Optional[Query] = None, 

33 batch_size: int = 1000, 

34 on_progress: Optional[Callable[[MigrationProgress], None]] = None, 

35 on_error: Optional[Callable[[Exception, Record], bool]] = None 

36 ) -> MigrationProgress: 

37 """ 

38 Migrate data between databases with optional transformation. 

39  

40 Args: 

41 source: Source database 

42 target: Target database 

43 transform: Optional transformer or migration to apply 

44 query: Optional query to filter source records 

45 batch_size: Number of records to process per batch 

46 on_progress: Optional callback for progress updates 

47 on_error: Optional error handler (return True to continue) 

48  

49 Returns: 

50 MigrationProgress with final statistics 

51 """ 

52 progress = MigrationProgress().start() 

53 

54 # Get total count for progress tracking 

55 all_records = source.search(query or Query()) 

56 progress.total = len(all_records) 

57 

58 batch = [] 

59 for record in all_records: 

60 try: 

61 # Apply transformation if provided 

62 if transform is not None: 

63 if isinstance(transform, Transformer): 

64 original_id = record.id # Preserve ID before transformation 

65 transformed = transform.transform(record) 

66 if transformed is None: 

67 # Record filtered out 

68 progress.record_skip("Filtered by transformer", original_id) 

69 continue 

70 record = transformed 

71 elif isinstance(transform, Migration): 

72 record = transform.apply(record) 

73 

74 batch.append(record) 

75 

76 # Process batch when full 

77 if len(batch) >= batch_size: 

78 self._write_batch(target, batch, progress, on_error) 

79 batch = [] 

80 

81 if on_progress: 

82 on_progress(progress) 

83 

84 except Exception as e: 

85 progress.record_failure(str(e), record.id if hasattr(record, 'id') else None, e) 

86 if on_error: 

87 if not on_error(e, record): 

88 # Handler says stop - re-raise to stop processing immediately 

89 raise 

90 # Handler says continue - keep going 

91 else: 

92 # No handler - stop processing immediately 

93 raise 

94 

95 # Process final batch 

96 if batch: 

97 self._write_batch(target, batch, progress, on_error) 

98 

99 progress.finish() 

100 

101 if on_progress: 

102 on_progress(progress) 

103 

104 return progress 

105 

106 def migrate_stream( 

107 self, 

108 source: SyncDatabase, 

109 target: SyncDatabase, 

110 transform: Optional[Union[Transformer, Migration]] = None, 

111 query: Optional[Query] = None, 

112 config: Optional[StreamConfig] = None, 

113 on_progress: Optional[Callable[[MigrationProgress], None]] = None 

114 ) -> MigrationProgress: 

115 """ 

116 Stream-based migration for memory efficiency. 

117  

118 Never loads full dataset into memory. 

119  

120 Args: 

121 source: Source database with streaming support 

122 target: Target database with streaming support 

123 transform: Optional transformer or migration to apply 

124 query: Optional query to filter source records 

125 config: Streaming configuration 

126 on_progress: Optional callback for progress updates 

127  

128 Returns: 

129 MigrationProgress with final statistics 

130 """ 

131 config = config or StreamConfig() 

132 progress = MigrationProgress().start() 

133 

134 # Estimate total (if possible) 

135 try: 

136 progress.total = source.count(query) 

137 except Exception: 

138 # Count not available, will track as we go 

139 pass 

140 

141 # Create streaming pipeline 

142 def transform_stream(records: Iterator[Record]) -> Iterator[Record]: 

143 """Apply transformation to streaming records.""" 

144 for record in records: 

145 progress.processed += 1 # Track that we've processed this record 

146 try: 

147 if transform is not None: 

148 if isinstance(transform, Transformer): 

149 original_id = record.id # Preserve ID before transformation 

150 transformed = transform.transform(record) 

151 if transformed: 

152 yield transformed 

153 else: 

154 progress.record_skip("Filtered by transformer", original_id) 

155 elif isinstance(transform, Migration): 

156 yield transform.apply(record) 

157 else: 

158 yield record 

159 except Exception as e: 

160 if config.on_error and config.on_error(e, record): 

161 progress.record_failure(str(e), record.id if hasattr(record, 'id') else None, e) 

162 continue 

163 else: 

164 progress.record_failure(str(e), record.id if hasattr(record, 'id') else None, e) 

165 raise 

166 

167 # Stream from source through transformation to target 

168 source_stream = source.stream_read(query, config) 

169 transformed_stream = transform_stream(source_stream) 

170 

171 # Write stream to target 

172 result = target.stream_write(transformed_stream, config) 

173 

174 # Update progress from result 

175 # Note: processed was already tracked in transform_stream 

176 # Result contains only write successes/failures 

177 progress.succeeded += result.successful 

178 progress.failed += result.failed 

179 progress.errors.extend(result.errors) 

180 

181 progress.finish() 

182 

183 if on_progress: 

184 on_progress(progress) 

185 

186 return progress 

187 

188 def migrate_parallel( 

189 self, 

190 source: SyncDatabase, 

191 target: SyncDatabase, 

192 transform: Optional[Union[Transformer, Migration]] = None, 

193 partitions: int = 4, 

194 partition_field: str = "partition_id", 

195 on_progress: Optional[Callable[[MigrationProgress], None]] = None 

196 ) -> MigrationProgress: 

197 """ 

198 Parallel streaming migration. 

199  

200 Partition data and migrate in parallel streams. 

201  

202 Args: 

203 source: Source database 

204 target: Target database 

205 transform: Optional transformer or migration 

206 partitions: Number of parallel partitions 

207 partition_field: Field to use for partitioning 

208 on_progress: Optional callback for progress updates 

209  

210 Returns: 

211 Combined MigrationProgress 

212 """ 

213 def migrate_partition(partition_id: int) -> MigrationProgress: 

214 """Migrate a single partition.""" 

215 query = Query().filter(partition_field, "=", partition_id) 

216 return self.migrate_stream(source, target, transform, query) 

217 

218 total_progress = MigrationProgress().start() 

219 

220 with concurrent.futures.ThreadPoolExecutor(max_workers=partitions) as executor: 

221 futures = [ 

222 executor.submit(migrate_partition, i) 

223 for i in range(partitions) 

224 ] 

225 

226 for future in concurrent.futures.as_completed(futures): 

227 partition_progress = future.result() 

228 total_progress.merge(partition_progress) 

229 

230 if on_progress: 

231 on_progress(total_progress) 

232 

233 total_progress.finish() 

234 return total_progress 

235 

236 async def migrate_async( 

237 self, 

238 source: AsyncDatabase, 

239 target: AsyncDatabase, 

240 transform: Optional[Union[Transformer, Migration]] = None, 

241 query: Optional[Query] = None, 

242 config: Optional[StreamConfig] = None, 

243 on_progress: Optional[Callable[[MigrationProgress], None]] = None 

244 ) -> MigrationProgress: 

245 """ 

246 Async stream-based migration. 

247  

248 Args: 

249 source: Async source database 

250 target: Async target database 

251 transform: Optional transformer or migration 

252 query: Optional query to filter source records 

253 config: Streaming configuration 

254 on_progress: Optional callback for progress updates 

255  

256 Returns: 

257 MigrationProgress with final statistics 

258 """ 

259 config = config or StreamConfig() 

260 progress = MigrationProgress().start() 

261 

262 # Estimate total (if possible) 

263 try: 

264 progress.total = await source.count(query) 

265 except Exception: 

266 pass 

267 

268 # Create async streaming pipeline 

269 async def transform_stream(records): 

270 """Apply transformation to async streaming records.""" 

271 async for record in records: 

272 progress.processed += 1 # Track that we've processed this record 

273 try: 

274 if transform is not None: 

275 if isinstance(transform, Transformer): 

276 original_id = record.id # Preserve ID before transformation 

277 transformed = transform.transform(record) 

278 if transformed: 

279 yield transformed 

280 else: 

281 progress.record_skip("Filtered by transformer", original_id) 

282 elif isinstance(transform, Migration): 

283 yield transform.apply(record) 

284 else: 

285 yield record 

286 except Exception as e: 

287 if config.on_error and config.on_error(e, record): 

288 progress.record_failure(str(e), record.id if hasattr(record, 'id') else None, e) 

289 continue 

290 else: 

291 progress.record_failure(str(e), record.id if hasattr(record, 'id') else None, e) 

292 raise 

293 

294 # Stream from source through transformation to target 

295 source_stream = source.stream_read(query, config) 

296 transformed_stream = transform_stream(source_stream) 

297 

298 # Write stream to target 

299 result = await target.stream_write(transformed_stream, config) 

300 

301 # Update progress from result 

302 # Note: processed was already tracked in transform_stream 

303 # Result contains only write successes/failures 

304 progress.succeeded += result.successful 

305 progress.failed += result.failed 

306 progress.errors.extend(result.errors) 

307 

308 progress.finish() 

309 

310 if on_progress: 

311 on_progress(progress) 

312 

313 return progress 

314 

315 def _write_batch( 

316 self, 

317 target: SyncDatabase, 

318 batch: List[Record], 

319 progress: MigrationProgress, 

320 on_error: Optional[Callable[[Exception, Record], bool]] = None 

321 ) -> None: 

322 """ 

323 Write a batch of records to target database. 

324  

325 Args: 

326 target: Target database 

327 batch: Batch of records to write 

328 progress: Progress tracker to update 

329 on_error: Optional error handler 

330 """ 

331 for record in batch: 

332 try: 

333 # Ensure record has an ID 

334 if not record.id: 

335 record.generate_id() 

336 

337 target.create(record) 

338 progress.record_success(record.id) 

339 except Exception as e: 

340 progress.record_failure(str(e), record.id, e) 

341 if on_error: 

342 if not on_error(e, record): 

343 # Handler says stop - re-raise to stop processing immediately 

344 raise 

345 # Handler says continue - keep going 

346 else: 

347 # No handler - stop processing immediately 

348 raise 

349 

350 def validate_migration( 

351 self, 

352 source: SyncDatabase, 

353 target: SyncDatabase, 

354 query: Optional[Query] = None, 

355 sample_size: Optional[int] = None 

356 ) -> tuple[bool, List[str]]: 

357 """ 

358 Validate that migration was successful. 

359  

360 Args: 

361 source: Source database 

362 target: Target database 

363 query: Optional query used for migration 

364 sample_size: Optional number of records to sample for validation 

365  

366 Returns: 

367 Tuple of (is_valid, list_of_issues) 

368 """ 

369 issues = [] 

370 

371 # Get counts 

372 source_records = source.search(query or Query()) 

373 target_records = target.search(Query()) 

374 

375 source_count = len(source_records) 

376 target_count = len(target_records) 

377 

378 if source_count != target_count: 

379 issues.append( 

380 f"Record count mismatch: source={source_count}, target={target_count}" 

381 ) 

382 

383 # Sample validation 

384 if sample_size: 

385 sample = source_records[:sample_size] 

386 else: 

387 sample = source_records 

388 

389 for source_record in sample: 

390 if source_record.id: 

391 target_record = target.read(source_record.id) 

392 if not target_record: 

393 issues.append(f"Record {source_record.id} not found in target") 

394 

395 return len(issues) == 0, issues