aga-runtime 0.4.0__py3-none-any.whl
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.
Potentially problematic release.
This version of aga-runtime might be problematic. Click here for more details.
- aga_runtime/__init__.py +99 -0
- aga_runtime/_authoring/__init__.py +3 -0
- aga_runtime/_authoring/adapters.py +132 -0
- aga_runtime/_authoring/catalog.py +304 -0
- aga_runtime/_authoring/contracts.py +168 -0
- aga_runtime/_authoring/definitions.py +280 -0
- aga_runtime/_authoring/lifecycle.py +251 -0
- aga_runtime/_authoring/models.py +62 -0
- aga_runtime/_authoring/operations.py +112 -0
- aga_runtime/_authoring/ports.py +94 -0
- aga_runtime/_authoring/runs.py +149 -0
- aga_runtime/_authoring/scope.py +65 -0
- aga_runtime/_lineage.py +197 -0
- aga_runtime/_version.py +13 -0
- aga_runtime/app.py +241 -0
- aga_runtime/client/__init__.py +5 -0
- aga_runtime/client/_http/__init__.py +3 -0
- aga_runtime/client/_http/capabilities.py +66 -0
- aga_runtime/client/_http/promises.py +77 -0
- aga_runtime/client/_http/runs.py +130 -0
- aga_runtime/client/_http/schedules.py +208 -0
- aga_runtime/client/_http/storage.py +56 -0
- aga_runtime/client/_http/transport.py +173 -0
- aga_runtime/client/http.py +339 -0
- aga_runtime/codec/__init__.py +5 -0
- aga_runtime/codec/cbor.py +99 -0
- aga_runtime/errors.py +128 -0
- aga_runtime/payload/__init__.py +5 -0
- aga_runtime/payload/blob.py +133 -0
- aga_runtime/protocol/__init__.py +5 -0
- aga_runtime/protocol/_runs.py +65 -0
- aga_runtime/protocol/wire.py +320 -0
- aga_runtime/py.typed +0 -0
- aga_runtime/workflow/__init__.py +7 -0
- aga_runtime/workflow/_runtime/__init__.py +3 -0
- aga_runtime/workflow/_runtime/child_runs.py +107 -0
- aga_runtime/workflow/_runtime/children.py +244 -0
- aga_runtime/workflow/_runtime/control.py +52 -0
- aga_runtime/workflow/_runtime/execution.py +66 -0
- aga_runtime/workflow/_runtime/handles.py +220 -0
- aga_runtime/workflow/_runtime/ports.py +61 -0
- aga_runtime/workflow/_runtime/replay.py +338 -0
- aga_runtime/workflow/_runtime/replay_decisions.py +55 -0
- aga_runtime/workflow/_runtime/serialization.py +66 -0
- aga_runtime/workflow/_runtime/signals.py +99 -0
- aga_runtime/workflow/_runtime/waiting.py +132 -0
- aga_runtime/workflow/_worker/__init__.py +3 -0
- aga_runtime/workflow/_worker/checkpoints.py +243 -0
- aga_runtime/workflow/_worker/claims.py +118 -0
- aga_runtime/workflow/_worker/host.py +121 -0
- aga_runtime/workflow/_worker/invocations.py +93 -0
- aga_runtime/workflow/_worker/leases.py +58 -0
- aga_runtime/workflow/_worker/retry.py +49 -0
- aga_runtime/workflow/_worker/roster.py +54 -0
- aga_runtime/workflow/_worker/schedules.py +189 -0
- aga_runtime/workflow/_worker/state.py +120 -0
- aga_runtime/workflow/_worker/steps.py +254 -0
- aga_runtime/workflow/_worker/tasks.py +93 -0
- aga_runtime/workflow/_worker/workflows.py +272 -0
- aga_runtime/workflow/decorators.py +89 -0
- aga_runtime/workflow/saga.py +176 -0
- aga_runtime-0.4.0.dist-info/METADATA +239 -0
- aga_runtime-0.4.0.dist-info/RECORD +65 -0
- aga_runtime-0.4.0.dist-info/WHEEL +4 -0
- aga_runtime-0.4.0.dist-info/licenses/LICENSE +202 -0
aga_runtime/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Aga — durable execution for Python.
|
|
2
|
+
|
|
3
|
+
import aga_runtime as aga
|
|
4
|
+
|
|
5
|
+
app = aga.App("checkout")
|
|
6
|
+
|
|
7
|
+
@app.step()
|
|
8
|
+
def charge(order): ...
|
|
9
|
+
|
|
10
|
+
@app.workflow()
|
|
11
|
+
async def checkout(order):
|
|
12
|
+
return await charge(order)
|
|
13
|
+
|
|
14
|
+
app.serve()
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# The one version string lives in _version.py — metadata, __version__, and
|
|
18
|
+
# the wire handshake all derive from it. See that module for why.
|
|
19
|
+
from ._version import __version__
|
|
20
|
+
from .app import (
|
|
21
|
+
App,
|
|
22
|
+
Approval,
|
|
23
|
+
ExecutionInfo,
|
|
24
|
+
FrameworkAdapter,
|
|
25
|
+
Handle,
|
|
26
|
+
Remote,
|
|
27
|
+
ResourceRef,
|
|
28
|
+
RetryPolicy,
|
|
29
|
+
Step,
|
|
30
|
+
Workflow,
|
|
31
|
+
cancel,
|
|
32
|
+
event,
|
|
33
|
+
info,
|
|
34
|
+
join,
|
|
35
|
+
signal,
|
|
36
|
+
sleep,
|
|
37
|
+
)
|
|
38
|
+
from .errors import (
|
|
39
|
+
AgaError,
|
|
40
|
+
CasConflict,
|
|
41
|
+
Conflict,
|
|
42
|
+
InvalidConfig,
|
|
43
|
+
NondeterminismDetected,
|
|
44
|
+
NotFound,
|
|
45
|
+
PermissionDenied,
|
|
46
|
+
Stale,
|
|
47
|
+
Timeout,
|
|
48
|
+
TooLarge,
|
|
49
|
+
Unavailable,
|
|
50
|
+
Unsupported,
|
|
51
|
+
)
|
|
52
|
+
from .protocol.wire import (
|
|
53
|
+
OVERLAP_ALLOW,
|
|
54
|
+
OVERLAP_SKIP,
|
|
55
|
+
)
|
|
56
|
+
from .workflow._runtime.execution import deadline
|
|
57
|
+
|
|
58
|
+
deadline.__module__ = __name__
|
|
59
|
+
RetryPolicy.__module__ = __name__
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"OVERLAP_ALLOW",
|
|
63
|
+
"OVERLAP_SKIP",
|
|
64
|
+
"App",
|
|
65
|
+
"Approval",
|
|
66
|
+
"CasConflict",
|
|
67
|
+
"Conflict",
|
|
68
|
+
"ExecutionInfo",
|
|
69
|
+
"FrameworkAdapter",
|
|
70
|
+
"Handle",
|
|
71
|
+
"InvalidConfig",
|
|
72
|
+
"NondeterminismDetected",
|
|
73
|
+
"NotFound",
|
|
74
|
+
"AgaError",
|
|
75
|
+
"PermissionDenied",
|
|
76
|
+
"Remote",
|
|
77
|
+
"ResourceRef",
|
|
78
|
+
"RetryPolicy",
|
|
79
|
+
"Stale",
|
|
80
|
+
"Step",
|
|
81
|
+
"Timeout",
|
|
82
|
+
"TooLarge",
|
|
83
|
+
"Unavailable",
|
|
84
|
+
"Unsupported",
|
|
85
|
+
"Workflow",
|
|
86
|
+
"__version__",
|
|
87
|
+
"cancel",
|
|
88
|
+
"deadline",
|
|
89
|
+
"event",
|
|
90
|
+
"info",
|
|
91
|
+
"join",
|
|
92
|
+
"signal",
|
|
93
|
+
"sleep",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
# Importing private lowering helpers loads the ``aga_runtime.workflow`` package as an
|
|
97
|
+
# implementation detail. Do not accidentally advertise that module as the
|
|
98
|
+
# removed root-level ``workflow`` decorator.
|
|
99
|
+
globals().pop("workflow", None)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Atomic framework-adapter registration and ordered hook invocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .catalog import CatalogSnapshot, DefinitionCatalog
|
|
9
|
+
from .ports import AdapterHooks, AppIdentity, RegistrationGate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AdapterRegistry:
|
|
13
|
+
"""Own adapters while delegating definitions and host state explicitly."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
owner: AppIdentity,
|
|
18
|
+
catalog: DefinitionCatalog,
|
|
19
|
+
gate: RegistrationGate,
|
|
20
|
+
):
|
|
21
|
+
self._owner = owner
|
|
22
|
+
self._catalog = catalog
|
|
23
|
+
self._gate = gate
|
|
24
|
+
self._adapters: dict[str, AdapterHooks] = {}
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def values(self) -> tuple[AdapterHooks, ...]:
|
|
28
|
+
return tuple(self._adapters.values())
|
|
29
|
+
|
|
30
|
+
def install(self, adapter: AdapterHooks) -> AdapterHooks:
|
|
31
|
+
with self._gate.adapter_hook(registration=True):
|
|
32
|
+
name = self._validate_shape(adapter)
|
|
33
|
+
if name in self._adapters:
|
|
34
|
+
raise ValueError(f"framework adapter {name!r} is already installed")
|
|
35
|
+
catalog_snapshot = self._catalog.snapshot()
|
|
36
|
+
adapter_snapshot = dict(self._adapters)
|
|
37
|
+
try:
|
|
38
|
+
self._adapters[name] = adapter
|
|
39
|
+
self.call(adapter, "install", required=True)
|
|
40
|
+
except BaseException:
|
|
41
|
+
self._restore(catalog_snapshot, adapter_snapshot)
|
|
42
|
+
raise
|
|
43
|
+
return adapter
|
|
44
|
+
|
|
45
|
+
def validate(self) -> None:
|
|
46
|
+
for adapter in self._adapters.values():
|
|
47
|
+
catalog_snapshot = self._catalog.snapshot()
|
|
48
|
+
adapter_snapshot = dict(self._adapters)
|
|
49
|
+
try:
|
|
50
|
+
with self._gate.adapter_hook(registration=False):
|
|
51
|
+
self.call(adapter, "validate")
|
|
52
|
+
except BaseException:
|
|
53
|
+
self._restore(catalog_snapshot, adapter_snapshot)
|
|
54
|
+
raise
|
|
55
|
+
if not self._catalog.unchanged(catalog_snapshot):
|
|
56
|
+
self._restore(catalog_snapshot, adapter_snapshot)
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
f"framework adapter {adapter.name!r} mutated App registration "
|
|
59
|
+
"during validate; use install(app) for registration"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def call(
|
|
63
|
+
self,
|
|
64
|
+
adapter: AdapterHooks,
|
|
65
|
+
hook_name: str,
|
|
66
|
+
*,
|
|
67
|
+
required: bool = False,
|
|
68
|
+
) -> None:
|
|
69
|
+
hook = getattr(adapter, hook_name, None)
|
|
70
|
+
if hook is None:
|
|
71
|
+
if required:
|
|
72
|
+
raise TypeError(f"framework adapter {adapter.name!r} requires {hook_name}(app)")
|
|
73
|
+
return
|
|
74
|
+
result = hook(self._owner)
|
|
75
|
+
if inspect.isawaitable(result):
|
|
76
|
+
close = getattr(result, "close", None)
|
|
77
|
+
if callable(close):
|
|
78
|
+
close()
|
|
79
|
+
raise TypeError(
|
|
80
|
+
f"framework adapter {adapter.name!r} {hook_name}(app) must be synchronous"
|
|
81
|
+
)
|
|
82
|
+
if result is not None:
|
|
83
|
+
raise TypeError(f"framework adapter {adapter.name!r} {hook_name}(app) must return None")
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _validate_shape(adapter: Any) -> str:
|
|
87
|
+
name = getattr(adapter, "name", "")
|
|
88
|
+
if not isinstance(name, str) or not name or name != name.strip():
|
|
89
|
+
raise ValueError("a framework adapter requires a stable name")
|
|
90
|
+
if not callable(getattr(adapter, "install", None)):
|
|
91
|
+
raise TypeError(f"framework adapter {name!r} requires install(app)")
|
|
92
|
+
for hook_name in ("validate", "start", "stop"):
|
|
93
|
+
hook = getattr(adapter, hook_name, None)
|
|
94
|
+
if hook is not None and not callable(hook):
|
|
95
|
+
raise TypeError(f"framework adapter {name!r} {hook_name} hook must be callable")
|
|
96
|
+
return name
|
|
97
|
+
|
|
98
|
+
def _restore(
|
|
99
|
+
self,
|
|
100
|
+
catalog_snapshot: CatalogSnapshot,
|
|
101
|
+
adapter_snapshot: dict[str, AdapterHooks],
|
|
102
|
+
) -> None:
|
|
103
|
+
self._catalog.restore(catalog_snapshot)
|
|
104
|
+
self._adapters.clear()
|
|
105
|
+
self._adapters.update(adapter_snapshot)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def attach_cleanup_diagnostics(
|
|
109
|
+
error: BaseException,
|
|
110
|
+
stop_errors: list[tuple[str, BaseException]],
|
|
111
|
+
*,
|
|
112
|
+
prefix: str,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Attach cleanup details without replacing the selected primary error."""
|
|
115
|
+
try:
|
|
116
|
+
try:
|
|
117
|
+
detail = "; ".join(f"{name}: {failure}" for name, failure in stop_errors)
|
|
118
|
+
except BaseException:
|
|
119
|
+
detail = "cleanup error details are not printable"
|
|
120
|
+
add_note = getattr(error, "add_note", None)
|
|
121
|
+
if callable(add_note):
|
|
122
|
+
add_note(f"{prefix}: {detail}")
|
|
123
|
+
return
|
|
124
|
+
except BaseException:
|
|
125
|
+
pass
|
|
126
|
+
try:
|
|
127
|
+
error.aga_adapter_stop_errors = tuple(stop_errors) # type: ignore[attr-defined]
|
|
128
|
+
except BaseException:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Owned registry for typed durable definitions and schedules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from ..protocol.wire import MAX_SCHEDULE_REVISION
|
|
10
|
+
from ..workflow._runtime.replay import ReplayRuntime
|
|
11
|
+
from ..workflow._runtime.serialization import JsonCodec
|
|
12
|
+
from ..workflow._runtime.signals import Suspended
|
|
13
|
+
from ..workflow.decorators import RetryPolicy, ScheduleSpec, StepSpec, WorkflowSpec
|
|
14
|
+
from .contracts import FunctionContract
|
|
15
|
+
from .definitions import (
|
|
16
|
+
Remote,
|
|
17
|
+
Step,
|
|
18
|
+
Workflow,
|
|
19
|
+
_Workflow,
|
|
20
|
+
create_remote,
|
|
21
|
+
create_step,
|
|
22
|
+
create_workflow,
|
|
23
|
+
require_registered_step,
|
|
24
|
+
)
|
|
25
|
+
from .models import UNSET, RemoteOptions
|
|
26
|
+
from .ports import AppIdentity, RegistrationGate
|
|
27
|
+
from .scope import ExecutionScope, active_scope
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CatalogSnapshot:
|
|
32
|
+
steps: dict[str, StepSpec]
|
|
33
|
+
workflows: dict[str, WorkflowSpec]
|
|
34
|
+
remotes: dict[tuple[str, str], Remote[Any, Any]]
|
|
35
|
+
schedules: dict[str, ScheduleSpec | None]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DefinitionCatalog:
|
|
39
|
+
"""Own all declarations for one App and their atomic snapshots."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
owner: AppIdentity,
|
|
44
|
+
gate: RegistrationGate,
|
|
45
|
+
codec: JsonCodec,
|
|
46
|
+
):
|
|
47
|
+
self._owner = owner
|
|
48
|
+
self._gate = gate
|
|
49
|
+
self._codec = codec
|
|
50
|
+
self._steps: dict[str, StepSpec] = {}
|
|
51
|
+
self._workflows: dict[str, WorkflowSpec] = {}
|
|
52
|
+
self._remotes: dict[tuple[str, str], Remote[Any, Any]] = {}
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def steps(self) -> dict[str, StepSpec]:
|
|
56
|
+
return self._steps
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def workflows(self) -> dict[str, WorkflowSpec]:
|
|
60
|
+
return self._workflows
|
|
61
|
+
|
|
62
|
+
def step(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
name: str | None,
|
|
66
|
+
target: str | None,
|
|
67
|
+
retry: RetryPolicy | None,
|
|
68
|
+
timeout: int,
|
|
69
|
+
attempt_timeout: int,
|
|
70
|
+
compensate_with: str | Step[Any, Any] | None,
|
|
71
|
+
pivot: bool,
|
|
72
|
+
) -> Callable[[Callable[..., Any]], Step[Any, Any]]:
|
|
73
|
+
def decorate(user_fn: Callable[..., Any]) -> Step[Any, Any]:
|
|
74
|
+
with self._gate.registration():
|
|
75
|
+
step_name = name or getattr(user_fn, "__name__", "")
|
|
76
|
+
if not step_name:
|
|
77
|
+
raise ValueError("a durable step requires a name")
|
|
78
|
+
if step_name in self._steps:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"step {step_name!r} is already registered in App {self._owner.name!r}"
|
|
81
|
+
)
|
|
82
|
+
compensation: str | None
|
|
83
|
+
if compensate_with is None or isinstance(compensate_with, str):
|
|
84
|
+
compensation = compensate_with
|
|
85
|
+
else:
|
|
86
|
+
compensation = require_registered_step(
|
|
87
|
+
compensate_with,
|
|
88
|
+
self._owner,
|
|
89
|
+
).__aga_step_name__
|
|
90
|
+
contract = FunctionContract(user_fn, step_name)
|
|
91
|
+
spec = StepSpec(
|
|
92
|
+
name=step_name,
|
|
93
|
+
fn=lambda *args, **kwargs: contract.invoke(tuple(args), dict(kwargs)),
|
|
94
|
+
target=target,
|
|
95
|
+
retry=retry,
|
|
96
|
+
timeout=timeout,
|
|
97
|
+
attempt_timeout=attempt_timeout,
|
|
98
|
+
compensate_with=compensation,
|
|
99
|
+
pivot=pivot,
|
|
100
|
+
)
|
|
101
|
+
definition = create_step(self._owner, contract, spec)
|
|
102
|
+
self._steps[step_name] = spec
|
|
103
|
+
return definition
|
|
104
|
+
|
|
105
|
+
return decorate
|
|
106
|
+
|
|
107
|
+
def remote(
|
|
108
|
+
self,
|
|
109
|
+
service: str,
|
|
110
|
+
*,
|
|
111
|
+
name: str | None,
|
|
112
|
+
timeout: float | None,
|
|
113
|
+
) -> Callable[[Callable[..., Any]], Remote[Any, Any]]:
|
|
114
|
+
if not service:
|
|
115
|
+
raise ValueError("a remote function requires a service name")
|
|
116
|
+
|
|
117
|
+
def decorate(stub: Callable[..., Any]) -> Remote[Any, Any]:
|
|
118
|
+
with self._gate.registration():
|
|
119
|
+
method = name or getattr(stub, "__name__", "")
|
|
120
|
+
if not method:
|
|
121
|
+
raise ValueError("a remote function requires a method name")
|
|
122
|
+
key = (service, method)
|
|
123
|
+
if key in self._remotes:
|
|
124
|
+
raise ValueError(f"remote {service}.{method} is already declared")
|
|
125
|
+
contract = FunctionContract(stub, f"{service}.{method}")
|
|
126
|
+
parameters = tuple(contract.signature.parameters.values())
|
|
127
|
+
portable = (
|
|
128
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
129
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
130
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
131
|
+
)
|
|
132
|
+
if (
|
|
133
|
+
len(parameters) != 1
|
|
134
|
+
or parameters[0].kind not in portable
|
|
135
|
+
or parameters[0].default is not inspect.Parameter.empty
|
|
136
|
+
):
|
|
137
|
+
raise TypeError(
|
|
138
|
+
f"remote {service}.{method} must declare exactly one required "
|
|
139
|
+
"request parameter for cross-language portability"
|
|
140
|
+
)
|
|
141
|
+
definition = create_remote(
|
|
142
|
+
self._owner,
|
|
143
|
+
service,
|
|
144
|
+
method,
|
|
145
|
+
contract,
|
|
146
|
+
RemoteOptions(timeout=timeout),
|
|
147
|
+
)
|
|
148
|
+
self._remotes[key] = definition
|
|
149
|
+
return definition
|
|
150
|
+
|
|
151
|
+
return decorate
|
|
152
|
+
|
|
153
|
+
def workflow(
|
|
154
|
+
self,
|
|
155
|
+
*,
|
|
156
|
+
name: str | None,
|
|
157
|
+
version: str,
|
|
158
|
+
execution: str,
|
|
159
|
+
target: str | None,
|
|
160
|
+
) -> Callable[[Callable[..., Any]], Workflow[Any, Any]]:
|
|
161
|
+
def decorate(user_fn: Callable[..., Any]) -> Workflow[Any, Any]:
|
|
162
|
+
with self._gate.registration():
|
|
163
|
+
workflow_name = name or getattr(user_fn, "__name__", "")
|
|
164
|
+
if not workflow_name:
|
|
165
|
+
raise ValueError("a durable workflow requires a name")
|
|
166
|
+
if workflow_name in self._workflows:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"workflow {workflow_name!r} is already registered in App "
|
|
169
|
+
f"{self._owner.name!r}"
|
|
170
|
+
)
|
|
171
|
+
if not inspect.iscoroutinefunction(user_fn):
|
|
172
|
+
raise TypeError(f"@app.workflow {workflow_name!r} must be `async def`")
|
|
173
|
+
if execution not in ("async", "async_distributed"):
|
|
174
|
+
raise ValueError(
|
|
175
|
+
"workflow execution must be 'async' or 'async_distributed', "
|
|
176
|
+
f"got {execution!r}"
|
|
177
|
+
)
|
|
178
|
+
internal = "async_sticky" if execution == "async" else "async_distributed"
|
|
179
|
+
contract = FunctionContract(user_fn, workflow_name)
|
|
180
|
+
|
|
181
|
+
async def registered(runtime: ReplayRuntime, param: Any) -> Any:
|
|
182
|
+
args, kwargs = contract.unpack(param)
|
|
183
|
+
scope = ExecutionScope(runtime, self._owner)
|
|
184
|
+
token = active_scope.set(scope)
|
|
185
|
+
try:
|
|
186
|
+
result = contract.invoke(args, kwargs)
|
|
187
|
+
if inspect.isawaitable(result):
|
|
188
|
+
result = await result
|
|
189
|
+
await scope.finish()
|
|
190
|
+
return result
|
|
191
|
+
except Suspended:
|
|
192
|
+
raise
|
|
193
|
+
except Exception:
|
|
194
|
+
scope.cancel_unfinished()
|
|
195
|
+
raise
|
|
196
|
+
finally:
|
|
197
|
+
active_scope.reset(token)
|
|
198
|
+
|
|
199
|
+
spec = WorkflowSpec(
|
|
200
|
+
name=workflow_name,
|
|
201
|
+
fn=registered,
|
|
202
|
+
version=version,
|
|
203
|
+
execution=internal,
|
|
204
|
+
target=target or self._owner.target,
|
|
205
|
+
)
|
|
206
|
+
definition = create_workflow(self._owner, contract, spec)
|
|
207
|
+
self._workflows[workflow_name] = spec
|
|
208
|
+
return definition
|
|
209
|
+
|
|
210
|
+
return decorate
|
|
211
|
+
|
|
212
|
+
def schedule(
|
|
213
|
+
self,
|
|
214
|
+
cron: str,
|
|
215
|
+
*,
|
|
216
|
+
schedule_id: str,
|
|
217
|
+
input_value: Any,
|
|
218
|
+
timeout_ms: int,
|
|
219
|
+
overlap: int,
|
|
220
|
+
catch_up_window_ms: int,
|
|
221
|
+
paused: bool,
|
|
222
|
+
revision: int,
|
|
223
|
+
) -> Callable[[Workflow[Any, Any]], Workflow[Any, Any]]:
|
|
224
|
+
def decorate(definition: Workflow[Any, Any]) -> Workflow[Any, Any]:
|
|
225
|
+
with self._gate.registration():
|
|
226
|
+
registered = self._validate_schedule(definition, revision)
|
|
227
|
+
resolved_id = schedule_id or registered._spec.name
|
|
228
|
+
self._assert_schedule_id_available(registered, resolved_id)
|
|
229
|
+
args = () if input_value is UNSET else (input_value,)
|
|
230
|
+
packed = registered._contract.pack(args, {})
|
|
231
|
+
snapshot = self._codec.decode(self._codec.encode(packed))
|
|
232
|
+
registered._spec.schedule = ScheduleSpec(
|
|
233
|
+
cron=cron,
|
|
234
|
+
schedule_id=resolved_id,
|
|
235
|
+
input_payload=snapshot,
|
|
236
|
+
timeout_ms=timeout_ms,
|
|
237
|
+
overlap=overlap,
|
|
238
|
+
catch_up_window_ms=catch_up_window_ms,
|
|
239
|
+
paused=paused,
|
|
240
|
+
revision=revision,
|
|
241
|
+
)
|
|
242
|
+
return registered
|
|
243
|
+
|
|
244
|
+
return decorate
|
|
245
|
+
|
|
246
|
+
def _validate_schedule(
|
|
247
|
+
self,
|
|
248
|
+
definition: Any,
|
|
249
|
+
revision: int,
|
|
250
|
+
) -> _Workflow[Any, Any]:
|
|
251
|
+
if not isinstance(definition, _Workflow):
|
|
252
|
+
raise TypeError(
|
|
253
|
+
"@app.schedule must wrap an @app.workflow definition "
|
|
254
|
+
"(place @app.schedule above @app.workflow)"
|
|
255
|
+
)
|
|
256
|
+
if definition._app is not self._owner:
|
|
257
|
+
raise TypeError("@app.schedule cannot decorate another App's workflow")
|
|
258
|
+
registered = definition
|
|
259
|
+
if revision < 1 or revision > MAX_SCHEDULE_REVISION:
|
|
260
|
+
raise ValueError(f"@app.schedule revision must be in 1..{MAX_SCHEDULE_REVISION}")
|
|
261
|
+
if registered._spec.schedule is not None:
|
|
262
|
+
raise ValueError(f"workflow {registered._spec.name!r} already has a schedule")
|
|
263
|
+
return registered
|
|
264
|
+
|
|
265
|
+
def _assert_schedule_id_available(
|
|
266
|
+
self,
|
|
267
|
+
definition: _Workflow[Any, Any],
|
|
268
|
+
schedule_id: str,
|
|
269
|
+
) -> None:
|
|
270
|
+
for registered in self._workflows.values():
|
|
271
|
+
schedule = registered.schedule
|
|
272
|
+
if (
|
|
273
|
+
registered is not definition._spec
|
|
274
|
+
and schedule is not None
|
|
275
|
+
and schedule.schedule_id == schedule_id
|
|
276
|
+
):
|
|
277
|
+
raise ValueError(
|
|
278
|
+
f"schedule {schedule_id!r} is already declared for workflow {registered.name!r}"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def snapshot(self) -> CatalogSnapshot:
|
|
282
|
+
return CatalogSnapshot(
|
|
283
|
+
dict(self._steps),
|
|
284
|
+
dict(self._workflows),
|
|
285
|
+
dict(self._remotes),
|
|
286
|
+
{name: spec.schedule for name, spec in self._workflows.items()},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def restore(self, snapshot: CatalogSnapshot) -> None:
|
|
290
|
+
self._steps.clear()
|
|
291
|
+
self._steps.update(snapshot.steps)
|
|
292
|
+
self._workflows.clear()
|
|
293
|
+
self._workflows.update(snapshot.workflows)
|
|
294
|
+
self._remotes.clear()
|
|
295
|
+
self._remotes.update(snapshot.remotes)
|
|
296
|
+
for name, schedule in snapshot.schedules.items():
|
|
297
|
+
self._workflows[name].schedule = schedule
|
|
298
|
+
|
|
299
|
+
def unchanged(self, snapshot: CatalogSnapshot) -> bool:
|
|
300
|
+
current = self.snapshot()
|
|
301
|
+
return current == snapshot
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
__all__: list[str] = []
|