Skip to content

feat: report STALE reason when a sync source is disconnected - #2017

Open
scottt732 wants to merge 1 commit into
open-feature:mainfrom
scottt732:feat/stale-reason-on-disconnected-sync
Open

feat: report STALE reason when a sync source is disconnected#2017
scottt732 wants to merge 1 commit into
open-feature:mainfrom
scottt732:feat/stale-reason-on-disconnected-sync

Conversation

@scottt732

Copy link
Copy Markdown

Closes #400.

The problem

When a sync source goes offline, flagd keeps serving the flags it already holds. That is the right call — last-known-good data beats failing evaluations open. But until now nothing distinguished those values from live ones:

  • evaluations keep returning STATIC / TARGETING_MATCH indefinitely
  • /readyz stays 200, documented to latch on the first successful sync and "not change from there on"
  • no metric exposes the condition — feature_flag.flagd.sync.active_streams counts inbound subscribers, not the health of flagd's own sync client

Behind a load balancer this is genuinely hard to diagnose. We hit it in production: one replica of eight lost its stream, kept serving a flag document from hours earlier, and stayed in the Service endpoints because every health signal said it was fine. Successive requests to the same Service returned different values for the same flag, and nothing indicated which answer was current.

This implements the behaviour agreed in #400, where STALE was subsequently added to the OpenFeature specification for exactly this case.

The change

  • sync.DataSync gains a Stale field, marking a connection-state notification rather than a flag payload. FlagData is ignored and the store is left untouched.
  • The gRPC sync emits one when its stream drops — both on initial failure and on each failed re-establishment — and stops once a payload arrives. The send is best-effort against the context so a blocked runtime can never stall the reconnect loop.
  • store.SourceState records which sources are disconnected. It is nil-safe: a nil value reports nothing stale, so embedders that never wire it up keep the previous behaviour exactly.
  • The runtime marks a source stale on such a notification and clears it on the next successful payload. It deliberately does not Emit — no flag data changed, and the sync protocol has no way to convey staleness downstream.
  • The evaluator reports model.StaleReason for flags resolved from a disconnected source, wired via a new evaluator.WithSourceState option.

STALE only ever replaces a successful resolution. ERROR is preserved — a failed evaluation has no value to be uncertain about — and FALLBACK is left alone because it carries internal meaning that is translated in the API response.

I implemented this only for the gRPC sync, since that is the push-based case where a silently dead stream is invisible. The same DataSync.Stale mechanism extends to the other syncs without further plumbing if maintainers would like that in this PR or a follow-up.

Why this shape

Because reasons already surface on the feature_flag.flagd.result.reason metric, a disconnected replica becomes visible per pod with no new telemetry — just a new value on an existing attribute. It also reaches SDKs through a spec-defined reason rather than anything flagd-specific.

Testing

New: core/pkg/store/source_state_test.go (including nil-receiver and concurrency) and core/pkg/evaluator/stale_test.go (reason replacement, error preservation, source isolation, recovery, and the unconfigured no-op case).

Two existing gRPC sync tests asserted the data channel was empty after a stream ended, which is no longer true now that a stale notification is emitted; they now assert no further flag payloads arrive, and Test_SyncRetry additionally asserts the stale notification is emitted. While there, I fixed a latent err.Error() on a nil err in that test which turned an assertion failure into a segfault.

Full core and flagd suites pass.

Verified end to end with two real flagd instances, one syncing from the other:

1. connected:          {"value":true,"reason":"STATIC"}
2. source killed:      {"value":true,"reason":"STALE"}    <- value still served
3. source restarted:   {"value":true,"reason":"STATIC"}   <- recovered

Happy to adjust the naming, the FALLBACK carve-out, or split the test fixes into their own PR if you'd prefer.

Closes open-feature#400.

When a sync source goes offline, flagd keeps serving the flags it already
holds -- deliberately, since last-known-good data beats failing evaluations
open. Until now nothing distinguished those values from live ones: a flagd
whose sync stream had dropped kept answering STATIC/TARGETING_MATCH
indefinitely, /readyz stayed 200 (documented to latch on first successful
sync and never change), and no metric exposed the condition. Behind a load
balancer, one disconnected replica among healthy ones returns different
values for the same flag on successive requests, with nothing to indicate
which answer is current.

This implements the behaviour agreed in open-feature#400, where STALE was added to the
OpenFeature specification for exactly this case.

- sync.DataSync gains a Stale field. It marks a connection-state
  notification rather than a flag payload; FlagData is ignored and the store
  is left untouched.
- The gRPC sync emits one when its stream drops, both on the initial failure
  and on each failed re-establishment, and stops once a payload arrives.
  The send is best-effort against the context so a blocked runtime can never
  stall the reconnect loop.
- store.SourceState records which sources are currently disconnected. A nil
  value reports nothing stale, so embedders that do not wire it up keep the
  previous behaviour exactly.
- The runtime marks a source stale on such a notification and clears it on
  the next successful payload. It does not Emit: no flag data changed, and
  the sync protocol cannot convey staleness downstream.
- The evaluator reports model.StaleReason for flags resolved from a
  disconnected source, wired via the new evaluator.WithSourceState option.

STALE only replaces a successful resolution. Errors keep ERROR -- a failed
evaluation has no value to be uncertain about -- and FALLBACK is left alone
because it carries internal meaning translated in the API response.

Because reasons already surface on feature_flag.flagd.result.reason, this
makes a disconnected replica visible per pod with no new telemetry.

Verified end to end against two flagd instances: STATIC while connected,
STALE with the value still served once the source is killed, and STATIC
again on reconnect.

Signed-off-by: Scott Holodak <scottt732@gmail.com>
@scottt732
scottt732 requested review from a team as code owners August 12, 2026 20:42
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Aug 12, 2026
@netlify

netlify Bot commented Aug 12, 2026

Copy link
Copy Markdown

Deploy Preview for polite-licorice-3db33c canceled.

Name Link
🔨 Latest commit c2279be
🔍 Latest deploy log https://app.netlify.com/projects/polite-licorice-3db33c/deploys/6a7cdabb4c2ec50008f1bbe8

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The evaluator now tracks per-source connection state. gRPC sync emits stale notifications after stream failures. Runtime preserves stored flag data during disconnection, and successful evaluations use STALE until the source recovers.

Changes

Stale source tracking

Layer / File(s) Summary
Source state and notification contracts
core/pkg/store/source_state.go, core/pkg/store/source_state_test.go, core/pkg/model/reason.go, core/pkg/sync/isync.go
Adds concurrency-safe per-source stale tracking, the STALE evaluation reason, and DataSync.Stale. Tests cover isolation, clearing, nil receivers, and concurrent access.
gRPC stale notifications
core/pkg/sync/grpc/grpc_sync.go, core/pkg/sync/grpc/grpc_sync_test.go
Emits source-only stale notifications after stream failures. Tests validate stale notifications during cleanup and reconnection.
Runtime source-state processing
flagd/pkg/runtime/from_config.go, flagd/pkg/runtime/runtime.go
Creates and shares SourceState. Stale payloads skip evaluator updates, while successful payloads clear stale state after updating flags.
Stale evaluation reasons
core/pkg/evaluator/json.go, core/pkg/evaluator/stale_test.go
Reports STALE for successful evaluations from stale sources. Errors and fallback reasons remain unchanged. Tests cover recovery, source isolation, and unconfigured evaluators.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant gRPCSync
  participant Runtime
  participant SourceState
  participant JSONEvaluator
  gRPCSync->>Runtime: Send stale DataSync
  Runtime->>SourceState: Mark source stale
  Runtime->>JSONEvaluator: Preserve existing flag data
  JSONEvaluator-->>Runtime: Evaluate retained flag
  Runtime-->>gRPCSync: Return STALE reason
  gRPCSync->>Runtime: Send successful payload
  Runtime->>JSONEvaluator: Update flag data
  Runtime->>SourceState: Clear source stale state
Loading

Suggested reviewers: toddbaert, aepfli

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: reporting STALE when a sync source disconnects.
Description check ✅ Passed The description explains the stale-source problem, implementation, behavior, scope, and testing.
Linked Issues check ✅ Passed The changes satisfy issue #400 by tracking disconnected sources and reporting STALE for retained flag values.
Out of Scope Changes check ✅ Passed The implementation and tests remain focused on stale-source tracking, evaluation reasons, gRPC notifications, and recovery behavior.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@sonarqubecloud

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/pkg/sync/grpc/grpc_sync.go`:
- Around line 169-178: Update notifyStale to include a default select branch so
a full dataSync channel causes the stale notification to be dropped immediately,
while preserving delivery when space is available and cancellation handling. Add
a regression test that invokes notifyStale with a full dataSync channel and
verifies it returns without blocking.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ac389090-10fb-4c4e-905b-f491de988d36

📥 Commits

Reviewing files that changed from the base of the PR and between 5c74b83 and c2279be.

📒 Files selected for processing (10)
  • core/pkg/evaluator/json.go
  • core/pkg/evaluator/stale_test.go
  • core/pkg/model/reason.go
  • core/pkg/store/source_state.go
  • core/pkg/store/source_state_test.go
  • core/pkg/sync/grpc/grpc_sync.go
  • core/pkg/sync/grpc/grpc_sync_test.go
  • core/pkg/sync/isync.go
  • flagd/pkg/runtime/from_config.go
  • flagd/pkg/runtime/runtime.go

Comment on lines +169 to +178
// notifyStale tells the runtime that this source is disconnected, so evaluations
// served from its flags can be reported as stale. Flags stay in the store; only
// the reported reason changes. The send is best-effort: a blocked or cancelled
// runtime must never stall the reconnection loop.
func (g *Sync) notifyStale(ctx context.Context, dataSync chan<- sync.DataSync) {
select {
case dataSync <- sync.DataSync{Source: g.URI, Stale: true}:
case <-ctx.Done():
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Prevent stale notification delivery from blocking reconnects.

The select blocks when dataSync is full and ctx remains active. This stops the retry loop after a stream failure. Add a default branch so delivery is actually best-effort. Add a regression test with a full dataSync channel.

Proposed fix
 func (g *Sync) notifyStale(ctx context.Context, dataSync chan<- sync.DataSync) {
 	select {
 	case dataSync <- sync.DataSync{Source: g.URI, Stale: true}:
 	case <-ctx.Done():
+	default:
 	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// notifyStale tells the runtime that this source is disconnected, so evaluations
// served from its flags can be reported as stale. Flags stay in the store; only
// the reported reason changes. The send is best-effort: a blocked or cancelled
// runtime must never stall the reconnection loop.
func (g *Sync) notifyStale(ctx context.Context, dataSync chan<- sync.DataSync) {
select {
case dataSync <- sync.DataSync{Source: g.URI, Stale: true}:
case <-ctx.Done():
}
}
// notifyStale tells the runtime that this source is disconnected, so evaluations
// served from its flags can be reported as stale. Flags stay in the store; only
// the reported reason changes. The send is best-effort: a blocked or cancelled
// runtime must never stall the reconnection loop.
func (g *Sync) notifyStale(ctx context.Context, dataSync chan<- sync.DataSync) {
select {
case dataSync <- sync.DataSync{Source: g.URI, Stale: true}:
case <-ctx.Done():
default:
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/pkg/sync/grpc/grpc_sync.go` around lines 169 - 178, Update notifyStale
to include a default select branch so a full dataSync channel causes the stale
notification to be dropped immediately, while preserving delivery when space is
available and cancellation handling. Add a regression test that invokes
notifyStale with a full dataSync channel and verifies it returns without
blocking.

@aepfli

aepfli commented Aug 13, 2026

Copy link
Copy Markdown
Member

I am not sure that a stal reason is the right approach. I see the problem, and this is definitely something we should help/support/make easier to find. But the reason of the flag evaluation is not STALE. This is the data state and not the evaluation outcome, and i feel like we are mixing domains. Still I have no better solution, but I feel like we should maybe think more carefully.

@scottt732

Copy link
Copy Markdown
Author

Thanks, @aepfli. Happy to rework the PR if you have any alternative approaches. In our case, one replica of eight lost its stream, kept serving a flag document from hours earlier, and stayed in the Service endpoints because every health signal said it was fine.

I stumbled on #400 which pointed out that STALE is now available as a resolution reason in the openfeature spec. But there are two distinct staleness conditions:

  1. Client lost its connection to flagd - the JS provider detects this and emits STALE.
  2. flagd lost its connection to its own upstream - what my PR tries to address here.

An in-process client can see (1) but is completely blind to (2). If our flagd's upstream sync breaks, every pod using it keeps serving rules downloaded hours earlier, perfectly happily, with no signal available to it at any layer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Detect stale flags

2 participants