# gRPCClient.jl ## Overview gRPCClient.jl is a production-grade Julia gRPC client built on libcurl for HTTP/2 transport. It integrates with ProtoBuf.jl for code generation. - **Version**: 1.0.1 - **Requires**: Julia >= 1.10 (streaming requires Julia >= 1.12) - **Dependencies**: LibCURL, ProtoBuf ~1.2.1, PrecompileTools, FileWatching ## Code Generation ### Setup ```julia using ProtoBuf using gRPCClient # must come before protojl — __init__ registers the service codegen hook protojl("path/to/service.proto", "proto/search/root", "output/dir") ``` `using gRPCClient` is sufficient; `grpc_register_service_codegen()` is called automatically in `__init__`. The explicit call in `test/protoc.sh` is redundant but harmless. ### protojl Arguments ```julia protojl(proto_file, proto_search_root, output_dir) ``` - `proto_file`: path to the `.proto` file to compile (absolute, or relative to CWD) - `proto_search_root`: directory used to resolve `import` statements in the proto file - `output_dir`: where generated Julia files are written ### Generated File Layout For a proto file with `package foo;` and service definitions, `protojl` generates: ``` output_dir/ foo/ foo.jl # thin module wrapper: module foo; include("foo_pb.jl"); end foo_pb.jl # all message structs + gRPCClient service client constructors ``` The `_pb.jl` file is structured as: ```julia # Autogenerated using ProtoBuf.jl ... import ProtoBuf as PB import gRPCClient # injected when any service is present using ProtoBuf: OneOf using ProtoBuf.EnumX: @enumx export MyResponse, MyRequest # --- Message structs with ProtoBuf encode/decode --- struct MyRequest field_a::UInt64 field_b::Vector{UInt64} end PB.default_values(::Type{MyRequest}) = (;field_a = zero(UInt64), field_b = Vector{UInt64}()) PB.field_numbers(::Type{MyRequest}) = (;field_a = 1, field_b = 2) # ... decode/encode/_encoded_size methods ... # gRPCClient.jl BEGIN MyService_MyRPC_Client( host, port; secure=false, grpc=gRPCClient.grpc_global_handle(), deadline=10, keepalive=60, max_send_message_length = 4*1024*1024, max_recieve_message_length = 4*1024*1024, ) = gRPCClient.gRPCServiceClient{MyRequest, false, MyResponse, false}( host, port, "/foo.MyService/MyRPC"; ... ) export MyService_MyRPC_Client # gRPCClient.jl END ``` Key observations: - The `# gRPCClient.jl BEGIN` / `# gRPCClient.jl END` markers delimit the injected service block. - `import gRPCClient` is added at the top only when at least one service is present. - Client constructors are exported when the proto has a package namespace or `always_use_modules` is set. ### Client Constructor Naming ``` {ServiceName}_{RPCName}_Client ``` For `service MyService { rpc DoThing(...) ... }` → `MyService_DoThing_Client` ### RPC Path Format ``` /{package}.{ServiceName}/{RPCName} ``` For `package foo; service MyService { rpc DoThing ... }` → `/foo.MyService/DoThing` ### gRPCServiceClient Type Parameters ```julia gRPCClient.gRPCServiceClient{TRequest, SRequest, TResponse, SResponse} ``` | Parameter | Type | Meaning | |------------|------|------------------------------| | `TRequest` | Type | Protobuf request message type | | `SRequest` | Bool | `true` = client streaming | | `TResponse`| Type | Protobuf response message type| | `SResponse`| Bool | `true` = server streaming | RPC variant → type parameters: - Unary: `{TReq, false, TResp, false}` - Server streaming: `{TReq, false, TResp, true}` - Client streaming: `{TReq, true, TResp, false}` - Bidirectional: `{TReq, true, TResp, true}` ### Cross-Package Types When a service in `pkg_a` uses message types from `pkg_b` (via proto `import`), the generated constructor uses the package-namespaced type name: ```julia # proto: service ExtService { rpc ExtRPC(ext_types.ExtRequest) returns (ext_types.ExtResponse) {} } gRPCClient.gRPCServiceClient{ext_types.ExtRequest, false, ext_types.ExtResponse, false}(...) ``` The namespace prefix comes from `rpc.request_type.package_namespace` (not `rpc.package_namespace`). ## Using Generated Stubs Generated files are loaded with `include`, not `using`: ```julia using gRPCClient # Load message types and client constructors include("gen/foo/foo_pb.jl") # Construct a client client = MyService_MyRPC_Client("localhost", 50051) # Use message constructors with positional args matching field declaration order req = MyRequest(42, zeros(UInt64, 10)) ``` ## API Reference ### Initialization / Shutdown ```julia grpc_init() # called automatically on `using gRPCClient` grpc_shutdown() # clean shutdown: closes connections, cancels in-flight requests grpc_global_handle() # returns the shared gRPCCURL (libcurl multi handle) ``` Custom handle for isolation (separate connection pool + semaphore): ```julia h = gRPCCURL() grpc_init(h) client = MyService_MyRPC_Client("host", 50051; grpc=h) # ... grpc_shutdown(h) ``` ### Client Constructor Parameters All generated constructors share the same keyword parameters: | Parameter | Default | Description | |----------------------------|-----------------|-------------| | `secure` | `false` | `true` = HTTPS/gRPCS, `false` = HTTP/gRPC | | `grpc` | global handle | `gRPCCURL` instance | | `deadline` | `10` | Timeout in seconds; raises `gRPCServiceCallException(GRPC_DEADLINE_EXCEEDED)` | | `keepalive` | `60` | TCP keepalive interval (seconds); sets both idle and probe interval | | `max_send_message_length` | `4*1024*1024` | Max bytes per outgoing message | | `max_recieve_message_length`| `4*1024*1024` | Max bytes per incoming message (note: typo in API — `recieve`) | ### Unary RPC ```julia # Synchronous (simplest) response = grpc_sync_request(client, request) # Async request/await (batch parallel requests) req = grpc_async_request(client, request) # returns gRPCRequest immediately response = grpc_async_await(client, req) # blocks until done, returns response # Async with channel (out-of-order responses) ch = Channel{gRPCAsyncChannelResponse{MyResponse}}(N) grpc_async_request(client, request, ch, index) # index is an Int64 correlation ID cr = take!(ch) !isnothing(cr.ex) && throw(cr.ex) response = cr.response # cr.index == the index passed in ``` `gRPCAsyncChannelResponse` fields: `.response`, `.ex` (exception or `nothing`), `.index`. ### Streaming RPC (Julia >= 1.12 required) **Client streaming** (many requests → one response): ```julia client = MyService_MyRPC_Client("localhost", 50051) # SRequest=true request_c = Channel{MyRequest}(16) req = grpc_async_request(client, request_c) put!(request_c, MyRequest(...)) # ... close(request_c) # signals end-of-stream to server response = grpc_async_await(client, req) # returns the single response ``` **Server streaming** (one request → many responses): ```julia client = MyService_MyRPC_Client("localhost", 50051) # SResponse=true response_c = Channel{MyResponse}(16) req = grpc_async_request(client, request, response_c) for resp in response_c # channel closes automatically when stream ends # process resp end grpc_async_await(req) # raise any exceptions (no return value) ``` **Bidirectional streaming**: ```julia client = MyService_MyRPC_Client("localhost", 50051) # SRequest=true, SResponse=true request_c = Channel{MyRequest}(16) response_c = Channel{MyResponse}(16) req = grpc_async_request(client, request_c, response_c) put!(request_c, MyRequest(...)) for resp in response_c # process resp; can interleave puts to request_c end close(request_c) grpc_async_await(req) ``` Note: For streaming variants, `grpc_async_await` does **not** return the response — it only raises exceptions. The response data flows through the channel. ### Exceptions ```julia struct gRPCServiceCallException <: gRPCException grpc_status::Int # gRPC status code integer message::String end ``` Status code constants exported: `GRPC_OK`, `GRPC_CANCELLED`, `GRPC_UNKNOWN`, `GRPC_INVALID_ARGUMENT`, `GRPC_DEADLINE_EXCEEDED`, `GRPC_NOT_FOUND`, `GRPC_ALREADY_EXISTS`, `GRPC_PERMISSION_DENIED`, `GRPC_RESOURCE_EXHAUSTED`, `GRPC_FAILED_PRECONDITION`, `GRPC_ABORTED`, `GRPC_OUT_OF_RANGE`, `GRPC_UNIMPLEMENTED`, `GRPC_INTERNAL`, `GRPC_UNAVAILABLE`, `GRPC_DATA_LOSS`, `GRPC_UNAUTHENTICATED`. ## Test Infrastructure ### Go Test Server ```bash cd test/go go build -o grpc_test_server ./grpc_test_server # listens on :8001 by default ``` ### Running Tests ```bash # Default: expects server already running on localhost:8001 julia --project test/runtests.jl # Auto-start Go server from within Julia test run JULIA_GRPCCLIENT_TEST_START_SERVER=go julia --project test/runtests.jl # Custom host/port GRPC_TEST_SERVER_HOST=myhost GRPC_TEST_SERVER_PORT=9000 julia --project test/runtests.jl ``` ### Regenerating Stubs ```bash cd test bash protoc.sh ``` This regenerates `test/gen/` from `test/proto/test.proto` and also regenerates Python stubs in `test/python/`. ## Proto → Julia Type Mapping | Proto type | Julia type | |-----------------|-------------------| | `uint32` | `UInt32` | | `uint64` | `UInt64` | | `int32` | `Int32` | | `int64` | `Int64` | | `float` | `Float32` | | `double` | `Float64` | | `bool` | `Bool` | | `string` | `String` | | `bytes` | `Vector{UInt8}` | | `repeated T` | `Vector{}`| | `message M` | generated struct | | `enum E` | `@enumx` enum | | `oneof` | `OneOf` | Struct fields are **positional** in the constructor — order matches proto field declaration order. Default values are defined by `PB.default_values`. ## Common Pitfalls 1. **Load order**: `using gRPCClient` must precede `protojl`. If you call `protojl` before loading gRPCClient, no service client constructors will be generated. 2. **Streaming on Julia < 1.12**: Streaming support is conditionally compiled. A warning is emitted and streaming functions are unavailable. 3. **`grpc_async_await` return value**: For streaming clients, `grpc_async_await(req)` returns nothing — do not assign it. For unary clients, `grpc_async_await(client, req)` returns the decoded response. 4. **Channel closing**: For server/bidirectional streaming, the response channel is closed by the library when the stream ends. Do not close it yourself from the consumer side (causes `InvalidStateException` internally, which is handled gracefully). 5. **`max_recieve_message_length` spelling**: The keyword is spelled with the typo (`recieve` not `receive`) — match it exactly. 6. **Include vs using**: Always `include("gen/foo/foo_pb.jl")` — the generated files are plain scripts, not registered packages.