mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
The wire does not move with the toolchain
jsonenc chose encoding/json/v2 on the goexperiment.jsonv2 tag, and Go 1.27 sets
that tag by default. Moving a module's go directive to 1.27 therefore changed what
every zip service writes: a zero number or false kept under omitempty, a nil slice
as [] and a nil map as {}, field names matched case-sensitively, map keys unsorted.
hanzoai/cloud went from green to 54 failing wire tests on that bump alone.
jsonenc is stdlib encoding/json and nothing else. Since 1.27 that API runs on the
v2 engine with v1 semantics, so the speed arrives with the toolchain and the bytes
stay put. A test pins the four differences.
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+1
-2
@@ -168,9 +168,8 @@ for i in $(seq 8); do
|
||||
go test -run='^$' -bench=Benchmark_PluginHop -benchmem -count=1 -benchtime=3000x .
|
||||
done
|
||||
|
||||
# JSON edge path (v1 vs json/v2)
|
||||
# JSON edge path (stdlib encoding/json)
|
||||
go test -run='^$' -bench='BenchmarkJSON' -benchmem -count=6 .
|
||||
GOEXPERIMENT=jsonv2 go test -run='^$' -bench='BenchmarkJSON' -benchmem -count=6 .
|
||||
|
||||
# Medians (requires golang.org/x/perf/cmd/benchstat)
|
||||
go test -run='^$' -bench='Benchmark_ZipTax|Benchmark_TypedRoute' -benchmem -count=6 . | benchstat -
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
// generic In/Out handlers, module.go's extension envelope — goes
|
||||
// through Marshal / Unmarshal here. There is no other JSON path.
|
||||
//
|
||||
// Implementation: two build-tag-gated files swap the body. When the
|
||||
// binary is compiled with GOEXPERIMENT=jsonv2, the import resolver
|
||||
// can reach the stdlib's encoding/json/v2 package and the v2.go file
|
||||
// is selected; otherwise v1.go is selected and we fall back to
|
||||
// stdlib encoding/json. Either way callers see Marshal / Unmarshal —
|
||||
// no v1/v2 branching in zip's own code.
|
||||
// It is stdlib encoding/json and nothing else. Since Go 1.27 that API runs on
|
||||
// the v2 engine with v1 semantics, so the speed arrives with the toolchain and
|
||||
// the wire stays where it is. Calling encoding/json/v2 directly would not: v2
|
||||
// keeps a zero number or false under omitempty, writes a nil slice as [] and a
|
||||
// nil map as {}, and matches field names case-sensitively. This package used to
|
||||
// select v2 on the goexperiment.jsonv2 build tag, which Go 1.27 sets by default,
|
||||
// so moving a module's go directive to 1.27 changed what every response said.
|
||||
//
|
||||
// Per HIP-0106 "Wire protocol stack": JSON is the boundary format
|
||||
// (ingress → gateway → subsystem handler). Inter-subsystem calls use
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package jsonenc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Variant names the JSON implementation. zip.New() logs it once at startup.
|
||||
const Variant = "encoding/json"
|
||||
|
||||
// Marshal encodes v as JSON.
|
||||
func Marshal(v any) ([]byte, error) { return json.Marshal(v) }
|
||||
|
||||
// Unmarshal decodes data into v.
|
||||
func Unmarshal(data []byte, v any) error { return json.Unmarshal(data, v) }
|
||||
@@ -0,0 +1,46 @@
|
||||
package jsonenc
|
||||
|
||||
import "testing"
|
||||
|
||||
// The wire is a contract with every client, so it must not move with the
|
||||
// toolchain. Each case below is a place where encoding/json/v2 answers
|
||||
// differently, and Go 1.27 turned v2 on by default.
|
||||
func TestWireDoesNotFollowTheToolchain(t *testing.T) {
|
||||
type shape struct {
|
||||
N int `json:"n,omitempty"`
|
||||
On bool `json:"on,omitempty"`
|
||||
List []int `json:"list"`
|
||||
Map map[string]int `json:"map"`
|
||||
}
|
||||
got, err := Marshal(shape{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := `{"list":null,"map":null}`; string(got) != want {
|
||||
t.Fatalf("Marshal(zero) = %s, want %s", got, want)
|
||||
}
|
||||
|
||||
// Map keys come out sorted, so a document generated twice is the same bytes.
|
||||
// Fourteen keys make an unsorted encoder matching by chance negligible.
|
||||
keys := map[string]int{}
|
||||
for _, k := range []string{"q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "a", "s", "d", "f"} {
|
||||
keys[k] = 1
|
||||
}
|
||||
got, err = Marshal(keys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := `{"a":1,"d":1,"e":1,"f":1,"i":1,"o":1,"p":1,"q":1,"r":1,"s":1,"t":1,"u":1,"w":1,"y":1}`; string(got) != want {
|
||||
t.Fatalf("Marshal(map) = %s, want sorted keys", got)
|
||||
}
|
||||
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := Unmarshal([]byte(`{"Name":"a"}`), &in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in.Name != "a" {
|
||||
t.Fatalf("a field name that differs only in case was dropped: %+v", in)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//go:build !goexperiment.jsonv2
|
||||
|
||||
package jsonenc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Variant reports which JSON implementation backs this build. v2 is
|
||||
// reachable only under `GOEXPERIMENT=jsonv2`; without that flag the
|
||||
// stdlib encoding/json/v2 import does not resolve at compile time and
|
||||
// zip falls back to encoding/json v1. zip.New() logs Variant once at
|
||||
// startup so operators can spot a missing GOEXPERIMENT in CI.
|
||||
const Variant = "encoding/json"
|
||||
|
||||
// Marshal encodes v as JSON via encoding/json (v1).
|
||||
func Marshal(v any) ([]byte, error) { return json.Marshal(v) }
|
||||
|
||||
// Unmarshal decodes data into v via encoding/json (v1).
|
||||
func Unmarshal(data []byte, v any) error { return json.Unmarshal(data, v) }
|
||||
@@ -1,17 +0,0 @@
|
||||
//go:build goexperiment.jsonv2
|
||||
|
||||
package jsonenc
|
||||
|
||||
import (
|
||||
jsonv2 "encoding/json/v2"
|
||||
)
|
||||
|
||||
// Variant reports which JSON implementation backs this build. zip.New()
|
||||
// logs this once at startup so operators can confirm v2 is active.
|
||||
const Variant = "encoding/json/v2"
|
||||
|
||||
// Marshal encodes v as JSON via encoding/json/v2.
|
||||
func Marshal(v any) ([]byte, error) { return jsonv2.Marshal(v) }
|
||||
|
||||
// Unmarshal decodes data into v via encoding/json/v2.
|
||||
func Unmarshal(data []byte, v any) error { return jsonv2.Unmarshal(data, v) }
|
||||
+2
-3
@@ -3,8 +3,7 @@ package js
|
||||
import "github.com/zap-proto/zip/internal/jsonenc"
|
||||
|
||||
// jsonMarshal / jsonUnmarshal route through zip's jsonenc package so the
|
||||
// embedded-JS bridge uses the same JSON impl as the edge (encoding/json,
|
||||
// or encoding/json/v2 under GOEXPERIMENT=jsonv2) — one wire format, one
|
||||
// way to encode it.
|
||||
// embedded-JS bridge uses the same JSON impl as the edge — one wire format,
|
||||
// one way to encode it.
|
||||
func jsonMarshal(v any) ([]byte, error) { return jsonenc.Marshal(v) }
|
||||
func jsonUnmarshal(b []byte, v any) error { return jsonenc.Unmarshal(b, v) }
|
||||
|
||||
+6
-19
@@ -1,15 +1,9 @@
|
||||
package zip_test
|
||||
|
||||
// Head-to-head bench: zip's edge JSON path with encoding/json (v1) vs
|
||||
// encoding/json/v2. Build select happens via the `goexperiment.jsonv2`
|
||||
// tag — the same `zip.JSONVariant` constant the benchmarks print is
|
||||
// what `zip.New` logs at startup.
|
||||
// Bench for zip's edge JSON path, which is stdlib encoding/json.
|
||||
//
|
||||
// Run:
|
||||
// go test -bench=BenchmarkJSON -benchmem -run=^$ .
|
||||
// GOEXPERIMENT=jsonv2 go test -bench=BenchmarkJSON -benchmem -run=^$ .
|
||||
//
|
||||
// Compare allocations + ns/op between the two invocations.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -55,8 +49,8 @@ var benchReqBody = []byte(`{
|
||||
}`)
|
||||
|
||||
// BenchmarkJSONEdge runs the full edge path: POST a JSON body, zip
|
||||
// decodes it via c.Bind() (json/v2 if GOEXPERIMENT=jsonv2, else
|
||||
// json/v1), the handler returns a JSON response, zip encodes it.
|
||||
// decodes it via c.Bind(), the handler returns a JSON response, zip
|
||||
// encodes it.
|
||||
// This is the "JSON at the edge" hot path in production.
|
||||
func BenchmarkJSONEdge(b *testing.B) {
|
||||
app := zip.New(zip.Config{
|
||||
@@ -155,16 +149,9 @@ func BenchmarkJSONUnmarshalOnly(b *testing.B) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestJSONVariantConstant catches the case where someone breaks the
|
||||
// build-tag wiring: with GOEXPERIMENT=jsonv2 we expect
|
||||
// `encoding/json/v2`; without it, `encoding/json`. The constant is
|
||||
// what zip.New logs at startup, so this is the same value operators
|
||||
// read in cloud-binary logs.
|
||||
// TestJSONVariantConstant pins the one JSON implementation zip.New logs.
|
||||
func TestJSONVariantConstant(t *testing.T) {
|
||||
switch zip.JSONVariant {
|
||||
case "encoding/json/v2", "encoding/json":
|
||||
// ok — exactly one of these must be reported.
|
||||
default:
|
||||
t.Fatalf("unexpected JSONVariant %q", zip.JSONVariant)
|
||||
if zip.JSONVariant != "encoding/json" {
|
||||
t.Fatalf("JSONVariant = %q, want encoding/json", zip.JSONVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,10 +60,8 @@ type Loader interface {
|
||||
// moduleEnvelope is the JSON shape every extension runtime receives.
|
||||
// Same shape across wasm / goja / pyvm / starlark — the host serializes
|
||||
// once and the guest sees the same bytes regardless of which engine ran
|
||||
// it. RawMessage is from encoding/json (v1) because the type lives in
|
||||
// v1 only; both v1 and v2 (encoding/json/v2) honor its MarshalJSON /
|
||||
// UnmarshalJSON so the envelope serializes identically under either
|
||||
// build of jsonenc.
|
||||
// it. RawMessage is encoding/json's, the same package jsonenc encodes
|
||||
// with.
|
||||
type moduleEnvelope struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
|
||||
@@ -42,12 +42,9 @@ import (
|
||||
"github.com/zap-proto/zip/internal/jsonenc"
|
||||
)
|
||||
|
||||
// JSONVariant reports which JSON implementation zip is using in this
|
||||
// build — "encoding/json/v2" when compiled with GOEXPERIMENT=jsonv2,
|
||||
// "encoding/json" otherwise. Exposed for cmd/cloud startup logs and
|
||||
// for tests that need to assert the variant. Per HIP-0106 the wire
|
||||
// stack is "JSON only at edge, ZAP between services"; this constant
|
||||
// tells operators which JSON impl is on the edge.
|
||||
// JSONVariant names the JSON implementation on the edge: stdlib
|
||||
// encoding/json, whatever the toolchain. Per HIP-0106 the wire stack is
|
||||
// "JSON only at edge, ZAP between services".
|
||||
const JSONVariant = jsonenc.Variant
|
||||
|
||||
// Handler is zip's request handler signature. Returning an error causes
|
||||
@@ -410,9 +407,8 @@ func newApp(cfg Config) *App {
|
||||
|
||||
// jsonMarshal / jsonUnmarshal route every fiber JSON path through zip's jsonenc
|
||||
// package: c.JSON(), c.Bind().Body(), and the default error handler when it
|
||||
// serialises an HTTPError. With GOEXPERIMENT=jsonv2 the underlying impl is
|
||||
// encoding/json/v2; otherwise encoding/json. Same call site, different
|
||||
// bytes-out. Named here so [App.fiberConfig] is the one place that says it.
|
||||
// serialises an HTTPError. Named here so [App.fiberConfig] is the one place
|
||||
// that says it.
|
||||
var (
|
||||
jsonMarshal = jsonenc.Marshal
|
||||
jsonUnmarshal = jsonenc.Unmarshal
|
||||
|
||||
Reference in New Issue
Block a user