diff --git a/demo/docker/availability.Dockerfile b/demo/docker/availability.Dockerfile index a97f5df0f9..90f7567ff5 100644 --- a/demo/docker/availability.Dockerfile +++ b/demo/docker/availability.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25 as builder +FROM golang:1.25 AS builder WORKDIR /app diff --git a/demo/docker/countries.Dockerfile b/demo/docker/countries.Dockerfile index dbbba843dc..10f5f6a1e6 100644 --- a/demo/docker/countries.Dockerfile +++ b/demo/docker/countries.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25 as builder +FROM golang:1.25 AS builder WORKDIR /app @@ -10,4 +10,4 @@ RUN go build ./cmd/countries && mv countries server ENTRYPOINT [ "./server" ] -EXPOSE 4008 +EXPOSE 4009 diff --git a/demo/docker/employees.Dockerfile b/demo/docker/employees.Dockerfile index 55cb45840c..b4f685c822 100644 --- a/demo/docker/employees.Dockerfile +++ b/demo/docker/employees.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25 as builder +FROM golang:1.25 AS builder WORKDIR /app diff --git a/demo/docker/family.Dockerfile b/demo/docker/family.Dockerfile index d036650b74..1fafc69cb7 100644 --- a/demo/docker/family.Dockerfile +++ b/demo/docker/family.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25 as builder +FROM golang:1.25 AS builder WORKDIR /app diff --git a/demo/docker/hobbies.Dockerfile b/demo/docker/hobbies.Dockerfile index 4903dfe403..424ff3e66a 100644 --- a/demo/docker/hobbies.Dockerfile +++ b/demo/docker/hobbies.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25 as builder +FROM golang:1.25 AS builder WORKDIR /app diff --git a/demo/docker/mood.Dockerfile b/demo/docker/mood.Dockerfile index bbe6454b75..f363e5fb10 100644 --- a/demo/docker/mood.Dockerfile +++ b/demo/docker/mood.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25 as builder +FROM golang:1.25 AS builder WORKDIR /app diff --git a/demo/docker/products.Dockerfile b/demo/docker/products.Dockerfile index 6ca1a899d0..7d624108aa 100644 --- a/demo/docker/products.Dockerfile +++ b/demo/docker/products.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25 as builder +FROM golang:1.25 AS builder WORKDIR /app diff --git a/demo/docker/products_fg.Dockerfile b/demo/docker/products_fg.Dockerfile index 66b416c887..8bbad56bd2 100644 --- a/demo/docker/products_fg.Dockerfile +++ b/demo/docker/products_fg.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25 as builder +FROM golang:1.25 AS builder WORKDIR /app diff --git a/demo/docker/test1.Dockerfile b/demo/docker/test1.Dockerfile index c5910ef086..377b28cfb7 100644 --- a/demo/docker/test1.Dockerfile +++ b/demo/docker/test1.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25 as builder +FROM golang:1.25 AS builder WORKDIR /app @@ -10,4 +10,4 @@ RUN go build ./cmd/test1 && mv test1 server ENTRYPOINT [ "./server" ] -EXPOSE 4002 +EXPOSE 4006 diff --git a/docker-compose.full.yml b/docker-compose.full.yml index 64eede694c..9c594d7daf 100644 --- a/docker-compose.full.yml +++ b/docker-compose.full.yml @@ -62,6 +62,21 @@ services: networks: - primary + test1: + environment: + OTEL_AUTH_TOKEN: ${OTEL_AUTH_TOKEN} + OTEL_HTTP_ENDPOINT: ${OTEL_HTTP_ENDPOINT:-otelcollector:4318} + container_name: test1 + build: + context: ./demo + dockerfile: docker/test1.Dockerfile + profiles: + - subgraphs + ports: + - '4006:4006' + networks: + - primary + availability: container_name: availability environment: @@ -92,6 +107,21 @@ services: networks: - primary + countries: + container_name: countries + environment: + OTEL_AUTH_TOKEN: ${OTEL_AUTH_TOKEN} + OTEL_HTTP_ENDPOINT: ${OTEL_HTTP_ENDPOINT:-otelcollector:4318} + build: + context: ./demo + dockerfile: docker/countries.Dockerfile + profiles: + - subgraphs + ports: + - '4009:4009' + networks: + - primary + products_fg: container_name: products_fg environment: @@ -188,7 +218,7 @@ services: depends_on: rustfs_perms: condition: service_completed_successfully - command: "rustfs server /data" + command: 'rustfs server /data' environment: RUSTFS_CONSOLE_ENABLE: 'true' RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY_ID:-admin} @@ -343,7 +373,7 @@ services: redis: image: redis:${DC_REDIS_VERSION:-7.2.4}-alpine healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 5s timeout: 3s retries: 5 diff --git a/mise-tasks/profile-reloads b/mise-tasks/profile-reloads new file mode 100755 index 0000000000..bbd75e7b4f --- /dev/null +++ b/mise-tasks/profile-reloads @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +#MISE description="Hot-reload a router N times and (if a pprof port is reachable) capture a heap profile after each reload" +#MISE raw=true +#USAGE flag "-r --reloads " help="Number of hot reloads to perform" default="10" +#USAGE flag "-s --settle " help="Settling period (seconds) after each reload before capturing" default="15" +#USAGE flag "-p --pprof-port " help="pprof port to capture heap from; omit to skip profiling (defaults to 6060 with --run-router)" +#USAGE flag "-o --outdir " help="Directory (relative to repo root) for heap profiles and logs" default="profiles" +#USAGE flag "-l --log-file " help="Log to monitor for reload confirmation; omit to wait blindly on a fixed cadence" +#USAGE flag "-c --router-config " help="Router config (YAML), relative to router/ — the CONFIG_PATH (only used with --run-router)" default="debug.config.yaml" +#USAGE flag "-e --execution-config " help="Execution config (JSON) to append to so the router reloads; pass empty to have reloads triggered externally" default="router/__schemas/config.json" +#USAGE flag "--run-router" help="Build and run the router ourselves (redirected log, confirmed reloads, profiling)" + +set -euo pipefail + +# --- args (usage spec sets usage_*, with fallbacks) -------------------------- +reloads="${usage_reloads:-10}" +settle="${usage_settle:-15}" +outdir_rel="${usage_outdir:-profiles}" +router_config="${usage_router_config:-debug.config.yaml}" +run_router="${usage_run_router:-false}" +port="${usage_pprof_port:-}" +# no-colon default so an explicit empty value (external reloads) is preserved. +execution_config="${usage_execution_config-router/__schemas/config.json}" + +# --- paths ------------------------------------------------------------------- +root="${MISE_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}" +router_dir="$root/router" +[[ $outdir_rel == /* ]] && outdir="$outdir_rel" || outdir="$root/$outdir_rel" +[[ -n $execution_config && $execution_config != /* ]] && execution_config="$root/$execution_config" +bin="$outdir/router" +mkdir -p "$outdir" + +# --- mode: degrade gracefully as flags are omitted --------------------------- +# --run-router implies profiling, so default the port when it wasn't given. +[[ $run_router == true && -z $port ]] && port=6060 +base="http://localhost:$port" + +# Which log to watch, and whether to gate reloads on it (else blind cadence). +log="$outdir/router.log" +monitor=false +if [[ $run_router == true ]]; then + monitor=true +elif [[ -n ${usage_log_file:-} ]]; then + [[ ${usage_log_file} == /* ]] && log="$usage_log_file" || log="$root/$usage_log_file" + monitor=true +fi + +# --- helpers ----------------------------------------------------------------- +die() { echo "error: $*" >&2; exit 1; } + +# grep -c prints "0" *and* exits non-zero on no matches, so branch on the +# file's existence rather than chaining on grep's status (which emits 0 twice). +count_reloads() { + if [[ -f $log ]]; then + grep -c 'Config file changed' "$log" || true + else + echo 0 + fi +} + +# Force two GC cycles, then capture an inuse heap profile (also gc=1) to $1. +capture_heap() { + curl -sf "$base/debug/pprof/heap?gc=1" -o /dev/null + sleep 1 + curl -sf "$base/debug/pprof/heap?gc=1" -o /dev/null + sleep 1 + curl -sf "$base/debug/pprof/heap?gc=1" -o "$1" +} + +# Block until $log records more than $1 reloads (or the router dies / we time out). +wait_for_reload() { + local before=$1 n=0 + while (($(count_reloads) <= before)); do + if [[ -n ${router_pid:-} ]] && ! kill -0 "$router_pid" 2>/dev/null; then + die "router exited during reload; see $log" + fi + sleep 0.2 + n=$((n + 1)) + ((n > 300)) && die "timed out waiting for reload (60s); see $log" + done +} + +# Diff the baseline against the final reload profile and print the top growth. +report_diff() { + printf -v final "%s/heap-%03d.pb.gz" "$outdir" "$reloads" + local diff="$outdir/diff-inuse_space.txt" + local args=(-base "$baseline" -inuse_space -cum -top) + [[ -f $bin ]] && args+=("$bin") + go tool pprof "${args[@]}" "$final" >"$diff" 2>&1 + echo "baseline: $baseline" + echo "final: $final" + echo "diff: $diff" + echo + echo "Top inuse_space growth (baseline → final):" + head -n 25 "$diff" +} + +# --- cleanup ----------------------------------------------------------------- +cleanup() { + if [[ -n ${router_pid:-} ]] && kill -0 "$router_pid" 2>/dev/null; then + echo "Stopping router (pid $router_pid)" + kill "$router_pid" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# --- preflight --------------------------------------------------------------- +if [[ $run_router == true && -z $execution_config ]]; then + die "--execution-config is required with --run-router (the file whose change triggers a reload)" +fi +[[ -n $execution_config && ! -f $execution_config ]] && die "execution config not found: $execution_config" + +# --- build & launch (only when asked to run the router) ---------------------- +if [[ $run_router == true ]]; then + cd "$router_dir" + echo "Building router binary" + go build -o "$bin" ./cmd/router || die "go build failed" + + echo "Starting router (PPROF_ADDR=:$port, CONFIG_PATH=$router_config)" + env PPROF_ADDR=":$port" CONFIG_PATH="$router_config" "$bin" >"$log" 2>&1 & + router_pid=$! + + echo "Waiting for router to become ready on :$port" + n=0 + until curl -sf "$base/debug/pprof/heap" -o /dev/null 2>/dev/null; do + kill -0 "$router_pid" 2>/dev/null || die "router exited early; see $log" + sleep 0.5 + n=$((n + 1)) + ((n > 120)) && die "timed out waiting for router (60s)" + done + echo "Router ready (pid $router_pid)" +fi + +# --- decide whether to profile ----------------------------------------------- +# A port makes profiling possible; when attaching, only if it actually responds. +profile=false +if [[ -n $port ]]; then + if [[ $run_router == true ]] || curl -sf "$base/debug/pprof/heap" -o /dev/null 2>/dev/null; then + profile=true + else + echo "pprof :$port unreachable — continuing without profiling" + fi +fi + +echo "Router hot-reload profiler — reloads=$reloads settle=${settle}s profile=$profile monitor=$monitor" + +# --- baseline ---------------------------------------------------------------- +if [[ $profile == true ]]; then + baseline="$outdir/heap-000-baseline.pb.gz" + capture_heap "$baseline" + echo "Baseline captured => $baseline" +fi + +# --- reload loop ------------------------------------------------------------- +for ((i = 1; i <= reloads; i++)); do + [[ $monitor == true ]] && before=$(count_reloads) + + # Trigger a reload by appending to the execution config; if none was given, + # a reload is expected to come from elsewhere and we just wait/settle. + if [[ -n $execution_config ]]; then + printf '\n' >>"$execution_config" + echo "Reload $i/$reloads · triggered" + fi + + if [[ $monitor == true ]]; then + echo "Reload $i/$reloads · waiting for router to pick up config change" + wait_for_reload "$before" + fi + + sleep "$settle" + + if [[ $profile == true ]]; then + printf -v out "%s/heap-%03d.pb.gz" "$outdir" "$i" + capture_heap "$out" + echo "Reload $i/$reloads captured → $out" + fi +done + +# --- diff -------------------------------------------------------------------- +if [[ $profile == true ]]; then + echo "Done — $reloads reloads profiled" + report_diff + echo + echo "Inspect interactively with:" + echo " go tool pprof -base $baseline -inuse_space -cum $bin $final" +else + echo "Done — $reloads reloads triggered (no profiling)" +fi diff --git a/router/__schemas/graph.yaml b/router/__schemas/graph.yaml index d389cda497..751cfe80aa 100644 --- a/router/__schemas/graph.yaml +++ b/router/__schemas/graph.yaml @@ -1,4 +1,25 @@ version: 1 +feature_flags: + - name: myff + feature_graphs: + - name: products_fg + subgraph_name: products + routing_url: http://localhost:4010/graphql + - name: myff2 + feature_graphs: + - name: products_fg + subgraph_name: products + routing_url: http://localhost:4010/graphql + - name: myff3 + feature_graphs: + - name: products_fg + subgraph_name: products + routing_url: http://localhost:4010/graphql + - name: myff4 + feature_graphs: + - name: products_fg + subgraph_name: products + routing_url: http://localhost:4010/graphql subgraphs: - name: employees routing_url: http://localhost:4001/graphql @@ -8,15 +29,17 @@ subgraphs: routing_url: http://localhost:4003/graphql - name: products routing_url: http://localhost:4004/graphql + - name: test1 + routing_url: http://localhost:4006/graphql - name: availability routing_url: http://localhost:4007/graphql - name: mood routing_url: http://localhost:4008/graphql + - name: countries + routing_url: http://localhost:4009/graphql - name: employee-events schema: file: ../../demo/pkg/subgraphs/employee-events/subgraph/schema.graphqls - name: employeeUpdates schema: file: ../../demo/pkg/subgraphs/employeeupdated/subgraph/schema.graphqls - - name: test1 - routing_url: http://localhost:4006/graphql diff --git a/router/core/context.go b/router/core/context.go index b20ffdbc0c..e983b850c9 100644 --- a/router/core/context.go +++ b/router/core/context.go @@ -298,7 +298,6 @@ func (c *headerBuilder) HeadersForSubgraph(subgraphName string) (http.Header, ui } func SubgraphHeadersBuilder(ctx *requestContext, headerPropagation *HeaderPropagation, executionPlan plan.Plan) resolve.SubgraphHeadersBuilder { - keyGen := xxhash.New() switch p := executionPlan.(type) { @@ -658,8 +657,8 @@ func (o *operationContext) Variables() *astjson.Value { return o.variables } -func (c *operationContext) VariablesView() resolve.VariablesView { - return resolve.NewVariablesView(c.variables, c.remapVariables) +func (o *operationContext) VariablesView() resolve.VariablesView { + return resolve.NewVariablesView(o.variables, o.remapVariables) } func (o *operationContext) Files() []*httpclient.FileUpload { @@ -914,7 +913,6 @@ type requestContextOptions struct { } func buildRequestContext(opts requestContextOptions) *requestContext { - rootCtx := expr.Context{ Request: expr.LoadRequest(opts.r), } diff --git a/router/core/graph_server.go b/router/core/graph_server.go index 46ed7915ff..6973a29a83 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -7,12 +7,15 @@ import ( "crypto/tls" "errors" "fmt" + "maps" "net/http" "net/url" "path/filepath" "runtime" + "slices" "strings" "sync" + "sync/atomic" "time" "github.com/cespare/xxhash/v2" @@ -27,10 +30,8 @@ import ( "go.opentelemetry.io/otel/attribute" otelmetric "go.opentelemetry.io/otel/metric" oteltrace "go.opentelemetry.io/otel/trace" - "go.uber.org/atomic" "go.uber.org/zap" "go.uber.org/zap/zapcore" - "golang.org/x/exp/maps" "golang.org/x/sync/errgroup" "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/common" @@ -95,7 +96,7 @@ type ( mux *chi.Mux // inFlightRequests is used to track the number of requests currently being processed // does not include websocket (hijacked) connections. - inFlightRequests *atomic.Uint64 + inFlightRequests *atomic.Int64 // graphMuxList contains all graph muxes of this graph server. // It's keyed by mux name (feature flag name or empty string for base graph). graphMuxList map[string]*graphMux @@ -221,7 +222,7 @@ func newGraphServer(routerCtx context.Context, r *Router, response *routerconfig playgroundHandler: r.playgroundHandler, traceDialer: traceDialer, baseRouterConfigVersion: response.Config.GetVersion(), - inFlightRequests: &atomic.Uint64{}, + inFlightRequests: &atomic.Int64{}, graphMuxList: make(map[string]*graphMux, 1), instanceData: InstanceData{ HostName: r.hostName, @@ -385,7 +386,7 @@ func newGraphServer(routerCtx context.Context, r *Router, response *routerconfig featureFlagConfigMap := response.Config.FeatureFlagConfigs.GetConfigByFeatureFlagName() if len(featureFlagConfigMap) > 0 { - s.logger.Info("Feature flags enabled", zap.Strings("flags", maps.Keys(featureFlagConfigMap))) + s.logger.Info("Feature flags enabled", zap.Strings("flags", slices.Sorted(maps.Keys(featureFlagConfigMap)))) } multiGraphHandler, ffReusedMuxes, err := s.buildMultiGraphHandler(buildMultiGraphHandlerOptions{ @@ -631,6 +632,8 @@ func (s *graphServer) buildMultiGraphHandler( featureFlagToMux[featureFlagName] = gm.mux } + baseMux := opts.baseMux // Capture only baseMux so the closure does not hold the whole opts struct + return func(w http.ResponseWriter, r *http.Request) { // Extract the feature flag and run the corresponding mux // 1. From the request header @@ -650,7 +653,7 @@ func (s *graphServer) buildMultiGraphHandler( return } - opts.baseMux.ServeHTTP(w, r) + baseMux.ServeHTTP(w, r) }, reused, nil } @@ -739,7 +742,7 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e s.planFallbackCache.Set(item.Key, item.Value, item.Value.planningDuration) } } - s.planCache, err = ristretto.NewCache[uint64, *planWithMetaData](planCacheConfig) + s.planCache, err = ristretto.NewCache(planCacheConfig) if err != nil { return computeSha256, fmt.Errorf("failed to create planner cache: %w", err) } @@ -755,7 +758,7 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e Metrics: true, } - s.persistedOperationCache, _ = ristretto.NewCache[uint64, NormalizationCacheEntry](persistedOperationCacheConfig) + s.persistedOperationCache, _ = ristretto.NewCache(persistedOperationCacheConfig) } if srv.engineExecutionConfiguration.EnableNormalizationCache && srv.engineExecutionConfiguration.NormalizationCacheSize > 0 { @@ -766,7 +769,7 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e IgnoreInternalCost: true, BufferItems: 64, } - s.normalizationCache, err = ristretto.NewCache[uint64, NormalizationCacheEntry](normalizationCacheConfig) + s.normalizationCache, err = ristretto.NewCache(normalizationCacheConfig) if err != nil { return computeSha256, fmt.Errorf("failed to create normalization cache: %w", err) } @@ -778,7 +781,7 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e IgnoreInternalCost: true, BufferItems: 64, } - s.variablesNormalizationCache, err = ristretto.NewCache[uint64, VariablesNormalizationCacheEntry](variablesNormalizationCacheConfig) + s.variablesNormalizationCache, err = ristretto.NewCache(variablesNormalizationCacheConfig) if err != nil { return computeSha256, fmt.Errorf("failed to create variables normalization cache: %w", err) } @@ -790,7 +793,7 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e IgnoreInternalCost: true, BufferItems: 64, } - s.remapVariablesCache, err = ristretto.NewCache[uint64, RemapVariablesCacheEntry](remapVariablesCacheConfig) + s.remapVariablesCache, err = ristretto.NewCache(remapVariablesCacheConfig) if err != nil { return computeSha256, fmt.Errorf("failed to create remap variables cache: %w", err) } @@ -804,7 +807,7 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e IgnoreInternalCost: true, BufferItems: 64, } - s.validationCache, err = ristretto.NewCache[uint64, bool](validationCacheConfig) + s.validationCache, err = ristretto.NewCache(validationCacheConfig) if err != nil { return computeSha256, fmt.Errorf("failed to create validation cache: %w", err) } @@ -818,7 +821,7 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e IgnoreInternalCost: true, BufferItems: 64, } - s.complexityCalculationCache, err = ristretto.NewCache[uint64, ComplexityCacheEntry](complexityCalculationCacheConfig) + s.complexityCalculationCache, err = ristretto.NewCache(complexityCalculationCacheConfig) if err != nil { return computeSha256, fmt.Errorf("failed to create query depth cache: %w", err) } @@ -862,7 +865,7 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e BufferItems: 64, Metrics: srv.metricConfig.OpenTelemetry.GraphqlCache || srv.metricConfig.Prometheus.GraphqlCache, } - s.operationHashCache, err = ristretto.NewCache[uint64, string](operationHashCacheConfig) + s.operationHashCache, err = ristretto.NewCache(operationHashCacheConfig) if err != nil { return computeSha256, fmt.Errorf("failed to create operation hash cache: %w", err) } @@ -1899,7 +1902,7 @@ func (s *graphServer) buildGraphMux( // Counting like this is safe because according to the go http.ServeHTTP documentation // the requests is guaranteed to be finished when ServeHTTP returns - defer s.inFlightRequests.Sub(1) + defer s.inFlightRequests.Add(-1) } handler.ServeHTTP(w, r) @@ -2262,7 +2265,7 @@ func (s *graphServer) Shutdown(ctx context.Context) error { return finalErr } -// startupPubSubProviders starts all pubsub providers +// startupPubSubProviders starts the given pubsub providers // It returns an error if any of the providers fail to start // or if some providers takes to long to start func (s *graphServer) startupPubSubProviders(ctx context.Context) error { diff --git a/router/core/graph_server_test.go b/router/core/graph_server_test.go index e312add8f2..aabc6ae4ff 100644 --- a/router/core/graph_server_test.go +++ b/router/core/graph_server_test.go @@ -1,14 +1,19 @@ package core import ( + "cmp" + "net/http" + "runtime" "slices" "testing" + "weak" - "golang.org/x/exp/constraints" - + "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/routerconfig" + "go.uber.org/zap" ) func TestGetRoutingUrlGroupingForCircuitBreakers(t *testing.T) { @@ -749,6 +754,42 @@ func TestCommitReusedMuxes(t *testing.T) { }) } +// The routing handler must not close over opts.currentGraphMuxes (the previous +// server's muxes); doing so leaks a full graphServer per hot reload. A weak +// pointer is used instead of a finalizer because the graphServer/mux graph is +// cyclic, and finalizers never run on objects in a cycle. +func TestBuildMultiGraphHandler(t *testing.T) { + t.Run("does not retain the previous server's graph muxes", func(t *testing.T) { + s := &graphServer{Config: &Config{logger: zap.NewNop()}} + + // Build in a nested scope so only the handler can keep the muxes alive. + build := func() (http.HandlerFunc, weak.Pointer[graphMux]) { + // stale: a previous-server mux the new build never touches; must be collectable. + stale := &graphMux{mux: chi.NewMux()} + // active: unchanged flag, taken via the reuse branch (no buildGraphMux). + active := &graphMux{mux: chi.NewMux()} + + handler, _, err := s.buildMultiGraphHandler(buildMultiGraphHandlerOptions{ + baseMux: chi.NewMux(), + featureFlagConfigs: map[string]*nodev1.FeatureFlagRouterExecutionConfig{"active": {}}, + changes: &routerconfig.Changes{}, // active unchanged => reused + currentGraphMuxes: map[string]*graphMux{"active": active, "stale": stale}, + }) + require.NoError(t, err) + + return handler, weak.Make(stale) + } + + handler, weakStale := build() + + runtime.GC() + runtime.GC() + + require.Nil(t, weakStale.Value(), "handler retained the previous server's graph muxes") + runtime.KeepAlive(handler) + }) +} + func toSet[T comparable](slice ...T) map[T]bool { set := make(map[T]bool, len(slice)) for _, v := range slice { @@ -757,7 +798,7 @@ func toSet[T comparable](slice ...T) map[T]bool { return set } -func toKeys[K constraints.Ordered, V any](m map[K]V) []K { +func toKeys[K cmp.Ordered, V any](m map[K]V) []K { keys := make([]K, 0, len(m)) for k := range m { keys = append(keys, k) diff --git a/router/core/reload_persistent_state.go b/router/core/reload_persistent_state.go index c8a5af6ecc..e1c542c1b8 100644 --- a/router/core/reload_persistent_state.go +++ b/router/core/reload_persistent_state.go @@ -32,7 +32,7 @@ func (s *ReloadPersistentState) CleanupFeatureFlags(routerCfg *nodev1.RouterConf s.inMemoryPlanCacheFallback.cleanupUnusedFeatureFlags(routerCfg) } -// This should always be called before graphMux.Shutdown() as ordering matters +// OnRouterConfigReload should always be called before graphMux.Shutdown() as ordering matters func (s *ReloadPersistentState) OnRouterConfigReload() { // For cases of router config changes (not execution config), we shut down before creating the // graph mux, because we need to initialize everything from the start diff --git a/router/debug.config.yaml b/router/debug.config.yaml index c82f7733be..4dc888721f 100644 --- a/router/debug.config.yaml +++ b/router/debug.config.yaml @@ -12,7 +12,7 @@ version: '1' execution_config: file: path: './__schemas/config.json' - # watch: true + watch: true router_registration: false diff --git a/router/go.mod b/router/go.mod index 7facb9348f..a1e0dfd08b 100644 --- a/router/go.mod +++ b/router/go.mod @@ -86,7 +86,6 @@ require ( github.com/wundergraph/go-arena v1.3.0 go.uber.org/goleak v1.3.0 go.uber.org/ratelimit v0.3.1 - golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 golang.org/x/net v0.55.0 golang.org/x/text v0.37.0 golang.org/x/time v0.9.0 diff --git a/router/go.sum b/router/go.sum index 68fe2c6f07..d0cc6aeef7 100644 --- a/router/go.sum +++ b/router/go.sum @@ -399,8 +399,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= -golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=