Repository navigation
AAWF-1532 Add UnmarshalUseNumber option to keep integer precision - #82
Conversation
Attributes were decoded into map[string]any before being re-marshaled into
the target struct, so every number went through float64 and integers above
2^53 were rounded, even for int64/uint64 fields.
Integer literals now stay json.Number during that first decode, so typed
integer fields receive every digit. Other numbers still become float64, so
values like 100.0 keep decoding into int fields and interface{} values are
unchanged.
UnmarshalUseNumber is a new opt-in option that decodes numbers in
interface{} attribute values (e.g. map[string]any) as json.Number.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Integers with at most 15 digits are exact in float64, so attributes without a run of 16 or more digits are decoded as before, without the extra copy and decode. Attributes now have their own type so the standard library hands its UnmarshalJSON the bytes directly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
jack-edmonds-dd
left a comment
There was a problem hiding this comment.
IIUC all the extra logic in jsonapi.go (hasLongDigitRun, floatNonIntegers) is there so we can fix typed int64 fields by default while still preserving the old side effect of the
map[string]any -> []byte round-trip (which shaved .0 off floats like 100.0 -> 100 so they could unmarshal into int64).
Since we only saw one deprecated route affected by typed int64, could we put both fixes behind UnmarshalUseNumber() so we don't need hasLongDigitRun or floatNonIntegers at all?
Specifically, in resourceObject.UnmarshalJSON we could capture attributes as a json.RawMessage (like we already do for relationships). Then in unmarshalAttributes:
- Old path (!m.useNumber): unmarshal the json.RawMessage into map[string]any, remarshal to []byte, and json.Unmarshal(b, v)
- Opt-in path (m.useNumber): decode the json.RawMessage directly into v with d.UseNumber(), which preserves precision for both typed int64 fields and map[string]any.
Attributes are now kept as raw JSON. Without the option they are decoded as
before (through map[string]any and float64), so the default behavior is
unchanged. With UnmarshalUseNumber they are decoded directly into the target
with UseNumber, so integers above 2^53 keep every digit in typed fields and
interface{} values hold json.Number.
This removes the digit-run scan and the float64 conversion of non-integer
literals that the default fix needed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
The precision fix looks sound, but I found an allocation regression on the default path and a problem with the benchmark cited in the PR description. The two attribute fields do not both get populated during decoding: Shared included resources also matter: the default path now rebuilds the attribute map each time the same included resource is decoded through a relationship. Previously, the parsed attribute map was reused. The existing benchmark does not measure successful attribute decoding. At unmarshal_test.go:1058, _ = Unmarshal(data, &bm.target, bm.opts...)returns I ran separate benchmarks comparing base Environment: Go 1.27.3, darwin/arm64, Apple M4 Max. Command on each snapshot: go test -run '^$' -bench '^BenchmarkReviewMemory$' -benchmem -benchtime=200ms -count=3The table shows the median of three samples for each metric. These are small local measurements, not statistically established performance guarantees. B/op is total allocation per call, not peak live heap or RSS.
The string cases contain one string attribute; the integer case contains a typed For example, the 4 KiB string allocates 20% more bytes on the default path and 50% more with UseNumber, relative to base. The shared-resource case allocates 39% more by default and 106% more with UseNumber. Conversely, the integer-array case allocates 31% fewer bytes with UseNumber, so the tradeoff depends on the payload. These intermediate buffers are temporary and collectible, but increase allocation/GC pressure. Interface-valued numeric results also change representation from
I would fix the existing benchmark, add committed benchmarks for both modes with representative payloads, and explicitly assess the default-path regression before merging. Ideally, callers who do not enable the option would retain the existing decoding path. Benchmark source used on the PR snapshotpackage jsonapi
import ("encoding/json";"strings";"strconv";"testing")
type reviewItem struct {
ID string `jsonapi:"primary,items" json:"-"`
Blob string `jsonapi:"attribute" json:"blob"`
Numbers []int64 `jsonapi:"attribute" json:"numbers"`
Related *reviewItem `jsonapi:"relationship" json:"related"`
}
var reviewSink any
func BenchmarkReviewMemory(b *testing.B) {
modes:=[]struct{name string;opts []UnmarshalOption}{{"default",nil},{"useNumber",[]UnmarshalOption{UnmarshalUseNumber()}}}
for _,mode:=range modes {
for _,size:=range []int{64,4096,1048576} {
blob,_:=json.Marshal(strings.Repeat("a",size))
data:=[]byte(`{"data":{"type":"items","id":"1","attributes":{"blob":`+string(blob)+`}}}`)
b.Run("string"+strconv.Itoa(size)+"/"+mode.name,func(b *testing.B){b.ReportAllocs();b.SetBytes(int64(len(data)));for i:=0;i<b.N;i++ {var v reviewItem;if err:=Unmarshal(data,&v,mode.opts...);err!=nil{b.Fatal(err)};reviewSink=&v}})
}
nums:=make([]int64,1000);for i:=range nums {nums[i]=int64(i)};raw,_:=json.Marshal(nums)
data:=[]byte(`{"data":{"type":"items","id":"1","attributes":{"numbers":`+string(raw)+`}}}`)
b.Run("numbers1000/"+mode.name,func(b *testing.B){b.ReportAllocs();for i:=0;i<b.N;i++ {var v reviewItem;if err:=Unmarshal(data,&v,mode.opts...);err!=nil{b.Fatal(err)};reviewSink=&v}})
blob,_:=json.Marshal(strings.Repeat("a",8192));parts:=make([]string,32)
for i:=range parts {parts[i]=`{"type":"items","id":"`+strconv.Itoa(i)+`","relationships":{"related":{"data":{"type":"items","id":"shared"}}}}`}
shared:=[]byte(`{"data":[`+strings.Join(parts,",")+`],"included":[{"type":"items","id":"shared","attributes":{"blob":`+string(blob)+`}}]}`)
b.Run("sharedIncluded32/"+mode.name,func(b *testing.B){b.ReportAllocs();for i:=0;i<b.N;i++ {var v []reviewItem;if err:=Unmarshal(shared,&v,mode.opts...);err!=nil{b.Fatal(err)};reviewSink=v}})
}
}For the base snapshot, remove the |
The option is now passed to the document before decoding, so resource objects only keep the raw attribute bytes when it is set (without copying them, and reused by every reference to an included resource). Without the option, attributes are decoded into a map during the first pass, as before. Also fix BenchmarkUnmarshal, which passed a pointer to an interface (and a non-pointer meta target) and discarded the error, so it never reached attribute decoding, and add BenchmarkUnmarshalNumbers for both modes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Attributes inside relationship data were still decoded through float64 with the option set, since relationship documents were created without it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Description
Unmarshaldecodes attributes into amap[string]anyand then re-marshals them into the target struct. Every number goes throughfloat64on the way, so integers above 2^53 are rounded even when the field isint64oruint64(9007199254740993arrives as9007199254740992).This adds an opt-in
UnmarshalUseNumber()option:UseNumber. Typed integer fields get every digit andinterface{}values (e.g.map[string]any) holdjson.Numberinstead offloat64. This also applies todataarrays, included resources and relationship data.UnmarshalUseNumber()9007199254740993→int649007199254740992900719925474099318446744073709551615→uint64184467440737095516159007199254740993→map[string]anyfloat64(9007199254740992)json.Number("9007199254740993")100.0→int64100Why it's opt-in
With the option, every number in an
interface{}value becomesjson.Number, small ones included. Code expectingfloat64(type assertions,case float64, comparisons likev == 3.0, reflection, some encoders: YAML writesjson.Numberas a quoted string, see DataDog/datadog-agent#58306) would break. Decoding directly is also stricter: non-integer literals like100.0no longer decode into integer fields, which thefloat64round-trip allowed.Additional context
Tests are in
TestUnmarshalNumbers, including adataarray, an included resource reached through a relationship, and attributes in relationship data.go test -race ./...and golangci-lint v1.59.1 pass locally (Go 1.27 for tests; Go 1.21/1.22 binaries don't run on my macOS, so CI covers those).Benchmarks:
BenchmarkUnmarshaldidn't reach attribute decoding: its targets were*interface{}, the meta target wasn't a pointer, and the error was discarded. It now uses a fresh concrete target per iteration and fails on error.BenchmarkUnmarshalNumbersis new and covers both modes with a 4 KiB string, 1,000 integers, and 32 resources referencing one included resource.mainwithin noise (benchstat, n=6, runs interleaved).UnmarshalUseNumber(), it's a trade-off. Number-heavy payloads allocate about a third less and decode faster. Large strings allocate 20–30% more, becausejson.Decoderbuffers the whole value. An included resource referenced many times allocates about twice as much, since it's decoded from the raw bytes for each reference.Resource and document
metaare not changed.🤖 Generated with Claude Code