Coverage for gco/services/central_queue_worker.py: 98.10%

245 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +0000

1"""Lease-fenced regional worker for the global DynamoDB Job queue.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import contextlib 

7import logging 

8import os 

9import uuid 

10from dataclasses import dataclass 

11from datetime import UTC, datetime 

12from typing import Any 

13 

14from kubernetes.client.rest import ApiException 

15 

16from gco.services.manifest_processor import ( 

17 ManifestProcessor, 

18 QueuedJobNotCreatedError, 

19 RetryableQueuedJobApplyError, 

20) 

21from gco.services.spot_price_gate import SpotPriceGate, should_persist_observation 

22from gco.services.structured_logging import sanitize_log_value 

23from gco.services.template_store import JobStatus, JobStore 

24 

25# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

26# Generated at (UTC): 2026-07-18T01:03:40Z 

27# Flowchart(s) generated from this file: 

28# * ``process_queued_jobs_once`` -> ``diagrams/code_diagrams/gco/services/central_queue_worker.process_queued_jobs_once.html`` 

29# (PNG: ``diagrams/code_diagrams/gco/services/central_queue_worker.process_queued_jobs_once.png``) 

30# * ``reconcile_active_jobs_once`` -> ``diagrams/code_diagrams/gco/services/central_queue_worker.reconcile_active_jobs_once.html`` 

31# (PNG: ``diagrams/code_diagrams/gco/services/central_queue_worker.reconcile_active_jobs_once.png``) 

32# Regenerate with ``python diagrams/code_diagrams/generate.py``. 

33# <pyflowchart-code-diagram> END 

34 

35 

36logger = logging.getLogger(__name__) 

37 

38_TERMINAL_STATUSES = frozenset({JobStatus.SUCCEEDED.value, JobStatus.FAILED.value}) 

39_MAX_ERROR_LENGTH = 2_000 

40 

41 

42def _utc_now() -> str: 

43 return datetime.now(UTC).isoformat() 

44 

45 

46def _bounded_error(value: object) -> str: 

47 """Bound user/runtime error text before persisting it in DynamoDB.""" 

48 text = str(value) 

49 return text if len(text) <= _MAX_ERROR_LENGTH else f"{text[:_MAX_ERROR_LENGTH]}...[truncated]" 

50 

51 

52def _worker_identity(region: str) -> str: 

53 """Return a process-unique owner including region and Kubernetes pod identity.""" 

54 pod_identity = os.getenv("POD_UID") or os.getenv("HOSTNAME") or "local" 

55 return f"{region}/{pod_identity}/{uuid.uuid4().hex}" 

56 

57 

58async def _lease_heartbeat( 

59 store: JobStore, 

60 *, 

61 job_id: str, 

62 target_region: str, 

63 claimed_by: str, 

64 claim_token: str, 

65 claim_generation: int, 

66 interval_seconds: float, 

67 done: asyncio.Event, 

68 lost: asyncio.Event, 

69) -> None: 

70 """Renew one active apply lease until completion or fencing.""" 

71 while not done.is_set(): 

72 try: 

73 await asyncio.wait_for(done.wait(), timeout=interval_seconds) 

74 return 

75 except TimeoutError: 

76 pass 

77 

78 try: 

79 renewed = await asyncio.to_thread( 

80 store.renew_claim, 

81 job_id, 

82 target_region, 

83 claimed_by, 

84 claim_token, 

85 claim_generation, 

86 ) 

87 except asyncio.CancelledError: 

88 raise 

89 except Exception: # noqa: BLE001 - loss must fence the in-flight result 

90 logger.exception( 

91 "Lease renewal failed for central queue job %s", 

92 sanitize_log_value(job_id), 

93 ) 

94 lost.set() 

95 return 

96 if not renewed: 

97 logger.warning( 

98 "Central queue job %s lost claim generation %d", 

99 sanitize_log_value(job_id), 

100 claim_generation, 

101 ) 

102 lost.set() 

103 return 

104 

105 

106async def _stop_heartbeat(task: asyncio.Task[None], done: asyncio.Event) -> None: 

107 done.set() 

108 with contextlib.suppress(asyncio.CancelledError): 

109 await task 

110 

111 

112async def _defer_price_gated_job( 

113 store: JobStore, 

114 gate: SpotPriceGate, 

115 queued_job: dict[str, Any], 

116 job_id: str, 

117) -> dict[str, Any] | None: 

118 """Return a deferral record when the job's spot price gate is closed. 

119 

120 ``None`` means the job carries no gate or its gate is open — dispatch 

121 proceeds. Closed gates optionally persist a throttled observation so 

122 ``gco queue get`` can show why the job is waiting. 

123 """ 

124 decision = await asyncio.to_thread(gate.evaluate, queued_job) 

125 if decision is None or not decision.gated: 

126 return None 

127 if should_persist_observation(queued_job): 

128 observed = ( 

129 f"{decision.observed_price:.6f}" if decision.observed_price is not None else "unknown" 

130 ) 

131 try: 

132 await asyncio.to_thread( 

133 store.record_spot_gate_observation, 

134 job_id, 

135 observed_price=observed, 

136 ) 

137 except Exception: # noqa: BLE001 - observations are advisory only 

138 logger.exception( 

139 "Failed to persist spot gate observation for %s", 

140 sanitize_log_value(job_id), 

141 ) 

142 logger.info( 

143 "Deferring price-gated central queue job %s: %s", 

144 sanitize_log_value(job_id), 

145 decision.reason, 

146 ) 

147 return { 

148 "job_id": job_id, 

149 "status": "price_gated", 

150 "instance_type": decision.instance_type, 

151 "max_spot_price": decision.max_price, 

152 "observed_spot_price": decision.observed_price, 

153 "reason": decision.reason, 

154 } 

155 

156 

157async def process_queued_jobs_once( 

158 processor: ManifestProcessor, 

159 store: JobStore, 

160 *, 

161 limit: int, 

162 owner_id: str | None = None, 

163 lease_renewal_seconds: float | None = None, 

164 stop_event: asyncio.Event | None = None, 

165 spot_gate: SpotPriceGate | None = None, 

166) -> tuple[int, list[dict[str, Any]]]: 

167 """Claim and apply one bounded batch for the processor's region. 

168 

169 Price-gated jobs whose spot cap is not currently met are deferred (left 

170 queued) without consuming the apply budget. To keep a run of gated 

171 high-priority jobs from starving dispatchable work behind them, the 

172 candidate fetch is wider than ``limit``; at most ``limit`` jobs are 

173 claimed/applied per pass. 

174 """ 

175 owner = owner_id or _worker_identity(processor.region) 

176 renewal_seconds = lease_renewal_seconds or max( 

177 5.0, 

178 min(float(store.claim_lease_seconds) / 3.0, 60.0), 

179 ) 

180 gate = spot_gate or SpotPriceGate(processor.region) 

181 migration = await asyncio.to_thread( 

182 store.migrate_legacy_records_for_region, 

183 processor.region, 

184 max(limit * 20, 100), 

185 ) 

186 migrated = int(migration.get("migrated", 0)) 

187 migration_failed = int(migration.get("failed", 0)) 

188 if migrated or migration_failed: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true

189 logger.warning( 

190 "Central queue migration for %s: backfilled=%d safely_failed=%d complete=%s", 

191 processor.region, 

192 migrated, 

193 migration_failed, 

194 migration.get("complete", False), 

195 ) 

196 fetch_limit = min(max(limit * 4, 20), 100) 

197 queued_jobs = await asyncio.to_thread( 

198 store.get_queued_jobs_for_region, 

199 processor.region, 

200 fetch_limit, 

201 ) 

202 processed: list[dict[str, Any]] = [] 

203 attempted = 0 

204 

205 for queued_job in queued_jobs: 

206 if stop_event is not None and stop_event.is_set(): 

207 break 

208 if attempted >= limit: 

209 break 

210 job_id = str(queued_job.get("job_id", "")) 

211 if not job_id: 

212 logger.error("Ignoring central queue record without a job_id") 

213 continue 

214 

215 deferral = await _defer_price_gated_job(store, gate, queued_job, job_id) 

216 if deferral is not None: 

217 processed.append(deferral) 

218 continue 

219 attempted += 1 

220 

221 claimed: dict[str, Any] | None = None 

222 try: 

223 claimed = await asyncio.to_thread( 

224 store.claim_job, 

225 job_id, 

226 processor.region, 

227 owner, 

228 ) 

229 if not claimed: 

230 continue 

231 claim_token = str(claimed.get("claim_token") or "") 

232 claim_generation = int(claimed.get("claim_generation", 0)) 

233 if not claim_token or claim_generation <= 0: 

234 raise RuntimeError("JobStore returned an incomplete fenced claim") 

235 

236 applying = await asyncio.to_thread( 

237 store.transition_job, 

238 job_id, 

239 target_region=processor.region, 

240 expected_status=JobStatus.CLAIMED, 

241 status=JobStatus.APPLYING, 

242 message="Applying deterministic Kubernetes Job", 

243 claimed_by=owner, 

244 claim_token=claim_token, 

245 claim_generation=claim_generation, 

246 ) 

247 if not applying: 

248 processed.append({"job_id": job_id, "status": "fenced"}) 

249 continue 

250 

251 manifest = claimed.get("manifest") 

252 namespace = claimed.get("namespace") 

253 if not isinstance(manifest, dict) or not isinstance(namespace, str): 

254 raise QueuedJobNotCreatedError( 

255 "Queued job contains an invalid manifest or namespace" 

256 ) 

257 

258 heartbeat_done = asyncio.Event() 

259 claim_lost = asyncio.Event() 

260 heartbeat = asyncio.create_task( 

261 _lease_heartbeat( 

262 store, 

263 job_id=job_id, 

264 target_region=processor.region, 

265 claimed_by=owner, 

266 claim_token=claim_token, 

267 claim_generation=claim_generation, 

268 interval_seconds=renewal_seconds, 

269 done=heartbeat_done, 

270 lost=claim_lost, 

271 ), 

272 name=f"central-queue-lease-{job_id}", 

273 ) 

274 try: 

275 resource = await asyncio.to_thread( 

276 processor.apply_queued_job, 

277 manifest, 

278 namespace, 

279 job_id, 

280 ) 

281 finally: 

282 await _stop_heartbeat(heartbeat, heartbeat_done) 

283 

284 if claim_lost.is_set(): 

285 processed.append({"job_id": job_id, "status": "fenced"}) 

286 continue 

287 

288 pending = await asyncio.to_thread( 

289 store.transition_job, 

290 job_id, 

291 target_region=processor.region, 

292 expected_status=JobStatus.APPLYING, 

293 status=JobStatus.PENDING, 

294 message="Applied to Kubernetes, waiting for scheduling", 

295 k8s_job_name=resource.name, 

296 k8s_job_namespace=resource.namespace, 

297 k8s_job_uid=resource.uid, 

298 claimed_by=owner, 

299 claim_token=claim_token, 

300 claim_generation=claim_generation, 

301 ) 

302 if pending: 

303 processed.append( 

304 { 

305 "job_id": job_id, 

306 "status": "applied", 

307 "k8s_job_name": resource.name, 

308 "k8s_job_uid": resource.uid, 

309 } 

310 ) 

311 else: 

312 processed.append({"job_id": job_id, "status": "fenced"}) 

313 except RetryableQueuedJobApplyError as exc: 

314 # The API may have accepted a deterministic create before the 

315 # response was lost. Keep APPLYING fenced and let lease recovery 

316 # return it to QUEUED, where the next attempt adopts by full queue ID. 

317 error = _bounded_error(exc) 

318 logger.warning( 

319 "Deferring central queue job %s after an inconclusive Kubernetes result", 

320 sanitize_log_value(job_id), 

321 ) 

322 processed.append({"job_id": job_id, "status": "retryable", "error": error}) 

323 except Exception as exc: # noqa: BLE001 - isolate malformed/permanent records 

324 error = _bounded_error(exc) 

325 logger.exception( 

326 "Failed to process central queue job %s", 

327 sanitize_log_value(job_id), 

328 ) 

329 claim = claimed 

330 token = claim.get("claim_token") if isinstance(claim, dict) else None 

331 generation = claim.get("claim_generation") if isinstance(claim, dict) else None 

332 if token and generation: 

333 transition_options: dict[str, Any] = {} 

334 if isinstance(exc, QueuedJobNotCreatedError): 

335 transition_options["workload_not_created"] = True 

336 try: 

337 failed = await asyncio.to_thread( 

338 store.transition_job, 

339 job_id, 

340 target_region=processor.region, 

341 expected_status=JobStatus.APPLYING, 

342 status=JobStatus.FAILED, 

343 message="Failed to apply deterministic Kubernetes Job", 

344 error=error, 

345 claimed_by=owner, 

346 claim_token=str(token), 

347 claim_generation=int(generation), 

348 **transition_options, 

349 ) 

350 except Exception: # noqa: BLE001 - lease recovery remains the fallback 

351 logger.exception( 

352 "Unable to persist failure for central queue job %s", 

353 sanitize_log_value(job_id), 

354 ) 

355 failed = None 

356 processed.append( 

357 {"job_id": job_id, "status": "failed" if failed else "fenced", "error": error} 

358 ) 

359 else: 

360 processed.append({"job_id": job_id, "status": "fenced", "error": error}) 

361 

362 return len(queued_jobs), processed 

363 

364 

365def _observed_job_state(job: Any) -> tuple[str, str | None]: 

366 """Map a Kubernetes Job object to the central queue lifecycle.""" 

367 status = job.status 

368 for condition in status.conditions or []: 

369 if condition.type == "Complete" and condition.status == "True": 

370 return JobStatus.SUCCEEDED.value, None 

371 if condition.type == "Failed" and condition.status == "True": 

372 detail = condition.message or condition.reason or "Kubernetes Job failed" 

373 return JobStatus.FAILED.value, _bounded_error(detail) 

374 if (status.active or 0) > 0: 

375 return JobStatus.RUNNING.value, None 

376 return JobStatus.PENDING.value, None 

377 

378 

379async def reconcile_active_jobs_once( 

380 processor: ManifestProcessor, 

381 store: JobStore, 

382 *, 

383 limit: int, 

384 stop_event: asyncio.Event | None = None, 

385) -> int: 

386 """Reconcile pending/running queue records with their exact Kubernetes Jobs.""" 

387 jobs = await asyncio.to_thread(store.get_active_jobs_for_region, processor.region, limit) 

388 transitions = 0 

389 

390 for queued_job in jobs: 

391 if stop_event is not None and stop_event.is_set(): 

392 break 

393 job_id = str(queued_job.get("job_id", "")) 

394 job_name = queued_job.get("k8s_job_name") 

395 namespace = queued_job.get("k8s_job_namespace") 

396 expected_uid = str(queued_job.get("k8s_job_uid") or "") 

397 current = str(queued_job.get("status") or "") 

398 if ( 

399 not job_id 

400 or not isinstance(job_name, str) 

401 or not isinstance(namespace, str) 

402 or not expected_uid 

403 or current not in {JobStatus.PENDING.value, JobStatus.RUNNING.value} 

404 ): 

405 logger.error( 

406 "Ignoring active queue record %s with incomplete Kubernetes identity", job_id 

407 ) 

408 continue 

409 

410 observed: str 

411 error: str | None 

412 try: 

413 k8s_job = await asyncio.to_thread( 

414 processor.read_queued_job, 

415 job_name, 

416 namespace, 

417 ) 

418 except ApiException as exc: 

419 if exc.status == 404: 

420 observed, error = JobStatus.FAILED.value, "Kubernetes Job no longer exists" 

421 else: 

422 logger.warning( 

423 "Unable to reconcile central queue job %s: %s", 

424 sanitize_log_value(job_id), 

425 exc, 

426 ) 

427 continue 

428 except Exception as exc: # noqa: BLE001 - reconciliation is best-effort per record 

429 logger.warning( 

430 "Unable to reconcile central queue job %s: %s", 

431 sanitize_log_value(job_id), 

432 exc, 

433 ) 

434 continue 

435 else: 

436 actual_uid = str(getattr(k8s_job.metadata, "uid", "") or "") 

437 if actual_uid != expected_uid: 

438 observed, error = JobStatus.FAILED.value, "Kubernetes Job identity changed" 

439 else: 

440 observed, error = _observed_job_state(k8s_job) 

441 

442 if observed == current: 

443 continue 

444 if observed not in { 444 ↛ 449line 444 didn't jump to line 449 because the condition on line 444 was never true

445 JobStatus.PENDING.value, 

446 JobStatus.RUNNING.value, 

447 *_TERMINAL_STATUSES, 

448 }: 

449 continue 

450 if observed not in { 

451 status.value 

452 for status in ( 

453 JobStatus.RUNNING, 

454 JobStatus.SUCCEEDED, 

455 JobStatus.FAILED, 

456 ) 

457 }: 

458 # Running Jobs never regress to pending. 

459 continue 

460 

461 message = { 

462 JobStatus.RUNNING.value: "Kubernetes Job is running", 

463 JobStatus.SUCCEEDED.value: "Kubernetes Job completed successfully", 

464 JobStatus.FAILED.value: "Kubernetes Job failed", 

465 }[observed] 

466 updated = await asyncio.to_thread( 

467 store.transition_job, 

468 job_id, 

469 target_region=processor.region, 

470 expected_status=current, 

471 status=observed, 

472 message=message, 

473 error=error, 

474 expected_k8s_uid=expected_uid, 

475 ) 

476 if updated: 

477 transitions += 1 

478 

479 return transitions 

480 

481 

482@dataclass 

483class CentralQueueWorker: 

484 """Continuously activate and reconcile Jobs for one regional cluster.""" 

485 

486 processor: ManifestProcessor 

487 store: JobStore 

488 poll_interval_seconds: float = 10.0 

489 batch_size: int = 5 

490 reconcile_limit: int = 100 

491 lease_renewal_seconds: float | None = None 

492 owner_id: str | None = None 

493 

494 def __post_init__(self) -> None: 

495 self.poll_interval_seconds = min(max(float(self.poll_interval_seconds), 1.0), 300.0) 

496 self.batch_size = min(max(int(self.batch_size), 1), 20) 

497 self.reconcile_limit = min(max(int(self.reconcile_limit), 1), 500) 

498 default_renewal = max(5.0, min(float(self.store.claim_lease_seconds) / 3.0, 60.0)) 

499 requested_renewal = ( 

500 default_renewal 

501 if self.lease_renewal_seconds is None 

502 else float(self.lease_renewal_seconds) 

503 ) 

504 self.lease_renewal_seconds = min( 

505 max(requested_renewal, 1.0), 

506 max(float(self.store.claim_lease_seconds) / 2.0, 1.0), 

507 ) 

508 self.owner_id = self.owner_id or _worker_identity(self.processor.region) 

509 self._stop_event = asyncio.Event() 

510 # One gate per worker so its price cache spans polling passes. 

511 self._spot_gate = SpotPriceGate(self.processor.region) 

512 self.running = False 

513 self.stopping = False 

514 self.last_pass_started_at: str | None = None 

515 self.last_successful_pass_at: str | None = None 

516 self.last_error: str | None = None 

517 

518 def stop(self) -> None: 

519 """Stop accepting new records and finish the current bounded SDK call.""" 

520 self.stopping = True 

521 self._stop_event.set() 

522 

523 def health(self) -> dict[str, Any]: 

524 """Return operator-safe worker health without exposing claim tokens.""" 

525 return { 

526 "enabled": True, 

527 "running": self.running, 

528 "stopping": self.stopping, 

529 "owner": self.owner_id, 

530 "last_pass_started_at": self.last_pass_started_at, 

531 "last_successful_pass_at": self.last_successful_pass_at, 

532 "last_error": self.last_error, 

533 } 

534 

535 async def run(self) -> None: 

536 """Run until stopped; isolate pass failures and keep polling.""" 

537 self.running = True 

538 logger.info( 

539 "Central queue worker started for %s (interval=%ss, batch=%d)", 

540 self.processor.region, 

541 self.poll_interval_seconds, 

542 self.batch_size, 

543 ) 

544 try: 

545 while not self._stop_event.is_set(): 

546 self.last_pass_started_at = _utc_now() 

547 try: 

548 recovered = await asyncio.to_thread( 

549 self.store.requeue_expired_jobs, 

550 self.processor.region, 

551 self.reconcile_limit, 

552 ) 

553 if self._stop_event.is_set(): 553 ↛ 554line 553 didn't jump to line 554 because the condition on line 553 was never true

554 break 

555 polled, processed = await process_queued_jobs_once( 

556 self.processor, 

557 self.store, 

558 limit=self.batch_size, 

559 owner_id=self.owner_id, 

560 lease_renewal_seconds=self.lease_renewal_seconds, 

561 stop_event=self._stop_event, 

562 spot_gate=self._spot_gate, 

563 ) 

564 transitions = 0 

565 if not self._stop_event.is_set(): 

566 transitions = await reconcile_active_jobs_once( 

567 self.processor, 

568 self.store, 

569 limit=self.reconcile_limit, 

570 stop_event=self._stop_event, 

571 ) 

572 self.last_successful_pass_at = _utc_now() 

573 self.last_error = None 

574 if recovered or polled or transitions: 

575 logger.info( 

576 "Central queue pass: recovered=%d polled=%d processed=%d transitions=%d", 

577 recovered, 

578 polled, 

579 len(processed), 

580 transitions, 

581 ) 

582 except asyncio.CancelledError: 

583 raise 

584 except Exception as exc: # noqa: BLE001 - transient pass failures are retried 

585 self.last_error = _bounded_error(exc) 

586 logger.exception("Central queue worker pass failed") 

587 

588 with contextlib.suppress(TimeoutError): 

589 await asyncio.wait_for( 

590 self._stop_event.wait(), 

591 timeout=self.poll_interval_seconds, 

592 ) 

593 finally: 

594 self.running = False 

595 logger.info("Central queue worker stopped for %s", self.processor.region)