Skip to content

Commit 30b75a2

Browse files
Fix: share NotificationCenter so PROVIDER_CONFIGURATION_CHANGED events propagate (#161)
1 parent f99307e commit 30b75a2

2 files changed

Lines changed: 147 additions & 2 deletions

File tree

optimizely/src/main/scala/zio/openfeature/optimizely/OptimizelyProvider.scala

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,17 @@ object OptimizelyProvider {
114114
// Construction
115115

116116
private def buildClient(sdkKey: String, datafileUrl: Option[String]): Optimizely = {
117-
val configBuilder = HttpProjectConfigManager.builder().withSdkKey(sdkKey)
117+
// Share a single NotificationCenter between the polling config manager and the Optimizely client. Without this,
118+
// the manager fires UpdateConfigNotification on its own private NotificationCenter and handlers registered via
119+
// `Optimizely.addUpdateConfigNotificationHandler` (the public API our provider uses) never see subsequent datafile
120+
// updates — observed empirically. The initial load still wakes the init latch because we also poll
121+
// `optimizely.isValid` directly, but the OpenFeature `PROVIDER_CONFIGURATION_CHANGED` event would never fire on
122+
// any datafile revision after the first one.
123+
val notificationCenter = new com.optimizely.ab.notification.NotificationCenter()
124+
val configBuilder = HttpProjectConfigManager.builder().withSdkKey(sdkKey).withNotificationCenter(notificationCenter)
118125
datafileUrl.foreach(configBuilder.withUrl)
119126
val configManager = configBuilder.build()
120-
Optimizely.builder().withConfigManager(configManager).build()
127+
Optimizely.builder().withConfigManager(configManager).withNotificationCenter(notificationCenter).build()
121128
}
122129

123130
// Validation
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package zio.openfeature.optimizely
2+
3+
import com.github.tomakehurst.wiremock.WireMockServer
4+
import com.github.tomakehurst.wiremock.client.WireMock._
5+
import com.github.tomakehurst.wiremock.core.WireMockConfiguration
6+
import com.github.tomakehurst.wiremock.stubbing.Scenario
7+
import com.optimizely.ab.Optimizely
8+
import com.optimizely.ab.config.HttpProjectConfigManager
9+
import com.optimizely.ab.notification.NotificationCenter
10+
import dev.openfeature.sdk.OpenFeatureAPI
11+
import zio._
12+
import zio.test._
13+
14+
import java.util.UUID
15+
import java.util.concurrent.{CountDownLatch, TimeUnit}
16+
import java.util.concurrent.atomic.AtomicInteger
17+
18+
/** Regression test for the `NotificationCenter` sharing bug: the Optimizely Java SDK creates two separate
19+
* `NotificationCenter` instances by default — one inside the polling `HttpProjectConfigManager`, one inside the
20+
* `Optimizely` client. Without explicitly sharing one, the manager fires `UpdateConfigNotification` on its private
21+
* center and handlers registered via `Optimizely.addUpdateConfigNotificationHandler` (which is what our provider's
22+
* `initialize` uses) never see subsequent datafile updates.
23+
*
24+
* The user-visible symptom is that an OpenFeature `Client.onProviderConfigurationChanged` listener never fires for any
25+
* datafile revision after the initial load. This test sets up that exact scenario via WireMock + a revision-bump
26+
* scenario, registers the listener, and asserts it receives the event.
27+
*/
28+
object OptimizelyConfigChangedEventSpec extends ZIOSpecDefault {
29+
30+
private val DatafilePath = "/datafiles/event-key.json"
31+
private val ValidDatafileV1 = readResource("/test-datafile-with-flag.json")
32+
// Same shape as v1 but with `revision: "2"` — drives `PollingProjectConfigManager.setConfig` to fire its
33+
// notification because `currentVersion > previousVersion`.
34+
private val ValidDatafileV2 = ValidDatafileV1.replace("\"revision\": \"1\"", "\"revision\": \"2\"")
35+
36+
private def readResource(path: String): String =
37+
scala.io.Source.fromInputStream(getClass.getResourceAsStream(path)).mkString
38+
39+
private def withMockServer[A](body: WireMockServer => A): A = {
40+
val server = new WireMockServer(WireMockConfiguration.options().dynamicPort())
41+
server.start()
42+
try body(server)
43+
finally server.stop()
44+
}
45+
46+
def spec: Spec[TestEnvironment & Scope, Any] = suite("OptimizelyFeatureProvider config-changed event propagation")(
47+
test("OptimizelyProvider.make wires a shared NotificationCenter through to the underlying Optimizely client") {
48+
// Direct structural check: the public production factory `OptimizelyProvider.make` must construct the
49+
// Optimizely client AND its config manager with a shared NotificationCenter. Without this, datafile-update
50+
// notifications fire on the manager's center and handlers registered via the client's API never see them.
51+
//
52+
// Catches the regression at construction time so it doesn't depend on polling timing or WireMock scenarios.
53+
withMockServer { server =>
54+
server.stubFor(get(urlEqualTo(DatafilePath)).willReturn(okJson(ValidDatafileV1)))
55+
val datafileUrl = s"http://localhost:${server.port()}$DatafilePath"
56+
val provider = Unsafe.unsafe { implicit u =>
57+
Runtime.default.unsafe
58+
.run(OptimizelyProvider.make("struct-check-key", Some(datafileUrl), java.time.Duration.ofSeconds(3)))
59+
.getOrThrowFiberFailure()
60+
}
61+
try {
62+
val optimizelyField = provider.getClass.getDeclaredField("optimizely")
63+
optimizelyField.setAccessible(true)
64+
val optimizely = optimizelyField.get(provider).asInstanceOf[Optimizely]
65+
val mgrField = optimizely.getClass.getDeclaredField("projectConfigManager")
66+
mgrField.setAccessible(true)
67+
val mgr = mgrField.get(optimizely).asInstanceOf[com.optimizely.ab.config.PollingProjectConfigManager]
68+
val clientCtr = optimizely.getNotificationCenter
69+
val mgrCtr = mgr.getNotificationCenter
70+
assertTrue(clientCtr eq mgrCtr)
71+
} finally provider.shutdown()
72+
}
73+
},
74+
test("PROVIDER_CONFIGURATION_CHANGED fires on a revision bump observed by the polling thread") {
75+
withMockServer { server =>
76+
// Two-step WireMock scenario: first request returns v1, every subsequent request returns v2.
77+
server.stubFor(
78+
get(urlEqualTo(DatafilePath))
79+
.inScenario("revision-bump")
80+
.whenScenarioStateIs(Scenario.STARTED)
81+
.willReturn(okJson(ValidDatafileV1))
82+
.willSetStateTo("v2-served")
83+
)
84+
server.stubFor(
85+
get(urlEqualTo(DatafilePath))
86+
.inScenario("revision-bump")
87+
.whenScenarioStateIs("v2-served")
88+
.willReturn(okJson(ValidDatafileV2))
89+
)
90+
91+
val datafileUrl = s"http://localhost:${server.port()}$DatafilePath"
92+
// Build the underlying client manually with a 1-second polling interval so the second fetch happens within
93+
// our 15s test window. The public `OptimizelyProvider.make` factory doesn't expose `pollingInterval` (the
94+
// SDK default is 5 minutes), and this spec is regression-checking the production wiring, so we mirror the
95+
// production code path: share a NotificationCenter between the manager and the Optimizely client. If the
96+
// production fix in OptimizelyProvider.buildClient regresses (omitting the share), the manual construction
97+
// here also won't see the event — but the same configuration is what production callers get, so the
98+
// regression surface is preserved.
99+
val sharedCenter = new NotificationCenter()
100+
val configManager = HttpProjectConfigManager
101+
.builder()
102+
.withSdkKey("event-test-key")
103+
.withUrl(datafileUrl)
104+
.withBlockingTimeout(2000L, TimeUnit.MILLISECONDS)
105+
.withPollingInterval(1L, TimeUnit.SECONDS)
106+
.withNotificationCenter(sharedCenter)
107+
.build()
108+
val optimizely =
109+
Optimizely.builder().withConfigManager(configManager).withNotificationCenter(sharedCenter).build()
110+
val provider =
111+
new OptimizelyFeatureProvider(optimizely, java.time.Duration.ofSeconds(3), closeOnShutdown = true)
112+
val api = OpenFeatureAPI.getInstance()
113+
val domain = s"optimizely-revbump-${UUID.randomUUID()}"
114+
val received = new AtomicInteger(0)
115+
val gate = new CountDownLatch(1)
116+
try {
117+
val client = api.getClient(domain)
118+
client.onProviderConfigurationChanged { _ =>
119+
received.incrementAndGet()
120+
gate.countDown()
121+
}
122+
// setProviderAndWait blocks until the provider transitions to READY against the v1 datafile.
123+
api.setProviderAndWait(domain, provider)
124+
// Wait up to 15s for the SDK's polling thread to fetch again and observe the revision change. The internal
125+
// polling interval defaults to 5 minutes if unspecified, but the SDK also re-fetches on its own scheduling
126+
// — for this regression we just need the v2 fetch + setConfig + notification chain to complete within a
127+
// generous window. The existing `OptimizelyProviderIntegrationSpec.datafile revision change` test
128+
// demonstrates the SDK does poll again well under our timeout when WireMock serves a fresh response.
129+
val fired = gate.await(15, TimeUnit.SECONDS)
130+
assertTrue(fired, received.get() >= 1)
131+
} finally {
132+
scala.util.Try(api.shutdown())
133+
()
134+
}
135+
}
136+
}
137+
) @@ TestAspect.sequential @@ TestAspect.timeout(60.seconds) @@ TestAspect.withLiveClock
138+
}

0 commit comments

Comments
 (0)