Skip to content

Commit 5a5f1a3

Browse files
committed
PR Feedback 2
1 parent 5b7d970 commit 5a5f1a3

14 files changed

Lines changed: 622 additions & 577 deletions

File tree

docs/plan-orchestrationHistoryExport.prompt.md

Lines changed: 0 additions & 438 deletions
This file was deleted.

durabletask/extensions/history_export/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
ExportHistoryClient,
2828
ExportHistoryJobClient,
2929
)
30+
from durabletask.extensions.history_export.entity import ExportJobEntity
3031
from durabletask.extensions.history_export.exceptions import (
3132
ExportJobError,
3233
ExportJobInvalidTransitionError,
@@ -65,6 +66,7 @@
6566
"ExportJobConfiguration",
6667
"ExportJobCreationOptions",
6768
"ExportJobDescription",
69+
"ExportJobEntity",
6870
"ExportJobError",
6971
"ExportJobInvalidTransitionError",
7072
"ExportJobNotFoundError",
@@ -76,5 +78,3 @@
7678
"HistoryWriter",
7779
"orchestrator_instance_id_for",
7880
]
79-
80-
PACKAGE_NAME = "durabletask.extensions.history_export"

durabletask/extensions/history_export/activities.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,25 @@ class HistoryExportContext:
6868

6969

7070
def bind_context(context: HistoryExportContext) -> None:
71-
"""Install the runtime dependencies for the history-export activities."""
71+
"""Install the runtime dependencies for the history-export activities.
72+
73+
The bound context is process-wide. Calling this more than once in
74+
the same process — for example by constructing two
75+
:class:`ExportHistoryClient` instances with different writers —
76+
silently replaces the previously-bound writer for *all* in-flight
77+
activities. Such a rebind emits a logger warning so the
78+
misconfiguration is visible at runtime.
79+
"""
7280
global _context
81+
if _context is not None and _context is not context:
82+
from durabletask.extensions.history_export._logging import logger
83+
logger.warning(
84+
"history_export.bind_context() replacing an existing bound "
85+
"context (writer=%r); only one writer can be active per process. "
86+
"Run a separate worker process per writer if you need multiple "
87+
"destinations.",
88+
type(context.writer).__name__,
89+
)
7390
_context = context
7491

7592

durabletask/extensions/history_export/azure_blob.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ class AzureBlobHistoryExportWriterOptions:
5353
(useful for Azurite compatibility).
5454
create_container_if_not_exists: When ``True`` (the default),
5555
ensure the container exists on the first write.
56+
overwrite: When ``True`` (the default), each blob upload
57+
replaces any existing blob of the same name. Set to
58+
``False`` for compliance setups that require
59+
write-once / immutable exports; in that mode the writer
60+
raises if a blob already exists at the target path.
5661
"""
5762

5863
container_name: str
@@ -61,6 +66,7 @@ class AzureBlobHistoryExportWriterOptions:
6166
credential: Any = field(default=None, repr=False)
6267
api_version: str | None = None
6368
create_container_if_not_exists: bool = True
69+
overwrite: bool = True
6470

6571
def __post_init__(self) -> None:
6672
if not self.container_name:
@@ -155,7 +161,7 @@ def write(
155161
container_client.upload_blob(
156162
name=blob_name,
157163
data=payload,
158-
overwrite=True,
164+
overwrite=self._options.overwrite,
159165
content_settings=content_settings,
160166
)
161167

durabletask/extensions/history_export/client.py

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ def create_job(
145145
safely re-create a previously-terminated job.
146146
"""
147147
config = options.to_configuration()
148-
resolved_job_id = job_id or options.job_id or uuid.uuid4().hex
148+
resolved_job_id = job_id or uuid.uuid4().hex
149149
entity_id = entities.EntityInstanceId(ENTITY_NAME, resolved_job_id)
150150
created_at = datetime.now(timezone.utc)
151151
config_dict = config.to_dict()
@@ -156,14 +156,13 @@ def create_job(
156156
# signals are processed in FIFO order by the entity dispatcher.
157157
self._client.signal_entity(
158158
entity_id,
159-
"create",
159+
ExportJobEntity.OP_CREATE,
160160
input={
161161
"config": config_dict,
162162
"created_at": created_at.isoformat(),
163163
},
164164
)
165-
self._client.signal_entity(entity_id, "run")
166-
165+
self._client.signal_entity(entity_id, ExportJobEntity.OP_RUN)
167166
logger.info(
168167
"Submitted export job %r; orchestrator instance ID will be %s",
169168
resolved_job_id, orchestrator_instance_id_for(resolved_job_id),
@@ -184,7 +183,14 @@ def create_job(
184183
)
185184

186185
def get_job(self, job_id: str) -> ExportJobDescription | None:
187-
"""Look up an export job by ID. Returns ``None`` if not found."""
186+
"""Look up an export job by ID. Returns ``None`` if not found.
187+
188+
Note that the lookup-miss contract differs from
189+
:meth:`wait_for_job`: ``get_job`` is a passive read that
190+
returns ``None`` when the entity does not exist, while
191+
``wait_for_job`` raises :class:`ExportJobNotFoundError` after
192+
its timeout if the entity never appears.
193+
"""
188194
entity_id = entities.EntityInstanceId(ENTITY_NAME, job_id)
189195
meta = self._client.get_entity(entity_id, include_state=True)
190196
if meta is None:
@@ -235,18 +241,35 @@ def list_jobs(
235241
continue
236242
raw = meta.get_state(str)
237243
if not raw:
244+
logger.warning(
245+
"list_jobs: skipping export-job entity %r with no "
246+
"persisted state", meta.id.key,
247+
)
238248
continue
239249
try:
240250
state = json.loads(raw)
241-
except (TypeError, ValueError):
251+
except (TypeError, ValueError) as ex:
252+
logger.warning(
253+
"list_jobs: skipping export-job entity %r; failed to "
254+
"parse state JSON (%s)", meta.id.key, ex,
255+
)
242256
continue
243257
if not isinstance(state, dict):
258+
logger.warning(
259+
"list_jobs: skipping export-job entity %r; persisted "
260+
"state is not a JSON object (got %s)",
261+
meta.id.key, type(state).__name__,
262+
)
244263
continue
245264
try:
246265
desc = ExportJobDescription.from_state_dict(
247266
meta.id.key, cast("dict[str, Any]", state),
248267
)
249-
except (KeyError, ValueError):
268+
except (KeyError, ValueError) as ex:
269+
logger.warning(
270+
"list_jobs: skipping export-job entity %r; state did "
271+
"not match the current schema (%s)", meta.id.key, ex,
272+
)
250273
continue
251274
if status_filter is not None and desc.status not in status_filter:
252275
continue
@@ -289,7 +312,13 @@ def wait_for_job(
289312
time.sleep(poll_interval)
290313

291314
def delete_job(self, job_id: str) -> None:
292-
"""Delete the export-job entity, clearing its state.
315+
"""Request deletion of the export-job entity, clearing its state.
316+
317+
This call is **best-effort and fire-and-forget**: it enqueues a
318+
``delete`` signal on the entity but does not wait for the
319+
entity dispatcher to process it. Callers that need
320+
confirmation should poll :meth:`get_job` and wait for it to
321+
return ``None``.
293322
294323
The driving orchestrator will detect the deletion at its next
295324
loop iteration (via :meth:`OrchestrationContext.call_entity`)
@@ -298,7 +327,17 @@ def delete_job(self, job_id: str) -> None:
298327
This does NOT delete blobs already written to the destination.
299328
"""
300329
entity_id = entities.EntityInstanceId(ENTITY_NAME, job_id)
301-
self._client.signal_entity(entity_id, "delete")
330+
self._client.signal_entity(entity_id, ExportJobEntity.OP_DELETE)
331+
332+
def cancel_job(self, job_id: str) -> None:
333+
"""Alias for :meth:`delete_job`.
334+
335+
``CONTINUOUS`` mode has no natural completion, so users
336+
looking to stop a tailing export are likely to look for
337+
``cancel_job`` rather than ``delete_job``. Provided as a thin
338+
alias to make either name discoverable.
339+
"""
340+
self.delete_job(job_id)
302341

303342
# ------------------------------------------------------------------
304343
# Convenience

durabletask/extensions/history_export/entity.py

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,21 @@ def _summarize_failures(failures: list[ExportFailure], *, limit: int = 10) -> st
9292
class ExportJobEntity(entities.DurableEntity):
9393
"""Durable entity that owns the lifecycle state of one export job."""
9494

95+
# ----- operation names ------------------------------------------
96+
#
97+
# Single source of truth for the wire-level entity operation
98+
# names. Clients, the orchestrator, and the transitions matrix
99+
# all import these so a typo in any one call site is impossible.
100+
# Mirrors the .NET ``nameof(this.Create)`` pattern.
101+
102+
OP_CREATE = "create"
103+
OP_GET = "get"
104+
OP_RUN = "run"
105+
OP_COMMIT_CHECKPOINT = "commit_checkpoint"
106+
OP_MARK_COMPLETED = "mark_completed"
107+
OP_MARK_FAILED = "mark_failed"
108+
OP_DELETE = "delete"
109+
95110
# ----- state helpers --------------------------------------------
96111

97112
def _load(self) -> ExportJobState | None:
@@ -123,18 +138,32 @@ def create(self, payload: Mapping[str, Any]) -> dict[str, Any]:
123138
job_id = self._job_id()
124139
current = self._current_status()
125140
assert_valid_transition(
126-
"create", current, ExportJobStatus.PENDING, job_id=job_id,
141+
self.OP_CREATE, current, ExportJobStatus.PENDING, job_id=job_id,
127142
)
128143

129144
config_dict = payload.get("config")
130-
if not config_dict:
145+
if config_dict is None:
131146
raise ValueError("create payload requires 'config'")
132-
config = ExportJobConfiguration.from_dict(config_dict)
147+
if not isinstance(config_dict, Mapping) or not config_dict:
148+
raise ValueError(
149+
"create payload 'config' must be a non-empty mapping"
150+
)
151+
config = ExportJobConfiguration.from_dict(
152+
cast("Mapping[str, Any]", config_dict),
153+
)
133154

134155
created_at_raw = payload.get("created_at")
135156
created_at = dt_from_iso(created_at_raw) if created_at_raw else _utcnow()
136157
assert created_at is not None
137158

159+
# Reviving a terminal job (COMPLETED / FAILED) constructs a
160+
# *fresh* ExportJobState here. That intentionally resets
161+
# every progress field — ``scanned_instances``,
162+
# ``exported_instances``, ``failed_instances``,
163+
# ``checkpoint.last_instance_key``, ``last_checkpoint_time``,
164+
# ``last_error``, and the accumulated ``failures`` list.
165+
# Matches the .NET ``ExportJob.Create`` revive semantics so a
166+
# re-created job starts from a clean slate.
138167
state = ExportJobState(
139168
status=ExportJobStatus.PENDING,
140169
config=config,
@@ -143,6 +172,7 @@ def create(self, payload: Mapping[str, Any]) -> dict[str, Any]:
143172
)
144173
logger.info(
145174
"Created export job %r in status %s", job_id, state.status.value,
175+
extra={"job_id": job_id, "operation": "create"},
146176
)
147177
return self._save(state)
148178

@@ -156,7 +186,7 @@ def run(self, _: Any = None) -> dict[str, Any] | None:
156186
raise ValueError("Cannot run uninitialized export job")
157187
job_id = self._job_id()
158188
assert_valid_transition(
159-
"run", state.status, ExportJobStatus.ACTIVE, job_id=job_id,
189+
self.OP_RUN, state.status, ExportJobStatus.ACTIVE, job_id=job_id,
160190
)
161191

162192
# The entity itself schedules the driving orchestrator. The
@@ -174,17 +204,25 @@ def run(self, _: Any = None) -> dict[str, Any] | None:
174204
logger.info(
175205
"Scheduled orchestrator %s for job %r with instance ID %s",
176206
ORCHESTRATOR_NAME, job_id, instance_id,
207+
extra={"job_id": job_id, "operation": "run"},
177208
)
178209
except Exception as ex: # noqa: BLE001
210+
# Mirror the .NET ExportJob.StartExportOrchestration pattern:
211+
# record the failure on persisted state and return, rather
212+
# than re-raising. Re-raising inside an entity operation
213+
# can cause some entity backends to discard the in-flight
214+
# state mutations, leaving the job stuck in PENDING with no
215+
# error recorded. Returning ensures FAILED + last_error
216+
# actually persist.
179217
state.status = ExportJobStatus.FAILED
180218
state.last_error = (
181219
f"Failed to schedule orchestrator: {type(ex).__name__}: {ex}"
182220
)
183221
logger.exception(
184222
"Failed to schedule orchestrator for export job %r", job_id,
223+
extra={"job_id": job_id, "operation": "run"},
185224
)
186-
self._save(state)
187-
raise
225+
return self._save(state)
188226

189227
state.status = ExportJobStatus.ACTIVE
190228
state.last_error = None
@@ -210,7 +248,7 @@ def commit_checkpoint(self, payload: Mapping[str, Any]) -> dict[str, Any] | None
210248
will_fail = bool(payload.get("mark_failed_on_batch")) and bool(new_failures)
211249
target = ExportJobStatus.FAILED if will_fail else ExportJobStatus.ACTIVE
212250
assert_valid_transition(
213-
"commit_checkpoint", state.status, target, job_id=job_id,
251+
self.OP_COMMIT_CHECKPOINT, state.status, target, job_id=job_id,
214252
)
215253

216254
state.scanned_instances += scanned_delta
@@ -240,6 +278,7 @@ def commit_checkpoint(self, payload: Mapping[str, Any]) -> dict[str, Any] | None
240278
logger.warning(
241279
"Export job %r marked FAILED after batch retries (%d failures)",
242280
job_id, len(new_failures),
281+
extra={"job_id": job_id, "operation": "commit_checkpoint"},
243282
)
244283

245284
return self._save(state)
@@ -250,12 +289,15 @@ def mark_completed(self, _: Any = None) -> dict[str, Any] | None:
250289
raise ValueError("Cannot mark_completed on uninitialized export job")
251290
job_id = self._job_id()
252291
assert_valid_transition(
253-
"mark_completed", state.status, ExportJobStatus.COMPLETED,
292+
self.OP_MARK_COMPLETED, state.status, ExportJobStatus.COMPLETED,
254293
job_id=job_id,
255294
)
256295
state.status = ExportJobStatus.COMPLETED
257296
state.last_error = None
258-
logger.info("Export job %r marked COMPLETED", job_id)
297+
logger.info(
298+
"Export job %r marked COMPLETED", job_id,
299+
extra={"job_id": job_id, "operation": "mark_completed"},
300+
)
259301
return self._save(state)
260302

261303
def mark_failed(
@@ -266,21 +308,28 @@ def mark_failed(
266308
raise ValueError("Cannot mark_failed on uninitialized export job")
267309
job_id = self._job_id()
268310
assert_valid_transition(
269-
"mark_failed", state.status, ExportJobStatus.FAILED, job_id=job_id,
311+
self.OP_MARK_FAILED, state.status, ExportJobStatus.FAILED, job_id=job_id,
270312
)
271313
reason = ""
272314
if payload is not None:
273315
reason = str(payload.get("reason", ""))
274316
state.status = ExportJobStatus.FAILED
275317
state.last_error = reason or None
276-
logger.info("Export job %r marked FAILED: %s", job_id, reason or "(no reason)")
318+
logger.info(
319+
"Export job %r marked FAILED: %s", job_id, reason or "(no reason)",
320+
extra={"job_id": job_id, "operation": "mark_failed"},
321+
)
277322
return self._save(state)
278323

279324
def delete(self, _: Any = None) -> None: # type: ignore[override]
280325
# The base class's delete() calls set_state(None) which is
281326
# exactly what we want for export-job cleanup. ``delete`` is
282327
# always valid regardless of current status.
283-
logger.info("Export job %r deleted", self._job_id())
328+
job_id = self._job_id()
329+
logger.info(
330+
"Export job %r deleted", job_id,
331+
extra={"job_id": job_id, "operation": "delete"},
332+
)
284333
super().delete()
285334

286335

0 commit comments

Comments
 (0)