Skip to content

Commit e31841b

Browse files
PR-B1 (ADR 0008 Phase B): gRPC server stub — Create / Close / GetSessionInfo
First PR of Phase B. Lands the gRPC RuntimeService surface implementing three of the five ADR 0008 \u00a72.2 RPCs against the SessionStore from PR-A2 / PR-A3b. AppendTokens and Generate are explicitly NOT implemented yet (PR-B2 / PR-B3 territory); calling them returns gRPC UNIMPLEMENTED, which is the framework default for non-overridden servicer methods and the right placeholder per \u00a72.10 'no graceful degradation'. The server is asyncio (grpc.aio) per \u00a72.5: all RPCs run on a single event loop, serializing SessionStore access at that layer. New files: inference_engine/server/grpc_app.py (215 lines) - RuntimeServiceServicer with CreateSession / CloseSession / GetSessionInfo implementations. - Error mapping per \u00a72.6 / \u00a72.10: SessionNotFoundError -> NOT_FOUND PoolExhausted -> RESOURCE_EXHAUSTED InvariantViolation / ValueError mapping land in PR-B2 when AppendTokens triggers them; not wired here to keep the diff minimal and 100%-tested. - GrpcServerConfig (frozen dataclass) with bind_address default 127.0.0.1:50051 (\u00a78 OQ-5 default — loopback only). - create_grpc_server(session_store, config) factory; built but not started, so callers control the start / stop lifecycle. inference_engine/server/proto_gen/ (generated) - kakeya/v1/runtime_pb2.py, runtime_pb2.pyi, runtime_pb2_grpc.py - Empty __init__.py at every package level so 'from inference_engine.server.proto_gen.kakeya.v1 import runtime_pb2' works under Python's package layout. - Generated by scripts/regenerate_proto_stubs.sh; CI's proto-stub-drift job re-runs the script and 'git diff --exit-code' to catch silent drift between proto/ and stubs. scripts/regenerate_proto_stubs.sh - Canonical regeneration command. Patches protoc's absolute 'from kakeya.v1 import runtime_pb2' to relative 'from . import runtime_pb2' (a known protoc/Python layout issue: protocolbuffers/protobuf#1491). tests/inference_engine/server/test_grpc_app.py (22 tests) - Real grpc.aio.server bound to 127.0.0.1:0 (random free port); real grpc.aio.insecure_channel client. End-to-end coverage of every reachable code path including PoolExhausted and SessionNotFoundError mappings. - Two regression tests for AppendTokens / Generate returning UNIMPLEMENTED (so a future PR-B2 / PR-B3 that forgets to implement them is caught at PR review time, not in production). Modified files: requirements.txt + grpcio>=1.65,<2.0 + grpcio-tools>=1.65,<2.0 (regen + drift-check) .coveragerc omit += inference_engine/server/proto_gen/* (generated stubs are not the surface we own; coverage on them is not meaningful or stable across protoc versions) .github/workflows/ci.yaml + proto-stub-drift job + grpc_app + proto_gen.kakeya.v1.runtime_pb2{,_grpc} imports added to package-import-smoke Local verification (Linux VM, py3.12): Linux CI gate: 562 passed (was 540 + 22 new), coverage 100.00 % on 1382 stmts (was 1336 + 46 new in grpc_app.py). Regen script idempotent: scripts/regenerate_proto_stubs.sh produces byte-identical stubs to the committed ones. Servicer methods exercised end-to-end via real gRPC channel (no mocks of the SUT; ServicerContext is the framework's, not a test double). Per ADR 0008 \u00a79: this PR is Linux-only — no MLX paths touched, no hardware-specific code. \u00a79 last-paragraph carve-out invoked: 'Linux-only path' justification, no Mac M4 integration test report needed. The Mac-M4-only suite (tests/backends/mlx/test_verifier.py etc.) is unaffected by this PR's diff. Next PR after merge: PR-B2 (ADR 0008 \u00a76.2): wire AppendTokens through SessionStore + the \u00a72.3 byte-exact prefill-incremental contract. Adds InvariantViolation -> FAILED_PRECONDITION mapping (not reachable in PR-B1's RPC surface but reachable in AppendTokens). Linux-only path; \u00a79 carve-out continues to apply until PR-B3's Generate touches the verifier sampler on real MLX. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent 0b1265f commit e31841b

12 files changed

Lines changed: 1224 additions & 0 deletions

File tree

.coveragerc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ branch = false
3030
# tested library code).
3131
omit =
3232
kv_cache_proposer/run_demo.py
33+
# Generated gRPC / protobuf stubs (PR-B1 of ADR 0008). The
34+
# canonical regeneration command is
35+
# ``scripts/regenerate_proto_stubs.sh``; CI's
36+
# ``proto-stub-drift`` step verifies that the committed stubs
37+
# match what the script would produce. The stubs are not the
38+
# surface we own — protoc owns them — so coverage on them is
39+
# neither meaningful nor stable across protoc versions.
40+
inference_engine/server/proto_gen/*
3341

3442
[report]
3543
exclude_lines =

.github/workflows/ci.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ jobs:
139139
import inference_engine.pipeline.coordinator; \
140140
import inference_engine.session; \
141141
import inference_engine.session.store; \
142+
import inference_engine.server.grpc_app; \
143+
import inference_engine.server.proto_gen.kakeya.v1.runtime_pb2; \
144+
import inference_engine.server.proto_gen.kakeya.v1.runtime_pb2_grpc; \
142145
import inference_engine.proposer; \
143146
import inference_engine.proposer.sparse_logits; \
144147
import inference_engine.backends.mlx.env; \
@@ -214,3 +217,42 @@ jobs:
214217
printf '%s\n' "$diff_output"
215218
exit 1
216219
fi
220+
221+
proto-stub-drift:
222+
name: proto stub drift
223+
runs-on: ubuntu-latest
224+
# ADR 0008 PR-B1: the committed Python stubs under
225+
# inference_engine/server/proto_gen/ MUST be byte-identical to what
226+
# scripts/regenerate_proto_stubs.sh produces from the .proto. This
227+
# job re-runs the script, then `git diff --exit-code` fails CI if
228+
# anything changed. Catches drift between the .proto contract and
229+
# the SDK-consumed Python API.
230+
steps:
231+
- name: Check out
232+
uses: actions/checkout@v4
233+
234+
- name: Set up Python 3.12
235+
uses: actions/setup-python@v5
236+
with:
237+
python-version: "3.12"
238+
cache: pip
239+
240+
- name: Install grpcio-tools
241+
run: |
242+
python -m pip install --upgrade pip
243+
# Pin the same grpcio-tools range as requirements.txt so the
244+
# stubs we regenerate match what production uses. If
245+
# grpcio-tools updates and starts producing different stub
246+
# bytes, this job catches it as a drift before merge.
247+
pip install 'grpcio>=1.65,<2.0' 'grpcio-tools>=1.65,<2.0'
248+
249+
- name: Regenerate stubs
250+
run: bash scripts/regenerate_proto_stubs.sh
251+
252+
- name: Fail if regenerated stubs differ from committed stubs
253+
run: |
254+
if ! git diff --exit-code -- inference_engine/server/proto_gen/; then
255+
echo "::error::Committed stubs are out of date with proto/."
256+
echo "Run scripts/regenerate_proto_stubs.sh locally and commit."
257+
exit 1
258+
fi
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
"""gRPC runtime service for Kakeya — ADR 0008 PR-B1 (Phase B).
2+
3+
Implements three RPCs from `proto/kakeya/v1/runtime.proto` against
4+
the :class:`SessionStore` from PR-A2 / PR-A3b:
5+
6+
- ``CreateSession``
7+
- ``CloseSession``
8+
- ``GetSessionInfo``
9+
10+
The remaining RPCs (``AppendTokens``, ``Generate``) are intentionally
11+
*not* implemented in this PR; calling them returns
12+
``UNIMPLEMENTED`` (the gRPC framework's default for un-overridden
13+
servicer methods, which is the correct stop-gap behavior per ADR
14+
0008 §2.10 "no graceful degradation"). They land in PR-B2 / PR-B3.
15+
16+
The server is **asyncio-based** (``grpc.aio``) per ADR 0008 §2.5:
17+
all RPCs run on a single event loop and serialize SessionStore
18+
access at the asyncio layer. The store itself is single-threaded;
19+
multi-worker support is v0.4 scope (ADR 0008 §4.5).
20+
21+
This module deliberately does *not* depend on FastAPI or the
22+
deprecated HTTP shim. The two surfaces share only the
23+
``SessionStore`` (or, in the deprecated shim's case, will share it
24+
once PR-D1 lands). They can be started together by the same CLI
25+
entry point or separately; the factory ``create_grpc_server`` is
26+
self-contained so any of those wirings is possible without code
27+
change here.
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import logging
33+
from dataclasses import dataclass
34+
from typing import Optional
35+
36+
import grpc
37+
38+
from inference_engine.memory.pool import PoolExhausted
39+
from inference_engine.server.proto_gen.kakeya.v1 import (
40+
runtime_pb2,
41+
runtime_pb2_grpc,
42+
)
43+
from inference_engine.session import (
44+
InvariantViolation,
45+
SessionNotFoundError,
46+
SessionStore,
47+
)
48+
49+
_logger = logging.getLogger(__name__)
50+
51+
DEFAULT_BIND_ADDRESS = "127.0.0.1:50051"
52+
"""Default bind address for the gRPC server.
53+
54+
Per ADR 0008 §8 OQ-5 default while unresolved: bind to loopback
55+
only. Multi-tenant deployments that need to reach the runtime from
56+
another host configure a different bind address explicitly and add
57+
the appropriate auth (also OQ-5 scope; v0.4)."""
58+
59+
60+
@dataclass(frozen=True)
61+
class GrpcServerConfig:
62+
"""Configuration for the gRPC runtime server.
63+
64+
Kept as a frozen dataclass so the configuration is auditable at
65+
construction time and accidentally re-binding mid-session is a
66+
structural impossibility.
67+
"""
68+
69+
bind_address: str = DEFAULT_BIND_ADDRESS
70+
"""`host:port` to bind. Defaults to loopback per ADR 0008 §8 OQ-5."""
71+
72+
max_concurrent_rpcs: Optional[int] = None
73+
"""Per-server cap on in-flight RPCs.
74+
75+
``None`` defers to grpc.aio's default. PR-B1 leaves this
76+
unconstrained because the per-session concurrency cap (ADR 0008
77+
§2.5 ``max_concurrent``) is enforced at the SessionStore /
78+
Generate level, not at the RPC dispatch level. Set explicitly
79+
when running on a constrained host where the gRPC handler
80+
threads themselves are the bottleneck."""
81+
82+
83+
class RuntimeServiceServicer(runtime_pb2_grpc.RuntimeServiceServicer):
84+
"""RuntimeService implementation backed by a :class:`SessionStore`.
85+
86+
Error mapping (per ADR 0008 §2.6 / §2.10 — every typed
87+
SessionStore error becomes a typed gRPC status, no silent
88+
fallback):
89+
90+
+-----------------------------+----------------------------------+
91+
| SessionStoreError subclass | gRPC status |
92+
+=============================+==================================+
93+
| SessionNotFoundError | NOT_FOUND |
94+
+-----------------------------+----------------------------------+
95+
| InvariantViolation | FAILED_PRECONDITION |
96+
+-----------------------------+----------------------------------+
97+
| (PoolExhausted from pool) | RESOURCE_EXHAUSTED |
98+
+-----------------------------+----------------------------------+
99+
| ValueError (token-id range) | INVALID_ARGUMENT |
100+
+-----------------------------+----------------------------------+
101+
102+
Of these, PR-B1's three RPCs only ever raise
103+
SessionNotFoundError or PoolExhausted (Create can raise
104+
PoolExhausted; Close and GetSessionInfo can raise
105+
SessionNotFoundError). InvariantViolation and ValueError become
106+
reachable in PR-B2 (``AppendTokens``) and are wired here for
107+
forward-compatibility — but un-tested in PR-B1 because the
108+
RPC paths that trigger them do not exist yet.
109+
"""
110+
111+
def __init__(self, session_store: SessionStore) -> None:
112+
self._store = session_store
113+
114+
async def CreateSession( # noqa: N802 — gRPC-generated method casing
115+
self,
116+
request: runtime_pb2.CreateSessionRequest,
117+
context: grpc.aio.ServicerContext,
118+
) -> runtime_pb2.CreateSessionResponse:
119+
"""Allocate a new session; return its server-issued id.
120+
121+
ADR 0008 §2.2 contract item 1: clients have no input on the
122+
``session_id`` value; this RPC is the only producer.
123+
"""
124+
try:
125+
session = self._store.create_session(
126+
eos_token_ids=list(request.eos_token_ids),
127+
client_label=request.client_label,
128+
)
129+
except PoolExhausted as exc:
130+
await context.abort(
131+
grpc.StatusCode.RESOURCE_EXHAUSTED,
132+
f"slab pool exhausted: {exc}",
133+
)
134+
return runtime_pb2.CreateSessionResponse(
135+
session_id=session.session_id,
136+
)
137+
138+
async def CloseSession( # noqa: N802
139+
self,
140+
request: runtime_pb2.CloseSessionRequest,
141+
context: grpc.aio.ServicerContext,
142+
) -> runtime_pb2.CloseSessionResponse:
143+
"""Close a session and return its final history length.
144+
145+
Returns NOT_FOUND if the session is unknown (closed,
146+
evicted, never existed — caller cannot distinguish, by
147+
ADR 0008 §2.6 design).
148+
"""
149+
try:
150+
final_length = self._store.close_session(request.session_id)
151+
except SessionNotFoundError as exc:
152+
await context.abort(grpc.StatusCode.NOT_FOUND, str(exc))
153+
return runtime_pb2.CloseSessionResponse(
154+
final_history_length=final_length,
155+
)
156+
157+
async def GetSessionInfo( # noqa: N802
158+
self,
159+
request: runtime_pb2.GetSessionInfoRequest,
160+
context: grpc.aio.ServicerContext,
161+
) -> runtime_pb2.GetSessionInfoResponse:
162+
"""Return diagnostic counters for a session.
163+
164+
Surfaces ADR 0008 §2.8's anomaly-invariant counters; healthy
165+
operation reports zero for both INV-1 and INV-2. Non-zero
166+
values are paging-grade — the session itself has by then
167+
been removed from the store, and a follow-up
168+
``GetSessionInfo`` on the same id will return NOT_FOUND.
169+
"""
170+
try:
171+
session = self._store.get_session(request.session_id)
172+
except SessionNotFoundError as exc:
173+
await context.abort(grpc.StatusCode.NOT_FOUND, str(exc))
174+
return runtime_pb2.GetSessionInfoResponse(
175+
history_length=session.history_length,
176+
kv_live_bytes=session.kv_live_bytes(),
177+
cache_invariant_inv1_violations=session.inv1_violations,
178+
cache_invariant_inv2_violations=session.inv2_violations,
179+
idle_seconds=session.idle_seconds,
180+
)
181+
182+
183+
def create_grpc_server(
184+
*,
185+
session_store: SessionStore,
186+
config: Optional[GrpcServerConfig] = None,
187+
) -> grpc.aio.Server:
188+
"""Build, but do not start, a configured gRPC asyncio server.
189+
190+
The caller invokes ``await server.start()`` and ``await
191+
server.wait_for_termination()`` (or ``await server.stop(grace)``
192+
for shutdown). This split is intentional: tests construct
193+
servers without starting them, and the eventual production
194+
entry point may want to wire signal handlers between
195+
construction and start.
196+
197+
The bound port is observable via the returned server's
198+
``add_insecure_port`` return value; callers that need the port
199+
should use the lower-level ``grpc.aio.server()`` directly,
200+
because PR-B1 returns the constructed server with the port
201+
already bound (so the asyncio event loop sees the listen socket
202+
immediately).
203+
"""
204+
if config is None:
205+
config = GrpcServerConfig()
206+
server = grpc.aio.server(
207+
maximum_concurrent_rpcs=config.max_concurrent_rpcs,
208+
)
209+
runtime_pb2_grpc.add_RuntimeServiceServicer_to_server(
210+
RuntimeServiceServicer(session_store),
211+
server,
212+
)
213+
server.add_insecure_port(config.bind_address)
214+
_logger.info("gRPC RuntimeService bound to %s", config.bind_address)
215+
return server

inference_engine/server/proto_gen/__init__.py

Whitespace-only changes.

inference_engine/server/proto_gen/kakeya/__init__.py

Whitespace-only changes.

inference_engine/server/proto_gen/kakeya/v1/__init__.py

Whitespace-only changes.

inference_engine/server/proto_gen/kakeya/v1/runtime_pb2.py

Lines changed: 62 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)