Skip to content

feat: feature flags on OpenFeature — v26.10.01 - #180

Merged
ancongui merged 87 commits into
mainfrom
feat/feature-flags
Oct 7, 2026
Merged

ancongui merged 87 commits into
mainfrom
feat/feature-flags

Conversation

@ancongui

@ancongui ancongui commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Adds optional OpenFeature feature flags with one shared evaluation contract for PyFly and LaraFly, including deterministic targeting and percentage rollouts. Flags default to disabled and support configuration, watched JSON/YAML, HTTP synchronization, and audited relational writes with optimistic concurrency.

The facade, decorators, routes, templates, actuator, dashboard, and CLI use the same effective definitions. Management preview suppresses evaluation telemetry; accepted writes that are not yet locally visible return a pending receipt. Shared conformance covers evaluation, context, overrides, HTTP conditions, management, and transaction behavior.

Includes a complete guide and public contract, tested Lumen examples, and updated English and Spanish books. The release workflow builds PDF/EPUB assets from the tag and attaches a source-commit/hash manifest. Package version is 26.10.1; release tag is v26.10.01. Distribution remains GitHub Releases.

Validation and review

  • Independent task, integration, and final whole-branch reviews completed; the single final runtime fix wave was independently approved. The later shared data/documentation clarification and its normal integration merge were independently approved (105 focused tests on the merge).
  • Final e9ef88f local gates: Python 3.12 and 3.13 each 8,998 passed, 6 skipped and 2,684 deselected; Ruff, format, strict mypy (817 source files), strict MkDocs, 14 book tests, distribution build and six installed-wheel checks passed. Both listing validators passed (31 files per edition). Fresh English (1,008 pages) and Spanish (1,062 pages) PDF/EPUB assets passed source-commit and checksum validation for e9ef88f. The release workflow rebuilds the four assets from the actual tag.
  • Full runtime gate at 07bfdd0, unchanged by the final five data/documentation/test-pin paths: SQLAlchemy 2.0.54 whole suite 8,990 passed / 11 skipped; PostgreSQL 948, MySQL 671, MariaDB 634, MongoDB 339, integration directory 2,564; SQLAlchemy 2.0 PostgreSQL 948; browser 3, Lumen 48 / 2 skipped and native catalog sample 1. These results are scoped to that runtime-equivalent revision.
  • Actual cross-runtime PostgreSQL round trips exercised both schema owners, bidirectional writes, conflicts, audit rows, and HTTP sync: 1,505 evaluations in each direction.
  • All 25 shared conformance files and both public contract pages were reverified byte-identical. The three new shared cases preserve valid JSON member names; fresh cross-runtime SQL and HTTP proof also covered leading U+0000, nested objects/lists and float values.

Compatibility

  • The optional feature-flags extra is included in full; applications enable the subsystem explicitly.
  • Applications including framework_metadata in Alembic target metadata will see the two shared flag tables in autogeneration.
  • Native database conflicts may require retrying the entire caller transaction. Last-good definitions remain active after source outages.
  • Framework-specific APIs, lifecycle limits, and operational constraints are documented in the feature-flags guide.

Implementation adaptations

  1. Template helpers are supplied through TemplateContextProcessor for ModelAndView; direct engine rendering does not receive them automatically.
  2. OpenFeatureBinding owns the gate slot in both built-in and external-provider modes.
  3. Existing exception mappings supply the configured 403/404/503 disabled-route status through dedicated subclasses.
  4. The OpenFeature API is process-global. Provider/client domain binding and its default-provider fallback are documented; applications sharing a process need distinct domains.
  5. Shared tables retain the firefly_ prefix and use exact keys, UTC timestamps without a zone and microsecond precision; MySQL/MariaDB document storage uses LONGTEXT. This supersedes the original physical TEXT/timestamp(0) planning sketch.
  6. Both web adapters mount sync on the application server through their existing config-server integration pattern.
  7. Poll durations use PyFly's existing numeric/unit syntax, without ISO-8601 support.
  8. External providers are discovered through AbstractProvider, because the SDK's provider protocol is not usable by the existing missing-bean condition.
  9. Provider/conformance groundwork preceded definition/composition work to make the first test gate executable.
  10. Packaging and the auto-configuration entry point were introduced together; final discovery is integrated and tested.
  11. HTTP tests run ASGI requests in the application's event loop to preserve lifecycle, pool and polling-task ownership.

The public contract captures the final shared interpretations: enabled-source ordering, UTC management timestamps, unknown-key errors, explicit-context telemetry-free preview, change origins, caller-default gating, trusted actor provenance, and strict object-shaped CLI/HTTP context input. Historical planning text is superseded by that published contract and the shipped guide.

…ff the loop and name the file in syntax errors
FlagdCore refuses an empty metadata key with 'key must not be empty' and
would refuse the whole composed document, and JSON carries neither NaN nor
infinity, which YAML reads from .nan and .inf. Both are now validation
errors with the contract's messages: 'metadata keys must not be empty' for
a flag's metadata or the document metadata, 'numbers must be finite' for
a flag (anywhere in its definition), an evaluator (key $evaluators, the
reason names it) or the document metadata.
FlagdCore substitutes $refs textually with the rule's JSON as an re.sub
template, so a backslash in an evaluator was re-escaped ("C:\\temp"
became a tab, "a\\d" refused the whole document), and its single pass
never resolved a reference an evaluator brings in.

The provider now expands every flag's targeting on the parsed document,
transitively and whatever the names sort as, and hands FlagdCore a
document without $evaluators. A missing name or a cycle stays an
unresolved {"$ref": ...}, which FlagdCore evaluates as PARSE_ERROR. A
flag whose targeting would expand to more than 10 000 JSON values is
counted with a bounded budget, never expanded, and made to reference an
evaluator that does not exist, so it too is a PARSE_ERROR. provider.document
keeps the document as pushed, $evaluators included.
…ct delta

A byte-identical copy of the shared conformance folder (firefly-vectors.json
sha256 96ff5ccd...). It adds the validate vector 'flag metadata keys must
not be empty' and eight evaluate vectors: backslashes kept inside an
evaluator, transitive references whatever the names sort as, a reference
cycle, and a fan-out under and over the 10 000-value expansion limit.
Counts: normalize 5, validate 20, compose 4, expiry 1, evaluate 46, bucket 5.
…rden refusals

- A boot composition the provider refuses now fails FlagRegistry.start()
  with FeatureFlagsError naming the provider's error (spec 4.5); nothing is
  published and the provider keeps its empty document.
- A test-override change the provider refuses is rolled back (overrides
  and the attribution flag) and raised as FeatureFlagsError to the test.
- A source whose document was refused keeps the refusal as its reason:
  a later failed load followed by "unchanged" reports the refusal again
  instead of the timeout text.
- A refusal blamed on a source also logs feature_flag_source_failed at
  WARNING with the source name, as load failures do (spec 4.9).
- One change signal: FireflyFlagProvider.update takes changed_keys and
  PROVIDER_CONFIGURATION_CHANGED carries exactly those keys; the registry
  passes the keys FeatureFlagsChanged carries. The return value is still
  FlagdCore's own diff.
- The registry's evaluator dependencies follow $refs in targeting only,
  as the provider now expands them.
- DocumentSource moves to tests/feature_flags/support.py, and tests now
  cover the test-overrides event drained by stop(), removed keys,
  refresh_all, has_source, unknown sources and the last_refresh rollback.
Contract rule (spec 4.1): a flag whose expanded targeting nests deeper than
128 levels is a PARSE_ERROR for that flag only. The targeting object is
level 1, each object or array inside a container adds one, and a resolved
$ref takes the place of its {"$ref": ...} object without adding a level.

The 10 000-value budget and the depth limit are decided in one bounded
pass. A reference to a reference is followed in a loop and recursion stops
at a container past level 128, so a long chain of evaluators can no longer
exhaust the stack: a 400-evaluator chain used to make update() raise
RecursionError, which refused the whole document and left the source
STALE. Only a flag within both limits is expanded, and the expansion
recurses at most 128 levels.

The conformance folder is re-vendored (firefly-vectors.json 6199394...,
evaluate 48: deep-chain-short and deep-chain-long).
…refuse over-deep definitions

- Every $ref resolved while expanding a flag's targeting now costs one unit
  of the 10 000-value budget (R-ref-budget), and the counting pass follows
  a chain of references only as far as the budget goes. A chain of
  references to references used to cost nothing, so each of up to 10 000
  occurrences could walk an arbitrarily long chain: the review's 78 KB
  probe (2 000 chained evaluators, a 14-level fan-out over the chain end,
  20 flags) blocked update() for 14 s; it now returns in about 30 ms.
- A definition nested deeper than Python's stack lets parse_document copy
  it is refused with FlagDefinitionError(<flag key>, "definition nests too
  deeply"), "$evaluators" naming the evaluator, instead of a
  RecursionError; the finite-number walk uses a stack of its own.
- What a reference is (one key, $ref, a text value) lives in the private
  module pyfly.feature_flags._references, shared by the provider and the
  registry, so the registry no longer counts {"$ref": x, "other": 1} as a
  dependency.
…incipal tenant win, and pin the transaction-context restore
…er a failed start or a cancelled stop, and evaluate typed getters with their own type
…rough client hooks

MetricsHook counts feature_flag_evaluations_total{flag, variant, reason} (variant none without one, an error as reason ERROR) and ExposureEventHook publishes FeatureFlagEvaluated on the running loop, on the application's loop from a worker thread, or through a short-lived loop. Both skip the management preview, never change an evaluation (failures are logged at DEBUG) and the exposure hook keeps its scheduled publishes for drain().
… counter creation, publish no variant for an error, and make the drain tests able to fail

The hooks and their tests carried a 12-line Apache header without the closing line. MetricsHook now creates its counter under a lock (the registry's counter() is check-then-create), ExposureEventHook publishes variant None when the evaluation failed (as the metric counts it), and the two dedicated drain tests give the drain task real chances to run, so a no-op drain fails them.
…ator vectors)

Byte-identical copy of the shared conformance folder (firefly-vectors.json sha256 721e54ad...). The vectors are one case per line now and gain three validate cases: a non-object evaluator rule reported under $evaluators, a 301-level targeting rejected as nesting too deeply, and a 201-level one that is valid. Two of them fail until the validation change that follows.
…uator rules under $evaluators

A definition nesting more than 256 levels anywhere (the flag object is level 1,
each object or array inside adds one; an evaluator rule's own object is level 1)
is invalid with `definition nests too deeply`. The check is one iterative walk
with an explicit stack that ends at the first container past the limit and
reports depth before non-finite numbers, so a 5,000-level or self-containing
(YAML anchor) document is refused cheaply instead of hitting a RecursionError.

parse_document no longer recurses at all: the YAML date and int-key conversion is
iterative and builds the copy that is stored (no deepcopy, no RecursionError
guard). A valid definition of up to 256 levels needs about 520 frames in the
copies made downstream (composition, provider), well inside Python's default.

A non-object evaluator rule is now reported under key `$evaluators` with
`targeting must be an object (evaluator '<name>')`, as the shared vectors and
LaraFly do.
… pyfly.feature-flags.enabled

FeatureFlagsProperties binds pyfly.feature-flags.* (every default lives in
pyfly-defaults.yaml) and refuses an invalid setting at startup with a ValueError
naming the full key. An enabled polling source (file, http, store) refuses a
refresh-interval that is not greater than 0: the registry polls only a positive
interval, so 0 would silently mean "never refresh".

FeatureFlagsAutoConfiguration wires the Firefly provider (or none, when the
application declares its own OpenFeature provider), the registry of sources,
the ambient evaluation context (trust-tenant-header defaults to false), the
transaction context filter, the framework's client, the facade and the binding.
The metrics hook is always attached and finds the MetricsRegistry at
evaluation time; the exposure hook is attached only with events.evaluations.
Several provider beans, a source that must load and cannot, and a refused boot
composition each fail the context's start.

pyfly.feature_flags exports its public names lazily (PEP 562), FeatureFlagsError
included, so the package imports without the feature-flags extra.
…ext is never a placeholder

Config.bind resolves the ${...} placeholders of the whole section it binds, which
included the verbatim flags/evaluators maps: a valid string variant such as
"Hello ${name}" failed startup with "Cannot resolve placeholder", without naming
the flag, and there is no escape syntax. FeatureFlagsProperties now binds from a
copy of the configuration whose pyfly.feature-flags section has no flags or
evaluators; the settings still resolve their own placeholders and take their
PYFLY_FEATURE_FLAGS_* overrides, and the config source keeps reading the maps
verbatim.

The tests also pin that the facade receives the registry, that an application
provider still gets the gating slot and the Firefly hooks, and that the tenant
test is immune to PYFLY_PROFILES_ACTIVE; the auto-configuration docstring states
the actual bean creation order.
…flag auto-configuration

An application booted the canonical way (create_app, then the context started in
the lifespan) uses flags from configuration and a watched file. A request
authenticated by a header reaches the facade and a plain OpenFeature client with
the same ambient context (roles, and naive and offset-aware date-time attributes
evaluated as epoch milliseconds), a flag variant whose text looks like a
placeholder is served verbatim, the file source propagates a change as one
FeatureFlagsChanged, the exposure events and the evaluation metric count only the
framework client's evaluations, and stopping the context restores the SDK and the
gating slot.
…nd empty-default vectors)

A byte-identical copy of the shared conformance folder (firefly-vectors.json
sha256 8986ef44...5aa5f92: validate 27, evaluate 53). New vectors: an empty
defaultVariant naming a "" variant is valid and evaluates as no default (the
caller's default, reason DEFAULT), and two authored cases pin that a float
request and an integer request on a boolean flag are TYPE_MISMATCH. The float
case fails until the provider refuses a boolean for a float request.
# Conflicts:
#	src/pyfly/feature_flags/auto_configuration.py
@ancongui
ancongui merged commit bb65c1b into main Oct 7, 2026
11 checks passed
@ancongui
ancongui deleted the feat/feature-flags branch October 7, 2026 06:21
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