Skip to content

adapter: add LaunchDarkly reconnect integration test and migrate to upstream 3.1.1#37026

Merged
jasonhernandez merged 3 commits into
MaterializeInc:mainfrom
jasonhernandez:jason/ld-sync-reconnect-test
Jul 7, 2026
Merged

adapter: add LaunchDarkly reconnect integration test and migrate to upstream 3.1.1#37026
jasonhernandez merged 3 commits into
MaterializeInc:mainfrom
jasonhernandez:jason/ld-sync-reconnect-test

Conversation

@jasonhernandez

@jasonhernandez jasonhernandez commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Merge this PR to move to LaunchDarkly mainline 3.1.1

Part 2 of 3 in the LaunchDarkly upstream-SDK stack. Stacked on #37025.

  1. adapter: migrate LaunchDarkly SDK off fork to upstream 3.1.1 #37025 — migrate off the fork to upstream 3.1.1 - no tests!
  2. adapter: add LaunchDarkly reconnect integration test and migrate to upstream 3.1.1 #37026 (this PR) — + the reconnect integration test
  3. [DO NOT MERGE] adapter: prove LD reconnect test goes red on pre-fix SDK 3.0.1 #37460 — + pin to the incident-era pre-fix stack to prove the test fails (DO NOT MERGE)

What this does

Adds test/launchdarkly-reconnect, an integration test that reproduces incident-984 deterministically against a mock, with no real LaunchDarkly credentials.

The failure mode matters. The incident signature was a mid-body read timeout on a silently-dead connection (hyper::Error(Body, Kind(TimedOut))), which the eventsource client surfaces to the data source as a stream error. A TCP RST or clean FIN does not reproduce it — those are retried inside the eventsource client on every SDK version and never reach the data source. We verified this empirically: an earlier RST-based version of this mock stayed green (nightly #17018) even on the exact incident-era dependency stack.

  • mock_ld.py — mocks the LD streaming API. The first streaming client gets an initial flag value (2 GiB), then the connection goes silent while held open (no FIN, no RST), so the SDK's transport read timeout ends the stream — the exact incident error class. Every reconnecting client gets an updated value (3 GiB) plus heartbeats.
  • mzcompose.py — boots environmentd against the mock with a 5s read timeout and asserts SHOW max_result_size reaches 3GB, which can only happen if the data source reconnected after the timeout.

Production changes, both hidden test knobs:

  • --launchdarkly-base-uri (env MZ_LAUNCHDARKLY_BASE_URI) — overrides the SDK's endpoints with a single base URL (ServiceEndpointsBuilder::relay_proxy), letting tests point the SDK at the mock.
  • MZ_LAUNCHDARKLY_READ_TIMEOUT — overrides the transport's streaming read timeout (default 300s) so the test can trigger the timeout path in seconds.

Against the fixed 3.1.1 SDK (#37025) this should pass green; #37460 proves it goes red on the pre-fix stack.


Stacking note: GitHub can't base a fork PR on another fork branch, so this targets main and its diff also contains #37025's migration commit. Review the top commit (adapter: add LaunchDarkly reconnect integration test).

@jasonhernandez jasonhernandez force-pushed the jason/ld-sync-reconnect-test branch from 3a96074 to 006218f Compare June 15, 2026 16:44
@jasonhernandez jasonhernandez force-pushed the jason/ld-sync-reconnect-test branch from 006218f to 59a7e8d Compare June 30, 2026 05:39
@jasonhernandez jasonhernandez added the ci-nightly PR CI control: also trigger Nightly label Jun 30, 2026
@jasonhernandez jasonhernandez force-pushed the jason/ld-sync-reconnect-test branch 2 times, most recently from e76f589 to 7fffddc Compare June 30, 2026 07:30
@jasonhernandez jasonhernandez force-pushed the jason/ld-sync-reconnect-test branch from 7fffddc to 414a4f7 Compare June 30, 2026 20:51
@jasonhernandez jasonhernandez marked this pull request as ready for review July 1, 2026 18:31
@jasonhernandez jasonhernandez requested review from a team and ggevay as code owners July 1, 2026 18:31

@def- def- left a comment

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.

We should actually verify if the test would found the bug.

@jasonhernandez

Copy link
Copy Markdown
Contributor Author

We should actually verify if the test would found the bug.

@def- I could stack a PR on this that has an older version of the 3.0 SDK and see if it fails. Would that be a reasonable approach?

@def-

def- commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Sure, works for me!

Move launchdarkly-server-sdk from the MaterializeInc/rust-server-sdk fork
back to upstream crates.io 3.1.1, restoring the launchdarkly-sdk-transport +
MetricsTransport setup and dropping the [patch.crates-io] override.

The fork existed for launchdarkly/rust-server-sdk#116: a StreamingDataSource
/eventsource StreamClosed bug where a non-Eof stream error left the data
source stuck with no reconnect, silently breaking LD sync. A prior upgrade to
upstream 3.0.1 had to be reverted (incident-984) because that bug was still
unfixed upstream. The fixes have since landed, rust-server-sdk#168 and
rust-eventsource-client#134/#135, and 3.1.1 resolves eventsource-client to
0.17.5, which carries them.

Use the rustls + aws-lc-rs features (hyper-rustls-native-roots,
crypto-aws-lc-rs), now the upstream defaults, instead of the prior attempt's
native-tls/crypto-openssl, avoiding the OpenSSL path. The transport
build_https() call is identical either way.

Because the SDK now builds a rustls client, the workspace links both rustls
provider features (aws_lc_rs via our features, ring transitively via the
hyper-rustls chain). With both enabled rustls cannot select a process-default
provider on its own and panics on first client build. Install aws-lc-rs
explicitly (idempotently) at the top of the environmentd, clusterd, balancerd,
sqllogictest, and testdrive entrypoints. orchestratord already installs it.

deny.toml gains skips for the duplicate versions the transport stack pulls
(older tower/rustls-native-certs; newer rand/rand_core/getrandom/cpufeatures)
and re-adds the launchdarkly-sdk-transport wrapper.

Adds MetricsTransport unit tests, including test_metric_frozen_on_midstream_
error, modeling the exact incident-984 failure mode (200 OK then a mid-stream
timeout): they assert the last_sse_time_seconds gauge freezes so the staleness
alert can detect a stuck data source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
incident-984 was a runtime failure: the LaunchDarkly data source stopped
reconnecting after its streaming connection died, silently wedging flag sync.
The existing test/launchdarkly nightly covers value sync, persistence,
targeting, and the kill switch, but nothing exercises reconnect after a
mid-stream failure. This adds test/launchdarkly-reconnect, which reproduces
the incident deterministically against a mock and needs no real LaunchDarkly
credentials.

The failure mode matters. The incident signature was a mid-body read timeout
on a silently-dead connection, hyper::Error(Body, Kind(TimedOut)), which the
eventsource client surfaces to the data source as a stream error. A TCP RST
or a clean FIN does NOT reproduce it: those are retried inside the
eventsource client on every SDK version and never reach the data source
(verified empirically, nightly MaterializeInc#17018 stayed green with an RST-based mock
even on the exact incident-era dependency stack).

The mock serves an initial flag value (2 GiB) on the first TWO streaming
connections and then goes silent holding each open, so the transport read
timeout ends the stream. Two, because environmentd creates a short-lived
bootstrap LD client at boot (load_remote_system_parameters) before the
long-lived sync client, and the stall must hit the sync client (verified
empirically, nightly MaterializeInc#17020: with only the first connection stalled, the
sync client landed on connection two and got the updated value without any
reconnect being exercised). Every later (reconnecting) client gets the
updated value (3 GiB) plus heartbeats.

Production changes, both hidden test knobs:
- --launchdarkly-base-uri (env MZ_LAUNCHDARKLY_BASE_URI) overrides the SDK's
  streaming/polling/events endpoints with a single base URL via the SDK's
  ServiceEndpointsBuilder::relay_proxy, letting tests point the SDK at the
  mock. Also generally useful for relay-proxy setups.
- MZ_LAUNCHDARKLY_READ_TIMEOUT overrides the transport's streaming read
  timeout (default 300s) so the test can trigger the timeout path in seconds.

mzcompose.py boots environmentd against the mock with a 5s read timeout and
asserts SHOW max_result_size reaches 3GB, which can only happen if the sync
client's data source reconnected after the timeout. A regressed SDK stays
stuck at 2GB and the assertion times out. Wired into the nightly pipeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jasonhernandez jasonhernandez force-pushed the jason/ld-sync-reconnect-test branch from 6439427 to aaf2f27 Compare July 6, 2026 21:07
@jasonhernandez jasonhernandez marked this pull request as ready for review July 7, 2026 03:49
@jasonhernandez

Copy link
Copy Markdown
Contributor Author

We should actually verify if the test would found the bug.

@def- I could stack a PR on this that has an older version of the 3.0 SDK and see if it fails. Would that be a reasonable approach?

@def- @ggevay - this throwaway PR #37460 fails the reconnect test

I propose that we merge this PR #37026 if all looks good to both of you.

@def- def- left a comment

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.

Thanks for checking, no complaints from QA side

@ggevay

ggevay commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Thank you very much for the thorough testing! This looks good to me, just some minor comments from Claude and me:


I reviewed this PR together with #37025. Since this PR carries both commits of the stack, I'm posting all findings here. Nothing blocking in either commit.

On the reconnect test commit

1. Unparsable MZ_LAUNCHDARKLY_READ_TIMEOUT is silently ignored (ld_config in src/adapter/src/config/frontend.rs). Both .ok()s swallow errors, so a typo like 5sec silently falls back to 300s. In the test that presents as testdrive timing out after 120s with no hint why, and although the knob is intended for tests, nothing stops an operator from setting it in production, where a silently ignored value is just as confusing. Suggest a tracing::error! on parse failure before falling back to the default.

2. Knob plumbing asymmetry (confirming it's deliberate). --launchdarkly-base-uri is plumbed CLI → ConfigSystemParameterSyncClientConfig, while the read timeout is a raw std::env::var read inside ld_config, and there is essentially no precedent for production-path env reads in this crate. I can see the appeal of not widening three config structs for a test-only knob, and the inline comment explains itself, so fine by me if intentional.

3. The mock comment slightly overstates its guarantee. "the sync client always receives at least one stall-then-timeout cycle" is not strictly true: if the bootstrap client's stalled connection ever timed out before its initialization completed, its reconnect would consume the second stall, and the sync client would then get 3 GiB on its very first connection. This doesn't undermine the test, though. Consuming both stalls itself requires a working post-timeout reconnect, the very behavior under test, in code shared by both clients, so a regressed SDK cannot pass this way. Just worth a small wording tweak in the comment.

4. Nits (take or leave):

  • c.testdrive("\n".join([...])) reads more naturally as a single dedented string, matching most compositions.
  • In the mock's stall branch, the wfile.write before the sleep can raise BrokenPipeError unhandled (the heartbeat branch handles it). Harmless in a mock, just noisy in logs if it ever races.

On the migration commit (#37025)

5. The rustls provider pin is five copy-pasted call sites with an empirically chosen scope. The PR body says "each binary that builds an LD client", but neither testdrive nor clusterd builds an LD client (clusterd has no LaunchDarkly usage at all, mz-dyncfg-launchdarkly's only user is balancerd). The real criterion is "builds any default-provider rustls client while both provider features are linked", which is what justifies the clusterd and testdrive pins via their other rustls-using dependencies. Today the coverage looks right: apart from the LD stack itself, the default-provider build sites appear to be axum-server (orchestratord, already pinned) and gcp_auth via mz-storage-types, whose binaries (environmentd, clusterd, testdrive, sqllogictest) are all pinned. The only unpinned binary I found linking gcp_auth is mz-catalog-debug, which never builds a GCP client in practice. (Caveat: I inferred this from the dependency tree and did not audit how gcp_auth constructs its TLS client, so treat the exact at-risk set as approximate.) The broader point stands regardless: nothing stops a future binary, or a future dependency that builds a default-provider rustls client, from reintroducing a runtime panic that no compile-time check catches. Suggest hoisting the install into a small shared helper (e.g. in mz_ore) called from each binary's main, so the six-line comment lives in one place and new binaries have an obvious thing to call. Not blocking.

6. The 300s read timeout is a load-bearing magic constant. It must stay above LaunchDarkly's streaming heartbeat interval (around 3 minutes, going by LD's documentation), or healthy idle streams would reconnect spuriously (benign now that reconnects work, but worth stating). The comment this stack adds explains the override mechanism, not the relationship to the heartbeat interval. One sentence on the constant's origin would help. Same constant appears in dyncfg-launchdarkly.

7. The new transport auto-detects proxy env vars, worth a comment. launchdarkly-sdk-transport routes requests through HTTP_PROXY/HTTPS_PROXY/NO_PROXY by default, which the old hyper-tls setup ignored. No exposure today (self-managed never builds an LD client, and our cloud pods set no proxy vars), so no code change needed. But a one-line comment next to the HyperTransport::builder() calls (frontend.rs and dyncfg-launchdarkly) noting the auto-detection would save someone a confusing debugging session if proxy vars ever show up on a pod.

8. Minor Cargo.lock churn: dirs-sys's recorded windows-sys dependency edge changes 0.61.1 → 0.59.0 with no version bump, which looks like incidental re-resolution rather than something this stack needs. Windows-only and harmless, just flagging it per lock hygiene.

9. Nit: launchdarkly-sdk-transport = "0.1" uses a two-component version where the workspace convention is full three-component pins ("0.1.4").

Fold in the non-blocking review comments from MaterializeInc#37026:

- Log an error instead of silently falling back to the default when
  MZ_LAUNCHDARKLY_READ_TIMEOUT fails to parse, so a typo surfaces at the
  source rather than as an unexplained downstream timeout.
- Document the 300s streaming read timeout's origin (must stay above LD's
  ~3-minute heartbeat interval) in both call sites.
- Note that HyperTransport auto-detects HTTP_PROXY/HTTPS_PROXY/NO_PROXY,
  behavior the old hyper-tls setup did not have.
- Reword the mock's STALL_CONNECTIONS comment: stalling two connections does
  not guarantee the sync client eats a stall, it makes 3 GiB unreachable
  without a working post-timeout reconnect either way.
- Handle BrokenPipeError on the mock's stall-branch write.
- Use a single dedented testdrive string.
- Pin launchdarkly-sdk-transport to the three-component 0.1.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jasonhernandez jasonhernandez changed the title adapter: add LaunchDarkly reconnect integration test adapter: add LaunchDarkly reconnect integration test and migrate to upstream 3.1.1 Jul 7, 2026
@jasonhernandez

jasonhernandez commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Thank you very much for the thorough testing! This looks good to me, just some minor comments from Claude:

I reviewed this PR together with #37025. Since this PR carries both commits of the stack, I'm posting all findings here. Nothing blocking in either commit.

On the reconnect test commit

1. Unparsable MZ_LAUNCHDARKLY_READ_TIMEOUT is silently ignored (ld_config in src/adapter/src/config/frontend.rs). Both .ok()s swallow errors, so a typo like 5sec silently falls back to 300s. In the test that presents as testdrive timing out after 120s with no hint why, and although the knob is intended for tests, nothing stops an operator from setting it in production, where a silently ignored value is just as confusing. Suggest a tracing::error! on parse failure before falling back to the default.

Good point - addressed it.

2. Knob plumbing asymmetry (confirming it's deliberate). --launchdarkly-base-uri is plumbed CLI → ConfigSystemParameterSyncClientConfig, while the read timeout is a raw std::env::var read inside ld_config, and there is essentially no precedent for production-path env reads in this crate. I can see the appeal of not widening three config structs for a test-only knob, and the inline comment explains itself, so fine by me if intentional.

It's intentional here. I'm also not sure how long we'll want to keep all of these tests beyond de-risking the migration off our fork.

3. The mock comment slightly overstates its guarantee. "the sync client always receives at least one stall-then-timeout cycle" is not strictly true: if the bootstrap client's stalled connection ever timed out before its initialization completed, its reconnect would consume the second stall, and the sync client would then get 3 GiB on its very first connection. This doesn't undermine the test, though. Consuming both stalls itself requires a working post-timeout reconnect, the very behavior under test, in code shared by both clients, so a regressed SDK cannot pass this way. Just worth a small wording tweak in the comment.

The comments have been clarified.

4. Nits (take or leave):

* `c.testdrive("\n".join([...]))` reads more naturally as a single dedented string, matching most compositions.

* In the mock's stall branch, the `wfile.write` before the sleep can raise `BrokenPipeError` unhandled (the heartbeat branch handles it). Harmless in a mock, just noisy in logs if it ever races.

Fixed it since I was pushing a commit.

On the migration commit (#37025)

5. The rustls provider pin is five copy-pasted call sites with an empirically chosen scope. The PR body says "each binary that builds an LD client", but neither testdrive nor clusterd builds an LD client (clusterd has no LaunchDarkly usage at all, mz-dyncfg-launchdarkly's only user is balancerd). The real criterion is "builds any default-provider rustls client while both provider features are linked", which is what justifies the clusterd and testdrive pins via their other rustls-using dependencies. Today the coverage looks right: apart from the LD stack itself, the default-provider build sites appear to be axum-server (orchestratord, already pinned) and gcp_auth via mz-storage-types, whose binaries (environmentd, clusterd, testdrive, sqllogictest) are all pinned. The only unpinned binary I found linking gcp_auth is mz-catalog-debug, which never builds a GCP client in practice. (Caveat: I inferred this from the dependency tree and did not audit how gcp_auth constructs its TLS client, so treat the exact at-risk set as approximate.) The broader point stands regardless: nothing stops a future binary, or a future dependency that builds a default-provider rustls client, from reintroducing a runtime panic that no compile-time check catches. Suggest hoisting the install into a small shared helper (e.g. in mz_ore) called from each binary's main, so the six-line comment lives in one place and new binaries have an obvious thing to call. Not blocking.

Once we're fully migrated over to rustls, some additional cleanup here would be great. I'm not going to address this now because it would increase the size / scope of the PR.

6. The 300s read timeout is a load-bearing magic constant. It must stay above LaunchDarkly's streaming heartbeat interval (around 3 minutes, going by LD's documentation), or healthy idle streams would reconnect spuriously (benign now that reconnects work, but worth stating). The comment this stack adds explains the override mechanism, not the relationship to the heartbeat interval. One sentence on the constant's origin would help. Same constant appears in dyncfg-launchdarkly.

I added some comments here.

7. The new transport auto-detects proxy env vars, worth a comment. launchdarkly-sdk-transport routes requests through HTTP_PROXY/HTTPS_PROXY/NO_PROXY by default, which the old hyper-tls setup ignored. No exposure today (self-managed never builds an LD client, and our cloud pods set no proxy vars), so no code change needed. But a one-line comment next to the HyperTransport::builder() calls (frontend.rs and dyncfg-launchdarkly) noting the auto-detection would save someone a confusing debugging session if proxy vars ever show up on a pod.

Noted

8. Minor Cargo.lock churn: dirs-sys's recorded windows-sys dependency edge changes 0.61.1 → 0.59.0 with no version bump, which looks like incidental re-resolution rather than something this stack needs. Windows-only and harmless, just flagging it per lock hygiene.

Good catch but leaving it.

9. Nit: launchdarkly-sdk-transport = "0.1" uses a two-component version where the workspace convention is full three-component pins ("0.1.4").

fixed

Thanks - the latest commit 4250039 addresses most of these.

@jasonhernandez jasonhernandez requested a review from ggevay July 7, 2026 18:11
@jasonhernandez

Copy link
Copy Markdown
Contributor Author

@ggevay if you don't mind, please have your agent take a final look at 4250039 and then we'll merge this

@ggevay ggevay left a comment

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.

LGTM, thank you!

@jasonhernandez jasonhernandez merged commit 34effa9 into MaterializeInc:main Jul 7, 2026
258 of 369 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-nightly PR CI control: also trigger Nightly

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants