feat: support isolated API instances#1928
Conversation
There was a problem hiding this comment.
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.
dcf7a52 to
e7b91f3
Compare
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| * @return a new API instance | ||
| * @see OpenFeatureAPI#createIsolated() | ||
| */ | ||
| public static OpenFeatureAPI createAPI() { |
There was a problem hiding this comment.
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
| // relies on a ready event to be emitted | ||
| emitterExecutor.submit(() -> { | ||
| try (var ignored = OpenFeatureAPI.lock.readLockAutoCloseable()) { | ||
| try (var ignored = localLock != null ? localLock.readLockAutoCloseable() : null) { |
There was a problem hiding this comment.
Why is the lock optional? IIRC, we needed that lock to avoid race conditions when emitting events
c24b936 to
c06e500
Compare
| api1.setProviderAndWait(new NoOpProvider()); | ||
|
|
||
| assertThat(api1HandlerLatch.await(1, TimeUnit.SECONDS)).isTrue(); | ||
| assertThat(api2HandlerCalled.get()).isFalse(); |
There was a problem hiding this comment.
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
013eb7d to
0e53150
Compare
| * @see <a href="https://openfeature.dev/specification/sections/flag-evaluation#18-isolated-api-instances"> | ||
| * Spec §1.8 — Isolated API Instances</a> | ||
| */ | ||
| public final class OpenFeatureAPIFactory { |
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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.
|
| } else { | ||
| this.onEmit = onEmit; | ||
| } | ||
| this.attachment = new Attachment(onEmit, lock); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
+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.
3e7374c to
91b8351
Compare
| 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; | ||
| } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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).
|
Hey @marcozabel; pushed two minor changes:
New public API surface is now just: public class OpenFeatureAPI {
public static OpenFeatureAPI createIsolated(); // factory; spec 1.8.1
} |
edeb0d0 to
0098372
Compare
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>
0098372 to
cfb2a51
Compare
|



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