Skip to content

Commit 513a49a

Browse files
committed
PR Feedback 3
1 parent dff8536 commit 513a49a

10 files changed

Lines changed: 355 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,21 @@ ADDED
2020
(tail terminal instances indefinitely until stopped via `delete_job`).
2121
Exported blobs are self-describing: each blob carries an explicit
2222
`schema_version`, the orchestration's `OrchestrationState` metadata, and
23-
the full ordered event list. The export workflow retries each instance up
23+
the full ordered event list. Each exported blob also carries
24+
`{"instance_id": <id>}` as destination-side metadata (the Azure writer
25+
persists this as Azure Blob metadata) so consumers can scan a container
26+
without parsing each blob body. The export workflow retries each instance up
2427
to 3 times with exponential backoff (15s/30s/60s), retries failed batches
2528
up to 3 times, caps in-flight exports via `max_parallel_exports`
2629
(default 32), continues-as-new every 5 page cycles to bound orchestrator
2730
history, and re-fetches entity state at the top of every page loop so
2831
external delete or mark-failed signals stop the orchestrator cleanly.
32+
`delete_job` actively tears the job down: it clears the entity state,
33+
terminates the driving orchestrator, waits briefly for it to settle, and
34+
purges its orchestration history so a re-created job with the same ID
35+
starts from a clean slate. Per-instance exports refuse to write a blob
36+
when the target instance has been purged or has re-entered a non-terminal
37+
state, surfacing the skipped instance as a per-batch failure.
2938
Job state lives in a durable entity with an explicit state-transition
3039
matrix (ACTIVE / COMPLETED / FAILED); invalid transitions raise
3140
`ExportJobInvalidTransitionError`. Persisted entity state uses a

durabletask/extensions/history_export/activities.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@
4848
from durabletask.extensions.history_export.writer import HistoryWriter
4949

5050

51+
# The set of runtime statuses considered "terminal" by the export
52+
# activity's safety guard. Matches the .NET ``IsCompleted`` helper.
53+
_TERMINAL_RUNTIME_STATUSES: frozenset[client_module.OrchestrationStatus] = frozenset({
54+
client_module.OrchestrationStatus.COMPLETED,
55+
client_module.OrchestrationStatus.FAILED,
56+
client_module.OrchestrationStatus.TERMINATED,
57+
})
58+
59+
5160
# The activity name registered with the worker is simply ``fn.__name__``
5261
# (see :func:`durabletask.task.get_name`). These constants exist so
5362
# downstream code (the orchestrator, tests) can refer to the names
@@ -174,13 +183,39 @@ def export_instance_history(
174183
prefix: str | None = str(prefix_raw) if prefix_raw is not None else None
175184

176185
try:
177-
events = ctx.client.get_orchestration_history(instance_id)
178-
# Fetch the orchestration's terminal metadata too so the
179-
# exported blob is self-describing (matches the .NET behavior).
186+
# Resolve the instance's terminal metadata first. If the
187+
# instance was purged, deleted, or has somehow re-entered a
188+
# non-terminal state between ``list_terminal_instances`` and
189+
# now, we refuse to write a partial/empty blob and surface a
190+
# specific failure to the orchestrator. Matches the .NET
191+
# ``ExportInstanceHistoryActivity`` guard.
180192
state = ctx.client.get_orchestration_state(
181193
instance_id, fetch_payloads=True,
182194
)
183-
metadata = orchestration_state_to_dict(state) if state is not None else None
195+
if state is None:
196+
return {
197+
"instance_id": instance_id,
198+
"success": False,
199+
"error": (
200+
f"instance {instance_id!r} no longer exists or has been "
201+
"purged"
202+
),
203+
}
204+
if state.runtime_status not in _TERMINAL_RUNTIME_STATUSES:
205+
return {
206+
"instance_id": instance_id,
207+
"success": False,
208+
"error": (
209+
f"instance {instance_id!r} is no longer terminal "
210+
f"(runtime_status={state.runtime_status.name})"
211+
),
212+
}
213+
214+
events = ctx.client.get_orchestration_history(instance_id)
215+
# The exported blob is self-describing: it carries the
216+
# serialized ``OrchestrationState`` metadata alongside the
217+
# event list. Matches the .NET behavior.
218+
metadata = orchestration_state_to_dict(state)
184219
payload = serialize_history(
185220
events,
186221
instance_id=instance_id,
@@ -195,6 +230,10 @@ def export_instance_history(
195230
payload=payload,
196231
content_type=content_type_for(fmt),
197232
content_encoding=content_encoding_for(fmt),
233+
# Standard hook downstream consumers use to scan a
234+
# container without parsing each blob body. Matches the
235+
# .NET writer's ``Metadata["instanceId"]`` convention.
236+
metadata={"instance_id": instance_id},
198237
)
199238
except Exception as ex: # noqa: BLE001 - reported back via return value
200239
return {

durabletask/extensions/history_export/azure_blob.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from __future__ import annotations
1919

20+
from collections.abc import Mapping
2021
from dataclasses import dataclass, field
2122
from typing import Any
2223

@@ -135,6 +136,7 @@ def write(
135136
payload: bytes,
136137
content_type: str,
137138
content_encoding: str | None,
139+
metadata: Mapping[str, str] | None = None,
138140
) -> None:
139141
del instance_id # included by the protocol but not needed here
140142
# This writer pins to the container configured at construction
@@ -158,11 +160,19 @@ def write(
158160
if content_encoding
159161
else ContentSettings(content_type=content_type)
160162
)
163+
# Azure Blob Storage requires the metadata dict to be a plain
164+
# ``dict[str, str]`` (the SDK does its own validation). Copy
165+
# whatever the activity passed into the shape the underlying
166+
# SDK expects, and pass ``None`` through unchanged so blobs
167+
# written via :meth:`write` without metadata behave exactly
168+
# the same as they did before this kwarg existed.
169+
blob_metadata = dict(metadata) if metadata else None
161170
container_client.upload_blob(
162171
name=blob_name,
163172
data=payload,
164173
overwrite=self._options.overwrite,
165174
content_settings=content_settings,
175+
metadata=blob_metadata,
166176
)
167177

168178
# ------------------------------------------------------------------

durabletask/extensions/history_export/client.py

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
from datetime import datetime, timezone
6060
from typing import Any, cast
6161

62+
import grpc
63+
6264
from durabletask import client as client_module
6365
from durabletask import entities
6466
from durabletask import worker as worker_module
@@ -93,6 +95,31 @@
9395
_TERMINAL_STATUSES = frozenset({ExportJobStatus.COMPLETED, ExportJobStatus.FAILED})
9496
_ENTITY_ID_PREFIX = f"@{ENTITY_NAME.lower()}@"
9597

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+
96123

97124
__all__ = ["ExportHistoryClient", "ExportHistoryJobClient"]
98125

@@ -311,23 +338,72 @@ def wait_for_job(
311338
time.sleep(poll_interval)
312339

313340
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.
325360
326361
This does NOT delete blobs already written to the destination.
327362
"""
328363
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.
329367
self._client.signal_entity(entity_id, ExportJobEntity.OP_DELETE)
330368

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+
331407
def cancel_job(self, job_id: str) -> None:
332408
"""Alias for :meth:`delete_job`.
333409

durabletask/extensions/history_export/entity.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,15 @@ def get(self, _: Any = None) -> dict[str, Any] | None:
211211
def commit_checkpoint(self, payload: Mapping[str, Any]) -> dict[str, Any] | None:
212212
state = self._load()
213213
if state is None:
214-
raise ValueError("Cannot commit_checkpoint on uninitialized export job")
214+
# The entity was deleted between the orchestrator's
215+
# mid-loop ``get`` call and this checkpoint (the race
216+
# window is small but real for CONTINUOUS jobs cancelled
217+
# via :meth:`ExportHistoryClient.delete_job`). Return
218+
# ``None`` so the orchestrator's ``call_entity`` resolves
219+
# cleanly and the loop exits via its normal
220+
# "entity gone" path rather than raising and triggering
221+
# an outer ``mark_failed`` on an already-deleted entity.
222+
return None
215223
job_id = self._job_id()
216224

217225
# commit_checkpoint may transition ACTIVE -> ACTIVE (no-op) or
@@ -266,7 +274,10 @@ def commit_checkpoint(self, payload: Mapping[str, Any]) -> dict[str, Any] | None
266274
def mark_completed(self, _: Any = None) -> dict[str, Any] | None:
267275
state = self._load()
268276
if state is None:
269-
raise ValueError("Cannot mark_completed on uninitialized export job")
277+
# Entity vanished mid-flight (see ``commit_checkpoint``
278+
# for the race description); silently succeed so the
279+
# orchestrator's final ``call_entity`` does not raise.
280+
return None
270281
job_id = self._job_id()
271282
assert_valid_transition(
272283
self.OP_MARK_COMPLETED, state.status, ExportJobStatus.COMPLETED,
@@ -285,7 +296,11 @@ def mark_failed(
285296
) -> dict[str, Any] | None:
286297
state = self._load()
287298
if state is None:
288-
raise ValueError("Cannot mark_failed on uninitialized export job")
299+
# Entity vanished mid-flight (see ``commit_checkpoint``
300+
# for the race description); silently succeed so the
301+
# orchestrator's best-effort failure report does not
302+
# raise on an already-deleted entity.
303+
return None
289304
job_id = self._job_id()
290305
assert_valid_transition(
291306
self.OP_MARK_FAILED, state.status, ExportJobStatus.FAILED, job_id=job_id,

0 commit comments

Comments
 (0)