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.
@@ -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
+ ]
@@ -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
+ )
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any