Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,16 @@ asserts (same value/reason/error-code, different channel).

Spec v0.9.0 renumbered these scenarios from `@spec-1.7.6`/`@spec-1.7.7` to `@spec-2.2.7`: provider
status is now derived from provider-emitted events, and the client short-circuit those requirements
mandated is no longer required. Neither runner filters on `@spec-*` tags, so the rename is inert
here. Adapting the library's own behaviour to that change is tracked separately in #332.
mandated is no longer required. Neither runner filters on `@spec-*` tags, so the rename is inert here.

**The short-circuit stays anyway, as deliberate library policy** (#332). v0.9.0 permits it rather than
requiring it, and removing it would change the published 1.0.0 error contract — `FeatureFlags`
evaluations fail with a typed `ProviderNotReady`/`ProviderFatal`, while transaction overrides and cached
evaluations deliberately bypass the gate — for no spec gain, since the gherkin asserts the same
observable outcomes either way. The step-definition bridge above therefore stays too. The behavioural
half of v0.9.0 (providers emitting their own lifecycle events) is upstream-blocked and tracked in #340.

Note the two `@spec-1.7.6`/`@spec-1.7.7` references in the paragraph above are deliberate: they record
what the scenarios *used to be* numbered so a future re-sync can trace the rename. Nothing in the code
or docs claims those requirements as current — the only other surviving mentions are past `CHANGELOG.md`
entries, which are release history and stay as written.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ package dev.openfeature.sdk
* Compile-time checked rather than reflective: an SDK upgrade that changes these signatures fails compilation here
* instead of surfacing as a runtime reflection error. Same package-shim pattern as `extras`' `EventProviderBridge`
* (which reaches the package-private `EventProvider.attach`/`detach`).
*
* Phase 2 watch-point (#340): re-verify this shim still applies once the OpenFeature Java SDK ships the spec-v0.9.0
* provider-event marker — it may change how attach/detach state is managed.
*/
object EventProviderAccess {

Expand Down
15 changes: 11 additions & 4 deletions core/src/main/scala/zio/openfeature/FeatureFlagsLive.scala
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ final private[openfeature] class FeatureFlagsLive(
// whose metadata name only materializes inside initialize() (so the async build captured "unknown", while the
// provider's own later events carry the real name). Same-named swaps are indistinguishable — see "Known limitation"
// in #244.
//
// Phase 2 watch-point (#340): re-verify this identity-guard behaviour once the OpenFeature Java SDK ships the
// spec-v0.9.0 provider-event marker — it may offer a more direct way to attribute an event to its emitting provider.
private def fromCurrentProvider(details: EventDetails): Boolean = {
val eventName = details.getProviderName
val currentName = providerNameRef.get()
Expand Down Expand Up @@ -382,9 +385,11 @@ final private[openfeature] class FeatureFlagsLive(
case ProviderStatus.NotReady => ZIO.fail(FeatureFlagError.ProviderNotReady(ProviderStatus.NotReady))
case ProviderStatus.ShuttingDown =>
ZIO.fail(FeatureFlagError.ProviderNotReady(ProviderStatus.ShuttingDown))
// Error/Ready/Stale proceed: per OpenFeature spec 1.7.6/1.7.7 only NOT_READY and FATAL fail-fast. A provider in
// ERROR is typically a transient, recoverable state and commonly still serves cached values — let the evaluation
// reach it (the provider serves or errors on its own) rather than turning one PROVIDER_ERROR into a total outage.
// Error/Ready/Stale proceed: fast-failing only NOT_READY and FATAL is this library's deliberate policy — spec
// v0.9.0 removed the requirements that used to mandate this and renumbered the equivalent scenarios to
// @spec-2.2.7 (+ @spec-1.4.10) as *permitted*, not required, behaviour. A provider in ERROR is typically a
// transient, recoverable state and commonly still serves cached values — let the evaluation reach it (the
// provider serves or errors on its own) rather than turning one PROVIDER_ERROR into a total outage.
case _ => Exit.unit
}

Expand Down Expand Up @@ -1080,7 +1085,9 @@ final private[openfeature] class FeatureFlagsLive(
newName = Option(newProvider.getMetadata).map(_.getName).getOrElse(FeatureFlags.UnknownProviderName)
_ <- providerRef.set(newProvider)
_ <- ZIO.succeed(providerNameRef.set(newName))
// 3. Register new provider with Java SDK (shuts down old, initializes new)
// 3. Register new provider with Java SDK (shuts down old, initializes new). Assumes `setProviderAndWait`
// returning normally means READY was reached (Phase 2 watch-point #340: re-verify against the SDK's
// spec-v0.9.0 provider-event marker once shipped).
_ <- (domain match {
case Some(d) => ZIO.attemptBlocking(api.setProviderAndWait(d, newProvider))
case None => ZIO.attemptBlocking(api.setProviderAndWait(newProvider))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ private[openfeature] object ProviderStatusMachine {
object Signal {
case object EventReady extends Signal

/** `fatal = true` when the event carries ErrorCode.PROVIDER_FATAL — the Java SDK's state manager maps that to FATAL
* (spec 1.7.6); mirror it.
/** `fatal = true` when the event carries ErrorCode.PROVIDER_FATAL — the Java SDK's state manager maps that to
* FATAL; mirror it.
*/
final case class EventError(fatal: Boolean) extends Signal
case object EventStale extends Signal
Expand Down Expand Up @@ -49,7 +49,7 @@ private[openfeature] object ProviderStatusMachine {
signal match {
// Lifecycle: authoritative from anywhere. setProvider is the ONLY exit from Fatal and from
// post-shutdown NotReady — it installs a NEW provider, so the old one's irrecoverability
// (spec 1.7.6) no longer applies.
// no longer applies.
case Signal.ShutdownStarted => Some(ShuttingDown)
case Signal.ShutdownCompleted => Some(NotReady)
case Signal.SwapStarted => Some(NotReady)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ import java.util.concurrent.atomic.AtomicReference
*
* Cases covered here:
* 1. Sync `initialize()` throws synchronously → layer build fails with the thrown exception. 5. Async provider fires
* `PROVIDER_ERROR` after construction → status reflects `Error`, but evaluations still proceed (spec 1.7.6/1.7.7:
* only NOT_READY and FATAL fail-fast). 5b. Fail-fast contract: NOT_READY and FATAL block evaluation; Ready/Error
* proceed. 6. Async provider recovers (ERROR → READY) → evaluations succeed after recovery. 7. Evaluation throws
* `UnknownHostException` from the Java SDK → classifier surfaces `Unreachable`.
* `PROVIDER_ERROR` after construction → status reflects `Error`, but evaluations still proceed (library policy:
* only NOT_READY and FATAL fail-fast, permitted — no longer required — under spec v0.9.0). 5b. Fail-fast
* contract: NOT_READY and FATAL block evaluation; Ready/Error proceed. 6. Async provider recovers (ERROR → READY)
* → evaluations succeed after recovery. 7. Evaluation throws `UnknownHostException` from the Java SDK →
* classifier surfaces `Unreachable`.
*
* Cases 2, 3, 4 are already covered by [[ProviderInitHardeningSpec]] and are not duplicated here.
*/
Expand Down Expand Up @@ -202,7 +203,7 @@ object ProviderInitFailureSpec extends ZIOSpecDefault {
)
} @@ withLiveClock,
test(
"[B2 / case 5] async provider fires PROVIDER_ERROR after init -> evaluations still proceed (spec 1.7.6/1.7.7)"
"[B2 / case 5] async provider fires PROVIDER_ERROR after init -> evaluations still proceed (library policy)"
) {
val provider = new EventDriverProvider
val api = OpenFeatureAPI.createIsolated()
Expand Down Expand Up @@ -232,8 +233,9 @@ object ProviderInitFailureSpec extends ZIOSpecDefault {
.repeatUntil(_ == ProviderStatus.Error)
.timeout(5.seconds)
.someOrFail(new Exception("timed out waiting for PROVIDER_ERROR to propagate"))
// Spec 1.7.6/1.7.7: only NOT_READY and FATAL fail-fast. In ERROR the evaluation proceeds to the provider
// (which serves cached values or errors on its own) instead of a blanket ProviderNotReady failure.
// Library policy: only NOT_READY and FATAL fail-fast (permitted, not required, under spec v0.9.0). In ERROR
// the evaluation proceeds to the provider (which serves cached values or errors on its own) instead of a
// blanket ProviderNotReady failure.
result <- ff.booleanDetails("any-flag", default = false).either
} yield assertTrue(result.isRight)
}
Expand Down
56 changes: 54 additions & 2 deletions core/src/test/scala/zio/openfeature/ProviderStatusBridgeSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package zio.openfeature
import zio._
import zio.test._
import zio.stream.SubscriptionRef
import zio.openfeature.internal.ProviderEvaluations
import zio.openfeature.internal.{ProviderEvaluations, ProviderStatusMachine}
import dev.openfeature.sdk.{
ErrorCode => OFErrorCode,
EvaluationContext => OFEvaluationContext,
Expand Down Expand Up @@ -221,6 +221,58 @@ object ProviderStatusBridgeSpec extends ZIOSpecDefault {
}
}
} yield out
},
// --- Duplicate-event behaviour, pinned ahead of spec v0.9.0 Phase 2 (#332) ---
//
// These two are characterization tests: they pass today and are expected to. Their job is to make a future
// change visible rather than to prove a fix. Under spec v0.9.0 providers emit their own lifecycle events while
// the SDK still synthesizes them on the legacy path, so a provider that adopts emission early produces a
// duplicate READY. The spec's appendix-e calls those duplicates "expected legacy behavior" — but that verdict
// is about the *SDK's* status, and the two tests below record that the two halves of this library disagree on
// how tolerable they are. That asymmetry is the whole reason Phase 2 defers provider-side emission.
test("a duplicate READY leaves status Ready — the status machine is idempotent") {
// Asserting the readback alone would be satisfied by a machine that blindly re-asserts Ready, so the
// mechanism is pinned directly too: `transition` must return None (no transition at all) for a repeat
// READY. Without this, the test's name would claim more than it checks.
val repeatReady = ProviderStatusMachine.transition(
ProviderStatus.Ready,
ProviderStatusMachine.Signal.EventReady,
ProviderStatusMachine.Context(everReady = true, swapInProgress = false, shutdownCompleted = false)
)
ZIO.scoped {
for {
ref <- SubscriptionRef.make[ProviderStatus](ProviderStatus.NotReady)
ff <- build(ref, "current")
_ <- ff.onReadyEvent(details("current"))
s1 <- ff.providerStatus
_ <- ff.onReadyEvent(details("current"))
s2 <- ff.providerStatus
} yield assertTrue(s1 == ProviderStatus.Ready, s2 == ProviderStatus.Ready, repeatReady.isEmpty)
}
},
test("but the event hub delivers BOTH READYs to observers — duplicates are user-visible") {
ZIO.scoped {
for {
ref <- SubscriptionRef.make[ProviderStatus](ProviderStatus.NotReady)
ff <- build(ref, "current")
count <- Ref.make(0)
both <- Promise.make[Nothing, Unit]
// `on` establishes the hub subscription before returning, so no event can slip in between registering
// and firing. Status is NotReady here, so the spec-5.3.3 immediate-fire does not add a phantom count.
cancel <- ff.on(
ProviderEventType.Ready,
_ => count.updateAndGet(_ + 1).flatMap(n => both.succeed(()).when(n >= 2)).unit
)
_ <- ff.onReadyEvent(details("current"))
_ <- ff.onReadyEvent(details("current"))
_ <- both.await.timeoutFail(new RuntimeException("hub delivered fewer than two READY events"))(10.seconds)
n <- count.get
_ <- cancel
// Delivered twice even though the machine transitioned once. Emitting init events from our own providers
// while the SDK still synthesizes them would therefore surface as duplicate READYs in USER handlers — a
// visible regression, not internal noise. If Phase 2 makes this 1, that is the deliberate flip.
} yield assertTrue(n == 2)
}
}
) @@ TestAspect.sequential
) @@ TestAspect.sequential @@ TestAspect.withLiveClock
}
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ The OpenFeature SDK manages provider lifecycle. ZIO OpenFeature adds scoped reso
|:------|:------------|
| `NotReady` | Provider not initialized. Evaluations fail with `ProviderNotReady` |
| `Ready` | Can evaluate flags |
| `Error` | Provider encountered a recoverable error. Evaluations still proceed (spec 1.7.6/1.7.7) — the provider serves cached values or errors on its own |
| `Error` | Provider encountered a recoverable error. Evaluations still proceed (deliberate library policy — only `NotReady`/`Fatal` fail fast) — the provider serves cached values or errors on its own |
| `Stale` | Provider data may be outdated |
| `Fatal` | Provider encountered unrecoverable error. Evaluations fail with `ProviderFatal` |

Expand Down
32 changes: 30 additions & 2 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -686,9 +686,9 @@ In every async configuration without a same-process fallback, there is a window

### Recovery semantics under `Fatal`

`Fatal` is a **terminal** "stop waiting, take operator action" signal (OpenFeature spec 1.7.6: `FATAL` is irrecoverable). The watchdog only ever escalates a provider that **never became usable** within `initTimeout`, and it shuts that provider down as it does so — so there is no live poller left to recover, and a later `PROVIDER_READY` from the same provider cannot resurrect it. To resume evaluation after a `Fatal`, install a new provider via `setProvider` (or rebuild the layer); `setProvider` is the only exit from `Fatal`.
`Fatal` is a **terminal** "stop waiting, take operator action" signal — irrecoverable, mirroring the Java SDK's own `FATAL` state. The watchdog only ever escalates a provider that **never became usable** within `initTimeout`, and it shuts that provider down as it does so — so there is no live poller left to recover, and a later `PROVIDER_READY` from the same provider cannot resurrect it. To resume evaluation after a `Fatal`, install a new provider via `setProvider` (or rebuild the layer); `setProvider` is the only exit from `Fatal`.

The transient case is handled *before* it can reach `Fatal`: a provider that reached `Ready` and later hits a recoverable `PROVIDER_ERROR` stays in `Error` (spec 1.7.6/1.7.7 keeps evaluations flowing to a provider that commonly still serves cached values), and a genuine `PROVIDER_READY` restores it to `Ready`. Only a provider that carries `ErrorCode.PROVIDER_FATAL` (mirroring the Java SDK's own `FATAL` state) goes terminal on its own.
The transient case is handled *before* it can reach `Fatal`: a provider that reached `Ready` and later hits a recoverable `PROVIDER_ERROR` stays in `Error` (this library keeps evaluations flowing to a provider that commonly still serves cached values — see the policy note under "Provider Lifecycle"), and a genuine `PROVIDER_READY` restores it to `Ready`. Only a provider that carries `ErrorCode.PROVIDER_FATAL` (mirroring the Java SDK's own `FATAL` state) goes terminal on its own.

Healthchecks that gate on `Ready`/`Stale` will see the pod un-route once a provider goes `Fatal`; because that state is terminal, treat it as a signal to replace the provider or recycle the pod rather than waiting for self-recovery.

Expand Down Expand Up @@ -881,6 +881,34 @@ eventHandler.fork
> example, diagnostic info or the source of a configuration change. Access it via the `eventMeta`
> extension method: `event.eventMeta.getString("source")`.

### Lifecycle-event emission by bundled provider

Audited against spec v0.9.0's `appendix-e-migrations.md` (#332). v0.9.0 moves lifecycle-event ownership
to providers: each should emit `PROVIDER_READY` before a successful `initialize()` returns and
`PROVIDER_ERROR` before it throws, opting in via a marker the SDK defines. **No bundled provider emits
init-time events today**, and that is deliberate — see below.

| Provider | Base | Emits at init | Emits in steady state |
|---|---|---|---|
| `OptimizelyFeatureProvider` | `EventProvider` | none — blocking init; the SDK synthesizes | `READY`/`STALE` from the staleness watchdog, `CONFIGURATION_CHANGED` on a new datafile revision |
| `TestFeatureProvider` (testkit) | `EventProvider` | none — the SDK synthesizes | full control surface: `READY`, `ERROR`, `STALE`, `CONFIGURATION_CHANGED` |
| `CircuitBreakerProvider` (extras) | `EventProvider` | none | **emits its own** `READY`/`STALE` on breaker transitions, *and* forwards the delegate's events |
| `CachingProvider` (extras) | `EventProvider` | none | forwards the delegate's events only |
| `CachingReasonProvider` (testkit) | `EventProvider` | none | **none** — extends `EventProvider` but never attaches to the delegate, so the delegate's events are dropped (same limitation as `DeferredProvider`) |
| `DeferredProvider` (extras) | `FeatureProvider` | none | none — documented limitation: wrapping an `EventProvider` does not forward its events |
| `HoconProvider` (extras) | `FeatureProvider` | none | none — static config, no lifecycle to report |
| `EnvVarProvider` (extras) | `FeatureProvider` | none | none — static config, no lifecycle to report |
| `IntegerWideningLongProvider` (extras) | `FeatureProvider` | none | none — evaluation-only wrapper; like `DeferredProvider` it does not forward a wrapped `EventProvider`'s events |
| `OFREPProvider` | n/a — factory returning the contrib provider | upstream's | upstream's |

**Why init-time emission is deferred (#340).** Until the Java SDK ships the opt-in marker it keeps
synthesizing lifecycle events, so a provider that emitted its own would produce a duplicate. The spec
calls such duplicates "expected legacy behavior" — but that judgement concerns the *SDK's* derived
status, and it does not hold here: this library's status machine absorbs a repeat `READY`, while the
event hub publishes **every** bridged event to user handlers. Duplicates would therefore reach user code.
Both halves of that asymmetry are pinned in `ProviderStatusBridgeSpec`, so the change becomes visible the
moment Phase 2 flips it.

---

## Testing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import dev.openfeature.sdk.internal.{AutoCloseableReentrantReadWriteLock, TriCon
* A delegate supports exactly one attachment; wrapping a provider takes ownership of its event channel. Registering
* the same delegate instance directly with an API while it is wrapped is unsupported (the SDK's own attach would
* fail).
*
* Phase 2 watch-point (#340): re-verify this bridge still applies once the OpenFeature Java SDK ships the spec-v0.9.0
* provider-event marker — it may change how event attachment/emission works.
*/
object EventProviderBridge {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ object AsyncInitSpec extends ZIOSpecDefault {
}.provide(asyncLayer(Map("flag" -> true)))
),
suite("Error state")(
test("evaluations proceed when a ready provider transitions to Error state (spec 1.7.6/1.7.7)") {
test("evaluations proceed when a ready provider transitions to Error state (library policy)") {
for {
tp <- ZIO.service[TestFeatureProvider]
_ <- tp.setStatus(ProviderStatus.Ready) // provider becomes ready and serves
Expand All @@ -109,7 +109,7 @@ object AsyncInitSpec extends ZIOSpecDefault {
for {
tp <- ZIO.service[TestFeatureProvider]
_ <- tp.setStatus(ProviderStatus.Ready)
_ <- tp.setStatus(ProviderStatus.Error) // transient blip — evaluations still proceed (spec 1.7.6/1.7.7)
_ <- tp.setStatus(ProviderStatus.Error) // transient blip — evaluations still proceed (library policy)
during <- FeatureFlags.boolean("flag", default = false)
_ <- tp.setStatus(ProviderStatus.Ready) // recovered
ok <- FeatureFlags.boolean("flag", default = false)
Expand Down
Loading