Skip to content

feat!: Add a start wait time option for initialization - #66

Merged
kinyoklion merged 9 commits into
mainfrom
devin/1789570463-java-start-wait
Nov 4, 2026
Merged

kinyoklion merged 9 commits into
mainfrom
devin/1789570463-java-start-wait

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

Requirements

  • I have added test coverage for new or changed functionality
  • I have followed the repository's pull request submission guidelines
  • I have validated my changes against all supported platform versions

Related issues

Fixes #58.

Describe the solution you've provided

Adds Provider(String sdkKey, LDConfig config, Duration startWait) so an application can bound, or deliberately opt out of, waiting for initialization, matching the OpenFeature provider spec (OFP) requirements for start wait behavior:

  • a positive Duration is applied by the LaunchDarkly client constructor, and initialize then reports whether the client became ready without waiting a second time
  • Duration.ZERO waits nowhere; initialize fails unless the client is already ready, and the application learns when it becomes usable from provider events
  • Provider.INFINITE_START_WAIT requests indefinite waiting: nothing is waited for during construction and initialize blocks until the data source becomes valid or fails permanently

The existing constructors keep honouring LDConfig.Builder.startWait(...) and no longer wait again in initialize, so a configured start wait is the only bound on initialization instead of being followed by an unbounded wait. The README feature matrix row and initialization section describe the new behavior.

Implementation details

initialize still registers data source and flag change listeners and evaluates the current data source status. Only the indefinite mode leaves the completion future pending; every other mode completes it with the outcome that is already known, so initialize returns promptly.

The OpenFeature SDK emits its own event for the transition that completes initialization, so the provider records that one state without emitting a duplicate, and emits every state change after it — including a permanent failure that arrives immediately after the data source became valid, and a recovery after a failed initialization.

Describe alternatives you've considered

Keeping the existing indefinite wait and adding a timeout parameter interpreted only inside initialize would double-wait when LDConfig.startWait is also set, and would not match OFP, which treats the start wait as a bound over construction plus initialization.

Java has no built-in infinite Duration, so indefinite waiting was initially expressed as null. It is now the explicit Provider.INFINITE_START_WAIT constant, matching Timeout.InfiniteTimeSpan in the .NET provider.

Additional context

Sibling changes for the same OFP requirement: launchdarkly/openfeature-python-server#64, launchdarkly/openfeature-ruby-server#36, launchdarkly/openfeature-dotnet-server#71, and launchdarkly/js-core#2024.

Verified with ./gradlew test (all tests pass, including new coverage for positive, zero, and indefinite start wait).

Link to Devin session: https://app.devin.ai/sessions/1fd3fcbfe79f482e8b58be29e8df2ffb
Open in Devin Desktop: https://app.devin.ai/desktop/session/1fd3fcbfe79f482e8b58be29e8df2ffb?variant=devin
Requested by: @kinyoklion

SDK-3285


Note

Overview
Fixes OpenFeature initialization behavior (#58) by adding Provider(String, LDConfig, Duration startWait) and Provider.INFINITE_START_WAIT, so startup waiting is bounded in one place instead of blocking again indefinitely inside initialize() after LDConfig.startWait.

Positive durations apply during client construction; Duration.ZERO fails fast unless already ready; indefinite mode skips construction blocking and waits in initialize() until the data source is valid, fails, or the provider shuts down. Oversized durations are treated as indefinite to avoid SDK millis overflow.

initialize() is reworked around a completion future and tighter state/event handling: failed init surfaces data-source errors, duplicate STALE/READY events are deduped, teardown OFF does not emit PROVIDER_ERROR, and shutdown() calls super.shutdown() to stop leaking the event executor. README documents cancellation of indefinite setProviderAndWait (direct provider.shutdown() vs API lock contention).

Lifecycle tests cover bounded/zero/infinite waits, recovery, and classification boundaries.

Reviewed by Cursor Bugbot for commit 1c96e64. Bugbot is set up for automated code reviews on this repo. Configure here.


tanderson-ld additional changes

Review-driven hardening of the start wait time behavior, on top of the commits above. Summary of the delta:

Indefinite wait is now cancellable. shutdown() completes the initialization future, and the interrupt flag is restored if the waiting thread is interrupted. The README and constructor javadoc document how to cancel an indefinite setProviderAndWait: call provider.shutdown() directly from another thread, or register with the non-blocking setProvider. OpenFeatureAPI.shutdown() cannot end such a wait — it blocks on the same API lock setProviderAndWait holds for the duration of initialization. This lock-contention deadlock is inherent to the OpenFeature Java SDK (it holds the API write lock across a synchronous initialize() and contends on it in shutdown()), so it is documented rather than worked around; an upstream issue will be filed against the OpenFeature Java SDK.

Start wait time classification / validation.

  • Any start wait time too large to represent in milliseconds — including Provider.INFINITE_START_WAIT and any other "forever" duration — is classified as indefinite, so it saturates to the indefinite path instead of overflowing when the SDK converts it to milliseconds. (Separately, a too-large duration configured through LDConfig.Builder.startWait instead of the constructor still overflows inside the SDK client constructor and leaks the client; that is pre-existing SDK behavior and has been filed as a separate java-core ticket. The sentinel is documented as constructor-argument-only.)
  • A null start wait time falls through to the SDK's default start wait rather than throwing.

Status / event fidelity.

  • A failed initialization carries the data source's last error into the thrown exception, so the OpenFeature error event reports the cause rather than a generic message.
  • The client's isInitialized() latch no longer reports READY when the data source is currently OFF.
  • Repeated interruptions emit one PROVIDER_STALE per state change (state-based dedupe, retained and documented), and stale events now carry the HTTP error detail.

Lifecycle cleanup. shutdown() removes its flag-change and data-source status listeners and calls super.shutdown() to terminate the event emitter executor.

Tests. Added lifecycle coverage for bounded / zero / indefinite waits, failed-init-then-recovery, interruption → stale → ready, single-stale-per-outage dedupe, cancellation of an indefinite setProviderAndWait via a direct provider.shutdown(), the OFF-while-initialized exclusion, listener removal on shutdown, and a direct unit test of the start-wait classification boundary. Full suite and checkstyle pass.

Fixes #58

Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot changed the title feat: Add a start wait time option for initialization feat!: Add a start wait time option for initialization Sep 16, 2026
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

@cursor review

Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
kinyoklion added a commit to launchdarkly/js-core that referenced this pull request Oct 9, 2026
Gives the OpenFeature providers the initialization waiting values the
OpenFeature provider spec (OFP) requires, which the numeric-only
`initTimeoutSeconds` could not express.

- `initTimeoutSeconds: 'forever'` waits indefinitely for the
LaunchDarkly client
- `initTimeoutSeconds: 0` waits nowhere and fails initialization unless
the client is already ready, so readiness is observed through provider
events
- A positive timeout behaves as before, and the default is still 10
seconds
- Node provider README feature matrix row updated for the new values

<details>
<summary>Implementation details</summary>

**Requirements**

- [x] I have added test coverage for new or changed functionality
- [x] I have followed the repository's [pull request submission
guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests)
- [x] I have validated my changes against all supported platform
versions

**Related issues**

OFP requirement 4.3.4 requires a value distinct from any duration to
request waiting indefinitely, and 4.3.6 requires a zero wait to wait
nowhere and fail initialization. Sibling changes for the same
requirement: launchdarkly/openfeature-java-server#66,
launchdarkly/openfeature-python-server#64,
launchdarkly/openfeature-dotnet-server#71,
launchdarkly/openfeature-ruby-server#36.

**Describe the solution you've provided**

`BaseProviderConfig.initTimeoutSeconds` and the Node provider's
constructor parameter are now `number | 'forever'`, and zero is no
longer coalesced into the default, since `??` only replaces `undefined`.
`waitForInitialization` treats a falsy timeout as no timeout, so zero
cannot be passed through: initialization races the pending
initialization promise against an immediately rejected one, which
succeeds only when the client is already ready.

The Cloudflare provider is unaffected because it evaluates from a KV
namespace and has no data source to wait for.

**Describe alternatives you've considered**

`number | null` was used initially for the indefinite case; `'forever'`
reads better at the call site and matches the repository's preference
for string unions. Adding `initialized()` to the client contract would
let the zero case be checked directly, but that is a breaking change to
a published interface for behavior the existing promise already
expresses.

**Additional context**

Verified with `yarn workspace @launchdarkly/openfeature-js-server-common
test` (82 tests), `yarn workspace @launchdarkly/openfeature-node-server
test` (29 tests), and lint, format and build checks for both packages.

</details>

Link to Devin session:
https://app.devin.ai/sessions/1fd3fcbfe79f482e8b58be29e8df2ffb
Open in Devin Desktop:
https://app.devin.ai/desktop/session/1fd3fcbfe79f482e8b58be29e8df2ffb?variant=devin
Requested by: @kinyoklion

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Extends **`initTimeoutSeconds`** on the shared
**`BaseOpenFeatureProvider`** and the Node **`LaunchDarklyProvider`** to
**`number | 'forever'`**, aligning initialization with the OpenFeature
provider spec.
> 
> **`'forever'`** calls **`waitForInitialization()`** with no timeout.
**`0`** no longer falls through to the 10-second default (only
**`undefined`** does); initialization races the client’s init promise
against an immediate failure so it succeeds only when the client is
already ready, otherwise it throws a clear error. Positive timeouts
behave as before (default **10** seconds).
> 
> The Node provider README initialization row documents the new values,
and unit tests cover indefinite wait, zero-timeout failure, and
zero-timeout success when already ready.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
87e9a23. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@kinyoklion

kinyoklion commented Oct 28, 2026 •

Copy link
Copy Markdown
Member Author

Devin check the equivalent .Net PR. I would prefer we have something like Duration.Infinite instead of null.

devin-ai-integration Bot and others added 2 commits October 28, 2026 18:16
… waiting

Co-Authored-By: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com>
…-start-wait

Co-Authored-By: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com>

# Conflicts:
#	src/main/java/com/launchdarkly/openfeature/serverprovider/Provider.java
…letes

Co-Authored-By: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Matched the .NET PR, which uses Timeout.InfiniteTimeSpan. Java has no built-in infinite Duration, so the provider now exposes Provider.INFINITE_START_WAIT (ChronoUnit.FOREVER.getDuration()) and the parameter is no longer nullable; it is compared by equality and mapped to Duration.ZERO for LDConfig.Builder.startWait, so construction does not block and the indefinite wait happens in initialize. README, Javadoc and tests updated.

Also merged main (the new error-code work conflicted) and fixed a race it exposed: a permanent failure arriving immediately after the data source became valid was being swallowed along with the initialization-completion event. Only the transition that completes initialization is now suppressed; everything after it is emitted. 57 tests pass.

@kinyoklion
kinyoklion marked this pull request as ready for review October 28, 2026 18:23
@kinyoklion
kinyoklion requested a review from a team as a code owner October 28, 2026 18:23

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

Stale Bugbot comment from a previous run.

devin-ai-integration Bot and others added 2 commits October 28, 2026 18:37
…tate

Co-Authored-By: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com>
Reworks the start wait time behavior in response to review:

- An indefinite initialization (Provider.INFINITE_START_WAIT) can now be
  cancelled: shutdown() completes the initialization future, and the
  interrupt flag is restored if the waiting thread is interrupted.
  Documents that a direct provider.shutdown() (from another thread) or the
  non-blocking setProvider is required to end such a wait, because
  OpenFeatureAPI.shutdown() would block on the API lock setProviderAndWait
  holds for the duration of the wait.
- Classifies any start wait time too large to represent in milliseconds,
  including INFINITE_START_WAIT, as indefinite, so it saturates instead of
  overflowing when the SDK converts it. A null start wait time falls
  through to the SDK's default rather than throwing.
- A failed initialization carries the data source's last error into the
  thrown exception, so the OpenFeature error event reports the cause.
- Does not report READY from the client's initialized latch while the data
  source is OFF.
- Keeps emitting one stale event per state change, and stale events carry
  the HTTP error detail.
- shutdown() removes its listeners and terminates the event emitter
  executor.

Adds lifecycle tests for these paths and makes start-wait classification
directly unit-testable.

@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 2ef5a1a. Configure here.

…the provider

Reworks shutdown and initialization in response to review:

- shutdown() no longer completes the initialization future or removes
  listeners itself. Closing the client transitions the data source to
  OFF, which the still-registered status listener handles under the lock
  to record the terminal state and end an indefinite initialization.
  Cancellation runs through the same path as any other status change, so
  there is no separate, unlocked write during shutdown.
- A client that has ever been initialized is reported READY again, rather
  than being excluded when its data source is currently OFF; the
  initialized latch is the signal that cached data can be served.
- The provider does not suppress ready/stale events during teardown; it
  reflects the data source status the SDK reports. The shutdown flag is
  only used to classify the client's own close as an expected teardown
  rather than a provider error.

Listeners are registered inline and intentionally kept through close so
the OFF status can be observed. Tests updated accordingly.
@tanderson-ld

tanderson-ld commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

AI review comment: addressing the two Cursor Bugbot findings after the latest rework (commit 1c96e64).

"Shutdown races with status updates" — addressed. shutdown() no longer performs any unlocked state write: it sets the shutdown flag, calls client.close(), and super.shutdown(). Cancellation of an in-progress indefinite initialization now flows through the data source going OFF on close, handled by the still-registered status listener under stateLock — the same path as any other status change — so the race Bugbot flagged (an unlocked complete() plus a concurrent callback) no longer exists. The provider no longer suppresses ready/stale events during teardown: it reflects whatever status the SDK reports, by design. The remaining piece — the SDK reliably delivering the terminal OFF on close() — is an SDK-level concern (its status broadcast is asynchronous on an executor that close() shuts down) and is tracked as a separate java-core SDK bug, SDK-3432.

"Race can drop ready event" — known, not reproduced. This is a pre-existing theoretical window on the bounded-wait path (fabricated-ERROR vs a concurrent VALID), not introduced or changed by this rework. An adversarial review attempted to reproduce it with a 400-iteration timing sweep and could not: the OpenFeature status write lands synchronously on the initializing thread while the provider's event is dispatched on a separate executor, so the newest status wins. Leaving it as a documented, unproven window rather than adding speculative synchronization.

@kinyoklion
kinyoklion merged commit 240f19c into main Nov 4, 2026
7 checks passed
@kinyoklion
kinyoklion deleted the devin/1789570463-java-start-wait branch November 4, 2026 22:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Provider initialization can block indefinitely

2 participants