~bigbes/tarantool

tarantool-protobuf

8b5bfc0e39a57a063cf6a5a8a80b9b7128ecd4c9 — Eugene Blikh 2 months ago 117c6ef
codegen: int64_as_number opt-in flag for 64-bit decode as Lua number (5y9)

Adds a plugin generator option:

    protoc --tarantool_opt=int64_as_number=true ...

mode=full only, default off, error on mode=runtime. When set, 64-bit
scalar decoders (int64/uint64/sint64/fixed64/sfixed64) return a Lua
number for values that fit [-2^53, 2^53] (inclusive — both endpoints
are powers of two and exact as doubles), cdata otherwise. The decoded
type becomes value-dependent under this option; `+`/`-`/`*`/`==`
work transparently on both, so most callers don't have to care.

Wire side: decode_int64_n / decode_uint64_n / decode_sint64_n /
decode_fixed64_n / decode_sfixed64_n added alongside the existing
decoders. Bounds use LL/ULL cdata literals so the threshold compares
compile to plain 64-bit integer compares on a hot trace.

Codegen side: cfg.Int64AsNumber threads to writer.int64AsNumber;
decodeFnSuffix() emits "_n" only when the flag is set and the scalar
is one of the affected kinds. Every wire.decode_<st> emit site picks
it up — singular, repeated, packed, oneof, extension, map value.

The flag is a no-op under PB_ENABLE_C=1: the C runtime decides
number-vs-cdata on its own via luaL_pushint64 (Tarantool's small-fits-
in-double convention). The test gates on PB_ENABLE_C accordingly.

Workload-specific tradeoff measured on c_int64.Wide decode (full mode,
no PB_ENABLE_C, 5 fields per message):
  tiny   (all 1-byte vars)   2050 -> 1700 ns/op   (-17%, cdata avoided)
  medium (3-byte vars)       3220 -> 3600 ns/op   (+11%, extra cmp+tonumber)
  huge   (past 2^53)         7575 -> 7750 ns/op   (+2%, noise)

Enable when fields are dominated by small IDs/counters/small
timestamps that hit the 1-byte varint path; leave off otherwise.
The plugin flag help documents the tradeoff.

Test fixture: examples/expected/full_n/c_int64/c_int64_pb.lua via
the new gen-int64-as-number Justfile recipe. test/int64_as_number_test.lua
covers byte-identical encoding, small-value Lua number returns, default
cdata returns, 2^53 boundary inclusive, past-2^53 cdata fallback, and
Lua-number-input round-trip.

Suites: test 771/771, test-c 1057/1057 (5 skipped under PB_ENABLE_C=1).
M .beads/issues.jsonl => .beads/issues.jsonl +1 -1
@@ 58,7 58,7 @@
{"_type":"issue","id":"tarantool-protobuf-0gg","title":"Decoder: investigate field-id dispatch as binary search or jump table","description":"Generated Type_decode bodies use linear 'if id == 1 elseif id == 2 ...' chains. For Person (~15 fields) the chain is short and the comparisons cheap. For messages with 30+ fields the linear walk to a high-id tag costs N comparisons per occurrence.\n\nOpen question: does LuaJIT's IR already lower this to a switch/jump? If yes, no work needed. If no, three options:\n\n  1. Codegen-emit a balanced if-elseif tree (binary search) when field count exceeds threshold.\n  2. For messages where field IDs are dense and small, emit a numeric branch table: 'local _f = _dispatch[id]; if _f then return _f(buf, pos, result) end'. Closure-per-field has setup cost but avoids the dispatch cost on every tag.\n  3. Sort by frequency (impossible to know at codegen time without profiling input). Skip this option.\n\nPre-work: jit.dump on Person_decode to check whether LuaJIT collapses the if-elseif. If it does, close as won't-fix; if it doesn't, decide between (1) and (2).\n\nLowest priority of the perf items because: (a) we don't know it's a problem yet, (b) Person profile shows decode_string + decode_tag + utf8 dominate, not the if-elseif walk.","notes":"Recent profile (Person 1KB): line 1007 (decode_tag call) is 14% of generated body time, and line 1021 (list append) is 24%. Linear if-elseif walk doesn't show up as a hotspot for Person, but Person has only 15 fields. Needs a bigger fixture to manifest.","status":"open","priority":3,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T17:15:07Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T17:15:07Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-1bo","title":"Codegen: chunk oversized generated decode bodies into per-range helpers","description":"test_messages_proto3_pb.lua is 4212 lines — the conformance fixture has so many fields that a single Type_decode body exceeds LuaJIT's inline budget and trace size limits. Symptom (to verify): bench/jit_trace.lua trace aborts on the big conformance message, despite no NYI bytecodes in the body.\n\nFix: when a message has \u003eN fields (threshold to find empirically; LuaJIT defaults LJ_TRACE_MAX_BC=8000), codegen splits the if-elseif chain into per-range helpers: _decode_fields_1_to_15, _decode_fields_16_to_31, etc. The top-level Type_decode dispatches to the right chunk by id range:\n\n  if id \u003c= 15 then\n    pos = _decode_fields_1_to_15(buf, pos, id, wt, result)\n  elseif id \u003c= 31 then\n    pos = _decode_fields_16_to_31(buf, pos, id, wt, result)\n  ...\n  end\n\nEach chunk is small enough to JIT-compile cleanly. Adds one function call per tag, but only on messages large enough to need it — small messages stay inline.\n\nThe range size and split threshold need measurement. Start with: split when message has \u003e32 fields, into chunks of 16. Measure throughput on conformance message before/after to validate the trade.","notes":"Pre-work: confirm the abort is real by running bench/jit_trace.lua against a conformance-like fixture and checking the FATAL traceerr codes. If LuaJIT compiles the 4212-line body fine, this is a non-issue.","status":"open","priority":3,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T17:14:39Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T17:14:39Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-gi0","title":"Codegen: emit wire.encode_varint directly for int32/int64/uint32/uint64 instead of typed alias","description":"runtime/pb/wire.lua aliases M.encode_int32 = encode_varint (and same for int64/uint32/uint64). Generated code calls wire.encode_int32(v) which resolves to encode_varint through two table lookups — wire.encode_int32 (one hash lookup), then the alias resolution. LuaJIT may collapse this when the trace stays hot, but each break re-incurs both.\n\nCodegen can emit wire.encode_varint directly for the four unsigned-varint scalar types (int32/int64/uint32/uint64) since they're literally encode_varint with different names. Skips one alias indirection per varint encode. Pairs naturally with kot (localize wire.* upvalues) — together they reduce the call to a direct LJ_FUNCC dispatch with no name lookup.\n\nSame applies to bool (encode_bool = encode_varint with v and 1 or 0 wrapper) and sint32/sint64 (zigzag wrapper) — codegen could inline the wrapper logic at the call site for sint, but that overlaps with h8v (FFI direct writes) which subsumes the question.","notes":"Codegen-side change in protoc-gen-tarantool. Constraint: the alias provides the typed encoder slot in TYPE_INFO that runtime-mode encoders walk — those aliases must stay. Only the generated mode=full code changes.","status":"closed","priority":3,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T17:14:38Z","created_by":"Eugene Blikh","updated_at":"2026-05-24T20:14:49Z","started_at":"2026-05-24T20:11:24Z","closed_at":"2026-05-24T20:14:49Z","close_reason":"Premise invalid. wire.encode_int32 / encode_int64 / encode_uint32 / encode_uint64 ARE literally encode_varint at module load (one assignment, same function value). kot's localize already captures wire.encode_int32 once per function, so the in-body call is a direct local read with no alias resolution. Verified: wire.encode_int32 == wire.encode_varint -\u003e true. No extra TGETS at call time. The only residual is kot creating separate locals when a function uses both encode_int32 and encode_varint (could dedupe to one closure upvalue) — marginal closure-size win, not the 'two table lookups' premise. Closing as won't-fix; the work was already done by Lua alias semantics + kot localization.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-5y9","title":"Decoder: int64/uint64 return Lua number when value fits in 2^53","description":"decode_int64/decode_uint64 always return ffi cdata, forcing a fresh cdata allocation per call. For values in [-2^53, 2^53) (the common case for IDs, sequence numbers, timestamps fitting in 53 bits, byte counts, etc.) a Lua number is exactly representable and skips the cdata header allocation.\n\nProposed shape: opt-in variant decoder, since changing the default breaks any caller that does type(v)=='cdata' or relies on cdata-only operators. Two API options:\n\n  1. Per-descriptor flag (desc.int64_as_number = true) wired in via a codegen option or generator flag. Generated code emits a different decoder fn.\n  2. Separate typed decoders (wire.decode_int64_n / decode_uint64_n) that callers opt into explicitly.\n\nWatch case: values exceeding 2^53 must still return cdata (with a runtime branch). The branch cost only pays off if cdata allocation cost \u003e one comparison, which it is on hot paths.","notes":"Bench impact bounded by how many int64 fields the workload has. For hello.Person, user_id (fixed64) is the only one — so impact on this fixture would be ~1-2%. Bigger win on protobuf workloads dominated by timestamps and sequence numbers (datastore RPCs, log streams).","status":"open","priority":3,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T17:14:37Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T17:14:37Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-5y9","title":"Decoder: int64/uint64 return Lua number when value fits in 2^53","description":"decode_int64/decode_uint64 always return ffi cdata, forcing a fresh cdata allocation per call. For values in [-2^53, 2^53) (the common case for IDs, sequence numbers, timestamps fitting in 53 bits, byte counts, etc.) a Lua number is exactly representable and skips the cdata header allocation.\n\nProposed shape: opt-in variant decoder, since changing the default breaks any caller that does type(v)=='cdata' or relies on cdata-only operators. Two API options:\n\n  1. Per-descriptor flag (desc.int64_as_number = true) wired in via a codegen option or generator flag. Generated code emits a different decoder fn.\n  2. Separate typed decoders (wire.decode_int64_n / decode_uint64_n) that callers opt into explicitly.\n\nWatch case: values exceeding 2^53 must still return cdata (with a runtime branch). The branch cost only pays off if cdata allocation cost \u003e one comparison, which it is on hot paths.","notes":"Bench impact bounded by how many int64 fields the workload has. For hello.Person, user_id (fixed64) is the only one — so impact on this fixture would be ~1-2%. Bigger win on protobuf workloads dominated by timestamps and sequence numbers (datastore RPCs, log streams).","status":"closed","priority":3,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T17:14:37Z","created_by":"Eugene Blikh","updated_at":"2026-05-24T21:22:52Z","started_at":"2026-05-24T21:04:46Z","closed_at":"2026-05-24T21:22:52Z","close_reason":"Implemented as opt-in via plugin flag --tarantool_opt=int64_as_number=true (mode=full only; default off). Adds wire.decode_int64_n / decode_uint64_n / decode_sint64_n / decode_fixed64_n / decode_sfixed64_n that return Lua number when the decoded value fits [-2^53, 2^53] (inclusive — both endpoints are powers of two and exact as doubles), cdata otherwise. LL/ULL literals are int64_t/uint64_t cdata; comparisons compile to plain 64-bit integer compares on trace.\n\nCodegen plumbs cfg.Int64AsNumber through writer.int64AsNumber (avoids threading through 10+ function signatures). decodeFnSuffix() returns \"_n\" for the affected scalar types only when the flag is set. All wire.decode_\u003cst\u003e emit sites updated.\n\nPlugin guards: --int64_as_number=true with mode=runtime errors out (would require descriptor flag wired through pb.codec — out of scope). Under PB_ENABLE_C=1 the option is a no-op since the C runtime makes its own number-vs-cdata decision via luaL_pushint64; test gates accordingly with t.skip.\n\nMeasured tradeoff on c_int64.Wide decode (full mode, no PB_ENABLE_C, 5 fields):\n  tiny   (all 1-byte vars)   2050 -\u003e 1700 ns/op   (-17%, cdata avoided entirely)\n  medium (3-byte vars)       3220 -\u003e 3600 ns/op   (+11%, extra cmp+tonumber)\n  huge   (past 2^53)         7575 -\u003e 7750 ns/op   (+2%, noise)\n\nWorkload-specific: enable for fields dominated by small IDs/counters/small timestamps (where 1-byte varint hits), leave off for large values. Documented in the plugin flag help.\n\nTest fixture: examples/expected/full_n/c_int64/c_int64_pb.lua regenerated by 'just gen-int64-as-number'. test/int64_as_number_test.lua covers byte-identical encoding, small-value Lua number returns, default cdata returns, 2^53 boundary inclusive, past-2^53 cdata fallback, Lua-number-input round-trip. Suites: test 771/771, test-c 1057/1057 (with 5 skipped under PB_ENABLE_C=1).","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-8k2","title":"Docs: remove PLAN.md after Beads migration","description":"PLAN.md has been converted from active roadmap to duplicate design-history prose now that all actionable work is tracked in Beads. Remove the file or replace remaining references with Beads/README pointers so project state has a single durable task source of truth.","status":"closed","priority":3,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T18:33:09Z","created_by":"Eugene Blikh","updated_at":"2026-05-17T18:33:48Z","closed_at":"2026-05-17T18:33:48Z","close_reason":"Removed PLAN.md and retargeted remaining references to Beads or concrete docs.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-c0i","title":"C accel: generated C codec backend for mode=full","description":"Extend protoc-gen-tarantool with an optional generated-C backend. Emit C source plus Lua wrappers for each .proto package; generated functions know tag bytes, field names, defaults, oneofs, maps, proto2 required/defaults/groups/extensions, and nested message calls. The Lua API remains M.Type_encode(t) -\u003e string and M.Type_decode(bytes) -\u003e table. Goal: one Lua-\u003eC call per top-level message, no Lua table.concat, no Lua decode_tag ladder, no per-field FFI boundary.","notes":"DEFERRED per docs/c-accel.md. Spike (04c) showed S4 hand-written C had ≤15% headroom over S3 (generic C runtime, ra6) and went the wrong way at scale (S4 *slower* than S3 at 10KB and 100KB encode). Codegen complexity not justified by current numbers. REVIVAL CRITERIA: a measured real-workload shape where ra6's per-field dispatch costs ≥25% over hand-written for that shape, demonstrated with a microbenchmark, AND the affected workload is on a hot path for a real user. Likely trigger shapes: wide messages with many optionals, heavy oneof use, complex maps, deeply nested (5+ levels) hierarchies. When triggered: write 2-3 paragraphs documenting shape and numbers, re-open this issue, scope narrowly via desc.encode/desc.decode override (same mechanism as WKT). If 6 months after ra6 ships no trigger fires, close as 'not justified'. Now blocked by ra6 (so it surfaces in ready list only after ra6 implementation lands).","status":"open","priority":3,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:32Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:19:41Z","dependencies":[{"issue_id":"tarantool-protobuf-c0i","depends_on_id":"tarantool-protobuf-47e","type":"blocks","created_at":"2026-05-17T19:33:29Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-c0i","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-17T19:33:12Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-c0i","depends_on_id":"tarantool-protobuf-ra6","type":"blocks","created_at":"2026-05-18T23:19:44Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-c0i","depends_on_id":"tarantool-protobuf-z7x","type":"blocks","created_at":"2026-05-17T19:33:29Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-bnj","title":"Tooling: add formatting targets for Go and Lua","description":"PLAN.md section 6 lists gofumpt and stylua. Add formatter configuration and Justfile targets, with generated code excluded unless the generator itself is intended to emit stylua-compliant output.","status":"open","priority":3,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:28:28Z","created_by":"Eugene Blikh","updated_at":"2026-05-17T16:28:28Z","dependency_count":0,"dependent_count":0,"comment_count":0}

M Justfile => Justfile +15 -1
@@ 86,7 86,7 @@ clean-c:
# ---------------------------------------------------------------------------

# Regenerate examples/expected/{full,runtime}/* + conformance protos.
gen: gen-full gen-runtime gen-conformance gen-proto2-tests
gen: gen-full gen-runtime gen-conformance gen-proto2-tests gen-int64-as-number

# Generate full-mode Lua (inline encode/decode bodies).
gen-full: build


@@ 142,6 142,20 @@ gen-proto2-tests: build
        -I {{proto2_test_dir}} -I options \
        {{proto2_test_dir}}/*.proto

# Generate the c_int64 fixture into examples/expected/full_n/ with the
# `int64_as_number=true` codegen flag set, so the opt-in 5y9 path has a
# parallel fixture for round-trip + type-stability tests next to the
# default full/ output. Single fixture is enough to cover the codegen
# surface; no need to double the entire examples/expected/full/ tree.
gen-int64-as-number: build
    mkdir -p {{gen_dir}}
    protoc \
        --plugin=./{{plugin}} \
        --tarantool_out={{gen_dir}} \
        --tarantool_opt=mode=full,prefix=full_n,int64_as_number=true \
        -I {{proto2_test_dir}} -I options \
        {{proto2_test_dir}}/c_int64.proto

# Regenerate Markdown reference docs (examples/docs/*.md) — committed output.
gen-docs: build-doc
    mkdir -p {{docs_dir}}

M cmd/protoc-gen-tarantool/internal/gen/gen.go => cmd/protoc-gen-tarantool/internal/gen/gen.go +15 -1
@@ 51,6 51,12 @@ type Config struct {
	// generated under multiple namespaces in one project — e.g. for
	// side-by-side full vs runtime mode comparison in tests.
	Prefix string
	// Int64AsNumber: when true (mode=full only), 64-bit decoders return a
	// Lua number for values that fit in [-2^53, 2^53] (or [0, 2^53] for
	// unsigned) and cdata otherwise. Skips the per-call cdata allocation
	// on the dominant small-value case (IDs, timestamps, byte counts).
	// Default false — decoded 64-bit fields are always cdata. (5y9)
	Int64AsNumber bool
}

// GenerateFile emits one `<lua_pkg>.lua` file per input `.proto`.


@@ 65,7 71,11 @@ func GenerateFile(plug *protogen.Plugin, file *protogen.File, cfg Config) error 
	allEnums := flattenEnums(file.Enums, file.Messages)

	out := plug.NewGeneratedFile(outputFilename(file.Desc, cfg.Prefix), "")
	w := &writer{GeneratedFile: out, opts: newOptionsResolver(plug)}
	w := &writer{
		GeneratedFile: out,
		opts:          newOptionsResolver(plug),
		int64AsNumber: cfg.Int64AsNumber,
	}

	emitHeader(w, file)
	imports := collectImports(file, allMsgs, cfg.Prefix)


@@ 157,6 167,10 @@ type writer struct {
	// emitInlineEncode/Decode to scan a function body for wire.* refs and
	// rewrite them to bare locals.
	buf *[]string
	// int64AsNumber mirrors Config.Int64AsNumber (5y9). Attached to the
	// writer rather than threaded through every emit-* signature so the
	// option is one field-lookup away wherever a decode call is emitted.
	int64AsNumber bool
}

func (w *writer) line(format string, args ...any) {

M cmd/protoc-gen-tarantool/internal/gen/inline.go => cmd/protoc-gen-tarantool/internal/gen/inline.go +25 -10
@@ 10,6 10,21 @@ import (
	"google.golang.org/protobuf/reflect/protoreflect"
)

// decodeFnSuffix returns "_n" when 5y9's int64_as_number option is set
// and `st` is one of the cdata-returning 64-bit scalar kinds, "" otherwise.
// Used to swap wire.decode_int64 -> wire.decode_int64_n at codegen sites
// where the value type is statically known. (5y9)
func decodeFnSuffix(w *writer, st string) string {
	if !w.int64AsNumber {
		return ""
	}
	switch st {
	case "int64", "uint64", "sint64", "fixed64", "sfixed64":
		return "_n"
	}
	return ""
}

// encodeCallExpr returns the Lua expression that encodes a single value
// of the given scalar type `st`. For types where mode=full can elide
// wire.to_int64 / wire.to_uint64's runtime type dispatch (sint64, fixed64,


@@ 260,10 275,10 @@ func emitPackedVarintElemDecode(w *writer, indent, st, valExpr string) {
		w.line("%s        %s = _b - 128 + _b2 * 128", indent, valExpr)
		w.line("%s        p2 = p2 + 2", indent)
		w.line("%s    else", indent)
		w.line("%s        %s, p2 = wire.decode_%s(payload, p2)", indent, valExpr, st)
		w.line("%s        %s, p2 = wire.decode_%s%s(payload, p2)", indent, valExpr, st, decodeFnSuffix(w, st))
		w.line("%s    end", indent)
		w.line("%selse", indent)
		w.line("%s    %s, p2 = wire.decode_%s(payload, p2)", indent, valExpr, st)
		w.line("%s    %s, p2 = wire.decode_%s%s(payload, p2)", indent, valExpr, st, decodeFnSuffix(w, st))
		w.line("%send", indent)
	case "bool":
		// Bool values are spec-valid only as 0 or 1, always 1-byte on the wire.


@@ 277,7 292,7 @@ func emitPackedVarintElemDecode(w *writer, indent, st, valExpr string) {
		w.line("%s    %s, p2 = wire.decode_bool(payload, p2)", indent, valExpr)
		w.line("%send", indent)
	default:
		w.line("%s%s, p2 = wire.decode_%s(payload, p2)", indent, valExpr, st)
		w.line("%s%s, p2 = wire.decode_%s%s(payload, p2)", indent, valExpr, st, decodeFnSuffix(w, st))
	}
}



@@ 824,7 839,7 @@ func emitInlineDecodeExtension(w *writer, ext *protogen.Extension, file *protoge
			st = "bytes"
		}
		w.line("            local _val")
		w.line("            _val, pos = wire.decode_%s(buf, pos)", st)
		w.line("            _val, pos = wire.decode_%s%s(buf, pos)", st, decodeFnSuffix(w, st))
		w.line("            local _e = result._extensions")
		w.line("            if _e == nil then _e = {}; result._extensions = _e end")
		w.line("            _e[%q] = _val", full)


@@ 886,17 901,17 @@ func emitInlineDecodeExtensionRepeated(w *writer, ext *protogen.Extension, full 
			w.line("                local p2, lim = 1, #_payload")
			w.line("                while p2 <= lim do")
			w.line("                    local _val")
			w.line("                    _val, p2 = wire.decode_%s(_payload, p2)", st)
			w.line("                    _val, p2 = wire.decode_%s%s(_payload, p2)", st, decodeFnSuffix(w, st))
			w.line("                    _list[#_list + 1] = _val")
			w.line("                end")
			w.line("            else")
			w.line("                local _val")
			w.line("                _val, pos = wire.decode_%s(buf, pos)", st)
			w.line("                _val, pos = wire.decode_%s%s(buf, pos)", st, decodeFnSuffix(w, st))
			w.line("                _list[#_list + 1] = _val")
			w.line("            end")
		} else {
			w.line("            local _val")
			w.line("            _val, pos = wire.decode_%s(buf, pos)", st)
			w.line("            _val, pos = wire.decode_%s%s(buf, pos)", st, decodeFnSuffix(w, st))
			w.line("            _list[#_list + 1] = _val")
		}
	}


@@ 1094,7 1109,7 @@ func emitInlineDecodeFieldBody(w *writer, f *protogen.Field, file *protogen.File
			emitInlineStringBytesScalar(w, st, dst, validateUTF8)
		} else {
			w.line("            local val")
			w.line("            val, pos = wire.decode_%s(buf, pos)", st)
			w.line("            val, pos = wire.decode_%s%s(buf, pos)", st, decodeFnSuffix(w, st))
			w.line("            %s = val", luaFieldAccess("result", fname))
		}
	}


@@ 1209,7 1224,7 @@ func emitInlineDecodeRepeated(w *writer, f *protogen.Field, fname string, file *
			w.line("                local list = %s", dst)
			w.line("                if list == nil then list = {}; %s = list end", dst)
			w.line("                local val")
			w.line("                val, pos = wire.decode_%s(buf, pos)", st)
			w.line("                val, pos = wire.decode_%s%s(buf, pos)", st, decodeFnSuffix(w, st))
			w.line("                %s = %s + 1; list[%s] = val", cnt, cnt, cnt)
			w.line("            end")
		} else {


@@ 1546,7 1561,7 @@ func emitMapDecode(w *writer, dst string, f *protogen.Field, file *protogen.File
		if st == "string" && !validateUTF8 {
			st = "bytes"
		}
		w.line("                    %s, _ep = wire.decode_%s(payload, _ep)", dst, st)
		w.line("                    %s, _ep = wire.decode_%s%s(payload, _ep)", dst, st, decodeFnSuffix(w, st))
	}
}


M cmd/protoc-gen-tarantool/main.go => cmd/protoc-gen-tarantool/main.go +13 -1
@@ 52,6 52,14 @@ func main() {
	prefixFlag := flags.String("prefix", "",
		"prefix prepended to every generated module's Lua require path "+
			"(useful for side-by-side generation in tests)")
	int64AsNumberFlag := flags.Bool("int64_as_number", false,
		"opt-in (mode=full only): emit decoders that return a Lua number "+
			"for int64/uint64/sint64/fixed64/sfixed64 values that fit in "+
			"[-2^53, 2^53), falling back to cdata for values outside that "+
			"range. Skips the per-call cdata allocation on the common case "+
			"(IDs, timestamps in seconds/ms, byte counts). Decoded type is "+
			"unstable (number-or-cdata); arithmetic works transparently. "+
			"Default false (always cdata).")
	plugin, err := protogen.Options{ParamFunc: flags.Set}.New(req)
	if err != nil {
		fail("init protogen: %v", err)


@@ 61,7 69,11 @@ func main() {
	if err != nil {
		fail("%v", err)
	}
	cfg := gen.Config{Mode: mode, Prefix: *prefixFlag}
	if *int64AsNumberFlag && mode != gen.ModeFull {
		fail("int64_as_number is currently mode=full only " +
			"(runtime mode would require a descriptor flag wired through pb.codec)")
	}
	cfg := gen.Config{Mode: mode, Prefix: *prefixFlag, Int64AsNumber: *int64AsNumberFlag}

	// Advertise proto3 optional support so protoc lets us see those fields.
	plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)

A examples/expected/full_n/c_int64/c_int64_pb.lua => examples/expected/full_n/c_int64/c_int64_pb.lua +264 -0
@@ 0,0 1,264 @@
-- Code generated by protoc-gen-tarantool. DO NOT EDIT.
-- source: c_int64.proto
-- syntax: proto3
-- package: c_int64

local pb = require("pb")
local wire = pb.wire
local string_byte = string.byte
local utf8_len = require('utf8').len
local band = bit.band
local rshift = bit.rshift
local CHARS = wire.CHARS
local table_new = require('table.new')
local ffi = require('ffi')
local INT64  = ffi.typeof('int64_t')
local UINT64 = ffi.typeof('uint64_t')

local M = {}

M.options = {go_package = "tarantoolpb_synthetic/c_int64"}

-- Pre-declare message descriptors so cross-references resolve.
M.Wide_descriptor = {name = "c_int64.Wide"}

-- Message: c_int64.Wide
M.Wide_descriptor.fields = {
    {name="a_int64", id=1, kind='scalar', proto_type="int64"},
    {name="a_uint64", id=2, kind='scalar', proto_type="uint64"},
    {name="a_sint64", id=3, kind='scalar', proto_type="sint64"},
    {name="a_fixed64", id=4, kind='scalar', proto_type="fixed64"},
    {name="a_sfixed64", id=5, kind='scalar', proto_type="sfixed64"},
}
pb.finalize_message(M.Wide_descriptor)
M.Wide_fields = pb.field_names({
    a_int64 = "a_int64",
    a_uint64 = "a_uint64",
    a_sint64 = "a_sint64",
    a_fixed64 = "a_fixed64",
    a_sfixed64 = "a_sfixed64",
})

-- EmmyLua / lua-language-server type annotations.
-- These are comments — no runtime effect. They give editors
-- autocomplete and type-checking for the generated wrappers.
---@class c_int64.Wide
---@field a_int64 integer
---@field a_uint64 integer
---@field a_sint64 integer
---@field a_fixed64 integer
---@field a_sfixed64 integer

---@param t? c_int64.Wide
---@return c_int64.Wide
function M.Wide_new(t) return t or {} end

---@param t c_int64.Wide
---@return string
function M.Wide_encode(t)
    local _d = M.Wide_descriptor
    if pb.c_runtime ~= nil then
        local _p = _d.c_plan or pb.c_runtime.compile_plan(_d)
        return pb.c_runtime.encode(_p, t)
    end
    if type(t) ~= 'table' then
        error("expected table for c_int64.Wide, got " .. type(t), 0)
    end
    local out, n = {}, 0
    local v
    -- field 1: a_int64
    v = t.a_int64
    if v ~= nil and v ~= 0 then
        n = n + 1; out[n] = "\x08"
        n = n + 1; out[n] = wire.encode_int64(v)
    end
    -- field 2: a_uint64
    v = t.a_uint64
    if v ~= nil and v ~= 0 then
        n = n + 1; out[n] = "\x10"
        n = n + 1; out[n] = wire.encode_uint64(v)
    end
    -- field 3: a_sint64
    v = t.a_sint64
    if v ~= nil and v ~= 0 then
        n = n + 1; out[n] = "\x18"
        n = n + 1; out[n] = wire.encode_sint64_i(INT64(v))
    end
    -- field 4: a_fixed64
    v = t.a_fixed64
    if v ~= nil and v ~= 0 then
        n = n + 1; out[n] = "\x21"
        n = n + 1; out[n] = wire.encode_fixed64_u(UINT64(v))
    end
    -- field 5: a_sfixed64
    v = t.a_sfixed64
    if v ~= nil and v ~= 0 then
        n = n + 1; out[n] = "\x29"
        n = n + 1; out[n] = wire.encode_sfixed64_u(UINT64(v))
    end
    local _uf = t._unknown_fields
    if _uf ~= nil and _uf ~= '' then n = n + 1; out[n] = _uf end
    return table.concat(out)
end

---@param b string
---@return c_int64.Wide
function M.Wide_decode(buf)
    local decode_tag = wire.decode_tag
    local _d = M.Wide_descriptor
    if pb.c_runtime ~= nil then
        local _p = _d.c_plan or pb.c_runtime.compile_plan(_d)
        return pb.c_runtime.decode(_p, buf)
    end
    if type(buf) ~= 'string' then
        error("expected string for c_int64.Wide decode, got " .. type(buf), 0)
    end
    local result = {}
    local pos, len = 1, #buf
    local _uf
    while pos <= len do
        local _tag_start = pos
        local id, wt
        local _b = string_byte(buf, pos)
        if _b ~= nil and _b < 0x80 then
            wt = band(_b, 7)
            if wt >= 6 then error("illegal wire type " .. wt, 0) end
            id = rshift(_b, 3)
            if id == 0 then error("illegal field number 0", 0) end
            pos = pos + 1
        elseif _b ~= nil and pos < len then
            local _b2 = string_byte(buf, pos + 1)
            if _b2 < 0x80 then
                if _b2 == 0 then error("overlong tag varint at offset " .. pos, 0) end
                local _v = _b - 128 + _b2 * 128
                wt = band(_v, 7)
                if wt >= 6 then error("illegal wire type " .. wt, 0) end
                id = rshift(_v, 3)
                pos = pos + 2
            else
                id, wt, pos = decode_tag(buf, pos)
            end
        else
            id, wt, pos = decode_tag(buf, pos)
        end
        if id == 1 then
            local val
            val, pos = wire.decode_int64_n(buf, pos)
            result.a_int64 = val
        elseif id == 2 then
            local val
            val, pos = wire.decode_uint64_n(buf, pos)
            result.a_uint64 = val
        elseif id == 3 then
            local val
            val, pos = wire.decode_sint64_n(buf, pos)
            result.a_sint64 = val
        elseif id == 4 then
            local val
            val, pos = wire.decode_fixed64_n(buf, pos)
            result.a_fixed64 = val
        elseif id == 5 then
            local val
            val, pos = wire.decode_sfixed64_n(buf, pos)
            result.a_sfixed64 = val
        else
            local _ebid = M.Wide_descriptor.extensions_by_id
            local _ext = _ebid and _ebid[id] or nil
            if _ext ~= nil then
                pos = pb.codec.decode_extension(_ext, buf, pos, wt, result)
            else
                pos = wire.skip_field(buf, pos, wt, id)
                if _uf == nil then _uf = {} end
                _uf[#_uf + 1] = buf:sub(_tag_start, pos - 1)
            end
        end
    end
    if _uf ~= nil then result._unknown_fields = table.concat(_uf) end
    return result
end

---@param b string
---@return c_int64.Wide
function M.Wide_decode_unsafe(buf)
    local decode_tag = wire.decode_tag
    local _d = M.Wide_descriptor
    if pb.c_runtime ~= nil then
        local _p = _d.c_plan or pb.c_runtime.compile_plan(_d)
        return pb.c_runtime.decode_unsafe(_p, buf)
    end
    if type(buf) ~= 'string' then
        error("expected string for c_int64.Wide decode, got " .. type(buf), 0)
    end
    local result = {}
    local pos, len = 1, #buf
    local _uf
    while pos <= len do
        local _tag_start = pos
        local id, wt
        local _b = string_byte(buf, pos)
        if _b ~= nil and _b < 0x80 then
            wt = band(_b, 7)
            if wt >= 6 then error("illegal wire type " .. wt, 0) end
            id = rshift(_b, 3)
            if id == 0 then error("illegal field number 0", 0) end
            pos = pos + 1
        elseif _b ~= nil and pos < len then
            local _b2 = string_byte(buf, pos + 1)
            if _b2 < 0x80 then
                if _b2 == 0 then error("overlong tag varint at offset " .. pos, 0) end
                local _v = _b - 128 + _b2 * 128
                wt = band(_v, 7)
                if wt >= 6 then error("illegal wire type " .. wt, 0) end
                id = rshift(_v, 3)
                pos = pos + 2
            else
                id, wt, pos = decode_tag(buf, pos)
            end
        else
            id, wt, pos = decode_tag(buf, pos)
        end
        if id == 1 then
            local val
            val, pos = wire.decode_int64_n(buf, pos)
            result.a_int64 = val
        elseif id == 2 then
            local val
            val, pos = wire.decode_uint64_n(buf, pos)
            result.a_uint64 = val
        elseif id == 3 then
            local val
            val, pos = wire.decode_sint64_n(buf, pos)
            result.a_sint64 = val
        elseif id == 4 then
            local val
            val, pos = wire.decode_fixed64_n(buf, pos)
            result.a_fixed64 = val
        elseif id == 5 then
            local val
            val, pos = wire.decode_sfixed64_n(buf, pos)
            result.a_sfixed64 = val
        else
            local _ebid = M.Wide_descriptor.extensions_by_id
            local _ext = _ebid and _ebid[id] or nil
            if _ext ~= nil then
                pos = pb.codec.decode_extension(_ext, buf, pos, wt, result)
            else
                pos = wire.skip_field(buf, pos, wt, id)
                if _uf == nil then _uf = {} end
                _uf[#_uf + 1] = buf:sub(_tag_start, pos - 1)
            end
        end
    end
    if _uf ~= nil then result._unknown_fields = table.concat(_uf) end
    return result
end

---@param b string
---@return pb.MessageView
function M.Wide_decode_lazy(b) return pb.decode_lazy(M.Wide_descriptor, b) end
---@param t c_int64.Wide
---@param opts? {single_line: boolean?, indent: string?}
---@return string
function M.Wide_text(t, opts) return pb.text.encode(M.Wide_descriptor, t, opts) end

return M

M runtime/pb/wire.lua => runtime/pb/wire.lua +70 -0
@@ 31,6 31,19 @@ local INT64  = ffi.typeof('int64_t')
local UINT64_ZERO = UINT64(0)
local CONT_MASK   = UINT64(bit.bnot(0x7f))  -- 0xFFFFFFFFFFFFFF80

-- 2^53 thresholds used by the opt-in decode_<type>_n variants (5y9).
-- A Lua double exactly represents every integer in [-2^53, 2^53] (bounds
-- inclusive — both endpoints are themselves powers of two and fit a
-- double's 53-bit mantissa exactly). Outside that range tonumber() would
-- silently lose precision on odd values. The _n decoders return a Lua
-- number when the decoded value fits the bounds, otherwise the cdata.
-- LL/ULL literals are already int64_t/uint64_t cdata constants; cdata
-- comparisons against them compile to plain 64-bit integer compares on
-- a hot trace.
local FITS_MAX_I =  0x20000000000000LL   --  2^53
local FITS_MIN_I = -0x20000000000000LL   -- -2^53
local FITS_MAX_U =  0x20000000000000ULL  --  2^53

-- FFI scratch unions used by the fixed-width decoders. Allocated once
-- and reused — `ffi.copy` from a `uint8_t*` cast over the Lua string body
-- writes into the union's byte view; reading back via `.u` / `.d` /


@@ 565,6 578,63 @@ M.decode_sint64   = decode_sint64
M.decode_bool     = decode_bool
M.decode_sfixed32 = decode_sfixed32
M.decode_sfixed64 = decode_sfixed64

-- Opt-in decode variants that return a Lua number when the decoded value
-- fits in [-2^53, 2^53) (or [0, 2^53) for the unsigned ones), else the
-- usual cdata. Wired in by mode=full codegen when the plugin is invoked
-- with `int64_as_number=true` — see cmd/protoc-gen-tarantool docs. The
-- type at decode time is unstable (number vs cdata) under this option,
-- but Lua's `+`/`-`/`*`/`==` work transparently on both and the saved
-- cdata allocation pays off on workloads dominated by small IDs,
-- timestamps that fit 2^53, byte counts, etc. (5y9)
local function decode_int64_n(buf, pos)
    local b = buf:byte(pos)
    if b == nil then error("truncated varint at offset " .. pos, 0) end
    if b < 0x80 then return b, pos + 1 end           -- 0..127, Lua number
    local u, np = decode_varint(buf, pos)
    local i = INT64(u)                                -- reinterpret bits
    if i <= FITS_MAX_I and i >= FITS_MIN_I then
        return tonumber(i), np
    end
    return i, np
end
local function decode_uint64_n(buf, pos)
    local b = buf:byte(pos)
    if b == nil then error("truncated varint at offset " .. pos, 0) end
    if b < 0x80 then return b, pos + 1 end
    local u, np = decode_varint(buf, pos)
    if u <= FITS_MAX_U then return tonumber(u), np end
    return u, np
end
local function decode_sint64_n(buf, pos)
    local b = buf:byte(pos)
    if b == nil then error("truncated varint at offset " .. pos, 0) end
    if b < 0x80 then return zigzag_decode32(b), pos + 1 end
    local u, np = decode_varint(buf, pos)
    local i = zigzag_decode64(u)
    if i <= FITS_MAX_I and i >= FITS_MIN_I then
        return tonumber(i), np
    end
    return i, np
end
local function decode_fixed64_n(buf, pos)
    local u, np = decode_fixed64(buf, pos)
    if u <= FITS_MAX_U then return tonumber(u), np end
    return u, np
end
local function decode_sfixed64_n(buf, pos)
    local u, np = decode_fixed64(buf, pos)
    local i = INT64(u)
    if i <= FITS_MAX_I and i >= FITS_MIN_I then
        return tonumber(i), np
    end
    return i, np
end
M.decode_int64_n    = decode_int64_n
M.decode_uint64_n   = decode_uint64_n
M.decode_sint64_n   = decode_sint64_n
M.decode_fixed64_n  = decode_fixed64_n
M.decode_sfixed64_n = decode_sfixed64_n
-- RFC 3629 UTF-8 validator. Rejects: out-of-range continuation bytes,
-- truncated multi-byte sequences, overlong encodings, UTF-16 surrogate
-- code points (U+D800..U+DFFF), and code points above U+10FFFF.

A test/int64_as_number_test.lua => test/int64_as_number_test.lua +143 -0
@@ 0,0 1,143 @@
-- 5y9: int64_as_number codegen option. Verifies that the opt-in `_n`
-- decoders return Lua number when the decoded value fits in [-2^53, 2^53]
-- and fall back to cdata otherwise. Encodes via the default (cdata-returning)
-- module and decodes via the `_n` module; assertions cover both type and
-- value across every 64-bit kind in c_int64.Wide.
--
-- examples/expected/full_n/c_int64/c_int64_pb.lua is regenerated by
-- `just gen-int64-as-number`, which runs the plugin with
-- `int64_as_number=true` for this single fixture so the default tree
-- stays cdata-typed and existing tests are unaffected. (5y9)
local t   = require('luatest')
local ffi = require('ffi')

local INT64  = ffi.typeof('int64_t')
local UINT64 = ffi.typeof('uint64_t')

local wide_default = require('full.c_int64.c_int64_pb')
local wide_n       = require('full_n.c_int64.c_int64_pb')

local g = t.group('int64_as_number')

-- The opt-in `_n` decoder lives in the pure-Lua codegen path. With
-- PB_ENABLE_C=1 the generated _decode prologue short-circuits to the
-- C runtime, which makes its own number-vs-cdata choice independent
-- of this codegen flag (luaL_pushint64 returns Lua number for values
-- that fit in double and cdata otherwise — the Tarantool convention).
-- Skip the type-stability assertions in that mode; the value-equality
-- behavior is still covered by the existing c_runtime_int64_test suite.
g.before_each(function()
    if os.getenv('PB_ENABLE_C') == '1' then
        t.skip('int64_as_number flag is a no-op under PB_ENABLE_C=1 — ' ..
               'the C runtime decides number-vs-cdata on its own')
    end
end)

-- Confirm the two fixtures share a wire format — the codegen flag only
-- changes the Lua return type, never the bytes that come off the wire.
g.test_encode_bytes_identical = function()
    local p = {
        a_int64    = INT64(-1234567),
        a_uint64   = UINT64(8765432),
        a_sint64   = INT64(-987654),
        a_fixed64  = UINT64(42),
        a_sfixed64 = INT64(-42),
    }
    t.assert_equals(wide_n.Wide_encode(p), wide_default.Wide_encode(p))
end

-- Values inside the 2^53 window should come back as plain Lua numbers
-- under the _n decoder, but stay cdata under the default decoder.
g.test_small_values_return_number = function()
    local p = {
        a_int64    = INT64(-123),
        a_uint64   = UINT64(456),
        a_sint64   = INT64(-789),
        a_fixed64  = UINT64(1011),
        a_sfixed64 = INT64(-1213),
    }
    local bytes = wide_default.Wide_encode(p)

    local d_n = wide_n.Wide_decode(bytes)
    t.assert_equals(type(d_n.a_int64),    'number')
    t.assert_equals(type(d_n.a_uint64),   'number')
    t.assert_equals(type(d_n.a_sint64),   'number')
    t.assert_equals(type(d_n.a_fixed64),  'number')
    t.assert_equals(type(d_n.a_sfixed64), 'number')

    t.assert_equals(d_n.a_int64,    -123)
    t.assert_equals(d_n.a_uint64,    456)
    t.assert_equals(d_n.a_sint64,   -789)
    t.assert_equals(d_n.a_fixed64,  1011)
    t.assert_equals(d_n.a_sfixed64, -1213)

    -- Default decoder stays cdata for everything 64-bit.
    local d = wide_default.Wide_decode(bytes)
    t.assert_equals(type(d.a_int64),    'cdata')
    t.assert_equals(type(d.a_uint64),   'cdata')
    t.assert_equals(type(d.a_sint64),   'cdata')
    t.assert_equals(type(d.a_fixed64),  'cdata')
    t.assert_equals(type(d.a_sfixed64), 'cdata')
end

-- Exactly 2^53 / -2^53 sit on the inclusive boundary (both are powers of
-- two and round-trip a Lua double exactly). Anything one notch past the
-- boundary on the magnitude side falls back to cdata.
g.test_boundary_2p53_returns_number = function()
    local p = {
        a_int64    = INT64( 0x20000000000000LL),  --  2^53
        a_sint64   = INT64(-0x20000000000000LL),  -- -2^53
        a_uint64   = UINT64(0x20000000000000ULL), --  2^53
        a_fixed64  = UINT64(0x20000000000000ULL), --  2^53
        a_sfixed64 = INT64(-0x20000000000000LL),  -- -2^53
    }
    local d_n = wide_n.Wide_decode(wide_default.Wide_encode(p))
    t.assert_equals(type(d_n.a_int64),    'number')
    t.assert_equals(type(d_n.a_sint64),   'number')
    t.assert_equals(type(d_n.a_uint64),   'number')
    t.assert_equals(type(d_n.a_fixed64),  'number')
    t.assert_equals(type(d_n.a_sfixed64), 'number')
    -- 2^53 is exact as a double; the value comparison passes.
    t.assert_equals(d_n.a_int64,    2 ^ 53)
    t.assert_equals(d_n.a_uint64,   2 ^ 53)
    t.assert_equals(d_n.a_fixed64,  2 ^ 53)
    t.assert_equals(d_n.a_sint64,  -(2 ^ 53))
    t.assert_equals(d_n.a_sfixed64, -(2 ^ 53))
end

g.test_past_2p53_returns_cdata = function()
    local p = {
        a_int64    = INT64( 0x40000000000000LL),  -- 2^54
        a_uint64   = UINT64(0x40000000000000ULL),
        a_sint64   = INT64(-0x40000000000000LL),
        a_fixed64  = UINT64(0x40000000000000ULL),
        a_sfixed64 = INT64(-0x40000000000000LL),
    }
    local d_n = wide_n.Wide_decode(wide_default.Wide_encode(p))
    t.assert_equals(type(d_n.a_int64),    'cdata')
    t.assert_equals(type(d_n.a_uint64),   'cdata')
    t.assert_equals(type(d_n.a_sint64),   'cdata')
    t.assert_equals(type(d_n.a_fixed64),  'cdata')
    t.assert_equals(type(d_n.a_sfixed64), 'cdata')
    -- Value still round-trips correctly (just as cdata, not number).
    t.assert_equals(d_n.a_int64,    INT64( 0x40000000000000LL))
    t.assert_equals(d_n.a_uint64,   UINT64(0x40000000000000ULL))
    t.assert_equals(d_n.a_sint64,   INT64(-0x40000000000000LL))
    t.assert_equals(d_n.a_fixed64,  UINT64(0x40000000000000ULL))
    t.assert_equals(d_n.a_sfixed64, INT64(-0x40000000000000LL))
end

-- Lua-number inputs round-trip clean too: encode accepts numbers (via the
-- existing encode wrappers), decode returns numbers under _n.
g.test_lua_number_inputs_round_trip = function()
    local p = {
        a_int64 = 100, a_uint64 = 200, a_sint64 = -300,
        a_fixed64 = 400, a_sfixed64 = -500,
    }
    local d_n = wide_n.Wide_decode(wide_default.Wide_encode(p))
    t.assert_equals(d_n.a_int64,    100)
    t.assert_equals(d_n.a_uint64,   200)
    t.assert_equals(d_n.a_sint64,   -300)
    t.assert_equals(d_n.a_fixed64,  400)
    t.assert_equals(d_n.a_sfixed64, -500)
end