open-feature / open-feature/flagd

`/readyz` reports ready before flags are evaluable

Open
#2,047 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Go
Stars
997
Forks
136
Avg merge
4d 8h
Merged PRs (30d)
11

Description

Summary

flagd's readiness probe returns 200 while the flag store is still empty, so an evaluation made immediately after /readyz first succeeds gets FLAG_NOT_FOUND for a flag the configuration plainly defines. Measured at 100% of starts with a realistic flag set, with a window of ~14ms (valid 30KB payload) to ~51ms (30KB payload containing entries that fail schema validation).

To be precise about what is and is not being claimed: the monitoring docs say 200 means "all sync providers at least have one successful data sync", and the code literally satisfies that — the sync provider really has fetched and delivered the data. So this is not code violating its documented contract. The claim is that those semantics are the wrong ones for a readiness probe, because "delivered" is not "applied".

Mechanism

core/pkg/sync/file/filepath_sync.go:

func (fs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error {
	...
	fs.sendDataSync(ctx, dataSync)   // only pushes onto the channel
	fs.setReady(true)                // IsReady() is now true -> /readyz 200

sendDataSync ends in dataSync <- sync.DataSync{...}, and in flagd/pkg/runtime/runtime.go that channel is

dataSync := make(chan sync.DataSync, len(r.Syncs))

buffered to exactly the number of sources. So every source can hand its payload to the buffer and call setReady(true) without a single payload having been received. Runtime.isReady() only asks each sync IsReady(), so /readyz flips to 200 at that point.

What actually makes a flag evaluable happens afterwards, on the separate goroutine draining the channel:

case data := <-dataSync:
	r.updateAndEmit(data)     // -> r.Evaluator.SetState(payload) : parse, validate, swap store

The window is therefore the cost of parsing and validating the payload, which is why it scales with flag-set size and is invisible in a trivial test fixture.

Reproduction

No provider and no test harness involved — plain flagd, one file source, tight-polling /readyz and evaluating over OFREP the instant it returns 200.

flags.json — 201 trivial, schema-valid flags (~30KB), generated with:

import json
flags = {"flag-%03d" % i: {"state": "ENABLED",
                           "variants": {"on": True, "off": False},
                           "defaultVariant": "on"} for i in range(200)}
flags["boolean-flag"] = {"state": "ENABLED",
                         "variants": {"on": True, "off": False},
                         "defaultVariant": "on"}
json.dump({"flags": flags}, open("flags.json", "w"), indent=2)
docker run -d --name flagd-repro -p 8014:8014 -p 8016:8016 \
  -v "$PWD/flags.json:/flags.json:ro" \
  ghcr.io/open-feature/flagd:v0.16.0 start --uri file:/flags.json

# poll /readyz in a tight loop until 200, then immediately:
curl -s -X POST -H 'Content-Type: application/json' -d '{}' \
  http://localhost:8016/ofrep/v1/evaluate/flags/boolean-flag
# {"key":"boolean-flag","errorCode":"FLAG_NOT_FOUND","errorDetails":"flag `boolean-flag` does not exist","metadata":{}}

The curl above has to win a ~14ms race against a process start, so it is unreliable by hand. This runs the whole thing in a loop and reports a rate:

flagd_repro.go — stdlib only, go run flagd_repro.go 15
// Minimal reproduction: flagd's /readyz reports 200 before flags are evaluable.
package main

import (
	"bytes"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"sort"
	"strconv"
	"time"
)

const (
	image     = "ghcr.io/open-feature/flagd:v0.16.0"
	container = "flagd-readyz-repro"
	flagKey   = "boolean-flag"

	readyzURL = "http://localhost:8014/readyz"
	ofrepURL  = "http://localhost:8016/ofrep/v1/evaluate/flags/" + flagKey
)

func main() {
	runs := 20
	if len(os.Args) > 1 {
		runs, _ = strconv.Atoi(os.Args[1])
	}

	client := &http.Client{Timeout: 2 * time.Second}
	var windows []time.Duration
	raced := 0

	for i := 1; i <= runs; i++ {
		stopContainer()
		if err := startContainer(); err != nil {
			fmt.Printf("%-4d could not start flagd: %v\n", i, err)
			continue
		}

		// Tight-poll /readyz so we observe the 200 the instant it happens.
		for {
			resp, err := client.Get(readyzURL)
			if err == nil {
				resp.Body.Close()
				if resp.StatusCode == http.StatusOK {
					break
				}
			}
		}
		ready := time.Now()

		notFound := 0
		var window time.Duration
		for {
			body, err := evaluate(client)
			elapsed := time.Since(ready)
			if err == nil && !bytes.Contains(body, []byte("FLAG_NOT_FOUND")) {
				window = elapsed
				break
			}
			notFound++
			if elapsed > 10*time.Second {
				window = elapsed
				break
			}
		}

		status := "ok"
		if notFound > 0 {
			status = "RACED"
			raced++
			windows = append(windows, window)
		}
		fmt.Printf("%-4d %-6s flag_not_found_responses=%-5d evaluable_after=%v\n", i, status, notFound, window)
	}
	stopContainer()

	fmt.Printf("\nflagd %s\n", image)
	fmt.Printf("runs=%d  runs_where_/readyz_200_preceded_evaluability=%d (%.0f%%)\n",
		runs, raced, 100*float64(raced)/float64(runs))
	if len(windows) > 0 {
		sort.Slice(windows, func(a, b int) bool { return windows[a] < windows[b] })
		fmt.Printf("window: min=%v median=%v max=%v\n",
			windows[0], windows[len(windows)/2], windows[len(windows)-1])
	}
}

func evaluate(c *http.Client) ([]byte, error) {
	resp, err := c.Post(ofrepURL, "application/json", bytes.NewReader([]byte("{}")))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	return io.ReadAll(resp.Body)
}

func startContainer() error {
	wd, _ := os.Getwd()
	cmd := exec.Command("docker", "run", "-d", "--name", container,
		"-p", "8014:8014", "-p", "8016:8016",
		"-v", wd+"/flags.json:/flags.json:ro",
		image, "start", "--uri", "file:/flags.json")
	out, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("%v: %s", err, out)
	}
	return nil
}

func stopContainer() {
	_ = exec.Command("docker", "rm", "-f", container).Run()
}
Results — flagd v0.16.0, OFREP on 8016, Docker on Linux
payload runs runs where /readyz 200 preceded evaluability window (min / median / max)
1 flag, ~200 bytes 20 0 (0%) not observable
201 valid flags, 30KB 15 15 (100%) 11.9ms / 13.8ms / 23.0ms
flagd-testbed's own flag set, 30KB, contains entries that fail schema validation 20 20 (100%) 43.5ms / 51.4ms / 85.5ms

The single-flag row is the control: the race is structural, but the window is sub-millisecond when there is nothing to parse — which is why a minimal fixture will not show it, and probably why this has gone unnoticed.

It is not protocol-specific. The flag evaluation port (8013) races identically; OFREP is just the easiest way to observe it.

Why it matters

Kubernetes. /readyz on the management port is the documented readiness probe. A pod therefore passes readiness and starts taking traffic before it can resolve a flag, and every request in that window gets FLAG_NOT_FOUND — which providers surface as the code default, silently. On a rollout that is a burst of wrong-but-plausible flag values rather than an error anyone notices. The window grows with flag-set size, so the larger the deployment, the worse it gets.

Conformance testing. This is how it was found. The OpenFeature provider conformance suite (open-feature/spec#417) drives flagd-testbed, whose /start waited on /readyz. Providers that block during their own initialisation absorb the window and never see it; a stateless provider such as OFREP evaluates the instant the backend reports ready, and fails essentially every scenario — which reads as a catastrophically broken provider rather than as a racing backend. Worked around on the testbed side in open-feature/flagd-testbed#394, but that fix is a workaround for this.

Suggested direction

Gate readiness on the store having been populated rather than on the payload having been handed over — e.g. have the runtime mark readiness once updateAndEmit has applied at least one payload per source, rather than deriving it from sync.IsReady() alone.

If the current semantics are intentional, then the documentation is the thing to change: it should say plainly that 200 does not mean flags are servable, and that /readyz is consequently not suitable as a Kubernetes readiness probe. I would expect most users to read the current wording as the opposite.

Version

  • flagd v0.16.0 (ghcr.io/open-feature/flagd:v0.16.0, built 2026-06-01)
  • Mechanism also present on main at 6fdf338

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with core/pkg/sync/file/filepath_sync.go and flagd/pkg/runtime/runtime.go, then trace the dataSync channel through updateAndEmit and Evaluator.SetState. Confirm when readiness changes relative to payload application using the supplied reproduction. Done means /readyz no longer reports success before the fetched flags are evaluable, or the monitoring documentation clearly states the existing limitation if that behavior is intentional.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
backend, devops
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.