@@ -92,6 +92,21 @@ def _summarize_failures(failures: list[ExportFailure], *, limit: int = 10) -> st
9292class 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