Skip to content

feat: scope flag deletion by flagSetId to support per-selector sync#1922

Open
erenatas wants to merge 2 commits intoopen-feature:mainfrom
erenatas:erenatas/support-per-selector-sync
Open

feat: scope flag deletion by flagSetId to support per-selector sync#1922
erenatas wants to merge 2 commits intoopen-feature:mainfrom
erenatas:erenatas/support-per-selector-sync

Conversation

@erenatas
Copy link
Copy Markdown

@erenatas erenatas commented Apr 1, 2026

This PR

When an upstream provider sends per-selector stream messages (each with a flagSetId in metadata),

  • store.Update now only deletes stale flags within the same flagSetId(s), preserving flags from other selector.
  • Updated ToLogString function since the old ToLogString only handled single-key selectors and returned <none> for compound ones. With the scoped deletion, selectors now have two keys (flagSetId+source), so debug logs would show <none> instead of the actual query — making it impossible to troubleshoot store operations.

Related Issues

This change should fix using flagd with a provider that has multiple selectors

@erenatas erenatas requested review from a team as code owners April 1, 2026 16:58
@netlify
Copy link
Copy Markdown

netlify bot commented Apr 1, 2026

Deploy Preview for polite-licorice-3db33c canceled.

Name Link
🔨 Latest commit 8869018
🔍 Latest deploy log https://app.netlify.com/projects/polite-licorice-3db33c/deploys/69d6611de2e36b00084cbe6c

@dosubot dosubot bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Apr 1, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces flagSetId-based scoping for flag updates, allowing for more granular control over flag deletions by targeting specific flag sets rather than the entire source. It also improves the selector's log string generation by supporting multiple keys and ensuring deterministic output through sorting. Feedback was provided to refine the scoping logic to correctly handle empty flagSetId strings and to optimize performance by utilizing the existing write transaction for queries instead of creating new read transactions.

Comment on lines +263 to +276
if fsi, ok := metadata["flagSetId"].(string); ok && fsi != "" {
seenFlagSetIds := map[string]struct{}{fsi: {}}
for id := range newFlags {
seenFlagSetIds[id.flagSetId] = struct{}{}
}
for fsi := range seenFlagSetIds {
sel := NewSelector(flagSetIdIndex + "=" + fsi).WithIndex(sourceIndex, source)
partial, _, _ := s.GetAll(context.Background(), &sel)
oldFlags = append(oldFlags, partial...)
}
} else {
sel := NewSelector(sourceIndex + "=" + source)
oldFlags, _, _ = s.GetAll(context.Background(), &sel)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The current implementation has two areas for improvement:

  1. Scoping for Default Selector: The check fsi != "" prevents scoping when the flagSetId is an empty string. Since flagd normalizes empty or missing flagSetIds to a default internal UUID (nilFlagSetId), skipping scoping for "" causes the "default" selector to fall back to source-scoped deletion. This may unintentionally delete flags from other selectors. Removing fsi != "" and relying on ok ensures consistent scoping for all selectors.
  2. Transaction Efficiency: Calling s.GetAll inside Update is suboptimal because it initiates new read transactions while a write transaction (txn) is already active. It is more efficient to use the existing txn directly. This also allows for proper error handling during the query phase.
	if fsi, ok := metadata["flagSetId"].(string); ok {
		seenFlagSetIds := map[string]struct{}{fsi: {}}
		for id := range newFlags {
			seenFlagSetIds[id.flagSetId] = struct{}{}
		}
		for fsi := range seenFlagSetIds {
			sel := NewSelector(flagSetIdIndex + "=" + fsi).WithIndex(sourceIndex, source)
			indexId, constraints := sel.ToQuery()
			it, err := txn.Get(flagsTable, indexId, constraints...)
			if err != nil {
				s.logger.Error(fmt.Sprintf("unable to query flags for flagSetId %s: %v", fsi, err))
				continue
			}
			oldFlags = append(oldFlags, s.collect(it)...)
		}
	} else {
		sel := NewSelector(sourceIndex + "=" + source)
		indexId, constraints := sel.ToQuery()
		it, err := txn.Get(flagsTable, indexId, constraints...)
		if err != nil {
			s.logger.Error(fmt.Sprintf("unable to query flags for source %s: %v", source, err))
		} else {
			oldFlags = s.collect(it)
		}
	}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Flags with an empty-string flagSetId are stored as "", but NewSelector silently rewrites "" to nilFlagSetId, so removing the guard would make the query look for the wrong value and never find those flags.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Transaction Efficiency: Calling s.GetAll inside Update is suboptimal because it initiates new read transactions while a write transaction (txn) is already active. It is more efficient to use the existing txn directly. This also allows for proper error handling during the query phase.

Updated

@erenatas erenatas force-pushed the erenatas/support-per-selector-sync branch from 8937fd1 to 62080d5 Compare April 1, 2026 17:03
…sync

  When an upstream provider sends per-selector stream messages (each with
  a flagSetId in metadata), store.Update now only deletes stale flags
  within the same flagSetId(s), preserving flags from other selector.

Signed-off-by: Eren Atas <eren_atas@hotmail.com>
@erenatas erenatas force-pushed the erenatas/support-per-selector-sync branch from 62080d5 to 5bf055b Compare April 1, 2026 17:12
@toddbaert
Copy link
Copy Markdown
Member

Hey @erenatas, thanks for this PR! The use case makes sense; I can see how per-flagSetId updates from a single source are very useful for multi-project gRPC backends.

A few thoughts:

I think this is a feature, not a fix. The current source-scoped deletion is working as designed; each update is a complete snapshot for that source. This PR adds support for incremental per-flagSetId updates, which is new behavior. The commit message should reflect that (e.g., feat: not fix:), so I've made that change.

Orphaned flags problem. By scoping deletion to flagSetId, the store moves from a stateless full-snapshot model to a stateful incremental one. This means flags can become orphaned if their flagSetId is renamed, removed, or stops being sent by the upstream; those flags never get deleted. There's currently no mechanism (in the store or the sync proto) to clean these up, short of restarting flagd or sending an update with no flagSetId to trigger a full source-scoped wipe.

Proposal: make this opt-in per source. Rather than implicitly switching deletion strategy based on whether flagSetId is present in metadata, I'm thinking this should be an explicit per-source config option (e.g., on SourceConfig). This way:

  • The default behavior is unchanged; no risk for existing users.
  • Operators who need per-flagSetId incremental updates opt in explicitly, aware of the tradeoffs, and sending explicit empty flagSetIds for flags they want removed from previous sets
  • Sources that include flagSetId in their flag definitions for metadata/selector purposes (but still send full snapshots) aren't silently affected.

@toddbaert toddbaert changed the title fix: scope flag deletion by flagSetId to support per-selector sync feat: scope flag deletion by flagSetId to support per-selector sync Apr 7, 2026
@toddbaert toddbaert self-requested a review April 7, 2026 16:25
Copy link
Copy Markdown
Member

@toddbaert toddbaert left a comment

Choose a reason for hiding this comment

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

I really like this idea and I want to fix the problem, but I'm blocking for now because of the concerns in this comment, which also has an alternative proposal.

@erenatas
Copy link
Copy Markdown
Author

erenatas commented Apr 8, 2026

I really like this idea and I want to fix the problem, but I'm blocking for now because of the concerns in this comment, which also has an alternative proposal.

I updated the PR to make it opt-in, can you have a look?

Signed-off-by: Eren Atas <eren_atas@hotmail.com>
@erenatas erenatas force-pushed the erenatas/support-per-selector-sync branch from b96d0b7 to 8869018 Compare April 8, 2026 14:07
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Apr 8, 2026

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.

2 participants