perf: Parse FDv2 payloads in a single jsonstream pass - #426
Conversation
Temporary, swap before merge: requires the pseudo-version of go-jsonstream/v3 branch mk/SDK-2760/jreader-raw-value (PR #45, which is stacked on the RFC 8259 compliance work in PR #46) so CI can build against the new jreader.RawValue and jreader.Offset APIs. Replace with the released jsonstream version once those PRs ship.
The FDv2 data sources previously scanned every item's JSON multiple
times: the polling path ran encoding/json reflection over the whole
{"events":[...]} envelope (copying each event into a RawEvent),
unmarshaled each event again into PutObject (copying the item JSON a
second time), and finally parsed the model with jreader in
ChangeSet.Collections(). The streaming path did the same minus the
envelope pass. This cost lands on every client init, since the default
data system mode runs a polling initializer first, and on every
changeset an application (or Relay) ingests.
The default build now decodes each payload in one pass with jsonstream.
Scalars are read in place, and a recognized item is model-decoded
directly from the stream -- which fully validates it, the tokenizer's
grammar being RFC 8259 compliant -- while the reader offsets recorded
around the decode capture the item's raw bytes as a zero-copy slice of
the input (jreader.Offset). Values that are never model-parsed here --
objects of unrecognized kinds, and event data arriving before its event
name -- are captured with jreader.RawValue, whose own validation is
then the only line of defense before the bytes can be relayed
downstream. The decoders are order-independent; LaunchDarkly services
always write "kind" before "object" and "event" before "data",
but nothing requires it.
The launchdarkly_easyjson build keeps the previous reflection-based
decode instead: easyjson support is planned for removal from
go-jsonstream, so no SDK code depends on its token reader (which
provides neither RawValue nor Offset). Both variants produce identical
results, including the eager item deserialization, and share the test
suite; the two intentional single-pass divergences (fractional-version
truncation, trailing bytes ignored after payload-transferred) are
documented in default-only tests.
ChangeSetBuilder gains AddParsedPut so parsed items travel with the raw
changes, and Finish pre-populates the ChangeSet's collections cache when
every recognized put is parsed, making Collections() allocation-free.
Change.Object raw bytes are preserved verbatim (surrounding JSON
whitespace trimmed, cap == len) for consumers such as Relay, which
re-serializes them downstream unmodified.
Benchmark (2000 flags, parse + Collections): the previous decode
measures 21.6ms / 8.12MB / 64.1k allocs; the single-pass decode measures
8.5ms / 4.61MB / 42.1k allocs (2.5x faster), within ~2% of the per-byte
throughput of an FDv1-equivalent parse of the same flags. A reference
implementation of the old decode is kept in the benchmarks. One error
kind changed: per-event JSON errors in a polling payload now surface as
invalid-data (malformedJSONError) instead of a network error, matching
the envelope error handling.
90be9c3 to
f4673b5
Compare
The single-pass decoder depended on a local, unmerged RawValue commit that does not exist on origin. RawValue is now merged to go-jsonstream v3, so pin the resolvable v3 tip and drop the reliance on a local-only pseudo-version.
…ng data Two behavioral divergences between the default single-pass build and the launchdarkly_easyjson build: - A fractional version/target (e.g. 1.9) was silently truncated by jreader.Int in the default build but rejected by the reflection decode. Add a strict readInt used at every integer field so both builds reject it. - A duplicated "data" property on a polling event was applied more than once by the default build. Capture the event data and dispatch once, so it resolves last-wins exactly as encoding/json does. Both variants now behave identically. Adds regression tests that run under both build tags.
| return intent, errors.New("changeset: server-intent event has no payloads") | ||
| } | ||
| return intent, nil | ||
| } |
There was a problem hiding this comment.
Thinking out loud: As a reader of this code, it might help navigate these payload-parsing functions if there was an example of a payload in a block comment. I understand that's included in the tests. Maybe it also exists in the call site (polling_http_request.go and streaming_data_source.go)?
There was a problem hiding this comment.
It looks like the payload structure is documented well in the sdk-specs repository: https://github.com/launchdarkly/sdk-specs/tree/main/specs/FDV2PL-payload-communication. And that will be the source of truth for what the payload should look like.
|
|
||
| // jsonWhitespace is the set of insignificant whitespace bytes RFC 8259 allows between tokens; a | ||
| // span captured via reader offsets may include them around the value and they are trimmed off. | ||
| const jsonWhitespace = " \t\r\n" |
There was a problem hiding this comment.
Is there a built-in (or available 3rd-party) helper function or constant that we could use here?
There was a problem hiding this comment.
More specifically, is it possible/safe to use strings.TrimSpace() or bytes.TrimSpace() here?
| @@ -0,0 +1,95 @@ | |||
| //go:build launchdarkly_easyjson | |||
| // +build launchdarkly_easyjson | |||
There was a problem hiding this comment.
Question: This code is destined to be thrown away. Is it necessary because jsonstream uses easyjson 100% when the launchdarkly_easyjson build tag is specified? In other words, does the code in event_parsing_default.go work at all if the build tag is used? It seems like we would only want to use event_parsing_default.go, if possible.
There was a problem hiding this comment.
OK, I see that it's necessary as long as go-server-sdk uses a version of jsonstream w/ easyjson 😞
readInt went through jreader.Float64, so it truncated fractional versions and produced an implementation-defined (CPU-architecture-dependent) result for a value outside the int range -- diverging from the reflection decode the launchdarkly_easyjson build uses. Split the scalar event decoders by build tag so both variants agree with encoding/json: the default build parses the raw number literal with strconv (rejecting fractional, exponent, and out-of-range values while preserving large integers exactly and treating null as zero), and the easyjson build decodes each scalar event with json.Unmarshal into the subsystems types. The payload- transferred selector is kept lenient in both builds to match Selector's float64-based decode. Adds a differential test that checks every integer field against an encoding/json oracle under both build tags.
The default build model-decodes a put-object's item as soon as the object is reached, under whatever kind has been seen so far. A later "kind" property (non-conformant, but json resolves it last-wins) overrode p.kind without re-parsing, leaving the item typed as the earlier kind -- e.g. a *FeatureFlag filed under the Segments collection, which serializes as an empty value into a persistent store. Track the kind the item was decoded under and, after the object is fully read, re-parse under the final kind if it changed, or drop the item and keep the object raw if the final kind is unrecognized. This matches the easyjson build, which decodes under the final kind.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 8e3d4ed. Configure here.
jreader.SkipValue recurses one stack frame per level of nesting with no depth limit, so a deeply nested value in an unknown field of an untrusted payload could overflow the stack and crash the process -- a regression from the previous encoding/json decode, which capped nesting at 10000. Skip unknown values with RawValue instead: it scans the value iteratively and validates it with encoding/json, whose nesting cap turns an over-deep value into an error rather than a crash. The easyjson build already went through encoding/json and is unaffected.

The FDv2 data sources previously scanned every item's JSON multiple times:
the polling path ran encoding/json reflection over the whole envelope
(copying each event into a RawEvent), unmarshaled each event again into
PutObject (copying the item JSON a second time), and finally parsed the
model with jreader in ChangeSet.Collections(). The streaming path did the
same minus the envelope pass. This cost lands on every client init, since
the default data system mode runs a polling initializer first.
Replace the reflection decodes with jsonstream decoders that walk each
payload once: scalars are read in place, each put-object's item is model-
decoded as soon as it is reached, and its raw JSON is captured as a
zero-copy slice of the input via the new jreader RawValue API. The decoders
are order-independent; a data property arriving before the event name (or
an object before its kind) is captured raw and decoded once the type is
known. Unknown kinds keep their raw bytes and are now explicitly validated,
preserving the previous malformed-payload semantics.
ChangeSetBuilder gains AddParsedPut so the parsed items travel with the raw
changes, and Finish pre-populates the ChangeSet's collections cache when
every recognized put is parsed, making Collections() allocation-free.
Change.Object raw bytes are preserved for consumers such as Relay, which
re-serializes them downstream verbatim.
Benchmark (2000 flags, ParsePollingPayload + Collections):
old 21.7ms / 8.12MB / 64.1k allocs -> new 10.3ms / 4.90MB / 44.1k allocs
(2.1x faster, -40% bytes, -31% allocations). One polling-payload parse
error kind changed: per-event JSON errors now surface as invalid-data
(malformedJSONError) instead of a network error, matching the envelope
error handling.
Note
Medium Risk
Touches core FDv2 init/sync parsing on every SDK start; behavior is heavily tested for parity, but polling now classifies some per-event JSON failures as invalid-data (
malformedJSONError) instead of network errors, and default-build change sets may alias the response body until released.Overview
FDv2 polling and streaming no longer decode payloads with repeated
encoding/jsonpasses over the envelope, each event, and again inChangeSet.Collections(). Sharedparse*helpers walk each payload once (default build:jreaderwith zero-copy raw capture and eager item decode;launchdarkly_easyjsonbuild keeps reflection for parity).ChangeSetBuildergainsAddParsedPutso recognized puts carry parsed items;Finishcan pre-fill the collections cache when every known put is parsed, makingCollections()skip a second JSON parse whileChange.Objectbytes stay verbatim for Relay.Polling wires
parsePollingPayload; streaming SSE events use the same decoders.go-jsonstreamis bumped forRawValue/offset APIs. Tests and benchmarks lock behavior to the old reflection path (including integer parity withencoding/json, deep-nesting rejection, and ~2× faster init on large payloads).Reviewed by Cursor Bugbot for commit b6fe049. Bugbot is set up for automated code reviews on this repo. Configure here.