Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs-website/router/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1282,6 +1282,31 @@ You can configure a fallback storage for fetching the execution config in the ev
| EXECUTION_CONFIG_FALLBACK_STORAGE_PROVIDER_ID | execution_config.fallback_storage.provider_id | <Icon icon="square" /> | The ID of the storage provider. The ID must match the ID of the storage provider in the `storage_providers` section. | |
| EXECUTION_CONFIG_FALLBACK_STORAGE_OBJECT_PATH | execution_config.fallback_storage.object_path | <Icon icon="square" /> | The path to the execution config in the storage provider. The path is used to download the execution config from the S3 bucket. | |

## Split Config Poller

The split-config polling strategy assembles the final router execution config by fetching the base graph and each feature flag config as separate files from the CDN. These rules govern its behavior when individual feature flag files are missing or should be excluded entirely. They are only applied when the router is enrolled in split-config loading; with a custom storage provider the router falls back to the regular polling strategy and these rules have no effect.

The base graph is always required. Skip and ignore rules apply to feature flags only. A missing or unfetchable base graph always aborts the poll cycle, regardless of these rules.

### Example YAML config:

```yaml config.yaml
version: "1"

split_config_poller:
skip_missing_feature_flags: true
ignored_feature_flags:
- "experimental-checkout"
- "ab-test-foo"
```

### Split config poller options

| Environment Variable | YAML | Required | Description | Default Value |
| ------------------------------------------------- | --------------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| SPLIT_CONFIG_POLLER_SKIP_MISSING_FEATURE_FLAGS | split_config_poller.skip_missing_feature_flags | <Icon icon="square" /> | Keep polling alive when a feature flag listed in the mapper cannot be fetched (responds with `file not found`). When false, a single missing feature flag aborts the poll cycle. | false |
| SPLIT_CONFIG_POLLER_IGNORED_FEATURE_FLAGS | split_config_poller.ignored_feature_flags | <Icon icon="square" /> | Feature flag names to skip entirely during polling. Listed flags are not fetched even when present in the mapper, and are absent from the assembled execution config. | [] |

## Traffic Shaping

Configure rules for traffic shaping like maximum request body size, timeouts, retry behavior, etc. For more info, check this section in the docs: [Traffic shaping](/router/traffic-shaping)
Expand Down
9 changes: 9 additions & 0 deletions router/core/init_config_poller.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,19 @@ func newSplitConfigPoller(r *Router) (*configpoller.ConfigPoller, error) {
return nil, fmt.Errorf("failed to create split config fetcher: %w", err)
}

ignoredFeatureFlags := make(map[string]struct{})
for _, featureFlag := range r.routerConfigPollerConfig.SplitConfigPoller.IgnoredFeatureFlags {
ignoredFeatureFlags[featureFlag] = struct{}{}
}

splitPoller := configpoller.NewSplitConfigPoller(
fetcher,
configpoller.WithSplitLogger(r.logger),
configpoller.WithSplitPolling(r.routerConfigPollerConfig.PollInterval, r.routerConfigPollerConfig.PollJitter),
configpoller.WithConfigRules(configpoller.ConfigRules{
SkipMissingFeatureFlags: r.routerConfigPollerConfig.SplitConfigPoller.SkipMissingFeatureFlags,
IgnoredFeatureFlags: ignoredFeatureFlags,
}),
)
return &splitPoller, nil
}
7 changes: 4 additions & 3 deletions router/core/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,10 @@ type (

RouterConfigPollerConfig struct {
config.ExecutionConfig
PollInterval time.Duration
PollJitter time.Duration
GraphSignKey string
PollInterval time.Duration
PollJitter time.Duration
GraphSignKey string
SplitConfigPoller config.SplitConfigPollerRules
}

ExecutionConfig struct {
Expand Down
9 changes: 5 additions & 4 deletions router/core/supervisor_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,11 @@ func newRouter(ctx context.Context, params RouterResources, additionalOptions ..
}))
} else {
options = append(options, WithConfigPollerConfig(&RouterConfigPollerConfig{
GraphSignKey: cfg.Graph.SignKey,
PollInterval: cfg.PollInterval,
PollJitter: cfg.PollJitter,
ExecutionConfig: cfg.ExecutionConfig,
GraphSignKey: cfg.Graph.SignKey,
PollInterval: cfg.PollInterval,
PollJitter: cfg.PollJitter,
ExecutionConfig: cfg.ExecutionConfig,
SplitConfigPoller: cfg.SplitConfigPoller,
}))
}

Expand Down
14 changes: 14 additions & 0 deletions router/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,19 @@ type ExecutionConfigFile struct {
WatchInterval time.Duration `yaml:"watch_interval,omitempty" envDefault:"1s" env:"EXECUTION_CONFIG_FILE_WATCH_INTERVAL"`
}

// SplitConfigPollerRules governs the behavior of the split-config polling strategy used to
// assemble the final router execution config. The split poller fetches the base graph and each
// feature flag config as separate files from the CDN. These rules apply when individual files
// are missing or should be excluded entirely.
type SplitConfigPollerRules struct {
// SkipMissingFeatureFlags keeps polling alive when a feature flag listed in the mapper cannot
// be fetched. When false (default), a single missing feature flag aborts the poll cycle.
SkipMissingFeatureFlags bool `yaml:"skip_missing_feature_flags" envDefault:"false" env:"SKIP_MISSING_FEATURE_FLAGS"`
// IgnoredFeatureFlags is the list of feature flag names to skip entirely during polling.
// Listed flags are not fetched even when present in the mapper.
IgnoredFeatureFlags []string `yaml:"ignored_feature_flags,omitempty" env:"IGNORED_FEATURE_FLAGS"`
}

type ExecutionConfig struct {
File ExecutionConfigFile `yaml:"file,omitempty"`
Storage ExecutionConfigStorage `yaml:"storage,omitempty" envPrefix:"EXECUTION_CONFIG_STORAGE_"`
Expand Down Expand Up @@ -1305,6 +1318,7 @@ type Config struct {

StorageProviders StorageProviders `yaml:"storage_providers" envPrefix:"STORAGE_PROVIDER_"`
ExecutionConfig ExecutionConfig `yaml:"execution_config"`
SplitConfigPoller SplitConfigPollerRules `yaml:"split_config_poller" envPrefix:"SPLIT_CONFIG_POLLER_"`
PersistedOperationsConfig PersistedOperationsConfig `yaml:"persisted_operations" envPrefix:"PERSISTED_OPERATIONS_"`
AutomaticPersistedQueries AutomaticPersistedQueriesConfig `yaml:"automatic_persisted_queries"`
ApolloCompatibilityFlags ApolloCompatibilityFlags `yaml:"apollo_compatibility_flags"`
Expand Down
17 changes: 17 additions & 0 deletions router/pkg/config/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,23 @@
}
]
},
"split_config_poller": {
"type": "object",
"description": "Behavior overrides for the split-config polling strategy, which assembles the final router execution config by fetching the base graph and each feature flag config as separate files from the CDN. Only applied when the router is enrolled in split-config loading.",
"additionalProperties": false,
"properties": {
"skip_missing_feature_flags": {
"type": "boolean",
"default": false,
"description": "Skip feature flags that cannot be fetched instead of aborting the poll cycle. Use this when occasional feature flag fetch failures should not stop the router from picking up changes to other graphs."
},
"ignored_feature_flags": {
"type": "array",
"items": { "type": "string" },
"description": "Feature flag names to skip entirely during config polling. Listed flags are not fetched even when present in the mapper."
}
}
},
"graphql_metrics": {
"type": "object",
"additionalProperties": false,
Expand Down
6 changes: 6 additions & 0 deletions router/pkg/config/fixtures/full.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,12 @@ execution_config:
provider_id: s3
object_path: '5ef73d80-cae4-4d0e-98a7-1e9fa922c1a4/92c25b45-a75b-4954-b8f6-6592a9b203eb/routerconfigs/latest.json'

split_config_poller:
skip_missing_feature_flags: true
ignored_feature_flags:
- 'experimental-checkout'
- 'ab-test-foo'

router_config_path: 'latest.json'

client_header:
Expand Down
4 changes: 4 additions & 0 deletions router/pkg/config/testdata/config_defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,10 @@
"ObjectPath": ""
}
},
"SplitConfigPoller": {
"SkipMissingFeatureFlags": false,
"IgnoredFeatureFlags": null
},
"PersistedOperationsConfig": {
"Disabled": false,
"LogUnknown": false,
Expand Down
7 changes: 7 additions & 0 deletions router/pkg/config/testdata/config_full.json
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,13 @@
"ObjectPath": ""
}
},
"SplitConfigPoller": {
"SkipMissingFeatureFlags": true,
"IgnoredFeatureFlags": [
"experimental-checkout",
"ab-test-foo"
]
},
"PersistedOperationsConfig": {
"Disabled": false,
"LogUnknown": true,
Expand Down
12 changes: 5 additions & 7 deletions router/pkg/controlplane/configpoller/config_poller.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"time"

"github.com/wundergraph/cosmo/router/pkg/errs"
"github.com/wundergraph/cosmo/router/pkg/routerconfig"

"github.com/wundergraph/cosmo/router/pkg/controlplane"
Expand All @@ -13,9 +14,6 @@ import (

type Option func(cp *configPoller)

var ErrConfigNotModified = errors.New("config not modified")
var ErrConfigNotFound = errors.New("config not found")

type ConfigPoller interface {
// Subscribe subscribes to the config poller with a handler function that will be invoked
// with the latest router config. If the handler takes longer than the poll interval
Expand Down Expand Up @@ -68,7 +66,7 @@ func (c *configPoller) Subscribe(ctx context.Context, handler func(newConfig *ro

cfg, err := c.getRouterConfig(ctx)
if err != nil {
if errors.Is(err, ErrConfigNotModified) {
if errors.Is(err, errs.ErrConfigNotModified) {
c.logger.Debug("No new router config available. Trying again ...",
zap.String("poll_interval", c.pollInterval.String()),
zap.String("fetch_time", time.Since(start).String()),
Expand Down Expand Up @@ -135,11 +133,11 @@ func (c *configPoller) getRouterConfig(ctx context.Context) (*routerconfig.Respo
return config, nil
}

if errors.Is(err, ErrConfigNotModified) {
if errors.Is(err, errs.ErrConfigNotModified) {
return nil, err
}

if c.demoMode && c.fallbackConfigClient == nil && errors.Is(err, ErrConfigNotFound) {
if c.demoMode && c.fallbackConfigClient == nil && errors.Is(err, errs.ErrRouterConfigNotFound) {
c.logger.Warn("The router is running in demo mode and no execution config has been found, using a demo execution config for testing purposes.")
return &routerconfig.Response{Config: routerconfig.GetDefaultConfig()}, nil
}
Expand All @@ -151,7 +149,7 @@ func (c *configPoller) getRouterConfig(ctx context.Context) (*routerconfig.Respo
c.logger.Warn("Failed to retrieve execution config. Attempting with fallback storage")

config, err = (*c.fallbackConfigClient).RouterConfig(ctx, c.latestRouterConfigVersion, c.latestRouterConfigDate)
if c.demoMode && errors.Is(err, ErrConfigNotFound) {
if c.demoMode && errors.Is(err, errs.ErrRouterConfigNotFound) {
return &routerconfig.Response{Config: routerconfig.GetDefaultConfig()}, nil
}
if err != nil {
Expand Down
31 changes: 31 additions & 0 deletions router/pkg/controlplane/configpoller/split_config_poller.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package configpoller

import (
"context"
"errors"
"fmt"
"maps"
"slices"
Expand All @@ -10,6 +11,7 @@ import (
"github.com/cespare/xxhash/v2"
nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1"
"github.com/wundergraph/cosmo/router/pkg/controlplane"
"github.com/wundergraph/cosmo/router/pkg/errs"
"github.com/wundergraph/cosmo/router/pkg/routerconfig"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
Expand All @@ -29,6 +31,11 @@ type SplitConfigFetcher interface {
// SplitConfigPollerOption configures a splitConfigPoller.
type SplitConfigPollerOption func(*splitConfigPoller)

type ConfigRules struct {

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.

Maybe a better name might be ConfigOptions

SkipMissingFeatureFlags bool
IgnoredFeatureFlags map[string]struct{}
}

type splitConfigPoller struct {
logger *zap.Logger
poller controlplane.Poller
Expand All @@ -40,6 +47,7 @@ type splitConfigPoller struct {
knownHashes map[string]string // name -> hash from last successful mapper fetch ("" = base)
currentConfig *nodev1.RouterConfig // last successfully assembled full config
latestVersion string // composite hash used for change detection
configRules ConfigRules // config rules to apply to the config
}

// NewSplitConfigPoller creates a ConfigPoller that uses the split-config strategy.
Expand Down Expand Up @@ -73,6 +81,12 @@ func WithSplitPolling(interval time.Duration, jitter time.Duration) SplitConfigP
}
}

func WithConfigRules(rules ConfigRules) SplitConfigPollerOption {
return func(p *splitConfigPoller) {
p.configRules = rules
}
}

// computeCompositeVersion returns a deterministic version string derived from all mapper entries.
func computeCompositeVersion(graphConfigs map[string]string) string {
keys := make([]string, 0, len(graphConfigs))
Expand Down Expand Up @@ -103,20 +117,36 @@ func (p *splitConfigPoller) fetchAndAssembleAll(ctx context.Context, activeGraph
CompatibilityVersion: baseConfig.CompatibilityVersion,
}

hasIgnoredFeatureFlags := len(p.configRules.IgnoredFeatureFlags) > 0

// Fetch feature flag configs.
for name := range activeGraphs {
if name == "" {
continue // base graph already handled above
}

if hasIgnoredFeatureFlags {
if _, ok := p.configRules.IgnoredFeatureFlags[name]; ok {
p.logger.Info("Feature flag is ignored, skipping", zap.String("feature_flag", name))
continue
}
}

ffConfig, err := p.fetcher.FetchConfig(ctx, name)
if err != nil {
if p.configRules.SkipMissingFeatureFlags && errors.Is(err, errs.ErrFileNotFound) {
p.logger.Warn("Feature flag config not found, skipping", zap.String("feature_flag", name))
continue
}
return nil, fmt.Errorf("failed to fetch config for feature flag %q: %w", name, err)
}

if assembled.FeatureFlagConfigs == nil {
assembled.FeatureFlagConfigs = &nodev1.FeatureFlagRouterExecutionConfigs{
ConfigByFeatureFlagName: make(map[string]*nodev1.FeatureFlagRouterExecutionConfig),
}
}

assembled.FeatureFlagConfigs.ConfigByFeatureFlagName[name] = &nodev1.FeatureFlagRouterExecutionConfig{
EngineConfig: ffConfig.EngineConfig,
Version: ffConfig.Version,
Expand All @@ -133,6 +163,7 @@ func (p *splitConfigPoller) GetRouterConfig(ctx context.Context) (*routerconfig.
if err != nil {
return nil, fmt.Errorf("failed to fetch mapper: %w", err)
}

if len(activeGraphs) == 0 {
return nil, fmt.Errorf("empty graph configs")
}
Expand Down
Loading
Loading