Skip to content

perf: Parse FDv2 payloads in a single jsonstream pass - #426

Open
keelerm84 wants to merge 7 commits into
v7from
mk/SDK-2760/fdv2-single-pass-parse
Open

perf: Parse FDv2 payloads in a single jsonstream pass#426
keelerm84 wants to merge 7 commits into
v7from
mk/SDK-2760/fdv2-single-pass-parse

Conversation

@keelerm84

@keelerm84 keelerm84 commented Jul 27, 2026

Copy link
Copy Markdown
Member

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/json passes over the envelope, each event, and again in ChangeSet.Collections(). Shared parse* helpers walk each payload once (default build: jreader with zero-copy raw capture and eager item decode; launchdarkly_easyjson build keeps reflection for parity).

ChangeSetBuilder gains AddParsedPut so recognized puts carry parsed items; Finish can pre-fill the collections cache when every known put is parsed, making Collections() skip a second JSON parse while Change.Object bytes stay verbatim for Relay.

Polling wires parsePollingPayload; streaming SSE events use the same decoders. go-jsonstream is bumped for RawValue/offset APIs. Tests and benchmarks lock behavior to the old reflection path (including integer parity with encoding/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.

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.
@keelerm84
keelerm84 force-pushed the mk/SDK-2760/fdv2-single-pass-parse branch from 90be9c3 to f4673b5 Compare July 28, 2026 16:54
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.
@keelerm84
keelerm84 marked this pull request as ready for review July 29, 2026 20:25
@keelerm84
keelerm84 requested a review from a team as a code owner July 29, 2026 20:25
Comment thread internal/datasourcev2/event_parsing.go Outdated
return intent, errors.New("changeset: server-intent event has no payloads")
}
return intent, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a built-in (or available 3rd-party) helper function or constant that we could use here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread internal/datasourcev2/event_parsing_default.go
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants