~bigbes/tarantool

tarantool-protobuf

ref: 7ec8f2b488e27723ebff9b5eea9587d3a854414b tarantool-protobuf/PLAN.md -rw-r--r-- 26.2 KiB
7ec8f2b4 — Eugene Blikh text: add pb.text.decode and wire it into conformance dispatch 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).
  • [x] Run the canonical conformance_test_runner binary locally and track the pass-rate as a numeric metric (regression gate). docker/conformance.Dockerfile builds conformance_test_runner from upstream protobuf v34.1 source (matching the host's libprotoc 34.1) and includes Tarantool 3 from the official deb; just conformance regenerates Lua then runs the harness against cmd/conformance-runner.lua with the repo mounted as a volume. Watchlists at test/conformance/known_failures.txt (main suite) and test/conformance/known_failures_text.txt (text-format suite). Current baseline (2026-05-16): - Binary+JSON suite: 1478 ✓ / 1313 skipped / 15 expected fails - Text-format suite: 8 ✓ / 426 skipped / 0 expected fails JSON output runs end-to-end; remaining expected fails are Recommended-only edge cases (FieldMask round-trip, duplicate-field-name rejection, null-in-collection rejection, unknown-enum-name rejection, NullValue oneof validator). Text-format output is wired through pb.text.encode and the proto3 text suite is fully clean: SGROUP/EGROUP are tolerated in wire.skip_field and pb.text renders captured unknown bytes in numeric field-ID form under opts.print_unknown_fields. The 426 still-skipped tests are proto2/editions message types we don't register. Text-format input parsing is still deferred. The PB_CONFORMANCE_SKIP_JSON=1 env var still short-circuits JSON output if a new encoder bug crashes jsoncpp. CI wire-up pending — the image build is the long pole (~10–15 min on a clean cache).
  • [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 (delayed; bench harness shipped, further perf work parked)

  • [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.

  • [x] Decoder fast path that returns an msgpack.object-like lazy view for nested messages; only materializes touched fields. Shipped as runtime/pb/lazy.lua + M.<Type>_decode_lazy codegen stubs in both modes. Surface: :get / :has / :which / :iter / :names on MessageView; :len / :at / :iter / :tolist on ArrayView; :get / :has / :keys / :iter / :totable on MapView. Mutation via :set is supported and propagates sub-view edits transparently (sub-MessageViews tracked on a flat array for JIT-stable is_dirty — see lazy.lua's _sub_msg_views). Re-encode is passthrough: untouched views return their original bytes verbatim; partially-dirty views walk fields in id order, splicing clean segments and re-emitting dirty ones. WKT descriptors (those with desc.decode) are eager-wrapped so the API stays uniform.

    Conformance: every interop fixture round-trips byte-equal through
    `decode_lazy(b):encode()`. Trace stability: gated by `make
    jit-trace` — index pass, sparse `:get` x2, and passthrough
    `:encode` all compile with no fatal aborts.
    
    Workload characteristics (from `tarantool bench/lazy_bench.lua`,
    Person at 1KB / 10KB / 100KB, after the SoA index refactor):
     - **Passthrough re-encode** is the headline win: **1.6–1.9×**
       faster than eager decode→encode across all sizes and both modes.
       Untouched views never re-walk the wire.
     - **Sparse read** (`:get` two top-level fields) is **0.90–1.16×
       of eager**: break-even at small sizes, slight win at 100KB
       (especially in runtime mode where eager pays more dispatch
       cost). Earlier 0.60–0.77× regression came from one Lua table
       per wire entry; replacing with parallel int arrays closed the
       allocation gap.
     - **Mutate-then-reencode** is **1.09–1.26× of eager**; the
       per-field splice path now consistently beats full re-encode.
    The honest framing: lazy is a *byte-passthrough* optimization
    that also handles sparse reads at parity. Best fit: proxy /
    router shapes that decode, touch a few fields, and re-encode.
    

#M7 — Developer ergonomics

  • [x] Generated EmmyLua / lua-language-server type annotations so t:Person_encode({name=...}) autocompletes in editors. Codegen emits ---@class <full.Name> per message (with one ---@field per field), ---@alias <full.Name> integer per enum, and ---@param / ---@return on every _new, _encode, _decode, _decode_lazy, _has_*, _clear_* wrapper. Class identifiers use proto full names verbatim so cross-file references resolve. Lazy view types (pb.MessageView, pb.ArrayView, pb.MapView) are declared inline in runtime/pb/lazy.lua so the LSP sees them. Pure comment addition — no runtime impact, 300/300 luatest + 19/19 jit-trace gate stay green.
  • [x] pb.from_pb(file_descriptor_set) — accepts binary FileDescriptorSet bytes (output of protoc --descriptor_set_out=...) and returns {files = {[name] = module}, order, lookup}. Each per-file module has the same surface as pb.parse output (statically-generated runtime mode). Translation pipeline: hand-built descriptor.proto descriptors decode the wire bytes via pb.codec, then a translator converts each FileDescriptorProto to the AST shape pb.parser emits, which pb.dynamic.build consumes. Map fields are reconstructed from synthetic entry messages (skipped from nested_messages); proto3_optional is rehydrated as optional=true instead of being modelled as a synthetic oneof.
  • [x] JSON encoding per the proto3 JSON spec (pb.json.encode / pb.json.decode).
  • [x] Text-format printer. pb.text.encode(desc, t, opts) returns the protoc --decode form (one field per line, 2-space indent, octal byte escapes); opts.single_line=true collapses to a space-separated one-liner for log lines and inline goldens. Codegen emits M.<Type>_text(t, opts) in both modes. WKT types know their idiomatic Lua shapes — Timestamp/Duration accept datetime cdata or {seconds,nanos}, wrappers print their unwrapped scalar as value: ..., Struct/Value/ListValue walk the tagged-table form, FieldMask prints paths: ... per entry, Any stays opaque.
  • [x] Text-format parser. pb.text.decode(desc, text, opts) is the recursive-descent counterpart: handles every grammar bucket the proto3 conformance suite exercises — decimal/hex/octal int literals, float specials (inf/infinity/nan any case, oversize exponents saturating to ±inf, underflow to ±0), C-style + \u/\U string escapes with adjacent-literal concat and surrogate rejection, aggregate {} / <> bodies, repeated short-form [a, b, c], key: K value: V map entries, the [type.googleapis.com/...] inline Any form, enum-by-name-or-number, reserved-name drop, and numeric-field-ID tolerance. Range-checks 32/64-bit ints, rejects duplicate singular fields, and threads through the conformance runner — cmd/conformance/core.lua no longer skips text_payload. Proto3 text-format conformance suite: 8 ✓ / 426 skipped → 406 ✓ / 18 skipped / 10 expected failures (the 10 are -0 float/double preservation; their root cause is in the codec, not the parser — see test/conformance/known_failures_text.txt).
  • [x] protoc-gen-tarantool-doc: sibling Go plugin under cmd/protoc-gen-tarantool-doc/ that emits one Markdown file per input .proto. Sections: header (package + imports), messages (per-message description + field table with # | Field | Type | Label | Description), enums (value table), services (method table with unary / client / server / bidi streaming label). Field type cells render scalar names, full type names for message/enum references, and map<K, V> for maps; synthetic map-entry messages are skipped. Leading comments are preserved via SourceCodeInfo (squashed to a single line inside table cells). Build with make build-doc; generate sample docs into examples/docs/ with make gen-docs.

#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.