~bigbes/tarantool

tarantool-protobuf

ref: 6fb4b04c2c5551c1947fc354804f0996d4a64cdb tarantool-protobuf/PLAN.md -rw-r--r-- 19.0 KiB
6fb4b04c — Eugene Blikh bench: lazy decode/encode scenarios + jit-trace coverage 3 months ago

#tarantool-protobuf — Roadmap & Test Plan

This document tracks the long-form design + testing strategy for protoc-gen-tarantool and the pb Lua runtime. Short-term task tracking lives in the in-session task list; this file is the slow-changing record.

#1. Vision

A first-class Protocol Buffers + gRPC stack for Tarantool that:

  • Generates idiomatic Lua from .proto files with two modes (full inline vs descriptor-driven runtime) so users can pick speed-vs-flexibility.
  • Interoperates byte-for-byte with mainline protoc implementations (Go, Python, C++) — passes Google's protobuf conformance suite for proto3.
  • Ships with a small, FFI-aware runtime that respects Tarantool conventions (LuaJIT cdata for 64-bit ints, no global state, no monkey-patching).
  • Is comfortable to use from real Tarantool apps: clean integration with net.box, fiber, box.session, and the 3.x config framework.

#2. Current state (M0)

See README.md for the user-visible summary. Internally:

  • Plugin: Go, single mode (descriptor + runtime dispatch).
  • Runtime: pure Lua + LuaJIT FFI; ~260 LOC wire layer, ~250 LOC dispatch.
  • Tests: 25 hand-rolled assertions in one file, no luatest.
  • Demo proto: 1 file, 11 fields, no map/oneof/services.

This is the floor we're building on.

#3. Phased roadmap

Each milestone ends with a green CI run, an updated README, and a tagged release on sourcecraft.dev.

#M1 — Two codegen modes + luatest harness (in progress)

  • [ ] Promote scalar encode/decode to typed helpers in pb.wire. wire.encode_int32(v), wire.decode_string(buf, pos), etc., for all 15 scalar proto types. Both modes consume the same primitives.
  • [ ] Full (inline) codegen: emit per-message _encode / _decode functions with no descriptor lookup. Tag bytes precomputed at gen time as Lua string literals. This is the JIT-friendly hot path.
  • [ ] Runtime codegen: keep current behavior. Useful for introspection, schema registries, and forward-compat with descriptor-only consumers.
  • [ ] Plugin parameter mode=full|runtime (default: full).
  • [ ] Migrate tests to luatest groups. Every behavior tested against both generated modules to prove parity.
  • [ ] Generate side-by-side outputs in examples/expected/{full,runtime}/ for visual diffing.

Done when: same .proto produces two modules; identical wire bytes; one luatest run covers both.

#M2 — Composite types

  • [ ] map<K,V>: emit as repeated synthetic *Entry messages with key/value fields per spec. Decode merges into a Lua table; encode iterates with pairs. Key types limited to scalars + string per spec.
  • [ ] oneof: descriptor includes oneof_index per field. Encode emits at most one branch (last assignment wins). Decode clears prior oneof siblings on assignment.
  • [ ] proto3 explicit optional: respect presence — emit field even when value equals scalar default. Generated descriptor exposes has_<name>(t) / clear_<name>(t) helpers.

Done when: a "composite" example proto with map<string,int32>, a oneof with 3 cases, and an explicit-optional bool round-trips through both modes with parity-against-protoc.

#M3 — Well-known types

  • [ ] google.protobuf.Timestamp ↔ Tarantool datetime module (epoch + nsec mapping).
  • [ ] google.protobuf.Durationinterval (or seconds+nanos table).
  • [ ] Wrappers (Int32Value, StringValue, BoolValue, …) with sugar: pass plain Lua value → auto-wrap; decode → auto-unwrap or keep.
  • [x] Empty, FieldMask. FieldMask is a repeated string; JSON mapping is the canonical comma-joined lowerCamelCase form.
  • [x] Any: opaque {type_url, value} form by default; pb.register(desc) + pb.any.pack(desc, t) / pb.any.unpack(any_t) for typed round-trips. JSON canonical mapping emits the flat {"@type": ...} object when the type is registered, falls back to base64 opaque form otherwise.
  • [x] Struct, Value, ListValue ↔ idiomatic Lua tables. box.NULL is the null_value sentinel (re-exported as pb.NULL). pb.wkt.struct(t) / pb.wkt.list(t) tag tables when the auto-detect heuristic (t[1] ~= nil → list, else struct) needs to be overridden, and decode preserves the tag for byte-stable round trips.

Done when: WKT-using protos round-trip and integrate visibly with datetime (e.g. os.date(...)-comparable timestamps).

#M4 — gRPC services (transport-agnostic) (done)

  • [x] Per-service descriptor: methods with full path /pkg.Svc/Method, input/output type refs, client_streaming / server_streaming flags.
  • [x] Client stub: MyService_client(transport) returns a table with one function per method. Transport interface, all served by the shipped loopback + multiplex implementations: lua transport:unary(path, req_bytes, ctx) -> resp_bytes transport:server_stream(path, req_bytes, ctx) -> stream transport:client_stream(path, ctx) -> stream transport:bidi(path, ctx) -> stream Streaming returns a stream object with send / close_send / recv() -> msg, err / cancel. End-of-stream is (nil, nil); handler errors surface as (nil, err_string).
  • [x] Server side: MyService_server(impl) returns {service, methods, streams} consumable by the loopback / multiplex transports. Handler signatures by RPC kind: lua unary: function(req, ctx) -> resp server_stream: function(req, stream, ctx) client_stream: function(stream, ctx) -> resp bidi: function(stream, ctx)
  • [ ] Reference network transports (separate projects, deferred):
    • pb.grpc.transport.netbox — gRPC tunneled over net.box calls.
    • HTTP/2 transport — out of scope; the transport interface above is deliberately HTTP/2-shaped so a plug-in is straightforward.

Done. The loopback transport runs each streaming handler on its own fiber and bridges client ↔ handler via fiber.channel. All four flavors (unary + 3 streaming) are exercised by parameterized luatest groups.

#M5 — Conformance + interop (runner shipped; pass-rate tracking pending)

  • [x] Wire up Google's protobuf conformance test runner. cmd/conformance-runner.lua is the testee: reads length-prefixed ConformanceRequest on stdin, runs it through our codec / JSON, writes a length-prefixed ConformanceResponse on stdout. Loops to EOF. Core dispatch is factored into cmd/conformance/core.lua and exercised directly by test/conformance_test.lua (10 dispatch cases + 3 stdin/stdout framing cases).
  • [ ] Run the canonical conformance_test_runner binary in CI and track the pass-rate as a numeric metric (regression gate).
  • [x] Cross-impl interop: 18-fixture corpus in test/interop/fixtures/ produced by mainline protoc --encode; tests assert byte-for-byte equality.

#M6 — Performance + production polish (bench harness shipped; rest pending)

  • [x] Microbenchmarks: encode and decode throughput (MB/s, msgs/s) for messages of 5 sizes (10 B / 100 B / 1 KB / 10 KB / 100 KB). Shipped as bench/bench.lua, run via make bench. Throughput is stderr-only (varies with CPU load); JSON document on stdout.
  • [x] Allocation profiling — bytes per encode/decode op, measured via GC delta with collectgarbage('stop') framing. Committed as bench/baseline.json. Regression gate: make bench-compare exits non-zero if alloc/op grows >5% vs baseline. Allocations are deterministic to ~10 bytes regardless of hardware.
  • [x] Trace stability — make jit-trace (bench/jit_trace.lua) attaches a jit.attach('trace') listener over the hot encode/decode paths and asserts no aborts in our source files fall into the fatal set (NYI bytecode, blacklisting, persistent type instability). Pass: 13/13 scenarios. Two fixes shipped: decode_varint grew a 1-byte fast path so callers no longer drag an inner loop into the root trace; pb.finalize_message now precomputes desc.oneofs_list so runtime-mode oneof encoding uses ipairs instead of pairs (the latter compiles to bytecode ISNEXT, which is NYI in LuaJIT 2.1). The gate also reports interpreter-bridge counts as a benchmark-quality metric (decoders have 0–4 per run depending on JIT timing — caused by side traces returning from inlined decode_varint calls, which LuaJIT 2.1 can't stitch back cleanly; small per-call overhead, structural to the engine). Scope caveat: map fields still encode via pairs() and remain off-trace — pinned by the gate's last scenario so we notice if upstream lifts the restriction.
  • [ ] Optional output: ibuf-based encoder that writes into a caller-owned ffi.cdata byte buffer instead of building a string list. Targets hot RPC paths where allocation cost dominates.
  • [ ] Decoder fast path that returns an msgpack.object-like lazy view for nested messages; only materializes touched fields.

#M7 — Developer ergonomics

  • [ ] Generated EmmyLua / lua-language-server type annotations so t:Person_encode({name=...}) autocompletes in editors.
  • [ ] pb.from_pb(file_descriptor_set) — runtime descriptor parser, lets apps load schemas at runtime without protoc-time codegen.
  • [ ] JSON encoding per the proto3 JSON spec (canonical and tolerant modes).
  • [ ] Text-format printer (MyMsg.print(t)).
  • [ ] protoc-gen-tarantool-doc: generates Markdown reference docs from .proto files.

#M8 — Release engineering

  • [ ] Sourcecraft.dev project + CI pipeline (matrix: Tarantool 2.11 / 3.x EE + CE, Linux + macOS).
  • [ ] Tagged releases; rockspec for the Lua runtime; pre-built binaries for the Go plugin.
  • [ ] Migration guide from Tarantool's built-in require('protobuf') to require('pb').
  • [ ] Example apps: pet-clinic CRUD over gRPC; replication of state via protobuf-encoded events on a queue.

#4. Per-feature design notes

#4.1 Inline (full) codegen — wire bytes

For each message we emit one _encode and one _decode function. Tag bytes are precomputed string literals; field-presence checks are inlined. The runtime is reduced to wire-format primitives.

function M.Address_encode(t)
    local out, n = {}, 0
    local v
    v = t.street
    if v ~= nil and v ~= '' then
        n = n + 1; out[n] = '\x0a'  -- tag(1, LEN)
        n = n + 1; out[n] = wire.encode_string(v)
    end
    -- ...
    return table.concat(out)
end

Decoder uses an if-elseif chain on field id (LuaJIT compiles this well for small chain lengths; for >16 fields we may want a numeric jump table or tag-byte switching).

#4.2 Map fields

map<K,V> is wire-format-equivalent to:

message FooEntry { K key = 1; V value = 2; }
repeated FooEntry foos = N;

Codegen synthesizes the entry message internally but exposes the field as a Lua table (hash, not array). Encode iterates with pairs; decode merges on duplicate keys (last wins, per spec).

#4.3 Oneofs

Descriptor gains oneofs = {[oneof_name] = {field_names...}}. Encode walks fields in declaration order and emits the first non-nil branch. Decode clears sibling fields on assignment so callers see exactly one set.

In inline mode, the encode walk becomes an explicit if-chain; the decode clears are emitted alongside each elseif id == N then arm.

#4.4 64-bit integer ergonomics

Locked: int64/uint64/fixed64/sfixed64/sint64 are LuaJIT int64_t / uint64_t cdata. Reasons:

  • Lossless beyond 2^53.
  • Same convention as Tarantool msgpackffi, net.box, box.tuple.
  • Compares cleanly against 0 (numeric coercion in LuaJIT).

We will document a wire.from_string(s) helper for users who get hex/dec strings (e.g. from JSON) and need to feed them into encode.

#4.5 Unknown fields (implemented)

t._unknown_fields is a single Lua string holding the verbatim concatenation of tag+value bytes for fields the decoder didn't recognize. Captured during decode in encounter order; re-emitted at the tail of _encode. Mirrors Tarantool's built-in protobuf module convention.

Map entries don't preserve unknowns (per spec — synthetic Entry messages). WKT types bypass this too, since they have custom desc.encode/decode.

Both codegen modes implement it: runtime mode in pb.codec, full (inline) mode emits per-message capture/re-emit blocks. Tests live in test/unknown_test.lua and run against both modes.

#4.6 gRPC service descriptors

M.Greeter_service = {
    name = 'hello.Greeter',
    methods = {
        SayHello = {
            full_name = '/hello.Greeter/SayHello',
            input = M.HelloRequest_descriptor,
            output = M.HelloReply_descriptor,
            client_streaming = false,
            server_streaming = false,
        },
        -- ...
    },
}

Client: M.Greeter_client(transport) returns {SayHello = function(req) ... end, ...}.

Server: M.Greeter_server(impl) returns a table compatible with the server:register(svc) interface.

#5. Testing strategy

Tests live in test/. Layout:

test/
  unit/           -- pb.wire and pb.codec unit tests (no codegen)
    wire_test.lua
    codec_test.lua
  roundtrip/      -- generated-module round-trip, both modes
    scalars_test.lua
    repeated_test.lua
    nested_test.lua
    map_test.lua            -- M2
    oneof_test.lua          -- M2
    wkt_test.lua            -- M3
  conformance/    -- M5: Google conformance harness
  interop/        -- M5: cross-impl byte-for-byte equality
    fixtures/     -- pre-encoded payloads from Go/Python
  bench/          -- M6: microbenchmarks
  fuzz/           -- malformed-input + random-input harness

#5.1 Unit tests (M1 onwards)

For each wire.encode_<type> / wire.decode_<type> pair:

  • Round-trip 0, min, max, edge cases (1, -1, NaN, Inf, empty string, 254/255/256-byte string for varint length-byte boundaries).
  • Truncated input → controlled error.
  • Spec-conformant byte sequences from the protobuf encoding doc.

#5.2 Round-trip parity tests (M1+)

Every test runs against both generated modules (full and runtime mode) using a parameterized luatest group:

for _, mode in ipairs({'full', 'runtime'}) do
    local g = t.group('roundtrip.' .. mode)
    local hello = require('hello.' .. mode .. '.hello_pb')
    g.test_address = function() ... end
    -- ...
end

Coverage targets:

  • All 15 scalar types, singular and repeated.
  • Packed vs non-packed repeated (default + explicit).
  • Nested messages, self-references, mutually recursive cycles.
  • Cross-file imports (2-file fixture).
  • Maps with all valid key types (M2).
  • Oneofs with all 3 wire-type families (M2).
  • Default-value elision (proto3 semantics).
  • Explicit-optional presence (M2).
  • Empty messages, single-field messages, 100-field messages.
  • Field IDs straddling varint boundaries: 1, 15, 16, 2047, 2048, 2^29 - 1.

#5.3 Conformance suite (M5)

Build a small Lua binary cmd/conformance-runner.lua that speaks the Google protobuf conformance protocol on stdin/stdout. Checked into the repo so CI runs it against the canonical test corpus. Track pass-rate as a CI metric.

#5.4 Cross-impl interop (M5)

For each of N reference protos:

  1. protoc --encode=msg < input.txt > golden.bin (using mainline protoc).
  2. Our test asserts pb.decode(msg, read('golden.bin')) produces the expected Lua table.
  3. Asserts pb.encode(msg, expected) produces bytes that, when decoded by mainline protoc, yield the same logical message (allow field reordering since neither order is canonical).

Generate goldens once via a make goldens target; commit them to the repo. CI just verifies them, never regenerates.

#5.5 Fuzz tests (M5/M6)

  • Malformed-input fuzz: feed random bytes to every _decode function; assert no crashes, only controlled error() calls.
  • Round-trip fuzz: generate random-but-valid messages (size-bounded), assert decode(encode(t)) == t (value-equal under our equality helper).
  • Use Tarantool's math.random with a seeded PRNG for reproducibility.

#5.6 Performance tests (M6)

Microbenchmarks measured:

  • Encode + decode throughput (msgs/sec, bytes/sec).
  • Allocation rate (tables/sec, strings/sec) via misc.memprof.
  • JIT trace count (no side traces in hot loops).

Run on a fixed corpus across 5 message sizes; track results in bench/baseline.json and fail PRs that regress >5%.

#5.7 Test infrastructure

  • luatest as the framework. Already installed via tt rocks install luatest to .rocks/.
  • Run via make test.rocks/bin/luatest -v test/.
  • CI: sourcecraft.dev native pipelines (TBD), matrix on Tarantool versions.
  • Go side: go test ./... for any pure-Go logic in the plugin.
  • Go integration test: spawn protoc on a fixture, diff generated Lua against committed expected output.

#6. Tooling roadmap

  • Justfile (or extend Makefile) with targets: build, gen, test, bench, lint, goldens, conformance, clean, release.
  • golangci-lint for the Go side; luacheck for the Lua side.
  • gofumpt + stylua for formatting.
  • Coverage: go test -cover for plugin; luacov for runtime.
  • Doc generation: pkgsite for Go; manually maintained Markdown for Lua until a tool emerges.

#7. Open questions / future decisions

Question Notes
Should map encoder be deterministic (sorted by key)? Spec says no; some users want yes. Add pb.encode_deterministic flag later.
Public C-FFI accelerator for varint? Defer until perf benchmarks show pure-Lua bottleneck.
Should we ship a stub HTTP/2 transport for gRPC? Probably no — large dependency. Recommend lua-http or write a focused project.
How to handle the existing Tarantool-builtin require('protobuf')? Document migration; do not override the loader.
Lua module path mapping when no lua_package and no proto package? Currently uses bare filename; consider erroring out instead.
Schema upgrade support (v1 → v2 of a message)? Out of scope; protobuf is forward-compatible by design.
Integration with Tarantool 3.x declarative config? A pb.types.<name> config role would be nice; defer until use case appears.

#8. Non-goals (for now)

  • proto2 syntax — spec is more complex (required, default values, groups), real demand is rare on Tarantool.
  • Protobuf editions beyond what proto3 enables.
  • HTTP/2 transport for gRPC (separate project).
  • Reflection service (gRPC server reflection) — implement after M5.
  • gRPC-Web — separate project.

#9. How to update this plan

Edit this file directly. After any milestone closes:

  1. Move the milestone section under a ## Done heading.
  2. Cross-link the closing PR.
  3. Update README.md status table.
  4. Cut a release on sourcecraft.dev.