Skip to content

fix: multiprovider parity gaps with JS SDK reference (#1882) - #1897

Closed
jonathannorris wants to merge 8 commits into
mainfrom
fix/multiprovider-parity-1882
Closed

fix: multiprovider parity gaps with JS SDK reference (#1882)#1897
jonathannorris wants to merge 8 commits into
mainfrom
fix/multiprovider-parity-1882

Conversation

@jonathannorris

@jonathannorris jonathannorris commented Mar 16, 2026

Copy link
Copy Markdown
Member

Summary

  • Fixes multiprovider parity gaps identified in cross-SDK comparison against the js-sdk reference
  • Decouples OpenFeatureClient from MultiProvider for hook context capture, uses a provider-level before hook with a ThreadLocal, matching the JS SDK's WeakMap pattern
  • Adds provider state tracking, child provider event observation, and track() delegation to MultiProvider
  • Adds ComparisonStrategy, parallel evaluation across all providers with configurable fallback and mismatch callback
  • Adds test coverage for concurrent evaluation, provider hook lifecycle ordering, event/state aggregation, and track() forwarding

Implementation

Change Details
Context capture MultiProvider.getProviderHooks() returns a before hook that captures ClientMetadata and a defensive copy of the hook hints into a ThreadLocal; a finallyAfter hook cleans it up. Removes the OpenFeatureClientMultiProvider import dependency.
Provider state tracking Per-child ProviderState map with severity-based aggregate state (FATAL > NOT_READY > ERROR > STALE > READY). Registers/deregisters EventProvider observers to react to child provider state changes. PROVIDER_CONFIGURATION_CHANGED is always forwarded.
Per-provider hooks MultiProviderHookExecutor runs each child provider's own hooks around its evaluation: before in registration order, after/error/finallyAfter in reverse, with an isolated context copy per provider.
ComparisonStrategy Evaluates all providers in parallel via ForkJoinPool.commonPool() (or a caller-supplied executor). Returns the fallback provider's result on agreement; invokes the optional onMismatch callback on disagreement. On failure returns a MultiProviderEvaluation carrying per-provider ProviderError details, consistent with FirstMatchStrategy / FirstSuccessfulStrategy.
track() Forwards to all child providers, skipping NOT_READY / FATAL ones. Per-provider errors are logged, not propagated.
Provider deduplication buildProviders now appends -1, -2 suffixes instead of silently dropping duplicates.
EventProvider Adds addEventObserver / removeEventObserver for composite provider patterns.

Notes

Related Issues

Fixes #1882
Replaces #1883

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the OpenFeature Java SDK's multiprovider capabilities, bringing its behavior and performance in line with the JavaScript SDK. The changes focus on improving how context is managed during hook execution, optimizing the parallel evaluation strategy to prevent resource issues, and standardizing error aggregation across multiple providers. These updates aim to create a more robust and consistent experience for developers using multiproviders.

Highlights

  • Multiprovider Parity: Addressed parity gaps in the multiprovider implementation identified through cross-SDK comparison with the JavaScript SDK reference.
  • Decoupled Context Capture: Decoupled OpenFeatureClient from MultiProvider by introducing a provider-level before hook for context capture, mirroring the JS SDK's WeakMap pattern.
  • Optimized Comparison Strategy: Fixed the ComparisonStrategy to reuse a shared executor for parallel evaluation, preventing thread exhaustion under load by avoiding the creation of a new thread pool per evaluation.
  • Enhanced Error Handling: Aligned error handling with the JS SDK, ensuring all errors are collected and reported if any provider encounters an issue.
  • Improved Test Coverage: Added new test coverage for concurrent evaluation, error aggregation, and provider hook context capture to ensure robustness and correctness.
Changelog
  • src/main/java/dev/openfeature/sdk/EventProvider.java
    • Added eventObservers list to manage event listeners.
    • Introduced addEventObserver and removeEventObserver methods for external observation of provider events.
    • Modified the emit method to notify registered event observers.
  • src/main/java/dev/openfeature/sdk/OpenFeatureClient.java
    • Refactored the createProviderEvaluation method call for improved readability and consistency.
  • src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java
    • Added a new ComparisonStrategy class for evaluating multiple providers in parallel.
    • Implemented constructors to allow specification of a fallback provider, an optional mismatch callback, and a custom ExecutorService.
    • Included logic to collect and report all provider errors, returning a GENERAL error code if any provider fails.
    • Ensured the strategy reuses a shared ForkJoinPool.commonPool() by default for parallel execution.
  • src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
    • Introduced a provider-level hook (getProviderHooks()) to capture ClientMetadata and hook hints using ThreadLocal for per-provider context isolation.
    • Implemented comprehensive provider state management, including providerStates and aggregateState, and event observation for child providers.
    • Modified the initialize method to register child provider observers and correctly handle initial provider states.
    • Updated all flag evaluation methods (getBooleanEvaluation, getStringEvaluation, etc.) to utilize a new evaluateWithProviderHooks mechanism for proper hook execution.
    • Enhanced the buildProviders method to automatically deduplicate provider names by appending numerical suffixes.
    • Added a track method to forward tracking events to child providers, ensuring fatal providers are skipped.
    • Improved the shutdown method to unregister observers and reset provider states.
    • Included new helper methods for copying evaluation contexts, handling evaluation exceptions, and normalizing default values for hook contexts.
    • Introduced internal HookExecution and HookExecutionContext classes to manage hook state and context.
  • src/test/java/dev/openfeature/sdk/multiprovider/ComparisonStrategyTest.java
    • Added new test cases to verify the ComparisonStrategy's behavior, including scenarios where all providers agree, providers disagree (triggering a mismatch callback), and multiple providers fail.
    • Included tests to confirm concurrent evaluation of providers and the proper reuse of a provided ExecutorService.
  • src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderEventsAndTrackingTest.java
    • Introduced new tests to validate MultiProvider's ability to aggregate child provider states and forward configuration change events.
    • Added tests to ensure tracking events are correctly forwarded to ready providers while skipping fatal ones.
  • src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderHooksTest.java
    • Added new tests to confirm that provider hooks are executed correctly within MultiProvider and that per-provider context isolation is maintained.
    • Included tests to verify that hook hints and client metadata are properly passed through and that thrown provider errors are enriched.
  • src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderTest.java
    • Updated an existing test to specifically verify the MultiProvider's new functionality for handling and deduplicating duplicate provider names.
Activity
  • No specific activity (comments, reviews, or progress updates) has been recorded for this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request significantly enhances the MultiProvider functionality by introducing a new ComparisonStrategy, improving event handling, and implementing a robust hook execution mechanism. The changes decouple OpenFeatureClient from MultiProvider by using a provider-level before hook for context capture, aligning with the JS SDK's approach. The ComparisonStrategy now reuses a shared executor, preventing resource exhaustion. Comprehensive state management for child providers and error aggregation have been added, along with extensive test coverage for concurrent evaluation, error handling, and hook context capture. The overall quality of the changes is high, with careful consideration for thread safety, error handling, and maintainability.

Comment thread src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java Outdated
@jonathannorris
jonathannorris force-pushed the fix/multiprovider-parity-1882 branch from 58843b2 to 05f0fce Compare May 8, 2026 18:21
@jonathannorris
jonathannorris marked this pull request as ready for review May 8, 2026 18:24
@jonathannorris
jonathannorris requested review from a team as code owners May 8, 2026 18:24
@jonathannorris
jonathannorris marked this pull request as draft May 8, 2026 18:31
@jonathannorris

Copy link
Copy Markdown
Member Author

/gemini review

@codecov

codecov Bot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.29330% with 81 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.76%. Comparing base (a8e6644) to head (9504869).

Files with missing lines Patch % Lines
...v/openfeature/sdk/multiprovider/MultiProvider.java 82.82% 15 Missing and 19 partials ⚠️
...e/sdk/multiprovider/MultiProviderHookExecutor.java 82.81% 10 Missing and 12 partials ⚠️
...nfeature/sdk/multiprovider/ComparisonStrategy.java 76.13% 15 Missing and 6 partials ⚠️
...c/main/java/dev/openfeature/sdk/EventProvider.java 73.33% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #1897      +/-   ##
============================================
- Coverage     92.52%   90.76%   -1.76%     
- Complexity      728      846     +118     
============================================
  Files            60       63       +3     
  Lines          1739     2155     +416     
  Branches        202      258      +56     
============================================
+ Hits           1609     1956     +347     
- Misses           80      114      +34     
- Partials         50       85      +35     
Flag Coverage Δ
unittests 90.76% <81.29%> (-1.76%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI 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.

Pull request overview

This PR closes multi-provider parity gaps vs the JS SDK by improving hook/context handling, provider state/event aggregation, tracking forwarding, and adding a new parallel ComparisonStrategy, along with expanded test coverage.

Changes:

  • Add provider-level context capture and per-child-provider hook execution with per-provider context isolation in MultiProvider.
  • Add child provider state tracking + event observation/aggregation and track() forwarding in MultiProvider (enabled by new observer APIs in EventProvider).
  • Introduce ComparisonStrategy for parallel evaluation with fallback-on-mismatch behavior, plus new/updated tests and Maven wrapper script.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java Adds context-capturing provider hook, per-provider hook execution, provider deduplication, state aggregation, event observation, and track() forwarding.
src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java New parallel comparison strategy with fallback provider + optional mismatch callback.
src/main/java/dev/openfeature/sdk/EventProvider.java Adds observer registration/removal to support composite providers observing child provider events.
src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderTest.java Expands duplicate-name test to assert deduped metadata entries are preserved.
src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderHooksTest.java New tests validating provider hook execution, isolation, hint/metadata propagation, and error enrichment.
src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderEventsAndTrackingTest.java New tests for state aggregation, config-event forwarding, init-time event preservation, and track() forwarding rules.
src/test/java/dev/openfeature/sdk/multiprovider/ComparisonStrategyTest.java New tests for comparison behavior, concurrency, executor reuse, and aggregated error handling.
mvnw Adds Maven wrapper shell script.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
Comment thread src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java Outdated
Comment thread src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java Outdated
Comment thread src/test/java/dev/openfeature/sdk/multiprovider/ComparisonStrategyTest.java Outdated

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces significant enhancements to the MultiProvider and EventProvider components, including a new ComparisonStrategy for parallel provider evaluation and result comparison. Key updates to MultiProvider include automated provider name deduplication, a state aggregation mechanism that propagates child provider events, and support for tracking event forwarding. Additionally, the implementation now includes logic for isolated hook execution for individual child providers. Review feedback identifies a high-severity issue in the hook execution lifecycle, noting that the current order of hook processing deviates from the OpenFeature specification.

Comment thread src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java Outdated
@sonarqubecloud

sonarqubecloud Bot commented May 8, 2026

Copy link
Copy Markdown

cursoragent and others added 8 commits August 5, 2026 16:41
Co-authored-by: jonathan <jonathan@taplytics.com>
Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com>
- Decouple OpenFeatureClient from MultiProvider by using a provider-level
  before hook for context capture (matching JS SDK WeakMap pattern)
- Fix ComparisonStrategy to reuse shared ForkJoinPool.commonPool() instead
  of creating a new thread pool per evaluation call
- Add timeout support and custom ExecutorService constructor to
  ComparisonStrategy
- Fix checkstyle violations (Javadoc, line length, import ordering)
- Improve type safety in normalizeDefaultValue
- Add tests for concurrent evaluation, executor reuse, multi-error
  collection, and provider hook context capture

Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com>
…xecutorService field

Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com>
Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com>
…from MultiProvider

Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com>
Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com>
Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com>
…mparisonStrategy

Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com>
@jonathannorris
jonathannorris force-pushed the fix/multiprovider-parity-1882 branch from 29f2e01 to 9504869 Compare August 5, 2026 21:00
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ca1ef12-de8b-443a-bb96-7d937f21f883

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2026

Copy link
Copy Markdown

Copilot AI 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.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Suppressed comments (4)

src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutor.java:74

  • This reverses the lifecycle ordering that the same provider hooks receive when the provider is used directly. HookSupport.executeBeforeHooks iterates hooks in reverse registration order (HookSupport.java:78-94), while after, error, and finallyAfter iterate in registration order (HookSupport.java:97-135). Here the loops do the opposite, so merely wrapping a provider changes observable hook behavior. Use reversedHooks for before and hooks for the later stages, and update the ordering test accordingly.
        // Per spec, before hooks run in registration order; after/error/finally run in reverse.
        List<HookExecution<T>> reversedHooks = new ArrayList<>(hooks);
        Collections.reverse(reversedHooks);

src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java:359

  • This hard-coded state check bypasses the active Strategy, so custom strategies cannot control which providers receive tracking as required by #1882. The referenced JS strategy API delegates this decision to shouldTrackWithThisProvider, with NOT_READY/FATAL filtering as its default. Extend the Java strategy contract with an equivalent predicate (including provider state and tracking inputs) and delegate this decision to it here.
            if (!shouldTrackProvider(providerName)) {

src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java:168

  • The fallback provider is documented as the choice when providers disagree, but this also returns its full result on agreement. Equal values can still carry different variant, reason, and metadata, so this diverges from the JS reference, which returns the first registered resolution when all values agree and uses the fallback only on mismatch. Return the first provider's result on this path and update the class documentation/test to distinguish the result details.
        if (allEvaluationsMatch(successfulResults)) {
            return fallbackResult;

src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java:144

  • A timed-out provider is cancelled but never added to providerErrors, so the returned MultiProviderEvaluation can contain an empty or partial error list even though the Strategy contract promises per-provider failure details. Keep each future associated with its provider name and add a timeout ProviderError for every cancelled future before building the aggregate result.
                if (future.isCancelled()) {
                    return errorResult(
                            "Comparison strategy timed out after " + timeoutMs + "ms", providers, providerErrors);

Comment on lines +225 to +226
provider.initialize(evaluationContext, domain);
setProviderReadyIfStillNotReady(providerName);

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java (1)

264-352: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated strategy plus hook-executor wiring into one generic helper.

The five evaluation methods differ only in the value type, the FlagValueType, and the terminal provider call. The remaining eight-argument wiring is duplicated five times. A generic helper keeps the call shape in one place, so a future change to the hook-executor signature touches one method.

♻️ Proposed refactor
+    private <T> ProviderEvaluation<T> evaluateWithHooks(
+            String key,
+            T defaultValue,
+            EvaluationContext ctx,
+            FlagValueType valueType,
+            BiFunction<FeatureProvider, EvaluationContext, ProviderEvaluation<T>> providerFunction) {
+        HookExecutionContext hookExecutionContext = currentHookExecutionContext();
+        return strategy.evaluate(
+                providers,
+                key,
+                defaultValue,
+                ctx,
+                provider -> hookExecutor.evaluate(
+                        provider, key, defaultValue, ctx, hookExecutionContext, valueType, providerFunction));
+    }
+
     `@Override`
     public ProviderEvaluation<Boolean> getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) {
-        HookExecutionContext hookExecutionContext = currentHookExecutionContext();
-        return strategy.evaluate(
-                providers,
-                key,
-                defaultValue,
-                ctx,
-                provider -> hookExecutor.evaluate(
-                        provider,
-                        key,
-                        defaultValue,
-                        ctx,
-                        hookExecutionContext,
-                        FlagValueType.BOOLEAN,
-                        (p, evaluationContext) -> p.getBooleanEvaluation(key, defaultValue, evaluationContext)));
+        return evaluateWithHooks(
+                key,
+                defaultValue,
+                ctx,
+                FlagValueType.BOOLEAN,
+                (p, evaluationContext) -> p.getBooleanEvaluation(key, defaultValue, evaluationContext));
     }

Apply the same replacement to getStringEvaluation, getIntegerEvaluation, getDoubleEvaluation, and getObjectEvaluation. java.util.function.BiFunction needs an import.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java` around
lines 264 - 352, Extract the duplicated strategy and hook-executor wiring from
the five evaluation methods into one generic helper in MultiProvider,
parameterized by value type, FlagValueType, and the terminal provider evaluation
BiFunction. Update getBooleanEvaluation, getStringEvaluation,
getIntegerEvaluation, getDoubleEvaluation, and getObjectEvaluation to delegate
to that helper, and add the required BiFunction import.
src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderEventsAndTrackingTest.java (1)

78-113: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Shut down the MultiProvider in these two tests.

shouldPreserveChildStateEmittedDuringInitialize and shouldForwardTrackToReadyProvidersAndSkipFatalProviders call initialize(null) but never call shutdown(). Each TrackingProvider and the MultiProvider keep a cached emitter thread pool alive for the rest of the test JVM. Test 1 already isolates cleanup in a finally block; the same pattern here keeps the suite consistent.

try {
    multiProvider.initialize(null);
    // assertions
} finally {
    multiProvider.shutdown();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderEventsAndTrackingTest.java`
around lines 78 - 113, Update shouldPreserveChildStateEmittedDuringInitialize
and shouldForwardTrackToReadyProvidersAndSkipFatalProviders to wrap
initialization and assertions in finally blocks that call
multiProvider.shutdown(). Preserve the existing test assertions while ensuring
cleanup runs even when a test fails.
src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutor.java (1)

43-181: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Split evaluate to clear the failing SonarCloud gate.

SonarCloud fails this method with cognitive complexity 29 against a limit of 15, and it also flags the nested try at Line 117. The method repeats the same seven-argument createHookContext(...) call four times and inlines four stage loops.

Extract one context factory per execution and one method per stage. That removes both findings without changing behavior.

♻️ Suggested decomposition
private <T> HookContext<T> contextFor(HookExecution<T> execution, EvaluationContext evaluatedContext, ...) {
    return createHookContext(key, valueType, defaultValue, evaluatedContext, provider, hookExecutionContext, execution.hookData);
}

private <T> void runErrorHooks(List<HookExecution<T>> reversedHooks, Exception error, ...) {
    for (HookExecution<T> execution : reversedHooks) {
        try {
            execution.hook.error(contextFor(execution, ...), error, hookHints);
        } catch (Exception e) {
            log.error("error executing provider hook error stage", e);
        }
    }
}

private <T> void runFinallyHooks(List<HookExecution<T>> reversedHooks, FlagEvaluationDetails<T> details, ...) { ... }

private <T> EvaluationContext runBeforeHooks(List<HookExecution<T>> hooks, EvaluationContext evaluatedContext, ...) { ... }

evaluate then reduces to the before / provider call / after-or-error / finally sequence. Consider grouping key, valueType, defaultValue, provider, hookExecutionContext, and hookHints into a small private request record to keep the parameter lists short.

As per static analysis hints: "Refactor this method to reduce its Cognitive Complexity from 29 to the 15 allowed" and "Extract this nested try block into a separate method".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutor.java`
around lines 43 - 181, Refactor evaluate to reduce its cognitive complexity
below the SonarCloud limit by extracting a context factory for HookExecution and
separate helpers for before, error, and finally hook stages, while preserving
registration/reverse execution order and behavior. Move the nested error-stage
try/catch into the error-hook helper, and reuse the context factory instead of
repeating createHookContext calls; optionally group shared parameters in a
private request record.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java`:
- Around line 94-175: Reduce cognitive complexity in ComparisonStrategy.evaluate
by extracting provider task creation and future-result collection into private
helper methods. Keep the existing provider iteration order, timeout handling,
cancellation behavior, Future.get propagation, and interruption restoration
unchanged; evaluate should continue delegating to those helpers while preserving
current errorResult handling.
- Around line 115-147: Update the task/future tracking in the comparison
strategy around providerFunction and invokeAll so each Future is associated with
its provider name. Before returning from the future.isCancelled() branch, add a
ProviderError for every cancelled provider to providerErrors, then call
errorResult as before. Add a regression test that blocks a provider, triggers
the timeout, and verifies the timed-out provider appears in the aggregate
providerErrors.

In `@src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java`:
- Around line 392-399: Update registerChildProviderObserver to remove any
previously registered observer for providerName from the child EventProvider
before creating and registering the new observer. Reuse the existing
providerEventObservers entry and unregisterChildProviderObserver behavior where
appropriate, then store only the newly registered observer so repeated
initialize calls do not accumulate listeners.

---

Nitpick comments:
In `@src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java`:
- Around line 264-352: Extract the duplicated strategy and hook-executor wiring
from the five evaluation methods into one generic helper in MultiProvider,
parameterized by value type, FlagValueType, and the terminal provider evaluation
BiFunction. Update getBooleanEvaluation, getStringEvaluation,
getIntegerEvaluation, getDoubleEvaluation, and getObjectEvaluation to delegate
to that helper, and add the required BiFunction import.

In
`@src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutor.java`:
- Around line 43-181: Refactor evaluate to reduce its cognitive complexity below
the SonarCloud limit by extracting a context factory for HookExecution and
separate helpers for before, error, and finally hook stages, while preserving
registration/reverse execution order and behavior. Move the nested error-stage
try/catch into the error-hook helper, and reuse the context factory instead of
repeating createHookContext calls; optionally group shared parameters in a
private request record.

In
`@src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderEventsAndTrackingTest.java`:
- Around line 78-113: Update shouldPreserveChildStateEmittedDuringInitialize and
shouldForwardTrackToReadyProvidersAndSkipFatalProviders to wrap initialization
and assertions in finally blocks that call multiProvider.shutdown(). Preserve
the existing test assertions while ensuring cleanup runs even when a test fails.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 998827c6-1f7a-4c57-95cb-f9153924ce5a

📥 Commits

Reviewing files that changed from the base of the PR and between a8e6644 and 9504869.

📒 Files selected for processing (10)
  • src/main/java/dev/openfeature/sdk/EventProvider.java
  • src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java
  • src/main/java/dev/openfeature/sdk/multiprovider/HookExecutionContext.java
  • src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
  • src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutor.java
  • src/test/java/dev/openfeature/sdk/multiprovider/ComparisonStrategyTest.java
  • src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderEventsAndTrackingTest.java
  • src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutorTest.java
  • src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderHooksTest.java
  • src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderTest.java

Comment on lines +94 to +175
public <T> ProviderEvaluation<T> evaluate(
Map<String, FeatureProvider> providers,
String key,
T defaultValue,
EvaluationContext ctx,
Function<FeatureProvider, ProviderEvaluation<T>> providerFunction) {
if (providers.isEmpty()) {
return ProviderEvaluation.<T>builder()
.errorCode(ErrorCode.GENERAL)
.errorMessage("No providers configured")
.build();
}
if (!providers.containsKey(fallbackProvider)) {
throw new IllegalArgumentException("fallbackProvider not found in providers: " + fallbackProvider);
}

int capacity = providers.size() * 4 / 3 + 1;
Map<String, ProviderEvaluation<T>> successfulResults = new ConcurrentHashMap<>(capacity);
Map<String, ProviderError> providerErrors = new ConcurrentHashMap<>(capacity);

try {
List<Callable<Void>> tasks = new ArrayList<>(providers.size());
for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
String providerName = entry.getKey();
FeatureProvider provider = entry.getValue();
tasks.add(() -> {
try {
ProviderEvaluation<T> evaluation = providerFunction.apply(provider);
if (evaluation == null) {
providerErrors.put(
providerName,
ProviderError.fromResult(providerName, ErrorCode.GENERAL, "null evaluation"));
} else if (evaluation.getErrorCode() == null) {
successfulResults.put(providerName, evaluation);
} else {
providerErrors.put(
providerName,
ProviderError.fromResult(
providerName, evaluation.getErrorCode(), evaluation.getErrorMessage()));
}
} catch (Exception e) {
providerErrors.put(providerName, ProviderError.fromException(providerName, e));
}
return null;
});
}
List<Future<Void>> futures = executorService.invokeAll(tasks, timeoutMs, TimeUnit.MILLISECONDS);
for (Future<Void> future : futures) {
if (future.isCancelled()) {
return errorResult(
"Comparison strategy timed out after " + timeoutMs + "ms", providers, providerErrors);
}
future.get();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return errorResult("Comparison strategy interrupted: " + e.getMessage(), providers, providerErrors);
} catch (Exception e) {
return errorResult("Comparison strategy failed: " + e.getMessage(), providers, providerErrors);
}

if (!providerErrors.isEmpty()) {
return errorResult("Provider errors during comparison", providers, providerErrors);
}

ProviderEvaluation<T> fallbackResult = successfulResults.get(fallbackProvider);
if (fallbackResult == null) {
return errorResult(
"Fallback provider did not return a successful evaluation: " + fallbackProvider,
providers,
providerErrors);
}

if (allEvaluationsMatch(successfulResults)) {
return fallbackResult;
}

if (onMismatch != null) {
onMismatch.accept(key, orderedResults(providers, successfulResults));
}
return fallbackResult;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Reduce evaluate cognitive complexity to pass the configured check.

SonarCloud reports complexity 20 at Line 94. The configured limit is 15. Extract provider task creation and future-result collection into private methods. Preserve the current ordering, timeout, and interruption behavior.

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[failure] 94-94: Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=open-feature_java-sdk&issues=AZ_TunwAcxQN4hDLcMwd&open=AZ_TunwAcxQN4hDLcMwd&pullRequest=1897

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java`
around lines 94 - 175, Reduce cognitive complexity in
ComparisonStrategy.evaluate by extracting provider task creation and
future-result collection into private helper methods. Keep the existing provider
iteration order, timeout handling, cancellation behavior, Future.get
propagation, and interruption restoration unchanged; evaluate should continue
delegating to those helpers while preserving current errorResult handling.

Source: Linters/SAST tools

Comment on lines +115 to +147
List<Callable<Void>> tasks = new ArrayList<>(providers.size());
for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
String providerName = entry.getKey();
FeatureProvider provider = entry.getValue();
tasks.add(() -> {
try {
ProviderEvaluation<T> evaluation = providerFunction.apply(provider);
if (evaluation == null) {
providerErrors.put(
providerName,
ProviderError.fromResult(providerName, ErrorCode.GENERAL, "null evaluation"));
} else if (evaluation.getErrorCode() == null) {
successfulResults.put(providerName, evaluation);
} else {
providerErrors.put(
providerName,
ProviderError.fromResult(
providerName, evaluation.getErrorCode(), evaluation.getErrorMessage()));
}
} catch (Exception e) {
providerErrors.put(providerName, ProviderError.fromException(providerName, e));
}
return null;
});
}
List<Future<Void>> futures = executorService.invokeAll(tasks, timeoutMs, TimeUnit.MILLISECONDS);
for (Future<Void> future : futures) {
if (future.isCancelled()) {
return errorResult(
"Comparison strategy timed out after " + timeoutMs + "ms", providers, providerErrors);
}
future.get();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Record each timed-out provider before returning the aggregate error.

At Line 140, invokeAll cancels unfinished tasks. Lines 142-145 return an error before adding their provider names to providerErrors. A timeout can therefore produce an empty MultiProviderEvaluation.providerErrors list.

Track each future with its provider name. Add a ProviderError for every cancelled future before calling errorResult. Add a regression test that blocks a provider and asserts its timeout error is present.

Proposed fix
 List<Callable<Void>> tasks = new ArrayList<>(providers.size());
+List<String> providerNames = new ArrayList<>(providers.size());
 for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
     String providerName = entry.getKey();
     FeatureProvider provider = entry.getValue();
+    providerNames.add(providerName);
     tasks.add(() -> {
         // ...
     });
 }
 List<Future<Void>> futures = executorService.invokeAll(tasks, timeoutMs, TimeUnit.MILLISECONDS);
-for (Future<Void> future : futures) {
+boolean timedOut = false;
+for (int index = 0; index < futures.size(); index++) {
+    Future<Void> future = futures.get(index);
     if (future.isCancelled()) {
-        return errorResult(
-                "Comparison strategy timed out after " + timeoutMs + "ms", providers, providerErrors);
+        String providerName = providerNames.get(index);
+        providerErrors.putIfAbsent(
+                providerName,
+                ProviderError.fromResult(
+                        providerName,
+                        ErrorCode.GENERAL,
+                        "timed out after " + timeoutMs + "ms"));
+        timedOut = true;
+        continue;
     }
     future.get();
 }
+if (timedOut) {
+    return errorResult(
+            "Comparison strategy timed out after " + timeoutMs + "ms", providers, providerErrors);
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
List<Callable<Void>> tasks = new ArrayList<>(providers.size());
for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
String providerName = entry.getKey();
FeatureProvider provider = entry.getValue();
tasks.add(() -> {
try {
ProviderEvaluation<T> evaluation = providerFunction.apply(provider);
if (evaluation == null) {
providerErrors.put(
providerName,
ProviderError.fromResult(providerName, ErrorCode.GENERAL, "null evaluation"));
} else if (evaluation.getErrorCode() == null) {
successfulResults.put(providerName, evaluation);
} else {
providerErrors.put(
providerName,
ProviderError.fromResult(
providerName, evaluation.getErrorCode(), evaluation.getErrorMessage()));
}
} catch (Exception e) {
providerErrors.put(providerName, ProviderError.fromException(providerName, e));
}
return null;
});
}
List<Future<Void>> futures = executorService.invokeAll(tasks, timeoutMs, TimeUnit.MILLISECONDS);
for (Future<Void> future : futures) {
if (future.isCancelled()) {
return errorResult(
"Comparison strategy timed out after " + timeoutMs + "ms", providers, providerErrors);
}
future.get();
}
List<Callable<Void>> tasks = new ArrayList<>(providers.size());
List<String> providerNames = new ArrayList<>(providers.size());
for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
String providerName = entry.getKey();
FeatureProvider provider = entry.getValue();
providerNames.add(providerName);
tasks.add(() -> {
try {
ProviderEvaluation<T> evaluation = providerFunction.apply(provider);
if (evaluation == null) {
providerErrors.put(
providerName,
ProviderError.fromResult(providerName, ErrorCode.GENERAL, "null evaluation"));
} else if (evaluation.getErrorCode() == null) {
successfulResults.put(providerName, evaluation);
} else {
providerErrors.put(
providerName,
ProviderError.fromResult(
providerName, evaluation.getErrorCode(), evaluation.getErrorMessage()));
}
} catch (Exception e) {
providerErrors.put(providerName, ProviderError.fromException(providerName, e));
}
return null;
});
}
List<Future<Void>> futures = executorService.invokeAll(tasks, timeoutMs, TimeUnit.MILLISECONDS);
boolean timedOut = false;
for (int index = 0; index < futures.size(); index++) {
Future<Void> future = futures.get(index);
if (future.isCancelled()) {
String providerName = providerNames.get(index);
providerErrors.putIfAbsent(
providerName,
ProviderError.fromResult(
providerName,
ErrorCode.GENERAL,
"timed out after " + timeoutMs + "ms"));
timedOut = true;
continue;
}
future.get();
}
if (timedOut) {
return errorResult(
"Comparison strategy timed out after " + timeoutMs + "ms", providers, providerErrors);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/dev/openfeature/sdk/multiprovider/ComparisonStrategy.java`
around lines 115 - 147, Update the task/future tracking in the comparison
strategy around providerFunction and invokeAll so each Future is associated with
its provider name. Before returning from the future.isCancelled() branch, add a
ProviderError for every cancelled provider to providerErrors, then call
errorResult as before. Add a regression test that blocks a provider, triggers
the timeout, and verifies the timed-out provider appears in the aggregate
providerErrors.

Comment on lines +392 to +399
private void registerChildProviderObserver(String providerName, FeatureProvider provider) {
if (provider instanceof EventProvider) {
BiConsumer<ProviderEvent, ProviderEventDetails> observer =
(event, details) -> onChildProviderEvent(providerName, event, details);
((EventProvider) provider).addEventObserver(observer);
providerEventObservers.put(providerName, observer);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unregister the previous observer before you register a new one.

registerChildProviderObserver creates a new lambda and calls providerEventObservers.put(...). put replaces the map entry, but the earlier lambda stays registered on the child EventProvider. unregisterChildProviderObserver can then remove only the last observer.

initialize(...) can run more than once on the same MultiProvider instance, for example after shutdown(). Each extra run adds one more observer per child provider. The accumulated observers leak, and each duplicate re-forwards PROVIDER_CONFIGURATION_CHANGED, so listeners receive duplicate configuration-change events.

🐛 Proposed fix
     private void registerChildProviderObserver(String providerName, FeatureProvider provider) {
         if (provider instanceof EventProvider) {
+            // Drop any observer left over from a previous initialize() on this instance.
+            unregisterChildProviderObserver(providerName, provider);
             BiConsumer<ProviderEvent, ProviderEventDetails> observer =
                     (event, details) -> onChildProviderEvent(providerName, event, details);
             ((EventProvider) provider).addEventObserver(observer);
             providerEventObservers.put(providerName, observer);
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private void registerChildProviderObserver(String providerName, FeatureProvider provider) {
if (provider instanceof EventProvider) {
BiConsumer<ProviderEvent, ProviderEventDetails> observer =
(event, details) -> onChildProviderEvent(providerName, event, details);
((EventProvider) provider).addEventObserver(observer);
providerEventObservers.put(providerName, observer);
}
}
private void registerChildProviderObserver(String providerName, FeatureProvider provider) {
if (provider instanceof EventProvider) {
// Drop any observer left over from a previous initialize() on this instance.
unregisterChildProviderObserver(providerName, provider);
BiConsumer<ProviderEvent, ProviderEventDetails> observer =
(event, details) -> onChildProviderEvent(providerName, event, details);
((EventProvider) provider).addEventObserver(observer);
providerEventObservers.put(providerName, observer);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java` around
lines 392 - 399, Update registerChildProviderObserver to remove any previously
registered observer for providerName from the child EventProvider before
creating and registering the new observer. Reuse the existing
providerEventObservers entry and unregisterChildProviderObserver behavior where
appropriate, then store only the newly registered observer so repeated
initialize calls do not accumulate listeners.

@jonathannorris

Copy link
Copy Markdown
Member Author

Split this up into #2003, #2004, #2005

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.

[Multi-provider] Gaps identified relative to js-sdk reference implementation

3 participants