β‘ perf: rewrite decode/encode hot paths - zero-alloc decoding, SWAR utils, cached metadata, panic-safety#76
Conversation
Speed up the schema package using the new SWAR primitives from gofiber/utils/v2 and by eliminating per-call allocations: - nextPathSegment: scan for '.' a word (8 bytes) at a time using the new swar package (Load8/ZeroLanes/Broadcast/FirstLane) instead of byte-by-byte, speeding up path parsing on cache misses and unknown keys. - checkRequired: consume the required-field map already precomputed in structInfo.requiredFields (previously computed but unused) instead of rebuilding it with findRequiredFields on every Decode call. - tagOptions: keep the raw comma-separated tag string and scan it lazily instead of allocating a []string via strings.Split; parseTag is now zero-alloc, which also removes an allocation per tagged field on every Encode call. - setDefaults: allocate the MultiError lazily and split '|'-separated slice defaults with strings.SplitSeq instead of strings.Split. - decode: split comma-separated slice values with strings.IndexByte + strings.SplitSeq, dropping the intermediate []string. benchstat (count=5, linux/amd64): LargeStructDecode 7.908Β΅ β 6.895Β΅ -12.81% 1200B β 748B 62 β 49 allocs SimpleStructDecode 3.474Β΅ β 2.890Β΅ -16.81% 479B β 190B 30 β 22 allocs CheckRequiredFields 1481n β 337.2n -77.23% 1048B β 0B 15 β 0 allocs TimeDurationDecoding 905.6n β 705.7n -22.07% 184B β 40B 8 β 5 allocs ParseTag 49.87n β 5.319n -89.33% 32B β 0B 1 β 0 allocs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
Profile-guided round two. Decoding a simple struct is now allocation-free
(was 22 allocs/op) and large-struct decode drops from 49 to 6 allocs/op:
- Cache encoding.TextUnmarshaler type facts per field (derefUnmarshaler /
elemUnmarshaler) instead of re-deriving them with reflect.New +
TypeAssert on every decode call; instances are bound to live values only
in the branches that need them. This was 61% of all decode allocations.
- Precompute field index chains (pathHop) in parsePath so decode walks
v.Field(i) directly instead of repeating reflect FieldByName lookups
(25% of decode CPU). The chains also allocate intermediate embedded nil
pointers, fixing a panic ("indirection through nil pointer to embedded
struct") when a promoted field sat behind two levels of embedded
pointers; ensureAnonymousPtrs is folded into the hop metadata so the
per-hop cache lookups and lock acquisitions disappear too.
- Add setBuiltinKind, decoding primitives straight into the destination
via v.SetInt/SetString/... instead of boxing through reflect.ValueOf.
- Add decodeBuiltinSlice: slices of builtin kinds parse directly into
slice slots (2 fixed allocs) instead of one reflect.Value per element,
still built detached to preserve all-or-nothing error behavior.
- Precompute structInfo.hasDefaults so setDefaults skips its whole
reflection walk (23% of decode CPU) when no default tags exist.
- Pre-size the remaining pooled slice materialization instead of growing
from zero capacity.
benchstat vs previous commit (count=5, linux/amd64):
SimpleStructDecode 2890n β 618n -79% 190B β 0B 22 β 0 allocs
LargeStructDecode 6.90Β΅ β 1.70Β΅ -75% 748B β 192B 49 β 6 allocs
TimeDurationDecoding 706n β 356n -50% 40B β 16B 5 β 2 allocs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
The encoder re-parsed struct tags (reflect.StructTag.Lookup + parseTag), re-resolved encoder funcs, and rebuilt pointer-wrapper closures for every field on every Encode call. Build that plan once per struct type ([]encField with resolved alias, omitempty flag, and encoder funcs), memoized in a sync.Map invalidated by RegisterEncoder/SetAliasTag. Also pre-size slice output instead of appending from an empty slice, and allocate the error map lazily (encoding errors are the exception). benchstat (count=5, linux/amd64): LargeStructEncode ~3.1Β΅s β ~1.3Β΅s -58% ~1070B β ~880B 12 β 6 allocs SimpleStructEncode ~1.8Β΅s β ~1.0Β΅s -45% ~550B β ~460B 5 β 3 allocs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
β¦ecode - Replace the RWMutex-guarded structInfo and path caches with sync.Map: the per-key lock/unlock atomics disappear from the decode hot path and parallel decodes no longer bounce the mutex cache line. The mutex now only serializes configuration writes (tag, converter registration). - Hoist the "interface must be a (pointer to) struct" errors into sentinels; Decode's invalid-destination path is now allocation-free. - decodeBuiltinSlice: count elements upfront (comma splitting is deterministic per kind since no builtin syntax admits commas) and fill an exactly-sized slice, dropping the detached-header allocation and the append closure: one MakeSlice per slice field. - cache.converter: skip the map lookup when no custom converters are registered. benchstat (count=3, linux/amd64): All (error path) 35ns β 10ns 16B β 0B 1 β 0 allocs LargeStructDecode 1.70Β΅ β 1.51Β΅ 192B β 144B 6 β 4 allocs LargeStructDecodeParallel ~530-780ns/op, allocs 6 β 4 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
Growing a slice-of-structs one index at a time reallocated and copied the whole slice for every new index, making "items.0"..."items.N" decodes O(N^2). Grow with doubling headroom instead, and extend in place when the existing capacity suffices β zeroing the newly exposed slots so reused destination slices cannot leak stale element data into the result. Decoding 200 indexed struct elements: 33.5KB/18 allocs -> 12.8KB/7 allocs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
β¦art flag
Profiling showed 35% of the remaining simple-decode CPU inside sync.Map
lookups, almost all of it typehash/nilinterhash over the composite
{path, type} cache key. Move the parsed-path cache onto structInfo keyed
by the plain path string (cheap AES string hash), and resolve the root
structInfo once per Decode call instead of once per key. Cache reset
stays a single m.Clear() since the path caches now live on the infos.
Also precompute fieldInfo.isMultipart so the decoder skips the multipart
type comparisons for every non-multipart field.
benchstat (count=3, linux/amd64):
SimpleStructDecode 647ns β 470ns -27%
LargeStructDecode 1.50Β΅ β 1.23Β΅ -18%
LargeStructDecodeParallel ~530ns β ~450ns
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
Adversarial review of the optimization branch surfaced three behavioral regressions (all reproduced against main) plus cleanup debt. Fixes: - setDefaults gate: skipping the walk also skipped its historical side effect of allocating nil anonymous embedded pointers. The gate is now needsDefaultsWalk (defaults OR embedded pointers reachable), restoring the old contract that promoted fields are reachable after Decode. - Promoted-field resolution: hops were re-resolved with t.FieldByName, whose name-based promotion rules disagree with the alias table when an outer field shadows a promoted field's Go name (nil-interface panic via mismatched cached unmarshaler facts) or when two embeds share a name (silently skipped keys turned into hard errors). structInfo now records each field's index chain while it walks the embedded structs, so paths resolve by identity; shadowed and same-named promoted fields decode correctly by alias. - Slice growth: extending within existing capacity wrote into memory the caller may share through other slices aliasing the original array; growth always detaches into a fresh backing array again. - Cache invalidation races: a structInfo/encoder-plan build racing SetAliasTag/RegisterConverter/RegisterEncoder could re-insert stale metadata after the clear, permanently latching the old configuration. Generation counters now guard both caches (check before store, re-check and delete after), and the encoder reads tag/regenc under the config lock, with a hammer test pinning the reconfiguration contract. Cleanup and efficiency from the same review: replace the parallel int parsing with utils.ParseNativeInt/ParseNativeUint (proper range errors on 32-bit instead of silent truncation); precompute required-field search paths so checkRequired allocates nothing; single-pass slice sizing via comma counting; drop dead MultiError.merge, isValidStructPointer and the write-only unmarshaler instance field; reuse identical unmarshaler facts in createField; thread rootInfo through Decode; make the terminal slice-element part explicit (pathPart.elem) instead of inferring it from empty hops; simplify tagOptions with strings.SplitSeq. New tests pin every fixed behavior: embedded-pointer allocation without defaults, shadowed/ambiguous promoted fields, slice growth detachment, encoder reconfiguration under concurrency, setBuiltinKind/builtinConverters table parity, and the no-commas parse invariant the slice fast path relies on. benchstat vs main after fixes: geomean -76% time; decode paths unchanged or faster than before the fixes (required-fields check -19%). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
Codecov Reportβ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #76 +/- ##
==========================================
+ Coverage 96.65% 99.23% +2.57%
==========================================
Files 4 4
Lines 866 1039 +173
==========================================
+ Hits 837 1031 +194
+ Misses 16 4 -12
+ Partials 13 4 -9
Flags with carried forward coverage won't be shown. Click here to find out more. β View full report in Codecov by Harness. π New features to boost your workflow:
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
π WalkthroughWalkthroughThe PR refactors schema metadata and path caching, updates decoder traversal and builtin conversion, adds embedded-pointer and slice-growth coverage, and introduces generation-safe cached encoding plans. ChangesSchema caching and decoding
Encoder plan caching
Repository support changes
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant Decoder
participant Cache
participant Destination
Client->>Decoder: Decode source into destination
Decoder->>Cache: Load root struct metadata
Cache-->>Decoder: Return path and field metadata
Decoder->>Destination: Walk index chains and allocate embedded pointers
Decoder->>Destination: Decode scalar or slice values
Decoder-->>Client: Return decoded result or errors
Possibly related PRs
Suggested reviewers: Poem
π₯ Pre-merge checks | β 3 | β 2β Failed checks (2 warnings)
β Passed checks (3 passed)
β¨ Finishing Touchesπ Generate docstrings
π§ͺ Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and canβt be posted inline due to platform limitations.
β οΈ Outside diff range comments (1)
util_test.go (1)
47-48: π Maintainability & Code Quality | π‘ Minor | β‘ Quick winRestore
isValidStructPointercoverage.Deleting
TestIsValidStructPointerremoves regression coverage for valid pointers, nil pointers, non-struct values, and non-struct pointers. Restore the test unless equivalent coverage now exists elsewhere.π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@util_test.go` around lines 47 - 48, Restore TestIsValidStructPointer coverage for valid struct pointers, nil pointers, non-struct values, and non-struct pointers; if equivalent tests already exist, ensure those cases remain covered instead of duplicating them.
π€ Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.gitignore:
- Around line 1-2: Restore the .gitignore rules for coverage.coverprofile,
vendor, and .idea alongside the existing *.test binary rule, ensuring these
generated or environment-specific files and directories remain excluded from
commits.
In `@cache.go`:
- Around line 35-45: The cache configuration read path is unsynchronized:
registerConverter updates regconv under l while converter reads it directly.
Update converter and the related configuration access around registerConverter
and the referenced ranges to read an immutable configuration snapshot atomically
or copy the configuration under l before use, ensuring Decode cannot
concurrently access regconv during reconfiguration.
In `@decoder.go`:
- Around line 176-185: Prevent the anonymous embedded-pointer allocation in the
decoder from calling Set on unsettable fields: update the metadata construction
for anonymousPtrFields or guard the allocation loop before field.Set. Ensure
unexported or otherwise non-settable embedding chains are skipped safely while
preserving allocation for settable nil pointers, including the related walker
logic around the other reported location.
In `@encoder.go`:
- Around line 86-87: Make cached encoder plans generation-tagged and have
Encodeβs cache-hit path validate the entry against the current encGen before
using it; reject mismatches and rebuild. Update the plan publication flow to
atomically publish the tagged plan rather than relying on Store followed by a
generation check. In encoder.go lines 86-87 and 126-130, apply these
implementation changes; in encoder_test.go lines 803-819, coordinate a delayed
old-generation store after reconfiguration and verify Encode ignores it.
---
Outside diff comments:
In `@util_test.go`:
- Around line 47-48: Restore TestIsValidStructPointer coverage for valid struct
pointers, nil pointers, non-struct values, and non-struct pointers; if
equivalent tests already exist, ensure those cases remain covered instead of
duplicating them.
πͺ Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
βΉοΈ Review info
βοΈ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: afea30d0-f1c1-4c3c-9a19-335eae55229d
π Files selected for processing (11)
.gitignorecache.gocache_test.goconverter.goconverter_test.godecoder.godecoder_test.goencoder.goencoder_test.gomultierror_test.goutil_test.go
π Review details
π Additional comments (9)
util_test.go (1)
39-39: LGTM!encoder.go (1)
8-79: LGTM!Also applies to: 164-235
cache.go (1)
12-32: LGTM!Also applies to: 63-198, 390-575
cache_test.go (1)
4-83: LGTM!Also applies to: 98-107
decoder.go (1)
21-24: LGTM!Also applies to: 112-151, 186-307, 451-779, 882-895
decoder_test.go (1)
13-13: LGTM!Also applies to: 2292-2524, 3730-3733
multierror_test.go (1)
39-59: LGTM!converter.go (1)
93-94: LGTM!Also applies to: 132-133, 166-260
converter_test.go (1)
4-4: LGTM!Also applies to: 69-169
β¦panic Coverage-driven test sweep over every reachable branch. Writing the tests surfaced a real long-standing bug: decoding an empty value into a []*T field with ZeroEmpty enabled appended reflect.Zero of the dereferenced element type, panicking with "int is not assignable to *int" (also broken on main via reflect.Append). Empty items now produce nil pointer elements. New tests: malformed path keys (empty, leading/trailing/double separators, bad indices, paths past scalar fields) with and without IgnoreUnknownKeys; pointer-element and custom-converter slices through the pooled path (comma fallback, Convert-to-element, error indices, ZeroEmpty); multipart field shape helpers; array-of-pointer field walks; nil-error appendError; out-of-range converter kinds; nested encoder error collection; decoder reconfiguration hammer (mirroring the encoder one); and concurrent same-path decoding. Also removed code the coverage work proved dead: the duplicate slice-level TypeAssert in isTextUnmarshaler, an unreachable else branch in the slice unmarshaler loop (reflect.New always yields a pointer), and never-nil cache.get guards. Metadata builds now read the alias tag under the configuration lock (cache.aliasTag) so the new concurrency tests are race-clean. Remaining uncovered statements are three nanosecond race-window branches in the generation guards (exercised by the hammer tests, not deterministically hittable) and two deliberately kept defensive guards against future shape drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
- Restore the .gitignore rules (coverage.coverprofile, vendor, .idea) that the earlier *.test addition accidentally clobbered. - Make converter registration race-free: regconv is now an immutable map published through an atomic pointer (copy-on-write under the config lock), so Decode's lock-free converter lookups can never race a concurrent RegisterConverter map write. - Close the residual stale-consume window in both metadata caches: structInfo and encoder-plan entries are tagged with the configuration generation they were built under and validated on every cache hit, so an entry stored by a build that raced a reconfiguration is never served once the reconfiguration returned. This replaces the store-then-delete dance with a simpler invariant, with deterministic stale-injection tests for both decoder and encoder. - Never Set unsettable embedded pointers: anonymousPtrFields only lists exported anonymous pointers, and walkIndexChain reports chains blocked by an unsettable nil pointer as unreachable instead of panicking (the old code panicked into a decode error for structs with unexported embedded pointers; siblings and defaults now decode normally). Adds a RegisterConverter-during-Decode hammer test alongside the existing reconfiguration hammers. Hot-path benchmarks unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
Final review sweep found scanDefaultsTree still counting unexported anonymous pointer fields, which the CodeRabbit fix excluded from allocation β so a struct tree whose only embedded pointers are unexported ran the full setDefaults walk on every Decode with zero effect. Mirror the IsExported filter so needsDefaultsWalk only reflects pointers the walk can actually allocate, with a test pinning all three gate cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
The removed isValidStructPointer helper's four cases (valid struct pointer, nil struct pointer, struct value, pointer to non-struct) are decided by the cached plan's recurseStructPtr flag plus a runtime nil check since the encoder plan refactor; cover them behaviorally at the Encode level instead of unit-testing a deleted helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
There was a problem hiding this comment.
Actionable comments posted: 2
π§Ή Nitpick comments (1)
decoder_test.go (1)
4240-4250: π― Functional Correctness | π΅ Trivial | β‘ Quick winEnforce the all-or-nothing slice-conversion contract in failure tests. Both tests only check the returned error, allowing partial destination mutation to regress unnoticed.
decoder_test.go#L4240-L4250: seeds.Pand assert it remains unchanged after"1,x"fails.decoder_test.go#L4285-L4289: seeds.Cand assert it remains unchanged after"a,bad,c"fails.π€ Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@decoder_test.go` around lines 4240 - 4250, The slice-conversion failure tests must verify that destination fields remain unchanged, not just that conversion errors are returned. In decoder_test.go:4240-4250, seed s.P before decoding and assert it is unchanged for {"1,x"}; in decoder_test.go:4285-4289, seed s.C before decoding and assert it is unchanged for {"a,bad,c"}. Preserve the existing MultiError and ConversionError assertions at both sites.
π€ Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@decoder_test.go`:
- Around line 4427-4438: Update the decoder setup in the test around NewDecoder
and IgnoreUnknownKeys so it uses the default unknown-key behavior instead of
suppressing unknown fields. Keep the input containing x and a, and retain the
assertion that the sibling field decodes, so the test distinguishes a
parsed-but-unreachable promoted field from x being absent from metadata.
- Around line 4497-4503: The concurrent decode in the goroutine currently
discards its result, allowing panic or race-related errors to pass unnoticed.
Send the error returned by d.Decode through the existing done channel, then
after RegisterConverter returns receive and assert that error is nil while
preserving the deferred channel close.
---
Nitpick comments:
In `@decoder_test.go`:
- Around line 4240-4250: The slice-conversion failure tests must verify that
destination fields remain unchanged, not just that conversion errors are
returned. In decoder_test.go:4240-4250, seed s.P before decoding and assert it
is unchanged for {"1,x"}; in decoder_test.go:4285-4289, seed s.C before decoding
and assert it is unchanged for {"a,bad,c"}. Preserve the existing MultiError and
ConversionError assertions at both sites.
πͺ Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
βΉοΈ Review info
βοΈ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 51423904-2c9c-4523-9dc0-8568d9cb32f2
π Files selected for processing (8)
.gitignorecache.gocache_test.goconverter_test.godecoder.godecoder_test.goencoder.goencoder_test.go
π§ Files skipped from review as they are similar to previous changes (5)
- converter_test.go
- cache_test.go
- encoder.go
- cache.go
- decoder.go
π Review details
π Additional comments (3)
.gitignore (1)
1-7: LGTM!encoder_test.go (1)
6-6: LGTM!Also applies to: 824-923
decoder_test.go (1)
15-15: LGTM!Also applies to: 4143-4239, 4251-4284, 4290-4426, 4439-4496, 4504-4513
Panic audit of both entry points. Three crashes found (all reproduce on main) and one hardening gap, now fixed: - Decode with a nil src map plus multipart files panicked on the nil-map write before the recovery handler was installed. The recover is now installed first thing after dst validation, and a nil src is allocated when files need to be merged in. - Encode into a nil dst map panicked on the first assignment; it now returns a "dst map must not be nil" error. - Encoding a non-nil pointer to an unsupported element type (*map, *[]T) called a nil inner encoder closure and crashed. typeEncoder no longer builds closures over nil element encoders; such fields report "encoder not found" while nil values keep the historical "null" output (and omitempty still suppresses it) via the plan's nilAsNull flag. - Encode had no panic recovery at all, so any reflection edge case or a panicking user-registered encoder crashed the process. It now recovers into an error, mirroring Decode. Decoder-side reflection panics (negative indices, unexported fields, unmarshaler edge cases) were audited and remain converted to errors by the existing recover, which now covers the entire call. Regression tests cover all four fixes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
Two CodeRabbit suggestions on the new tests: - TestUnexportedEmbeddedPointerSkipped now uses the default unknown-key behavior so it distinguishes a parsed-but-unreachable promoted field (no error) from the field missing from metadata entirely (which would surface as an UnknownKeyError instead of silently passing). - TestDecoderRegisterConverterDuringDecode now propagates the concurrent decode's error through the channel and fails on it instead of discarding a recovered panic or race-related failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
Code review (xhigh) found a confirmed decode panic and two lesser issues. - []*NamedType (e.g. []*MyInt where `type MyInt int`) panicked: the builtin converter yields the underlying kind (int), which was Set into the *MyInt pointer without converting to the named type first β the Convert was gated behind !isPtrElem and skipped. The panic unwound the whole decode loop (recovered as "panic while decoding"), silently dropping other fields. Extracted appendConvertedItem, which converts before wrapping, fixing both the main and comma-split sites. - isEmpty omitted int16 and native uint, so a required int16/uint field submitted empty was never reported empty, bypassing `required`. - needsDefaultsWalk (was scanDefaultsTree) no longer re-parses tags under a per-field config lock: create() snapshots the alias tag once and passes it to createField and the walk, cutting O(fields x depth) RLock churn from each structInfo build (ParsePathCacheMiss ~830ns -> ~510ns). Regression tests cover []*named-int/[]*named-string decoding and required int16/uint empty detection. Hot-path decode benchmarks unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
A multi-agent review found the same converter-result/named-type mismatch that appendConvertedItem fixed for slice decoding, still present in all three setDefaults `default:` paths. Builtin converters (and convertPointer) return the underlying Go kind, which was Set/Append-ed into the named target without conversion, panicking (recovered into a MultiError) so the default was silently never applied: - named scalar: `type Role string; R Role schema:"r,default:admin"` - pointer-to-named: `type Level int; P *Level schema:"p,default:7"` - named-element slice: `type MyInt int; A []MyInt schema:"a,default:1|2|3"` Each now converts the value to the field's (possibly named) element/target type before reflect.Append / Set, mirroring the decode path's value.Convert(t). Unnamed-type defaults are unaffected (Convert to the same type is identity). Regression test covers named string/int/bool/float, pointer-to-named, and named-element slices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
A multi-agent review caught an encoder regression: this branch made typeEncoder return nil for pointers to unencodable types (a panic fix), so []*Struct (and other slices whose element has no encoder) got elemEnc==nil. The encode loop checked elemEnc==nil and errored BEFORE the empty/omitempty guard, so an empty or omitempty []*Struct now returned "schema: encoder not found" β on main such empty fields encoded cleanly (skipped under omitempty, or emitted as an empty slice), breaking the omitempty skip contract. Reorder so slice emptiness/omitempty is handled first: an empty slice has nothing to encode and is skipped (omitempty) or emitted empty even when its element type has no encoder; only a non-empty such slice errors. Non-slice unencodable fields (map, chan, array, non-nil pointer to unencodable) still error unconditionally, matching main. Non-empty []*Struct now errors cleanly where main panicked. Regression test covers omitempty-skip, empty-emit, non-empty-error, and map-field-still-errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
There was a problem hiding this comment.
Actionable comments posted: 1
π€ Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@encoder_test.go`:
- Around line 1010-1032: Add allocated empty-slice cases to the test structs
used in the omitempty and plain encoding scenarios by setting Children to
make([]*Inner, 0) instead of leaving it nil. Preserve the existing assertions
verifying omitempty skips the field and plain encoding emits an empty value,
ensuring both behaviors are covered for non-nil empty slices.
πͺ Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
βΉοΈ Review info
βοΈ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: d1c32193-3f66-4248-b7fb-e6eeb3e7f06b
π Files selected for processing (2)
encoder.goencoder_test.go
π§ Files skipped from review as they are similar to previous changes (1)
- encoder.go
Differential testing against main showed the previous empty-slice fix over-corrected: main encodes a []*Struct with nil elements as ["null",...] (matching a scalar nil *Struct), and errors unconditionally on a value []Struct. The prior fix errored on the former and emitted an empty slice for empty of the latter. Restore faithful behavior via encField.elemPtrNil (a slice whose element is a pointer with no encoder): nil elements encode as "null", a non-nil such element errors (main panicked). Slices whose element is unencodable and not a pointer (e.g. []Struct) error unconditionally as before. Empty/omitempty pointer-element slices are still skipped/emitted-empty (the original regression fix). Confirmed byte-identical to main across the full encoder differential except the intended fixes (main panics -> clean errors). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
The encoder's slice empty/omitempty handling is driven by Len()==0, not a nil check, so a non-nil empty slice (make([]*Inner, 0)) must behave identically to a nil one. Add explicit make([]*Inner, 0) cases alongside the existing nil cases for both omitempty (skipped) and plain (emitted empty) scenarios. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
CI flagged a BenchmarkConvertPointer regression. A same-machine A/B showed convertPointer is byte-identical to main and statistically the same speed (~34ns both), so the flag was CI run-to-run variance, not a real regression. But the earlier ParseNativeInt switch did give up utils.ParseInt's SWAR fast path (ParseNativeInt always takes the generic parseSigned path), which is slower for longer numeric inputs. Route native int/uint conversion back through utils.ParseInt/ParseUint (fast path) with an explicit native-fit range check (parseNativeInt/parseNativeUint): a no-op on 64-bit, and on 32-bit it still rejects out-of-range values that a plain int() conversion would truncate. This keeps both the performance and the 32-bit correctness, and is shared by convertInt/convertUint and setBuiltinKind. setBuiltinKind/builtinConverters parity test still passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
There was a problem hiding this comment.
Pull request overview
Performance-focused rewrite of the schema packageβs encode/decode hot paths, adding SWAR-assisted path scanning, more aggressive caching, and allocation reductions while preserving (and expanding) behavioral coverage with new regression and concurrency tests.
Changes:
- Decoder: precomputed field index chains + per-field
TextUnmarshalerfacts, SWAR path parsing, faster builtin setters and slice decoding, and reduced/guarded defaults + required-field checks. - Encoder: per-struct-type encoding plans cached with generation-based invalidation for safe concurrent reconfiguration.
- Tests/benchmarks: substantial new regression and concurrency coverage for the new caching and parsing behaviors.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| util_test.go | Updates tagOptions test to match new tagOptions representation; removes obsolete helper test. |
| multierror_test.go | Renames and adjusts merge test to new mergeErrors API. |
| encoder.go | Adds cached per-type encoding plan, generation invalidation, nil-dst handling, and panic recovery. |
| encoder_test.go | Adds extensive tests for encoder plan invalidation, nil-dst behavior, panic recovery, and edge cases. |
| decoder.go | Adds faster decode paths (builtin setters/slices), SWAR-aware parsing integration, and default/required optimizations. |
| decoder_test.go | Adds regression and concurrency tests for new decode semantics and caching behavior. |
| converter.go | Adds native int/uint range checks and introduces setBuiltinKind fast setter. |
| converter_test.go | Adds parity tests to keep setBuiltinKind aligned with builtinConverters, plus invariants for slice sizing. |
| cache.go | Reworks metadata/path caches (sync.Map + generation), adds SWAR segment scan, and optimizes tag option parsing. |
| cache_test.go | Adds SWAR segment tests, defaults-walk gating tests, and updated path-cache detachment assertions. |
| .gitignore | Ignores *.test binaries. |
π‘ Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Review follow-up on the pointer-default path. The flagged case (*MyInt) was already correct β *int is convertible to *MyInt (both unnamed pointer types with identical underlying base types), covered by TestDefaultsOnNamedTypes β but the adjacent case of a NAMED pointer type (type MyIntPtr *MyInt) was not: *int is not convertible to MyIntPtr, so its default panicked into a recovered error (identically before and after the earlier Convert change) and was never applied. Build the default as reflect.New(elem) and convert the parsed value to the element type instead: *elem is assignable to the field even when the field type is itself a named pointer type. This replaces convertPointer's base-kind pointer construction; convertPointer is now production-dead and removed along with its tests/benchmark (the benchmark was also the noisy CI row that false-flagged a variance-only regression). TestDefaultsOnNamedTypes now covers the named-pointer-type default too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
Benchmark
This comment was automatically generated by workflow using github-action-benchmark. |
Benchmarks vs main flagged ConvertInt +19% (8.5 -> 10.1 ns): the parseNativeInt/parseNativeUint wrappers added a call frame and tuple return around an ~8ns parse. Write the native-fit guard inline at each call site instead β `int64(int(v)) == v` folds away entirely on 64-bit and still rejects out-of-range values on 32-bit. Same-machine A/B (count=8): main 7.79 ns vs branch 7.77 ns β parity restored, and the 32-bit range check plus setBuiltinKind/builtinConverters parity tests still pass. The other two flagged deltas are not code regressions: isMultipartField and isZero are byte-identical to main (verified by diff), and the same-machine A/B shows parity (50.17 vs 49.88 ns; 16.10 vs 16.22 ns) β the CI deltas there are binary-layout/runner variance. Both are also off the decode hot path now (isMultipartField is precomputed per field into fieldInfo.isMultipart at cache build). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
|
Summary of improvements: Decoding (biggest wins)
Encoding
Internals
|
ReneWerner87
left a comment
There was a problem hiding this comment.
Impressive work β I went through the full diff and tried to break the trickier parts (the generation-counter cache design, the decodeBuiltinSlice sizing invariant, the encoder plan mapping) by tracing the race interleavings and running a few differential probes against main. I couldn't find a correctness issue, and the tests pin exactly the invariants the optimizations rely on. A few small observations, mostly about the PR description rather than the code:
-
Undocumented bugfix that's worth advertising: unexported embedded pointers. On
main, anyDecodeinto a struct that merely contains an unexported anonymous pointer field fails entirely with a recoveredreflect: reflect.Value.Set using value obtained using unexported fieldβ even with an emptysrc, and sibling fields don't decode either. With this branch such structs decode normally, and keys targeting fields behind the unsettable pointer are skipped (pinned byTestUnexportedEmbeddedPointerSkipped). This seems like one of the more impactful behavior improvements in the PR, so it might deserve a spot in the bug-fix list of the description. -
Small description drift around
ParseNativeInt/ParseNativeUint. The description says int/uint parsing now usesutils.ParseNativeInt/ParseNativeUint, but after 59b82ee the code usesutils.ParseInt/ParseUintwith the inline native-fit guard (int64(int(v)) == v). Functionally equivalent β just flagging it so the description matches the final code. -
Pre-existing, optional follow-up:
Decodemutates the caller'ssrcmap when multipart files are passed.src[path] = []string{""}injects file keys into the caller's map and silently overwrites an existing caller-provided entry under the same key. Not introduced by this PR (verified identical onmain), but since this code was already reshuffled for the nil-srcfix, it could be a cheap opportunity to stop mutating caller state β happy to leave it for a separate PR though. -
Tiny consistency nit: the decoder cache stores
cacheEntryby value in itssync.Mapwhile the encoder cache stores*encPlanby pointer. Both are correct (the value store only boxes on a cache miss); aligning the two would just make the pattern easier to follow. -
Note on the generation guards (no action needed): I traced the interleavings, including a delayed stale store landing after the clear, and the user-facing guarantee holds in every case β any call that starts after
SetAliasTag/RegisterEncoderreturns observes the new configuration. A call already in flight during the reconfiguration can still briefly observe old metadata, which is inherent and fine; the comment wording ("must never be served once the reconfiguration returned") is marginally stronger than that, in case you want to tighten the phrasing while you're in there.
Also verified on my end: go.mod already pins gofiber/utils/v2 v2.2.0 on main, so the absence of module changes is correct, and CI is fully green. Nice numbers on the benchmarks β the allocation-free flat-struct decode in particular.
|
The PR description is out of date. Let me fix that |
β¦lish Address maintainer review follow-ups: - Decode no longer mutates the caller's src map when multipart files are passed: file keys are merged into an internal copy, so the caller's map gains no keys and a caller-provided value under a file's key is not overwritten (pre-existing behavior on main; test added). The copy is taken only on the multipart path, so the common no-files decode is unchanged (still 0 allocs for flat structs). - Store decoder cache entries by pointer (*cacheEntry), matching the encoder's *encPlan pattern. - Tighten the generation-guard comments to state the actual guarantee: any call starting after a reconfiguration returns observes the new configuration; a call already in flight may briefly use old metadata, which is inherent to concurrent reconfiguration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx
|
Thanks for the thorough review @ReneWerner87 β all five points are addressed (657f120 + description update):
Full suite still green under Generated by Claude Code |
Summary
Profile-guided performance rewrite of the schema package built on the new SWAR utilities in
gofiber/utils/v2 v2.2.0, hardened by iterative adversarial review (multi-agent finder/verifier rounds, CodeRabbit, Copilot, maintainer review) and an empirical differential harness that A/Bs every behavior againstmain. Along the way the reviews surfaced β and this PR fixes β a series of long-standing correctness bugs and caller-crashing panics.Decoding a flat struct is now allocation-free, large-struct decode dropped from 62 to 4 allocs/op, the benchmark geomean improved ~β76%, and statement coverage stands at 99.4%. Behavior is byte-identical to
mainacross a ~1,500-case differential battery, except where amainbug was deliberately fixed (each listed below).Benchmark improvements
benchstat, count=5, linux/amd64,
origin/mainvs this branch:BenchmarkAll)Parallel decode (
LargeStructDecodeParallel) improved from ~780 β ~460 ns/op with the lock-free caches. Decoding 200 indexed slice elements (items.0β¦items.199) dropped from 33.5 KB / 18 allocs to 12.8 KB / 7 allocs. Micro-benchmarks flagged by CI were re-verified with same-machine A/Bs:ConvertIntis at parity with main (7.79 vs 7.77 ns after inlining the native-fit guard), and theIsMultipartFile/IsZerodeltas are binary-layout variance β those functions are byte-identical to main (andisMultipartFieldis now precomputed per field, off the decode hot path entirely).What changed
Decoder
encoding.TextUnmarshalertype facts per field (derefUnmarshaler/elemUnmarshaler) instead of re-deriving them withreflect.New+TypeAsserton everydecode()call β this was 61% of all decode allocations. Instances are bound to live values only in the branches that need them.structInforecords each field's index chain (through embedded structs, with per-promotion copies) while it walks the type, sodecode()andsetDefaults()walkv.Field(i)directly instead of repeatingreflectFieldByNamelookups (25% of decode CPU). Terminal slice-element parts are marked explicitly (pathPart.elem).setBuiltinKind): primitives decode viav.SetInt/SetString/β¦instead of boxing throughreflect.ValueOf; a table-parity test keeps it in lockstep withbuiltinConverters. Native int/uint parsing usesutils.ParseInt/ParseUint(SWAR fast path) with an inline native-fit guard that compiles away on 64-bit and rejects overflow on 32-bit.decodeBuiltinSlice): slices of builtin kinds parse directly into slice slots sized by a single comma-counting pass (oneMakeSlicetotal), built detached to preserve all-or-nothing error behavior. A test pins the "no builtin kind parses commas" invariant the sizing relies on.setDefaultsearly-out: skipped entirely when the struct tree has neitherdefault:tags nor exported anonymous embedded pointers to allocate (23% of decode CPU on the common path).checkRequiredconsumes the required-field map precomputed instructInfo(previously rebuilt recursively on everyDecode), with search paths precomputed so per-request checks allocate nothing.nextPathSegmentscans for.a word (8 bytes) at a time usingswar.Load8/ZeroLanes/Broadcast/FirstLanefromgofiber/utils/v2/swar.strings.SplitSeqreplaces allocatingstrings.Splitfor comma/|value splitting; sentinel errors replace per-callerrors.New; lazy error-map allocation.Encoder
[]encField(previously re-parsed tags and rebuilt pointer-wrapper closures per field on everyEncode). Slice/pointer dispatch (elemEnc/nilAsNull/elemPtrNil/recurseStructPtr) is precomputed and faithful to historical output.Caches & concurrency
structInfoand parsed-path caches moved tosync.Map(no per-key mutex atomics, no cache-line bouncing in parallel decodes), with path caches living on eachstructInfokeyed by the plain path string β composite-keytypehashwas 35% of remaining simple-decode CPU.*cacheEntry, encoder*encPlan): every cache hit validates its generation, so any call starting afterSetAliasTag/RegisterConverter/RegisterEncoderreturns observes the new configuration, even if a build racing the reconfiguration stored a stale entry after the clear. Registered converters live in an immutable copy-on-write map behind anatomic.Pointer(lock-free reads, race-free registration); tag reads go through the config lock. Deterministic stale-injection tests plus-racehammer tests pin all of this.Bug fixes (behavior improvements over
main)main, anyDecodeinto a struct containing an unexported anonymous pointer field failed entirely with a recoveredreflect.Value.Set using value obtained using unexported fieldβ even with an emptysrc, and sibling fields did not decode. Such structs now decode normally; keys targeting fields behind the unsettable pointer are skipped cleanly (TestUnexportedEmbeddedPointerSkipped).reflect: indirection through nil pointer to embedded struct; the index-chain walk allocates intermediate nil pointers, so they now decode correctly.Xwith aliasesxa/xb) both decode now;mainsilently dropped them.[]*MyIntwheretype MyInt int):mainpanicked (int is not assignable to MyInt); values now convert before the pointer wrap.default:on named types:type Role string,*Level,[]MyInt, and even named pointer types (type MyIntPtr *MyInt) all panicked into a recovered error and the default was silently never applied; defaults are now converted/built against the actual field type.ZeroEmpty+ pointer-element slices: decoding an empty value into a[]*Tfield withZeroEmptyenabled used to panic; empty items now produce nil pointer elements.isEmptyomittedint16and nativeuint, so empty submissions to suchrequiredfields were silently accepted.srcmap no longer mutated: passing multipart files used to inject file keys into β and overwrite caller-provided entries of β the caller'ssrcmap; file keys are now merged into an internal copy (taken only on the multipart path, so the common no-files decode is unchanged).int/uintparsing rejects out-of-range values instead of silently truncating.Panic-safety (previously crashed the calling process)
Encodeinto a nil dst map β now returnsschema: dst map must not be nil(was an uncaught panic).Encodeof a non-nil pointer to an unsupported type (*map,*[]int) β clean "encoder not found" error (was a nil-encoder call crash); nil values keep the historical"null"output, empty/omitempty slices of such elements keep their historical skip/emit behavior, and[]*Struct{nil,nil}still emits["null","null"].Encodenow recovers panics (from reflection or user-registered encoders) into errors, mirroringDecode.Decodewith nil src + multipart files β previously panicked before the recovery handler was installed; the recover now guards the entire call and a nil src is handled by the internal merge.Review & verification
origin/mainbuild β slices/named/pointer element matrices, embedding/promotion/shadowing to depth 3, defaults, required-field error keys, TextUnmarshaler shapes, multipart, malformed/fuzzed path keys, encoder shapes, reconfiguration. Byte-identical output except the intended fixes above.-race -count=1 -shuffle=on; gofumpt clean; golangci-lint 0 issues. Dead code removed along the way (isValidStructPointer,MultiError.merge,convertPointer, duplicate type asserts).setBuiltinKind/builtinConvertersparity, and the no-comma parse invariant.π€ Generated with Claude Code
https://claude.ai/code/session_01TogBKWQrUdFqaPyboqUwSx