From a77a1c97066077108fce38d7cc9e10b9c46ee663 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Mon, 21 Sep 2026 22:41:45 +0800 Subject: [PATCH] feat(featureflags): read int flag fallbacks from the environment A deployment with no LAUNCH_DARKLY_API_KEY serves every flag's compiled-in fallback, so an int flag resolves to a value only a rebuild can change. That is fine for flags whose fallback is a safe default, but not for the per-node limits: max-sandboxes-per-node falls back to 200, which is sized for cloud node types rather than for the host it runs on. A self-hosted node with 384 CPUs and 1.5 TB of memory refuses the 201st sandbox with ResourceExhausted, and the only way past it is editing a literal in this file. Add envIntOr, the int twin of envBoolOr, and use it for the two per-node limits: max-sandboxes-per-node (MAX_SANDBOXES_PER_NODE) and max-starting-instances-per-node (MAX_STARTING_INSTANCES_PER_NODE). The override applies to the fallback rather than to the evaluated value, so both modes stay consistent: without a LaunchDarkly key it is the value the offline store serves, and with a key set it is the default for flags that the LaunchDarkly environment does not define. A defined LaunchDarkly flag still wins, so live configuration is not bypassed. Unset, empty, unparseable and non-positive values all keep the existing fallback. Rejecting non-positive values means a typo cannot silently stop a node from accepting sandboxes, and it matches the "Must be > 0" requirement already documented on MaxStartingInstancesPerNode. Only these two flags opt in; other int flags are unchanged. Other flag types can follow the same shape if there is demand. Signed-off-by: Wei Wang --- packages/shared/pkg/featureflags/flags.go | 23 ++++++++++-- .../pkg/featureflags/flags_envint_test.go | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 packages/shared/pkg/featureflags/flags_envint_test.go diff --git a/packages/shared/pkg/featureflags/flags.go b/packages/shared/pkg/featureflags/flags.go index feb46140c5..c7452da2d9 100644 --- a/packages/shared/pkg/featureflags/flags.go +++ b/packages/shared/pkg/featureflags/flags.go @@ -432,6 +432,25 @@ func (f IntFlag) Fallback() int { return f.fallback } +// envIntOr reads key as an int, falling back when it is unset, unparseable, or +// not positive. It exists for the same reason as envBoolOr: on a cluster with no +// LaunchDarkly an int flag resolves to a value only a rebuild can change, and +// max-sandboxes-per-node's fallback is sized for cloud node types rather than +// for the host it runs on. A non-positive value keeps the fallback so a typo +// cannot silently stop a node from accepting sandboxes. +func envIntOr(key string, fallback int) int { + raw := env.GetEnv(key, "") + if raw == "" { + return fallback + } + parsed, err := strconv.Atoi(raw) + if err != nil || parsed <= 0 { + return fallback + } + + return parsed +} + func NewIntFlag(name string, fallback int) IntFlag { flag := IntFlag{name: name, fallback: fallback} builder := launchDarklyOfflineStore.Flag(flag.name).ValueForAll(ldvalue.Int(fallback)) @@ -441,7 +460,7 @@ func NewIntFlag(name string, fallback int) IntFlag { } var ( - MaxSandboxesPerNode = NewIntFlag("max-sandboxes-per-node", 200) + MaxSandboxesPerNode = NewIntFlag("max-sandboxes-per-node", envIntOr("MAX_SANDBOXES_PER_NODE", 200)) // The LD keys keep the legacy "gcloud-" prefix, but the limits apply to uploads on all storage providers. StorageConcurrentUploadLimit = NewIntFlag("gcloud-concurrent-upload-limit", 8) StorageMaxUploadTasks = NewIntFlag("gcloud-max-tasks", 16) @@ -599,7 +618,7 @@ var ( // MaxStartingInstancesPerNode limits concurrent sandbox start/resume operations on a single orchestrator node. // Must be > 0. - MaxStartingInstancesPerNode = NewIntFlag("max-starting-instances-per-node", 3) + MaxStartingInstancesPerNode = NewIntFlag("max-starting-instances-per-node", envIntOr("MAX_STARTING_INSTANCES_PER_NODE", 3)) // MaxConcurrentEvictions caps the number of sandbox evictions that can run // in parallel per API instance. Excess items remain expired in the store diff --git a/packages/shared/pkg/featureflags/flags_envint_test.go b/packages/shared/pkg/featureflags/flags_envint_test.go new file mode 100644 index 0000000000..0c98388651 --- /dev/null +++ b/packages/shared/pkg/featureflags/flags_envint_test.go @@ -0,0 +1,35 @@ +package featureflags + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +//nolint:paralleltest,tparallel // t.Setenv +func TestEnvIntOr(t *testing.T) { + const key = "MAX_SANDBOXES_PER_NODE_TEST_ONLY" + + for _, tc := range []struct { + name string + set bool + value string + fallback int + want int + }{ + {name: "unset keeps the fallback", fallback: 200, want: 200}, + {name: "empty keeps the fallback", set: true, value: "", fallback: 200, want: 200}, + {name: "a value overrides the fallback", set: true, value: "1200", fallback: 200, want: 1200}, + {name: "garbage keeps the fallback", set: true, value: "many", fallback: 200, want: 200}, + {name: "zero keeps the fallback", set: true, value: "0", fallback: 200, want: 200}, + {name: "negative keeps the fallback", set: true, value: "-1", fallback: 200, want: 200}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.set { + t.Setenv(key, tc.value) + } + + require.Equal(t, tc.want, envIntOr(key, tc.fallback)) + }) + } +}