pygrpc-native 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- pygrpc_native-0.3.0/CHANGELOG.md +62 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/CONTRIBUTING.md +7 -5
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/Cargo.lock +1 -1
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/Cargo.toml +1 -1
- pygrpc_native-0.3.0/PKG-INFO +169 -0
- pygrpc_native-0.3.0/README.md +154 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/benchmarks/README.md +2 -1
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/benchmarks/echo.py +4 -2
- pygrpc_native-0.2.0/README.md → pygrpc_native-0.3.0/docs/low-level-api.md +41 -119
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/python/pygrpc_native/_native.pyi +3 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/src/lib.rs +48 -1
- pygrpc_native-0.3.0/src/metrics.rs +270 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/src/server.rs +146 -18
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_api.py +33 -2
- pygrpc_native-0.3.0/tests/test_metrics.py +122 -0
- pygrpc_native-0.2.0/PKG-INFO +0 -241
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/CODEOWNERS +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/dependabot.yml +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/workflows/ci.yml +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/workflows/dco.yml +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/workflows/release.yml +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.github/workflows/stale.yml +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.gitignore +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/.pre-commit-config.yaml +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/DCO +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/LICENSE +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/SECURITY.md +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/pyproject.toml +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/python/pygrpc_native/__init__.py +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/python/pygrpc_native/_server.py +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/python/pygrpc_native/aio.py +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/python/pygrpc_native/py.typed +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_add_methods.py +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_aio.py +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_bearer_auth.py +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_call_details.py +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_calls.py +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_delay_stream_headers.py +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_fallback.py +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_front.py +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_response_metadata.py +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_server.py +0 -0
- {pygrpc_native-0.2.0 → pygrpc_native-0.3.0}/tests/test_stream_try_send.py +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes are listed here. The project follows
|
|
4
|
+
[semantic versioning](https://semver.org); see
|
|
5
|
+
[Compatibility](README.md#compatibility) for what that covers.
|
|
6
|
+
|
|
7
|
+
## 0.3.0 (2026-10-26)
|
|
8
|
+
|
|
9
|
+
Everything here is an addition, so code written for 0.1 and 0.2 keeps working.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `NativeGrpcFront(metrics=True)` and `metrics()`: call counts and latencies
|
|
14
|
+
per method and status, recorded in Rust without the GIL. The keys
|
|
15
|
+
`metrics()` returns are covered by the compatibility promise. (#29, #31)
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- The low-level API reference moved from the README to
|
|
20
|
+
[docs/low-level-api.md](docs/low-level-api.md). (#31)
|
|
21
|
+
|
|
22
|
+
## 0.2.0 (2026-10-25)
|
|
23
|
+
|
|
24
|
+
Everything here is an addition, so code written for 0.1 keeps working.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- `pygrpc_native.server()` and `pygrpc_native.aio.server()` take the same
|
|
29
|
+
arguments as `grpc.server()` and `grpc.aio.server()` and serve grpcio-tools
|
|
30
|
+
generated servicers unchanged. Status codes, metadata, deadlines and
|
|
31
|
+
cancellation behave as in grpcio, and the aio server runs on uvloop too.
|
|
32
|
+
(#17, #18, #20)
|
|
33
|
+
- Methods the front doesn't serve itself, such as client-streaming and
|
|
34
|
+
bidirectional methods or server reflection, go to a grpcio server behind it.
|
|
35
|
+
(#19)
|
|
36
|
+
- `NativeGrpcFront` can require a bearer token (`bearer_token_sha256`,
|
|
37
|
+
`unauthenticated_message`). Calls without one are rejected in Rust before
|
|
38
|
+
they reach Python. (#23)
|
|
39
|
+
- `call_details` and `recv_batch_details()` report each call's request
|
|
40
|
+
metadata, peer and deadline. (#10)
|
|
41
|
+
- `add_methods()` registers methods after the front is bound. (#11)
|
|
42
|
+
- `stream_try_send()` for callers that can't block, such as an event loop.
|
|
43
|
+
(#12)
|
|
44
|
+
- `respond()`, `fail()` and `stream_end()` take response metadata, and `fail()`
|
|
45
|
+
can send `grpc-status-details-bin`. (#13)
|
|
46
|
+
- `delay_stream_headers` and `stream_send_initial_metadata()` hold a stream's
|
|
47
|
+
headers back until its first message. (#14)
|
|
48
|
+
- With `call_details`, a cancelled unary call or one that misses its deadline
|
|
49
|
+
is reported as an `EVENT_CANCEL`. (#16)
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- The extension module moved to `pygrpc_native._native`, so the
|
|
54
|
+
`pygrpc_native.pygrpc_native` attribute is gone. Import names from
|
|
55
|
+
`pygrpc_native` as before. (#15)
|
|
56
|
+
|
|
57
|
+
## 0.1.0 (2026-10-24)
|
|
58
|
+
|
|
59
|
+
First release: `NativeGrpcFront`, a gRPC server whose HTTP/2 and gRPC work
|
|
60
|
+
runs in Rust, outside the GIL. Unary and server-streaming methods, gzip
|
|
61
|
+
requests, deadlines and batched calls to Python. abi3 wheels for CPython 3.10
|
|
62
|
+
and later on Linux (x86_64, aarch64) and macOS (arm64, x86_64).
|
|
@@ -59,20 +59,22 @@ commit without it.
|
|
|
59
59
|
## What a PR needs to merge
|
|
60
60
|
|
|
61
61
|
The DCO, lint, rust and python checks have to pass, and a PR needs one
|
|
62
|
-
approving review with every review thread resolved.
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
approving review with every review thread resolved. While the project has a
|
|
63
|
+
single maintainer, the maintainer's own PRs are merged once the checks pass.
|
|
64
|
+
`main` keeps a linear history: PRs are squash-merged with the PR title as the
|
|
65
|
+
commit title, and force pushes and branch deletion are blocked. Issues and PRs with no activity
|
|
65
66
|
for 60 days are marked stale and closed 30 days later.
|
|
66
67
|
|
|
67
68
|
## Releases
|
|
68
69
|
|
|
69
70
|
Releases follow semantic versioning. Existing users call the low-level API
|
|
70
71
|
directly, so `tests/test_api.py` pins its names, signatures and event tuples;
|
|
71
|
-
change it only together with a
|
|
72
|
+
change it only together with a new major version.
|
|
72
73
|
|
|
73
74
|
Before a release:
|
|
74
75
|
|
|
75
|
-
1. Bump the version in `Cargo.toml
|
|
76
|
+
1. Bump the version in `Cargo.toml` and move the changes into a new section
|
|
77
|
+
of `CHANGELOG.md`.
|
|
76
78
|
2. Build the wheel and run the tests of known downstream projects against it.
|
|
77
79
|
3. Push a `vX.Y.Z` tag. The release workflow builds the wheels and publishes
|
|
78
80
|
them to PyPI.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pygrpc-native
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Classifier: Programming Language :: Python :: 3
|
|
5
|
+
Classifier: Programming Language :: Rust
|
|
6
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
7
|
+
Classifier: Operating System :: MacOS
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Summary: A gRPC server in Rust that hands calls to Python handlers in batches
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
13
|
+
Project-URL: Repository, https://github.com/theminjunchoi/pygrpc-native
|
|
14
|
+
|
|
15
|
+
# pygrpc-native
|
|
16
|
+
|
|
17
|
+
A gRPC server for Python whose HTTP/2 and gRPC work runs in Rust, outside the
|
|
18
|
+
GIL. Your handlers stay in Python.
|
|
19
|
+
|
|
20
|
+
grpcio runs its own Python code for every call, under the same GIL as your
|
|
21
|
+
handlers. pygrpc-native does the framing, flow control, deadlines and
|
|
22
|
+
cancellation on a tokio runtime instead, and gives Python each call as an
|
|
23
|
+
event: an id, the method, the request bytes and who sent it. You decode the
|
|
24
|
+
protobuf, run the handler and send back the encoded response. Clients and the
|
|
25
|
+
wire format are the same as with grpcio.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
grpc client ──HTTP/2──▶ tokio threads (no GIL)
|
|
29
|
+
│ path → method id
|
|
30
|
+
▼
|
|
31
|
+
event queue ──▶ recv_batch() in Python
|
|
32
|
+
◀── respond(call_id, bytes) from any thread
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Calls reach Python in batches, so a burst takes one trip across the GIL
|
|
36
|
+
rather than one per call.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install pygrpc-native
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Wheels are published for Linux (x86_64, aarch64) and macOS (arm64,
|
|
45
|
+
x86_64) and work on CPython 3.10 and later. To build from a checkout:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install maturin
|
|
49
|
+
maturin develop --release
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## With grpcio servicers
|
|
53
|
+
|
|
54
|
+
`pygrpc_native.server()` takes the same arguments as `grpc.server()` and
|
|
55
|
+
serves servicers from grpcio-tools generated code (1.62 or later) unchanged:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from concurrent import futures
|
|
59
|
+
import pygrpc_native
|
|
60
|
+
|
|
61
|
+
server = pygrpc_native.server(futures.ThreadPoolExecutor(16))
|
|
62
|
+
greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
|
|
63
|
+
server.add_insecure_port("[::]:50051")
|
|
64
|
+
server.start()
|
|
65
|
+
server.wait_for_termination()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Status codes, error messages, metadata, deadlines, cancellation and
|
|
69
|
+
`ServicerContext` behave as they do in grpcio.
|
|
70
|
+
|
|
71
|
+
Unary and server-streaming methods are served natively on one insecure port.
|
|
72
|
+
The rest, such as client-streaming and bidirectional methods, server
|
|
73
|
+
reflection, or handlers added only with `add_generic_rpc_handlers`, go to a
|
|
74
|
+
grpcio server on a Unix socket behind it. They work, but they aren't any
|
|
75
|
+
faster, and `peer()` in them is the socket.
|
|
76
|
+
|
|
77
|
+
Interceptors, `maximum_concurrent_rpcs`, TLS, response compression, and server
|
|
78
|
+
options other than the message size limits and client ping settings raise an
|
|
79
|
+
error rather than being ignored. There's one known difference: if a unary
|
|
80
|
+
handler calls `send_initial_metadata()` and then fails, the metadata goes out
|
|
81
|
+
with the status, so the client sees it as trailing metadata.
|
|
82
|
+
|
|
83
|
+
`pygrpc_native.aio.server()` does the same for `grpc.aio.server()`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import pygrpc_native.aio
|
|
87
|
+
|
|
88
|
+
server = pygrpc_native.aio.server()
|
|
89
|
+
greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
|
|
90
|
+
server.add_insecure_port("[::]:50051")
|
|
91
|
+
await server.start()
|
|
92
|
+
await server.wait_for_termination()
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Handlers run on the event loop that called `start()`, uvloop included.
|
|
96
|
+
`abort()` raises `grpc.aio.AbortError`, and a handler whose client cancels or
|
|
97
|
+
whose deadline passes gets `asyncio.CancelledError`, as in grpc.aio. Streams
|
|
98
|
+
can yield from an async generator or call `context.write()`, and handlers
|
|
99
|
+
that aren't coroutines run in `migration_thread_pool`. The fallback,
|
|
100
|
+
the unsupported options and the unary metadata difference are the same as
|
|
101
|
+
for the sync server.
|
|
102
|
+
|
|
103
|
+
## Low-level API
|
|
104
|
+
|
|
105
|
+
`NativeGrpcFront` is the layer under `server()`. Each call reaches Python as
|
|
106
|
+
an event that you answer by id:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
import threading
|
|
110
|
+
from pygrpc_native import EVENT_CALL, NativeGrpcFront
|
|
111
|
+
|
|
112
|
+
front = NativeGrpcFront("0.0.0.0:50051", [("/echo.Echo/Say", False)])
|
|
113
|
+
front.start()
|
|
114
|
+
|
|
115
|
+
def serve():
|
|
116
|
+
while (events := front.recv_batch(64)) is not None:
|
|
117
|
+
for kind, call_id, method_id, payload, client_key in events:
|
|
118
|
+
if kind == EVENT_CALL:
|
|
119
|
+
front.respond(call_id, payload)
|
|
120
|
+
|
|
121
|
+
threading.Thread(target=serve).start()
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Streaming, call details, response metadata, bearer tokens and metrics are
|
|
125
|
+
covered in [docs/low-level-api.md](https://github.com/theminjunchoi/pygrpc-native/blob/main/docs/low-level-api.md).
|
|
126
|
+
|
|
127
|
+
## Compatibility
|
|
128
|
+
|
|
129
|
+
pygrpc-native follows semantic versioning, and the low-level API stays
|
|
130
|
+
compatible within 0.x as well. `tests/test_api.py` pins the 0.1 names,
|
|
131
|
+
signatures and event tuples of `NativeGrpcFront`: they won't change or go
|
|
132
|
+
away, and new parameters only come after the existing ones, with a default.
|
|
133
|
+
The keys `metrics()` returns won't be renamed or removed within 0.x either,
|
|
134
|
+
though new ones may be added. `server()` and `aio.server()` follow grpcio's
|
|
135
|
+
API, and any difference from grpcio's behavior is a bug unless it is listed in
|
|
136
|
+
this README. Changes are listed in [CHANGELOG.md](CHANGELOG.md).
|
|
137
|
+
|
|
138
|
+
## Limitations
|
|
139
|
+
|
|
140
|
+
- `NativeGrpcFront` serves unary and server-streaming methods only; other
|
|
141
|
+
kinds can go through the fallback server. `server()` and `aio.server()`
|
|
142
|
+
send them there on their own.
|
|
143
|
+
- No TLS yet, and grpcio interceptors don't run on the listed methods. Bearer
|
|
144
|
+
tokens are the only authentication, and only on `NativeGrpcFront`.
|
|
145
|
+
- Clients of a Unix socket server must set `grpc.default_authority`, since
|
|
146
|
+
the socket path grpc-core sends as `:authority` is not valid HTTP/2.
|
|
147
|
+
- Like grpc-core, binding a Unix socket path replaces any socket file
|
|
148
|
+
already there.
|
|
149
|
+
- Per connection: at most 1024 concurrent streams and 16 KiB of request
|
|
150
|
+
metadata. A connection that hasn't finished the HTTP/2 preface after 120 s
|
|
151
|
+
is dropped.
|
|
152
|
+
|
|
153
|
+
## Benchmarks
|
|
154
|
+
|
|
155
|
+
With an empty handler on an Apple M4 Pro, `pygrpc_native.server()` answers
|
|
156
|
+
about 1.5x the calls per second of `grpc.server()` from one client thread and
|
|
157
|
+
about 5x from 32. On a 40-vCPU Linux container the low-level API answered
|
|
158
|
+
about 12x at 32 threads. See [benchmarks](https://github.com/theminjunchoi/pygrpc-native/blob/main/benchmarks/README.md) for the
|
|
159
|
+
numbers and how they were measured.
|
|
160
|
+
|
|
161
|
+
## Contributing
|
|
162
|
+
|
|
163
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Every commit needs a DCO sign-off
|
|
164
|
+
(`git commit -s`).
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
Apache-2.0
|
|
169
|
+
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# pygrpc-native
|
|
2
|
+
|
|
3
|
+
A gRPC server for Python whose HTTP/2 and gRPC work runs in Rust, outside the
|
|
4
|
+
GIL. Your handlers stay in Python.
|
|
5
|
+
|
|
6
|
+
grpcio runs its own Python code for every call, under the same GIL as your
|
|
7
|
+
handlers. pygrpc-native does the framing, flow control, deadlines and
|
|
8
|
+
cancellation on a tokio runtime instead, and gives Python each call as an
|
|
9
|
+
event: an id, the method, the request bytes and who sent it. You decode the
|
|
10
|
+
protobuf, run the handler and send back the encoded response. Clients and the
|
|
11
|
+
wire format are the same as with grpcio.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
grpc client ──HTTP/2──▶ tokio threads (no GIL)
|
|
15
|
+
│ path → method id
|
|
16
|
+
▼
|
|
17
|
+
event queue ──▶ recv_batch() in Python
|
|
18
|
+
◀── respond(call_id, bytes) from any thread
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Calls reach Python in batches, so a burst takes one trip across the GIL
|
|
22
|
+
rather than one per call.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install pygrpc-native
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Wheels are published for Linux (x86_64, aarch64) and macOS (arm64,
|
|
31
|
+
x86_64) and work on CPython 3.10 and later. To build from a checkout:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install maturin
|
|
35
|
+
maturin develop --release
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## With grpcio servicers
|
|
39
|
+
|
|
40
|
+
`pygrpc_native.server()` takes the same arguments as `grpc.server()` and
|
|
41
|
+
serves servicers from grpcio-tools generated code (1.62 or later) unchanged:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from concurrent import futures
|
|
45
|
+
import pygrpc_native
|
|
46
|
+
|
|
47
|
+
server = pygrpc_native.server(futures.ThreadPoolExecutor(16))
|
|
48
|
+
greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
|
|
49
|
+
server.add_insecure_port("[::]:50051")
|
|
50
|
+
server.start()
|
|
51
|
+
server.wait_for_termination()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Status codes, error messages, metadata, deadlines, cancellation and
|
|
55
|
+
`ServicerContext` behave as they do in grpcio.
|
|
56
|
+
|
|
57
|
+
Unary and server-streaming methods are served natively on one insecure port.
|
|
58
|
+
The rest, such as client-streaming and bidirectional methods, server
|
|
59
|
+
reflection, or handlers added only with `add_generic_rpc_handlers`, go to a
|
|
60
|
+
grpcio server on a Unix socket behind it. They work, but they aren't any
|
|
61
|
+
faster, and `peer()` in them is the socket.
|
|
62
|
+
|
|
63
|
+
Interceptors, `maximum_concurrent_rpcs`, TLS, response compression, and server
|
|
64
|
+
options other than the message size limits and client ping settings raise an
|
|
65
|
+
error rather than being ignored. There's one known difference: if a unary
|
|
66
|
+
handler calls `send_initial_metadata()` and then fails, the metadata goes out
|
|
67
|
+
with the status, so the client sees it as trailing metadata.
|
|
68
|
+
|
|
69
|
+
`pygrpc_native.aio.server()` does the same for `grpc.aio.server()`:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import pygrpc_native.aio
|
|
73
|
+
|
|
74
|
+
server = pygrpc_native.aio.server()
|
|
75
|
+
greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
|
|
76
|
+
server.add_insecure_port("[::]:50051")
|
|
77
|
+
await server.start()
|
|
78
|
+
await server.wait_for_termination()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Handlers run on the event loop that called `start()`, uvloop included.
|
|
82
|
+
`abort()` raises `grpc.aio.AbortError`, and a handler whose client cancels or
|
|
83
|
+
whose deadline passes gets `asyncio.CancelledError`, as in grpc.aio. Streams
|
|
84
|
+
can yield from an async generator or call `context.write()`, and handlers
|
|
85
|
+
that aren't coroutines run in `migration_thread_pool`. The fallback,
|
|
86
|
+
the unsupported options and the unary metadata difference are the same as
|
|
87
|
+
for the sync server.
|
|
88
|
+
|
|
89
|
+
## Low-level API
|
|
90
|
+
|
|
91
|
+
`NativeGrpcFront` is the layer under `server()`. Each call reaches Python as
|
|
92
|
+
an event that you answer by id:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import threading
|
|
96
|
+
from pygrpc_native import EVENT_CALL, NativeGrpcFront
|
|
97
|
+
|
|
98
|
+
front = NativeGrpcFront("0.0.0.0:50051", [("/echo.Echo/Say", False)])
|
|
99
|
+
front.start()
|
|
100
|
+
|
|
101
|
+
def serve():
|
|
102
|
+
while (events := front.recv_batch(64)) is not None:
|
|
103
|
+
for kind, call_id, method_id, payload, client_key in events:
|
|
104
|
+
if kind == EVENT_CALL:
|
|
105
|
+
front.respond(call_id, payload)
|
|
106
|
+
|
|
107
|
+
threading.Thread(target=serve).start()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Streaming, call details, response metadata, bearer tokens and metrics are
|
|
111
|
+
covered in [docs/low-level-api.md](https://github.com/theminjunchoi/pygrpc-native/blob/main/docs/low-level-api.md).
|
|
112
|
+
|
|
113
|
+
## Compatibility
|
|
114
|
+
|
|
115
|
+
pygrpc-native follows semantic versioning, and the low-level API stays
|
|
116
|
+
compatible within 0.x as well. `tests/test_api.py` pins the 0.1 names,
|
|
117
|
+
signatures and event tuples of `NativeGrpcFront`: they won't change or go
|
|
118
|
+
away, and new parameters only come after the existing ones, with a default.
|
|
119
|
+
The keys `metrics()` returns won't be renamed or removed within 0.x either,
|
|
120
|
+
though new ones may be added. `server()` and `aio.server()` follow grpcio's
|
|
121
|
+
API, and any difference from grpcio's behavior is a bug unless it is listed in
|
|
122
|
+
this README. Changes are listed in [CHANGELOG.md](CHANGELOG.md).
|
|
123
|
+
|
|
124
|
+
## Limitations
|
|
125
|
+
|
|
126
|
+
- `NativeGrpcFront` serves unary and server-streaming methods only; other
|
|
127
|
+
kinds can go through the fallback server. `server()` and `aio.server()`
|
|
128
|
+
send them there on their own.
|
|
129
|
+
- No TLS yet, and grpcio interceptors don't run on the listed methods. Bearer
|
|
130
|
+
tokens are the only authentication, and only on `NativeGrpcFront`.
|
|
131
|
+
- Clients of a Unix socket server must set `grpc.default_authority`, since
|
|
132
|
+
the socket path grpc-core sends as `:authority` is not valid HTTP/2.
|
|
133
|
+
- Like grpc-core, binding a Unix socket path replaces any socket file
|
|
134
|
+
already there.
|
|
135
|
+
- Per connection: at most 1024 concurrent streams and 16 KiB of request
|
|
136
|
+
metadata. A connection that hasn't finished the HTTP/2 preface after 120 s
|
|
137
|
+
is dropped.
|
|
138
|
+
|
|
139
|
+
## Benchmarks
|
|
140
|
+
|
|
141
|
+
With an empty handler on an Apple M4 Pro, `pygrpc_native.server()` answers
|
|
142
|
+
about 1.5x the calls per second of `grpc.server()` from one client thread and
|
|
143
|
+
about 5x from 32. On a 40-vCPU Linux container the low-level API answered
|
|
144
|
+
about 12x at 32 threads. See [benchmarks](https://github.com/theminjunchoi/pygrpc-native/blob/main/benchmarks/README.md) for the
|
|
145
|
+
numbers and how they were measured.
|
|
146
|
+
|
|
147
|
+
## Contributing
|
|
148
|
+
|
|
149
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Every commit needs a DCO sign-off
|
|
150
|
+
(`git commit -s`).
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
Apache-2.0
|
|
@@ -15,7 +15,8 @@ payload bytes. The server and the clients share the machine.
|
|
|
15
15
|
|
|
16
16
|
Median of three runs, calls/s with the client CPU per call in brackets.
|
|
17
17
|
|
|
18
|
-
Apple M4 Pro (14 cores), Python 3.12.13, grpcio 1.84.1, pygrpc-native
|
|
18
|
+
Apple M4 Pro (14 cores), Python 3.12.13, grpcio 1.84.1, pygrpc-native 0.2.0
|
|
19
|
+
(measured at #20):
|
|
19
20
|
|
|
20
21
|
| server | 1x1 | 4x4 | 8x4 |
|
|
21
22
|
|---|---|---|---|
|
|
@@ -7,7 +7,7 @@ pygrpc_native.server() and pygrpc_native.aio.server(), or native for the
|
|
|
7
7
|
low-level API.
|
|
8
8
|
|
|
9
9
|
DUR sets the seconds measured (default 5) and SIZE the payload bytes
|
|
10
|
-
(default 64).
|
|
10
|
+
(default 64). METRICS=1 turns on the native front's metrics.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
import multiprocessing as mp
|
|
@@ -33,7 +33,9 @@ def serve(kind: str, port: int) -> None:
|
|
|
33
33
|
if kind == "native":
|
|
34
34
|
from pygrpc_native import EVENT_CALL, NativeGrpcFront
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
# Only when asked, so the script still runs against older releases.
|
|
37
|
+
extra = {"metrics": True} if os.environ.get("METRICS") == "1" else {}
|
|
38
|
+
front = NativeGrpcFront(target, [(METHOD, False)], **extra)
|
|
37
39
|
front.start()
|
|
38
40
|
while (events := front.recv_batch(256)) is not None:
|
|
39
41
|
for ev_kind, call_id, _, payload, _ in events:
|
|
@@ -1,90 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Low-level API
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
`NativeGrpcFront` is the layer under `pygrpc_native.server()`. Each call
|
|
4
|
+
reaches Python as an event, and you answer it by id, so you choose how calls
|
|
5
|
+
are batched, which threads run the handlers, and how calls from the same
|
|
6
|
+
client are routed.
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
handlers. pygrpc-native does the framing, flow control, deadlines and
|
|
8
|
-
cancellation on a tokio runtime instead, and gives Python each call as an
|
|
9
|
-
event: an id, the method, the request bytes and who sent it. You decode the
|
|
10
|
-
protobuf, run the handler and send back the encoded response. Clients and the
|
|
11
|
-
wire format are the same as with grpcio.
|
|
12
|
-
|
|
13
|
-
```
|
|
14
|
-
grpc client ──HTTP/2──▶ tokio threads (no GIL)
|
|
15
|
-
│ path → method id
|
|
16
|
-
▼
|
|
17
|
-
event queue ──▶ recv_batch() in Python
|
|
18
|
-
◀── respond(call_id, bytes) from any thread
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
Calls reach Python in batches, so a burst takes one trip across the GIL
|
|
22
|
-
rather than one per call.
|
|
23
|
-
|
|
24
|
-
## Install
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
pip install pygrpc-native
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
Wheels are published for Linux (x86_64, aarch64) and macOS (arm64,
|
|
31
|
-
x86_64) and work on CPython 3.10 and later. To build from a checkout:
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
pip install maturin
|
|
35
|
-
maturin develop --release
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
## With grpcio servicers
|
|
39
|
-
|
|
40
|
-
`pygrpc_native.server()` takes the same arguments as `grpc.server()` and
|
|
41
|
-
serves servicers from grpcio-tools generated code (1.62 or later) unchanged:
|
|
42
|
-
|
|
43
|
-
```python
|
|
44
|
-
from concurrent import futures
|
|
45
|
-
import pygrpc_native
|
|
46
|
-
|
|
47
|
-
server = pygrpc_native.server(futures.ThreadPoolExecutor(16))
|
|
48
|
-
greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
|
|
49
|
-
server.add_insecure_port("[::]:50051")
|
|
50
|
-
server.start()
|
|
51
|
-
server.wait_for_termination()
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
Status codes, error messages, metadata, deadlines, cancellation and
|
|
55
|
-
`ServicerContext` behave as they do in grpcio. Unary and server-streaming
|
|
56
|
-
methods are served natively on one insecure port. Everything else, such as
|
|
57
|
-
client-streaming and bidirectional methods, server reflection or handlers
|
|
58
|
-
added only with `add_generic_rpc_handlers`, is passed to a grpcio server on a
|
|
59
|
-
Unix socket behind it, so it works but doesn't get faster, and `peer()` there
|
|
60
|
-
is the socket. Interceptors, `maximum_concurrent_rpcs`, TLS, response
|
|
61
|
-
compression and server options other than the message size limits and client
|
|
62
|
-
ping settings raise an error instead of being ignored. One difference
|
|
63
|
-
remains: a unary handler that calls `send_initial_metadata()` and then fails
|
|
64
|
-
sends the metadata with the status instead of before it, so the client sees
|
|
65
|
-
it as trailing metadata.
|
|
66
|
-
|
|
67
|
-
`pygrpc_native.aio.server()` does the same for `grpc.aio.server()`:
|
|
68
|
-
|
|
69
|
-
```python
|
|
70
|
-
import pygrpc_native.aio
|
|
71
|
-
|
|
72
|
-
server = pygrpc_native.aio.server()
|
|
73
|
-
greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
|
|
74
|
-
server.add_insecure_port("[::]:50051")
|
|
75
|
-
await server.start()
|
|
76
|
-
await server.wait_for_termination()
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
Handlers run on the event loop that called `start()`, uvloop included.
|
|
80
|
-
`abort()` raises `grpc.aio.AbortError`, and a handler whose client cancels or
|
|
81
|
-
whose deadline passes gets `asyncio.CancelledError`, as in grpc.aio. Streams
|
|
82
|
-
can yield from an async generator or call `context.write()`, and handlers
|
|
83
|
-
that aren't coroutines run in `migration_thread_pool`. The fallback,
|
|
84
|
-
the unsupported options and the unary metadata difference are the same as
|
|
85
|
-
for the sync server.
|
|
86
|
-
|
|
87
|
-
## Low-level example
|
|
8
|
+
## Example
|
|
88
9
|
|
|
89
10
|
```python
|
|
90
11
|
import threading
|
|
@@ -115,9 +36,9 @@ In a real server `payload` is a serialized protobuf message: decode it with
|
|
|
115
36
|
the generated class, run the handler (in a thread pool, if it blocks) and
|
|
116
37
|
pass the serialized reply to `respond`.
|
|
117
38
|
|
|
118
|
-
##
|
|
39
|
+
## Reference
|
|
119
40
|
|
|
120
|
-
`NativeGrpcFront(target, methods, io_threads=2, client_key_metadata=None, call_details=False, delay_stream_headers=False, bearer_token_sha256=None, unauthenticated_message="Unauthenticated")`
|
|
41
|
+
`NativeGrpcFront(target, methods, io_threads=2, client_key_metadata=None, call_details=False, delay_stream_headers=False, bearer_token_sha256=None, unauthenticated_message="Unauthenticated", metrics=False)`
|
|
121
42
|
binds `host:port` (every address the host resolves to), `unix:path` or
|
|
122
43
|
`unix:///path`. Port 0 picks a free port. `methods` lists
|
|
123
44
|
`(path, server_streaming)` pairs, where a path is `/package.Service/Method`
|
|
@@ -139,6 +60,7 @@ before `start()`, so the port can be bound before the methods are known.
|
|
|
139
60
|
| `stream_send_initial_metadata(call_id, metadata)` | send a held-back stream's headers now |
|
|
140
61
|
| `stream_end(call_id, trailing_metadata=None)` | finish a stream with OK; a stream cut short by `close()` ends with UNAVAILABLE |
|
|
141
62
|
| `stream_open(call_id)` | whether the client is still reading a stream |
|
|
63
|
+
| `metrics()` | call counts and latencies per method and status; `None` without `metrics=True` |
|
|
142
64
|
| `close()` | stop without waiting for calls in flight; their clients get UNAVAILABLE |
|
|
143
65
|
|
|
144
66
|
`recv_batch` returns `(kind, call_id, method_id, payload, client_key)`
|
|
@@ -188,39 +110,39 @@ front = NativeGrpcFront(
|
|
|
188
110
|
`grpc.Server` on a Unix socket and its methods are proxied there, with its
|
|
189
111
|
interceptors.
|
|
190
112
|
|
|
191
|
-
##
|
|
113
|
+
## Metrics
|
|
192
114
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
is dropped.
|
|
115
|
+
With `metrics=True`, the front counts calls like gRFC A66's
|
|
116
|
+
`grpc.server.call.started` and `grpc.server.call.duration`. It counts each
|
|
117
|
+
call when it starts, and records its status and duration when the trailers go
|
|
118
|
+
out. A call the client resets counts as CANCELLED, so one the client drops at
|
|
119
|
+
its deadline can show up as CANCELLED or DEADLINE_EXCEEDED, depending on which
|
|
120
|
+
side notices first. Calls to paths you didn't list count under `"other"`, so
|
|
121
|
+
unknown paths can't add entries. Calls forwarded to the fallback aren't
|
|
122
|
+
counted here; the fallback server counts them. Each tokio thread keeps its own
|
|
123
|
+
counters, which costs about 70 ns per call, and `metrics()` adds them up when
|
|
124
|
+
you call it:
|
|
204
125
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
126
|
+
```python
|
|
127
|
+
{
|
|
128
|
+
"start_time_unix_nano": 1761436800000000000,
|
|
129
|
+
"bounds": [0.0, 1e-05, ..., 100.0], # A66's latency buckets, in seconds
|
|
130
|
+
"methods": {
|
|
131
|
+
"/echo.Echo/Say": {
|
|
132
|
+
"started": 1042, # still running: started minus the counts
|
|
133
|
+
"status": {
|
|
134
|
+
0: {"count": 1040, "sum": 0.21, "min": 0.00009, "max": 0.0031,
|
|
135
|
+
"buckets": [0, 0, 0, 12, ...]}, # one more than bounds
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
}
|
|
217
140
|
```
|
|
218
141
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
Apache-2.0
|
|
142
|
+
Each entry in `buckets` is the number of calls in that bucket, not a running
|
|
143
|
+
total. A call goes in the first bucket whose bound is at least its duration,
|
|
144
|
+
and the last bucket holds anything slower than the last bound. OpenTelemetry
|
|
145
|
+
histograms use the same layout, while Prometheus `le` buckets are cumulative,
|
|
146
|
+
so add them up from the first one. Nothing resets: the totals count up from
|
|
147
|
+
`start_time_unix_nano`. The status keys are gRPC status code numbers, with 0
|
|
148
|
+
for OK. A66 labels statuses by name instead.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from collections.abc import Sequence
|
|
2
|
+
from typing import Any
|
|
2
3
|
|
|
3
4
|
Metadata = Sequence[tuple[str, str | bytes]]
|
|
4
5
|
|
|
@@ -30,6 +31,7 @@ class NativeGrpcFront:
|
|
|
30
31
|
delay_stream_headers: bool = False,
|
|
31
32
|
bearer_token_sha256: Sequence[bytes] | None = None,
|
|
32
33
|
unauthenticated_message: str = "Unauthenticated",
|
|
34
|
+
metrics: bool = False,
|
|
33
35
|
) -> None: ...
|
|
34
36
|
@property
|
|
35
37
|
def bound_port(self) -> int: ...
|
|
@@ -65,4 +67,5 @@ class NativeGrpcFront:
|
|
|
65
67
|
self, call_id: int, trailing_metadata: Metadata | None = None
|
|
66
68
|
) -> None: ...
|
|
67
69
|
def stream_open(self, call_id: int) -> bool: ...
|
|
70
|
+
def metrics(self) -> dict[str, Any] | None: ...
|
|
68
71
|
def close(self) -> None: ...
|