Skip to content

feat!: add StateManagingProvider and deprecate SDK-synthesized lifecycle events - #260

Closed
mtonko-flx wants to merge 3 commits into
open-feature:mainfrom
mtonko-flx:feat/state-managing-provider
Closed

feat!: add StateManagingProvider and deprecate SDK-synthesized lifecycle events#260
mtonko-flx wants to merge 3 commits into
open-feature:mainfrom
mtonko-flx:feat/state-managing-provider

Conversation

@mtonko-flx

Copy link
Copy Markdown

Stacked on #259 (which is stacked on #258). Base is main because the branch lives in a fork and GitHub requires a base branch in the upstream repository, so the diff shown here includes both parents' commits. Review only the last commit, feat!: add StateManagingProvider…, or merge #258 and #259 first and this diff will reduce to it.

Intent

The SDK infers provider readiness from lifecycle methods returning: it publishes Ready once initialize returns, and drives the RECONCILING/READY transitions around onContextSet itself. A provider doing work in the background can report something in that window and have the SDK's own conclusion overwrite it — the race in spec #365. This PR lets a provider declare that it reports its own lifecycle events, in which case the SDK publishes nothing of its own and reports exactly what the provider signals, in the order signalled.

Motivation

spec #385 resolves #365 by making provider status derived entirely from provider-emitted events, with an opt-in marker so existing providers keep working. Notably it was chosen over #380, which moved the status itself onto the provider: state stays in the SDK precisely so that providers are not burdened with concurrency-safe status accessors. So StateManagingProvider here carries no status accessor — the provider owns the events, the SDK still owns the status.

The Kotlin SDK is the first to implement this; the Java, JS and Go PoCs on #365 predate #385 and still follow the rejected shape. The marker keeps their name for cross-SDK recognisability despite the changed meaning; happy to revisit if the ecosystem settles on something else.

Spec Requirements

Requirement Relationship
2.8.1 — provider MUST emit an event for each status transition Satisfied for marker providers
2.8.2, 2.8.3PROVIDER_READY/PROVIDER_ERROR before initialize terminates Satisfied — closes the #365 init race
2.8.4PROVIDER_CONTEXT_CHANGED/PROVIDER_ERROR for on context changed Satisfied
5.3.4.1, 5.3.4.2, 5.3.4.3 — reconciliation events, terminal one only after the last reentrant invocation Satisfied — provider-owned; the wrapper does it for legacy providers
1.1.2.4 — wait for initialize and its resulting lifecycle event to be processed Satisfied
1.8.4 — a provider SHOULD NOT be bound to two API instances Preserved — the registry keys the registered instance, not the wrapper
2.5.2, 2.5.3 — shutdown reverts state, is idempotent Satisfied
Condition 2.8.5 Not expressible in Kotlin — initialize is abstract, so every provider defines it; the legacy path covers the behaviour
Appendix E Legacy path implemented as the deprecated compatibility path, with a registration-time warning

Changes

Implementation

  • StateManagingProvider: marker extending FeatureProvider, redeclaring observe() without a default so opting in requires a real event stream — implementing #408's advice to couple lifecycle methods with event support.
  • LegacyProviderWrapper (internal): holds all deprecated behaviour for providers without the marker — synthesising readiness and reconciliation events, and coalescing overlapping onContextSet invocations. The generation-counter arbitration previously in OpenFeatureAPIInstance moves here, so withdrawing the legacy path later is a deletion rather than an untangling.
  • Registration normalises to one lifecycle path; getProvider() unwraps so callers always get the instance they registered.
  • setProviderAndWait settles on the provider's reported event rather than on initialize returning.
  • Registering a provider without the marker logs a deprecation warning.

Mixed mode

Appendix E permits duplicate events where a legacy provider also emits its own. Its sanction covers the derived status, which absorbs a repeat — but the event stream would deliver both to application handlers, which on main it does not. So the wrapper stands aside when the provider has already reported a transition itself. Everything the wrapper publishes goes through one queue with a single consumer, so forwarded and synthesised events keep a total order rather than racing.

Usage Examples

class MyProvider : StateManagingProvider {
    private val events = MutableSharedFlow<OpenFeatureProviderEvents>(replay = 1)
    override fun observe(): Flow<OpenFeatureProviderEvents> = events

    override suspend fun initialize(initialContext: EvaluationContext?) {
        try {
            connect()
            events.emit(OpenFeatureProviderEvents.ProviderReady())
        } catch (e: Exception) {
            events.emit(OpenFeatureProviderEvents.ProviderError(/**/))
            throw e
        }
    }

    override suspend fun onContextSet(old: EvaluationContext?, new: EvaluationContext) {
        events.emit(OpenFeatureProviderEvents.ProviderReconciling())
        reconcile(new)
        events.emit(OpenFeatureProviderEvents.ProviderContextChanged())
    }
}

Testing

  • A marker provider's reported status is what the SDK publishes, and readiness reported during initialize is not overwritten afterwards — the #365 regression test.
  • A reported PROVIDER_FATAL becomes FATAL, not ERROR.
  • Provider-owned reconciliation produces exactly RECONCILINGCONTEXT_CHANGED, with nothing added by the SDK.
  • A legacy provider emitting its own readiness yields one event, not two; a silent one also yields exactly one.
  • getProvider() returns the registered instance, never the wrapper.
  • A provider whose initialize threw can be registered again and succeed; shutdown is repeatable.
  • All five pre-existing reconciliation race tests still pass against the wrapper, unchanged in intent.
  • 251 tests, ktlintCheck and apiCheck green, apiDump regenerated (LegacyProviderWrapper stays internal).

A note on the test diff

Two consequences are worth knowing when reading it. Status is now observable only once the event carrying it has been dispatched, so tests asserting status straight after a gated lifecycle call have to let the dispatcher run. And registration settling on an event means a collector started alongside it observes NOT_READY first.

Breaking Changes

Providers wanting to own their lifecycle events implement StateManagingProvider; the SDK synthesising them is deprecated but still the default for existing providers, so no provider needs changing yet. setProviderAndWait now waits for the provider's lifecycle event rather than for initialize to return — a provider that returns without reporting anything is in breach of 2.8.2, and the SDK logs that and keeps waiting rather than substituting a status, since substituting one is what caused the original race. Applications needing a bound can impose their own with withTimeout.

mtonko-flx and others added 3 commits August 28, 2026 12:09
Spec requirement 5.1.1 requires the provider event set to include
PROVIDER_RECONCILING and PROVIDER_CONTEXT_CHANGED, and 5.2.3 requires event
details to carry the provider name. Neither existed, so the SDK could not
represent context reconciliation as events nor attribute an event to its
provider.

Signed-off-by: Mark Tonkonoh <mark.tonkonoh@fluxon.com>
BREAKING CHANGE: statusFlow is now StateFlow<OpenFeatureStatus> and conflates
consecutive equal values.

Signed-off-by: Mark Tonkonoh <mark.tonkonoh@fluxon.com>
…cle events

The SDK inferred provider readiness from lifecycle methods returning: it published
Ready once initialize returned, and drove the Reconciling/Ready transitions around
onContextSet itself. A provider doing work in the background could report something
in that window and have the SDK's own conclusion overwrite it.

Providers can now declare, by implementing StateManagingProvider, that they report
their own lifecycle events. For those the SDK publishes nothing of its own and
reports exactly what the provider signals, in the order signalled. Note the marker
carries no status accessor: the provider owns the events, the SDK still owns the
status.

Providers that do not implement it keep working unchanged. Everything the SDK used
to do for all providers now lives in LegacyProviderWrapper, installed for them at
registration and nowhere else, so withdrawing the behavior later is a deletion
rather than an untangling. That includes coalescing overlapping context
reconciliations, which moved out of the API instance along with its generation
counters. Where such a provider already reports a lifecycle event itself, the
wrapper stands aside instead of reporting the same transition twice. Registering a
legacy provider logs a deprecation warning.

setProviderAndWait now settles on the provider's reported event rather than on
initialize returning. A provider that returns from initialize without reporting
anything is in breach of its contract; the SDK logs that and keeps waiting rather
than substituting a status, since substituting one is what caused the original
race. Applications needing a bound can impose their own.

Two consequences worth noting for anyone reading the test diff. Status is now
observable only once the event carrying it has been dispatched, so tests asserting
status straight after a gated lifecycle call have to let the dispatcher run.
Registration settling on an event also means a collector started alongside it
observes NotReady first.

SpyProvider gained real metadata: provider names are now read during registration
to attribute events, and a test double throwing from metadata broke that.

BREAKING CHANGE: providers wanting to own their lifecycle events implement
StateManagingProvider; the SDK synthesizing them is deprecated. setProviderAndWait
waits for the provider's lifecycle event rather than for initialize to return.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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

@mtonko-flx

Copy link
Copy Markdown
Author

Superseded by #262, which delivers this work as a single change. Closing the stack.

@mtonko-flx mtonko-flx closed this Aug 28, 2026
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.

1 participant