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.
- {durabletask-1.5.0 → durabletask-1.6.0}/PKG-INFO +3 -1
- durabletask-1.6.0/durabletask/extensions/history_export/__init__.py +80 -0
- durabletask-1.6.0/durabletask/extensions/history_export/_constants.py +34 -0
- durabletask-1.6.0/durabletask/extensions/history_export/_internal.py +38 -0
- durabletask-1.6.0/durabletask/extensions/history_export/_logging.py +19 -0
- durabletask-1.6.0/durabletask/extensions/history_export/activities.py +341 -0
- durabletask-1.6.0/durabletask/extensions/history_export/azure_blob.py +195 -0
- durabletask-1.6.0/durabletask/extensions/history_export/client.py +486 -0
- durabletask-1.6.0/durabletask/extensions/history_export/entity.py +341 -0
- durabletask-1.6.0/durabletask/extensions/history_export/exceptions.py +55 -0
- durabletask-1.6.0/durabletask/extensions/history_export/models.py +594 -0
- durabletask-1.6.0/durabletask/extensions/history_export/orchestrator.py +412 -0
- durabletask-1.6.0/durabletask/extensions/history_export/serialization.py +182 -0
- durabletask-1.6.0/durabletask/extensions/history_export/transitions.py +80 -0
- durabletask-1.6.0/durabletask/extensions/history_export/writer.py +122 -0
- durabletask-1.6.0/durabletask/internal/orchestrator_service_pb2.py +330 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/orchestrator_service_pb2.pyi +19 -6
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/worker.py +55 -46
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask.egg-info/PKG-INFO +3 -1
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask.egg-info/SOURCES.txt +14 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask.egg-info/requires.txt +3 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/pyproject.toml +4 -1
- durabletask-1.5.0/durabletask/internal/orchestrator_service_pb2.py +0 -322
- {durabletask-1.5.0 → durabletask-1.6.0}/LICENSE +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/README.md +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/__init__.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/client.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/__init__.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/durable_entity.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/entity_context.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/entity_instance_id.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/entity_lock.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/entity_metadata.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/entities/entity_operation_failed_exception.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/extensions/__init__.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/extensions/azure_blob_payloads/__init__.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/extensions/azure_blob_payloads/blob_payload_store.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/extensions/azure_blob_payloads/options.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/grpc_options.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/history.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/client_helpers.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/entity_state_shim.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/exceptions.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/grpc_interceptor.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/grpc_resiliency.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/helpers.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/history_helpers.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/json_encode_output_exception.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/orchestration_entity_context.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/orchestrator_service_pb2_grpc.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/proto_task_hub_sidecar_service_stub.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/shared.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/internal/tracing.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/payload/__init__.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/payload/helpers.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/payload/store.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/py.typed +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/task.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/testing/__init__.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask/testing/in_memory_backend.py +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask.egg-info/dependency_links.txt +0 -0
- {durabletask-1.5.0 → durabletask-1.6.0}/durabletask.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
+
}
|