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.

Files changed (65) hide show
  1. aga_runtime/__init__.py +99 -0
  2. aga_runtime/_authoring/__init__.py +3 -0
  3. aga_runtime/_authoring/adapters.py +132 -0
  4. aga_runtime/_authoring/catalog.py +304 -0
  5. aga_runtime/_authoring/contracts.py +168 -0
  6. aga_runtime/_authoring/definitions.py +280 -0
  7. aga_runtime/_authoring/lifecycle.py +251 -0
  8. aga_runtime/_authoring/models.py +62 -0
  9. aga_runtime/_authoring/operations.py +112 -0
  10. aga_runtime/_authoring/ports.py +94 -0
  11. aga_runtime/_authoring/runs.py +149 -0
  12. aga_runtime/_authoring/scope.py +65 -0
  13. aga_runtime/_lineage.py +197 -0
  14. aga_runtime/_version.py +13 -0
  15. aga_runtime/app.py +241 -0
  16. aga_runtime/client/__init__.py +5 -0
  17. aga_runtime/client/_http/__init__.py +3 -0
  18. aga_runtime/client/_http/capabilities.py +66 -0
  19. aga_runtime/client/_http/promises.py +77 -0
  20. aga_runtime/client/_http/runs.py +130 -0
  21. aga_runtime/client/_http/schedules.py +208 -0
  22. aga_runtime/client/_http/storage.py +56 -0
  23. aga_runtime/client/_http/transport.py +173 -0
  24. aga_runtime/client/http.py +339 -0
  25. aga_runtime/codec/__init__.py +5 -0
  26. aga_runtime/codec/cbor.py +99 -0
  27. aga_runtime/errors.py +128 -0
  28. aga_runtime/payload/__init__.py +5 -0
  29. aga_runtime/payload/blob.py +133 -0
  30. aga_runtime/protocol/__init__.py +5 -0
  31. aga_runtime/protocol/_runs.py +65 -0
  32. aga_runtime/protocol/wire.py +320 -0
  33. aga_runtime/py.typed +0 -0
  34. aga_runtime/workflow/__init__.py +7 -0
  35. aga_runtime/workflow/_runtime/__init__.py +3 -0
  36. aga_runtime/workflow/_runtime/child_runs.py +107 -0
  37. aga_runtime/workflow/_runtime/children.py +244 -0
  38. aga_runtime/workflow/_runtime/control.py +52 -0
  39. aga_runtime/workflow/_runtime/execution.py +66 -0
  40. aga_runtime/workflow/_runtime/handles.py +220 -0
  41. aga_runtime/workflow/_runtime/ports.py +61 -0
  42. aga_runtime/workflow/_runtime/replay.py +338 -0
  43. aga_runtime/workflow/_runtime/replay_decisions.py +55 -0
  44. aga_runtime/workflow/_runtime/serialization.py +66 -0
  45. aga_runtime/workflow/_runtime/signals.py +99 -0
  46. aga_runtime/workflow/_runtime/waiting.py +132 -0
  47. aga_runtime/workflow/_worker/__init__.py +3 -0
  48. aga_runtime/workflow/_worker/checkpoints.py +243 -0
  49. aga_runtime/workflow/_worker/claims.py +118 -0
  50. aga_runtime/workflow/_worker/host.py +121 -0
  51. aga_runtime/workflow/_worker/invocations.py +93 -0
  52. aga_runtime/workflow/_worker/leases.py +58 -0
  53. aga_runtime/workflow/_worker/retry.py +49 -0
  54. aga_runtime/workflow/_worker/roster.py +54 -0
  55. aga_runtime/workflow/_worker/schedules.py +189 -0
  56. aga_runtime/workflow/_worker/state.py +120 -0
  57. aga_runtime/workflow/_worker/steps.py +254 -0
  58. aga_runtime/workflow/_worker/tasks.py +93 -0
  59. aga_runtime/workflow/_worker/workflows.py +272 -0
  60. aga_runtime/workflow/decorators.py +89 -0
  61. aga_runtime/workflow/saga.py +176 -0
  62. aga_runtime-0.4.0.dist-info/METADATA +239 -0
  63. aga_runtime-0.4.0.dist-info/RECORD +65 -0
  64. aga_runtime-0.4.0.dist-info/WHEEL +4 -0
  65. aga_runtime-0.4.0.dist-info/licenses/LICENSE +202 -0
@@ -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,3 @@
1
+ """Private implementation of the public :mod:`aga_runtime.app` facade."""
2
+
3
+ __all__: list[str] = []
@@ -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] = []