Skip to content

perf: Deduplicate concurrent polling requests with a flight group - #798

Draft
keelerm84 wants to merge 5 commits into
v9from
mk/SDK-2877/singleflight-for-poll
Draft

perf: Deduplicate concurrent polling requests with a flight group#798
keelerm84 wants to merge 5 commits into
v9from
mk/SDK-2877/singleflight-for-poll

Conversation

@keelerm84

Copy link
Copy Markdown
Member

The streaming endpoints have always collapsed concurrent replay builds
through a singleflight group, but every polling request snapshotted and
serialized the store on its own. The two server-side full-payload
polling endpoints (GET /sdk/poll and GET /sdk/flags) now share one
payload build per environment through a flight group exposed by the
EnvContext, keyed by the FDv2 basis where the payload depends on it.
The per-context evaluation endpoints and the per-key PHP endpoints are
unchanged: their results rarely collide, so there is little duplicate
work to share.

The store and serialize spans belong to the one request that executes
the build; every request records relay.singleflight.shared on its
request span so a trace without those child spans is explainable.

The streaming endpoints have always collapsed concurrent replay builds
through a singleflight group, but every polling request snapshotted and
serialized the store on its own. The two server-side full-payload
polling endpoints (GET /sdk/poll and GET /sdk/flags) now share one
payload build per environment through a flight group exposed by the
EnvContext, keyed by the FDv2 basis where the payload depends on it.
The per-context evaluation endpoints and the per-key PHP endpoints are
unchanged: their results rarely collide, so there is little duplicate
work to share.

The store and serialize spans belong to the one request that executes
the build; every request records relay.singleflight.shared on its
request span so a trace without those child spans is explainable.
…time

A request that received its payload from a flight another request was
executing now records how long it waited as relay.singleflight.wait_ms
on its request span. The executing request carries no wait attribute --
it did not wait, and its time is visible as the store and serialize
child spans -- so the attribute's presence alone identifies a request
that waited, and slow waits are queryable.

Both polling handlers now resolve their flights through a shared
runPollingFlight helper that owns the Do call and both span
annotations. That indirection also breaks the taint chain behind the
gosec G705 false positive on writeCacheableJSONResponse, so its nolint
directive is removed.
The streaming repositories' replay flight groups now record the same
information the polling endpoints do: relay.singleflight.shared on the
subscribing request's span, plus relay.singleflight.wait_ms when a
replay waited on a flight another subscriber was already executing.
The annotation logic moves into tracing.SingleflightDo so both sides
record identical information by construction.

The flags-only repository never implemented ReplayWithContext, so it
had no request context to annotate; it now advertises context support
the same way the main server-side repository does.
Review feedback: ReplayWithContext's comment claimed the context was
only used for telemetry, but cancellation-on-disconnect is the reason
the eventsource RepositoryWithContext interface exists. The flags-only
replay now behaves like the main server-side repository: it skips the
payload build if the subscriber is already gone and abandons the send
on disconnect instead of relying on the eventsource server draining
the channel on its behalf.

The flight functions' telemetry-only comments now explain why they do
not cancel: a flight in progress may be shared with other waiting
subscribers, so disconnect handling belongs to replay's send loop.
A request that waited on another request's payload build used to show
that wait as unexplained empty space between its child spans, with only
the relay.singleflight.wait_ms attribute to account for it. The wait is
now also emitted as a relay.singleflight.wait child span covering
exactly the waiting window, so trace timelines read directly.

The span is back-dated: whether a caller waited (rather than executed)
is only known once the flight resolves, so it cannot be opened
beforehand without giving the executing caller a bogus wait span. It
comes from the provider owning the surrounding request span, so it
lands wherever that span is recorded.
@keelerm84
keelerm84 force-pushed the mk/SDK-2877/singleflight-for-poll branch from 4bc9aaf to c6da085 Compare August 7, 2026 16:12
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.

1 participant