perd-worker 0.1.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.
- perd_worker/__init__.py +32 -0
- perd_worker/compiler.py +178 -0
- perd_worker/registry.py +252 -0
- perd_worker/runtime.py +488 -0
- perd_worker/serve.py +103 -0
- perd_worker-0.1.0.dist-info/METADATA +9 -0
- perd_worker-0.1.0.dist-info/RECORD +8 -0
- perd_worker-0.1.0.dist-info/WHEEL +4 -0
perd_worker/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
r"""perd_worker — the SDK workflow developers build against.
|
|
2
|
+
|
|
3
|
+
Authoring surface:
|
|
4
|
+
|
|
5
|
+
.. code-block:: python
|
|
6
|
+
|
|
7
|
+
from perd_worker import workflow, WorkflowStreamInput, WorkflowStreamOutput
|
|
8
|
+
|
|
9
|
+
Build time: :func:`perd_worker.compiler.compile_contract` projects the
|
|
10
|
+
registered signatures into the workflow's ``FileDescriptorSet``; its SHA-256
|
|
11
|
+
is the contract identity every other component pins.
|
|
12
|
+
|
|
13
|
+
Runtime: :class:`perd_worker.runtime.WorkerServicer` serves the uniform
|
|
14
|
+
``perd.v1.WorkerService`` over gRPC.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .registry import (
|
|
18
|
+
WorkflowDefinitionError,
|
|
19
|
+
WorkflowStreamInput,
|
|
20
|
+
WorkflowStreamOutput,
|
|
21
|
+
workflow,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"workflow",
|
|
28
|
+
"WorkflowStreamInput",
|
|
29
|
+
"WorkflowStreamOutput",
|
|
30
|
+
"WorkflowDefinitionError",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
perd_worker/compiler.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
r"""Build-time projection: registered signatures -> protobuf contract.
|
|
2
|
+
|
|
3
|
+
Runs ONCE, at image build. The output — a serialized ``FileDescriptorSet`` —
|
|
4
|
+
is the workflow's entire public identity:
|
|
5
|
+
|
|
6
|
+
.. math::
|
|
7
|
+
|
|
8
|
+
\mathrm{contract\_hash} = \operatorname{SHA256}\!\left(D\right)
|
|
9
|
+
|
|
10
|
+
where:
|
|
11
|
+
|
|
12
|
+
- :math:`D`: the ``FileDescriptorSet`` bytes embedded in the worker image and returned verbatim by ``WorkerService.Describe``
|
|
13
|
+
- :math:`\mathrm{contract\_hash}`: pinned by the store at publish, by the client at resolve, and re-verified by the orchestrator at registration — one value, three enforcement points
|
|
14
|
+
|
|
15
|
+
Never runs at request time. v1 downloaded protos from GCS and shelled out to
|
|
16
|
+
``protoc`` on first use of every workflow, inside the orchestrator; here the
|
|
17
|
+
runtime path only ever *reads* the embedded bytes.
|
|
18
|
+
|
|
19
|
+
Field rules (all consumed by ``contracts/tools/manifest_from_descriptor``):
|
|
20
|
+
|
|
21
|
+
- every field is proto3 ``optional`` — presence-tracked, so a returned
|
|
22
|
+
:math:`0`, :math:`0.0`, ``""`` or ``False`` is distinguishable from absent,
|
|
23
|
+
and an omitted defaulted parameter is distinguishable from an explicit zero
|
|
24
|
+
- input-stream items are ``item_1..item_N`` numbered from 1
|
|
25
|
+
- per-call parameters keep their Python names, numbered from 20 upward so
|
|
26
|
+
item fields and parameter fields can never collide
|
|
27
|
+
- non-streaming results use the single field ``result``; streaming outputs
|
|
28
|
+
use ``item_1..item_N``
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import re
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import tempfile
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from .registry import (
|
|
42
|
+
MODE_BI_DI,
|
|
43
|
+
MODE_INPUT_STREAM,
|
|
44
|
+
MODE_OUTPUT_STREAM,
|
|
45
|
+
SCALAR_TYPES,
|
|
46
|
+
RegisteredOp,
|
|
47
|
+
WorkflowRegistry,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
#: First field number for per-call parameters; items occupy 1..19.
|
|
51
|
+
PARAM_FIELD_BASE = 20
|
|
52
|
+
|
|
53
|
+
_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CompileError(RuntimeError):
|
|
57
|
+
"""Raised when the registry cannot be projected into a valid contract."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pascal(name: str) -> str:
|
|
61
|
+
return "".join(part[:1].upper() + part[1:] for part in name.split("_") if part)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class CompiledContract:
|
|
66
|
+
"""The build artifact: proto source, descriptor bytes, and identity."""
|
|
67
|
+
|
|
68
|
+
workflow_name: str
|
|
69
|
+
proto_source: str
|
|
70
|
+
descriptor_set: bytes
|
|
71
|
+
contract_hash: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_proto(workflow_name: str, ops: dict[str, RegisteredOp]) -> str:
|
|
75
|
+
"""Render the registry as a .proto source file."""
|
|
76
|
+
if not _NAME_RE.match(workflow_name):
|
|
77
|
+
raise CompileError(
|
|
78
|
+
f"workflow name '{workflow_name}' must be snake_case "
|
|
79
|
+
"([a-z][a-z0-9_]*) — it becomes a protobuf package segment."
|
|
80
|
+
)
|
|
81
|
+
if not ops:
|
|
82
|
+
raise CompileError(
|
|
83
|
+
"No operations registered. Decorate at least one function with "
|
|
84
|
+
"@workflow.unary / .input_stream / .output_stream / .bi_di."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
lines = [
|
|
88
|
+
"// Generated by perd_worker.compiler — DO NOT EDIT.",
|
|
89
|
+
'syntax = "proto3";',
|
|
90
|
+
"",
|
|
91
|
+
f"package perd.workflow.{workflow_name};",
|
|
92
|
+
"",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
rpcs = []
|
|
96
|
+
for op in ops.values():
|
|
97
|
+
rpc = _pascal(op.function_name)
|
|
98
|
+
req, res = f"{rpc}Request", f"{rpc}Response"
|
|
99
|
+
|
|
100
|
+
lines.append(f"message {req} {{")
|
|
101
|
+
for i, t in enumerate(op.input_item_types, start=1):
|
|
102
|
+
lines.append(f" optional {SCALAR_TYPES[t]} item_{i} = {i};")
|
|
103
|
+
if len(op.input_item_types) >= PARAM_FIELD_BASE:
|
|
104
|
+
raise CompileError(
|
|
105
|
+
f"{op.function_name}: at most {PARAM_FIELD_BASE - 1} stream "
|
|
106
|
+
"item fields are supported."
|
|
107
|
+
)
|
|
108
|
+
for j, (name, t, _hd, _d) in enumerate(op.params, start=PARAM_FIELD_BASE):
|
|
109
|
+
if re.fullmatch(r"item_\d+", name):
|
|
110
|
+
raise CompileError(
|
|
111
|
+
f"{op.function_name}: parameter name '{name}' collides "
|
|
112
|
+
"with the reserved stream-item naming (item_N)."
|
|
113
|
+
)
|
|
114
|
+
lines.append(f" optional {SCALAR_TYPES[t]} {name} = {j};")
|
|
115
|
+
lines.append("}")
|
|
116
|
+
lines.append("")
|
|
117
|
+
|
|
118
|
+
lines.append(f"message {res} {{")
|
|
119
|
+
if op.mode in (MODE_OUTPUT_STREAM, MODE_BI_DI):
|
|
120
|
+
for i, t in enumerate(op.output_item_types, start=1):
|
|
121
|
+
lines.append(f" optional {SCALAR_TYPES[t]} item_{i} = {i};")
|
|
122
|
+
else:
|
|
123
|
+
lines.append(
|
|
124
|
+
f" optional {SCALAR_TYPES[op.output_item_types[0]]} result = 1;"
|
|
125
|
+
)
|
|
126
|
+
lines.append("}")
|
|
127
|
+
lines.append("")
|
|
128
|
+
|
|
129
|
+
in_stream = "stream " if op.mode in (MODE_INPUT_STREAM, MODE_BI_DI) else ""
|
|
130
|
+
out_stream = "stream " if op.mode in (MODE_OUTPUT_STREAM, MODE_BI_DI) else ""
|
|
131
|
+
rpcs.append(f" rpc {rpc}({in_stream}{req}) returns ({out_stream}{res});")
|
|
132
|
+
|
|
133
|
+
lines.append("service Workflow {")
|
|
134
|
+
lines.extend(rpcs)
|
|
135
|
+
lines.append("}")
|
|
136
|
+
return "\n".join(lines) + "\n"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def compile_contract(
|
|
140
|
+
workflow_name: str, registry: WorkflowRegistry
|
|
141
|
+
) -> CompiledContract:
|
|
142
|
+
"""Project the registry into a serialized ``FileDescriptorSet``.
|
|
143
|
+
|
|
144
|
+
Uses ``grpc_tools.protoc`` in a temp dir — acceptable because this is a
|
|
145
|
+
build step, executed exactly once per image, never per request.
|
|
146
|
+
"""
|
|
147
|
+
proto_source = render_proto(workflow_name, registry.ops)
|
|
148
|
+
|
|
149
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
150
|
+
root = Path(tmp)
|
|
151
|
+
(root / "workflow.proto").write_text(proto_source)
|
|
152
|
+
out = root / "descriptor.binpb"
|
|
153
|
+
result = subprocess.run(
|
|
154
|
+
[
|
|
155
|
+
sys.executable,
|
|
156
|
+
"-m",
|
|
157
|
+
"grpc_tools.protoc",
|
|
158
|
+
f"-I{root}",
|
|
159
|
+
f"--descriptor_set_out={out}",
|
|
160
|
+
"--include_imports",
|
|
161
|
+
str(root / "workflow.proto"),
|
|
162
|
+
],
|
|
163
|
+
capture_output=True,
|
|
164
|
+
text=True,
|
|
165
|
+
)
|
|
166
|
+
if result.returncode != 0:
|
|
167
|
+
raise CompileError(
|
|
168
|
+
f"protoc rejected the generated schema:\n{result.stderr}\n"
|
|
169
|
+
f"--- generated source ---\n{proto_source}"
|
|
170
|
+
)
|
|
171
|
+
descriptor_set = out.read_bytes()
|
|
172
|
+
|
|
173
|
+
return CompiledContract(
|
|
174
|
+
workflow_name=workflow_name,
|
|
175
|
+
proto_source=proto_source,
|
|
176
|
+
descriptor_set=descriptor_set,
|
|
177
|
+
contract_hash=hashlib.sha256(descriptor_set).hexdigest(),
|
|
178
|
+
)
|
perd_worker/registry.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
r"""Workflow authoring surface: decorators and stream type markers.
|
|
2
|
+
|
|
3
|
+
A workflow developer writes ordinary Python and marks entry points:
|
|
4
|
+
|
|
5
|
+
.. code-block:: python
|
|
6
|
+
|
|
7
|
+
from perd_worker import workflow, WorkflowStreamInput, WorkflowStreamOutput
|
|
8
|
+
|
|
9
|
+
@workflow.unary
|
|
10
|
+
def add(a: float, b: float) -> float: ...
|
|
11
|
+
|
|
12
|
+
@workflow.bi_di
|
|
13
|
+
async def train(
|
|
14
|
+
inputStream: WorkflowStreamInput[float, float, float],
|
|
15
|
+
batch_size: int = 4,
|
|
16
|
+
) -> WorkflowStreamOutput[int, float]: ...
|
|
17
|
+
|
|
18
|
+
The decorators only *register*; all schema work happens at build time in
|
|
19
|
+
:mod:`perd_worker.compiler`, which projects these signatures into a protobuf
|
|
20
|
+
service and embeds its ``FileDescriptorSet`` in the image. The four mode names
|
|
21
|
+
here are the only spellings that exist anywhere in the system — v1 carried
|
|
22
|
+
three independent normalisation tables plus legacy aliases, which is why they
|
|
23
|
+
drifted.
|
|
24
|
+
|
|
25
|
+
where:
|
|
26
|
+
|
|
27
|
+
- :math:`\text{unary}`: one request message, one response message
|
|
28
|
+
- :math:`\text{input\_stream}`: a stream of item messages plus trailing per-call parameters, one response
|
|
29
|
+
- :math:`\text{output\_stream}`: one request, a stream of responses
|
|
30
|
+
- :math:`\text{bi\_di}`: both, fully interleaved
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import inspect
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Any, Callable
|
|
38
|
+
|
|
39
|
+
MODE_UNARY = "unary"
|
|
40
|
+
MODE_INPUT_STREAM = "input_stream"
|
|
41
|
+
MODE_OUTPUT_STREAM = "output_stream"
|
|
42
|
+
MODE_BI_DI = "bi_di"
|
|
43
|
+
|
|
44
|
+
#: Python annotation -> proto scalar type. The ONLY types a workflow
|
|
45
|
+
#: signature may use. Matches manifest_from_descriptor._PROTO_TYPE_NAMES.
|
|
46
|
+
SCALAR_TYPES: dict[type, str] = {
|
|
47
|
+
float: "double",
|
|
48
|
+
int: "int64",
|
|
49
|
+
str: "string",
|
|
50
|
+
bool: "bool",
|
|
51
|
+
bytes: "bytes",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _StreamMarker:
|
|
56
|
+
"""Base for the generic stream annotations; carries the item types."""
|
|
57
|
+
|
|
58
|
+
item_types: tuple[type, ...] = ()
|
|
59
|
+
|
|
60
|
+
def __class_getitem__(cls, item: type | tuple[type, ...]) -> type[_StreamMarker]:
|
|
61
|
+
types = item if isinstance(item, tuple) else (item,)
|
|
62
|
+
marker = type(cls.__name__, (cls,), {"item_types": tuple(types)})
|
|
63
|
+
return marker
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class WorkflowStreamInput(_StreamMarker):
|
|
67
|
+
r"""Annotates the (first) parameter that receives the input stream.
|
|
68
|
+
|
|
69
|
+
``WorkflowStreamInput[float, int]`` declares two-field stream items; the
|
|
70
|
+
generated request message carries them as ``item_1: double, item_2: int64``.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class WorkflowStreamOutput(_StreamMarker):
|
|
75
|
+
r"""Annotates a streaming return type.
|
|
76
|
+
|
|
77
|
+
``WorkflowStreamOutput[int, float]`` declares two-field output items,
|
|
78
|
+
carried as ``item_1: int64, item_2: double`` on the response message.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class WorkflowDefinitionError(TypeError):
|
|
83
|
+
"""Raised at registration time for a signature the SDK cannot serve."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class RegisteredOp:
|
|
88
|
+
"""One decorated function plus everything introspected from it."""
|
|
89
|
+
|
|
90
|
+
function_name: str
|
|
91
|
+
mode: str
|
|
92
|
+
fn: Callable[..., Any]
|
|
93
|
+
#: (name, python type, has_default, default) for per-call parameters.
|
|
94
|
+
params: tuple[tuple[str, type, bool, Any], ...]
|
|
95
|
+
#: item types of the input stream ('' when the mode has none).
|
|
96
|
+
input_item_types: tuple[type, ...]
|
|
97
|
+
#: item types of the output stream / the single result type for
|
|
98
|
+
#: non-streaming outputs (always at least one entry).
|
|
99
|
+
output_item_types: tuple[type, ...]
|
|
100
|
+
docstring: str | None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_scalar(t: Any, where: str) -> type:
|
|
104
|
+
if t not in SCALAR_TYPES:
|
|
105
|
+
allowed = ", ".join(x.__name__ for x in SCALAR_TYPES)
|
|
106
|
+
raise WorkflowDefinitionError(
|
|
107
|
+
f"{where}: type {t!r} is not supported. "
|
|
108
|
+
f"Workflow signatures may only use: {allowed}."
|
|
109
|
+
)
|
|
110
|
+
assert isinstance(t, type) # SCALAR_TYPES membership guarantees this
|
|
111
|
+
return t
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _resolve_hints(fn: Callable[..., Any]) -> dict[str, Any]:
|
|
115
|
+
"""Resolve annotations to real objects.
|
|
116
|
+
|
|
117
|
+
``fn.__annotations__`` holds *strings* under ``from __future__ import
|
|
118
|
+
annotations`` (PEP 563), which every modern workflow module uses —
|
|
119
|
+
evaluate them in the function's own namespace.
|
|
120
|
+
"""
|
|
121
|
+
import typing
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
return typing.get_type_hints(fn)
|
|
125
|
+
except NameError as exc:
|
|
126
|
+
raise WorkflowDefinitionError(
|
|
127
|
+
f"{fn.__name__}: annotation references an undefined name ({exc})."
|
|
128
|
+
) from exc
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _introspect(fn: Callable[..., Any], mode: str) -> RegisteredOp:
|
|
132
|
+
sig = inspect.signature(fn)
|
|
133
|
+
hints = _resolve_hints(fn)
|
|
134
|
+
params: list[tuple[str, type, bool, Any]] = []
|
|
135
|
+
input_items: tuple[type, ...] = ()
|
|
136
|
+
|
|
137
|
+
parameters = list(sig.parameters.values())
|
|
138
|
+
input_streaming = mode in (MODE_INPUT_STREAM, MODE_BI_DI)
|
|
139
|
+
output_streaming = mode in (MODE_OUTPUT_STREAM, MODE_BI_DI)
|
|
140
|
+
|
|
141
|
+
if input_streaming:
|
|
142
|
+
if not parameters:
|
|
143
|
+
raise WorkflowDefinitionError(
|
|
144
|
+
f"{fn.__name__}: {mode} requires a first WorkflowStreamInput parameter."
|
|
145
|
+
)
|
|
146
|
+
first = parameters[0]
|
|
147
|
+
ann = hints.get(first.name)
|
|
148
|
+
if not (isinstance(ann, type) and issubclass(ann, WorkflowStreamInput)):
|
|
149
|
+
raise WorkflowDefinitionError(
|
|
150
|
+
f"{fn.__name__}: first parameter of a {mode} workflow must be "
|
|
151
|
+
f"annotated WorkflowStreamInput[...], got {ann!r}."
|
|
152
|
+
)
|
|
153
|
+
if not ann.item_types:
|
|
154
|
+
raise WorkflowDefinitionError(
|
|
155
|
+
f"{fn.__name__}: WorkflowStreamInput needs at least one item type, "
|
|
156
|
+
"e.g. WorkflowStreamInput[float]."
|
|
157
|
+
)
|
|
158
|
+
input_items = tuple(
|
|
159
|
+
_check_scalar(t, f"{fn.__name__} stream item {i + 1}")
|
|
160
|
+
for i, t in enumerate(ann.item_types)
|
|
161
|
+
)
|
|
162
|
+
parameters = parameters[1:]
|
|
163
|
+
|
|
164
|
+
for p in parameters:
|
|
165
|
+
if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
|
|
166
|
+
raise WorkflowDefinitionError(
|
|
167
|
+
f"{fn.__name__}: *args/**kwargs are not representable in a "
|
|
168
|
+
"workflow contract; declare explicit parameters."
|
|
169
|
+
)
|
|
170
|
+
if p.name.startswith("_"):
|
|
171
|
+
raise WorkflowDefinitionError(
|
|
172
|
+
f"{fn.__name__}: parameter '{p.name}' is invalid — names "
|
|
173
|
+
"beginning with '_' are reserved for client call options "
|
|
174
|
+
"(e.g. _deadline_ms)."
|
|
175
|
+
)
|
|
176
|
+
ann = hints.get(p.name)
|
|
177
|
+
if ann is None:
|
|
178
|
+
raise WorkflowDefinitionError(
|
|
179
|
+
f"{fn.__name__}: parameter '{p.name}' needs a type annotation."
|
|
180
|
+
)
|
|
181
|
+
_check_scalar(ann, f"{fn.__name__}.{p.name}")
|
|
182
|
+
has_default = p.default is not inspect.Parameter.empty
|
|
183
|
+
params.append((p.name, ann, has_default, p.default if has_default else None))
|
|
184
|
+
|
|
185
|
+
ret = hints.get("return")
|
|
186
|
+
if output_streaming:
|
|
187
|
+
if not (isinstance(ret, type) and issubclass(ret, WorkflowStreamOutput)):
|
|
188
|
+
raise WorkflowDefinitionError(
|
|
189
|
+
f"{fn.__name__}: a {mode} workflow must return "
|
|
190
|
+
f"WorkflowStreamOutput[...], got {ret!r}."
|
|
191
|
+
)
|
|
192
|
+
out_items = tuple(
|
|
193
|
+
_check_scalar(t, f"{fn.__name__} output item {i + 1}")
|
|
194
|
+
for i, t in enumerate(ret.item_types)
|
|
195
|
+
)
|
|
196
|
+
if not out_items:
|
|
197
|
+
raise WorkflowDefinitionError(
|
|
198
|
+
f"{fn.__name__}: WorkflowStreamOutput needs at least one item type."
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
if ret is None:
|
|
202
|
+
raise WorkflowDefinitionError(
|
|
203
|
+
f"{fn.__name__}: a return annotation is required."
|
|
204
|
+
)
|
|
205
|
+
out_items = (_check_scalar(ret, f"{fn.__name__} return"),)
|
|
206
|
+
|
|
207
|
+
return RegisteredOp(
|
|
208
|
+
function_name=fn.__name__,
|
|
209
|
+
mode=mode,
|
|
210
|
+
fn=fn,
|
|
211
|
+
params=tuple(params),
|
|
212
|
+
input_item_types=input_items,
|
|
213
|
+
output_item_types=out_items,
|
|
214
|
+
docstring=inspect.getdoc(fn),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class WorkflowRegistry:
|
|
220
|
+
"""Collects decorated operations for one workflow module."""
|
|
221
|
+
|
|
222
|
+
ops: dict[str, RegisteredOp] = field(default_factory=dict)
|
|
223
|
+
|
|
224
|
+
def _register(self, fn: Callable[..., Any], mode: str) -> Callable[..., Any]:
|
|
225
|
+
op = _introspect(fn, mode)
|
|
226
|
+
if op.function_name in self.ops:
|
|
227
|
+
raise WorkflowDefinitionError(
|
|
228
|
+
f"Duplicate workflow operation '{op.function_name}'."
|
|
229
|
+
)
|
|
230
|
+
self.ops[op.function_name] = op
|
|
231
|
+
return fn
|
|
232
|
+
|
|
233
|
+
# The four decorators. Names are the contract; never alias them.
|
|
234
|
+
def unary(self, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
235
|
+
"""Register a request/response operation."""
|
|
236
|
+
return self._register(fn, MODE_UNARY)
|
|
237
|
+
|
|
238
|
+
def input_stream(self, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
239
|
+
"""Register a client-streaming operation (stream in, one result out)."""
|
|
240
|
+
return self._register(fn, MODE_INPUT_STREAM)
|
|
241
|
+
|
|
242
|
+
def output_stream(self, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
243
|
+
"""Register a server-streaming operation (one request, stream out)."""
|
|
244
|
+
return self._register(fn, MODE_OUTPUT_STREAM)
|
|
245
|
+
|
|
246
|
+
def bi_di(self, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
247
|
+
"""Register a fully bidirectional streaming operation."""
|
|
248
|
+
return self._register(fn, MODE_BI_DI)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
#: The module-level registry workflow authors decorate against.
|
|
252
|
+
workflow = WorkflowRegistry()
|
perd_worker/runtime.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
r"""WorkerService runtime: Describe + the uniform Invoke stream.
|
|
2
|
+
|
|
3
|
+
One gRPC service carries every workflow and every call mode. The per-workflow
|
|
4
|
+
schema lives entirely in the embedded ``FileDescriptorSet``; message classes
|
|
5
|
+
are built in-process from an isolated ``DescriptorPool`` — no protoc, no
|
|
6
|
+
downloads, no generated modules on the runtime path.
|
|
7
|
+
|
|
8
|
+
Contract guarantees enforced here, each mapped to the v1 defect it kills:
|
|
9
|
+
|
|
10
|
+
- ``deadline_ms`` is required and finite; ``0`` is rejected with
|
|
11
|
+
``INVALID_ARGUMENT`` (v1 provisioned ``GRPC_DEADLINE_SECONDS`` as the
|
|
12
|
+
string ``none``, disabling every timeout in the chain)
|
|
13
|
+
- input frames carry contiguous ``seq`` from 1; a gap fails the job rather
|
|
14
|
+
than silently reordering (v1 had no input sequencing at all)
|
|
15
|
+
- input is credit-gated: the worker grants ``InvokeInputCredit`` and the
|
|
16
|
+
orchestrator may not exceed it (v1 buffered into an unbounded queue)
|
|
17
|
+
- results are presence-tracked messages; a returned :math:`0`, ``""`` or
|
|
18
|
+
``False`` arrives set, never elided (v1's ``MessageToDict`` dropped them)
|
|
19
|
+
- a user-code exception becomes ``InvokeFailed(INTERNAL)`` with the message
|
|
20
|
+
only — never a traceback on the wire
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import contextlib
|
|
27
|
+
import inspect
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Any, AsyncIterator
|
|
30
|
+
|
|
31
|
+
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory
|
|
32
|
+
|
|
33
|
+
from perd.v1 import common_pb2, worker_pb2, worker_pb2_grpc
|
|
34
|
+
from manifest_from_descriptor import Operation, build_manifest
|
|
35
|
+
|
|
36
|
+
from .compiler import CompiledContract
|
|
37
|
+
from .registry import (
|
|
38
|
+
MODE_BI_DI,
|
|
39
|
+
MODE_INPUT_STREAM,
|
|
40
|
+
MODE_OUTPUT_STREAM,
|
|
41
|
+
MODE_UNARY,
|
|
42
|
+
RegisteredOp,
|
|
43
|
+
WorkflowRegistry,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
SDK_VERSION = "0.1.0"
|
|
49
|
+
|
|
50
|
+
#: Input credit granted on start, and the consumption step that triggers a
|
|
51
|
+
#: fresh grant. Bounds worker memory to ~INITIAL_INPUT_CREDIT frames.
|
|
52
|
+
INITIAL_INPUT_CREDIT = 32
|
|
53
|
+
CREDIT_REFRESH_STEP = 16
|
|
54
|
+
|
|
55
|
+
_MODE_TO_PROTO = {
|
|
56
|
+
MODE_UNARY: worker_pb2.CALL_MODE_UNARY,
|
|
57
|
+
MODE_INPUT_STREAM: worker_pb2.CALL_MODE_INPUT_STREAM,
|
|
58
|
+
MODE_OUTPUT_STREAM: worker_pb2.CALL_MODE_OUTPUT_STREAM,
|
|
59
|
+
MODE_BI_DI: worker_pb2.CALL_MODE_BI_DI,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _InvokeAbort(Exception):
|
|
64
|
+
"""Internal: terminate the invoke with a typed Failure."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self, code: common_pb2.FailureCode, message: str, *, retryable: bool = False
|
|
68
|
+
):
|
|
69
|
+
super().__init__(message)
|
|
70
|
+
self.failure = common_pb2.Failure(
|
|
71
|
+
code=code, message=message, retryable=retryable
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Cancelled(_InvokeAbort):
|
|
76
|
+
def __init__(self, reason: str):
|
|
77
|
+
super().__init__(
|
|
78
|
+
common_pb2.FAILURE_CODE_CANCELLED,
|
|
79
|
+
f"cancelled by client: {reason}" if reason else "cancelled by client",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _failed(failure: common_pb2.Failure) -> worker_pb2.InvokeResponse:
|
|
84
|
+
return worker_pb2.InvokeResponse(failed=worker_pb2.InvokeFailed(failure=failure))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class WorkerServicer(worker_pb2_grpc.WorkerServiceServicer):
|
|
88
|
+
"""Serves one workflow's operations over the uniform contract."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, contract: CompiledContract, registry: WorkflowRegistry):
|
|
91
|
+
self._contract = contract
|
|
92
|
+
self._registry = registry
|
|
93
|
+
|
|
94
|
+
# Message classes from the embedded descriptor, in an isolated pool.
|
|
95
|
+
fds = descriptor_pb2.FileDescriptorSet()
|
|
96
|
+
fds.ParseFromString(contract.descriptor_set)
|
|
97
|
+
pool = descriptor_pool.DescriptorPool()
|
|
98
|
+
for file_proto in fds.file:
|
|
99
|
+
pool.Add(file_proto)
|
|
100
|
+
pkg = f"perd.workflow.{contract.workflow_name}"
|
|
101
|
+
|
|
102
|
+
self._ops_by_rpc: dict[str, tuple[RegisteredOp, type, type, Operation]] = {}
|
|
103
|
+
for manifest_op in build_manifest(contract.descriptor_set):
|
|
104
|
+
reg_op = registry.ops[manifest_op.function_name]
|
|
105
|
+
req_cls = message_factory.GetMessageClass(
|
|
106
|
+
pool.FindMessageTypeByName(f"{pkg}.{manifest_op.rpc_name}Request")
|
|
107
|
+
)
|
|
108
|
+
res_cls = message_factory.GetMessageClass(
|
|
109
|
+
pool.FindMessageTypeByName(f"{pkg}.{manifest_op.rpc_name}Response")
|
|
110
|
+
)
|
|
111
|
+
self._ops_by_rpc[manifest_op.rpc_name] = (
|
|
112
|
+
reg_op,
|
|
113
|
+
req_cls,
|
|
114
|
+
res_cls,
|
|
115
|
+
manifest_op,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
self._describe = self._build_describe()
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------ Describe
|
|
121
|
+
def _build_describe(self) -> worker_pb2.DescribeResponse:
|
|
122
|
+
ops = []
|
|
123
|
+
for _rpc, (reg_op, _req, _res, m) in sorted(self._ops_by_rpc.items()):
|
|
124
|
+
ops.append(
|
|
125
|
+
worker_pb2.OperationSpec(
|
|
126
|
+
function_name=m.function_name,
|
|
127
|
+
rpc_name=m.rpc_name,
|
|
128
|
+
mode=_MODE_TO_PROTO[reg_op.mode],
|
|
129
|
+
params=[
|
|
130
|
+
worker_pb2.FieldSpec(
|
|
131
|
+
name=f.name,
|
|
132
|
+
proto_type=f.proto_type,
|
|
133
|
+
field_number=f.field_number,
|
|
134
|
+
optional=f.optional,
|
|
135
|
+
)
|
|
136
|
+
for f in m.params
|
|
137
|
+
],
|
|
138
|
+
input_stream_fields=[
|
|
139
|
+
worker_pb2.FieldSpec(
|
|
140
|
+
name=f.name,
|
|
141
|
+
proto_type=f.proto_type,
|
|
142
|
+
field_number=f.field_number,
|
|
143
|
+
optional=f.optional,
|
|
144
|
+
)
|
|
145
|
+
for f in m.input_stream_fields
|
|
146
|
+
],
|
|
147
|
+
output_fields=[
|
|
148
|
+
worker_pb2.FieldSpec(
|
|
149
|
+
name=f.name,
|
|
150
|
+
proto_type=f.proto_type,
|
|
151
|
+
field_number=f.field_number,
|
|
152
|
+
optional=f.optional,
|
|
153
|
+
)
|
|
154
|
+
for f in m.output_fields
|
|
155
|
+
],
|
|
156
|
+
docstring=reg_op.docstring or "",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
fds = descriptor_pb2.FileDescriptorSet()
|
|
161
|
+
fds.ParseFromString(self._contract.descriptor_set)
|
|
162
|
+
return worker_pb2.DescribeResponse(
|
|
163
|
+
contract=worker_pb2.WorkflowContract(
|
|
164
|
+
ref=common_pb2.WorkflowRef(
|
|
165
|
+
workflow_id=self._contract.workflow_name,
|
|
166
|
+
version="",
|
|
167
|
+
contract_hash=self._contract.contract_hash,
|
|
168
|
+
),
|
|
169
|
+
descriptor_set=fds,
|
|
170
|
+
operations=ops,
|
|
171
|
+
),
|
|
172
|
+
contract_hash=self._contract.contract_hash,
|
|
173
|
+
sdk_version=SDK_VERSION,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
async def Describe(
|
|
177
|
+
self, request: worker_pb2.DescribeRequest, context: Any
|
|
178
|
+
) -> worker_pb2.DescribeResponse:
|
|
179
|
+
return self._describe
|
|
180
|
+
|
|
181
|
+
# -------------------------------------------------------------- Invoke
|
|
182
|
+
async def Invoke(
|
|
183
|
+
self,
|
|
184
|
+
request_iterator: AsyncIterator[worker_pb2.InvokeRequest],
|
|
185
|
+
context: Any,
|
|
186
|
+
) -> AsyncIterator[worker_pb2.InvokeResponse]:
|
|
187
|
+
outbound: asyncio.Queue[worker_pb2.InvokeResponse | None] = asyncio.Queue()
|
|
188
|
+
|
|
189
|
+
async def run() -> None:
|
|
190
|
+
try:
|
|
191
|
+
await self._run_invoke(request_iterator, outbound)
|
|
192
|
+
except _InvokeAbort as abort:
|
|
193
|
+
await outbound.put(_failed(abort.failure))
|
|
194
|
+
except asyncio.CancelledError:
|
|
195
|
+
raise
|
|
196
|
+
except Exception as exc: # user code or protocol surprise
|
|
197
|
+
logger.exception("invoke failed")
|
|
198
|
+
await outbound.put(
|
|
199
|
+
_failed(
|
|
200
|
+
common_pb2.Failure(
|
|
201
|
+
code=common_pb2.FAILURE_CODE_INTERNAL,
|
|
202
|
+
message=str(exc) or exc.__class__.__name__,
|
|
203
|
+
retryable=False,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
finally:
|
|
208
|
+
await outbound.put(None)
|
|
209
|
+
|
|
210
|
+
task = asyncio.create_task(run())
|
|
211
|
+
try:
|
|
212
|
+
while True:
|
|
213
|
+
frame = await outbound.get()
|
|
214
|
+
if frame is None:
|
|
215
|
+
break
|
|
216
|
+
yield frame
|
|
217
|
+
finally:
|
|
218
|
+
if not task.done():
|
|
219
|
+
task.cancel()
|
|
220
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
221
|
+
await task
|
|
222
|
+
|
|
223
|
+
async def _run_invoke(
|
|
224
|
+
self,
|
|
225
|
+
request_iterator: AsyncIterator[worker_pb2.InvokeRequest],
|
|
226
|
+
outbound: asyncio.Queue[worker_pb2.InvokeResponse | None],
|
|
227
|
+
) -> None:
|
|
228
|
+
first = await anext(aiter(request_iterator), None)
|
|
229
|
+
if first is None or first.WhichOneof("frame") != "start":
|
|
230
|
+
raise _InvokeAbort(
|
|
231
|
+
common_pb2.FAILURE_CODE_INVALID_ARGUMENT,
|
|
232
|
+
"First frame must be InvokeStart.",
|
|
233
|
+
)
|
|
234
|
+
start = first.start
|
|
235
|
+
|
|
236
|
+
if start.deadline_ms <= 0:
|
|
237
|
+
raise _InvokeAbort(
|
|
238
|
+
common_pb2.FAILURE_CODE_INVALID_ARGUMENT,
|
|
239
|
+
"deadline_ms must be > 0; there is no 'no deadline'.",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
entry = self._ops_by_rpc.get(start.rpc_name)
|
|
243
|
+
if entry is None:
|
|
244
|
+
known = ", ".join(sorted(self._ops_by_rpc))
|
|
245
|
+
raise _InvokeAbort(
|
|
246
|
+
common_pb2.FAILURE_CODE_NOT_FOUND,
|
|
247
|
+
f"Unknown rpc '{start.rpc_name}'. This worker serves: {known}.",
|
|
248
|
+
)
|
|
249
|
+
reg_op, req_cls, res_cls, manifest_op = entry
|
|
250
|
+
|
|
251
|
+
if start.mode != _MODE_TO_PROTO[reg_op.mode]:
|
|
252
|
+
raise _InvokeAbort(
|
|
253
|
+
common_pb2.FAILURE_CODE_CONTRACT_MISMATCH,
|
|
254
|
+
f"'{manifest_op.function_name}' is {reg_op.mode}; the caller "
|
|
255
|
+
f"asserted a different mode. The client contract is stale.",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
kwargs = self._bind_params(reg_op, req_cls, start.params)
|
|
259
|
+
input_iter = (
|
|
260
|
+
self._input_stream(request_iterator, reg_op, req_cls, outbound)
|
|
261
|
+
if reg_op.mode in (MODE_INPUT_STREAM, MODE_BI_DI)
|
|
262
|
+
else None
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
async with asyncio.timeout(start.deadline_ms / 1000.0):
|
|
267
|
+
if input_iter is not None:
|
|
268
|
+
await self._execute(reg_op, res_cls, kwargs, input_iter, outbound)
|
|
269
|
+
else:
|
|
270
|
+
await self._execute_with_cancel_watch(
|
|
271
|
+
request_iterator, reg_op, res_cls, kwargs, outbound
|
|
272
|
+
)
|
|
273
|
+
except TimeoutError:
|
|
274
|
+
raise _InvokeAbort(
|
|
275
|
+
common_pb2.FAILURE_CODE_DEADLINE_EXCEEDED,
|
|
276
|
+
f"deadline of {start.deadline_ms}ms exceeded",
|
|
277
|
+
retryable=True,
|
|
278
|
+
) from None
|
|
279
|
+
|
|
280
|
+
async def _execute_with_cancel_watch(
|
|
281
|
+
self,
|
|
282
|
+
request_iterator: AsyncIterator[worker_pb2.InvokeRequest],
|
|
283
|
+
reg_op: RegisteredOp,
|
|
284
|
+
res_cls: type[Any],
|
|
285
|
+
kwargs: dict[str, Any],
|
|
286
|
+
outbound: asyncio.Queue[worker_pb2.InvokeResponse | None],
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Run a no-input-stream operation while honouring mid-call cancel.
|
|
289
|
+
|
|
290
|
+
For ``input_stream``/``bi_di`` the input loop reads the request stream
|
|
291
|
+
and sees ``InvokeCancel`` naturally; for ``unary``/``output_stream``
|
|
292
|
+
nothing else reads it, so cancellation needs this watcher — without it
|
|
293
|
+
a cancel frame sat unread and the job ran to completion.
|
|
294
|
+
"""
|
|
295
|
+
exec_task = asyncio.create_task(
|
|
296
|
+
self._execute(reg_op, res_cls, kwargs, None, outbound)
|
|
297
|
+
)
|
|
298
|
+
cancel_task = asyncio.create_task(self._watch_cancel(request_iterator))
|
|
299
|
+
try:
|
|
300
|
+
done, _ = await asyncio.wait(
|
|
301
|
+
{exec_task, cancel_task}, return_when=asyncio.FIRST_COMPLETED
|
|
302
|
+
)
|
|
303
|
+
if cancel_task in done and not exec_task.done():
|
|
304
|
+
reason = cancel_task.result()
|
|
305
|
+
if reason is not None:
|
|
306
|
+
raise _Cancelled(reason)
|
|
307
|
+
# Client closed its send side without cancelling: normal for
|
|
308
|
+
# these modes; let the work finish.
|
|
309
|
+
await exec_task
|
|
310
|
+
finally:
|
|
311
|
+
for task in (exec_task, cancel_task):
|
|
312
|
+
if not task.done():
|
|
313
|
+
task.cancel()
|
|
314
|
+
await asyncio.gather(exec_task, cancel_task, return_exceptions=True)
|
|
315
|
+
|
|
316
|
+
async def _watch_cancel(
|
|
317
|
+
self, request_iterator: AsyncIterator[worker_pb2.InvokeRequest]
|
|
318
|
+
) -> str | None:
|
|
319
|
+
"""Return the cancel reason, or None if the stream ends without one."""
|
|
320
|
+
async for frame in request_iterator:
|
|
321
|
+
if frame.WhichOneof("frame") == "cancel":
|
|
322
|
+
return frame.cancel.reason
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------- request plumbing
|
|
326
|
+
def _bind_params(
|
|
327
|
+
self, reg_op: RegisteredOp, req_cls: type[Any], params_bytes: bytes
|
|
328
|
+
) -> dict[str, Any]:
|
|
329
|
+
msg = req_cls()
|
|
330
|
+
if params_bytes:
|
|
331
|
+
msg.ParseFromString(params_bytes)
|
|
332
|
+
kwargs: dict[str, Any] = {}
|
|
333
|
+
missing: list[str] = []
|
|
334
|
+
for name, _t, has_default, default in reg_op.params:
|
|
335
|
+
if msg.HasField(name):
|
|
336
|
+
kwargs[name] = getattr(msg, name)
|
|
337
|
+
elif has_default:
|
|
338
|
+
kwargs[name] = default
|
|
339
|
+
else:
|
|
340
|
+
missing.append(name)
|
|
341
|
+
if missing:
|
|
342
|
+
raise _InvokeAbort(
|
|
343
|
+
common_pb2.FAILURE_CODE_INVALID_ARGUMENT,
|
|
344
|
+
f"Missing required parameter(s): {', '.join(missing)}.",
|
|
345
|
+
)
|
|
346
|
+
return kwargs
|
|
347
|
+
|
|
348
|
+
async def _input_stream(
|
|
349
|
+
self,
|
|
350
|
+
request_iterator: AsyncIterator[worker_pb2.InvokeRequest],
|
|
351
|
+
reg_op: RegisteredOp,
|
|
352
|
+
req_cls: type[Any],
|
|
353
|
+
outbound: asyncio.Queue[worker_pb2.InvokeResponse | None],
|
|
354
|
+
) -> AsyncIterator[Any]:
|
|
355
|
+
arity = len(reg_op.input_item_types)
|
|
356
|
+
names = [f"item_{i}" for i in range(1, arity + 1)]
|
|
357
|
+
await outbound.put(
|
|
358
|
+
worker_pb2.InvokeResponse(
|
|
359
|
+
input_credit=worker_pb2.InvokeInputCredit(
|
|
360
|
+
additional=INITIAL_INPUT_CREDIT
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
expected_seq = 1
|
|
365
|
+
consumed_since_grant = 0
|
|
366
|
+
async for frame in request_iterator:
|
|
367
|
+
kind = frame.WhichOneof("frame")
|
|
368
|
+
if kind == "cancel":
|
|
369
|
+
raise _Cancelled(frame.cancel.reason)
|
|
370
|
+
if kind != "input":
|
|
371
|
+
raise _InvokeAbort(
|
|
372
|
+
common_pb2.FAILURE_CODE_INVALID_ARGUMENT,
|
|
373
|
+
f"Unexpected mid-stream frame '{kind}'.",
|
|
374
|
+
)
|
|
375
|
+
inp = frame.input
|
|
376
|
+
if inp.final and not inp.payload:
|
|
377
|
+
return
|
|
378
|
+
if inp.seq != expected_seq:
|
|
379
|
+
raise _InvokeAbort(
|
|
380
|
+
common_pb2.FAILURE_CODE_INVALID_ARGUMENT,
|
|
381
|
+
f"Input sequence gap: expected seq={expected_seq}, "
|
|
382
|
+
f"got seq={inp.seq}. Frames were lost or reordered.",
|
|
383
|
+
)
|
|
384
|
+
expected_seq += 1
|
|
385
|
+
|
|
386
|
+
item_msg = req_cls()
|
|
387
|
+
item_msg.ParseFromString(inp.payload)
|
|
388
|
+
values = []
|
|
389
|
+
for n in names:
|
|
390
|
+
if not item_msg.HasField(n):
|
|
391
|
+
raise _InvokeAbort(
|
|
392
|
+
common_pb2.FAILURE_CODE_INVALID_ARGUMENT,
|
|
393
|
+
f"Stream item seq={inp.seq} is missing field '{n}'.",
|
|
394
|
+
)
|
|
395
|
+
values.append(getattr(item_msg, n))
|
|
396
|
+
yield values[0] if arity == 1 else tuple(values)
|
|
397
|
+
|
|
398
|
+
consumed_since_grant += 1
|
|
399
|
+
if consumed_since_grant >= CREDIT_REFRESH_STEP:
|
|
400
|
+
consumed_since_grant = 0
|
|
401
|
+
await outbound.put(
|
|
402
|
+
worker_pb2.InvokeResponse(
|
|
403
|
+
input_credit=worker_pb2.InvokeInputCredit(
|
|
404
|
+
additional=CREDIT_REFRESH_STEP
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
if inp.final:
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
# ------------------------------------------------------------ execute
|
|
412
|
+
def _encode_output(
|
|
413
|
+
self, reg_op: RegisteredOp, res_cls: type[Any], value: Any
|
|
414
|
+
) -> Any:
|
|
415
|
+
msg = res_cls()
|
|
416
|
+
if reg_op.mode in (MODE_OUTPUT_STREAM, MODE_BI_DI):
|
|
417
|
+
arity = len(reg_op.output_item_types)
|
|
418
|
+
items = (value,) if arity == 1 else tuple(value)
|
|
419
|
+
if len(items) != arity:
|
|
420
|
+
raise _InvokeAbort(
|
|
421
|
+
common_pb2.FAILURE_CODE_INTERNAL,
|
|
422
|
+
f"'{reg_op.function_name}' yielded {len(items)} values; "
|
|
423
|
+
f"its contract declares {arity}.",
|
|
424
|
+
)
|
|
425
|
+
for i, v in enumerate(items, start=1):
|
|
426
|
+
setattr(msg, f"item_{i}", v)
|
|
427
|
+
else:
|
|
428
|
+
msg.result = value
|
|
429
|
+
return msg
|
|
430
|
+
|
|
431
|
+
async def _execute(
|
|
432
|
+
self,
|
|
433
|
+
reg_op: RegisteredOp,
|
|
434
|
+
res_cls: type[Any],
|
|
435
|
+
kwargs: dict[str, Any],
|
|
436
|
+
input_iter: AsyncIterator[Any] | None,
|
|
437
|
+
outbound: asyncio.Queue[worker_pb2.InvokeResponse | None],
|
|
438
|
+
) -> None:
|
|
439
|
+
out_seq = 0
|
|
440
|
+
|
|
441
|
+
async def emit(value: Any) -> int:
|
|
442
|
+
nonlocal out_seq
|
|
443
|
+
out_seq += 1
|
|
444
|
+
payload = self._encode_output(reg_op, res_cls, value)
|
|
445
|
+
await outbound.put(
|
|
446
|
+
worker_pb2.InvokeResponse(
|
|
447
|
+
output=worker_pb2.InvokeOutput(
|
|
448
|
+
seq=out_seq, payload=payload.SerializeToString()
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
return out_seq
|
|
453
|
+
|
|
454
|
+
fn = reg_op.fn
|
|
455
|
+
if reg_op.mode == MODE_UNARY:
|
|
456
|
+
result = fn(**kwargs)
|
|
457
|
+
if inspect.isawaitable(result):
|
|
458
|
+
result = await result
|
|
459
|
+
final = await emit(result)
|
|
460
|
+
|
|
461
|
+
elif reg_op.mode == MODE_OUTPUT_STREAM:
|
|
462
|
+
produced = fn(**kwargs)
|
|
463
|
+
if inspect.isawaitable(produced):
|
|
464
|
+
produced = await produced
|
|
465
|
+
if hasattr(produced, "__aiter__"):
|
|
466
|
+
async for value in produced:
|
|
467
|
+
await emit(value)
|
|
468
|
+
else:
|
|
469
|
+
for value in produced:
|
|
470
|
+
await emit(value)
|
|
471
|
+
final = out_seq
|
|
472
|
+
|
|
473
|
+
elif reg_op.mode == MODE_INPUT_STREAM:
|
|
474
|
+
result = fn(input_iter, **kwargs)
|
|
475
|
+
if inspect.isawaitable(result):
|
|
476
|
+
result = await result
|
|
477
|
+
final = await emit(result)
|
|
478
|
+
|
|
479
|
+
else: # MODE_BI_DI
|
|
480
|
+
async for value in fn(input_iter, **kwargs):
|
|
481
|
+
await emit(value)
|
|
482
|
+
final = out_seq
|
|
483
|
+
|
|
484
|
+
await outbound.put(
|
|
485
|
+
worker_pb2.InvokeResponse(
|
|
486
|
+
completed=worker_pb2.InvokeCompleted(final_seq=final)
|
|
487
|
+
)
|
|
488
|
+
)
|
perd_worker/serve.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
r"""Serve a workflow module over WorkerService — the worker container entrypoint.
|
|
2
|
+
|
|
3
|
+
.. code-block:: bash
|
|
4
|
+
|
|
5
|
+
python -m perd_worker.serve --module my_flows --name my_flows --port 50051
|
|
6
|
+
|
|
7
|
+
The module is imported, its registry located (the module-level ``workflow``
|
|
8
|
+
object by convention, or ``--factory`` naming a zero-argument callable that
|
|
9
|
+
returns one), the contract compiled, and the uniform ``WorkerService``
|
|
10
|
+
served. The orchestrator registers against ``Describe`` and recomputes the
|
|
11
|
+
contract hash from the descriptor bytes — nothing here is trusted.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
import importlib
|
|
19
|
+
import logging
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
import grpc
|
|
23
|
+
|
|
24
|
+
from perd.v1 import worker_pb2_grpc
|
|
25
|
+
|
|
26
|
+
from .compiler import compile_contract
|
|
27
|
+
from .registry import WorkflowRegistry
|
|
28
|
+
from .runtime import WorkerServicer
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("perd_worker")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_registry(module_name: str, factory: str | None) -> WorkflowRegistry:
|
|
34
|
+
"""Import ``module_name`` and return its :class:`WorkflowRegistry`.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
SystemExit: with a remediation message if no registry is found.
|
|
38
|
+
"""
|
|
39
|
+
module = importlib.import_module(module_name)
|
|
40
|
+
if factory is not None:
|
|
41
|
+
registry = getattr(module, factory)()
|
|
42
|
+
else:
|
|
43
|
+
registry = getattr(module, "workflow", None)
|
|
44
|
+
if not isinstance(registry, WorkflowRegistry):
|
|
45
|
+
raise SystemExit(
|
|
46
|
+
f"{module_name!r} does not expose a WorkflowRegistry. Either "
|
|
47
|
+
"decorate against `from perd_worker import workflow` (the "
|
|
48
|
+
"module-level registry) or pass --factory <callable> returning one."
|
|
49
|
+
)
|
|
50
|
+
return registry
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def serve(
|
|
54
|
+
registry: WorkflowRegistry, name: str, port: int, *, bind_host: str = "0.0.0.0"
|
|
55
|
+
) -> None:
|
|
56
|
+
contract = compile_contract(name, registry)
|
|
57
|
+
server = grpc.aio.server()
|
|
58
|
+
# The generated grpc glue is untyped by design (see the perd.v1 override).
|
|
59
|
+
worker_pb2_grpc.add_WorkerServiceServicer_to_server( # type: ignore[no-untyped-call]
|
|
60
|
+
WorkerServicer(contract, registry), server
|
|
61
|
+
)
|
|
62
|
+
bound = server.add_insecure_port(f"{bind_host}:{port}")
|
|
63
|
+
await server.start()
|
|
64
|
+
logger.info(
|
|
65
|
+
"serving workflow %s [%s…] on %s:%d (%d ops)",
|
|
66
|
+
name,
|
|
67
|
+
contract.contract_hash[:12],
|
|
68
|
+
bind_host,
|
|
69
|
+
bound,
|
|
70
|
+
len(registry.ops),
|
|
71
|
+
)
|
|
72
|
+
await server.wait_for_termination()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(
|
|
76
|
+
argv: list[str],
|
|
77
|
+
) -> int: # pragma: no cover — container entrypoint, subprocess-only
|
|
78
|
+
logging.basicConfig(
|
|
79
|
+
level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s"
|
|
80
|
+
)
|
|
81
|
+
parser = argparse.ArgumentParser(
|
|
82
|
+
prog="perd_worker.serve", description="Serve a workflow over WorkerService."
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument("--module", required=True, help="python module to import")
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--name", required=True, help="workflow name (becomes workflow_id)"
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--factory",
|
|
90
|
+
default=None,
|
|
91
|
+
help="zero-arg callable on the module returning a WorkflowRegistry "
|
|
92
|
+
"(default: use the module-level `workflow` registry)",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument("--port", type=int, default=50051)
|
|
95
|
+
parser.add_argument("--bind-host", default="0.0.0.0")
|
|
96
|
+
args = parser.parse_args(argv)
|
|
97
|
+
registry = load_registry(args.module, args.factory)
|
|
98
|
+
asyncio.run(serve(registry, args.name, args.port, bind_host=args.bind_host))
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: perd-worker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Author PERD workflows: decorators, contract compiler, WorkerService runtime
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: grpcio-tools>=1.60
|
|
7
|
+
Requires-Dist: grpcio>=1.60
|
|
8
|
+
Requires-Dist: perd-contracts~=0.1.0
|
|
9
|
+
Requires-Dist: protobuf>=6
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
perd_worker/__init__.py,sha256=mQ-3vQDK92nCcHv-I9-FcunLoC_5rT2ojqMeGqjuToM,784
|
|
2
|
+
perd_worker/compiler.py,sha256=lB0-_TIFcZOh9upkWe9xqsFXXaTH3ACMVWMZ35ubbbc,6264
|
|
3
|
+
perd_worker/registry.py,sha256=Afuoct--pu4gVWSZfuadva7YU-6kzSw-wM7jWQ8Z-ME,9165
|
|
4
|
+
perd_worker/runtime.py,sha256=7FDuZu41-yjM2FTiBtIf1YZ5NNXrvij25OFJ02mJh9I,18532
|
|
5
|
+
perd_worker/serve.py,sha256=BX87KMvx_OjMHU00PGeMcR0PZu92y3MecO6xak3MyCM,3505
|
|
6
|
+
perd_worker-0.1.0.dist-info/METADATA,sha256=5ckY0ONaMhrLoJCvtL8XpUK38xiGPjiABKqFDqGuOtc,290
|
|
7
|
+
perd_worker-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
perd_worker-0.1.0.dist-info/RECORD,,
|