positronic-model-server 0.2.2__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.
- positronic_model_server/grpc_wire.py +166 -0
- positronic_model_server/keys.py +10 -0
- positronic_model_server/protocol.py +60 -0
- positronic_model_server/py.typed +0 -0
- positronic_model_server/serialization.py +144 -0
- positronic_model_server/server.py +309 -0
- positronic_model_server/server_wire.py +106 -0
- positronic_model_server/spec.py +78 -0
- positronic_model_server/tests/test_model_server.py +472 -0
- positronic_model_server/tests/test_serialization.py +115 -0
- positronic_model_server/tests/test_spec.py +85 -0
- positronic_model_server/websocket_wire.py +337 -0
- positronic_model_server-0.2.2.dist-info/METADATA +214 -0
- positronic_model_server-0.2.2.dist-info/RECORD +15 -0
- positronic_model_server-0.2.2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The server side of the gRPC wire: one bidirectional stream per session, which carries the ``protocol`` frames,
|
|
2
|
+
and the unary keepalive call beside it."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import AsyncIterator, Mapping
|
|
7
|
+
from urllib.parse import parse_qsl
|
|
8
|
+
|
|
9
|
+
import grpc
|
|
10
|
+
import grpc.aio
|
|
11
|
+
from positronic_wire import wire
|
|
12
|
+
from positronic_wire.grpc import (
|
|
13
|
+
KEEPALIVE_METHOD,
|
|
14
|
+
MESSAGE_SIZE_OPTIONS,
|
|
15
|
+
METHOD,
|
|
16
|
+
PING_EVERY_MS,
|
|
17
|
+
SERVICE,
|
|
18
|
+
SESSION_PATH_HEADER,
|
|
19
|
+
SESSION_QUERY_HEADER,
|
|
20
|
+
target,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from . import server_wire
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GrpcServerConnection(server_wire.ServerConnection):
|
|
29
|
+
"""A server's end of one gRPC session."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
requests: AsyncIterator[bytes],
|
|
34
|
+
context: grpc.aio.ServicerContext,
|
|
35
|
+
headers: Mapping[str, str],
|
|
36
|
+
served_address: server_wire.ServedHostPort,
|
|
37
|
+
):
|
|
38
|
+
self._requests = requests
|
|
39
|
+
self._context = context
|
|
40
|
+
self._headers = headers
|
|
41
|
+
self._served_address = served_address
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def peer(self) -> str:
|
|
45
|
+
return self._context.peer()
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def served_address(self) -> server_wire.ServedHostPort:
|
|
49
|
+
return self._served_address
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def session_path(self) -> str:
|
|
53
|
+
return self._headers.get(SESSION_PATH_HEADER, wire.SESSION_PATH)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def query_params(self) -> list[tuple[str, str]]:
|
|
57
|
+
return parse_qsl(self._headers.get(SESSION_QUERY_HEADER, ''), keep_blank_values=True)
|
|
58
|
+
|
|
59
|
+
async def send(self, message: bytes) -> None:
|
|
60
|
+
try:
|
|
61
|
+
await self._context.write(message)
|
|
62
|
+
except grpc.RpcError as e:
|
|
63
|
+
raise wire.PeerDisconnected(f'{self.peer} ended the session: {e}') from e
|
|
64
|
+
|
|
65
|
+
async def receive(self) -> bytes:
|
|
66
|
+
try:
|
|
67
|
+
return await anext(self._requests)
|
|
68
|
+
except StopAsyncIteration:
|
|
69
|
+
raise wire.PeerDisconnected(f'{self.peer} ended the session') from None
|
|
70
|
+
|
|
71
|
+
async def refuse(self, reason: str) -> None:
|
|
72
|
+
self._context.set_code(grpc.StatusCode.ABORTED)
|
|
73
|
+
self._context.set_details(reason)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _headers(context: grpc.aio.ServicerContext) -> dict[str, str]:
|
|
77
|
+
"""The session metadata, as the header names both wires share. A ``-bin`` key carries no header."""
|
|
78
|
+
return {key: value for key, value in (context.invocation_metadata() or ()) if isinstance(value, str)}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# The shortest ping interval the server answers without a strike: half of `PING_EVERY_MS`, so a client
|
|
82
|
+
# ping that arrives early is never one.
|
|
83
|
+
_PING_TOLERATED_EVERY_MS = PING_EVERY_MS // 2
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _server_options() -> list[tuple[str, int]]:
|
|
87
|
+
return [
|
|
88
|
+
*MESSAGE_SIZE_OPTIONS,
|
|
89
|
+
# gRPC's own defaults, a five-minute floor and two strikes, answer a 20s ping with GOAWAY.
|
|
90
|
+
('grpc.http2.min_ping_interval_without_data_ms', _PING_TOLERATED_EVERY_MS),
|
|
91
|
+
('grpc.http2.max_ping_strikes', 0),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class GrpcWire(server_wire.Wire):
|
|
96
|
+
"""The gRPC wire: sessions on a port of their own, one bidirectional stream each, and the keepalive call.
|
|
97
|
+
|
|
98
|
+
``served_address`` is the host and the port it binds; a port of 0 binds any free one. The port is
|
|
99
|
+
plaintext, and a TLS edge in front of it serves an authenticated endpoint.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, served_address: server_wire.ServedHostPort):
|
|
103
|
+
self._binds = served_address
|
|
104
|
+
self._server: grpc.aio.Server | None = None
|
|
105
|
+
self._served_address: server_wire.ServedHostPort | None = None
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def served_address(self) -> server_wire.ServedHostPort:
|
|
109
|
+
assert self._served_address is not None, 'The gRPC wire has not started'
|
|
110
|
+
return self._served_address
|
|
111
|
+
|
|
112
|
+
async def start(
|
|
113
|
+
self,
|
|
114
|
+
session: server_wire.SessionHandler,
|
|
115
|
+
keepalive: server_wire.KeepaliveHandler,
|
|
116
|
+
authorized: server_wire.Authorized,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Bind the gRPC port, which carries sessions and the keepalive call."""
|
|
119
|
+
|
|
120
|
+
async def serve_one(requests: AsyncIterator[bytes], context: grpc.aio.ServicerContext) -> None:
|
|
121
|
+
headers = _headers(context)
|
|
122
|
+
if not authorized(headers):
|
|
123
|
+
await context.abort(grpc.StatusCode.PERMISSION_DENIED, 'Invalid or missing bearer token')
|
|
124
|
+
conn = GrpcServerConnection(requests, context, headers, self.served_address)
|
|
125
|
+
if conn.session_path != wire.SESSION_PATH:
|
|
126
|
+
refusal = f'No session on {conn.session_path!r}; sessions open on {wire.SESSION_PATH}'
|
|
127
|
+
await context.abort(grpc.StatusCode.NOT_FOUND, refusal)
|
|
128
|
+
try:
|
|
129
|
+
await session(conn)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
# The session reports its own errors over the stream. One that reaches here reaches the client
|
|
132
|
+
# as the status alone.
|
|
133
|
+
logger.error(f'Failed gRPC session: {e}', exc_info=True)
|
|
134
|
+
await context.abort(grpc.StatusCode.INTERNAL, str(e))
|
|
135
|
+
|
|
136
|
+
async def answer_keepalive(_request: bytes, context: grpc.aio.ServicerContext) -> bytes:
|
|
137
|
+
if not authorized(_headers(context)):
|
|
138
|
+
await context.abort(grpc.StatusCode.PERMISSION_DENIED, 'Invalid or missing bearer token')
|
|
139
|
+
return json.dumps({wire.ALIVE_SECONDS: keepalive()}).encode()
|
|
140
|
+
|
|
141
|
+
methods = {
|
|
142
|
+
METHOD: grpc.stream_stream_rpc_method_handler(
|
|
143
|
+
serve_one, request_deserializer=None, response_serializer=None
|
|
144
|
+
),
|
|
145
|
+
KEEPALIVE_METHOD: grpc.unary_unary_rpc_method_handler(
|
|
146
|
+
answer_keepalive, request_deserializer=None, response_serializer=None
|
|
147
|
+
),
|
|
148
|
+
}
|
|
149
|
+
server = grpc.aio.server(options=_server_options())
|
|
150
|
+
server.add_generic_rpc_handlers((grpc.method_handlers_generic_handler(SERVICE, methods),))
|
|
151
|
+
bound = server.add_insecure_port(target(self._binds.host, self._binds.port))
|
|
152
|
+
if bound == 0:
|
|
153
|
+
# gRPC reports a refused bind as port 0, and a server started on it accepts nothing and says nothing.
|
|
154
|
+
raise OSError(f'gRPC could not bind {target(self._binds.host, self._binds.port)}')
|
|
155
|
+
self._server = server
|
|
156
|
+
self._served_address = server_wire.ServedHostPort(self._binds.host, bound)
|
|
157
|
+
await server.start()
|
|
158
|
+
logger.info(f'gRPC sessions on {self._binds.host}:{bound}')
|
|
159
|
+
|
|
160
|
+
async def serve(self) -> None:
|
|
161
|
+
assert self._server is not None, 'The gRPC wire has not started'
|
|
162
|
+
await self._server.wait_for_termination()
|
|
163
|
+
|
|
164
|
+
async def stop(self) -> None:
|
|
165
|
+
if self._server is not None:
|
|
166
|
+
await self._server.stop(grace=None)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Metadata fields written by the model-server wrapper."""
|
|
2
|
+
|
|
3
|
+
HOST = 'host'
|
|
4
|
+
PORT = 'port'
|
|
5
|
+
# The socket path a server bound instead of a host and a port. One of the two pairs is present, never both.
|
|
6
|
+
UDS = 'uds'
|
|
7
|
+
LOCAL_STACK = 'local_stack'
|
|
8
|
+
MODEL_SERVER_VERSION = 'model_server_version'
|
|
9
|
+
SESSION_PARAMS = 'session_params'
|
|
10
|
+
EFFECTIVE_PARAMS = 'effective_params'
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Shared inference message fields, versions, authentication and timing."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum, StrEnum
|
|
4
|
+
|
|
5
|
+
AUTH_HEADER = 'Authorization'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def bearer(token: str) -> str:
|
|
9
|
+
"""The authorization header value for a bearer token."""
|
|
10
|
+
return f'Bearer {token}'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# The top-level keys of every server-to-client message: ``STATUS`` until the server reports itself ready
|
|
14
|
+
# and hands over its ``META``, then one ``RESULT`` or ``ERROR`` per inference.
|
|
15
|
+
STATUS = 'status'
|
|
16
|
+
META = 'meta'
|
|
17
|
+
RESULT = 'result'
|
|
18
|
+
ERROR = 'error'
|
|
19
|
+
# The ready handshake issues an ID. Requests carry it beside the observation, and an end request
|
|
20
|
+
# is acknowledged with the same ID after the model releases the session's state.
|
|
21
|
+
SESSION_ID = 'session_id'
|
|
22
|
+
OBSERVATION = 'observation'
|
|
23
|
+
END_SESSION = 'end_session'
|
|
24
|
+
PROTOCOL_VERSION = 'protocol_version'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProtocolVersion(IntEnum):
|
|
28
|
+
V1 = 1
|
|
29
|
+
V2 = 2
|
|
30
|
+
V3 = 3
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# What the server spent on one inference, beside the ``RESULT`` it answers with: durations in
|
|
34
|
+
# milliseconds on the server's own clock. A server that sends none leaves the round trip undivided.
|
|
35
|
+
TIMING = 'timing'
|
|
36
|
+
|
|
37
|
+
# The phases ``TIMING`` reports. `SERVED` brackets the others: it opens on the observation
|
|
38
|
+
# arriving and closes before the answer is encoded.
|
|
39
|
+
TIMING_SERVED = 'served_ms'
|
|
40
|
+
TIMING_DECODE = 'decode_ms'
|
|
41
|
+
TIMING_INFER = 'infer_ms'
|
|
42
|
+
# Time the observation waited for the inference slot, inside `SERVED`.
|
|
43
|
+
TIMING_QUEUED = 'queued_ms'
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def timing_key(name: str) -> str:
|
|
47
|
+
return f'{name}_ms'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# The loaded model's call, excluding codec conversions.
|
|
51
|
+
MODEL_CALL = 'model'
|
|
52
|
+
# Time the model's own call took, inside `INFER`.
|
|
53
|
+
TIMING_MODEL = timing_key(MODEL_CALL)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ServerStatus(StrEnum):
|
|
57
|
+
READY = 'ready'
|
|
58
|
+
WAITING = 'waiting'
|
|
59
|
+
LOADING = 'loading'
|
|
60
|
+
ERROR = 'error'
|
|
File without changes
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Wire serialization helpers for numpy arrays, images and standard Python types.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- built-in scalars: `str`, `int`, `float`, `bool`, `None`
|
|
5
|
+
- containers: `dict` / `list` / `tuple` recursively composed of supported values
|
|
6
|
+
- numeric numpy values: `numpy.ndarray` and `numpy` scalar types
|
|
7
|
+
- images JPEG-encoded with ``encode_jpeg``
|
|
8
|
+
|
|
9
|
+
Nothing here knows a domain type. A boundary that carries one writes its own msgpack hooks and
|
|
10
|
+
delegates to ``pack`` / ``unpack`` for the rest.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import collections.abc as cabc
|
|
14
|
+
import functools
|
|
15
|
+
import io
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import msgpack
|
|
20
|
+
import numpy as np
|
|
21
|
+
from PIL import Image as PilImage
|
|
22
|
+
|
|
23
|
+
# The envelope each encoded value travels in: a marker naming the type, and the fields carrying it.
|
|
24
|
+
_NDARRAY = b'__ndarray__'
|
|
25
|
+
_NPGENERIC = b'__npgeneric__'
|
|
26
|
+
_JPEG = b'__jpeg__'
|
|
27
|
+
_DATA = b'data'
|
|
28
|
+
_DTYPE = b'dtype'
|
|
29
|
+
_SHAPE = b'shape'
|
|
30
|
+
_FRAMES = b'frames'
|
|
31
|
+
_NDIM = b'ndim'
|
|
32
|
+
|
|
33
|
+
DEFAULT_JPEG_QUALITY = 90
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class JpegEncoding:
|
|
38
|
+
"""An explicit mapping/list path to an RGB image; an empty path selects the whole value."""
|
|
39
|
+
|
|
40
|
+
path: tuple[str | int, ...]
|
|
41
|
+
quality: int = DEFAULT_JPEG_QUALITY
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
if any(type(part) not in (str, int) for part in self.path):
|
|
45
|
+
raise ValueError('Image paths contain only mapping keys and list indices')
|
|
46
|
+
if type(self.quality) is not int or not 0 <= self.quality <= 100:
|
|
47
|
+
raise ValueError('JPEG quality must be an integer between 0 and 100')
|
|
48
|
+
|
|
49
|
+
def encode(self, value: Any) -> Any:
|
|
50
|
+
"""Copy the selected container path and encode its leaf, preserving unrelated values."""
|
|
51
|
+
|
|
52
|
+
def at(node: Any, path: tuple[str | int, ...]) -> Any:
|
|
53
|
+
if not path:
|
|
54
|
+
if not isinstance(node, np.ndarray):
|
|
55
|
+
raise ValueError(f'JPEG path {self.path!r} does not select an array')
|
|
56
|
+
return encode_jpeg(node, self.quality)
|
|
57
|
+
key, *rest = path
|
|
58
|
+
if isinstance(node, cabc.Mapping):
|
|
59
|
+
return {**node, key: at(node[key], tuple(rest))}
|
|
60
|
+
if isinstance(node, list | tuple) and type(key) is int:
|
|
61
|
+
copy = list(node)
|
|
62
|
+
copy[key] = at(node[key], tuple(rest))
|
|
63
|
+
return tuple(copy) if isinstance(node, tuple) else copy
|
|
64
|
+
raise ValueError(f'JPEG path {self.path!r} does not match the value')
|
|
65
|
+
|
|
66
|
+
return at(value, self.path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def encode_images(value: Any, encodings: cabc.Sequence[JpegEncoding]) -> Any:
|
|
70
|
+
"""Encode only the explicitly selected images, in observations or model results."""
|
|
71
|
+
for encoding in encodings:
|
|
72
|
+
value = encoding.encode(value)
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def encode_jpeg(image: np.ndarray, quality: int = DEFAULT_JPEG_QUALITY) -> dict[bytes, Any]:
|
|
77
|
+
"""JPEG-encode RGB images shaped ``(..., H, W, 3)``, casting pixels to uint8.
|
|
78
|
+
|
|
79
|
+
Nonempty 3D/4D inputs use the v1/v2 marker. Extra leading dimensions and empty batches carry
|
|
80
|
+
their full shape. JPEG changes pixels; ordinary arrays passed to ``serialise`` stay lossless.
|
|
81
|
+
"""
|
|
82
|
+
if image.ndim < 3 or image.shape[-1] != 3 or min(image.shape[-3:-1]) < 1:
|
|
83
|
+
raise ValueError('JPEG images must have shape (..., H, W, 3) with positive height and width')
|
|
84
|
+
if type(quality) is not int or not 0 <= quality <= 100:
|
|
85
|
+
raise ValueError('JPEG quality must be an integer between 0 and 100')
|
|
86
|
+
frames = image.reshape((-1, *image.shape[-3:]))
|
|
87
|
+
bufs = []
|
|
88
|
+
for frame in frames:
|
|
89
|
+
buf = io.BytesIO()
|
|
90
|
+
PilImage.fromarray(np.ascontiguousarray(frame, dtype=np.uint8)).save(buf, format='JPEG', quality=quality)
|
|
91
|
+
bufs.append(buf.getvalue())
|
|
92
|
+
if image.ndim > 4 or not bufs:
|
|
93
|
+
return {_JPEG: True, _FRAMES: bufs, _SHAPE: image.shape}
|
|
94
|
+
return {_JPEG: True, _FRAMES: bufs, _NDIM: int(image.ndim)}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _decode_jpeg(marker: dict) -> np.ndarray:
|
|
98
|
+
"""Inverse of ``encode_jpeg``: decode per-frame JPEGs and restore the original shape."""
|
|
99
|
+
if _SHAPE in marker and not marker[_FRAMES]:
|
|
100
|
+
shape = tuple(marker[_SHAPE])
|
|
101
|
+
if len(shape) < 4 or shape[-1] != 3 or min(shape[-3:-1]) < 1 or 0 not in shape[:-3]:
|
|
102
|
+
raise ValueError('An empty JPEG batch must have an empty leading dimension')
|
|
103
|
+
return np.empty(shape, dtype=np.uint8)
|
|
104
|
+
frames = np.stack([np.asarray(PilImage.open(io.BytesIO(buf))) for buf in marker[_FRAMES]])
|
|
105
|
+
if _SHAPE in marker:
|
|
106
|
+
return frames.reshape(marker[_SHAPE])
|
|
107
|
+
return frames if marker[_NDIM] == 4 else frames[0]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def pack(obj):
|
|
111
|
+
"""msgpack's ``default`` hook: one value in its wire form, or unchanged when msgpack handles it."""
|
|
112
|
+
if isinstance(obj, cabc.Mapping):
|
|
113
|
+
return dict(obj)
|
|
114
|
+
if isinstance(obj, np.ndarray | np.generic) and obj.dtype.kind in ('V', 'O', 'c'):
|
|
115
|
+
raise ValueError(f'Unsupported dtype: {obj.dtype}')
|
|
116
|
+
if isinstance(obj, np.ndarray):
|
|
117
|
+
return {_NDARRAY: True, _DATA: obj.tobytes(), _DTYPE: obj.dtype.str, _SHAPE: obj.shape}
|
|
118
|
+
if isinstance(obj, np.generic):
|
|
119
|
+
return {_NPGENERIC: True, _DATA: obj.item(), _DTYPE: obj.dtype.str}
|
|
120
|
+
return obj
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def unpack(obj):
|
|
124
|
+
"""msgpack's ``object_hook``: one decoded mapping restored to the value it encodes."""
|
|
125
|
+
if _NDARRAY in obj:
|
|
126
|
+
return np.ndarray(buffer=obj[_DATA], dtype=np.dtype(obj[_DTYPE]), shape=obj[_SHAPE])
|
|
127
|
+
if _NPGENERIC in obj:
|
|
128
|
+
return np.dtype(obj[_DTYPE]).type(obj[_DATA])
|
|
129
|
+
if _JPEG in obj:
|
|
130
|
+
return _decode_jpeg(obj)
|
|
131
|
+
return obj
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def serialise(obj: Any) -> bytes:
|
|
135
|
+
packed = msgpack.packb(obj, default=pack)
|
|
136
|
+
assert packed is not None
|
|
137
|
+
return packed
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
deserialise = functools.partial(msgpack.unpackb, object_hook=unpack)
|
|
141
|
+
|
|
142
|
+
# Aliases for consistency
|
|
143
|
+
serialize = serialise
|
|
144
|
+
deserialize = deserialise
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Serve a warmed native model and its sessions in one process."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import math
|
|
8
|
+
import time
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from importlib.metadata import version
|
|
14
|
+
from typing import Any, TypeVar
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
from positronic_wire import wire
|
|
18
|
+
|
|
19
|
+
from . import keys, protocol, serialization, server_wire, spec
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
T = TypeVar('T')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Session(ABC):
|
|
26
|
+
"""A prepared, warm session that owns its state while sharing loaded weights.
|
|
27
|
+
|
|
28
|
+
``close`` releases that state. ``output_images`` selects result values for JPEG serialization.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
client_stack: dict[str, Any],
|
|
34
|
+
*,
|
|
35
|
+
metadata: dict[str, Any] | None = None,
|
|
36
|
+
output_images: Sequence[serialization.JpegEncoding] = (),
|
|
37
|
+
):
|
|
38
|
+
self.client_stack = client_stack
|
|
39
|
+
self.metadata = {} if metadata is None else metadata
|
|
40
|
+
self.output_images = output_images
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def infer(self, observation: Any) -> Any: ...
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
"""Release this session's resources after its operations finish."""
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Model(ABC):
|
|
51
|
+
"""Loaded, warm model resources and the operation that prepares each session.
|
|
52
|
+
|
|
53
|
+
``parameters`` declares accepted session names and their defaults. ``prepare_session`` receives
|
|
54
|
+
resolved values, validates their model-specific meaning and returns only once that session is warm.
|
|
55
|
+
A preparation that raises must release any resources it acquired before returning a session.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, *, parameters: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None):
|
|
59
|
+
self.parameters = {} if parameters is None else parameters
|
|
60
|
+
self.metadata = {} if metadata is None else metadata
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def prepare_session(self, params: dict[str, Any]) -> Session: ...
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
"""Release the model's resources after its sessions finish."""
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ModelServer:
|
|
71
|
+
"""One model, serialized operations, and independently configured transport listeners.
|
|
72
|
+
|
|
73
|
+
Loading, session preparation, inference and cleanup all run on one worker thread. The network
|
|
74
|
+
loop remains available during these operations. The factory must finish shared warm-up before
|
|
75
|
+
returning its model; listeners bind afterwards. Each session must retain its own parameters and
|
|
76
|
+
history, and its warm-up must leave the session in its intended initial state.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
WAITING_INTERVAL_SEC = 5.0
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self, load_model: Callable[[], Model], *, idle_timeout_min: float | None = None, auth_token: str | None = None
|
|
83
|
+
):
|
|
84
|
+
if auth_token is not None and not (auth_token and all('!' <= c <= '~' for c in auth_token)):
|
|
85
|
+
raise ValueError('auth_token must be non-empty printable ASCII without spaces; pass None to serve open')
|
|
86
|
+
if idle_timeout_min is not None and not 0 < idle_timeout_min < math.inf:
|
|
87
|
+
raise ValueError('idle_timeout_min must be positive and finite, or None')
|
|
88
|
+
self._load_model = load_model
|
|
89
|
+
self._auth_token = auth_token
|
|
90
|
+
self._idle_seconds = None if idle_timeout_min is None else idle_timeout_min * 60
|
|
91
|
+
self._model: Model | None = None
|
|
92
|
+
self._worker: ThreadPoolExecutor | None = None
|
|
93
|
+
self._stop: Future[None] = Future()
|
|
94
|
+
self._sessions: set[asyncio.Task] = set()
|
|
95
|
+
self._last_activity = time.monotonic()
|
|
96
|
+
|
|
97
|
+
def _authorized(self, headers: Mapping[str, str]) -> bool:
|
|
98
|
+
if self._auth_token is None:
|
|
99
|
+
return True
|
|
100
|
+
value = headers.get(protocol.AUTH_HEADER.lower(), '')
|
|
101
|
+
return hmac.compare_digest(value.encode(), protocol.bearer(self._auth_token).encode())
|
|
102
|
+
|
|
103
|
+
def _keepalive(self) -> int | None:
|
|
104
|
+
self._last_activity = time.monotonic()
|
|
105
|
+
return None if self._idle_seconds is None else math.floor(self._idle_seconds)
|
|
106
|
+
|
|
107
|
+
async def _call(self, function: Callable[[], T]) -> T:
|
|
108
|
+
"""Keep an operation alive until it finishes, including while its caller is cancelled."""
|
|
109
|
+
assert self._worker is not None
|
|
110
|
+
pending = asyncio.get_running_loop().run_in_executor(self._worker, function)
|
|
111
|
+
try:
|
|
112
|
+
return await asyncio.shield(pending)
|
|
113
|
+
except asyncio.CancelledError:
|
|
114
|
+
await pending
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
async def _answer(self, conn: server_wire.ServerConnection, session: Session, session_id: str) -> None:
|
|
118
|
+
while True:
|
|
119
|
+
raw = await conn.receive()
|
|
120
|
+
self._last_activity = time.monotonic()
|
|
121
|
+
started = time.perf_counter_ns()
|
|
122
|
+
request = serialization.deserialise(raw)
|
|
123
|
+
if request[protocol.SESSION_ID] != session_id:
|
|
124
|
+
raise ValueError('The session ID does not belong to this connection')
|
|
125
|
+
if request.get(protocol.END_SESSION) is True:
|
|
126
|
+
return
|
|
127
|
+
observation = request[protocol.OBSERVATION]
|
|
128
|
+
decoded = time.perf_counter_ns()
|
|
129
|
+
await conn.send(await self._infer_response(session, observation, started, decoded))
|
|
130
|
+
|
|
131
|
+
async def _infer_response(self, session: Session, observation: Any, started: int, decoded: int) -> bytes:
|
|
132
|
+
timing = {protocol.TIMING_DECODE: (decoded - started) / 1e6}
|
|
133
|
+
|
|
134
|
+
def infer() -> bytes:
|
|
135
|
+
called = time.perf_counter_ns()
|
|
136
|
+
timing[protocol.TIMING_QUEUED] = (called - decoded) / 1e6
|
|
137
|
+
try:
|
|
138
|
+
result = session.infer(observation)
|
|
139
|
+
finally:
|
|
140
|
+
timing[protocol.TIMING_MODEL] = timing[protocol.TIMING_INFER] = (time.perf_counter_ns() - called) / 1e6
|
|
141
|
+
timing[protocol.TIMING_SERVED] = (time.perf_counter_ns() - started) / 1e6
|
|
142
|
+
# Pack before releasing the worker: native results can reference reusable model buffers.
|
|
143
|
+
result = serialization.encode_images(result, session.output_images)
|
|
144
|
+
return serialization.serialise({protocol.RESULT: result, protocol.TIMING: timing})
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
return await self._call(infer)
|
|
148
|
+
except Exception as error:
|
|
149
|
+
logger.exception('Inference failed')
|
|
150
|
+
return serialization.serialise({protocol.ERROR: str(error)})
|
|
151
|
+
|
|
152
|
+
async def _prepare(self, conn: server_wire.ServerConnection, prepare: Callable[[], None]) -> None:
|
|
153
|
+
pending = asyncio.create_task(self._call(prepare))
|
|
154
|
+
try:
|
|
155
|
+
while not pending.done():
|
|
156
|
+
await conn.send(serialization.serialise({protocol.STATUS: protocol.ServerStatus.WAITING}))
|
|
157
|
+
await asyncio.wait({pending}, timeout=self.WAITING_INTERVAL_SEC)
|
|
158
|
+
await pending
|
|
159
|
+
finally:
|
|
160
|
+
# A disconnected opener still owns the session being prepared until its cleanup completes.
|
|
161
|
+
await asyncio.shield(pending)
|
|
162
|
+
|
|
163
|
+
@asynccontextmanager
|
|
164
|
+
async def _prepared_session(self, conn: server_wire.ServerConnection, params: dict[str, Any]):
|
|
165
|
+
model = self._model
|
|
166
|
+
assert model is not None
|
|
167
|
+
session: Session | None = None
|
|
168
|
+
|
|
169
|
+
def prepare() -> None:
|
|
170
|
+
nonlocal session
|
|
171
|
+
session = model.prepare_session(json.loads(json.dumps(params, allow_nan=False)))
|
|
172
|
+
spec.validate(session.client_stack)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
await self._prepare(conn, prepare)
|
|
176
|
+
assert session is not None
|
|
177
|
+
yield session
|
|
178
|
+
finally:
|
|
179
|
+
if session is not None:
|
|
180
|
+
await self._call(session.close)
|
|
181
|
+
|
|
182
|
+
async def _exchange_session(self, conn: server_wire.ServerConnection) -> None:
|
|
183
|
+
model = self._model
|
|
184
|
+
assert model is not None
|
|
185
|
+
requested = spec.parse_params(conn.query_params)
|
|
186
|
+
params = spec.resolve_params(model.parameters, requested)
|
|
187
|
+
session_id = uuid4().hex
|
|
188
|
+
async with self._prepared_session(conn, params) as session:
|
|
189
|
+
metadata = {
|
|
190
|
+
**conn.served_address.meta,
|
|
191
|
+
**model.metadata,
|
|
192
|
+
**session.metadata,
|
|
193
|
+
keys.LOCAL_STACK: session.client_stack,
|
|
194
|
+
keys.SESSION_PARAMS: requested,
|
|
195
|
+
keys.EFFECTIVE_PARAMS: params,
|
|
196
|
+
keys.MODEL_SERVER_VERSION: version('positronic-model-server'),
|
|
197
|
+
}
|
|
198
|
+
await conn.send(
|
|
199
|
+
serialization.serialise({
|
|
200
|
+
protocol.STATUS: protocol.ServerStatus.READY,
|
|
201
|
+
protocol.PROTOCOL_VERSION: protocol.ProtocolVersion.V3,
|
|
202
|
+
protocol.SESSION_ID: session_id,
|
|
203
|
+
protocol.META: metadata,
|
|
204
|
+
})
|
|
205
|
+
)
|
|
206
|
+
await self._answer(conn, session, session_id)
|
|
207
|
+
await conn.send(serialization.serialise({protocol.SESSION_ID: session_id, protocol.END_SESSION: True}))
|
|
208
|
+
|
|
209
|
+
async def _serve_session(self, conn: server_wire.ServerConnection) -> None:
|
|
210
|
+
task = asyncio.current_task()
|
|
211
|
+
assert task is not None
|
|
212
|
+
self._sessions.add(task)
|
|
213
|
+
self._last_activity = time.monotonic()
|
|
214
|
+
try:
|
|
215
|
+
await self._exchange_session(conn)
|
|
216
|
+
except wire.PeerDisconnected:
|
|
217
|
+
logger.info('Session disconnected: %s', conn.peer)
|
|
218
|
+
except Exception as error:
|
|
219
|
+
logger.exception('Session failed: %s', conn.peer)
|
|
220
|
+
try:
|
|
221
|
+
await conn.send(
|
|
222
|
+
serialization.serialise({protocol.STATUS: protocol.ServerStatus.ERROR, protocol.ERROR: str(error)})
|
|
223
|
+
)
|
|
224
|
+
await conn.refuse(str(error))
|
|
225
|
+
except wire.PeerDisconnected:
|
|
226
|
+
logger.info('Session disconnected before receiving its error: %s', conn.peer)
|
|
227
|
+
finally:
|
|
228
|
+
self._sessions.discard(task)
|
|
229
|
+
self._last_activity = time.monotonic()
|
|
230
|
+
|
|
231
|
+
async def _idle_watchdog(self) -> None:
|
|
232
|
+
assert self._idle_seconds is not None
|
|
233
|
+
while True:
|
|
234
|
+
await asyncio.sleep(min(self._idle_seconds, 30))
|
|
235
|
+
if not self._sessions and time.monotonic() - self._last_activity >= self._idle_seconds:
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
def _load(self) -> None:
|
|
239
|
+
self._model = self._load_model()
|
|
240
|
+
spec.resolve_params(self._model.parameters, {})
|
|
241
|
+
|
|
242
|
+
async def _run(self, wires: Sequence[server_wire.Wire], on_ready: Callable[[], None] | None) -> None:
|
|
243
|
+
started: list[server_wire.Wire] = []
|
|
244
|
+
serving: list[asyncio.Task] = []
|
|
245
|
+
ending: list[asyncio.Future] = []
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
await self._call(self._load)
|
|
249
|
+
if self._stop.cancelled():
|
|
250
|
+
return
|
|
251
|
+
for transport in wires:
|
|
252
|
+
started.append(transport)
|
|
253
|
+
await transport.start(self._serve_session, self._keepalive, self._authorized)
|
|
254
|
+
self._last_activity = time.monotonic()
|
|
255
|
+
serving = [asyncio.create_task(transport.serve()) for transport in started]
|
|
256
|
+
ending = [asyncio.wrap_future(self._stop)]
|
|
257
|
+
if self._idle_seconds is not None:
|
|
258
|
+
ending.append(asyncio.create_task(self._idle_watchdog()))
|
|
259
|
+
if on_ready is not None:
|
|
260
|
+
on_ready()
|
|
261
|
+
await asyncio.wait(serving + ending, return_when=asyncio.FIRST_COMPLETED)
|
|
262
|
+
finally:
|
|
263
|
+
for task in ending:
|
|
264
|
+
task.cancel()
|
|
265
|
+
await asyncio.gather(*ending, return_exceptions=True)
|
|
266
|
+
await self._finish(started, serving)
|
|
267
|
+
|
|
268
|
+
async def _finish(self, started: Sequence[server_wire.Wire], serving: Sequence[asyncio.Task]) -> None:
|
|
269
|
+
stops = await asyncio.gather(*(transport.stop() for transport in started), return_exceptions=True)
|
|
270
|
+
for stopped, task in zip(stops, serving, strict=False):
|
|
271
|
+
if isinstance(stopped, BaseException):
|
|
272
|
+
task.cancel()
|
|
273
|
+
outcomes = await asyncio.gather(*serving, return_exceptions=True)
|
|
274
|
+
for task in self._sessions:
|
|
275
|
+
if not task.cancelling():
|
|
276
|
+
task.cancel()
|
|
277
|
+
sessions = await asyncio.gather(*self._sessions, return_exceptions=True)
|
|
278
|
+
failures = [value for value in [*stops, *sessions, *outcomes] if isinstance(value, Exception)]
|
|
279
|
+
try:
|
|
280
|
+
if self._model is not None:
|
|
281
|
+
await self._call(self._model.close)
|
|
282
|
+
except Exception as error:
|
|
283
|
+
failures.append(error)
|
|
284
|
+
finally:
|
|
285
|
+
self._model = None
|
|
286
|
+
for error in failures[1:]:
|
|
287
|
+
logger.error('Additional shutdown failure', exc_info=error)
|
|
288
|
+
if failures:
|
|
289
|
+
raise failures[0]
|
|
290
|
+
|
|
291
|
+
def serve(self, wires: Sequence[server_wire.Wire], on_ready: Callable[[], None] | None = None) -> None:
|
|
292
|
+
"""Load and warm once, then serve until shutdown, idle expiry, or a transport ends."""
|
|
293
|
+
if not wires:
|
|
294
|
+
raise ValueError('At least one transport is required')
|
|
295
|
+
if self._worker is not None:
|
|
296
|
+
raise RuntimeError('This server is already running')
|
|
297
|
+
with ThreadPoolExecutor(max_workers=1, thread_name_prefix='model') as worker:
|
|
298
|
+
self._worker = worker
|
|
299
|
+
try:
|
|
300
|
+
asyncio.run(self._run(wires, on_ready))
|
|
301
|
+
except KeyboardInterrupt:
|
|
302
|
+
logger.info('Server interrupted')
|
|
303
|
+
finally:
|
|
304
|
+
self._worker = None
|
|
305
|
+
self._stop = Future()
|
|
306
|
+
|
|
307
|
+
def shutdown(self) -> None:
|
|
308
|
+
"""Request shutdown from any thread; running model work finishes before cleanup."""
|
|
309
|
+
self._stop.cancel()
|