Skip to content

Commit

Permalink
feat: x/tx/signing/aminojson: Marshal sort fields
Browse files Browse the repository at this point in the history
This change gives (aminojson.Encoder).Marshal the ability
to sort field names before marshalling, mimicking the outward
behavior of encoding/json.Marshal and thus eliminating a long
prior roundtrip that required sdk.*SortJSON which would take
the produced JSON and encoding/json.Unmarshal it to an interface
firstly, then encoding/json.Marshal it out to get the field names
sorted. While here, this change adds an opt-out field to
aminojson.EncoderOptions called "DoNotSortFields", in case one wants
to preserve legacy behavior for compatibility checks/reasons.

The performance benchmarks from before and after reveal improvements

```shell
$ benchstat before.txt after.txt
name         old time/op    new time/op    delta
AminoJSON-8    76.4µs ± 1%    61.7µs ± 2%  -19.28%  (p=0.000 n=9+10)

name         old alloc/op   new alloc/op   delta
AminoJSON-8    15.0kB ± 0%     9.4kB ± 0%  -37.28%  (p=0.000 n=10+10)

name         old allocs/op  new allocs/op  delta
AminoJSON-8       332 ± 0%       234 ± 0%  -29.52%  (p=0.000 n=10+10)
```

Fixes #2350
  • Loading branch information
odeke-em committed May 31, 2023
1 parent 89b1009 commit bf4e8fe
Show file tree
Hide file tree
Showing 6 changed files with 128 additions and 9 deletions.
4 changes: 2 additions & 2 deletions tests/integration/aminojson/aminojson_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func TestAminoJSON_Equivalence(t *testing.T) {
gov.AppModuleBasic{}, groupmodule.AppModuleBasic{}, mint.AppModuleBasic{}, params.AppModuleBasic{},
slashing.AppModuleBasic{}, staking.AppModuleBasic{}, upgrade.AppModuleBasic{}, vesting.AppModuleBasic{})
legacytx.RegressionTestingAminoCodec = encCfg.Amino
aj := aminojson.NewEncoder(aminojson.EncoderOptions{})
aj := aminojson.NewEncoder(aminojson.EncoderOptions{DoNotSortFields: true})

for _, tt := range rapidgen.DefaultGeneratedTypes {
desc := tt.Pulsar.ProtoReflect().Descriptor()
Expand Down Expand Up @@ -209,7 +209,7 @@ func TestAminoJSON_LegacyParity(t *testing.T) {
vesting.AppModuleBasic{}, gov.AppModuleBasic{})
legacytx.RegressionTestingAminoCodec = encCfg.Amino

aj := aminojson.NewEncoder(aminojson.EncoderOptions{})
aj := aminojson.NewEncoder(aminojson.EncoderOptions{DoNotSortFields: true})
addr1 := types.AccAddress("addr1")
now := time.Now()

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/aminojson/repeated_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (

func TestRepeatedFields(t *testing.T) {
cdc := codec.NewLegacyAmino()
aj := aminojson.NewEncoder(aminojson.EncoderOptions{})
aj := aminojson.NewEncoder(aminojson.EncoderOptions{DoNotSortFields: true})

cases := map[string]struct {
gogo gogoproto.Message
Expand Down
1 change: 1 addition & 0 deletions x/tx/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Ref: https://keepachangelog.com/en/1.0.0/

* [#16044](https://github.com/cosmos/cosmos-sdk/pull/16044): rename aminojson.NewAminoJSON -> aminojson.NewEncoder
* [#16047](https://github.com/cosmos/cosmos-sdk/pull/16047): aminojson.NewEncoder now takes EncoderOptions as an argument.
* [#16254](https://github.com/cosmos/cosmos-sdk/pull/16254): aminojson.Encoder.Marshal now sorts all fields like encoding/json.Marshal does, hence no more need for sdk.\*SortJSON.

## v0.6.2

Expand Down
66 changes: 66 additions & 0 deletions x/tx/signing/aminojson/bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package aminojson_test

import (
"testing"

"cosmossdk.io/x/tx/signing/aminojson"
"cosmossdk.io/x/tx/signing/aminojson/internal/testpb"
)

var sink any

var msg = &testpb.ABitOfEverything{
Message: &testpb.NestedMessage{
Foo: "test",
Bar: 0, // this is the default value and should be omitted from output
},
Enum: testpb.AnEnum_ONE,
Repeated: []int32{3, -7, 2, 6, 4},
Str: `abcxyz"foo"def`,
Bool: true,
Bytes: []byte{0, 1, 2, 3},
I32: -15,
F32: 1001,
U32: 1200,
Si32: -376,
Sf32: -1000,
I64: 14578294827584932,
F64: 9572348124213523654,
U64: 4759492485,
Si64: -59268425823934,
Sf64: -659101379604211154,
}

func BenchmarkAminoJSONNaiveSort(b *testing.B) {
benchmarkAminoJSON(b, true)
}

func BenchmarkAminoJSONDefaultSort(b *testing.B) {
benchmarkAminoJSON(b, false)
}

func benchmarkAminoJSON(b *testing.B, addNaiveSort bool) {
enc := aminojson.NewEncoder(aminojson.EncoderOptions{DoNotSortFields: addNaiveSort})
b.ReportAllocs()
b.ResetTimer()

for i := 0; i < b.N; i++ {
sink = runAminoJSON(b, enc, addNaiveSort)
}
if sink == nil {
b.Fatal("Benchmark was not run")
}
sink = nil
}

func runAminoJSON(b *testing.B, enc aminojson.Encoder, addNaiveSort bool) []byte {
bz, err := enc.Marshal(msg)
if err != nil {
b.Fatal(err)
}

if addNaiveSort {
return naiveSortedJSON(b, bz)
}
return bz
}
33 changes: 30 additions & 3 deletions x/tx/signing/aminojson/json_marshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"sort"

"github.com/pkg/errors"
"google.golang.org/protobuf/proto"
Expand All @@ -22,6 +23,8 @@ type FieldEncoder func(*Encoder, protoreflect.Value, io.Writer) error

// EncoderOptions are options for creating a new Encoder.
type EncoderOptions struct {
// DonotSortFields when set turns off sorting of field names.
DoNotSortFields bool
// TypeResolver is used to resolve protobuf message types by TypeURL when marshaling any packed messages.
TypeResolver protoregistry.MessageTypeResolver
// FileResolver is used to resolve protobuf file descriptors TypeURL when TypeResolver fails.
Expand All @@ -36,6 +39,7 @@ type Encoder struct {
fieldEncoders map[string]FieldEncoder
fileResolver signing.ProtoFileResolver
typeResolver protoregistry.MessageTypeResolver
doNotSortFields bool
}

// NewEncoder returns a new Encoder capable of serializing protobuf messages to JSON using the Amino JSON encoding
Expand All @@ -61,8 +65,9 @@ func NewEncoder(options EncoderOptions) Encoder {
"legacy_coins": nullSliceAsEmptyEncoder,
"cosmos_dec_bytes": cosmosDecEncoder,
},
fileResolver: options.FileResolver,
typeResolver: options.TypeResolver,
fileResolver: options.FileResolver,
typeResolver: options.TypeResolver,
doNotSortFields: options.DoNotSortFields,
}
return enc
}
Expand Down Expand Up @@ -164,6 +169,11 @@ func (enc Encoder) marshal(value protoreflect.Value, writer io.Writer) error {
}
}

type nameAndIndex struct {
i int
name string
}

func (enc Encoder) marshalMessage(msg protoreflect.Message, writer io.Writer) error {
if msg == nil {
return errors.New("nil message")
Expand Down Expand Up @@ -192,10 +202,27 @@ func (enc Encoder) marshalMessage(msg protoreflect.Message, writer io.Writer) er
fields := msg.Descriptor().Fields()
first := true
emptyOneOfWritten := map[string]bool{}

// 1. If permitted, ensure the names are sorted.
indices := make([]*nameAndIndex, 0, fields.Len())
for i := 0; i < fields.Len(); i++ {
f := fields.Get(i)
v := msg.Get(f)
name := getAminoFieldName(f)
indices = append(indices, &nameAndIndex{i: i, name: name})
}

if shouldSortFields := !enc.doNotSortFields; shouldSortFields {
sort.Slice(indices, func(i, j int) bool {
ni, nj := indices[i], indices[j]
return ni.name < nj.name
})
}

for _, ni := range indices {
i := ni.i
name := ni.name
f := fields.Get(i)
v := msg.Get(f)
oneof := f.ContainingOneof()
isOneOf := oneof != nil
oneofFieldName, oneofTypeName, err := getOneOfNames(f)
Expand Down
31 changes: 28 additions & 3 deletions x/tx/signing/aminojson/json_marshal_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package aminojson_test

import (
"encoding/json"
"fmt"
"reflect"
"testing"
Expand Down Expand Up @@ -96,18 +97,42 @@ func TestAminoJSON(t *testing.T) {
Si64: -59268425823934,
Sf64: -659101379604211154,
}
bz, err := aminojson.NewEncoder(aminojson.EncoderOptions{}).Marshal(msg)

unsortedBz, err := aminojson.NewEncoder(aminojson.EncoderOptions{DoNotSortFields: true}).Marshal(msg)
assert.NilError(t, err)
legacyBz, err := cdc.MarshalJSON(msg)
assert.NilError(t, err)
require.Equal(t, string(legacyBz), string(bz))
require.Equal(t, string(legacyBz), string(unsortedBz))

// Now ensure that the default encoder behavior sorts fields and that they match
// as we'd have them from encoding/json.Marshal.
// Please see https://github.com/cosmos/cosmos-sdk/issues/2350
encodedDefaultBz, err := aminojson.NewEncoder(aminojson.EncoderOptions{}).Marshal(msg)
assert.NilError(t, err)

// Ensure that it is NOT equal to the legacy JSON but that it is equal to the sorted JSON.
require.NotEqual(t, string(legacyBz), string(encodedDefaultBz))

// Now ensure that the legacy's sortedJSON is as the aminojson.Encoder would produce.
// This proves that we can eliminate the use of sdk.*SortJSON(encoderBz)
sortedBz := naiveSortedJSON(t, unsortedBz)
require.Equal(t, string(sortedBz), string(encodedDefaultBz))
}

func naiveSortedJSON(t testing.TB, jsonToSort []byte) []byte {
var c interface{}
err := json.Unmarshal(jsonToSort, &c)
assert.NilError(t, err)
sortedBz, err := json.Marshal(c)
assert.NilError(t, err)
return sortedBz
}

func TestRapid(t *testing.T) {
gen := rapidproto.MessageGenerator(&testpb.ABitOfEverything{}, rapidproto.GeneratorOptions{})
rapid.Check(t, func(t *rapid.T) {
msg := gen.Draw(t, "msg")
bz, err := aminojson.NewEncoder(aminojson.EncoderOptions{}).Marshal(msg)
bz, err := aminojson.NewEncoder(aminojson.EncoderOptions{DoNotSortFields: true}).Marshal(msg)
assert.NilError(t, err)
checkInvariants(t, msg, bz)
})
Expand Down

0 comments on commit bf4e8fe

Please sign in to comment.