End-to-end Greeter service running entirely in-process: same Tarantool
instance hosts the server, calls it through a generated client. The
loopback transport (pb.grpc.loopback) bridges the two via
fiber.channel — no network, no HTTP/2 library, no external
dependencies.
This is the path for tests, same-process apps, and any scenario where you want gRPC's ergonomics without leaving the process.
For real (external) transports the contract is documented in reference/grpc-contract.md; a worked example of writing your own lives in how-to: custom transport.
We use the Greeter service from examples/proto/hello.proto. All
four streaming flavors:
service Greeter {
rpc SayHello(HelloRequest) returns (HelloReply);
rpc Echo(HelloRequest) returns (HelloRequest);
rpc StreamHellos(HelloRequest) returns (stream HelloReply);
rpc CollectHellos(stream HelloRequest) returns (HelloReply);
rpc Chat(stream HelloRequest) returns (stream HelloReply);
}
message HelloRequest { string name = 1; }
message HelloReply { string greeting = 1; }
The repo regenerates this on just gen. For a project of your own:
protoc --tarantool_out=./gen hello.proto.
Plain Lua table with one function per RPC. Streaming functions
receive a stream parameter that exposes :send, :recv, etc. (see
grpc-contract.md → stream object — server view).
examples/grpc/server.lua:
local pb = require('pb')
local hello = require('full.hello.hello_pb')
local impl = {
-- Unary: req in, reply out.
SayHello = function(req, _ctx)
return {greeting = 'Hi ' .. req.name}
end,
-- Server-stream: push N replies then return.
StreamHellos = function(req, stream, _ctx)
for i = 1, 3 do
stream:send({greeting = ('Hi #%d %s'):format(i, req.name)})
end
end,
-- Client-stream: pull until peer closes, return one reply.
CollectHellos = function(stream, _ctx)
local names = {}
while true do
local req, err = stream:recv()
if req == nil then
if err ~= nil then error(err, 0) end
break
end
names[#names + 1] = req.name
end
return {greeting = 'Hi ' .. table.concat(names, ', ')}
end,
-- Bidi: pull a request, push a reply, repeat until peer closes.
Chat = function(stream, _ctx)
while true do
local req, err = stream:recv()
if req == nil then
if err ~= nil then error(err, 0) end
return
end
stream:send({greeting = 'Echo ' .. req.name})
end
end,
}
local server = hello.Greeter_server(impl)
return pb.grpc.loopback(server)
examples/grpc/client.lua:
local fiber = require('fiber')
local hello = require('full.hello.hello_pb')
local transport = dofile('examples/grpc/server.lua') -- builds loopback
local client = hello.Greeter_client(transport)
-- Unary
print(client.SayHello({name = 'Alice'}, {}).greeting)
-- Server-stream
local s = client.StreamHellos({name = 'Bob'}, {})
while true do
local msg, err = s:recv()
if msg == nil then break end
print(msg.greeting)
end
-- Client-stream: send N, close, recv one
local c = client.CollectHellos({})
c:send({name = 'Alice'}); c:send({name = 'Bob'}); c:send({name = 'Carol'})
c:close_send()
print(c:recv().greeting)
-- Bidi: send on one fiber, recv on another
local b = client.Chat({})
fiber.create(function()
for _, name in ipairs({'X', 'Y', 'Z'}) do b:send({name = name}) end
b:close_send()
end)
while true do
local msg, err = b:recv()
if msg == nil then break end
print(msg.greeting)
end
Run it:
LUA_PATH="./runtime/?/init.lua;./runtime/?.lua;./examples/expected/?.lua;./examples/expected/?/init.lua;;" \
tarantool examples/grpc/client.lua
Expected output:
--- unary ---
Hi Alice
--- server-stream ---
Hi #1 Bob
Hi #2 Bob
Hi #3 Bob
--- client-stream ---
Hi Alice, Bob, Carol
--- bidi ---
Echo X
Echo Y
Echo Z
The example above splits the bidi call across two fibers (one
sends, the main fiber receives). That's the safest pattern — :send
blocks when the channel buffer fills, so doing both from one fiber
risks self-deadlock if you're not careful.
Single-fiber bidi works when send/recv naturally interleave: send, recv, send, recv, …
local b = client.Chat({})
for _, name in ipairs({'X', 'Y', 'Z'}) do
b:send({name = name})
print(b:recv().greeting)
end
b:close_send()
Either side can cancel mid-stream:
local s = client.StreamHellos({name = 'Bob'}, {})
local first = s:recv()
s:cancel() -- drop the rest
Cancellation closes the channel; the server-side :send raises
pb.grpc: stream canceled on the next attempt. Handlers that don't
need to react gracefully can ignore the raise — the fiber unwinds and
the call ends.
Errors raised in the server's handler surface as the err return on
the client's next :recv():
StreamHellos = function(req, stream, _ctx)
stream:send({greeting = 'first'})
error('boom!', 0)
end
-- Client side:
local s = client.StreamHellos({name = 'x'}, {})
local r1 = s:recv() -- {greeting = 'first'}
local r2, err = s:recv() -- nil, "boom!"
The first message goes through; the error surfaces when the channel drains.
ctx argumentThe second-or-third argument to every RPC is ctx, an opaque table.
Standard reserved keys (ctx.deadline, ctx.headers,
ctx.trace_id, ctx.span_id, ctx.options) are documented in
grpc-contract.md → context.
The loopback transport doesn't enforce deadlines today — it threads
ctx to the handler unchanged. Real external transports that
implement the contract are expected to honor the standard keys.
local greeter = hello.Greeter_server(greeter_impl)
local catalog = catalog.Catalog_server(catalog_impl)
local transport = pb.grpc.multiplex({greeter, catalog})
local greeter_client = hello.Greeter_client(transport)
local catalog_client = catalog.Catalog_client(transport)
pb.grpc.multiplex errors on duplicate paths, so two services with
the same package + service name will fail loudly at setup time, not
silently at the first dispatch.