Skip to content

feat: support isolated API instances#1928

Open
marcozabel wants to merge 20 commits into
open-feature:mainfrom
marcozabel:feat/isolated-api-instances
Open

feat: support isolated API instances#1928
marcozabel wants to merge 20 commits into
open-feature:mainfrom
marcozabel:feat/isolated-api-instances

Conversation

@marcozabel

@marcozabel marcozabel commented Apr 16, 2026

Copy link
Copy Markdown

This PR

This PR introduces support for creating isolated OpenFeature API instances, each with their own providers, hooks, context, and event handling - enabling multi-tenant or side-by-side usage without shared global state.

Related Issues

Fixes #1925

Notes

Follow-up Tasks

How to test

@marcozabel marcozabel requested review from a team as code owners April 16, 2026 15:00

@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 support for isolated OpenFeature API instances by adding a createIsolated method to OpenFeatureAPI and a new OpenFeatureAPIFactory in a dedicated package. These changes allow for independent state management (providers, hooks, context) across different instances, satisfying specification requirements. Feedback includes a necessary fix for a compilation error in the tests due to unhandled exceptions, a recommendation to move the shared process-wide lock to an instance level for better performance isolation, and a concern regarding the completeness of global state restoration in the test cleanup logic.

Comment thread src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java Outdated
Comment thread src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java Outdated
Comment thread src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java Outdated
@marcozabel marcozabel force-pushed the feat/isolated-api-instances branch 6 times, most recently from dcf7a52 to e7b91f3 Compare April 16, 2026 15:15
@codecov

codecov Bot commented Apr 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.11765% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.31%. Comparing base (b592514) to head (cfb2a51).

Files with missing lines Patch % Lines
...c/main/java/dev/openfeature/sdk/EventProvider.java 84.61% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #1928      +/-   ##
============================================
+ Coverage     92.28%   93.31%   +1.02%     
- Complexity      655      665      +10     
============================================
  Files            59       59              
  Lines          1594     1615      +21     
  Branches        181      182       +1     
============================================
+ Hits           1471     1507      +36     
+ Misses           76       62      -14     
+ Partials         47       46       -1     
Flag Coverage Δ
unittests 93.31% <94.11%> (+1.02%) ⬆️

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.

* @return a new API instance
* @see OpenFeatureAPI#createIsolated()
*/
public static OpenFeatureAPI createAPI() {

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.

I don't fully see the point of this method/class. OpenFeatureAPI.createIsolated() has the exact same visibility level and does the same thing. I think this will only lead to confusion

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Agree

// relies on a ready event to be emitted
emitterExecutor.submit(() -> {
try (var ignored = OpenFeatureAPI.lock.readLockAutoCloseable()) {
try (var ignored = localLock != null ? localLock.readLockAutoCloseable() : null) {

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.

Why is the lock optional? IIRC, we needed that lock to avoid race conditions when emitting events

Comment thread src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java Outdated
Comment thread src/test/java/dev/openfeature/sdk/LockingSingeltonTest.java Outdated
@marcozabel marcozabel force-pushed the feat/isolated-api-instances branch 6 times, most recently from c24b936 to c06e500 Compare April 20, 2026 12:33
api1.setProviderAndWait(new NoOpProvider());

assertThat(api1HandlerLatch.await(1, TimeUnit.SECONDS)).isTrue();
assertThat(api2HandlerCalled.get()).isFalse();

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.

I am not sure this test really tests anything, the lambda for api2 might still get called some time after this tests finished. Unless we can read the queue of the emitterExecutor in the EventProvider, we cannot be sure that the lambda isn't scheduled for execution.

We could wait before this line for a few hundred ms. While this would slow down the test suite and still would not be a real proof, it should catch most errors

@marcozabel marcozabel force-pushed the feat/isolated-api-instances branch 2 times, most recently from 013eb7d to 0e53150 Compare April 22, 2026 12:27
@toddbaert toddbaert self-requested a review April 22, 2026 18:58
* @see <a href="https://openfeature.dev/specification/sections/flag-evaluation#18-isolated-api-instances">
* Spec &sect;1.8 &mdash; Isolated API Instances</a>
*/
public final class OpenFeatureAPIFactory {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Section 1.8 is marked experimental in the spec. Might be worth adding an @apiNote to flag that, e.g.:

* @apiNote This API is experimental and subject to change.

Could go on the class-level Javadoc or on createAPI() itself (or both).

*/
@Test
@DisplayName("isolated instance does not interfere with singleton")
void isolatedInstanceDoesNotInterfereWithSingleton() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This tests that mutating an isolated instance doesn't affect the singleton, which is great. Worth adding the reverse direction too; e.g. set a provider/hook/context on the singleton and assert a previously created isolated instance is unaffected.

@sonarqubecloud

sonarqubecloud Bot commented May 7, 2026

Copy link
Copy Markdown

} else {
this.onEmit = onEmit;
}
this.attachment = new Attachment(onEmit, lock);

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.

this.attachment is not guaranteed to be null here any more. If we want to ensure that, we need an AtomicReference and a cas operation

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

+1 to this; we flagged the same race in our earlier review pass. volatile gives visibility but not atomicity. An AtomicReference with compareAndSet would be better I think, or just guarding attach() with a synchronized block since it's not on a hot path.

Comment thread src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java Outdated
@marcozabel marcozabel force-pushed the feat/isolated-api-instances branch from 3e7374c to 91b8351 Compare May 21, 2026 09:29
Comment on lines +61 to +71
Attachment newAttachment = new Attachment(onEmit, lock);
while (true) {
Attachment existing = this.attachment.get();
if (existing != null && existing.onEmit != onEmit) {
// if we are trying to attach this provider to a different onEmit, something has gone wrong
throw new IllegalStateException(
"Provider " + this.getMetadata().getName() + " is already attached.");
}
if (this.attachment.compareAndSet(existing, newAttachment)) {
return;
}

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.

Couldn't we simplify all of this with

if (!this.attachment.compareAndSet(null, newAttachment)) {
   throw new IllegalStateException();
}

we expect the existing attachement to be null, every other case is exceptional, and we wouldn't retry anyway

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I made this change... Also dropped doesNotThrowWhenOnEmitSame which failed with this change, BUT that test guarded a code branch that's not reachable in practice (attach() is internal, and the internal caller passes a fresh method-reference each time).

@toddbaert

toddbaert commented Jun 9, 2026

Copy link
Copy Markdown
Member

Hey @marcozabel; pushed two minor changes:

  • configured maven-javadoc-plugin to recognize @apiNote (fixes the failing Java 17 CI; build-time only, no runtime changes).
  • removed OpenFeatureAPIFactory per @chrfwow's point on feat: support isolated API instances #1928 (comment); the sub-package factory doesn't actually achieve the "less discoverable" goal because Java's package-access rules force us to expose public surface either way. Spec 1.8.3 is a SHOULD, so we're still compliant.

New public API surface is now just:

public class OpenFeatureAPI {
    public static OpenFeatureAPI createIsolated();   // factory; spec 1.8.1
}

@toddbaert toddbaert requested review from chrfwow and toddbaert June 9, 2026 18:08
@toddbaert toddbaert force-pushed the feat/isolated-api-instances branch from edeb0d0 to 0098372 Compare June 9, 2026 18:10
marcozabel and others added 20 commits June 10, 2026 15:16
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
…Isolated()

Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
…vider binding

Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
per chrfwow's review suggestion. The previous CAS loop existed to
support a no-op-same-onEmit branch which is unreachable in practice:
attach() is package-private (not part of the public API), and the
sole production caller (OpenFeatureAPI.attachEventProvider) always
passes a fresh method-reference. Drops the now-orphaned
doesNotThrowWhenOnEmitSame test.

Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
@toddbaert toddbaert force-pushed the feat/isolated-api-instances branch from 0098372 to cfb2a51 Compare June 10, 2026 19:16
@sonarqubecloud

Copy link
Copy Markdown

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.

Support isolated API instances

4 participants