|
59 | 59 | from datetime import datetime, timezone |
60 | 60 | from typing import Any, cast |
61 | 61 |
|
| 62 | +import grpc |
| 63 | + |
62 | 64 | from durabletask import client as client_module |
63 | 65 | from durabletask import entities |
64 | 66 | from durabletask import worker as worker_module |
|
93 | 95 | _TERMINAL_STATUSES = frozenset({ExportJobStatus.COMPLETED, ExportJobStatus.FAILED}) |
94 | 96 | _ENTITY_ID_PREFIX = f"@{ENTITY_NAME.lower()}@" |
95 | 97 |
|
| 98 | +# Max seconds :meth:`ExportHistoryClient.delete_job` waits for the |
| 99 | +# driving orchestrator to terminate before continuing on to purge. |
| 100 | +# Sized to be longer than a single ``commit_checkpoint`` round-trip |
| 101 | +# but short enough that a stuck orchestrator cannot block the caller |
| 102 | +# indefinitely. |
| 103 | +_DELETE_WAIT_TIMEOUT_SECONDS = 30.0 |
| 104 | + |
| 105 | + |
| 106 | +def _grpc_status(ex: grpc.RpcError) -> grpc.StatusCode | None: |
| 107 | + """Return the gRPC status code of *ex*, or ``None`` if it is not set. |
| 108 | +
|
| 109 | + The ``code()`` method is declared on the runtime ``grpc.Call`` |
| 110 | + mixin rather than on :class:`grpc.RpcError` itself, so we go |
| 111 | + through ``getattr`` to keep both pyright and runtime happy when a |
| 112 | + test backend raises a bare ``RpcError``. |
| 113 | + """ |
| 114 | + code = getattr(ex, "code", None) |
| 115 | + if not callable(code): |
| 116 | + return None |
| 117 | + try: |
| 118 | + result = code() |
| 119 | + except Exception: # noqa: BLE001 - defensive, never re-raise here |
| 120 | + return None |
| 121 | + return result if isinstance(result, grpc.StatusCode) else None |
| 122 | + |
96 | 123 |
|
97 | 124 | __all__ = ["ExportHistoryClient", "ExportHistoryJobClient"] |
98 | 125 |
|
@@ -311,23 +338,72 @@ def wait_for_job( |
311 | 338 | time.sleep(poll_interval) |
312 | 339 |
|
313 | 340 | def delete_job(self, job_id: str) -> None: |
314 | | - """Request deletion of the export-job entity, clearing its state. |
315 | | -
|
316 | | - This call is **best-effort and fire-and-forget**: it enqueues a |
317 | | - ``delete`` signal on the entity but does not wait for the |
318 | | - entity dispatcher to process it. Callers that need |
319 | | - confirmation should poll :meth:`get_job` and wait for it to |
320 | | - return ``None``. |
321 | | -
|
322 | | - The driving orchestrator will detect the deletion at its next |
323 | | - loop iteration (via :meth:`OrchestrationContext.call_entity`) |
324 | | - and exit cleanly without issuing further signals. |
| 341 | + """Stop and delete an export job. |
| 342 | +
|
| 343 | + The call performs the full teardown sequence (matching the |
| 344 | + .NET ``DefaultExportHistoryJobClient.DeleteAsync`` flow): |
| 345 | +
|
| 346 | + 1. Signal the entity to clear its persisted state |
| 347 | + (``ExportJobEntity.OP_DELETE``). |
| 348 | + 2. Terminate the driving orchestrator so it stops issuing |
| 349 | + further activity calls and entity signals. |
| 350 | + 3. Wait briefly for the orchestrator to actually reach a |
| 351 | + terminal state. |
| 352 | + 4. Purge the orchestration history so a re-created job with |
| 353 | + the same ID can start from a clean slate. |
| 354 | +
|
| 355 | + Steps 2–4 are best-effort: each tolerates a missing |
| 356 | + orchestrator (the job may never have run, or already been |
| 357 | + purged) by swallowing gRPC ``NOT_FOUND`` errors. Step 3 |
| 358 | + tolerates a slow termination by logging and continuing rather |
| 359 | + than blocking the caller indefinitely. |
325 | 360 |
|
326 | 361 | This does NOT delete blobs already written to the destination. |
327 | 362 | """ |
328 | 363 | entity_id = entities.EntityInstanceId(ENTITY_NAME, job_id) |
| 364 | + orch_instance_id = orchestrator_instance_id_for(job_id) |
| 365 | + |
| 366 | + # Step 1: clear the persisted entity state. |
329 | 367 | self._client.signal_entity(entity_id, ExportJobEntity.OP_DELETE) |
330 | 368 |
|
| 369 | + # Step 2: terminate the driving orchestrator so it stops |
| 370 | + # issuing activity calls and entity signals. |
| 371 | + try: |
| 372 | + self._client.terminate_orchestration( |
| 373 | + orch_instance_id, recursive=True, |
| 374 | + ) |
| 375 | + except grpc.RpcError as ex: |
| 376 | + if _grpc_status(ex) != grpc.StatusCode.NOT_FOUND: |
| 377 | + raise |
| 378 | + |
| 379 | + # Step 3: wait briefly for the orchestration to settle so the |
| 380 | + # subsequent purge actually removes its history. Capped by |
| 381 | + # ``_DELETE_WAIT_TIMEOUT_SECONDS`` so a stuck orchestrator |
| 382 | + # cannot block the caller indefinitely; a slow termination is |
| 383 | + # logged rather than re-raised. |
| 384 | + try: |
| 385 | + self._client.wait_for_orchestration_completion( |
| 386 | + orch_instance_id, timeout=_DELETE_WAIT_TIMEOUT_SECONDS, |
| 387 | + ) |
| 388 | + except TimeoutError: |
| 389 | + logger.warning( |
| 390 | + "Export job %r orchestrator %r did not terminate within %ss; " |
| 391 | + "continuing with purge anyway", |
| 392 | + job_id, orch_instance_id, _DELETE_WAIT_TIMEOUT_SECONDS, |
| 393 | + ) |
| 394 | + except grpc.RpcError as ex: |
| 395 | + if _grpc_status(ex) != grpc.StatusCode.NOT_FOUND: |
| 396 | + raise |
| 397 | + |
| 398 | + # Step 4: purge the orchestration history. |
| 399 | + try: |
| 400 | + self._client.purge_orchestration( |
| 401 | + orch_instance_id, recursive=True, |
| 402 | + ) |
| 403 | + except grpc.RpcError as ex: |
| 404 | + if _grpc_status(ex) != grpc.StatusCode.NOT_FOUND: |
| 405 | + raise |
| 406 | + |
331 | 407 | def cancel_job(self, job_id: str) -> None: |
332 | 408 | """Alias for :meth:`delete_job`. |
333 | 409 |
|
|
0 commit comments