durabletask 1.5.0__tar.gz → 1.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {durabletask-1.5.0 → durabletask-1.6.0}/PKG-INFO +3 -1
  2. durabletask-1.6.0/durabletask/extensions/history_export/__init__.py +80 -0
  3. durabletask-1.6.0/durabletask/extensions/history_export/_constants.py +34 -0
  4. durabletask-1.6.0/durabletask/extensions/history_export/_internal.py +38 -0
  5. durabletask-1.6.0/durabletask/extensions/history_export/_logging.py +19 -0
  6. durabletask-1.6.0/durabletask/extensions/history_export/activities.py +341 -0
  7. durabletask-1.6.0/durabletask/extensions/history_export/azure_blob.py +195 -0
  8. durabletask-1.6.0/durabletask/extensions/history_export/client.py +486 -0
  9. durabletask-1.6.0/durabletask/extensions/history_export/entity.py +341 -0
  10. durabletask-1.6.0/durabletask/extensions/history_export/exceptions.py +55 -0
  11. durabletask-1.6.0/durabletask/extensions/history_export/models.py +594 -0
  12. durabletask-1.6.0/durabletask/extensions/history_export/orchestrator.py +412 -0
  13. durabletask-1.6.0/durabletask/extensions/history_export/serialization.py +182 -0
  14. durabletask-1.6.0/durabletask/extensions/history_export/transitions.py +80 -0
  15. durabletask-1.6.0/durabletask/extensions/history_export/writer.py +122 -0
  16. durabletask-1.6.0/durabletask/internal/orchestrator_service_pb2.py +330 -0
  17. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/orchestrator_service_pb2.pyi +19 -6
  18. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/worker.py +55 -46
  19. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask.egg-info/PKG-INFO +3 -1
  20. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask.egg-info/SOURCES.txt +14 -0
  21. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask.egg-info/requires.txt +3 -0
  22. {durabletask-1.5.0 → durabletask-1.6.0}/pyproject.toml +4 -1
  23. durabletask-1.5.0/durabletask/internal/orchestrator_service_pb2.py +0 -322
  24. {durabletask-1.5.0 → durabletask-1.6.0}/LICENSE +0 -0
  25. {durabletask-1.5.0 → durabletask-1.6.0}/README.md +0 -0
  26. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/__init__.py +0 -0
  27. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/client.py +0 -0
  28. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/__init__.py +0 -0
  29. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/durable_entity.py +0 -0
  30. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/entity_context.py +0 -0
  31. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/entity_instance_id.py +0 -0
  32. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/entity_lock.py +0 -0
  33. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/entity_metadata.py +0 -0
  34. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/entity_operation_failed_exception.py +0 -0
  35. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/extensions/__init__.py +0 -0
  36. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/extensions/azure_blob_payloads/__init__.py +0 -0
  37. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/extensions/azure_blob_payloads/blob_payload_store.py +0 -0
  38. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/extensions/azure_blob_payloads/options.py +0 -0
  39. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/grpc_options.py +0 -0
  40. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/history.py +0 -0
  41. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/client_helpers.py +0 -0
  42. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/entity_state_shim.py +0 -0
  43. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/exceptions.py +0 -0
  44. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/grpc_interceptor.py +0 -0
  45. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/grpc_resiliency.py +0 -0
  46. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/helpers.py +0 -0
  47. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/history_helpers.py +0 -0
  48. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/json_encode_output_exception.py +0 -0
  49. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/orchestration_entity_context.py +0 -0
  50. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/orchestrator_service_pb2_grpc.py +0 -0
  51. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/proto_task_hub_sidecar_service_stub.py +0 -0
  52. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/shared.py +0 -0
  53. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/tracing.py +0 -0
  54. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/payload/__init__.py +0 -0
  55. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/payload/helpers.py +0 -0
  56. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/payload/store.py +0 -0
  57. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/py.typed +0 -0
  58. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/task.py +0 -0
  59. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/testing/__init__.py +0 -0
  60. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/testing/in_memory_backend.py +0 -0
  61. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask.egg-info/dependency_links.txt +0 -0
  62. {durabletask-1.5.0 → durabletask-1.6.0}/durabletask.egg-info/top_level.txt +0 -0
  63. {durabletask-1.5.0 → durabletask-1.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durabletask
3
- Version: 1.5.0
3
+ Version: 1.6.0
4
4
  Summary: A Durable Task Client SDK for Python
5
5
  License: MIT License
6
6
 
@@ -42,6 +42,8 @@ Requires-Dist: opentelemetry-api>=1.0.0; extra == "opentelemetry"
42
42
  Requires-Dist: opentelemetry-sdk>=1.0.0; extra == "opentelemetry"
43
43
  Provides-Extra: azure-blob-payloads
44
44
  Requires-Dist: azure-storage-blob[aio]>=12.0.0; extra == "azure-blob-payloads"
45
+ Provides-Extra: history-export-azure
46
+ Requires-Dist: azure-storage-blob[aio]>=12.0.0; extra == "history-export-azure"
45
47
  Dynamic: license-file
46
48
 
47
49
  # Durable Task SDK for Python
@@ -0,0 +1,80 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ """Orchestration history export for Durable Task.
5
+
6
+ This optional extension package provides a workflow for exporting
7
+ orchestration history from terminal instances to a configured
8
+ destination, modeled after the durabletask-dotnet ``ExportHistory``
9
+ package.
10
+
11
+ The core building blocks (models, durable entity, activities,
12
+ orchestrator, and the public client) live in this package and have
13
+ no required runtime dependencies beyond the core SDK. Specific
14
+ destinations (for example Azure Blob Storage) may require optional
15
+ dependencies; see the destination module documentation for details.
16
+ """
17
+
18
+ from durabletask.extensions.history_export._constants import (
19
+ ENTITY_NAME,
20
+ ORCHESTRATOR_NAME,
21
+ orchestrator_instance_id_for,
22
+ )
23
+ from durabletask.extensions.history_export.activities import (
24
+ HistoryExportContext,
25
+ )
26
+ from durabletask.extensions.history_export.client import (
27
+ ExportHistoryClient,
28
+ ExportHistoryJobClient,
29
+ )
30
+ from durabletask.extensions.history_export.entity import ExportJobEntity
31
+ from durabletask.extensions.history_export.exceptions import (
32
+ ExportJobError,
33
+ ExportJobInvalidTransitionError,
34
+ ExportJobNotFoundError,
35
+ )
36
+ from durabletask.extensions.history_export.models import (
37
+ STATE_SCHEMA_VERSION,
38
+ ExportCheckpoint,
39
+ ExportDestination,
40
+ ExportFailure,
41
+ ExportFilter,
42
+ ExportFormat,
43
+ ExportFormatKind,
44
+ ExportJobConfiguration,
45
+ ExportJobCreationOptions,
46
+ ExportJobDescription,
47
+ ExportJobQuery,
48
+ ExportJobState,
49
+ ExportJobStatus,
50
+ ExportMode,
51
+ )
52
+ from durabletask.extensions.history_export.writer import HistoryWriter
53
+
54
+ __all__ = [
55
+ "ENTITY_NAME",
56
+ "ORCHESTRATOR_NAME",
57
+ "STATE_SCHEMA_VERSION",
58
+ "ExportCheckpoint",
59
+ "ExportDestination",
60
+ "ExportFailure",
61
+ "ExportFilter",
62
+ "ExportFormat",
63
+ "ExportFormatKind",
64
+ "ExportHistoryClient",
65
+ "ExportHistoryJobClient",
66
+ "ExportJobConfiguration",
67
+ "ExportJobCreationOptions",
68
+ "ExportJobDescription",
69
+ "ExportJobEntity",
70
+ "ExportJobError",
71
+ "ExportJobInvalidTransitionError",
72
+ "ExportJobNotFoundError",
73
+ "ExportJobQuery",
74
+ "ExportJobState",
75
+ "ExportJobStatus",
76
+ "ExportMode",
77
+ "HistoryExportContext",
78
+ "HistoryWriter",
79
+ "orchestrator_instance_id_for",
80
+ ]
@@ -0,0 +1,34 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ """Stable, cross-cutting constants for the history-export extension.
5
+
6
+ These constants are imported by both the entity and the orchestrator
7
+ modules. Keeping them in their own module avoids the circular import
8
+ that would arise if either of those modules imported the other.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ ENTITY_NAME = "ExportJobEntity"
14
+ """Logical name of the export-job durable entity."""
15
+
16
+ ORCHESTRATOR_NAME = "export_job_orchestrator"
17
+ """Function-derived name of the export-job orchestrator."""
18
+
19
+ ORCHESTRATOR_INSTANCE_ID_PREFIX = "export-job-"
20
+ """Prefix applied to deterministic orchestrator instance IDs."""
21
+
22
+
23
+ def orchestrator_instance_id_for(job_id: str) -> str:
24
+ """Return the deterministic orchestrator instance ID for *job_id*.
25
+
26
+ All export-job orchestrators share a stable instance-ID pattern so
27
+ that public clients can reliably correlate a job ID with the
28
+ orchestrator driving it (for logs, monitoring, restart, etc.).
29
+ Matches the .NET ``ExportHistoryConstants.GetOrchestratorInstanceId``
30
+ pattern.
31
+ """
32
+ if not job_id:
33
+ raise ValueError("job_id must be a non-empty string")
34
+ return f"{ORCHESTRATOR_INSTANCE_ID_PREFIX}{job_id}"
@@ -0,0 +1,38 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ """Package-internal helpers shared across the history-export modules.
5
+
6
+ Names here have no leading underscore so they can be imported by sibling
7
+ modules without tripping pyright's ``reportPrivateUsage`` check. They
8
+ remain package-private by convention: nothing in this module is exported
9
+ from :mod:`durabletask.extensions.history_export.__init__`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime, timezone
15
+
16
+
17
+ def dt_to_iso(value: datetime | None) -> str | None:
18
+ """Normalize *value* to a UTC ISO-8601 string (or ``None``)."""
19
+ if value is None:
20
+ return None
21
+ if value.tzinfo is None:
22
+ value = value.replace(tzinfo=timezone.utc)
23
+ else:
24
+ value = value.astimezone(timezone.utc)
25
+ return value.isoformat()
26
+
27
+
28
+ def dt_from_iso(value: str | None) -> datetime | None:
29
+ """Parse *value* as an ISO-8601 timestamp, defaulting naive values to UTC."""
30
+ if value is None:
31
+ return None
32
+ parsed = datetime.fromisoformat(value)
33
+ if parsed.tzinfo is None:
34
+ parsed = parsed.replace(tzinfo=timezone.utc)
35
+ return parsed
36
+
37
+
38
+ __all__ = ["dt_from_iso", "dt_to_iso"]
@@ -0,0 +1,19 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ """Shared logger for the history-export extension.
5
+
6
+ Submodules that emit log records should import :data:`logger` from
7
+ this module rather than calling :func:`logging.getLogger` themselves.
8
+ This keeps every emit attributed to the same logger name so that
9
+ callers can configure / filter the extension's output in one place.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+
16
+ logger = logging.getLogger("durabletask.extensions.history_export")
17
+ """Module-wide logger for the history-export extension."""
18
+
19
+ __all__ = ["logger"]
@@ -0,0 +1,341 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ """Activities for the history export workflow.
5
+
6
+ Two activities cooperate to drive an export job:
7
+
8
+ * ``list_terminal_instances`` — wraps
9
+ :meth:`TaskHubGrpcClient.list_instance_ids` to fetch one page of
10
+ terminal instance IDs that match the job's filter.
11
+
12
+ * ``export_instance_history`` — fetches the full history for a single
13
+ instance via :meth:`TaskHubGrpcClient.get_orchestration_history`,
14
+ serializes it with the configured format, and writes the resulting
15
+ blob through a :class:`HistoryWriter`.
16
+
17
+ The client and writer are not serializable, so they cannot be passed
18
+ through orchestrator inputs. Instead, the public client registers a
19
+ module-level :class:`HistoryExportContext` once at worker startup.
20
+ The activities resolve their dependencies from that context at
21
+ execution time. This is acceptable because activities run in-process
22
+ within the worker that registered them.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ from collections.abc import Mapping
29
+ from dataclasses import dataclass
30
+ from datetime import datetime, timezone
31
+ from typing import Any, cast
32
+
33
+ from durabletask import client as client_module
34
+ from durabletask import task
35
+ from durabletask import worker as worker_module
36
+
37
+ from durabletask.extensions.history_export._internal import dt_from_iso
38
+ from durabletask.extensions.history_export.models import (
39
+ ExportFormat,
40
+ ExportFormatKind,
41
+ )
42
+ from durabletask.extensions.history_export.serialization import (
43
+ content_encoding_for,
44
+ content_type_for,
45
+ file_extension_for,
46
+ orchestration_state_to_dict,
47
+ serialize_history,
48
+ )
49
+ from durabletask.extensions.history_export.writer import HistoryWriter
50
+
51
+
52
+ # The set of runtime statuses considered "terminal" by the export
53
+ # activity's safety guard. Matches the .NET ``IsCompleted`` helper.
54
+ _TERMINAL_RUNTIME_STATUSES: frozenset[client_module.OrchestrationStatus] = frozenset({
55
+ client_module.OrchestrationStatus.COMPLETED,
56
+ client_module.OrchestrationStatus.FAILED,
57
+ client_module.OrchestrationStatus.TERMINATED,
58
+ })
59
+
60
+
61
+ # The activity name registered with the worker is simply ``fn.__name__``
62
+ # (see :func:`durabletask.task.get_name`). These constants exist so
63
+ # downstream code (the orchestrator, tests) can refer to the names
64
+ # symbolically without re-deriving them from the function objects.
65
+ LIST_TERMINAL_INSTANCES_ACTIVITY = "list_terminal_instances"
66
+ EXPORT_INSTANCE_HISTORY_ACTIVITY = "export_instance_history"
67
+
68
+
69
+ @dataclass
70
+ class HistoryExportContext:
71
+ """Runtime dependencies shared by all history-export activities."""
72
+
73
+ client: client_module.TaskHubGrpcClient
74
+ writer: HistoryWriter
75
+
76
+
77
+ _context: HistoryExportContext | None = None
78
+
79
+
80
+ def bind_context(context: HistoryExportContext) -> None:
81
+ """Install the runtime dependencies for the history-export activities.
82
+
83
+ The bound context is process-wide. Calling this more than once in
84
+ the same process — for example by constructing two
85
+ :class:`ExportHistoryClient` instances with different writers —
86
+ silently replaces the previously-bound writer for *all* in-flight
87
+ activities. Such a rebind emits a logger warning so the
88
+ misconfiguration is visible at runtime.
89
+ """
90
+ global _context
91
+ if _context is not None and _context is not context:
92
+ from durabletask.extensions.history_export._logging import logger
93
+ logger.warning(
94
+ "history_export.bind_context() replacing an existing bound "
95
+ "context (writer=%r); only one writer can be active per process. "
96
+ "Run a separate worker process per writer if you need multiple "
97
+ "destinations.",
98
+ type(context.writer).__name__,
99
+ )
100
+ _context = context
101
+
102
+
103
+ def clear_context() -> None:
104
+ """Remove the bound context. Useful for tests."""
105
+ global _context
106
+ _context = None
107
+
108
+
109
+ def _require_context() -> HistoryExportContext:
110
+ if _context is None:
111
+ raise RuntimeError(
112
+ "history-export activities invoked without a bound context; "
113
+ "call bind_context(HistoryExportContext(...)) before starting the worker"
114
+ )
115
+ return _context
116
+
117
+
118
+ # ----------------------------------------------------------------------
119
+ # Activity bodies
120
+ # ----------------------------------------------------------------------
121
+
122
+ def list_terminal_instances(
123
+ _: task.ActivityContext, input: Mapping[str, Any],
124
+ ) -> dict[str, Any]:
125
+ """Activity: fetch one page of terminal instance IDs."""
126
+ ctx = _require_context()
127
+
128
+ raw_statuses = input.get("runtime_status")
129
+ runtime_status_names: list[str] | None = (
130
+ list(raw_statuses) if raw_statuses is not None else None
131
+ )
132
+ completed_time_from = dt_from_iso(input.get("completed_time_from"))
133
+ completed_time_to = dt_from_iso(input.get("completed_time_to"))
134
+ page_size_raw = input.get("page_size")
135
+ page_size: int | None = int(page_size_raw) if page_size_raw is not None else None
136
+ continuation_token_raw = input.get("continuation_token")
137
+ continuation_token: str | None = (
138
+ str(continuation_token_raw) if continuation_token_raw is not None else None
139
+ )
140
+
141
+ if completed_time_from is None:
142
+ raise ValueError("list_terminal_instances requires 'completed_time_from'")
143
+
144
+ runtime_status: list[client_module.OrchestrationStatus] | None = None
145
+ if runtime_status_names is not None:
146
+ runtime_status = [
147
+ client_module.OrchestrationStatus[name] for name in runtime_status_names
148
+ ]
149
+
150
+ page = ctx.client.list_instance_ids(
151
+ runtime_status=runtime_status,
152
+ completed_time_from=completed_time_from,
153
+ completed_time_to=completed_time_to,
154
+ page_size=page_size,
155
+ continuation_token=continuation_token,
156
+ )
157
+
158
+ return {
159
+ "instance_ids": list(page.items),
160
+ "continuation_token": page.continuation_token,
161
+ }
162
+
163
+
164
+ def export_instance_history(
165
+ _: task.ActivityContext, input: Mapping[str, Any],
166
+ ) -> dict[str, Any]:
167
+ """Activity: serialize and write one instance's history."""
168
+ ctx = _require_context()
169
+
170
+ instance_id = str(input["instance_id"])
171
+ fmt_input = input.get("format") or {
172
+ "kind": ExportFormatKind.JSONL_GZIP.value,
173
+ "schema_version": "1.0",
174
+ }
175
+ if not isinstance(fmt_input, Mapping):
176
+ raise TypeError("format must be a mapping")
177
+ fmt = ExportFormat.from_dict(cast("Mapping[str, Any]", fmt_input))
178
+ destination_raw: Mapping[str, Any] = input.get("destination") or {}
179
+ container_raw: Any = destination_raw.get("container")
180
+ if not container_raw:
181
+ raise ValueError("destination.container is required")
182
+ container: str = str(container_raw)
183
+ prefix_raw: Any = destination_raw.get("prefix")
184
+ prefix: str | None = str(prefix_raw) if prefix_raw is not None else None
185
+
186
+ try:
187
+ # Resolve the instance's terminal metadata first. If the
188
+ # instance was purged, deleted, or has somehow re-entered a
189
+ # non-terminal state between ``list_terminal_instances`` and
190
+ # now, we refuse to write a partial/empty blob and surface a
191
+ # specific failure to the orchestrator. Matches the .NET
192
+ # ``ExportInstanceHistoryActivity`` guard.
193
+ state = ctx.client.get_orchestration_state(
194
+ instance_id, fetch_payloads=True,
195
+ )
196
+ if state is None:
197
+ return {
198
+ "instance_id": instance_id,
199
+ "success": False,
200
+ "error": (
201
+ f"instance {instance_id!r} no longer exists or has been "
202
+ "purged"
203
+ ),
204
+ }
205
+ if state.runtime_status not in _TERMINAL_RUNTIME_STATUSES:
206
+ return {
207
+ "instance_id": instance_id,
208
+ "success": False,
209
+ "error": (
210
+ f"instance {instance_id!r} is no longer terminal "
211
+ f"(runtime_status={state.runtime_status.name})"
212
+ ),
213
+ }
214
+
215
+ events = ctx.client.get_orchestration_history(instance_id)
216
+ # The exported blob is self-describing: it carries the
217
+ # serialized ``OrchestrationState`` metadata alongside the
218
+ # event list. Matches the .NET behavior.
219
+ metadata = orchestration_state_to_dict(state)
220
+ payload = serialize_history(
221
+ events,
222
+ instance_id=instance_id,
223
+ fmt=fmt,
224
+ metadata=metadata,
225
+ )
226
+ # Blob name is a SHA-256 hash of the instance's terminal
227
+ # timestamp + instance ID (matches the .NET
228
+ # ``ExportInstanceHistoryActivity`` scheme). This means:
229
+ # • Two exports of the *same* completion produce the same
230
+ # blob name (idempotent under retry when ``overwrite=True``).
231
+ # • An instance re-exported after a later completion lands
232
+ # at a new path rather than overwriting the previous one.
233
+ # • Instance IDs that differ only by ``/`` no longer collide
234
+ # under the old ``.replace("/", "_")`` transform.
235
+ blob_name = _blob_name_for(
236
+ instance_id=instance_id,
237
+ last_updated_at=state.last_updated_at,
238
+ prefix=prefix,
239
+ fmt=fmt,
240
+ )
241
+ ctx.writer.write(
242
+ instance_id=instance_id,
243
+ container=container,
244
+ blob_name=blob_name,
245
+ payload=payload,
246
+ content_type=content_type_for(fmt),
247
+ content_encoding=content_encoding_for(fmt),
248
+ # Standard hook downstream consumers use to scan a
249
+ # container without parsing each blob body. Matches the
250
+ # .NET writer's ``Metadata["instanceId"]`` convention.
251
+ metadata={"instance_id": instance_id},
252
+ )
253
+ except Exception as ex: # noqa: BLE001 - reported back via return value
254
+ return {
255
+ "instance_id": instance_id,
256
+ "success": False,
257
+ "error": f"{type(ex).__name__}: {ex}",
258
+ }
259
+
260
+ return {"instance_id": instance_id, "success": True, "error": None}
261
+
262
+
263
+ # ----------------------------------------------------------------------
264
+ # Helpers
265
+ # ----------------------------------------------------------------------
266
+
267
+ def _blob_name_for(
268
+ *,
269
+ instance_id: str,
270
+ last_updated_at: datetime,
271
+ prefix: str | None,
272
+ fmt: ExportFormat,
273
+ ) -> str:
274
+ """Return the destination blob name for one exported instance.
275
+
276
+ Matches the .NET ``ExportInstanceHistoryActivity.GenerateBlobFileName``
277
+ scheme: lowercase-hex SHA-256 of
278
+ ``f"{last_updated_at:O}|{instance_id}"`` with the format-appropriate
279
+ extension appended, optionally namespaced under the configured
280
+ destination prefix. Hash byte-equivalence with .NET output
281
+ requires matching the .NET ``DateTimeOffset.ToString("O")`` format
282
+ exactly (see :func:`_dotnet_o_format`).
283
+ """
284
+ timestamp_str = _dotnet_o_format(last_updated_at)
285
+ hash_input = f"{timestamp_str}|{instance_id}"
286
+ digest = hashlib.sha256(hash_input.encode("utf-8")).hexdigest()
287
+ ext = file_extension_for(fmt)
288
+ blob_name = f"{digest}{ext}"
289
+ if prefix:
290
+ return f"{prefix.rstrip('/')}/{blob_name}"
291
+ return blob_name
292
+
293
+
294
+ def _dotnet_o_format(dt: datetime) -> str:
295
+ """Format *dt* to match .NET ``DateTimeOffset.ToString("O")``.
296
+
297
+ .NET's round-trip format is ``yyyy-MM-ddTHH:mm:ss.fffffffK`` for
298
+ ``DateTimeOffset``, where ``K`` resolves to ``+HH:MM`` / ``-HH:MM``
299
+ and fractional seconds always render with seven digits (100-ns
300
+ ticks resolution). Python :class:`datetime.datetime` only carries
301
+ microsecond precision (six digits), so the seventh digit is always
302
+ a trailing zero. Naive datetimes are assumed UTC.
303
+ """
304
+ if dt.tzinfo is None:
305
+ dt = dt.replace(tzinfo=timezone.utc)
306
+ base = dt.strftime("%Y-%m-%dT%H:%M:%S")
307
+ fractional = f"{dt.microsecond:06d}0"
308
+ offset = dt.utcoffset()
309
+ if offset is None:
310
+ offset_str = "+00:00"
311
+ else:
312
+ total_minutes = int(offset.total_seconds() // 60)
313
+ sign = "+" if total_minutes >= 0 else "-"
314
+ total_minutes = abs(total_minutes)
315
+ offset_str = f"{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}"
316
+ return f"{base}.{fractional}{offset_str}"
317
+
318
+
319
+ def register(worker_instance: worker_module.TaskHubGrpcWorker) -> None:
320
+ """Convenience helper to register both activities on *worker*."""
321
+ worker_instance.add_activity(list_terminal_instances)
322
+ worker_instance.add_activity(export_instance_history)
323
+
324
+
325
+ # Used by the orchestrator to build a fresh activity input from the
326
+ # resolved job configuration without leaking model objects.
327
+ def build_list_activity_input(
328
+ *,
329
+ runtime_status_names: list[str] | None,
330
+ completed_time_from: datetime,
331
+ completed_time_to: datetime | None,
332
+ page_size: int,
333
+ continuation_token: str | None,
334
+ ) -> dict[str, Any]:
335
+ return {
336
+ "runtime_status": runtime_status_names,
337
+ "completed_time_from": completed_time_from.isoformat(),
338
+ "completed_time_to": completed_time_to.isoformat() if completed_time_to else None,
339
+ "page_size": page_size,
340
+ "continuation_token": continuation_token,
341
+ }