Coverage for src / lexigram / admin / services / background_jobs.py: 0%

157 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-11 02:25 +0800

1"""Background job queue for admin import/export operations. 

2 

3Wraps import and export services to run large operations asynchronously with 

4real-time progress tracking via :class:`ProgressTrackerProtocol`. 

5 

6Usage:: 

7 

8 from lexigram.admin.services.background_jobs import BackgroundJobService 

9 from lexigram.tasks.progress import InMemoryProgressTracker 

10 

11 tracker = InMemoryProgressTracker() 

12 job_svc = BackgroundJobService( 

13 progress_tracker=tracker, 

14 import_service=importer, 

15 export_service=exporter, 

16 ) 

17 

18 # Enqueue a CSV import — returns immediately with a job_id 

19 job_id = await job_svc.enqueue_import( 

20 resource_type="user", 

21 file_content=csv_bytes, 

22 file_format="csv", 

23 actor_id="admin-user", 

24 ) 

25 

26 # Poll progress 

27 status = await job_svc.get_status(job_id) 

28 # → {"job_id": "...", "percent": 42, "status": "running", ...} 

29 

30 # Enqueue CSV/JSON export 

31 job_id = await job_svc.enqueue_export( 

32 resource_type="user", 

33 export_format="csv", 

34 filters={"active": True}, 

35 actor_id="admin-user", 

36 ) 

37""" 

38 

39from __future__ import annotations 

40 

41import asyncio 

42from dataclasses import dataclass, field 

43from datetime import UTC, datetime 

44from typing import Any 

45 

46from lexigram.contracts.infra.tasks.progress import ProgressTrackerProtocol 

47from lexigram.logging import get_logger 

48from lexigram.serialization import dumps_str 

49 

50logger = get_logger(__name__) 

51 

52 

53class JobNotFoundError(KeyError): 

54 """Raised when a job_id is not found in the queue.""" 

55 

56 _code: str = "LEX_ERR_ADMIN_028" 

57 

58 

59@dataclass 

60class BackgroundJob: 

61 """Runtime record of a background operation. 

62 

63 Attributes: 

64 job_id: Unique identifier. 

65 job_type: ``"import"`` or ``"export"``. 

66 resource_type: Admin resource name. 

67 actor_id: Who triggered the job. 

68 status: ``"pending"``, ``"running"``, ``"completed"``, or ``"failed"``. 

69 percent: 0-100 completion percentage. 

70 created_at: UTC timestamp. 

71 completed_at: UTC timestamp (when finished). 

72 result: Final result data (on completion). 

73 error: Error message (on failure). 

74 metadata: Extra context (format, filename, etc.). 

75 """ 

76 

77 job_id: str 

78 job_type: str 

79 resource_type: str 

80 actor_id: str 

81 status: str = "pending" 

82 percent: int = 0 

83 created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) 

84 completed_at: datetime | None = None 

85 result: dict[str, Any] = field(default_factory=dict) 

86 error: str = "" 

87 metadata: dict[str, Any] = field(default_factory=dict) 

88 

89 def to_dict(self) -> dict[str, Any]: 

90 """Serialise to a JSON-safe dict for SSE/polling endpoints.""" 

91 return { 

92 "job_id": self.job_id, 

93 "job_type": self.job_type, 

94 "resource_type": self.resource_type, 

95 "actor_id": self.actor_id, 

96 "status": self.status, 

97 "percent": self.percent, 

98 "created_at": self.created_at.isoformat(), 

99 "completed_at": self.completed_at.isoformat() 

100 if self.completed_at 

101 else None, 

102 "result": self.result, 

103 "error": self.error, 

104 "metadata": self.metadata, 

105 } 

106 

107 

108class BackgroundJobService: 

109 """Manages background import/export jobs with progress tracking. 

110 

111 All heavy work runs in a background :func:`asyncio.Task`. Progress is 

112 broadcast via :class:`ProgressTrackerProtocol` so subscribers can receive 

113 live updates and callers can poll :meth:`get_status` without blocking. 

114 

115 Args: 

116 progress_tracker: Optional :class:`ProgressTrackerProtocol` instance. 

117 When provided, jobs broadcast live updates so the admin progress 

118 UI works automatically. 

119 import_service: Optional ``AdminImportService`` instance. 

120 export_service: Optional ``ExportService`` instance. 

121 max_retained_jobs: Maximum number of completed/failed jobs to keep 

122 in memory before oldest are evicted. 

123 """ 

124 

125 def __init__( 

126 self, 

127 progress_tracker: ProgressTrackerProtocol | None = None, 

128 import_service: Any = None, 

129 export_service: Any = None, 

130 max_retained_jobs: int = 200, 

131 ) -> None: 

132 self._progress = progress_tracker 

133 self._importer = import_service 

134 self._exporter = export_service 

135 self._max_retained = max_retained_jobs 

136 self._jobs: dict[str, BackgroundJob] = {} 

137 self._tasks: dict[str, asyncio.Task[None]] = {} 

138 self._background_tasks: set[asyncio.Task[None]] = set() 

139 self._counter = 0 

140 

141 # ------------------------------------------------------------------ 

142 # Internal helpers 

143 # ------------------------------------------------------------------ 

144 

145 def _new_id(self, prefix: str) -> str: 

146 self._counter += 1 

147 ts = datetime.now(UTC).strftime("%Y%m%d%H%M%S%f") 

148 return f"{prefix}-{ts}-{self._counter}" 

149 

150 def _register(self, job: BackgroundJob) -> None: 

151 self._jobs[job.job_id] = job 

152 self._evict_old() 

153 

154 def _evict_old(self) -> None: 

155 done = [j for j in self._jobs.values() if j.status in ("completed", "failed")] 

156 if len(done) > self._max_retained: 

157 for old in sorted(done, key=lambda j: j.created_at)[ 

158 : len(done) - self._max_retained 

159 ]: 

160 del self._jobs[old.job_id] 

161 

162 async def _update( 

163 self, job: BackgroundJob, percent: int, status: str = "running" 

164 ) -> None: 

165 job.percent = percent 

166 job.status = status 

167 if self._progress: 

168 try: 

169 await self._progress.update( 

170 job.job_id, percent, 100, f"{status} ({percent}%)" 

171 ) 

172 except (TimeoutError, OSError, RuntimeError): 

173 pass 

174 

175 async def _finish(self, job: BackgroundJob, result: dict[str, Any]) -> None: 

176 job.status = "completed" 

177 job.percent = 100 

178 job.result = result 

179 job.completed_at = datetime.now(UTC) 

180 if self._progress: 

181 try: 

182 await self._progress.complete(job.job_id, dumps_str(result)) 

183 except (TimeoutError, OSError, RuntimeError): 

184 pass 

185 

186 async def _fail(self, job: BackgroundJob, error: str) -> None: 

187 job.status = "failed" 

188 job.error = error 

189 job.completed_at = datetime.now(UTC) 

190 if self._progress: 

191 try: 

192 await self._progress.fail(job.job_id, error) 

193 except (TimeoutError, OSError, RuntimeError): 

194 pass 

195 

196 # ------------------------------------------------------------------ 

197 # Enqueue import 

198 # ------------------------------------------------------------------ 

199 

200 async def enqueue_import( 

201 self, 

202 resource_type: str, 

203 file_content: bytes, 

204 *, 

205 file_format: str = "csv", 

206 actor_id: str = "system", 

207 filename: str = "", 

208 options: dict[str, Any] | None = None, 

209 ) -> str: 

210 """Enqueue a background import job. 

211 

212 Args: 

213 resource_type: Resource name (e.g. ``"user"``). 

214 file_content: Raw file bytes. 

215 file_format: ``"csv"`` or ``"json"``. 

216 actor_id: Principal who triggered the import. 

217 filename: Optional original filename (stored in metadata). 

218 options: Extra options forwarded to the import service. 

219 

220 Returns: 

221 JobProtocol ID for polling via :meth:`get_status`. 

222 """ 

223 job_id = self._new_id("import") 

224 job = BackgroundJob( 

225 job_id=job_id, 

226 job_type="import", 

227 resource_type=resource_type, 

228 actor_id=actor_id, 

229 metadata={"file_format": file_format, "filename": filename}, 

230 ) 

231 self._register(job) 

232 

233 task = asyncio.create_task( 

234 self._run_import(job, file_content, file_format, options or {}) 

235 ) 

236 self._background_tasks.add(task) 

237 task.add_done_callback(self._background_tasks.discard) 

238 self._tasks[job_id] = task 

239 task.add_done_callback(lambda _t: self._tasks.pop(job_id, None)) 

240 logger.info("Enqueued import job %s for resource '%s'", job_id, resource_type) 

241 return job_id 

242 

243 async def _run_import( 

244 self, 

245 job: BackgroundJob, 

246 file_content: bytes, 

247 file_format: str, 

248 options: dict[str, Any], 

249 ) -> None: 

250 try: 

251 await self._update(job, 5) 

252 

253 if self._importer is None: 

254 # Simulate when no service wired (useful for testing) 

255 await asyncio.sleep(0) 

256 await self._finish( 

257 job, {"rows_imported": 0, "note": "no import service configured"} 

258 ) 

259 return 

260 

261 await self._update(job, 20) 

262 import_job = await self._importer.parse( 

263 file_content, file_format=file_format, **options 

264 ) 

265 await self._update(job, 60) 

266 

267 result = await self._importer.commit(import_job) 

268 await self._update(job, 95) 

269 

270 if result.is_ok(): 

271 r = result.unwrap() 

272 await self._finish( 

273 job, 

274 { 

275 "rows_imported": getattr(r, "imported", 0), 

276 "rows_skipped": getattr(r, "skipped", 0), 

277 "rows_failed": getattr(r, "failed", 0), 

278 }, 

279 ) 

280 else: 

281 err = result.unwrap_err() 

282 await self._fail(job, str(err)) 

283 

284 except Exception as exc: # noqa: BLE001 — top-level task runner must capture all failures to mark job as failed 

285 logger.exception("Import job %s failed: %s", job.job_id, exc) 

286 await self._fail(job, str(exc)) 

287 

288 # ------------------------------------------------------------------ 

289 # Enqueue export 

290 # ------------------------------------------------------------------ 

291 

292 async def enqueue_export( 

293 self, 

294 resource_type: str, 

295 *, 

296 export_format: str = "csv", 

297 actor_id: str = "system", 

298 filters: dict[str, Any] | None = None, 

299 columns: list[str] | None = None, 

300 ) -> str: 

301 """Enqueue a background export job. 

302 

303 Args: 

304 resource_type: Resource name. 

305 export_format: ``"csv"``, ``"json"``, or ``"xlsx"``. 

306 actor_id: Principal who triggered the export. 

307 filters: Optional filters to apply to the query. 

308 columns: Optional list of columns to include. 

309 

310 Returns: 

311 JobProtocol ID for polling via :meth:`get_status`. 

312 """ 

313 job_id = self._new_id("export") 

314 job = BackgroundJob( 

315 job_id=job_id, 

316 job_type="export", 

317 resource_type=resource_type, 

318 actor_id=actor_id, 

319 metadata={"export_format": export_format, "columns": columns or []}, 

320 ) 

321 self._register(job) 

322 

323 task = asyncio.create_task( 

324 self._run_export(job, export_format, filters or {}, columns or []) 

325 ) 

326 self._background_tasks.add(task) 

327 task.add_done_callback(self._background_tasks.discard) 

328 self._tasks[job_id] = task 

329 task.add_done_callback(lambda _t: self._tasks.pop(job_id, None)) 

330 logger.info("Enqueued export job %s for resource '%s'", job_id, resource_type) 

331 return job_id 

332 

333 async def _run_export( 

334 self, 

335 job: BackgroundJob, 

336 export_format: str, 

337 filters: dict[str, Any], 

338 columns: list[str], 

339 ) -> None: 

340 try: 

341 await self._update(job, 10) 

342 

343 if self._exporter is None: 

344 await asyncio.sleep(0) 

345 await self._finish( 

346 job, {"rows_exported": 0, "note": "no export service configured"} 

347 ) 

348 return 

349 

350 await self._update(job, 30) 

351 output = await self._exporter.export( 

352 job.resource_type, 

353 format=export_format, 

354 filters=filters, 

355 columns=columns, 

356 ) 

357 await self._update(job, 90) 

358 await self._finish( 

359 job, 

360 { 

361 "rows_exported": getattr(output, "row_count", 0), 

362 "download_url": getattr(output, "download_url", ""), 

363 "export_format": export_format, 

364 }, 

365 ) 

366 

367 except Exception as exc: # noqa: BLE001 — top-level task runner must capture all failures to mark job as failed 

368 logger.exception("Export job %s failed: %s", job.job_id, exc) 

369 await self._fail(job, str(exc)) 

370 

371 # ------------------------------------------------------------------ 

372 # Status / query 

373 # ------------------------------------------------------------------ 

374 

375 async def get_status(self, job_id: str) -> dict[str, Any] | None: 

376 """Return current job status as a JSON-safe dict. 

377 

378 Args: 

379 job_id: JobProtocol identifier returned by :meth:`enqueue_import` / 

380 :meth:`enqueue_export`. 

381 

382 Returns: 

383 Status dict or ``None`` if job_id not found. 

384 """ 

385 job = self._jobs.get(job_id) 

386 return job.to_dict() if job else None 

387 

388 async def get_job(self, job_id: str) -> BackgroundJob | None: 

389 """Return the :class:`BackgroundJob` for *job_id*, or ``None``.""" 

390 return self._jobs.get(job_id) 

391 

392 def list_jobs( 

393 self, 

394 *, 

395 resource_type: str | None = None, 

396 job_type: str | None = None, 

397 status: str | None = None, 

398 ) -> list[BackgroundJob]: 

399 """Return jobs matching the given filters. 

400 

401 Args: 

402 resource_type: Filter by resource type. 

403 job_type: ``"import"`` or ``"export"``. 

404 status: Filter by status string. 

405 """ 

406 jobs = list(self._jobs.values()) 

407 if resource_type: 

408 jobs = [j for j in jobs if j.resource_type == resource_type] 

409 if job_type: 

410 jobs = [j for j in jobs if j.job_type == job_type] 

411 if status: 

412 jobs = [j for j in jobs if j.status == status] 

413 return sorted(jobs, key=lambda j: j.created_at, reverse=True) 

414 

415 async def cancel(self, job_id: str) -> bool: 

416 """Cancel a pending or running job. 

417 

418 Args: 

419 job_id: JobProtocol to cancel. 

420 

421 Returns: 

422 ``True`` if the job was cancelled, ``False`` if not found or 

423 already finished. 

424 """ 

425 job = self._jobs.get(job_id) 

426 if job is None or job.status in ("completed", "failed"): 

427 return False 

428 

429 task = self._tasks.get(job_id) 

430 if task and not task.done(): 

431 task.cancel() 

432 

433 job.status = "failed" 

434 job.error = "Cancelled by user" 

435 job.completed_at = datetime.now(UTC) 

436 return True 

437 

438 

439__all__ = [ 

440 "BackgroundJob", 

441 "BackgroundJobService", 

442 "JobNotFoundError", 

443]