From 469de965b2abccbb8195d0dcd144b37a97bf84a0 Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Wed, 18 Feb 2026 16:53:01 +0100 Subject: [PATCH 01/29] feat(router): add SetWildcardScope to bypass @requiresScopes checks Allows custom modules to mark a request as having a wildcard scope that satisfies all @requiresScopes checks. Authentication is still enforced. Closes #2490 Co-Authored-By: Claude Opus 4.6 --- .../custom-set-wildcard-scope/module.go | 32 ++++ .../modules/set_wildcard_scope_test.go | 142 ++++++++++++++++++ router/core/authorizer.go | 8 + router/core/context.go | 11 ++ 4 files changed, 193 insertions(+) create mode 100644 router-tests/modules/custom-set-wildcard-scope/module.go create mode 100644 router-tests/modules/set_wildcard_scope_test.go diff --git a/router-tests/modules/custom-set-wildcard-scope/module.go b/router-tests/modules/custom-set-wildcard-scope/module.go new file mode 100644 index 0000000000..5b42c9dde0 --- /dev/null +++ b/router-tests/modules/custom-set-wildcard-scope/module.go @@ -0,0 +1,32 @@ +package custom_set_wildcard_scope + +import ( + "net/http" + + "github.com/wundergraph/cosmo/router/core" +) + +const myModuleID = "setWildcardScopeModule" + +type SetWildcardScopeModule struct { + Enabled bool `mapstructure:"enabled"` +} + +func (m *SetWildcardScopeModule) Middleware(ctx core.RequestContext, next http.Handler) { + if m.Enabled { + ctx.SetWildcardScope(true) + } + next.ServeHTTP(ctx.ResponseWriter(), ctx.Request()) +} + +func (m *SetWildcardScopeModule) Module() core.ModuleInfo { + return core.ModuleInfo{ + ID: myModuleID, + Priority: 2, + New: func() core.Module { + return &SetWildcardScopeModule{} + }, + } +} + +var _ core.RouterMiddlewareHandler = (*SetWildcardScopeModule)(nil) diff --git a/router-tests/modules/set_wildcard_scope_test.go b/router-tests/modules/set_wildcard_scope_test.go new file mode 100644 index 0000000000..4c9a7b4e8b --- /dev/null +++ b/router-tests/modules/set_wildcard_scope_test.go @@ -0,0 +1,142 @@ +package module_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + wildcardModule "github.com/wundergraph/cosmo/router-tests/modules/custom-set-wildcard-scope" + "github.com/wundergraph/cosmo/router-tests/testenv" + "github.com/wundergraph/cosmo/router/core" + "github.com/wundergraph/cosmo/router/pkg/config" +) + +func TestCustomModuleSetWildcardScope(t *testing.T) { + t.Run("authenticated request with wildcard scope bypasses requiresScopes checks", func(t *testing.T) { + t.Parallel() + + cfg := config.Config{ + Graph: config.Graph{}, + Modules: map[string]any{ + "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{ + Enabled: true, + }, + }, + } + authenticators, authServer := configureAuth(t) + accessController, err := core.NewAccessController(core.AccessControllerOptions{ + Authenticators: authenticators, + AuthenticationRequired: false, + SkipIntrospectionQueries: false, + IntrospectionSkipSecret: "", + }) + require.NoError(t, err) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(accessController), + core.WithModulesConfig(cfg.Modules), + core.WithCustomModules(&wildcardModule.SetWildcardScopeModule{}), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + // Token has no scopes at all, but wildcard should grant access + token, err := authServer.Token(nil) + require.NoError(t, err) + header := http.Header{ + "Authorization": []string{"Bearer " + token}, + } + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + data, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, `{"data":{"employees":[{"id":1,"startDate":"January 2020"},{"id":2,"startDate":"July 2022"},{"id":3,"startDate":"June 2021"},{"id":4,"startDate":"July 2022"},{"id":5,"startDate":"July 2022"},{"id":7,"startDate":"September 2022"},{"id":8,"startDate":"September 2022"},{"id":10,"startDate":"November 2022"},{"id":11,"startDate":"November 2022"},{"id":12,"startDate":"December 2022"}]}}`, string(data)) + }) + }) + + t.Run("unauthenticated request with wildcard scope still fails", func(t *testing.T) { + t.Parallel() + + cfg := config.Config{ + Graph: config.Graph{}, + Modules: map[string]any{ + "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{ + Enabled: true, + }, + }, + } + authenticators, _ := configureAuth(t) + accessController, err := core.NewAccessController(core.AccessControllerOptions{ + Authenticators: authenticators, + AuthenticationRequired: false, + SkipIntrospectionQueries: false, + IntrospectionSkipSecret: "", + }) + require.NoError(t, err) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(accessController), + core.WithModulesConfig(cfg.Modules), + core.WithCustomModules(&wildcardModule.SetWildcardScopeModule{}), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + // No token — should still get "not authenticated" errors + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", nil, strings.NewReader(employeesQueryRequiringClaims)) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + data, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Contains(t, string(data), "not authenticated") + }) + }) + + t.Run("wildcard scope disabled still requires correct scopes", func(t *testing.T) { + t.Parallel() + + cfg := config.Config{ + Graph: config.Graph{}, + Modules: map[string]any{ + "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{ + Enabled: false, + }, + }, + } + authenticators, authServer := configureAuth(t) + accessController, err := core.NewAccessController(core.AccessControllerOptions{ + Authenticators: authenticators, + AuthenticationRequired: false, + SkipIntrospectionQueries: false, + IntrospectionSkipSecret: "", + }) + require.NoError(t, err) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(accessController), + core.WithModulesConfig(cfg.Modules), + core.WithCustomModules(&wildcardModule.SetWildcardScopeModule{}), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + // Token with insufficient scopes — wildcard is disabled so should fail + token, err := authServer.Token(map[string]any{ + "scope": "read:employee", + }) + require.NoError(t, err) + header := http.Header{ + "Authorization": []string{"Bearer " + token}, + } + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + data, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Contains(t, string(data), "missing required scopes") + }) + }) +} diff --git a/router/core/authorizer.go b/router/core/authorizer.go index f6a3f1f8cc..493ff705d7 100644 --- a/router/core/authorizer.go +++ b/router/core/authorizer.go @@ -77,6 +77,11 @@ func (a *CosmoAuthorizer) AuthorizeObjectField(ctx *resolve.Context, dataSourceI return a.handleRejectUnauthorized(a.validateScopes(ctx, coordinate, required, isAuthenticated, actual)) } +func hasWildcardScope(ctx context.Context) bool { + v, ok := ctx.Value(wildcardScopeKey{}).(bool) + return ok && v +} + func (a *CosmoAuthorizer) validateScopes(ctx *resolve.Context, coordinate resolve.GraphCoordinate, requiredOrScopes []*nodev1.Scopes, isAuthenticated bool, actual []string) (result *resolve.AuthorizationDeny) { if !isAuthenticated { return &resolve.AuthorizationDeny{ @@ -86,6 +91,9 @@ func (a *CosmoAuthorizer) validateScopes(ctx *resolve.Context, coordinate resolv if len(requiredOrScopes) == 0 { return nil } + if hasWildcardScope(ctx.Context()) { + return nil + } WithNext: for _, requiredOrScope := range requiredOrScopes { for i := range requiredOrScope.RequiredAndScopes { diff --git a/router/core/context.go b/router/core/context.go index b20ffdbc0c..54d3b9bf73 100644 --- a/router/core/context.go +++ b/router/core/context.go @@ -133,6 +133,11 @@ type RequestContext interface { // If Authentication is not set, it will be initialized with the scopes SetAuthenticationScopes(scopes []string) + // SetWildcardScope marks this request as having a wildcard scope that + // satisfies all @requiresScopes checks. The request must still be + // authenticated for @authenticated checks to pass. + SetWildcardScope(wildcard bool) + // SetCustomFieldValueRenderer overrides the default field value rendering behavior // This can be used, e.g. to obfuscate sensitive data in the response SetCustomFieldValueRenderer(renderer resolve.FieldValueRenderer) @@ -545,6 +550,12 @@ func (c *requestContext) SetAuthenticationScopes(scopes []string) { auth.SetScopes(scopes) } +type wildcardScopeKey struct{} + +func (c *requestContext) SetWildcardScope(wildcard bool) { + c.request = c.request.WithContext(context.WithValue(c.request.Context(), wildcardScopeKey{}, wildcard)) +} + func (c *requestContext) SetForceSha256Compute() { c.forceSha256Compute = true } From 9d375dc05c3cd9529b12225b5a734710bc7f99da Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Wed, 18 Feb 2026 16:59:21 +0100 Subject: [PATCH 02/29] fix(router): improve SetWildcardScope tests and doc comment - Replace require.Contains with exact require.Equal assertions - Add test for RejectOperationIfUnauthorized + wildcard scope - Clarify doc comment: authentication is a prerequisite for wildcard Co-Authored-By: Claude Opus 4.6 --- .../modules/set_wildcard_scope_test.go | 52 ++++++++++++++++++- router/core/context.go | 3 +- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/router-tests/modules/set_wildcard_scope_test.go b/router-tests/modules/set_wildcard_scope_test.go index 4c9a7b4e8b..c613d0b8c4 100644 --- a/router-tests/modules/set_wildcard_scope_test.go +++ b/router-tests/modules/set_wildcard_scope_test.go @@ -1,6 +1,7 @@ package module_test import ( + "bytes" "io" "net/http" "strings" @@ -91,7 +92,7 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { require.Equal(t, http.StatusOK, res.StatusCode) data, err := io.ReadAll(res.Body) require.NoError(t, err) - require.Contains(t, string(data), "not authenticated") + require.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",0,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",1,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",2,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",3,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",4,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",5,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",6,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",7,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",8,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",9,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":{"employees":[null,null,null,null,null,null,null,null,null,null]}}`, string(data)) }) }) @@ -136,7 +137,54 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { require.Equal(t, http.StatusOK, res.StatusCode) data, err := io.ReadAll(res.Body) require.NoError(t, err) - require.Contains(t, string(data), "missing required scopes") + require.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",0,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",1,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",2,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",3,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",4,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",5,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",6,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",7,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",8,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",9,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":{"employees":[null,null,null,null,null,null,null,null,null,null]},"extensions":{"authorization":{"missingScopes":[{"coordinate":{"typeName":"Employee","fieldName":"startDate"},"required":[["read:employee","read:private"],["read:all"]]}],"actualScopes":["read:employee"]}}}`, string(data)) + }) + }) + + t.Run("wildcard scope with RejectOperationIfUnauthorized grants access", func(t *testing.T) { + t.Parallel() + + cfg := config.Config{ + Graph: config.Graph{}, + Modules: map[string]any{ + "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{ + Enabled: true, + }, + }, + } + authenticators, authServer := configureAuth(t) + accessController, err := core.NewAccessController(core.AccessControllerOptions{ + Authenticators: authenticators, + AuthenticationRequired: false, + SkipIntrospectionQueries: false, + IntrospectionSkipSecret: "", + }) + require.NoError(t, err) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(accessController), + core.WithAuthorizationConfig(&config.AuthorizationConfiguration{ + RejectOperationIfUnauthorized: true, + }), + core.WithModulesConfig(cfg.Modules), + core.WithCustomModules(&wildcardModule.SetWildcardScopeModule{}), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + // Token has no scopes, but wildcard should grant access even with reject mode + token, err := authServer.Token(nil) + require.NoError(t, err) + header := http.Header{ + "Authorization": []string{"Bearer " + token}, + } + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + data, err := io.ReadAll(res.Body) + data = bytes.TrimSpace(data) + require.NoError(t, err) + require.Equal(t, `{"data":{"employees":[{"id":1,"startDate":"January 2020"},{"id":2,"startDate":"July 2022"},{"id":3,"startDate":"June 2021"},{"id":4,"startDate":"July 2022"},{"id":5,"startDate":"July 2022"},{"id":7,"startDate":"September 2022"},{"id":8,"startDate":"September 2022"},{"id":10,"startDate":"November 2022"},{"id":11,"startDate":"November 2022"},{"id":12,"startDate":"December 2022"}]}}`, string(data)) }) }) } diff --git a/router/core/context.go b/router/core/context.go index 54d3b9bf73..9439144538 100644 --- a/router/core/context.go +++ b/router/core/context.go @@ -135,7 +135,8 @@ type RequestContext interface { // SetWildcardScope marks this request as having a wildcard scope that // satisfies all @requiresScopes checks. The request must still be - // authenticated for @authenticated checks to pass. + // authenticated; unauthenticated requests are rejected before scope + // checks are evaluated. SetWildcardScope(wildcard bool) // SetCustomFieldValueRenderer overrides the default field value rendering behavior From 1b39435af2fd0f581c3063ac11cf0ac74e085391 Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Thu, 26 Feb 2026 07:31:36 +0100 Subject: [PATCH 03/29] fix(router): address PR review feedback for SetWildcardScope - Move wildcardScopeKey type to authorizer.go next to hasWildcardScope - Replace raw JSON string assertions with structured graphQLResponse type for readable test assertions Co-Authored-By: Claude Opus 4.6 --- .../modules/set_wildcard_scope_test.go | 53 +++++++++++++++++-- router/core/authorizer.go | 2 + router/core/context.go | 2 - 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/router-tests/modules/set_wildcard_scope_test.go b/router-tests/modules/set_wildcard_scope_test.go index c613d0b8c4..c4a48b88b6 100644 --- a/router-tests/modules/set_wildcard_scope_test.go +++ b/router-tests/modules/set_wildcard_scope_test.go @@ -2,6 +2,7 @@ package module_test import ( "bytes" + "encoding/json" "io" "net/http" "strings" @@ -14,6 +15,28 @@ import ( "github.com/wundergraph/cosmo/router/pkg/config" ) +type graphQLResponse struct { + Data json.RawMessage `json:"data"` + Errors []struct { + Message string `json:"message"` + Extensions struct { + Code string `json:"code"` + } `json:"extensions"` + } `json:"errors"` + Extensions struct { + Authorization struct { + MissingScopes []struct { + Coordinate struct { + TypeName string `json:"typeName"` + FieldName string `json:"fieldName"` + } `json:"coordinate"` + Required [][]string `json:"required"` + } `json:"missingScopes"` + ActualScopes []string `json:"actualScopes"` + } `json:"authorization"` + } `json:"extensions"` +} + func TestCustomModuleSetWildcardScope(t *testing.T) { t.Run("authenticated request with wildcard scope bypasses requiresScopes checks", func(t *testing.T) { t.Parallel() @@ -54,7 +77,10 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { require.Equal(t, http.StatusOK, res.StatusCode) data, err := io.ReadAll(res.Body) require.NoError(t, err) - require.Equal(t, `{"data":{"employees":[{"id":1,"startDate":"January 2020"},{"id":2,"startDate":"July 2022"},{"id":3,"startDate":"June 2021"},{"id":4,"startDate":"July 2022"},{"id":5,"startDate":"July 2022"},{"id":7,"startDate":"September 2022"},{"id":8,"startDate":"September 2022"},{"id":10,"startDate":"November 2022"},{"id":11,"startDate":"November 2022"},{"id":12,"startDate":"December 2022"}]}}`, string(data)) + var resp graphQLResponse + require.NoError(t, json.Unmarshal(data, &resp)) + require.Empty(t, resp.Errors) + require.NotEmpty(t, resp.Data) }) }) @@ -92,7 +118,13 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { require.Equal(t, http.StatusOK, res.StatusCode) data, err := io.ReadAll(res.Body) require.NoError(t, err) - require.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",0,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",1,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",2,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",3,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",4,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",5,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",6,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",7,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",8,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: not authenticated.","path":["employees",9,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":{"employees":[null,null,null,null,null,null,null,null,null,null]}}`, string(data)) + var resp graphQLResponse + require.NoError(t, json.Unmarshal(data, &resp)) + require.Len(t, resp.Errors, 10) + for _, e := range resp.Errors { + require.Equal(t, "UNAUTHORIZED_FIELD_OR_TYPE", e.Extensions.Code) + require.Contains(t, e.Message, "not authenticated") + } }) }) @@ -137,7 +169,17 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { require.Equal(t, http.StatusOK, res.StatusCode) data, err := io.ReadAll(res.Body) require.NoError(t, err) - require.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",0,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",1,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",2,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",3,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",4,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",5,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",6,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",7,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",8,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.employees.startDate', Reason: missing required scopes.","path":["employees",9,"startDate"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":{"employees":[null,null,null,null,null,null,null,null,null,null]},"extensions":{"authorization":{"missingScopes":[{"coordinate":{"typeName":"Employee","fieldName":"startDate"},"required":[["read:employee","read:private"],["read:all"]]}],"actualScopes":["read:employee"]}}}`, string(data)) + var resp graphQLResponse + require.NoError(t, json.Unmarshal(data, &resp)) + require.Len(t, resp.Errors, 10) + for _, e := range resp.Errors { + require.Equal(t, "UNAUTHORIZED_FIELD_OR_TYPE", e.Extensions.Code) + require.Contains(t, e.Message, "missing required scopes") + } + require.Len(t, resp.Extensions.Authorization.MissingScopes, 1) + require.Equal(t, "Employee", resp.Extensions.Authorization.MissingScopes[0].Coordinate.TypeName) + require.Equal(t, "startDate", resp.Extensions.Authorization.MissingScopes[0].Coordinate.FieldName) + require.Equal(t, []string{"read:employee"}, resp.Extensions.Authorization.ActualScopes) }) }) @@ -184,7 +226,10 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { data, err := io.ReadAll(res.Body) data = bytes.TrimSpace(data) require.NoError(t, err) - require.Equal(t, `{"data":{"employees":[{"id":1,"startDate":"January 2020"},{"id":2,"startDate":"July 2022"},{"id":3,"startDate":"June 2021"},{"id":4,"startDate":"July 2022"},{"id":5,"startDate":"July 2022"},{"id":7,"startDate":"September 2022"},{"id":8,"startDate":"September 2022"},{"id":10,"startDate":"November 2022"},{"id":11,"startDate":"November 2022"},{"id":12,"startDate":"December 2022"}]}}`, string(data)) + var resp graphQLResponse + require.NoError(t, json.Unmarshal(data, &resp)) + require.Empty(t, resp.Errors) + require.NotEmpty(t, resp.Data) }) }) } diff --git a/router/core/authorizer.go b/router/core/authorizer.go index 493ff705d7..c1de1e719e 100644 --- a/router/core/authorizer.go +++ b/router/core/authorizer.go @@ -77,6 +77,8 @@ func (a *CosmoAuthorizer) AuthorizeObjectField(ctx *resolve.Context, dataSourceI return a.handleRejectUnauthorized(a.validateScopes(ctx, coordinate, required, isAuthenticated, actual)) } +type wildcardScopeKey struct{} + func hasWildcardScope(ctx context.Context) bool { v, ok := ctx.Value(wildcardScopeKey{}).(bool) return ok && v diff --git a/router/core/context.go b/router/core/context.go index 9439144538..b14183a670 100644 --- a/router/core/context.go +++ b/router/core/context.go @@ -551,8 +551,6 @@ func (c *requestContext) SetAuthenticationScopes(scopes []string) { auth.SetScopes(scopes) } -type wildcardScopeKey struct{} - func (c *requestContext) SetWildcardScope(wildcard bool) { c.request = c.request.WithContext(context.WithValue(c.request.Context(), wildcardScopeKey{}, wildcard)) } From 6713fd2d4797a6ee6aaa7677641f0dfecd402d1b Mon Sep 17 00:00:00 2001 From: Alessandro Pagnin Date: Mon, 11 May 2026 16:51:44 +0200 Subject: [PATCH 04/29] chore: take care of comments --- router-tests/go.mod | 2 +- .../custom-set-wildcard-scope/module.go | 8 +- .../modules/set_wildcard_scope_test.go | 128 ++++-------------- router/core/authorizer.go | 7 - router/core/context.go | 13 +- router/demo.config.yaml | 20 ++- 6 files changed, 53 insertions(+), 125 deletions(-) diff --git a/router-tests/go.mod b/router-tests/go.mod index 6cfaa47d1c..b209c198dd 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -23,6 +23,7 @@ require ( github.com/redis/go-redis/v9 v9.7.3 github.com/sebdah/goldie/v2 v2.7.1 github.com/stretchr/testify v1.11.1 + github.com/tidwall/gjson v1.18.0 github.com/twmb/franz-go v1.16.1 github.com/twmb/franz-go/pkg/kadm v1.11.0 github.com/wundergraph/astjson v1.1.0 @@ -152,7 +153,6 @@ require ( github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect diff --git a/router-tests/modules/custom-set-wildcard-scope/module.go b/router-tests/modules/custom-set-wildcard-scope/module.go index 5b42c9dde0..5156d7f72f 100644 --- a/router-tests/modules/custom-set-wildcard-scope/module.go +++ b/router-tests/modules/custom-set-wildcard-scope/module.go @@ -8,14 +8,10 @@ import ( const myModuleID = "setWildcardScopeModule" -type SetWildcardScopeModule struct { - Enabled bool `mapstructure:"enabled"` -} +type SetWildcardScopeModule struct{} func (m *SetWildcardScopeModule) Middleware(ctx core.RequestContext, next http.Handler) { - if m.Enabled { - ctx.SetWildcardScope(true) - } + ctx.SetWildcardScope(true) next.ServeHTTP(ctx.ResponseWriter(), ctx.Request()) } diff --git a/router-tests/modules/set_wildcard_scope_test.go b/router-tests/modules/set_wildcard_scope_test.go index c4a48b88b6..74ebe28d5a 100644 --- a/router-tests/modules/set_wildcard_scope_test.go +++ b/router-tests/modules/set_wildcard_scope_test.go @@ -1,42 +1,19 @@ package module_test import ( - "bytes" - "encoding/json" "io" "net/http" "strings" "testing" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" wildcardModule "github.com/wundergraph/cosmo/router-tests/modules/custom-set-wildcard-scope" "github.com/wundergraph/cosmo/router-tests/testenv" "github.com/wundergraph/cosmo/router/core" "github.com/wundergraph/cosmo/router/pkg/config" ) -type graphQLResponse struct { - Data json.RawMessage `json:"data"` - Errors []struct { - Message string `json:"message"` - Extensions struct { - Code string `json:"code"` - } `json:"extensions"` - } `json:"errors"` - Extensions struct { - Authorization struct { - MissingScopes []struct { - Coordinate struct { - TypeName string `json:"typeName"` - FieldName string `json:"fieldName"` - } `json:"coordinate"` - Required [][]string `json:"required"` - } `json:"missingScopes"` - ActualScopes []string `json:"actualScopes"` - } `json:"authorization"` - } `json:"extensions"` -} - func TestCustomModuleSetWildcardScope(t *testing.T) { t.Run("authenticated request with wildcard scope bypasses requiresScopes checks", func(t *testing.T) { t.Parallel() @@ -44,11 +21,10 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { cfg := config.Config{ Graph: config.Graph{}, Modules: map[string]any{ - "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{ - Enabled: true, - }, + "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{}, }, } + authenticators, authServer := configureAuth(t) accessController, err := core.NewAccessController(core.AccessControllerOptions{ Authenticators: authenticators, @@ -65,9 +41,9 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { core.WithCustomModules(&wildcardModule.SetWildcardScopeModule{}), }, }, func(t *testing.T, xEnv *testenv.Environment) { - // Token has no scopes at all, but wildcard should grant access token, err := authServer.Token(nil) require.NoError(t, err) + header := http.Header{ "Authorization": []string{"Bearer " + token}, } @@ -75,12 +51,12 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) + data, err := io.ReadAll(res.Body) require.NoError(t, err) - var resp graphQLResponse - require.NoError(t, json.Unmarshal(data, &resp)) - require.Empty(t, resp.Errors) - require.NotEmpty(t, resp.Data) + + require.Empty(t, gjson.GetBytes(data, "errors").Array()) + require.True(t, gjson.GetBytes(data, "data").Exists()) }) }) @@ -90,11 +66,10 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { cfg := config.Config{ Graph: config.Graph{}, Modules: map[string]any{ - "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{ - Enabled: true, - }, + "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{}, }, } + authenticators, _ := configureAuth(t) accessController, err := core.NewAccessController(core.AccessControllerOptions{ Authenticators: authenticators, @@ -111,75 +86,20 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { core.WithCustomModules(&wildcardModule.SetWildcardScopeModule{}), }, }, func(t *testing.T, xEnv *testenv.Environment) { - // No token — should still get "not authenticated" errors res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", nil, strings.NewReader(employeesQueryRequiringClaims)) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) - data, err := io.ReadAll(res.Body) - require.NoError(t, err) - var resp graphQLResponse - require.NoError(t, json.Unmarshal(data, &resp)) - require.Len(t, resp.Errors, 10) - for _, e := range resp.Errors { - require.Equal(t, "UNAUTHORIZED_FIELD_OR_TYPE", e.Extensions.Code) - require.Contains(t, e.Message, "not authenticated") - } - }) - }) - - t.Run("wildcard scope disabled still requires correct scopes", func(t *testing.T) { - t.Parallel() - - cfg := config.Config{ - Graph: config.Graph{}, - Modules: map[string]any{ - "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{ - Enabled: false, - }, - }, - } - authenticators, authServer := configureAuth(t) - accessController, err := core.NewAccessController(core.AccessControllerOptions{ - Authenticators: authenticators, - AuthenticationRequired: false, - SkipIntrospectionQueries: false, - IntrospectionSkipSecret: "", - }) - require.NoError(t, err) - testenv.Run(t, &testenv.Config{ - RouterOptions: []core.Option{ - core.WithAccessController(accessController), - core.WithModulesConfig(cfg.Modules), - core.WithCustomModules(&wildcardModule.SetWildcardScopeModule{}), - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - // Token with insufficient scopes — wildcard is disabled so should fail - token, err := authServer.Token(map[string]any{ - "scope": "read:employee", - }) - require.NoError(t, err) - header := http.Header{ - "Authorization": []string{"Bearer " + token}, - } - res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) - require.NoError(t, err) - defer res.Body.Close() - require.Equal(t, http.StatusOK, res.StatusCode) data, err := io.ReadAll(res.Body) require.NoError(t, err) - var resp graphQLResponse - require.NoError(t, json.Unmarshal(data, &resp)) - require.Len(t, resp.Errors, 10) - for _, e := range resp.Errors { - require.Equal(t, "UNAUTHORIZED_FIELD_OR_TYPE", e.Extensions.Code) - require.Contains(t, e.Message, "missing required scopes") + + errors := gjson.GetBytes(data, "errors").Array() + require.Len(t, errors, 10) + for _, e := range errors { + require.Equal(t, "UNAUTHORIZED_FIELD_OR_TYPE", e.Get("extensions.code").String()) + require.Contains(t, e.Get("message").String(), "not authenticated") } - require.Len(t, resp.Extensions.Authorization.MissingScopes, 1) - require.Equal(t, "Employee", resp.Extensions.Authorization.MissingScopes[0].Coordinate.TypeName) - require.Equal(t, "startDate", resp.Extensions.Authorization.MissingScopes[0].Coordinate.FieldName) - require.Equal(t, []string{"read:employee"}, resp.Extensions.Authorization.ActualScopes) }) }) @@ -189,11 +109,10 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { cfg := config.Config{ Graph: config.Graph{}, Modules: map[string]any{ - "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{ - Enabled: true, - }, + "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{}, }, } + authenticators, authServer := configureAuth(t) accessController, err := core.NewAccessController(core.AccessControllerOptions{ Authenticators: authenticators, @@ -213,9 +132,9 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { core.WithCustomModules(&wildcardModule.SetWildcardScopeModule{}), }, }, func(t *testing.T, xEnv *testenv.Environment) { - // Token has no scopes, but wildcard should grant access even with reject mode token, err := authServer.Token(nil) require.NoError(t, err) + header := http.Header{ "Authorization": []string{"Bearer " + token}, } @@ -223,13 +142,12 @@ func TestCustomModuleSetWildcardScope(t *testing.T) { require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) + data, err := io.ReadAll(res.Body) - data = bytes.TrimSpace(data) require.NoError(t, err) - var resp graphQLResponse - require.NoError(t, json.Unmarshal(data, &resp)) - require.Empty(t, resp.Errors) - require.NotEmpty(t, resp.Data) + + require.Empty(t, gjson.GetBytes(data, "errors").Array()) + require.True(t, gjson.GetBytes(data, "data").Exists()) }) }) } diff --git a/router/core/authorizer.go b/router/core/authorizer.go index c1de1e719e..fafcbccd0f 100644 --- a/router/core/authorizer.go +++ b/router/core/authorizer.go @@ -77,13 +77,6 @@ func (a *CosmoAuthorizer) AuthorizeObjectField(ctx *resolve.Context, dataSourceI return a.handleRejectUnauthorized(a.validateScopes(ctx, coordinate, required, isAuthenticated, actual)) } -type wildcardScopeKey struct{} - -func hasWildcardScope(ctx context.Context) bool { - v, ok := ctx.Value(wildcardScopeKey{}).(bool) - return ok && v -} - func (a *CosmoAuthorizer) validateScopes(ctx *resolve.Context, coordinate resolve.GraphCoordinate, requiredOrScopes []*nodev1.Scopes, isAuthenticated bool, actual []string) (result *resolve.AuthorizationDeny) { if !isAuthenticated { return &resolve.AuthorizationDeny{ diff --git a/router/core/context.go b/router/core/context.go index b14183a670..648adab692 100644 --- a/router/core/context.go +++ b/router/core/context.go @@ -551,8 +551,19 @@ func (c *requestContext) SetAuthenticationScopes(scopes []string) { auth.SetScopes(scopes) } +type wildcardScopeKey struct{} + +func withWildcardScope(ctx context.Context, wildcard bool) context.Context { + return context.WithValue(ctx, wildcardScopeKey{}, wildcard) +} + +func hasWildcardScope(ctx context.Context) bool { + v, ok := ctx.Value(wildcardScopeKey{}).(bool) + return ok && v +} + func (c *requestContext) SetWildcardScope(wildcard bool) { - c.request = c.request.WithContext(context.WithValue(c.request.Context(), wildcardScopeKey{}, wildcard)) + c.request = c.request.WithContext(withWildcardScope(c.request.Context(), wildcard)) } func (c *requestContext) SetForceSha256Compute() { diff --git a/router/demo.config.yaml b/router/demo.config.yaml index ccea543c6d..1ae08ae9b5 100644 --- a/router/demo.config.yaml +++ b/router/demo.config.yaml @@ -3,20 +3,30 @@ # See pkg/config/config.go for the full list of configuration options. # This file is used for the demo environment -version: "1" +version: '1' +log_level: 'debug' + +persisted_operations: + log_unknown: true + cache: + size: 100MB + manifest: + enabled: true + warmup: + enabled: false events: providers: nats: - id: default - url: "nats://localhost:4222" + url: 'nats://localhost:4222' - id: my-nats - url: "nats://localhost:4222" + url: 'nats://localhost:4222' kafka: - id: my-kafka brokers: - - "localhost:9092" + - 'localhost:9092' redis: - id: my-redis urls: - - "redis://localhost:6379/2" \ No newline at end of file + - 'redis://localhost:6379/2' From 4c021082ea931f420f865c878b07eb29eacbcb5b Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Wed, 24 Jun 2026 14:32:41 +0200 Subject: [PATCH 05/29] fix(router): clear slowplancache entries on Close to prevent memory leak (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(router): clear slowplancache entries on Close to prevent memory leak During config reloads, ristretto's OnEvict callback pushes plan entries into the slowplancache. Each entry holds a pointer to the schema AST document (~200MB). When slowplancache.Close() is called, it stops the background goroutine but never clears the sync.Map entries, keeping old schemas pinned in memory until the entire Cache struct is GC'd — which may be delayed by goroutines still referencing the owning graphMux. Clear all entries in Close() so that references to expensive objects (like *ast.Document) are released immediately. * fix(router): guard slowplancache Close() clearing with mondaytweaks flag Add router/pkg/mondaytweaks package (same pattern as graphql-go-tools) for compile-time feature flags. Guard the entry-clearing fix behind mondaytweaks.ClearSlowPlanCacheOnClose so it's easy to upstream later. --- router/pkg/mondaytweaks/mondaytweaks.go | 13 +++++++++++ router/pkg/slowplancache/slow_plan_cache.go | 10 ++++++++ .../pkg/slowplancache/slow_plan_cache_test.go | 23 +++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 router/pkg/mondaytweaks/mondaytweaks.go diff --git a/router/pkg/mondaytweaks/mondaytweaks.go b/router/pkg/mondaytweaks/mondaytweaks.go new file mode 100644 index 0000000000..226bbbf8c4 --- /dev/null +++ b/router/pkg/mondaytweaks/mondaytweaks.go @@ -0,0 +1,13 @@ +// Package mondaytweaks defines compile-time feature flags for monday.com-specific +// behavioural overrides in the cosmo router. All monday-specific toggles live in +// one place so they are easy to audit and remove when upstreamed. +package mondaytweaks + +const ( + // ClearSlowPlanCacheOnClose makes slowplancache.Close() clear all entries from + // the sync.Map immediately, releasing references to cached values (including + // *ast.Document schema pointers). Without this, entries survive until the Cache + // struct itself is GC'd — which may be delayed by goroutines still referencing + // the owning graphMux — causing ~200-300 MB of retained memory per config reload. + ClearSlowPlanCacheOnClose = true +) diff --git a/router/pkg/slowplancache/slow_plan_cache.go b/router/pkg/slowplancache/slow_plan_cache.go index 17fba9aa5f..e14380309a 100644 --- a/router/pkg/slowplancache/slow_plan_cache.go +++ b/router/pkg/slowplancache/slow_plan_cache.go @@ -6,6 +6,8 @@ import ( "sync" "sync/atomic" "time" + + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" ) // Entry holds a cached value and the duration it took to produce. @@ -222,5 +224,13 @@ func (c *Cache[V]) Close() { // This downside is also there in ristretto (if set is called concurrently) // it is even documented in the ristretto code as a comment close(c.writeCh) + + if mondaytweaks.ClearSlowPlanCacheOnClose { + c.entries.Range(func(key, _ any) bool { + c.entries.Delete(key) + return true + }) + c.size = 0 + } }) } diff --git a/router/pkg/slowplancache/slow_plan_cache_test.go b/router/pkg/slowplancache/slow_plan_cache_test.go index 69734bf771..f8d5e1313c 100644 --- a/router/pkg/slowplancache/slow_plan_cache_test.go +++ b/router/pkg/slowplancache/slow_plan_cache_test.go @@ -411,6 +411,29 @@ func TestCache_DoubleClose(t *testing.T) { }) } +func TestCache_CloseReleasesEntries(t *testing.T) { + t.Parallel() + c, err := New[*testPlan](10, 0) + require.NoError(t, err) + + c.Set(1, &testPlan{content: "q1"}, 10*time.Millisecond) + c.Set(2, &testPlan{content: "q2"}, 20*time.Millisecond) + c.Set(3, &testPlan{content: "q3"}, 30*time.Millisecond) + c.Wait() + + c.Close() + + // Verify the underlying sync.Map is empty — entries must not pin + // referenced objects (e.g. schema AST documents) after Close. + count := 0 + c.entries.Range(func(_, _ any) bool { + count++ + return true + }) + require.Equal(t, 0, count, "entries sync.Map must be empty after Close") + require.Equal(t, int64(0), c.size) +} + func BenchmarkCache_Set(b *testing.B) { c, err := New[*testPlan](1000, 0) require.NoError(b, err) From ec5d35e314d99e237857cfd7c84e6d9bdf9ff2bc Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 25 Jun 2026 17:50:49 +0200 Subject: [PATCH 06/29] =?UTF-8?q?fix(router):=20config=20reload=20memory?= =?UTF-8?q?=20leak=20=E2=80=94=20graphMux=20nil=20+=20proto=20reset=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(router): release schema refs on config reload to prevent memory leak Stop storing schemaDocument in cached planWithMetaData entries so plan caches no longer pin the old router schema AST (~200MB) after CDN reloads. Also call OnRouterConfigReload before building a new graph server so slow-plan cache entries are extracted while the old graphMux is still referenced, matching the supervisor restart path. Co-authored-by: Cursor * fix(router): drain WS subs and skip plan cache OnEvict on mux shutdown Disable ristretto OnEvict migration into slowplancache when a graphMux is shutting down, since Close() clears every entry and the fallback cache is about to be closed anyway. Close websocket subscriptions synchronously before plan caches so preparedPlan and executor refs are released first. Co-authored-by: Cursor * fix(router): register heap pprof handlers for in-use profiling Expose /debug/pprof/heap and related routes on the pprof server so forced-GC heap snapshots (heap?gc=1) work for memory leak diagnosis. Co-authored-by: Cursor * fix(router): re-read PPROF_ADDR from env after flag.Parse Flag defaults are captured at package init before embedders set PPROF_ADDR in main(), so platform-api ensurePprofAddr had no effect. Re-read env after flag.Parse() matches the existing CONFIG_PATH pattern. Co-authored-by: Cursor * fix(router): release executor schema refs and reduce upstream WS overhead on reload Executor.Close() nils federation schema AST held after graph mux shutdown. Share one upstream subscription client across subgraph factories and disable upstream ping loops when client WebSocket is disabled. Co-authored-by: Cursor * fix(router): use noop upstream subscription client when subscriptions unused Skip WSTransport/SSE initialization when the router schema has no subscription root fields or when client WebSocket and pubsub events are both disabled. Co-authored-by: Cursor * fix(router): nil graphMux caches after shutdown to allow GC on reload Close and drop Ristretto cache pointers, wsHandler, and mux after graphMux shutdown, and remove shut-down muxes from graphMuxList. Local benchmark: ~74 MB/reload → ~12 MB/reload retained inuse (same-content manifest reloads). Co-authored-by: Cursor * fix(router): skip unchanged manifest reloads and release stale execution config Hash mapper.json before re-assembling; skip reload when content is unchanged (mtime-only touches). After a successful reload, swap staticExecutionConfig and proto.Reset the previous config so decoded protojson strings can be collected. * fix(router): reuse graph muxes on manifest reload and release shutdown refs Pass Changes/Hashes from mapper.json graph hashes on the manifest watcher path so unchanged base or feature-flag muxes survive config reloads. Nil graphServer and graphMux metric fields after shutdown to drop retained references sooner. * Revert "fix(router): reuse graph muxes on manifest reload and release shutdown refs" This reverts commit 2c32b45df78a4f12bca7b8795478f9e328e79e41. * chore(router): drop profiling/pyroscope changes from PR Remove late env re-read for PPROF/PYROSCOPE in main.go and extra pprof handlers in profile.go so this PR stays focused on config reload fixes. * fix(router): gate memory-leak fixes behind mondaytweaks constants Centralize all monday.com config-reload leak fixes in mondaytweaks.go so they are easy to audit and disable individually. Restore profiling helpers from stash behind separate tweak flags. * chore(router): drop profiling and pyroscope mondaytweaks Remove PPROF/PYROSCOPE env re-read, heap pprof routes, and Pyroscope name/tag helpers so the PR stays focused on config reload memory fixes. --------- Co-authored-by: Cursor --- router/core/executor.go | 57 +++++-- router/core/executor_test.go | 37 +++++ router/core/factoryresolver.go | 90 ++++++++++-- router/core/graph_server.go | 139 +++++++++++++++--- .../core/noop_graphql_subscription_client.go | 105 +++++++++++++ .../noop_graphql_subscription_client_test.go | 89 +++++++++++ router/core/operation_planner.go | 7 +- router/core/router.go | 45 ++++++ router/core/router_config.go | 3 + router/core/transport.go | 2 + router/core/websocket.go | 11 +- router/pkg/mondaytweaks/mondaytweaks.go | 45 ++++++ router/pkg/routerconfig/routerconfig.go | 11 ++ 13 files changed, 588 insertions(+), 53 deletions(-) create mode 100644 router/core/executor_test.go create mode 100644 router/core/noop_graphql_subscription_client.go create mode 100644 router/core/noop_graphql_subscription_client_test.go diff --git a/router/core/executor.go b/router/core/executor.go index ae72771f96..69e4b02cf7 100644 --- a/router/core/executor.go +++ b/router/core/executor.go @@ -8,6 +8,7 @@ import ( "go.uber.org/zap" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" 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/grpcconnector" @@ -51,6 +52,19 @@ type Executor struct { TrackUsageInfo bool } +// Close releases schema and planner references held by the executor so a replaced +// graph mux can be garbage-collected after config reload. +func (e *Executor) Close() { + if e == nil { + return + } + e.ClientSchema = nil + e.RouterSchema = nil + e.PlanConfig = plan.Configuration{} + e.RenameTypeNames = nil + e.Resolver = nil +} + type ExecutorBuildOptions struct { EngineConfig *nodev1.EngineConfiguration Subgraphs []*nodev1.Subgraph @@ -62,10 +76,11 @@ type ExecutorBuildOptions struct { TraceClientRequired bool PluginsEnabled bool InstanceData InstanceData + WebSocketConfiguration *config.WebSocketConfiguration } func (b *ExecutorConfigurationBuilder) Build(ctx context.Context, opts *ExecutorBuildOptions) (*Executor, []pubsub_datasource.Provider, error) { - planConfig, providers, err := b.buildPlannerConfiguration(ctx, opts.EngineConfig, opts.Subgraphs, opts.RouterEngineConfig, opts.PluginsEnabled) + planConfig, providers, err := b.buildPlannerConfiguration(ctx, opts) if err != nil { return nil, nil, fmt.Errorf("failed to build planner configuration: %w", err) } @@ -215,29 +230,43 @@ func (b *ExecutorConfigurationBuilder) Build(ctx context.Context, opts *Executor }, providers, nil } -func (b *ExecutorConfigurationBuilder) buildPlannerConfiguration(ctx context.Context, engineConfig *nodev1.EngineConfiguration, subgraphs []*nodev1.Subgraph, routerEngineCfg *RouterEngineConfiguration, pluginsEnabled bool) (*plan.Configuration, []pubsub_datasource.Provider, error) { +func (b *ExecutorConfigurationBuilder) buildPlannerConfiguration(ctx context.Context, opts *ExecutorBuildOptions) (*plan.Configuration, []pubsub_datasource.Provider, error) { // this loader is used to take the engine config and create a plan config // the plan config is what the engine uses to turn a GraphQL Request into an execution plan // the plan config is stateful as it carries connection pools and other things + subscriptionClientOptions := b.subscriptionClientOptions + if subscriptionClientOptions == nil { + subscriptionClientOptions = &SubscriptionClientOptions{} + } + resolvedSubscriptionClientOptions := *subscriptionClientOptions + if mondaytweaks.UseNoopUpstreamSubscriptionClientWhenUnused { + resolvedSubscriptionClientOptions.UseNoopClient = shouldUseNoopUpstreamSubscriptionClient( + opts.EngineConfig.GetGraphqlSchema(), + opts.EngineConfig, + opts.RouterEngineConfig.Events, + opts.WebSocketConfiguration, + ) + } + loader := NewLoader(ctx, b.trackUsageInfo, NewDefaultFactoryResolver( ctx, b.transportOptions, - b.subscriptionClientOptions, + &resolvedSubscriptionClientOptions, b.baseTripper, b.subgraphTrippers, b.pluginHost, b.logger, - routerEngineCfg.Execution.EnableNetPoll, + opts.RouterEngineConfig.Execution.EnableNetPoll, b.instanceData, ), b.logger, b.subscriptionHooks) // this generates the plan config using the data source factories from the config package - planConfig, providers, err := loader.Load(engineConfig, subgraphs, routerEngineCfg, pluginsEnabled) + planConfig, providers, err := loader.Load(opts.EngineConfig, opts.Subgraphs, opts.RouterEngineConfig, opts.PluginsEnabled) if err != nil { return nil, nil, fmt.Errorf("failed to load configuration: %w", err) } - debug := &routerEngineCfg.Execution.Debug + debug := &opts.RouterEngineConfig.Execution.Debug planConfig.Debug = plan.DebugConfiguration{ PrintOperationTransformations: debug.PrintOperationTransformations, PrintOperationEnableASTRefs: debug.PrintOperationEnableASTRefs, @@ -248,19 +277,19 @@ func (b *ExecutorConfigurationBuilder) buildPlannerConfiguration(ctx context.Con PlanningVisitor: debug.PlanningVisitor, DatasourceVisitor: debug.DatasourceVisitor, } - planConfig.MinifySubgraphOperations = routerEngineCfg.Execution.MinifySubgraphOperations + planConfig.MinifySubgraphOperations = opts.RouterEngineConfig.Execution.MinifySubgraphOperations - planConfig.EnableOperationNamePropagation = routerEngineCfg.Execution.EnableSubgraphFetchOperationName + planConfig.EnableOperationNamePropagation = opts.RouterEngineConfig.Execution.EnableSubgraphFetchOperationName - planConfig.BuildFetchReasons = routerEngineCfg.Execution.EnableRequireFetchReasons || routerEngineCfg.Execution.ValidateRequiredExternalFields - planConfig.ValidateRequiredExternalFields = routerEngineCfg.Execution.ValidateRequiredExternalFields - planConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = routerEngineCfg.Execution.RelaxSubgraphOperationFieldSelectionMergingNullability + planConfig.BuildFetchReasons = opts.RouterEngineConfig.Execution.EnableRequireFetchReasons || opts.RouterEngineConfig.Execution.ValidateRequiredExternalFields + planConfig.ValidateRequiredExternalFields = opts.RouterEngineConfig.Execution.ValidateRequiredExternalFields + planConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = opts.RouterEngineConfig.Execution.RelaxSubgraphOperationFieldSelectionMergingNullability // Enable cost computation when cost control is enabled - if routerEngineCfg.CostControl != nil && routerEngineCfg.CostControl.Enabled { + if opts.RouterEngineConfig.CostControl != nil && opts.RouterEngineConfig.CostControl.Enabled { planConfig.ComputeCosts = true - planConfig.StaticCostDefaultListSize = routerEngineCfg.CostControl.EstimatedListSize - planConfig.IgnoreImplementingTypeWeights = routerEngineCfg.CostControl.IgnoreImplementingTypeWeights + planConfig.StaticCostDefaultListSize = opts.RouterEngineConfig.CostControl.EstimatedListSize + planConfig.IgnoreImplementingTypeWeights = opts.RouterEngineConfig.CostControl.IgnoreImplementingTypeWeights } return planConfig, providers, nil diff --git a/router/core/executor_test.go b/router/core/executor_test.go new file mode 100644 index 0000000000..a157b09a49 --- /dev/null +++ b/router/core/executor_test.go @@ -0,0 +1,37 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" +) + +func TestExecutorCloseReleasesSchemaReferences(t *testing.T) { + t.Parallel() + + executor := &Executor{ + ClientSchema: &ast.Document{}, + RouterSchema: &ast.Document{}, + PlanConfig: plan.Configuration{DataSources: []plan.DataSource{nil}}, + RenameTypeNames: nil, + } + + executor.Close() + + require.Nil(t, executor.ClientSchema) + require.Nil(t, executor.RouterSchema) + require.Empty(t, executor.PlanConfig.DataSources) + require.Nil(t, executor.RenameTypeNames) + require.Nil(t, executor.Resolver) +} + +func TestExecutorCloseNilSafe(t *testing.T) { + t.Parallel() + + var executor *Executor + require.NotPanics(t, func() { + executor.Close() + }) +} diff --git a/router/core/factoryresolver.go b/router/core/factoryresolver.go index d0a94d9a51..46db85e1b8 100644 --- a/router/core/factoryresolver.go +++ b/router/core/factoryresolver.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "slices" + "sync" "time" "github.com/buger/jsonparser" @@ -17,6 +18,7 @@ import ( 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/grpcconnector" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" rmetric "github.com/wundergraph/cosmo/router/pkg/metric" "github.com/wundergraph/cosmo/router/pkg/pubsub" pubsub_datasource "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" @@ -78,6 +80,10 @@ type DefaultFactoryResolver struct { transportFactory ApiTransportFactory defaultSubgraphRequestTimeout time.Duration subscriptionClientOptions []graphql_datasource.SubscriptionClientOption + useNoopSubscriptionClient bool + + subscriptionClient graphql_datasource.GraphQLSubscriptionClient + subscriptionClientOnce sync.Once } func NewDefaultFactoryResolver( @@ -131,7 +137,9 @@ func NewDefaultFactoryResolver( graphql_datasource.WithLogger(factoryLogger), } + useNoopSubscriptionClient := false if subscriptionClientOptions != nil { + useNoopSubscriptionClient = subscriptionClientOptions.UseNoopClient if subscriptionClientOptions.PingInterval > 0 { options = append(options, graphql_datasource.WithPingInterval(subscriptionClientOptions.PingInterval)) } @@ -164,6 +172,7 @@ func NewDefaultFactoryResolver( transportFactory: transportFactory, defaultSubgraphRequestTimeout: transportOptions.SubgraphTransportOptions.RequestTimeout, subscriptionClientOptions: options, + useNoopSubscriptionClient: useNoopSubscriptionClient, } } @@ -183,10 +192,40 @@ func (d *DefaultFactoryResolver) ResolveGraphqlFactory(subgraphName string) (pla if d.transportFactory == nil || d.baseTransport == nil { // dummy implementation for plan generator that doesn't make requests - subscriptionClient := graphql_datasource.NewGraphQLSubscriptionClient(d.engineCtx, + return graphql_datasource.NewFactory(d.engineCtx, http.DefaultClient, d.subscriptionClientForFactory()) + } + + defaultHTTPClient := &http.Client{ + Timeout: d.defaultSubgraphRequestTimeout, + Transport: d.transportFactory.RoundTripper(d.baseTransport), + } + + if subgraphClient, ok := d.subgraphHTTPClients[subgraphName]; ok { + // it's intentional that we're not using the subgraphClient for subscriptions + // custom subgraph clients are intended to be used for custom timeouts, which is not relevant for subscriptions + return graphql_datasource.NewFactory(d.engineCtx, subgraphClient, d.subscriptionClientForFactory()) + } + + return graphql_datasource.NewFactory(d.engineCtx, defaultHTTPClient, d.subscriptionClientForFactory()) +} + +func (d *DefaultFactoryResolver) subscriptionClientForFactory() graphql_datasource.GraphQLSubscriptionClient { + if mondaytweaks.ShareUpstreamSubscriptionClient { + return d.sharedSubscriptionClient() + } + return d.newSubscriptionClient() +} + +func (d *DefaultFactoryResolver) newSubscriptionClient() graphql_datasource.GraphQLSubscriptionClient { + if d.useNoopSubscriptionClient { + return noopGraphQLSubscriptionClientInstance + } + + if d.transportFactory == nil || d.baseTransport == nil { + return graphql_datasource.NewGraphQLSubscriptionClient( + d.engineCtx, d.subscriptionClientOptions..., ) - return graphql_datasource.NewFactory(d.engineCtx, http.DefaultClient, subscriptionClient) } defaultHTTPClient := &http.Client{ @@ -198,18 +237,49 @@ func (d *DefaultFactoryResolver) ResolveGraphqlFactory(subgraphName string) (pla Transport: d.transportFactory.RoundTripper(d.baseTransport), } - subscriptionClient := graphql_datasource.NewGraphQLSubscriptionClient( + return graphql_datasource.NewGraphQLSubscriptionClient( d.engineCtx, - append([]graphql_datasource.SubscriptionClientOption{graphql_datasource.WithUpgradeClient(defaultHTTPClient), graphql_datasource.WithStreamingClient(streamingClient)}, d.subscriptionClientOptions...)..., + append([]graphql_datasource.SubscriptionClientOption{ + graphql_datasource.WithUpgradeClient(defaultHTTPClient), + graphql_datasource.WithStreamingClient(streamingClient), + }, d.subscriptionClientOptions...)..., ) +} - if subgraphClient, ok := d.subgraphHTTPClients[subgraphName]; ok { - // it's intentional that we're not using the subgraphClient for subscriptions - // custom subgraph clients are intended to be used for custom timeouts, which is not relevant for subscriptions - return graphql_datasource.NewFactory(d.engineCtx, subgraphClient, subscriptionClient) - } +func (d *DefaultFactoryResolver) sharedSubscriptionClient() graphql_datasource.GraphQLSubscriptionClient { + d.subscriptionClientOnce.Do(func() { + if d.useNoopSubscriptionClient { + d.subscriptionClient = noopGraphQLSubscriptionClientInstance + return + } + + if d.transportFactory == nil || d.baseTransport == nil { + d.subscriptionClient = graphql_datasource.NewGraphQLSubscriptionClient( + d.engineCtx, + d.subscriptionClientOptions..., + ) + return + } + + defaultHTTPClient := &http.Client{ + Timeout: d.defaultSubgraphRequestTimeout, + Transport: d.transportFactory.RoundTripper(d.baseTransport), + } + + streamingClient := &http.Client{ + Transport: d.transportFactory.RoundTripper(d.baseTransport), + } + + d.subscriptionClient = graphql_datasource.NewGraphQLSubscriptionClient( + d.engineCtx, + append([]graphql_datasource.SubscriptionClientOption{ + graphql_datasource.WithUpgradeClient(defaultHTTPClient), + graphql_datasource.WithStreamingClient(streamingClient), + }, d.subscriptionClientOptions...)..., + ) + }) - return graphql_datasource.NewFactory(d.engineCtx, defaultHTTPClient, subscriptionClient) + return d.subscriptionClient } func (d *DefaultFactoryResolver) ResolveStaticFactory() (factory plan.PlannerFactory[staticdatasource.Configuration], err error) { diff --git a/router/core/graph_server.go b/router/core/graph_server.go index af206997be..1e47962df3 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -23,6 +23,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/klauspost/compress/gzhttp" "github.com/klauspost/compress/gzip" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" "github.com/wundergraph/cosmo/router/pkg/routerconfig" "go.opentelemetry.io/otel/attribute" otelmetric "go.opentelemetry.io/otel/metric" @@ -681,6 +682,10 @@ type graphMux struct { mux *chi.Mux reused atomic.Bool + wsHandler *WebsocketHandler + executor *Executor + planCacheOnEvictEnabled atomic.Bool + planCache *ristretto.Cache[uint64, *planWithMetaData] planFallbackCache *slowplancache.Cache[*planWithMetaData] persistedOperationCache *ristretto.Cache[uint64, NormalizationCacheEntry] @@ -721,11 +726,21 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e BufferItems: 64, } if srv.cacheWarmup != nil && srv.cacheWarmup.Enabled && srv.cacheWarmup.InMemoryFallback { - planCacheConfig.OnEvict = func(item *ristretto.Item[*planWithMetaData]) { - // This could be called before planFallbackCache is set, but it's not a problem - // because there is a nil guard inside, as well as items should not really be evicted - // on startup - s.planFallbackCache.Set(item.Key, item.Value, item.Value.planningDuration) + if mondaytweaks.SkipPlanCacheOnEvictDuringMuxShutdown { + s.planCacheOnEvictEnabled.Store(true) + planCacheConfig.OnEvict = func(item *ristretto.Item[*planWithMetaData]) { + // This could be called before planFallbackCache is set, but it's not a problem + // because there is a nil guard inside, as well as items should not really be evicted + // on startup + if !s.planCacheOnEvictEnabled.Load() { + return + } + s.planFallbackCache.Set(item.Key, item.Value, item.Value.planningDuration) + } + } else { + planCacheConfig.OnEvict = func(item *ristretto.Item[*planWithMetaData]) { + s.planFallbackCache.Set(item.Key, item.Value, item.Value.planningDuration) + } } } s.planCache, err = ristretto.NewCache[uint64, *planWithMetaData](planCacheConfig) @@ -957,10 +972,31 @@ func (s *graphMux) configureCacheMetrics(srv *graphServer, baseOtelAttributes [] return nil } -func (s *graphMux) Shutdown(ctx context.Context) error { - // cancel the graph muxes context to close its resources like websocket connections, resolvers, etc. - s.cancel() +func closeRistrettoCacheUint64[V any](cache **ristretto.Cache[uint64, V]) { + if *cache != nil { + (*cache).Close() + *cache = nil + } +} + +// releaseOperationCaches drops references to closed Ristretto caches so the old +// graphMux can be collected after shutdown (Close clears entries but retains structs). +func (s *graphMux) releaseOperationCaches() { + closeRistrettoCacheUint64(&s.planCache) + if s.planFallbackCache != nil { + s.planFallbackCache.Close() + s.planFallbackCache = nil + } + closeRistrettoCacheUint64(&s.persistedOperationCache) + closeRistrettoCacheUint64(&s.normalizationCache) + closeRistrettoCacheUint64(&s.variablesNormalizationCache) + closeRistrettoCacheUint64(&s.remapVariablesCache) + closeRistrettoCacheUint64(&s.complexityCalculationCache) + closeRistrettoCacheUint64(&s.validationCache) + closeRistrettoCacheUint64(&s.operationHashCache) +} +func (s *graphMux) closeOperationCachesLegacy() { s.planCache.Close() s.planFallbackCache.Close() s.persistedOperationCache.Close() @@ -970,6 +1006,43 @@ func (s *graphMux) Shutdown(ctx context.Context) error { s.complexityCalculationCache.Close() s.validationCache.Close() s.operationHashCache.Close() +} + +func (s *graphMux) Shutdown(ctx context.Context) error { + if mondaytweaks.DrainWebsocketSubscriptionsBeforeCacheClose { + // Close websocket subscriptions synchronously before tearing down plan caches so + // active preparedPlan and executor references are released first. + if s.wsHandler != nil { + s.wsHandler.ShutdownConnections() + } + } + + // cancel the graph muxes context to close its resources like websocket connections, resolvers, etc. + s.cancel() + + if mondaytweaks.SkipPlanCacheOnEvictDuringMuxShutdown { + // ristretto Close() clears all entries and invokes OnEvict for each one. Disable + // migration into the slow-plan fallback cache during intentional mux shutdown. + s.planCacheOnEvictEnabled.Store(false) + if s.planFallbackCache != nil { + s.planFallbackCache.Wait() + } + } + + if mondaytweaks.CloseExecutorOnGraphMuxShutdown { + if s.executor != nil { + s.executor.Close() + s.executor = nil + } + } + + if mondaytweaks.NilGraphMuxCachesOnShutdown { + s.releaseOperationCaches() + s.wsHandler = nil + s.mux = nil + } else { + s.closeOperationCachesLegacy() + } var err error @@ -1446,22 +1519,30 @@ func (s *graphServer) buildGraphMux( return nil, fmt.Errorf("failed to process retry options: %w", err) } + subscriptionClientOptions := &SubscriptionClientOptions{ + PingInterval: s.engineExecutionConfiguration.WebSocketClientPingInterval, + PingTimeout: s.engineExecutionConfiguration.WebSocketClientPingTimeout, + WriteTimeout: s.engineExecutionConfiguration.WebSocketClientWriteTimeout, + AckTimeout: s.engineExecutionConfiguration.WebSocketClientAckTimeout, + ReadLimit: int64(s.engineExecutionConfiguration.WebSocketClientReadLimit), + DefaultErrorExtensionCode: s.subgraphErrorPropagation.DefaultExtensionCode, + } + // Client-facing WebSocket subscriptions are disabled; skip upstream ping loops + // that would otherwise start one goroutine per subgraph datasource factory. + if mondaytweaks.DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled && + s.webSocketConfiguration != nil && !s.webSocketConfiguration.Enabled { + subscriptionClientOptions.PingInterval = 0 + } + ecb := &ExecutorConfigurationBuilder{ - introspection: s.introspection, - baseURL: s.baseURL, - baseTripper: s.baseTransport, - subgraphTrippers: subgraphTippers, - pluginHost: s.connector, - logger: s.logger, - trackUsageInfo: s.graphqlMetricsConfig.Enabled || s.metricConfig.Prometheus.PromSchemaFieldUsage.Enabled, - subscriptionClientOptions: &SubscriptionClientOptions{ - PingInterval: s.engineExecutionConfiguration.WebSocketClientPingInterval, - PingTimeout: s.engineExecutionConfiguration.WebSocketClientPingTimeout, - WriteTimeout: s.engineExecutionConfiguration.WebSocketClientWriteTimeout, - AckTimeout: s.engineExecutionConfiguration.WebSocketClientAckTimeout, - ReadLimit: int64(s.engineExecutionConfiguration.WebSocketClientReadLimit), - DefaultErrorExtensionCode: s.subgraphErrorPropagation.DefaultExtensionCode, - }, + introspection: s.introspection, + baseURL: s.baseURL, + baseTripper: s.baseTransport, + subgraphTrippers: subgraphTippers, + pluginHost: s.connector, + logger: s.logger, + trackUsageInfo: s.graphqlMetricsConfig.Enabled || s.metricConfig.Prometheus.PromSchemaFieldUsage.Enabled, + subscriptionClientOptions: subscriptionClientOptions, transportOptions: &TransportOptions{ SubgraphTransportOptions: s.subgraphTransportOptions, PreHandlers: s.preOriginHandlers, @@ -1492,11 +1573,15 @@ func (s *graphServer) buildGraphMux( HeartbeatInterval: s.subscriptionHeartbeatInterval, PluginsEnabled: s.plugins.Enabled, InstanceData: s.instanceData, + WebSocketConfiguration: s.webSocketConfiguration, }, ) if err != nil { return nil, fmt.Errorf("failed to build plan configuration: %w", err) } + if mondaytweaks.CloseExecutorOnGraphMuxShutdown { + gm.executor = executor + } s.pubSubProviders = providers if pubSubStartupErr := s.startupPubSubProviders(s.graphServerCtx); pubSubStartupErr != nil { @@ -1830,7 +1915,7 @@ func (s *graphServer) buildGraphMux( }) if s.webSocketConfiguration != nil && s.webSocketConfiguration.Enabled { - wsMiddleware := NewWebsocketMiddleware(graphMuxCtx, WebsocketMiddlewareOptions{ + wsMiddleware, wsHandler := NewWebsocketMiddleware(graphMuxCtx, WebsocketMiddlewareOptions{ OperationProcessor: operationProcessor, OperationBlocker: operationBlocker, Planner: operationPlanner, @@ -1850,6 +1935,9 @@ func (s *graphServer) buildGraphMux( DisableVariablesRemapping: s.engineExecutionConfiguration.DisableVariablesRemapping, ApolloCompatibilityFlags: s.apolloCompatibilityFlags, }) + if mondaytweaks.DrainWebsocketSubscriptionsBeforeCacheClose { + gm.wsHandler = wsHandler + } // When the playground path is equal to the graphql path, we need to handle // ws upgrades and html requests on the same route. @@ -2172,6 +2260,9 @@ func (s *graphServer) Shutdown(ctx context.Context) error { if err := mux.Shutdown(ctx); err != nil { finalErr = errors.Join(finalErr, err) } + if mondaytweaks.NilGraphMuxCachesOnShutdown { + delete(s.graphMuxList, name) + } } // Close idle connections on base and subgraph transports diff --git a/router/core/noop_graphql_subscription_client.go b/router/core/noop_graphql_subscription_client.go new file mode 100644 index 0000000000..79af8d33f5 --- /dev/null +++ b/router/core/noop_graphql_subscription_client.go @@ -0,0 +1,105 @@ +package core + +import ( + "errors" + + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/graphql_datasource" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +var errUpstreamGraphQLSubscriptionsDisabled = errors.New("upstream GraphQL subscriptions are disabled") + +// noopGraphQLSubscriptionClient satisfies graphql-go-tools NewFactory's non-nil +// subscription client requirement without initializing upstream WS/SSE transports. +type noopGraphQLSubscriptionClient struct{} + +func (c *noopGraphQLSubscriptionClient) Subscribe(_ *resolve.Context, _ graphql_datasource.GraphQLSubscriptionOptions, _ resolve.SubscriptionUpdater) error { + return errUpstreamGraphQLSubscriptionsDisabled +} + +var noopGraphQLSubscriptionClientInstance graphql_datasource.GraphQLSubscriptionClient = &noopGraphQLSubscriptionClient{} + +func shouldUseNoopUpstreamSubscriptionClient( + graphqlSchema string, + engineConfig *nodev1.EngineConfiguration, + eventsConfig config.EventsConfiguration, + webSocketConfiguration *config.WebSocketConfiguration, +) bool { + if !schemaHasSubscriptionRootFields(graphqlSchema) { + return true + } + if !clientWebSocketSubscriptionsEnabled(webSocketConfiguration) && !eventSubscriptionsEnabled(engineConfig, eventsConfig) { + return true + } + return false +} + +func schemaHasSubscriptionRootFields(graphqlSchema string) bool { + if graphqlSchema == "" { + return false + } + + doc, report := astparser.ParseGraphqlDocumentString(graphqlSchema) + if report.HasErrors() { + return false + } + if err := asttransform.MergeDefinitionWithBaseSchema(&doc); err != nil { + return false + } + + return subscriptionRootFieldCount(&doc) > 0 +} + +func subscriptionRootFieldCount(doc *ast.Document) int { + if doc.Index.SubscriptionTypeName == nil { + return 0 + } + + node, ok := doc.Index.FirstNodeByNameBytes(doc.Index.SubscriptionTypeName) + if !ok || node.Kind != ast.NodeKindObjectTypeDefinition { + return 0 + } + + return len(doc.ObjectTypeDefinitions[node.Ref].FieldsDefinition.Refs) +} + +func clientWebSocketSubscriptionsEnabled(webSocketConfiguration *config.WebSocketConfiguration) bool { + if webSocketConfiguration == nil { + return true + } + return webSocketConfiguration.Enabled +} + +func eventSubscriptionsEnabled(engineConfig *nodev1.EngineConfiguration, eventsConfig config.EventsConfiguration) bool { + if len(eventsConfig.Providers.Nats) > 0 || + len(eventsConfig.Providers.Kafka) > 0 || + len(eventsConfig.Providers.Redis) > 0 { + return true + } + + if engineConfig == nil { + return false + } + + for _, ds := range engineConfig.GetDatasourceConfigurations() { + if ds.GetKind() == nodev1.DataSourceKind_PUBSUB { + return true + } + customEvents := ds.GetCustomEvents() + if customEvents == nil { + continue + } + if len(customEvents.GetNats()) > 0 || + len(customEvents.GetKafka()) > 0 || + len(customEvents.GetRedis()) > 0 { + return true + } + } + + return false +} diff --git a/router/core/noop_graphql_subscription_client_test.go b/router/core/noop_graphql_subscription_client_test.go new file mode 100644 index 0000000000..a4a286c62c --- /dev/null +++ b/router/core/noop_graphql_subscription_client_test.go @@ -0,0 +1,89 @@ +package core + +import ( + "testing" + + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/stretchr/testify/require" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/graphql_datasource" +) + +func TestShouldUseNoopUpstreamSubscriptionClient_NoSubscriptionRootFields(t *testing.T) { + schema := `type Query { hello: String }` + + require.True(t, shouldUseNoopUpstreamSubscriptionClient( + schema, + nil, + config.EventsConfiguration{}, + &config.WebSocketConfiguration{Enabled: true}, + )) +} + +func TestShouldUseNoopUpstreamSubscriptionClient_EmptySubscriptionType(t *testing.T) { + schema := `type Query { hello: String } +type Subscription { }` + + require.True(t, shouldUseNoopUpstreamSubscriptionClient( + schema, + nil, + config.EventsConfiguration{}, + &config.WebSocketConfiguration{Enabled: true}, + )) +} + +func TestShouldUseNoopUpstreamSubscriptionClient_ClientWSDisabledWithoutEvents(t *testing.T) { + schema := `type Query { hello: String } +type Subscription { onUpdate: String }` + + require.True(t, shouldUseNoopUpstreamSubscriptionClient( + schema, + &nodev1.EngineConfiguration{}, + config.EventsConfiguration{}, + &config.WebSocketConfiguration{Enabled: false}, + )) +} + +func TestShouldUseNoopUpstreamSubscriptionClient_ClientWSDisabledWithPubSubDatasource(t *testing.T) { + schema := `type Query { hello: String } +type Subscription { onUpdate: String }` + + engineConfig := &nodev1.EngineConfiguration{ + DatasourceConfigurations: []*nodev1.DataSourceConfiguration{ + {Kind: nodev1.DataSourceKind_PUBSUB}, + }, + } + + require.False(t, shouldUseNoopUpstreamSubscriptionClient( + schema, + engineConfig, + config.EventsConfiguration{}, + &config.WebSocketConfiguration{Enabled: false}, + )) +} + +func TestShouldUseNoopUpstreamSubscriptionClient_UpstreamSubscriptionsNeeded(t *testing.T) { + schema := `type Query { hello: String } +type Subscription { onUpdate: String }` + + require.False(t, shouldUseNoopUpstreamSubscriptionClient( + schema, + &nodev1.EngineConfiguration{}, + config.EventsConfiguration{}, + &config.WebSocketConfiguration{Enabled: true}, + )) +} + +func TestNoopGraphQLSubscriptionClient_SubscribeReturnsError(t *testing.T) { + err := noopGraphQLSubscriptionClientInstance.Subscribe(nil, graphql_datasource.GraphQLSubscriptionOptions{}, nil) + require.ErrorIs(t, err, errUpstreamGraphQLSubscriptionsDisabled) +} + +func TestSharedSubscriptionClient_UsesNoopWhenConfigured(t *testing.T) { + resolver := &DefaultFactoryResolver{ + useNoopSubscriptionClient: true, + } + + client := resolver.sharedSubscriptionClient() + require.Same(t, noopGraphQLSubscriptionClientInstance, client) +} diff --git a/router/core/operation_planner.go b/router/core/operation_planner.go index f9da57396f..037b23021a 100644 --- a/router/core/operation_planner.go +++ b/router/core/operation_planner.go @@ -18,9 +18,9 @@ import ( ) type planWithMetaData struct { - preparedPlan plan.Plan - operationDocument, schemaDocument *ast.Document - typeFieldUsageInfo []*graphqlschemausage.TypeFieldUsageInfo + preparedPlan plan.Plan + operationDocument *ast.Document + typeFieldUsageInfo []*graphqlschemausage.TypeFieldUsageInfo argumentUsageInfo []*graphqlmetricsv1.ArgumentUsageInfo content string operationName string @@ -96,7 +96,6 @@ func (p *OperationPlanner) planOperation(content string, name string, includeQue return &planWithMetaData{ preparedPlan: preparedPlan, operationDocument: &doc, - schemaDocument: p.executor.RouterSchema, }, nil } diff --git a/router/core/router.go b/router/core/router.go index 5dd087acd1..e1fe1cfbeb 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -19,6 +19,7 @@ import ( "connectrpc.com/connect" "github.com/mitchellh/mapstructure" "github.com/nats-io/nuid" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" "github.com/wundergraph/cosmo/router/pkg/routerconfig" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -27,6 +28,7 @@ import ( sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.uber.org/zap" "google.golang.org/grpc" + "google.golang.org/protobuf/proto" "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1/graphqlmetricsv1connect" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" @@ -627,6 +629,12 @@ func (r *Router) serverTLSConfig() (*tls.Config, error) { // newGraphServer creates a new server. func (r *Router) newServer(ctx context.Context, response *routerconfig.Response) error { + // Extract slow-plan cache entries before building the new graph server, which + // overwrites ReloadPersistentState cache references and before the old graphMux shuts down. + if mondaytweaks.CallOnRouterConfigReloadOnHotReload { + r.reloadPersistentState.OnRouterConfigReload() + } + server, err := newGraphServer(ctx, r, response, r.proxy) if err != nil { r.logger.Error("Failed to create graph server. Keeping the old server", zap.Error(err)) @@ -1098,6 +1106,11 @@ func (r *Router) bootstrap(ctx context.Context) error { } r.staticExecutionConfig = executionConfig + + if hash, hashErr := routerconfig.ManifestMapperSHA256(r.manifestConfig.Path); hashErr == nil && mondaytweaks.SkipManifestReloadWhenMapperUnchanged { + r.lastManifestMapperHash = hash + r.manifestMapperHashSeen = true + } } if err := r.buildClients(ctx); err != nil { @@ -1717,6 +1730,20 @@ func (r *Router) buildManifestConfigWatcher(ctx context.Context, ll *zap.Logger) return } + if mondaytweaks.SkipManifestReloadWhenMapperUnchanged { + mapperHash, err := routerconfig.ManifestMapperSHA256(r.manifestConfig.Path) + if err != nil { + ll.Error("Failed to hash manifest mapper", zap.Error(err)) + return + } + + if r.manifestMapperHashSeen && mapperHash == r.lastManifestMapperHash { + ll.Debug("Manifest mapper unchanged, skipping reload", + zap.String("path", r.manifestConfig.Path)) + return + } + } + cfg, err := routerconfig.AssembleStaticExecutionConfigFromManifest( r.manifestConfig.Path, routerconfig.AssembleConfigRules{ SkipMissingFeatureFlags: r.manifestConfig.SkipMissingFeatureFlags, @@ -1734,6 +1761,24 @@ func (r *Router) buildManifestConfigWatcher(ctx context.Context, ll *zap.Logger) ll.Error("Failed to update server with new config", zap.Error(err)) return } + + if mondaytweaks.SkipManifestReloadWhenMapperUnchanged { + mapperHash, err := routerconfig.ManifestMapperSHA256(r.manifestConfig.Path) + if err != nil { + ll.Error("Failed to hash manifest mapper", zap.Error(err)) + return + } + r.lastManifestMapperHash = mapperHash + r.manifestMapperHashSeen = true + } + + if mondaytweaks.ResetExecutionConfigProtoOnReload { + if old := r.staticExecutionConfig; old != nil && old != cfg { + proto.Reset(old) + } + } + r.staticExecutionConfig = cfg + r.trackExecutionConfigUsage(cfg, true) }, }) diff --git a/router/core/router_config.go b/router/core/router_config.go index 6b380ede12..062ae1409f 100644 --- a/router/core/router_config.go +++ b/router/core/router_config.go @@ -153,6 +153,9 @@ type Config struct { grpcPluginDialOptions []grpc.DialOption tracingAttributes []config.CustomAttribute subscriptionHooks subscriptionHooks + // lastManifestMapperHash skips manifest reload when mapper.json content is unchanged. + lastManifestMapperHash [32]byte + manifestMapperHashSeen bool } // Usage returns an anonymized version of the config for usage tracking diff --git a/router/core/transport.go b/router/core/transport.go index 609229c341..32afe6745e 100644 --- a/router/core/transport.go +++ b/router/core/transport.go @@ -223,6 +223,8 @@ type SubscriptionClientOptions struct { AckTimeout time.Duration ReadLimit int64 DefaultErrorExtensionCode string + // UseNoopClient skips upstream WS/SSE transport initialization when subscriptions are not needed. + UseNoopClient bool } func NewTransport(opts *TransportOptions) *TransportFactory { diff --git a/router/core/websocket.go b/router/core/websocket.go index 95f83864b6..e4cf5b85a0 100644 --- a/router/core/websocket.go +++ b/router/core/websocket.go @@ -70,7 +70,7 @@ type WebsocketMiddlewareOptions struct { ApolloCompatibilityFlags config.ApolloCompatibilityFlags } -func NewWebsocketMiddleware(ctx context.Context, opts WebsocketMiddlewareOptions) func(http.Handler) http.Handler { +func NewWebsocketMiddleware(ctx context.Context, opts WebsocketMiddlewareOptions) (func(http.Handler) http.Handler, *WebsocketHandler) { handler := &WebsocketHandler{ ctx: ctx, operationProcessor: opts.OperationProcessor, @@ -148,7 +148,16 @@ func NewWebsocketMiddleware(ctx context.Context, opts WebsocketMiddlewareOptions } handler.handleUpgradeRequest(w, r) }) + }, handler +} + +// ShutdownConnections closes all active websocket connections and unsubscribes +// any live GraphQL subscriptions before graph mux caches are torn down. +func (h *WebsocketHandler) ShutdownConnections() { + if h == nil { + return } + h.closeAllConnections() } // wsConnectionWrapper is a wrapper around websocket.Conn that allows diff --git a/router/pkg/mondaytweaks/mondaytweaks.go b/router/pkg/mondaytweaks/mondaytweaks.go index 226bbbf8c4..ab53923cf1 100644 --- a/router/pkg/mondaytweaks/mondaytweaks.go +++ b/router/pkg/mondaytweaks/mondaytweaks.go @@ -10,4 +10,49 @@ const ( // struct itself is GC'd — which may be delayed by goroutines still referencing // the owning graphMux — causing ~200-300 MB of retained memory per config reload. ClearSlowPlanCacheOnClose = true + + // OmitSchemaDocumentFromCachedPlans removes the unused schemaDocument field from + // planWithMetaData (compile-time structural change in operation_planner.go). + OmitSchemaDocumentFromCachedPlans = true + + // CallOnRouterConfigReloadOnHotReload invokes ReloadPersistentState.OnRouterConfigReload + // at the start of Router.newServer(), matching the supervisor restart path. + CallOnRouterConfigReloadOnHotReload = true + + // SkipPlanCacheOnEvictDuringMuxShutdown disables ristretto OnEvict migration into + // slowplancache while a graphMux is shutting down intentionally. + SkipPlanCacheOnEvictDuringMuxShutdown = true + + // DrainWebsocketSubscriptionsBeforeCacheClose closes client websocket subscriptions + // synchronously before plan caches are torn down on graphMux shutdown. + DrainWebsocketSubscriptionsBeforeCacheClose = true + + // CloseExecutorOnGraphMuxShutdown nils federation schema refs held by Executor after + // graphMux drain, allowing the old graph generation to be garbage-collected. + CloseExecutorOnGraphMuxShutdown = true + + // NilGraphMuxCachesOnShutdown closes and nils Ristretto caches on shut-down graphMux, + // drops wsHandler/mux references, and removes the mux from graphMuxList. + NilGraphMuxCachesOnShutdown = true + + // ResetExecutionConfigProtoOnReload proto.Resets the previous staticExecutionConfig + // after a successful manifest reload so decoded protojson strings can be collected. + ResetExecutionConfigProtoOnReload = true + + // SkipManifestReloadWhenMapperUnchanged skips manifest watcher reload when mapper.json + // bytes are unchanged. Disabled: latest.json / feature-flag files can change without + // mapper.json changing, which would serve stale config. + SkipManifestReloadWhenMapperUnchanged = false + + // ShareUpstreamSubscriptionClient uses one upstream GraphQLSubscriptionClient per + // DefaultFactoryResolver instead of one per subgraph factory (behavior-altering). + ShareUpstreamSubscriptionClient = true + + // UseNoopUpstreamSubscriptionClientWhenUnused skips upstream WS/SSE transport init + // when subscriptions are not used (behavior-altering). + UseNoopUpstreamSubscriptionClientWhenUnused = true + + // DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled sets PingInterval=0 on + // upstream subscription clients when client-facing websocket is disabled. + DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled = true ) diff --git a/router/pkg/routerconfig/routerconfig.go b/router/pkg/routerconfig/routerconfig.go index 71ba30e846..bda805ac0f 100644 --- a/router/pkg/routerconfig/routerconfig.go +++ b/router/pkg/routerconfig/routerconfig.go @@ -18,6 +18,7 @@ package routerconfig import ( + "crypto/sha256" "encoding/json" "fmt" "io/fs" @@ -106,6 +107,16 @@ func readMapperFile(path string) (map[string]string, error) { return mapper, nil } +// ManifestMapperSHA256 returns the SHA-256 digest of mapper.json bytes. +// Used to skip manifest reload when only the file mtime changed. +func ManifestMapperSHA256(manifestConfigPath string) ([32]byte, error) { + data, err := os.ReadFile(filepath.Join(manifestConfigPath, "mapper.json")) + if err != nil { + return [32]byte{}, fmt.Errorf("failed to read mapper file: %w", err) + } + return sha256.Sum256(data), nil +} + // assembleConfig assembles the router execution config from the base config and the feature flag configs. // The base config is the latest.json file in the manifest directory. // The feature flag configs are the feature-flags/.json files in the manifest directory. From d653d13b38c0f0e196c8d610cb33be6b9313a6d4 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Fri, 3 Jul 2026 10:07:37 +0200 Subject: [PATCH 07/29] perf(router): size-aware execution-plan cache eviction (mondaytweaks flag) (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(router): size-aware execution-plan cache eviction (mondaytweaks flag) The execution-plan Ristretto cache evicts by entry count (every entry costs 1, MaxCost = ExecutionPlanCacheSize), so a single structurally-unique aliased-batch mutation plan occupies one slot regardless of its true retained size. On US cluster group 02 a burst of such giant plans could evict thousands of small hot plans while collectively pinning most of the plan-cache heap — the dominant driver of the 02 vs 01 RSS gap (plan cache ~16% of heap on 02 vs ~4% on 01). Adds mondaytweaks.SizeAwarePlanCache (default off, canary-first): when enabled, MaxCost becomes ExecutionPlanCacheSize * PlanCacheSizeAwareBudgetPerSlotBytes (a byte budget) and each entry is charged estimatePlanCacheCost — a cheap O(number-of-slices) estimate of the retained heap keyed off operationDocument (always populated) plus the raw operation bytes. Giant plans then cost tens of slots and evict first, and total plan-cache heap is bounded to a predictable ceiling. NumCounters stays keyed to the expected entry count for TinyLFU. Default off so existing count-based behaviour (and the plan-fallback-cache tests that rely on ExecutionPlanCacheSize=1 forcing single-entry eviction) is unchanged; intended to be enabled as a per-cluster canary starting with 02. * perf(router): enable size-aware plan cache by default with per-instance opt-out Flip mondaytweaks.SizeAwarePlanCache on by default so the execution-plan cache evicts by estimated retained heap (byte budget) instead of entry count. Defaulting it on would break the plan-fallback-cache integration tests, which rely on count-based single-entry eviction (ExecutionPlanCacheSize=1) to force eviction and exercise the fallback trigger. Since router-tests run with -race, toggling the global flag inside those parallel tests is not safe. Instead add a per-instance opt-out, config.EngineExecutionConfiguration. DisableSizeAwarePlanCache (programmatic only, no env/yaml binding). Both the cache-budget computation (graph_server) and the plan-cache Set cost (operation_planner) now consult sizeAwarePlanCacheEnabled(cfg) so cost and MaxCost always agree, and the fallback tests set DisableSizeAwarePlanCache=true to pin deterministic count-based eviction without mutating the global. Production leaves the override false and follows the mondaytweaks default (on). --- .../operations/plan_fallback_cache_test.go | 7 ++ router/core/graph_server.go | 12 +++- router/core/operation_planner.go | 71 ++++++++++++++++++- .../core/operation_planner_sizeaware_test.go | 71 +++++++++++++++++++ router/pkg/config/config.go | 5 ++ router/pkg/mondaytweaks/mondaytweaks.go | 29 ++++++++ 6 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 router/core/operation_planner_sizeaware_test.go diff --git a/router-tests/operations/plan_fallback_cache_test.go b/router-tests/operations/plan_fallback_cache_test.go index 0ff63d461e..07415a7dc2 100644 --- a/router-tests/operations/plan_fallback_cache_test.go +++ b/router-tests/operations/plan_fallback_cache_test.go @@ -69,6 +69,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = fallbackThreshold cfg.SlowPlanCacheSize = 100 }, @@ -98,6 +99,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = fallbackThreshold cfg.SlowPlanCacheSize = 100 }, @@ -137,6 +139,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = fallbackThreshold cfg.SlowPlanCacheSize = 100 }, @@ -261,6 +264,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = fallbackThreshold cfg.SlowPlanCacheSize = 100 }, @@ -316,6 +320,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = fallbackThreshold cfg.SlowPlanCacheSize = 10 }, @@ -344,6 +349,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = fallbackThreshold cfg.SlowPlanCacheSize = 50 }, @@ -431,6 +437,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = 1 * time.Hour cfg.SlowPlanCacheSize = 100 }, diff --git a/router/core/graph_server.go b/router/core/graph_server.go index 1e47962df3..e65129f7cf 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -718,9 +718,17 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e // different inputs that would generate the same execution plan if srv.engineExecutionConfiguration.ExecutionPlanCacheSize > 0 { + // planCacheMaxCost is the ExecutionPlanCacheSize entry count by default. When + // SizeAwarePlanCache is enabled the cache instead evicts by estimated retained heap + // (see estimatePlanCacheCost / planCacheCost), so MaxCost becomes a byte budget while + // NumCounters stays keyed to the expected entry count for TinyLFU admission. + planCacheMaxCost := srv.engineExecutionConfiguration.ExecutionPlanCacheSize + if sizeAwarePlanCacheEnabled(srv.engineExecutionConfiguration) { + planCacheMaxCost = srv.engineExecutionConfiguration.ExecutionPlanCacheSize * mondaytweaks.PlanCacheSizeAwareBudgetPerSlotBytes + } planCacheConfig := &ristretto.Config[uint64, *planWithMetaData]{ Metrics: srv.metricConfig.OpenTelemetry.GraphqlCache || srv.metricConfig.Prometheus.GraphqlCache, - MaxCost: srv.engineExecutionConfiguration.ExecutionPlanCacheSize, + MaxCost: planCacheMaxCost, NumCounters: srv.engineExecutionConfiguration.ExecutionPlanCacheSize * 10, IgnoreInternalCost: true, BufferItems: 64, @@ -1627,7 +1635,7 @@ func (s *graphServer) buildGraphMux( } } - operationPlanner := NewOperationPlanner(executor, gm.planCache, gm.planFallbackCache, s.planningDurationOverride) + operationPlanner := NewOperationPlanner(executor, gm.planCache, gm.planFallbackCache, s.planningDurationOverride, sizeAwarePlanCacheEnabled(s.engineExecutionConfiguration)) // We support the MCP only on the base graph. Feature flags are not supported yet. if opts.IsBaseGraph() && s.mcpServer != nil { diff --git a/router/core/operation_planner.go b/router/core/operation_planner.go index 037b23021a..92d04ffbd0 100644 --- a/router/core/operation_planner.go +++ b/router/core/operation_planner.go @@ -8,7 +8,9 @@ import ( "golang.org/x/sync/singleflight" graphqlmetricsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" + "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/graphqlschemausage" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" "github.com/wundergraph/cosmo/router/pkg/slowplancache" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" @@ -27,6 +29,63 @@ type planWithMetaData struct { planningDuration time.Duration } +// planCacheCostNodeBytes and planCacheCostUsageBytes approximate the average retained heap +// of a single AST structural element and a single usage-info entry. Ristretto cost is +// relative to MaxCost, so the constants only need to preserve ordering across cache entries; +// they are deliberately coarse and cheap to compute. +const ( + planCacheCostNodeBytes = 48 + planCacheCostUsageBytes = 64 +) + +// estimatePlanCacheCost approximates the retained heap of a cached plan entry so the +// size-aware Ristretto config (mondaytweaks.SizeAwarePlanCache) evicts by memory footprint +// instead of by entry count. It keys off operationDocument, which is always populated (the +// content string is only set when the slow-plan cache is enabled), summing the raw operation +// bytes and the lengths of the operation-side AST slices — both of which scale with operation +// complexity and therefore with the size of the prepared plan tree the entry retains. The +// estimate is intentionally an O(number-of-slices) field read, not a deep walk. +func estimatePlanCacheCost(p *planWithMetaData) int64 { + if p == nil { + return 1 + } + cost := int64(len(p.content) + len(p.operationName)) + if d := p.operationDocument; d != nil { + cost += int64(len(d.Input.RawBytes) + len(d.Input.Variables)) + nodes := len(d.RootNodes) + len(d.Arguments) + len(d.Values) + + len(d.Selections) + len(d.SelectionSets) + len(d.Fields) + + len(d.ObjectFields) + len(d.ObjectValues) + len(d.ListValues) + + len(d.VariableValues) + len(d.StringValues) + len(d.IntValues) + + len(d.FloatValues) + len(d.EnumValues) + len(d.InlineFragments) + + len(d.FragmentSpreads) + len(d.VariableDefinitions) + len(d.Directives) + cost += int64(nodes) * planCacheCostNodeBytes + } + cost += int64(len(p.typeFieldUsageInfo)+len(p.argumentUsageInfo)) * planCacheCostUsageBytes + if cost < 1 { + return 1 + } + return cost +} + +// sizeAwarePlanCacheEnabled reports whether the execution-plan cache should evict by estimated +// retained heap (mondaytweaks.SizeAwarePlanCache) for this engine configuration. The per-config +// DisableSizeAwarePlanCache override forces count-based eviction (tests, or a targeted +// per-router rollback) without mutating the global flag, which matters under -race. +func sizeAwarePlanCacheEnabled(cfg config.EngineExecutionConfiguration) bool { + return mondaytweaks.SizeAwarePlanCache && !cfg.DisableSizeAwarePlanCache +} + +// planCacheCost returns the Ristretto cost for a plan-cache entry: the size-aware estimate +// when size-aware eviction is enabled for this planner, or the historical unit cost of 1. The +// MaxCost configured in buildOperationCaches must use the same decision so cost and budget +// agree. +func (op *OperationPlanner) planCacheCost(p *planWithMetaData) int64 { + if op.sizeAwarePlanCache { + return estimatePlanCacheCost(p) + } + return 1 +} + type OperationPlanner struct { sf singleflight.Group planCache ExecutionPlanCache[uint64, *planWithMetaData] @@ -37,6 +96,12 @@ type OperationPlanner struct { // planningDurationOverride, when set, replaces the measured planning duration. // This is used in tests to simulate slow queries. planningDurationOverride func(content string) time.Duration + + // sizeAwarePlanCache mirrors the plan cache's eviction mode: when true, plan-cache Set + // costs are the estimated retained heap (matching the byte budget MaxCost); when false, + // the historical unit cost of 1 (count-based). Kept per-planner so it agrees with the + // cache built for the same engine configuration. + sizeAwarePlanCache bool } type operationPlannerOpts struct { @@ -59,6 +124,7 @@ func NewOperationPlanner( planCache ExecutionPlanCache[uint64, *planWithMetaData], fallbackCache *slowplancache.Cache[*planWithMetaData], planningDurationOverride func(content string) time.Duration, + sizeAwarePlanCache bool, ) *OperationPlanner { return &OperationPlanner{ planCache: planCache, @@ -66,6 +132,7 @@ func NewOperationPlanner( trackUsageInfo: executor.TrackUsageInfo, slowPlanCache: fallbackCache, planningDurationOverride: planningDurationOverride, + sizeAwarePlanCache: sizeAwarePlanCache, } } @@ -168,7 +235,7 @@ func (p *OperationPlanner) plan(opContext *operationContext, options PlanOptions // found in the plan fallback cache — re-use and re-insert into main cache opContext.preparedPlan = cachedPlan opContext.planCacheHit = true - p.planCache.Set(operationID, cachedPlan, 1) + p.planCache.Set(operationID, cachedPlan, p.planCacheCost(cachedPlan)) } } @@ -191,7 +258,7 @@ func (p *OperationPlanner) plan(opContext *operationContext, options PlanOptions // Set into the main cache after planningDuration is finalized, // because the OnEvict callback reads planningDuration concurrently. - p.planCache.Set(operationID, prepared, 1) + p.planCache.Set(operationID, prepared, p.planCacheCost(prepared)) p.slowPlanCache.Set(operationID, prepared, prepared.planningDuration) return prepared, nil diff --git a/router/core/operation_planner_sizeaware_test.go b/router/core/operation_planner_sizeaware_test.go new file mode 100644 index 0000000000..a2ffce8021 --- /dev/null +++ b/router/core/operation_planner_sizeaware_test.go @@ -0,0 +1,71 @@ +package core + +import ( + "testing" + + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" +) + +// TestEstimatePlanCacheCost verifies the size-aware cost estimate is nil-safe, always +// positive, and monotonically larger for a structurally larger operation — the property the +// size-aware Ristretto config relies on to evict giant aliased-batch plans before hot small +// plans. +func TestEstimatePlanCacheCost(t *testing.T) { + if got := estimatePlanCacheCost(nil); got != 1 { + t.Fatalf("nil plan: want cost 1, got %d", got) + } + + small := &planWithMetaData{operationDocument: &ast.Document{}, content: "query{a}"} + small.operationDocument.Input.RawBytes = []byte("query{a}") + small.operationDocument.Fields = make([]ast.Field, 1) + small.operationDocument.Selections = make([]ast.Selection, 1) + + // Mimics the aliased-batch mutation shape: a large raw body and thousands of AST nodes. + large := &planWithMetaData{operationDocument: &ast.Document{}, content: "large"} + large.operationDocument.Input.RawBytes = make([]byte, 100_000) + large.operationDocument.Fields = make([]ast.Field, 2_000) + large.operationDocument.Arguments = make([]ast.Argument, 4_000) + large.operationDocument.Selections = make([]ast.Selection, 2_000) + large.operationDocument.Values = make([]ast.Value, 4_000) + + cs := estimatePlanCacheCost(small) + cl := estimatePlanCacheCost(large) + if cs < 1 { + t.Fatalf("small plan: want cost >= 1, got %d", cs) + } + if cl <= cs { + t.Fatalf("expected large plan to cost more than small: small=%d large=%d", cs, cl) + } +} + +// TestPlanCacheCostRespectsMode confirms a planner uses the historical unit cost when size- +// aware eviction is disabled, and the size-aware estimate when it is enabled. +func TestPlanCacheCostRespectsMode(t *testing.T) { + p := &planWithMetaData{operationDocument: &ast.Document{}} + p.operationDocument.Fields = make([]ast.Field, 100) + + countBased := &OperationPlanner{sizeAwarePlanCache: false} + if got := countBased.planCacheCost(p); got != 1 { + t.Fatalf("count-based: want cost 1, got %d", got) + } + + sizeAware := &OperationPlanner{sizeAwarePlanCache: true} + if got := sizeAware.planCacheCost(p); got <= 1 { + t.Fatalf("size-aware: want cost > 1, got %d", got) + } +} + +// TestSizeAwarePlanCacheEnabled confirms the per-config DisableSizeAwarePlanCache override +// forces count-based eviction regardless of the mondaytweaks default, and that an unset +// config follows the mondaytweaks default. It reads the global flag but never mutates it, so +// it is safe under -race alongside parallel tests. +func TestSizeAwarePlanCacheEnabled(t *testing.T) { + if sizeAwarePlanCacheEnabled(config.EngineExecutionConfiguration{DisableSizeAwarePlanCache: true}) { + t.Fatal("DisableSizeAwarePlanCache must force count-based eviction") + } + if got := sizeAwarePlanCacheEnabled(config.EngineExecutionConfiguration{}); got != mondaytweaks.SizeAwarePlanCache { + t.Fatalf("unset config should follow mondaytweaks.SizeAwarePlanCache=%v, got %v", mondaytweaks.SizeAwarePlanCache, got) + } +} diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 61e053dd63..0ec44cceb3 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -454,6 +454,11 @@ type EngineExecutionConfiguration struct { MaxConcurrentResolvers int `envDefault:"1024" env:"ENGINE_MAX_CONCURRENT_RESOLVERS" yaml:"max_concurrent_resolvers,omitempty"` EnableNetPoll bool `envDefault:"true" env:"ENGINE_ENABLE_NET_POLL" yaml:"enable_net_poll"` ExecutionPlanCacheSize int64 `envDefault:"1024" env:"ENGINE_EXECUTION_PLAN_CACHE_SIZE" yaml:"execution_plan_cache_size,omitempty"` + // DisableSizeAwarePlanCache forces the execution-plan cache back to count-based eviction + // even when mondaytweaks.SizeAwarePlanCache is enabled. It is set programmatically (tests, + // or a targeted per-router rollback) and has no env/yaml binding; production leaves it + // false and follows the mondaytweaks default. See mondaytweaks.SizeAwarePlanCache. + DisableSizeAwarePlanCache bool `yaml:"-"` SlowPlanCacheSize int64 `envDefault:"300" env:"ENGINE_SLOW_PLAN_CACHE_SIZE" yaml:"slow_plan_cache_size,omitempty"` SlowPlanCacheThreshold time.Duration `envDefault:"100ms" env:"ENGINE_SLOW_PLAN_CACHE_THRESHOLD" yaml:"slow_plan_cache_threshold,omitempty"` MinifySubgraphOperations bool `envDefault:"true" env:"ENGINE_MINIFY_SUBGRAPH_OPERATIONS" yaml:"minify_subgraph_operations"` diff --git a/router/pkg/mondaytweaks/mondaytweaks.go b/router/pkg/mondaytweaks/mondaytweaks.go index ab53923cf1..0db69c7081 100644 --- a/router/pkg/mondaytweaks/mondaytweaks.go +++ b/router/pkg/mondaytweaks/mondaytweaks.go @@ -55,4 +55,33 @@ const ( // DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled sets PingInterval=0 on // upstream subscription clients when client-facing websocket is disabled. DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled = true + + // PlanCacheSizeAwareBudgetPerSlotBytes is the per-configured-slot byte budget used to + // derive the size-aware execution-plan-cache MaxCost when SizeAwarePlanCache is enabled. + // The Ristretto MaxCost becomes ExecutionPlanCacheSize * this value (bytes), and each + // entry is charged its estimated retained heap (see estimatePlanCacheCost). With the + // historical count-based config a single giant aliased-batch plan occupied one of N + // slots regardless of its true size, so a burst of structurally-unique giant plans (US + // cluster group 02) could pin far more heap than the operator budgeted for. 8 KiB/slot + // keeps normal-traffic capacity roughly unchanged (typical plans estimate well under + // this) while charging a 200 KB+ giant plan tens of slots, and — crucially — bounds the + // total plan-cache heap to a predictable ceiling instead of (entry count x worst case). + PlanCacheSizeAwareBudgetPerSlotBytes int64 = 8 * 1024 +) + +var ( + // SizeAwarePlanCache switches the execution-plan Ristretto cache from count-based + // eviction (every entry costs 1, MaxCost = ExecutionPlanCacheSize) to size-aware + // eviction (each entry costs its estimated retained heap, MaxCost = + // ExecutionPlanCacheSize * PlanCacheSizeAwareBudgetPerSlotBytes). This targets the RSS + // gap on US cluster group 02, where structurally-unique aliased-batch mutation plans are + // far larger than typical plans yet, under count-based eviction, could evict thousands of + // small hot plans while collectively pinning most of the heap. + // + // Unlike the behaviour-preserving fixes above, this materially changes cache eviction + // semantics and the plan-cache heap ceiling for every request, so it defaults OFF and is + // intended to be enabled as a per-cluster canary (start with US cluster group 02) rather + // than flipped on globally. It is a var so tests can exercise both cache configurations. + // When false the original count-based path runs unchanged. + SizeAwarePlanCache = true ) From b887fd8a98ff0d95c9adfed2ddfa6a64fe683432 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Fri, 3 Jul 2026 10:19:52 +0200 Subject: [PATCH 08/29] fix(router): join websocket connection handlers before executor.Close to fix shutdown data race (#8) --- router/core/websocket.go | 74 ++++++++++++++++++++----- router/pkg/mondaytweaks/mondaytweaks.go | 9 +-- 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/router/core/websocket.go b/router/core/websocket.go index 7dc4b1af74..62bc8db567 100644 --- a/router/core/websocket.go +++ b/router/core/websocket.go @@ -152,12 +152,15 @@ func NewWebsocketMiddleware(ctx context.Context, opts WebsocketMiddlewareOptions } // ShutdownConnections closes all active websocket connections and unsubscribes -// any live GraphQL subscriptions before graph mux caches are torn down. +// any live GraphQL subscriptions before graph mux caches are torn down. It blocks +// until every sync connection-handler goroutine has returned so executor.Resolver +// is not read concurrently with executor.Close during graphMux shutdown. func (h *WebsocketHandler) ShutdownConnections() { if h == nil { return } h.closeAllConnections() + h.closeSyncConnectionsAndWait() } // wsConnectionWrapper is a wrapper around websocket.Conn that allows @@ -261,6 +264,14 @@ type WebsocketHandler struct { connections map[int]*WebSocketConnectionHandler connectionsMu sync.RWMutex + // syncHandlers tracks connections handled by handleConnectionSync goroutines (used + // when netpoll is unavailable). ShutdownConnections closes them and waits on + // syncHandlersWg so every handler goroutine returns before graphMux tears down + // executor.Resolver. + syncHandlers map[*WebSocketConnectionHandler]struct{} + syncHandlersMu sync.Mutex + syncHandlersWg sync.WaitGroup + stats statistics.EngineStatistics readTimeout time.Duration @@ -453,7 +464,23 @@ func (h *WebsocketHandler) handleUpgradeRequest(w http.ResponseWriter, r *http.R // Handle messages sync when net poller implementation is not available - go h.handleConnectionSync(handler) + h.syncHandlersMu.Lock() + if h.syncHandlers == nil { + h.syncHandlers = make(map[*WebSocketConnectionHandler]struct{}) + } + h.syncHandlers[handler] = struct{}{} + h.syncHandlersMu.Unlock() + + h.syncHandlersWg.Add(1) + go func() { + defer h.syncHandlersWg.Done() + defer func() { + h.syncHandlersMu.Lock() + delete(h.syncHandlers, handler) + h.syncHandlersMu.Unlock() + }() + h.handleConnectionSync(handler) + }() } func (h *WebsocketHandler) handleConnectionSync(handler *WebSocketConnectionHandler) { @@ -621,6 +648,21 @@ func (h *WebsocketHandler) closeAllConnections() { } } +func (h *WebsocketHandler) closeSyncConnectionsAndWait() { + h.syncHandlersMu.Lock() + handlers := make([]*WebSocketConnectionHandler, 0, len(h.syncHandlers)) + for handler := range h.syncHandlers { + handlers = append(handlers, handler) + } + h.syncHandlersMu.Unlock() + + for _, handler := range handlers { + handler.Close(true, wsproto.CloseKindGoingAway) + } + + h.syncHandlersWg.Wait() +} + type websocketResponseWriter struct { id string protocol wsproto.Proto @@ -819,6 +861,8 @@ type WebSocketConnectionHandler struct { apolloCompatibilityFlags config.ApolloCompatibilityFlags clientInfoFromInitialPayload config.WebSocketClientInfoFromInitialPayloadConfiguration + + closeOnce sync.Once } type forwardConfig struct { @@ -1358,19 +1402,21 @@ func (h *WebSocketConnectionHandler) shouldComputeOperationSha256(operationKit * } func (h *WebSocketConnectionHandler) Close(unsubscribe bool, closeKind wsproto.CloseKind) { - if unsubscribe { - // Remove any pending IDs associated with this connection - err := h.graphqlHandler.executor.Resolver.UnsubscribeClient(h.connectionID) - if err != nil { - h.logger.Debug("Unsubscribing client", zap.Error(err)) + h.closeOnce.Do(func() { + if unsubscribe { + // Remove any pending IDs associated with this connection + err := h.graphqlHandler.executor.Resolver.UnsubscribeClient(h.connectionID) + if err != nil { + h.logger.Debug("Unsubscribing client", zap.Error(err)) + } } - } - if err := h.conn.WriteCloseFrame(closeKind.Code, closeKind.Reason); err != nil { - h.logger.Debug("Writing close frame", zap.Error(err)) - } + if err := h.conn.WriteCloseFrame(closeKind.Code, closeKind.Reason); err != nil { + h.logger.Debug("Writing close frame", zap.Error(err)) + } - if err := h.conn.Close(); err != nil { - h.logger.Debug("Closing websocket connection", zap.Error(err)) - } + if err := h.conn.Close(); err != nil { + h.logger.Debug("Closing websocket connection", zap.Error(err)) + } + }) } diff --git a/router/pkg/mondaytweaks/mondaytweaks.go b/router/pkg/mondaytweaks/mondaytweaks.go index 0db69c7081..5d57939589 100644 --- a/router/pkg/mondaytweaks/mondaytweaks.go +++ b/router/pkg/mondaytweaks/mondaytweaks.go @@ -79,9 +79,10 @@ var ( // small hot plans while collectively pinning most of the heap. // // Unlike the behaviour-preserving fixes above, this materially changes cache eviction - // semantics and the plan-cache heap ceiling for every request, so it defaults OFF and is - // intended to be enabled as a per-cluster canary (start with US cluster group 02) rather - // than flipped on globally. It is a var so tests can exercise both cache configurations. - // When false the original count-based path runs unchanged. + // semantics and the plan-cache heap ceiling for every request. It defaults ON so the + // size-aware heap ceiling applies fleet-wide; an individual instance can opt back out to + // the original count-based eviction via EngineExecutionConfiguration.DisableSizeAwarePlanCache + // (used by tests that rely on count-based single-entry eviction). It is a var so tests can + // exercise both cache configurations. When false the original count-based path runs unchanged. SizeAwarePlanCache = true ) From 498a51b756ecd2eb8611f498e8909a09dbd17e1a Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Fri, 3 Jul 2026 10:23:39 +0200 Subject: [PATCH 09/29] feat(router): expose subgraph fetch count context field (#6) --- router/core/access_log_field_handler_test.go | 47 ++++++++++++++++++ router/core/request_context_fields.go | 50 ++++++++++++++------ router/pkg/mondaytweaks/mondaytweaks.go | 4 ++ 3 files changed, 86 insertions(+), 15 deletions(-) diff --git a/router/core/access_log_field_handler_test.go b/router/core/access_log_field_handler_test.go index 7c96e2d333..4ad58200d1 100644 --- a/router/core/access_log_field_handler_test.go +++ b/router/core/access_log_field_handler_test.go @@ -6,6 +6,9 @@ import ( "github.com/wundergraph/cosmo/router/internal/expr" "github.com/wundergraph/cosmo/router/internal/requestlogger" "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" "go.uber.org/zap" "net/http" @@ -127,4 +130,48 @@ func TestAccessLogsFieldHandler(t *testing.T) { require.Equal(t, &ExprWrapError{requestError}, expressionResponse.Interface) }) + t.Run("logs operation subgraph fetch count when monday tweak is enabled", func(t *testing.T) { + t.Parallel() + + require.True(t, mondaytweaks.ExposeOperationSubgraphFetchCountContextField) + + req, err := http.NewRequest(http.MethodPost, "http://localhost:3002/graphql", nil) + require.NoError(t, err) + + rcc := buildRequestContext(requestContextOptions{r: req}) + rcc.operation = &operationContext{ + preparedPlan: &planWithMetaData{ + preparedPlan: &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{Info: &resolve.FetchInfo{DataSourceName: "monolith"}}), + resolve.Single(&resolve.SingleFetch{Info: &resolve.FetchInfo{DataSourceName: "users"}}), + resolve.Single(&resolve.SingleFetch{Info: &resolve.FetchInfo{DataSourceName: "monolith"}}), + ), + }, + }, + }, + } + req = req.WithContext(withRequestContext(req.Context(), rcc)) + + response := RouterAccessLogsFieldHandler( + &zap.Logger{}, + []config.CustomAttribute{{ + Key: "operation_subgraph_fetch_count", + ValueFrom: &config.CustomDynamicAttribute{ + ContextField: ContextFieldOperationSubgraphFetchCount, + }, + }}, + make([]requestlogger.ExpressionAttribute, 0), + nil, + req, + nil, + nil, + ) + + require.Len(t, response, 2) + require.Equal(t, "operation_subgraph_fetch_count", response[1].Key) + require.Equal(t, int64(3), response[1].Integer) + }) + } diff --git a/router/core/request_context_fields.go b/router/core/request_context_fields.go index 05c59e1edc..38cf7a777b 100644 --- a/router/core/request_context_fields.go +++ b/router/core/request_context_fields.go @@ -14,27 +14,29 @@ import ( "github.com/wundergraph/cosmo/router/internal/requestlogger" "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/logging" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) // Context field names used to expose information about the operation being executed. const ( - ContextFieldOperationName = "operation_name" - ContextFieldOperationHash = "operation_hash" - ContextFieldOperationType = "operation_type" - ContextFieldOperationServices = "operation_service_names" - ContextFieldGraphQLErrorCodes = "graphql_error_codes" - ContextFieldGraphQLErrorServices = "graphql_error_service_names" - ContextFieldOperationParsingTime = "operation_parsing_time" - ContextFieldOperationValidationTime = "operation_validation_time" - ContextFieldOperationPlanningTime = "operation_planning_time" - ContextFieldOperationNormalizationTime = "operation_normalization_time" - ContextFieldPersistedOperationSha256 = "persisted_operation_sha256" - ContextFieldOperationSha256 = "operation_sha256" - ContextFieldResponseErrorMessage = "response_error_message" - ContextFieldRequestError = "request_error" - ContextFieldRouterConfigVersion = "router_config_version" + ContextFieldOperationName = "operation_name" + ContextFieldOperationHash = "operation_hash" + ContextFieldOperationType = "operation_type" + ContextFieldOperationServices = "operation_service_names" + ContextFieldGraphQLErrorCodes = "graphql_error_codes" + ContextFieldGraphQLErrorServices = "graphql_error_service_names" + ContextFieldOperationParsingTime = "operation_parsing_time" + ContextFieldOperationValidationTime = "operation_validation_time" + ContextFieldOperationPlanningTime = "operation_planning_time" + ContextFieldOperationSubgraphFetchCount = "operation_subgraph_fetch_count" + ContextFieldOperationNormalizationTime = "operation_normalization_time" + ContextFieldPersistedOperationSha256 = "persisted_operation_sha256" + ContextFieldOperationSha256 = "operation_sha256" + ContextFieldResponseErrorMessage = "response_error_message" + ContextFieldRequestError = "request_error" + ContextFieldRouterConfigVersion = "router_config_version" ) // Helper functions to create zap fields for custom attributes. @@ -73,6 +75,13 @@ func NewBoolLogField(val bool, attribute config.CustomAttribute) zap.Field { return zap.Skip() } +func NewIntLogField(val int, attribute config.CustomAttribute) zap.Field { + if val != 0 { + return zap.Int(attribute.Key, val) + } + return zap.Skip() +} + func NewStringSliceLogField(val []string, attribute config.CustomAttribute) zap.Field { if v := val; len(v) > 0 { return zap.Strings(attribute.Key, v) @@ -198,6 +207,8 @@ func GetLogFieldFromCustomAttribute(field config.CustomAttribute, req *requestCo return NewStringLogField(v, field) case bool: return NewBoolLogField(v, field) + case int: + return NewIntLogField(v, field) case []string: return NewStringSliceLogField(v, field) case time.Duration: @@ -244,6 +255,15 @@ func getCustomDynamicAttributeValue( return "" } return reqContext.operation.planningTime + case ContextFieldOperationSubgraphFetchCount: + if !mondaytweaks.ExposeOperationSubgraphFetchCountContextField || reqContext.operation == nil { + return "" + } + stats, statsErr := reqContext.operation.QueryPlanStats() + if statsErr != nil { + return "" + } + return stats.TotalSubgraphFetches case ContextFieldOperationNormalizationTime: if reqContext.operation == nil { return "" diff --git a/router/pkg/mondaytweaks/mondaytweaks.go b/router/pkg/mondaytweaks/mondaytweaks.go index 5d57939589..ccfa086100 100644 --- a/router/pkg/mondaytweaks/mondaytweaks.go +++ b/router/pkg/mondaytweaks/mondaytweaks.go @@ -56,6 +56,10 @@ const ( // upstream subscription clients when client-facing websocket is disabled. DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled = true + // ExposeOperationSubgraphFetchCountContextField enables the + // operation_subgraph_fetch_count access-log context field. + ExposeOperationSubgraphFetchCountContextField = true + // PlanCacheSizeAwareBudgetPerSlotBytes is the per-configured-slot byte budget used to // derive the size-aware execution-plan-cache MaxCost when SizeAwarePlanCache is enabled. // The Ristretto MaxCost becomes ExecutionPlanCacheSize * this value (bytes), and each From 9455ffe24faa165683eda03261cdf1509c9ec7eb Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 7 Jul 2026 10:29:41 +0200 Subject: [PATCH 10/29] chore(router): remove mondaytweaks memory-leak flags after upstream #3035 Local reload benchmarks showed ~7.6 MB/reload retained with flags off vs pre-fix ~260 MB, so the flag-guarded reload cleanup is redundant with upstream graphMux closure fixes. Drop the eight memory-leak toggles and their guarded code while keeping behavior/perf tweaks and the structural schemaDocument removal in cached plans. --- router/core/executor.go | 15 +- router/core/executor_test.go | 37 ----- router/core/graph_server.go | 134 +++++------------- router/core/router.go | 43 ------ router/core/router_config.go | 3 - router/core/websocket.go | 54 +------ router/pkg/mondaytweaks/mondaytweaks.go | 68 +-------- router/pkg/slowplancache/slow_plan_cache.go | 10 -- .../pkg/slowplancache/slow_plan_cache_test.go | 23 --- 9 files changed, 40 insertions(+), 347 deletions(-) delete mode 100644 router/core/executor_test.go diff --git a/router/core/executor.go b/router/core/executor.go index 69e4b02cf7..c7d287be86 100644 --- a/router/core/executor.go +++ b/router/core/executor.go @@ -8,10 +8,10 @@ import ( "go.uber.org/zap" - "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" 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/grpcconnector" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" pubsub_datasource "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" @@ -52,19 +52,6 @@ type Executor struct { TrackUsageInfo bool } -// Close releases schema and planner references held by the executor so a replaced -// graph mux can be garbage-collected after config reload. -func (e *Executor) Close() { - if e == nil { - return - } - e.ClientSchema = nil - e.RouterSchema = nil - e.PlanConfig = plan.Configuration{} - e.RenameTypeNames = nil - e.Resolver = nil -} - type ExecutorBuildOptions struct { EngineConfig *nodev1.EngineConfiguration Subgraphs []*nodev1.Subgraph diff --git a/router/core/executor_test.go b/router/core/executor_test.go deleted file mode 100644 index a157b09a49..0000000000 --- a/router/core/executor_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package core - -import ( - "testing" - - "github.com/stretchr/testify/require" - "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" -) - -func TestExecutorCloseReleasesSchemaReferences(t *testing.T) { - t.Parallel() - - executor := &Executor{ - ClientSchema: &ast.Document{}, - RouterSchema: &ast.Document{}, - PlanConfig: plan.Configuration{DataSources: []plan.DataSource{nil}}, - RenameTypeNames: nil, - } - - executor.Close() - - require.Nil(t, executor.ClientSchema) - require.Nil(t, executor.RouterSchema) - require.Empty(t, executor.PlanConfig.DataSources) - require.Nil(t, executor.RenameTypeNames) - require.Nil(t, executor.Resolver) -} - -func TestExecutorCloseNilSafe(t *testing.T) { - t.Parallel() - - var executor *Executor - require.NotPanics(t, func() { - executor.Close() - }) -} diff --git a/router/core/graph_server.go b/router/core/graph_server.go index f83aacc246..475f877f6d 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -701,10 +701,6 @@ type graphMux struct { reused atomic.Bool finalized atomic.Bool - wsHandler *WebsocketHandler - executor *Executor - planCacheOnEvictEnabled atomic.Bool - planCache *ristretto.Cache[uint64, *planWithMetaData] planFallbackCache *slowplancache.Cache[*planWithMetaData] persistedOperationCache *ristretto.Cache[uint64, NormalizationCacheEntry] @@ -755,21 +751,8 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e BufferItems: 64, } if srv.cacheWarmup != nil && srv.cacheWarmup.Enabled && srv.cacheWarmup.InMemoryFallback { - if mondaytweaks.SkipPlanCacheOnEvictDuringMuxShutdown { - s.planCacheOnEvictEnabled.Store(true) - planCacheConfig.OnEvict = func(item *ristretto.Item[*planWithMetaData]) { - // This could be called before planFallbackCache is set, but it's not a problem - // because there is a nil guard inside, as well as items should not really be evicted - // on startup - if !s.planCacheOnEvictEnabled.Load() { - return - } - s.planFallbackCache.Set(item.Key, item.Value, item.Value.planningDuration) - } - } else { - planCacheConfig.OnEvict = func(item *ristretto.Item[*planWithMetaData]) { - s.planFallbackCache.Set(item.Key, item.Value, item.Value.planningDuration) - } + planCacheConfig.OnEvict = func(item *ristretto.Item[*planWithMetaData]) { + s.planFallbackCache.Set(item.Key, item.Value, item.Value.planningDuration) } } s.planCache, err = ristretto.NewCache(planCacheConfig) @@ -1020,81 +1003,41 @@ func (s *graphMux) stopPubsubProviders(ctx context.Context) error { }, providerTimeout, "pubsub provider shutdown timed out") } -func closeRistrettoCacheUint64[V any](cache **ristretto.Cache[uint64, V]) { - if *cache != nil { - (*cache).Close() - *cache = nil - } -} - -// releaseOperationCaches drops references to closed Ristretto caches so the old -// graphMux can be collected after shutdown (Close clears entries but retains structs). -func (s *graphMux) releaseOperationCaches() { - closeRistrettoCacheUint64(&s.planCache) - if s.planFallbackCache != nil { - s.planFallbackCache.Close() - s.planFallbackCache = nil - } - closeRistrettoCacheUint64(&s.persistedOperationCache) - closeRistrettoCacheUint64(&s.normalizationCache) - closeRistrettoCacheUint64(&s.variablesNormalizationCache) - closeRistrettoCacheUint64(&s.remapVariablesCache) - closeRistrettoCacheUint64(&s.complexityCalculationCache) - closeRistrettoCacheUint64(&s.validationCache) - closeRistrettoCacheUint64(&s.operationHashCache) -} - -func (s *graphMux) closeOperationCachesLegacy() { - s.planCache.Close() - s.planFallbackCache.Close() - s.persistedOperationCache.Close() - s.normalizationCache.Close() - s.variablesNormalizationCache.Close() - s.remapVariablesCache.Close() - s.complexityCalculationCache.Close() - s.validationCache.Close() - s.operationHashCache.Close() -} - func (s *graphMux) Shutdown(ctx context.Context) error { // Make sure we do not shutdown the mux multiple times if !s.finalized.CompareAndSwap(false, true) { return nil } - if mondaytweaks.DrainWebsocketSubscriptionsBeforeCacheClose { - // Close websocket subscriptions synchronously before tearing down plan caches so - // active preparedPlan and executor references are released first. - if s.wsHandler != nil { - s.wsHandler.ShutdownConnections() - } - } - // cancel the graph muxes context to close its resources like websocket connections, resolvers, etc. s.cancel() - if mondaytweaks.SkipPlanCacheOnEvictDuringMuxShutdown { - // ristretto Close() clears all entries and invokes OnEvict for each one. Disable - // migration into the slow-plan fallback cache during intentional mux shutdown. - s.planCacheOnEvictEnabled.Store(false) - if s.planFallbackCache != nil { - s.planFallbackCache.Wait() - } + if s.planCache != nil { + s.planCache.Close() } - - if mondaytweaks.CloseExecutorOnGraphMuxShutdown { - if s.executor != nil { - s.executor.Close() - s.executor = nil - } + if s.planFallbackCache != nil { + s.planFallbackCache.Close() } - - if mondaytweaks.NilGraphMuxCachesOnShutdown { - s.releaseOperationCaches() - s.wsHandler = nil - s.mux = nil - } else { - s.closeOperationCachesLegacy() + if s.persistedOperationCache != nil { + s.persistedOperationCache.Close() + } + if s.normalizationCache != nil { + s.normalizationCache.Close() + } + if s.variablesNormalizationCache != nil { + s.variablesNormalizationCache.Close() + } + if s.remapVariablesCache != nil { + s.remapVariablesCache.Close() + } + if s.complexityCalculationCache != nil { + s.complexityCalculationCache.Close() + } + if s.validationCache != nil { + s.validationCache.Close() + } + if s.operationHashCache != nil { + s.operationHashCache.Close() } var err error @@ -1605,13 +1548,13 @@ func (s *graphServer) buildGraphMux( ecb := &ExecutorConfigurationBuilder{ introspection: s.introspection, - baseURL: s.baseURL, - baseTripper: s.baseTransport, - subgraphTrippers: subgraphTippers, - pluginHost: s.connector, - logger: s.logger, - trackUsageInfo: s.graphqlMetricsConfig.Enabled || s.metricConfig.Prometheus.PromSchemaFieldUsage.Enabled, - subscriptionClientOptions: subscriptionClientOptions, + baseURL: s.baseURL, + baseTripper: s.baseTransport, + subgraphTrippers: subgraphTippers, + pluginHost: s.connector, + logger: s.logger, + trackUsageInfo: s.graphqlMetricsConfig.Enabled || s.metricConfig.Prometheus.PromSchemaFieldUsage.Enabled, + subscriptionClientOptions: subscriptionClientOptions, transportOptions: &TransportOptions{ SubgraphTransportOptions: s.subgraphTransportOptions, PreHandlers: s.preOriginHandlers, @@ -1648,9 +1591,6 @@ func (s *graphServer) buildGraphMux( if err != nil { return nil, fmt.Errorf("failed to build plan configuration: %w", err) } - if mondaytweaks.CloseExecutorOnGraphMuxShutdown { - gm.executor = executor - } if s.engineStats != nil && executor.Resolver != nil { s.engineStats.RegisterResolver(executor.Resolver) @@ -1994,7 +1934,7 @@ func (s *graphServer) buildGraphMux( }) if s.webSocketConfiguration != nil && s.webSocketConfiguration.Enabled { - wsMiddleware, wsHandler := NewWebsocketMiddleware(graphMuxCtx, WebsocketMiddlewareOptions{ + wsMiddleware, _ := NewWebsocketMiddleware(graphMuxCtx, WebsocketMiddlewareOptions{ OperationProcessor: operationProcessor, OperationBlocker: operationBlocker, Planner: operationPlanner, @@ -2014,9 +1954,6 @@ func (s *graphServer) buildGraphMux( DisableVariablesRemapping: s.engineExecutionConfiguration.DisableVariablesRemapping, ApolloCompatibilityFlags: s.apolloCompatibilityFlags, }) - if mondaytweaks.DrainWebsocketSubscriptionsBeforeCacheClose { - gm.wsHandler = wsHandler - } // When the playground path is equal to the graphql path, we need to handle // ws upgrades and html requests on the same route. @@ -2381,9 +2318,6 @@ func (s *graphServer) Shutdown(ctx context.Context) error { if err := mux.Shutdown(ctx); err != nil { finalErr = errors.Join(finalErr, err) } - if mondaytweaks.NilGraphMuxCachesOnShutdown { - delete(s.graphMuxList, name) - } } // Close idle connections on base and subgraph transports diff --git a/router/core/router.go b/router/core/router.go index 81a297f6e2..f7a0f49bdd 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -19,7 +19,6 @@ import ( "connectrpc.com/connect" "github.com/mitchellh/mapstructure" "github.com/nats-io/nuid" - "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" "github.com/wundergraph/cosmo/router/pkg/routerconfig" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -28,7 +27,6 @@ import ( sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.uber.org/zap" "google.golang.org/grpc" - "google.golang.org/protobuf/proto" "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1/graphqlmetricsv1connect" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" @@ -629,12 +627,6 @@ func (r *Router) serverTLSConfig() (*tls.Config, error) { // newGraphServer creates a new server. func (r *Router) newServer(ctx context.Context, response *routerconfig.Response) error { - // Extract slow-plan cache entries before building the new graph server, which - // overwrites ReloadPersistentState cache references and before the old graphMux shuts down. - if mondaytweaks.CallOnRouterConfigReloadOnHotReload { - r.reloadPersistentState.OnRouterConfigReload() - } - server, err := newGraphServer(ctx, r, response, r.proxy) if err != nil { r.logger.Error("Failed to create graph server. Keeping the old server", zap.Error(err)) @@ -1114,11 +1106,6 @@ func (r *Router) bootstrap(ctx context.Context) error { } r.staticExecutionConfig = executionConfig - - if hash, hashErr := routerconfig.ManifestMapperSHA256(r.manifestConfig.Path); hashErr == nil && mondaytweaks.SkipManifestReloadWhenMapperUnchanged { - r.lastManifestMapperHash = hash - r.manifestMapperHashSeen = true - } } if err := r.buildClients(ctx); err != nil { @@ -1738,20 +1725,6 @@ func (r *Router) buildManifestConfigWatcher(ctx context.Context, ll *zap.Logger) return } - if mondaytweaks.SkipManifestReloadWhenMapperUnchanged { - mapperHash, err := routerconfig.ManifestMapperSHA256(r.manifestConfig.Path) - if err != nil { - ll.Error("Failed to hash manifest mapper", zap.Error(err)) - return - } - - if r.manifestMapperHashSeen && mapperHash == r.lastManifestMapperHash { - ll.Debug("Manifest mapper unchanged, skipping reload", - zap.String("path", r.manifestConfig.Path)) - return - } - } - cfg, err := routerconfig.AssembleStaticExecutionConfigFromManifest( r.manifestConfig.Path, routerconfig.AssembleConfigRules{ SkipMissingFeatureFlags: r.manifestConfig.SkipMissingFeatureFlags, @@ -1769,22 +1742,6 @@ func (r *Router) buildManifestConfigWatcher(ctx context.Context, ll *zap.Logger) ll.Error("Failed to update server with new config", zap.Error(err)) return } - - if mondaytweaks.SkipManifestReloadWhenMapperUnchanged { - mapperHash, err := routerconfig.ManifestMapperSHA256(r.manifestConfig.Path) - if err != nil { - ll.Error("Failed to hash manifest mapper", zap.Error(err)) - return - } - r.lastManifestMapperHash = mapperHash - r.manifestMapperHashSeen = true - } - - if mondaytweaks.ResetExecutionConfigProtoOnReload { - if old := r.staticExecutionConfig; old != nil && old != cfg { - proto.Reset(old) - } - } r.staticExecutionConfig = cfg r.trackExecutionConfigUsage(cfg, true) }, diff --git a/router/core/router_config.go b/router/core/router_config.go index 4931e5346a..239fe721b3 100644 --- a/router/core/router_config.go +++ b/router/core/router_config.go @@ -158,9 +158,6 @@ type Config struct { grpcPluginDialOptions []grpc.DialOption tracingAttributes []config.CustomAttribute subscriptionHooks subscriptionHooks - // lastManifestMapperHash skips manifest reload when mapper.json content is unchanged. - lastManifestMapperHash [32]byte - manifestMapperHashSeen bool } // Usage returns an anonymized version of the config for usage tracking diff --git a/router/core/websocket.go b/router/core/websocket.go index a57a483b40..c7ab8fe7a0 100644 --- a/router/core/websocket.go +++ b/router/core/websocket.go @@ -152,18 +152,6 @@ func NewWebsocketMiddleware(ctx context.Context, opts WebsocketMiddlewareOptions }, handler } -// ShutdownConnections closes all active websocket connections and unsubscribes -// any live GraphQL subscriptions before graph mux caches are torn down. It blocks -// until every sync connection-handler goroutine has returned so executor.Resolver -// is not read concurrently with executor.Close during graphMux shutdown. -func (h *WebsocketHandler) ShutdownConnections() { - if h == nil { - return - } - h.closeAllConnections() - h.closeSyncConnectionsAndWait() -} - // wsConnectionWrapper is a wrapper around websocket.Conn that allows // writing from multiple goroutines type wsConnectionWrapper struct { @@ -265,14 +253,6 @@ type WebsocketHandler struct { connections map[int]*WebSocketConnectionHandler connectionsMu sync.RWMutex - // syncHandlers tracks connections handled by handleConnectionSync goroutines (used - // when netpoll is unavailable). ShutdownConnections closes them and waits on - // syncHandlersWg so every handler goroutine returns before graphMux tears down - // executor.Resolver. - syncHandlers map[*WebSocketConnectionHandler]struct{} - syncHandlersMu sync.Mutex - syncHandlersWg sync.WaitGroup - stats statistics.EngineStatistics readTimeout time.Duration @@ -464,24 +444,7 @@ func (h *WebsocketHandler) handleUpgradeRequest(w http.ResponseWriter, r *http.R } // Handle messages sync when net poller implementation is not available - - h.syncHandlersMu.Lock() - if h.syncHandlers == nil { - h.syncHandlers = make(map[*WebSocketConnectionHandler]struct{}) - } - h.syncHandlers[handler] = struct{}{} - h.syncHandlersMu.Unlock() - - h.syncHandlersWg.Add(1) - go func() { - defer h.syncHandlersWg.Done() - defer func() { - h.syncHandlersMu.Lock() - delete(h.syncHandlers, handler) - h.syncHandlersMu.Unlock() - }() - h.handleConnectionSync(handler) - }() + go h.handleConnectionSync(handler) } func (h *WebsocketHandler) handleConnectionSync(handler *WebSocketConnectionHandler) { @@ -649,21 +612,6 @@ func (h *WebsocketHandler) closeAllConnections() { } } -func (h *WebsocketHandler) closeSyncConnectionsAndWait() { - h.syncHandlersMu.Lock() - handlers := make([]*WebSocketConnectionHandler, 0, len(h.syncHandlers)) - for handler := range h.syncHandlers { - handlers = append(handlers, handler) - } - h.syncHandlersMu.Unlock() - - for _, handler := range handlers { - handler.Close(true, wsproto.CloseKindGoingAway) - } - - h.syncHandlersWg.Wait() -} - type websocketResponseWriter struct { id string protocol wsproto.Proto diff --git a/router/pkg/mondaytweaks/mondaytweaks.go b/router/pkg/mondaytweaks/mondaytweaks.go index ccfa086100..6ca9e3d638 100644 --- a/router/pkg/mondaytweaks/mondaytweaks.go +++ b/router/pkg/mondaytweaks/mondaytweaks.go @@ -1,49 +1,10 @@ // Package mondaytweaks defines compile-time feature flags for monday.com-specific -// behavioural overrides in the cosmo router. All monday-specific toggles live in -// one place so they are easy to audit and remove when upstreamed. +// behavioural overrides in the cosmo router. Keep only non-memory-leak behavior +// and performance toggles here; memory-reload cleanup notes live in +// `wiki/reference/cosmo-router-reload-memory-benchmark-tooling`. package mondaytweaks const ( - // ClearSlowPlanCacheOnClose makes slowplancache.Close() clear all entries from - // the sync.Map immediately, releasing references to cached values (including - // *ast.Document schema pointers). Without this, entries survive until the Cache - // struct itself is GC'd — which may be delayed by goroutines still referencing - // the owning graphMux — causing ~200-300 MB of retained memory per config reload. - ClearSlowPlanCacheOnClose = true - - // OmitSchemaDocumentFromCachedPlans removes the unused schemaDocument field from - // planWithMetaData (compile-time structural change in operation_planner.go). - OmitSchemaDocumentFromCachedPlans = true - - // CallOnRouterConfigReloadOnHotReload invokes ReloadPersistentState.OnRouterConfigReload - // at the start of Router.newServer(), matching the supervisor restart path. - CallOnRouterConfigReloadOnHotReload = true - - // SkipPlanCacheOnEvictDuringMuxShutdown disables ristretto OnEvict migration into - // slowplancache while a graphMux is shutting down intentionally. - SkipPlanCacheOnEvictDuringMuxShutdown = true - - // DrainWebsocketSubscriptionsBeforeCacheClose closes client websocket subscriptions - // synchronously before plan caches are torn down on graphMux shutdown. - DrainWebsocketSubscriptionsBeforeCacheClose = true - - // CloseExecutorOnGraphMuxShutdown nils federation schema refs held by Executor after - // graphMux drain, allowing the old graph generation to be garbage-collected. - CloseExecutorOnGraphMuxShutdown = true - - // NilGraphMuxCachesOnShutdown closes and nils Ristretto caches on shut-down graphMux, - // drops wsHandler/mux references, and removes the mux from graphMuxList. - NilGraphMuxCachesOnShutdown = true - - // ResetExecutionConfigProtoOnReload proto.Resets the previous staticExecutionConfig - // after a successful manifest reload so decoded protojson strings can be collected. - ResetExecutionConfigProtoOnReload = true - - // SkipManifestReloadWhenMapperUnchanged skips manifest watcher reload when mapper.json - // bytes are unchanged. Disabled: latest.json / feature-flag files can change without - // mapper.json changing, which would serve stale config. - SkipManifestReloadWhenMapperUnchanged = false - // ShareUpstreamSubscriptionClient uses one upstream GraphQLSubscriptionClient per // DefaultFactoryResolver instead of one per subgraph factory (behavior-altering). ShareUpstreamSubscriptionClient = true @@ -62,31 +23,10 @@ const ( // PlanCacheSizeAwareBudgetPerSlotBytes is the per-configured-slot byte budget used to // derive the size-aware execution-plan-cache MaxCost when SizeAwarePlanCache is enabled. - // The Ristretto MaxCost becomes ExecutionPlanCacheSize * this value (bytes), and each - // entry is charged its estimated retained heap (see estimatePlanCacheCost). With the - // historical count-based config a single giant aliased-batch plan occupied one of N - // slots regardless of its true size, so a burst of structurally-unique giant plans (US - // cluster group 02) could pin far more heap than the operator budgeted for. 8 KiB/slot - // keeps normal-traffic capacity roughly unchanged (typical plans estimate well under - // this) while charging a 200 KB+ giant plan tens of slots, and — crucially — bounds the - // total plan-cache heap to a predictable ceiling instead of (entry count x worst case). PlanCacheSizeAwareBudgetPerSlotBytes int64 = 8 * 1024 ) var ( - // SizeAwarePlanCache switches the execution-plan Ristretto cache from count-based - // eviction (every entry costs 1, MaxCost = ExecutionPlanCacheSize) to size-aware - // eviction (each entry costs its estimated retained heap, MaxCost = - // ExecutionPlanCacheSize * PlanCacheSizeAwareBudgetPerSlotBytes). This targets the RSS - // gap on US cluster group 02, where structurally-unique aliased-batch mutation plans are - // far larger than typical plans yet, under count-based eviction, could evict thousands of - // small hot plans while collectively pinning most of the heap. - // - // Unlike the behaviour-preserving fixes above, this materially changes cache eviction - // semantics and the plan-cache heap ceiling for every request. It defaults ON so the - // size-aware heap ceiling applies fleet-wide; an individual instance can opt back out to - // the original count-based eviction via EngineExecutionConfiguration.DisableSizeAwarePlanCache - // (used by tests that rely on count-based single-entry eviction). It is a var so tests can - // exercise both cache configurations. When false the original count-based path runs unchanged. + // SizeAwarePlanCache — monday perf tweak (#7 OPEN); not an upstream memory-leak fix. SizeAwarePlanCache = true ) diff --git a/router/pkg/slowplancache/slow_plan_cache.go b/router/pkg/slowplancache/slow_plan_cache.go index e14380309a..17fba9aa5f 100644 --- a/router/pkg/slowplancache/slow_plan_cache.go +++ b/router/pkg/slowplancache/slow_plan_cache.go @@ -6,8 +6,6 @@ import ( "sync" "sync/atomic" "time" - - "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" ) // Entry holds a cached value and the duration it took to produce. @@ -224,13 +222,5 @@ func (c *Cache[V]) Close() { // This downside is also there in ristretto (if set is called concurrently) // it is even documented in the ristretto code as a comment close(c.writeCh) - - if mondaytweaks.ClearSlowPlanCacheOnClose { - c.entries.Range(func(key, _ any) bool { - c.entries.Delete(key) - return true - }) - c.size = 0 - } }) } diff --git a/router/pkg/slowplancache/slow_plan_cache_test.go b/router/pkg/slowplancache/slow_plan_cache_test.go index f8d5e1313c..69734bf771 100644 --- a/router/pkg/slowplancache/slow_plan_cache_test.go +++ b/router/pkg/slowplancache/slow_plan_cache_test.go @@ -411,29 +411,6 @@ func TestCache_DoubleClose(t *testing.T) { }) } -func TestCache_CloseReleasesEntries(t *testing.T) { - t.Parallel() - c, err := New[*testPlan](10, 0) - require.NoError(t, err) - - c.Set(1, &testPlan{content: "q1"}, 10*time.Millisecond) - c.Set(2, &testPlan{content: "q2"}, 20*time.Millisecond) - c.Set(3, &testPlan{content: "q3"}, 30*time.Millisecond) - c.Wait() - - c.Close() - - // Verify the underlying sync.Map is empty — entries must not pin - // referenced objects (e.g. schema AST documents) after Close. - count := 0 - c.entries.Range(func(_, _ any) bool { - count++ - return true - }) - require.Equal(t, 0, count, "entries sync.Map must be empty after Close") - require.Equal(t, int64(0), c.size) -} - func BenchmarkCache_Set(b *testing.B) { c, err := New[*testPlan](1000, 0) require.NoError(b, err) From e747f7905cd4c5942f40c3577a261d5b1feb82a8 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 7 Jul 2026 13:26:33 +0200 Subject: [PATCH 11/29] test(router): refresh config goldens for DisableSizeAwarePlanCache field --- router/pkg/config/testdata/config_defaults.json | 1 + router/pkg/config/testdata/config_full.json | 1 + 2 files changed, 2 insertions(+) diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 903baa297d..d1a876c597 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -471,6 +471,7 @@ "MaxConcurrentResolvers": 1024, "EnableNetPoll": true, "ExecutionPlanCacheSize": 1024, + "DisableSizeAwarePlanCache": false, "SlowPlanCacheSize": 300, "SlowPlanCacheThreshold": 100000000, "MinifySubgraphOperations": true, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index 727d2d7230..394906038c 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -923,6 +923,7 @@ "MaxConcurrentResolvers": 32, "EnableNetPoll": true, "ExecutionPlanCacheSize": 1024, + "DisableSizeAwarePlanCache": false, "SlowPlanCacheSize": 300, "SlowPlanCacheThreshold": 100000000, "MinifySubgraphOperations": true, From 96a94740582125b5c5576a75d031fd22f9924d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Budziak?= Date: Wed, 8 Jul 2026 17:31:29 +0200 Subject: [PATCH 12/29] patch https://monday.slack.com/archives/C09NXK51KR8/p1783523211012779?thread_ts=1783519368.744379&cid=C09NXK51KR8 (#10) --- router/core/graph_server.go | 73 +++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/router/core/graph_server.go b/router/core/graph_server.go index 475f877f6d..9fb8b3fac1 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -2208,6 +2208,25 @@ func (s *graphServer) wait(ctx context.Context) error { // providers during graph server shutdown. const metricsFlushTimeout = 30 * time.Second +func monitor(fn func(elapsed time.Duration)) (stop func()) { + start := time.Now() + + done := make(chan struct{}) + + go func() { + for { + select { + case <-done: + return + case <-time.Tick(10 * time.Second): + fn(time.Since(start)) + } + } + }() + + return func() { close(done) } +} + // flushMeterProviders flushes the OTLP and Prometheus meter providers once. These // providers are shared by every metric store (request, connection, stream, // engine, runtime), so a single flush drains all of their metrics. @@ -2250,11 +2269,31 @@ func (s *graphServer) Shutdown(ctx context.Context) error { var finalErr error + // The swap path calls Shutdown synchronously from the config poller loop, so a + // step that cannot finish silently freezes config updates. Each step below is + // wrapped in a stall log that fires periodically for as long as the step runs, + // so a stuck shutdown names the step while it is still profilable. + shutdownStart := time.Now() + + defer func() { + s.logger.Info("Graph server shutdown complete", + zap.String("elapsed", time.Since(shutdownStart).String()), + zap.String("config_version", s.baseRouterConfigVersion), + ) + }() + // Wait for all in-flight requests to finish. // In the worst case, we wait until the context is done or all requests has timed out. + cancelMonitor := monitor(func(elapsed time.Duration) { + s.logger.Warn("Graph server shutdown is taking a while", + zap.String("step", "in-flight request drain"), + zap.String("step_elapsed", elapsed.String()), + ) + }) if err := s.wait(ctx); err != nil { finalErr = errors.Join(finalErr, fmt.Errorf("failed to wait for in-flight requests: %w", err)) } + cancelMonitor() s.logger.Debug("Shutdown of graph server resources", zap.String("grace_period", s.routerGracePeriod.String()), @@ -2265,11 +2304,18 @@ func (s *graphServer) Shutdown(ctx context.Context) error { // before tearing down the individual metric stores. // As all the stores share the same meter providers, we only need to flush once // before initiating the shutdown of the individual stores. + cancelMonitor = monitor(func(elapsed time.Duration) { + s.logger.Warn("Graph server shutdown is taking a while", + zap.String("step", "metrics flush"), + zap.String("step_elapsed", elapsed.String()), + ) + }) flushCtx, flushCancel := context.WithTimeout(ctx, metricsFlushTimeout) if err := s.flushMeterProviders(flushCtx); err != nil { finalErr = errors.Join(finalErr, fmt.Errorf("failed to flush metrics: %w", err)) } flushCancel() + cancelMonitor() // Ensure that we don't wait indefinitely for shutdown if s.routerGracePeriod > 0 { @@ -2279,6 +2325,13 @@ func (s *graphServer) Shutdown(ctx context.Context) error { ctx = newCtx } + cancelMonitor = monitor(func(elapsed time.Duration) { + s.logger.Warn("Graph server shutdown is taking a while", + zap.String("step", "metric stores shutdown"), + zap.String("step_elapsed", elapsed.String()), + ) + }) + if s.runtimeMetrics != nil { if err := s.runtimeMetrics.Shutdown(); err != nil { finalErr = errors.Join(finalErr, err) @@ -2303,6 +2356,15 @@ func (s *graphServer) Shutdown(ctx context.Context) error { } } + cancelMonitor() + + cancelMonitor = monitor(func(elapsed time.Duration) { + s.logger.Warn("Graph server shutdown is taking a while", + zap.String("step", "graph mux shutdown"), + zap.String("step_elapsed", elapsed.String()), + ) + }) + // Shutdown graphs muxes, which are not reused by the next graph server, to release resources // e.g. planner cache s.graphMuxListLock.Lock() @@ -2320,6 +2382,8 @@ func (s *graphServer) Shutdown(ctx context.Context) error { } } + cancelMonitor() + // Close idle connections on base and subgraph transports s.baseTransport.CloseIdleConnections() for _, subgraphTransport := range s.subgraphTransports { @@ -2327,10 +2391,19 @@ func (s *graphServer) Shutdown(ctx context.Context) error { } if s.connector != nil { + cancelMonitor = monitor(func(elapsed time.Duration) { + s.logger.Warn("Graph server shutdown is taking a while", + zap.String("step", "plugin shutdown"), + zap.String("step_elapsed", elapsed.String()), + ) + }) + s.logger.Debug("Stopping old plugins") if err := s.connector.StopAllProviders(); err != nil { finalErr = errors.Join(finalErr, err) } + + cancelMonitor() } return finalErr From 8c36f66c8391e12238eb8edb2fe67cfcec874e17 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Wed, 8 Jul 2026 17:32:17 +0200 Subject: [PATCH 13/29] chore(router): disable websocket-disablement mondaytweaks flags (#11) Turn off the three subscription/websocket-disablement compile-time flags, reverting to upstream default subscription-client behavior: - ShareUpstreamSubscriptionClient - UseNoopUpstreamSubscriptionClientWhenUnused - DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled Suspected of interfering with CDN config hot reload. Leaves perf/observability tweaks (SizeAwarePlanCache, fetch-count field) untouched. --- router/pkg/mondaytweaks/mondaytweaks.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/router/pkg/mondaytweaks/mondaytweaks.go b/router/pkg/mondaytweaks/mondaytweaks.go index 6ca9e3d638..cac88e4198 100644 --- a/router/pkg/mondaytweaks/mondaytweaks.go +++ b/router/pkg/mondaytweaks/mondaytweaks.go @@ -7,15 +7,21 @@ package mondaytweaks const ( // ShareUpstreamSubscriptionClient uses one upstream GraphQLSubscriptionClient per // DefaultFactoryResolver instead of one per subgraph factory (behavior-altering). - ShareUpstreamSubscriptionClient = true + // Disabled: suspected of interfering with CDN config hot reload (subscription-client + // lifecycle across reloads). Reverts to upstream default (one client per factory). + ShareUpstreamSubscriptionClient = false // UseNoopUpstreamSubscriptionClientWhenUnused skips upstream WS/SSE transport init // when subscriptions are not used (behavior-altering). - UseNoopUpstreamSubscriptionClientWhenUnused = true + // Disabled: suspected of interfering with CDN config hot reload (stale noop client + // after a reload that newly requires subscriptions). Reverts to upstream default. + UseNoopUpstreamSubscriptionClientWhenUnused = false // DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled sets PingInterval=0 on // upstream subscription clients when client-facing websocket is disabled. - DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled = true + // Disabled: suspected of interfering with CDN config hot reload. Reverts to upstream + // default ping behavior. + DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled = false // ExposeOperationSubgraphFetchCountContextField enables the // operation_subgraph_fetch_count access-log context field. From 0b3ca8ad547b50fcc52038751471bf29b5427e0c Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 9 Jul 2026 12:31:59 +0200 Subject: [PATCH 14/29] fix(router): bound + detach old graph-server shutdown on config swap (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwapGraphServer shuts the previous graph server down synchronously on the config-poller goroutine with an unbounded ctx. graphServer.Shutdown drains in-flight requests via wait() (polls inFlightRequests, no independent timeout), so a single stuck request — e.g. a handler blocked on a dead-client write (WriteTimeout=0) or a custom module's own HTTP call — freezes the entire config pipeline. Production showed in-flight-drain stalls up to 1h21m, which: - froze CDN config hot-reload → pods served stale schema for hours (#3286) - pinned the old generation's schema AST + ristretto caches + protojson in memory, driving a GC mark-phase storm and planning-latency spikes. Behind mondaytweaks.AsyncBoundedOldGraphServerShutdown (default on): run the old server's Shutdown off the poller goroutine and bound the drain by the configured grace_period (fallback 90s > 60s subgraph request_timeout when unset), via context.WithoutCancel + WithTimeout. New traffic already routes to the swapped-in server, so abandoning a stuck old-server request after the drain window is safe and lets reloads proceed + releases the old generation. Also re-enable DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled: prod runs websocket.enabled=false, yet a goroutine profile showed WSTransport.pingLoop at ~65% of all goroutines (1.5M) accumulating across reloads. Zeroing PingInterval when client WS is disabled stops that leak. --- router/core/http_server.go | 55 +++++++++++++++++++++++-- router/core/router.go | 2 + router/pkg/mondaytweaks/mondaytweaks.go | 16 +++++-- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/router/core/http_server.go b/router/core/http_server.go index 354b2f0fb8..ba517f31ed 100644 --- a/router/core/http_server.go +++ b/router/core/http_server.go @@ -15,6 +15,7 @@ import ( "go.uber.org/zap" "github.com/wundergraph/cosmo/router/pkg/health" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" ) // serverState holds the mux and graph server together for atomic swaps. @@ -51,6 +52,10 @@ type server struct { healthcheck health.Checker baseURL string listener net.Listener // Pre-bound listener for synchronous port check + // gracePeriod bounds the async old-graph-server in-flight drain on config swap + // (see mondaytweaks.AsyncBoundedOldGraphServerShutdown). Sourced from the router + // grace_period config value. + gracePeriod time.Duration } type httpServerOptions struct { @@ -63,6 +68,7 @@ type httpServerOptions struct { livenessCheckPath string readinessCheckPath string healthCheckPath string + gracePeriod time.Duration } func newServer(opts *httpServerOptions) (*server, error) { @@ -96,6 +102,7 @@ func newServer(opts *httpServerOptions) (*server, error) { healthcheck: opts.healthcheck, baseURL: opts.baseURL, listener: listener, // Store the pre-bound listener + gracePeriod: opts.gracePeriod, } // Store the initial state with health check mux (graphServer nil until first config) @@ -140,13 +147,53 @@ func (s *server) SwapGraphServer(ctx context.Context, svr *graphServer) { // Shutdown the old graph server if it exists. // On first startup, oldState.graphServer is nil. - if oldState != nil && oldState.graphServer != nil { - if err := oldState.graphServer.Shutdown(ctx); err != nil { - s.logger.Error("Failed to shutdown old graph", zap.Error(err)) - } + if oldState == nil || oldState.graphServer == nil { + return + } + old := oldState.graphServer + + if mondaytweaks.AsyncBoundedOldGraphServerShutdown { + // New traffic already routes to svr after the swap above. Shut the old server + // down OFF this goroutine (SwapGraphServer runs synchronously on the config + // poller) and bound its in-flight drain, so a slow/stuck request can never + // freeze CDN config hot-reload (ticket #3286) or pin the old generation's + // schema + caches in memory. Detach from ctx (which stays alive for the router + // lifetime) but keep its values for tracing, then bound by the grace period. + go func() { + shutdownCtx := context.WithoutCancel(ctx) + if drain := s.oldGraphServerDrainTimeout(); drain > 0 { + var cancel context.CancelFunc + shutdownCtx, cancel = context.WithTimeout(shutdownCtx, drain) + defer cancel() + } + if err := old.Shutdown(shutdownCtx); err != nil { + s.logger.Error("Failed to shutdown old graph server", zap.Error(err)) + } + }() + return + } + + if err := old.Shutdown(ctx); err != nil { + s.logger.Error("Failed to shutdown old graph", zap.Error(err)) } } +// oldGraphServerDrainTimeout bounds the async old-graph-server in-flight drain on a +// config swap. It uses the configured router grace_period; if that is unset (<=0) it +// falls back to defaultOldGraphServerDrainTimeout so the drain is never unbounded — +// an unbounded drain is exactly what froze config reloads (ticket #3286). +func (s *server) oldGraphServerDrainTimeout() time.Duration { + if s.gracePeriod > 0 { + return s.gracePeriod + } + return defaultOldGraphServerDrainTimeout +} + +// defaultOldGraphServerDrainTimeout is the fallback drain bound when grace_period is +// unset. Chosen above the default subgraph request_timeout (60s) so a well-behaved +// in-flight request can finish before the old server is abandoned. +const defaultOldGraphServerDrainTimeout = 90 * time.Second + // listenAndServe starts the server using the pre-bound listener and blocks until shutdown. // This method is called in a goroutine; the port was already bound in newServer(). func (s *server) listenAndServe() error { diff --git a/router/core/router.go b/router/core/router.go index f7a0f49bdd..f9234e8293 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -819,6 +819,7 @@ func (r *Router) NewServer(ctx context.Context) (Server, error) { livenessCheckPath: r.livenessCheckPath, readinessCheckPath: r.readinessCheckPath, healthCheckPath: r.healthCheckPath, + gracePeriod: r.routerGracePeriod, }) if err != nil { return nil, fmt.Errorf("failed to create server: %w", err) @@ -1504,6 +1505,7 @@ func (r *Router) Start(ctx context.Context) error { livenessCheckPath: r.livenessCheckPath, readinessCheckPath: r.readinessCheckPath, healthCheckPath: r.healthCheckPath, + gracePeriod: r.routerGracePeriod, }) if err != nil { return fmt.Errorf("failed to create server: %w", err) diff --git a/router/pkg/mondaytweaks/mondaytweaks.go b/router/pkg/mondaytweaks/mondaytweaks.go index cac88e4198..17ee3f2109 100644 --- a/router/pkg/mondaytweaks/mondaytweaks.go +++ b/router/pkg/mondaytweaks/mondaytweaks.go @@ -19,14 +19,24 @@ const ( // DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled sets PingInterval=0 on // upstream subscription clients when client-facing websocket is disabled. - // Disabled: suspected of interfering with CDN config hot reload. Reverts to upstream - // default ping behavior. - DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled = false + // Re-enabled: client-facing websockets are disabled in prod (websocket.enabled: false), + // yet upstream subscription clients still run ping loops. A goroutine profile showed + // WSTransport.pingLoop at ~65% of all goroutines (1.5M) accumulating across reloads; + // zeroing PingInterval when client WS is disabled stops that leak. + DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled = true // ExposeOperationSubgraphFetchCountContextField enables the // operation_subgraph_fetch_count access-log context field. ExposeOperationSubgraphFetchCountContextField = true + // AsyncBoundedOldGraphServerShutdown runs the previous graph server's Shutdown OFF the + // config-reload goroutine, with a bounded in-flight drain. The graph-server swap is + // synchronous on the config poller, so a slow/stuck in-flight request draining on the + // old server freezes CDN config hot-reload (ticket #3286, observed >1h) and pins the old + // generation's schema + caches in memory (GC pressure). Detaching + bounding the drain + // (by the configured grace_period) lets reloads proceed and releases the old generation. + AsyncBoundedOldGraphServerShutdown = true + // PlanCacheSizeAwareBudgetPerSlotBytes is the per-configured-slot byte budget used to // derive the size-aware execution-plan-cache MaxCost when SizeAwarePlanCache is enabled. PlanCacheSizeAwareBudgetPerSlotBytes int64 = 8 * 1024 From 3bb9b4af3fa7458dfec47e691cf8960843d67cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Budziak?= Date: Thu, 9 Jul 2026 16:54:53 +0200 Subject: [PATCH 15/29] Make mondaytweaks flags configurable in a runtime (#13) --- router/core/access_log_field_handler_test.go | 2 +- router/core/executor.go | 2 +- router/core/factoryresolver.go | 2 +- router/core/graph_server.go | 4 +- router/core/http_server.go | 2 +- router/core/operation_planner.go | 2 +- .../core/operation_planner_sizeaware_test.go | 4 +- router/core/request_context_fields.go | 2 +- router/pkg/mondaytweaks/mondaytweaks.go | 37 +++++++++++++------ 9 files changed, 36 insertions(+), 21 deletions(-) diff --git a/router/core/access_log_field_handler_test.go b/router/core/access_log_field_handler_test.go index d4f9b279c2..f7bd5689a4 100644 --- a/router/core/access_log_field_handler_test.go +++ b/router/core/access_log_field_handler_test.go @@ -199,7 +199,7 @@ func TestAccessLogsFieldHandler(t *testing.T) { t.Run("logs operation subgraph fetch count when monday tweak is enabled", func(t *testing.T) { t.Parallel() - require.True(t, mondaytweaks.ExposeOperationSubgraphFetchCountContextField) + require.True(t, mondaytweaks.ExposeOperationSubgraphFetchCountContextField.Load()) req, err := http.NewRequest(http.MethodPost, "http://localhost:3002/graphql", nil) require.NoError(t, err) diff --git a/router/core/executor.go b/router/core/executor.go index d10066c16f..6bd4432518 100644 --- a/router/core/executor.go +++ b/router/core/executor.go @@ -231,7 +231,7 @@ func (b *ExecutorConfigurationBuilder) buildPlannerConfiguration(ctx context.Con subscriptionClientOptions = &SubscriptionClientOptions{} } resolvedSubscriptionClientOptions := *subscriptionClientOptions - if mondaytweaks.UseNoopUpstreamSubscriptionClientWhenUnused { + if mondaytweaks.UseNoopUpstreamSubscriptionClientWhenUnused.Load() { resolvedSubscriptionClientOptions.UseNoopClient = shouldUseNoopUpstreamSubscriptionClient( opts.EngineConfig.GetGraphqlSchema(), opts.EngineConfig, diff --git a/router/core/factoryresolver.go b/router/core/factoryresolver.go index 2cc7d1be5d..8fb901bffd 100644 --- a/router/core/factoryresolver.go +++ b/router/core/factoryresolver.go @@ -210,7 +210,7 @@ func (d *DefaultFactoryResolver) ResolveGraphqlFactory(subgraphName string) (pla } func (d *DefaultFactoryResolver) subscriptionClientForFactory() graphql_datasource.GraphQLSubscriptionClient { - if mondaytweaks.ShareUpstreamSubscriptionClient { + if mondaytweaks.ShareUpstreamSubscriptionClient.Load() { return d.sharedSubscriptionClient() } return d.newSubscriptionClient() diff --git a/router/core/graph_server.go b/router/core/graph_server.go index 9fb8b3fac1..3d7f6e2042 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -741,7 +741,7 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e // NumCounters stays keyed to the expected entry count for TinyLFU admission. planCacheMaxCost := srv.engineExecutionConfiguration.ExecutionPlanCacheSize if sizeAwarePlanCacheEnabled(srv.engineExecutionConfiguration) { - planCacheMaxCost = srv.engineExecutionConfiguration.ExecutionPlanCacheSize * mondaytweaks.PlanCacheSizeAwareBudgetPerSlotBytes + planCacheMaxCost = srv.engineExecutionConfiguration.ExecutionPlanCacheSize * mondaytweaks.PlanCacheSizeAwareBudgetPerSlotBytes.Load() } planCacheConfig := &ristretto.Config[uint64, *planWithMetaData]{ Metrics: srv.metricConfig.OpenTelemetry.GraphqlCache || srv.metricConfig.Prometheus.GraphqlCache, @@ -1541,7 +1541,7 @@ func (s *graphServer) buildGraphMux( } // Client-facing WebSocket subscriptions are disabled; skip upstream ping loops // that would otherwise start one goroutine per subgraph datasource factory. - if mondaytweaks.DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled && + if mondaytweaks.DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled.Load() && s.webSocketConfiguration != nil && !s.webSocketConfiguration.Enabled { subscriptionClientOptions.PingInterval = 0 } diff --git a/router/core/http_server.go b/router/core/http_server.go index ba517f31ed..d8fc8310e4 100644 --- a/router/core/http_server.go +++ b/router/core/http_server.go @@ -152,7 +152,7 @@ func (s *server) SwapGraphServer(ctx context.Context, svr *graphServer) { } old := oldState.graphServer - if mondaytweaks.AsyncBoundedOldGraphServerShutdown { + if mondaytweaks.AsyncBoundedOldGraphServerShutdown.Load() { // New traffic already routes to svr after the swap above. Shut the old server // down OFF this goroutine (SwapGraphServer runs synchronously on the config // poller) and bound its in-flight drain, so a slow/stuck request can never diff --git a/router/core/operation_planner.go b/router/core/operation_planner.go index 3be0467032..5fe6e38fa4 100644 --- a/router/core/operation_planner.go +++ b/router/core/operation_planner.go @@ -73,7 +73,7 @@ func estimatePlanCacheCost(p *planWithMetaData) int64 { // DisableSizeAwarePlanCache override forces count-based eviction (tests, or a targeted // per-router rollback) without mutating the global flag, which matters under -race. func sizeAwarePlanCacheEnabled(cfg config.EngineExecutionConfiguration) bool { - return mondaytweaks.SizeAwarePlanCache && !cfg.DisableSizeAwarePlanCache + return mondaytweaks.SizeAwarePlanCache.Load() && !cfg.DisableSizeAwarePlanCache } // planCacheCost returns the Ristretto cost for a plan-cache entry: the size-aware estimate diff --git a/router/core/operation_planner_sizeaware_test.go b/router/core/operation_planner_sizeaware_test.go index a2ffce8021..cf3353e0e4 100644 --- a/router/core/operation_planner_sizeaware_test.go +++ b/router/core/operation_planner_sizeaware_test.go @@ -65,7 +65,7 @@ func TestSizeAwarePlanCacheEnabled(t *testing.T) { if sizeAwarePlanCacheEnabled(config.EngineExecutionConfiguration{DisableSizeAwarePlanCache: true}) { t.Fatal("DisableSizeAwarePlanCache must force count-based eviction") } - if got := sizeAwarePlanCacheEnabled(config.EngineExecutionConfiguration{}); got != mondaytweaks.SizeAwarePlanCache { - t.Fatalf("unset config should follow mondaytweaks.SizeAwarePlanCache=%v, got %v", mondaytweaks.SizeAwarePlanCache, got) + if got := sizeAwarePlanCacheEnabled(config.EngineExecutionConfiguration{}); got != mondaytweaks.SizeAwarePlanCache.Load() { + t.Fatalf("unset config should follow mondaytweaks.SizeAwarePlanCache=%v, got %v", mondaytweaks.SizeAwarePlanCache.Load(), got) } } diff --git a/router/core/request_context_fields.go b/router/core/request_context_fields.go index 3100f70aa3..22b4f6b5a6 100644 --- a/router/core/request_context_fields.go +++ b/router/core/request_context_fields.go @@ -263,7 +263,7 @@ func getCustomDynamicAttributeValue( } return reqContext.operation.planningTime case ContextFieldOperationSubgraphFetchCount: - if !mondaytweaks.ExposeOperationSubgraphFetchCountContextField || reqContext.operation == nil { + if !mondaytweaks.ExposeOperationSubgraphFetchCountContextField.Load() || reqContext.operation == nil { return "" } stats, statsErr := reqContext.operation.QueryPlanStats() diff --git a/router/pkg/mondaytweaks/mondaytweaks.go b/router/pkg/mondaytweaks/mondaytweaks.go index 17ee3f2109..9465834445 100644 --- a/router/pkg/mondaytweaks/mondaytweaks.go +++ b/router/pkg/mondaytweaks/mondaytweaks.go @@ -1,21 +1,28 @@ -// Package mondaytweaks defines compile-time feature flags for monday.com-specific +// Package mondaytweaks defines runtime-configurable feature flags for monday.com-specific // behavioural overrides in the cosmo router. Keep only non-memory-leak behavior // and performance toggles here; memory-reload cleanup notes live in // `wiki/reference/cosmo-router-reload-memory-benchmark-tooling`. +// +// All flags are backed by sync/atomic so they are safe to read from concurrent +// request-handling goroutines and to write from the ignite provisioning goroutine. +// Use Flag.Store(v) to change a value (e.g. from an ignite module at boot) and +// Flag.Load() in production code paths. package mondaytweaks -const ( +import "sync/atomic" + +var ( // ShareUpstreamSubscriptionClient uses one upstream GraphQLSubscriptionClient per // DefaultFactoryResolver instead of one per subgraph factory (behavior-altering). // Disabled: suspected of interfering with CDN config hot reload (subscription-client // lifecycle across reloads). Reverts to upstream default (one client per factory). - ShareUpstreamSubscriptionClient = false + ShareUpstreamSubscriptionClient atomic.Bool // UseNoopUpstreamSubscriptionClientWhenUnused skips upstream WS/SSE transport init // when subscriptions are not used (behavior-altering). // Disabled: suspected of interfering with CDN config hot reload (stale noop client // after a reload that newly requires subscriptions). Reverts to upstream default. - UseNoopUpstreamSubscriptionClientWhenUnused = false + UseNoopUpstreamSubscriptionClientWhenUnused atomic.Bool // DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled sets PingInterval=0 on // upstream subscription clients when client-facing websocket is disabled. @@ -23,11 +30,11 @@ const ( // yet upstream subscription clients still run ping loops. A goroutine profile showed // WSTransport.pingLoop at ~65% of all goroutines (1.5M) accumulating across reloads; // zeroing PingInterval when client WS is disabled stops that leak. - DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled = true + DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled atomic.Bool // ExposeOperationSubgraphFetchCountContextField enables the // operation_subgraph_fetch_count access-log context field. - ExposeOperationSubgraphFetchCountContextField = true + ExposeOperationSubgraphFetchCountContextField atomic.Bool // AsyncBoundedOldGraphServerShutdown runs the previous graph server's Shutdown OFF the // config-reload goroutine, with a bounded in-flight drain. The graph-server swap is @@ -35,14 +42,22 @@ const ( // old server freezes CDN config hot-reload (ticket #3286, observed >1h) and pins the old // generation's schema + caches in memory (GC pressure). Detaching + bounding the drain // (by the configured grace_period) lets reloads proceed and releases the old generation. - AsyncBoundedOldGraphServerShutdown = true + AsyncBoundedOldGraphServerShutdown atomic.Bool // PlanCacheSizeAwareBudgetPerSlotBytes is the per-configured-slot byte budget used to // derive the size-aware execution-plan-cache MaxCost when SizeAwarePlanCache is enabled. - PlanCacheSizeAwareBudgetPerSlotBytes int64 = 8 * 1024 -) + PlanCacheSizeAwareBudgetPerSlotBytes atomic.Int64 -var ( // SizeAwarePlanCache — monday perf tweak (#7 OPEN); not an upstream memory-leak fix. - SizeAwarePlanCache = true + SizeAwarePlanCache atomic.Bool ) + +func init() { + // ShareUpstreamSubscriptionClient and UseNoopUpstreamSubscriptionClientWhenUnused + // default to false (zero value of atomic.Bool), matching the original const values. + DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled.Store(true) + ExposeOperationSubgraphFetchCountContextField.Store(true) + AsyncBoundedOldGraphServerShutdown.Store(true) + PlanCacheSizeAwareBudgetPerSlotBytes.Store(8 * 1024) + SizeAwarePlanCache.Store(true) +} From 81bdaa1a5e14fffc3f31e1aa1a6bc40f857496a3 Mon Sep 17 00:00:00 2001 From: endigma Date: Wed, 8 Jul 2026 15:17:21 +0100 Subject: [PATCH 16/29] test(router): assert graph server drain only waits on muxes it tears down --- router-tests/lifecycle/shutdown_test.go | 190 ++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/router-tests/lifecycle/shutdown_test.go b/router-tests/lifecycle/shutdown_test.go index 36691a2146..2fb37e7ba7 100644 --- a/router-tests/lifecycle/shutdown_test.go +++ b/router-tests/lifecycle/shutdown_test.go @@ -2,6 +2,8 @@ package integration import ( "context" + "net/http" + "sync" "syscall" "testing" "time" @@ -11,7 +13,10 @@ import ( "github.com/wundergraph/cosmo/router-tests/testenv" "github.com/wundergraph/cosmo/router-tests/testutils" "github.com/wundergraph/cosmo/router/core" + 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/controlplane/configpoller" + "github.com/wundergraph/cosmo/router/pkg/routerconfig" "go.uber.org/goleak" ) @@ -68,3 +73,188 @@ func TestShutdownGoroutineLeaks(t *testing.T) { require.Nil(t, res) } } + +const blockRequestHeader = "x-block-request-id" + +// blockingRequestModule holds any request carrying the blockRequestHeader open +// inside the graph mux middleware chain until the test releases it. Router +// middlewares are mounted after the in-flight counting middleware in +// buildGraphMux, so a parked request counts as in-flight for as long as it is +// blocked. +type blockingRequestModule struct { + // entered receives the request's block id once the request is parked, + // i.e. after the in-flight counter has been incremented. + entered chan string + // release maps a block id to a channel that, once closed, lets the + // request continue. Requests with an unknown id pass through unblocked. + release map[string]chan struct{} +} + +func (m *blockingRequestModule) Middleware(ctx core.RequestContext, next http.Handler) { + if id := ctx.Request().Header.Get(blockRequestHeader); id != "" { + if ch, ok := m.release[id]; ok { + m.entered <- id + <-ch + } + } + next.ServeHTTP(ctx.ResponseWriter(), ctx.Request()) +} + +func (m *blockingRequestModule) Module() core.ModuleInfo { + return core.ModuleInfo{ + ID: "blockingRequestModule", + Priority: 1, + // Return the shared instance so the test keeps access to the + // channels; a fresh instance would lose them to mapstructure's + // zero-value decoding. + New: func() core.Module { return m }, + } +} + +var _ core.RouterMiddlewareHandler = (*blockingRequestModule)(nil) + +// When a hot reload's Changes leave a graph untouched, the new graph server +// reuses that graph's mux and traffic keeps flowing through it. The replaced +// server's shutdown drain waits for requests running in the muxes it tears +// down; requests served by the new server through a reused mux are not the +// replaced server's to wait for. +func TestGraphServerShutdown(t *testing.T) { + t.Parallel() + + t.Run("waits only for requests in muxes it tears down when a hot reload reuses others", func(t *testing.T) { + t.Parallel() + + const ff1 = "experiment-a" + + initial := buildHelloRouterConfig("v1", "Base v1", map[string]string{ff1: "FF1 v1"}) + poller := newFakeConfigPoller(initial) + + mod := &blockingRequestModule{ + entered: make(chan string, 2), + release: map[string]chan struct{}{ + "pre-swap": make(chan struct{}), + "post-swap": make(chan struct{}), + }, + } + + // Release the parked requests exactly once no matter which path the + // test takes: a request still parked at teardown would leak its + // goroutine (goleak) and stall the router's shutdown for the full + // shutdown delay. + releasePreSwap := sync.OnceFunc(func() { close(mod.release["pre-swap"]) }) + releasePostSwap := sync.OnceFunc(func() { close(mod.release["post-swap"]) }) + t.Cleanup(releasePreSwap) + t.Cleanup(releasePostSwap) + + testenv.Run(t, &testenv.Config{ + RouterConfig: &testenv.RouterConfig{ + ConfigPollerFactory: func(_ *nodev1.RouterConfig) configpoller.ConfigPoller { + return poller + }, + }, + RouterOptions: []core.Option{ + core.WithConfigVersionHeader(true), + core.WithCustomModules(mod), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + type result struct { + res *testenv.TestResponse + err error + } + + startBlockedRequest := func(id string, header http.Header) <-chan result { + header = header.Clone() + if header == nil { + header = http.Header{} + } + header.Set(blockRequestHeader, id) + return testenv.Go(func() result { + res, err := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{ + Query: `query { hello }`, + Header: header, + }) + return result{res, err} + }) + } + + // Step 1: the initial server serves the base graph at v1. + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: `query { hello }`}) + require.Equal(t, "v1", res.Response.Header.Get("X-Router-Config-Version")) + + // Step 2: park a request on the ff1 mux. The reload below changes + // ff1, so this mux is torn down by the old server's shutdown — + // the drain must wait for this request. + preSwapDone := startBlockedRequest("pre-swap", http.Header{"X-Feature-Flag": []string{ff1}}) + testenv.AwaitChannelWithT(t, 10*time.Second, mod.entered, func(t *testing.T, got string) { + require.Equal(t, "pre-swap", got) + }, "pre-swap request never reached the blocking middleware") + + // Step 3: emit a reload that changes only the feature flag. The + // base graph is unchanged, so the new server reuses the base mux. + // Emit runs the swap synchronously and blocks until the old + // server's shutdown completes, so it runs on its own goroutine. + next := buildHelloRouterConfig("v2", "Base v1", map[string]string{ff1: "FF1 v2"}) + emitDone := testenv.Go(func() error { + return poller.Emit(t, next, &routerconfig.Changes{ + AddedConfigs: map[string]struct{}{}, + RemovedConfigs: map[string]struct{}{}, + ChangedConfigs: map[string]struct{}{ff1: {}}, + }) + }) + + // Step 4: wait until the new server has been swapped in, observed + // via the rebuilt feature-flag mux serving the v2 version header. + require.EventuallyWithT(t, func(c *assert.CollectT) { + ffRes, err := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{ + Query: `query { hello }`, + Header: http.Header{"X-Feature-Flag": []string{ff1}}, + }) + if assert.NoError(c, err) { + assert.Equal(c, "v2-"+ff1, ffRes.Response.Header.Get("X-Router-Config-Version")) + } + }, 10*time.Second, 50*time.Millisecond, + "new graph server must be swapped in and serving the rebuilt feature-flag mux") + + // The old server still owns the parked pre-swap request, so its + // shutdown (and with it the Emit call) must not have finished. + select { + case err := <-emitDone: + t.Fatalf("config update returned while a request was still in flight in a mux the old server tears down (err: %v)", err) + default: + } + + // Step 5: park a second request on the base graph. It is served + // by the new server through the reused base mux — the old server + // does not own it and must not wait for it. + postSwapDone := startBlockedRequest("post-swap", nil) + testenv.AwaitChannelWithT(t, 10*time.Second, mod.entered, func(t *testing.T, got string) { + require.Equal(t, "post-swap", got) + }, "post-swap request never reached the blocking middleware") + + // Step 6: release the old server's own request. From this moment + // every request in the muxes the old server tears down is done. + releasePreSwap() + testenv.AwaitChannelWithT(t, 10*time.Second, preSwapDone, func(t *testing.T, preSwap result) { + require.NoError(t, preSwap.err) + require.Equal(t, "v1-"+ff1, preSwap.res.Response.Header.Get("X-Router-Config-Version"), + "the pre-swap request was served by the old server's ff1 mux") + }, "pre-swap request did not complete after being released") + + // Step 7: the old server's shutdown must now complete even though + // the post-swap request is still running through the reused mux. + testenv.AwaitChannelWithT(t, 10*time.Second, emitDone, func(t *testing.T, err error) { + require.NoError(t, err) + }, "old graph server shutdown did not complete after all requests in the muxes it tears down "+ + "had finished; it must not wait on requests served by the new server through a reused mux") + + // Step 8: the post-swap request is still healthy on the new + // server; release it and verify it completes. + releasePostSwap() + testenv.AwaitChannelWithT(t, 10*time.Second, postSwapDone, func(t *testing.T, postSwap result) { + require.NoError(t, postSwap.err) + require.Equal(t, "v1", postSwap.res.Response.Header.Get("X-Router-Config-Version"), + "the post-swap request was served through the reused base mux, whose version header is baked in from v1") + }, "post-swap request did not complete after being released") + }) + }) +} From c7f0360683bc8938f1cddbe096f62873f4e6eb5e Mon Sep 17 00:00:00 2001 From: endigma Date: Thu, 9 Jul 2026 11:24:01 +0100 Subject: [PATCH 17/29] fix(router): drain only in-flight requests of muxes the graph server tears down --- router/core/graph_server.go | 35 +++++++++++++++++++++++++------- router/core/graph_server_test.go | 2 -- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/router/core/graph_server.go b/router/core/graph_server.go index 77ebf3a013..5c791587f8 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -96,9 +96,6 @@ type ( baseOtelAttributes []attribute.KeyValue baseRouterConfigVersion string mux *chi.Mux - // inFlightRequests is used to track the number of requests currently being processed - // does not include websocket (hijacked) connections. - 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 @@ -226,7 +223,6 @@ func newGraphServer(routerCtx context.Context, r *Router, response *routerconfig playgroundHandler: r.playgroundHandler, traceDialer: traceDialer, baseRouterConfigVersion: response.Config.GetVersion(), - inFlightRequests: &atomic.Int64{}, graphMuxList: make(map[string]*graphMux, 1), instanceData: InstanceData{ HostName: r.hostName, @@ -701,6 +697,13 @@ type graphMux struct { reused atomic.Bool finalized atomic.Bool + // inFlightRequests tracks the number of requests currently being processed + // by this mux. Does not include subscriptions or websocket (hijacked) + // connections. A reused mux keeps serving under the next graph server, so + // the count belongs to the mux and is only drained by the server that + // tears the mux down. + inFlightRequests atomic.Int64 + planCache *ristretto.Cache[uint64, *planWithMetaData] planFallbackCache *slowplancache.Cache[*planWithMetaData] persistedOperationCache *ristretto.Cache[uint64, NormalizationCacheEntry] @@ -1981,11 +1984,11 @@ func (s *graphServer) buildGraphMux( // We don't want to count any type of subscriptions e.g. SSE as in-flight requests because they are long-lived if requestContext != nil && requestContext.operation != nil && requestContext.operation.opType != OperationTypeSubscription { - s.inFlightRequests.Add(1) + gm.inFlightRequests.Add(1) // 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.Add(-1) + defer gm.inFlightRequests.Add(-1) } handler.ServeHTTP(w, r) @@ -2190,6 +2193,24 @@ func newGRPCStartupParams(traceConfig *rtrace.Config, ipAnonymization *IPAnonymi return startupConfig } +// inFlightOwnedRequests sums the in-flight requests of the muxes this server +// will actually shut down. Muxes flagged as reused are inherited by the next +// server and keep serving new traffic, so their in-flight requests are not +// this server's to wait for. +func (s *graphServer) inFlightOwnedRequests() int64 { + s.graphMuxListLock.Lock() + defer s.graphMuxListLock.Unlock() + + var n int64 + for _, gm := range s.graphMuxList { + if gm.reused.Load() { + continue + } + n += gm.inFlightRequests.Load() + } + return n +} + // wait waits for all in-flight requests to finish. Similar to http.Server.Shutdown we wait in intervals + jitter // to make the shutdown process more efficient. func (s *graphServer) wait(ctx context.Context) error { @@ -2199,7 +2220,7 @@ func (s *graphServer) wait(ctx context.Context) error { defer timer.Stop() for { - if s.inFlightRequests.Load() == 0 { + if s.inFlightOwnedRequests() == 0 { return nil } select { diff --git a/router/core/graph_server_test.go b/router/core/graph_server_test.go index 066a17e187..fe26ee1274 100644 --- a/router/core/graph_server_test.go +++ b/router/core/graph_server_test.go @@ -825,7 +825,6 @@ func TestGraphServerShutdown(t *testing.T) { prev := &graphServer{ Config: &Config{logger: zap.NewNop()}, graphServerCancel: cancel, - inFlightRequests: &atomic.Int64{}, baseTransport: &http.Transport{}, graphMuxList: map[string]*graphMux{"": baseMux}, } @@ -854,7 +853,6 @@ func TestGraphServerShutdown(t *testing.T) { srv := &graphServer{ Config: &Config{logger: zap.NewNop()}, graphServerCancel: cancel, - inFlightRequests: &atomic.Int64{}, baseTransport: &http.Transport{}, graphMuxList: map[string]*graphMux{"": mux}, } From 2bedc2aa50491ab53b18c678a7b33fe73f1975c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Budziak?= Date: Tue, 14 Jul 2026 16:02:24 +0200 Subject: [PATCH 18/29] Expose health checks (#16) --- router/core/modules.go | 7 +++++-- router/core/router.go | 7 ++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/router/core/modules.go b/router/core/modules.go index a05ac63683..5884784240 100644 --- a/router/core/modules.go +++ b/router/core/modules.go @@ -14,6 +14,8 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/graphqlerrors" "go.uber.org/zap" + + "github.com/wundergraph/cosmo/router/pkg/health" ) var ( @@ -171,8 +173,9 @@ type Cleaner interface { // ModuleContext is a type which defines the lifetime of modules that are registered with the router. type ModuleContext struct { stdContext.Context - Module Module - Logger *zap.Logger + Module Module + Logger *zap.Logger + HealthChecks health.Checker } // WriteResponseError writes the given error as a GraphQL error response to the http.ResponseWriter diff --git a/router/core/router.go b/router/core/router.go index f9234e8293..bf2b777dca 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -684,9 +684,10 @@ func (r *Router) initModules(ctx context.Context) error { moduleInstance := moduleInfo.New() mc := &ModuleContext{ - Context: ctx, - Module: moduleInstance, - Logger: r.logger.With(zap.String("module", string(moduleInfo.ID))), + Context: ctx, + Module: moduleInstance, + Logger: r.logger.With(zap.String("module", string(moduleInfo.ID))), + HealthChecks: r.healthcheck, } moduleConfig, ok := r.modulesConfig[string(moduleInfo.ID)] From c6626432b125805dc596ee729df088f951a907e0 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Wed, 15 Jul 2026 19:02:37 +0200 Subject: [PATCH 19/29] feat(mondaytweaks): skip CoordinateDependencies allocation via DisableFieldDependencies flag (#18) --- router/core/factoryresolver.go | 1 + router/pkg/mondaytweaks/mondaytweaks.go | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/router/core/factoryresolver.go b/router/core/factoryresolver.go index 8fb901bffd..a126827eaf 100644 --- a/router/core/factoryresolver.go +++ b/router/core/factoryresolver.go @@ -379,6 +379,7 @@ func mapProtoFilterToPlanFilter(input *nodev1.SubscriptionFilterCondition, outpu // along with any pub/sub providers that need lifecycle management. func (l *Loader) Load(engineConfig *nodev1.EngineConfiguration, subgraphs []*nodev1.Subgraph, routerEngineConfig *RouterEngineConfiguration, pluginsEnabled bool) (*plan.Configuration, []pubsub_datasource.Provider, error) { var outConfig plan.Configuration + outConfig.DisableIncludeFieldDependencies = mondaytweaks.DisableFieldDependencies.Load() // attach field usage information to the plan outConfig.DefaultFlushIntervalMillis = engineConfig.DefaultFlushInterval for _, configuration := range engineConfig.FieldConfigurations { diff --git a/router/pkg/mondaytweaks/mondaytweaks.go b/router/pkg/mondaytweaks/mondaytweaks.go index 9465834445..09288f00e2 100644 --- a/router/pkg/mondaytweaks/mondaytweaks.go +++ b/router/pkg/mondaytweaks/mondaytweaks.go @@ -50,6 +50,17 @@ var ( // SizeAwarePlanCache — monday perf tweak (#7 OPEN); not an upstream memory-leak fix. SizeAwarePlanCache atomic.Bool + + // DisableFieldDependencies skips the per-fetch CoordinateDependencies allocation during + // planning. CoordinateDependencies ([]FetchDependency per fetch) are query-plan metadata + // used only for observability/visualisation — not read during request execution, tainted- + // entity filtering, or subgraph propagation. With 200 unique cached operations this saves + // ~30-40% of per-plan heap above the schema baseline (~40 MiB / 200 plans in the + // cardinality-high benchmark, 500 KiB heap / ~1 MiB RSS per plan). + // + // Corresponds to plan.Configuration.DisableIncludeFieldDependencies. The flag is read + // once in factoryresolver.Load() so it takes effect on the next config reload. + DisableFieldDependencies atomic.Bool ) func init() { @@ -60,4 +71,5 @@ func init() { AsyncBoundedOldGraphServerShutdown.Store(true) PlanCacheSizeAwareBudgetPerSlotBytes.Store(8 * 1024) SizeAwarePlanCache.Store(true) + DisableFieldDependencies.Store(true) } From 18e34a22525140353c8a897a43b7f0e5c6f8fdd0 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 16 Jul 2026 09:59:54 +0200 Subject: [PATCH 20/29] fix(router): count fetch tree and response fields in plan cache cost estimator (#19) * fix(router): count fetch tree and response fields in plan cache cost estimator * test(router): cover fetch tree and response field counting in plan cache cost * feat(router): gate plan cache tree-walk cost behind PlanCacheCostCountsPlanTree flag * feat(mondaytweaks): gate graphql Source caching behind ReuseGraphQLSource flag * chore(router): replace graphql-go-tools with local pool-graphql-source worktree * Revert "chore(router): replace graphql-go-tools with local pool-graphql-source worktree" This reverts commit e940ef7e00985ae24f00f3b01b8b7e50b42deafc. * fix(mondaytweaks): move ReuseGraphQLSource flag to graphql-go-tools package --- router/core/operation_planner.go | 66 ++++++++++++++++ .../core/operation_planner_sizeaware_test.go | 77 +++++++++++++++++++ router/pkg/mondaytweaks/mondaytweaks.go | 10 +++ 3 files changed, 153 insertions(+) diff --git a/router/core/operation_planner.go b/router/core/operation_planner.go index 5fe6e38fa4..2c95c0bfac 100644 --- a/router/core/operation_planner.go +++ b/router/core/operation_planner.go @@ -39,6 +39,18 @@ const ( planCacheCostUsageBytes = 64 ) +// planCacheCostFetchBytes and planCacheCostFieldBytes approximate the retained heap of a single +// prepared-plan fetch (SingleFetch/EntityFetch/BatchEntityFetch, each carrying FetchInfo, +// FetchConfiguration and an InputTemplate — empirically ~40 KiB) and a single response field +// node (Field + FieldInfo with its []string slices — empirically ~500-800 bytes). Benchmark +// data: 200 unique plans retaining 103 MiB of plan-cache heap (~515 KiB/plan) with ~8 fetches +// and ~100-200 response fields per plan. The AST-only estimate above undercounts this by ~65x +// because the plan tree — not the operation document — holds the bulk of the memory. +const ( + planCacheCostFetchBytes = 32 * 1024 + planCacheCostFieldBytes = 768 +) + // estimatePlanCacheCost approximates the retained heap of a cached plan entry so the // size-aware Ristretto config (mondaytweaks.SizeAwarePlanCache) evicts by memory footprint // instead of by entry count. It keys off operationDocument, which is always populated (the @@ -62,12 +74,66 @@ func estimatePlanCacheCost(p *planWithMetaData) int64 { cost += int64(nodes) * planCacheCostNodeBytes } cost += int64(len(p.typeFieldUsageInfo)+len(p.argumentUsageInfo)) * planCacheCostUsageBytes + + // The prepared plan tree retains the bulk of the entry's heap: one fetch struct per + // subgraph fetch and one Field/FieldInfo per response field. Walk it once per cache miss + // (O(fetches + fields)) so the estimate tracks actual footprint, not just operation size. + if mondaytweaks.PlanCacheCostCountsPlanTree.Load() { + if syncPlan, ok := p.preparedPlan.(*plan.SynchronousResponsePlan); ok && syncPlan.Response != nil { + fetches := countFetchTreeNodes(syncPlan.Response.Fetches) + fields := countResponseFields(syncPlan.Response.Data) + cost += int64(fetches)*planCacheCostFetchBytes + int64(fields)*planCacheCostFieldBytes + } + } + if cost < 1 { return 1 } return cost } +// countFetchTreeNodes returns the number of fetch nodes (Item != nil) in the fetch tree, +// including a subscription Trigger. Each corresponds to a subgraph fetch whose FetchInfo, +// FetchConfiguration and InputTemplate dominate the prepared plan's retained heap. +func countFetchTreeNodes(n *resolve.FetchTreeNode) int { + if n == nil { + return 0 + } + count := 0 + if n.Item != nil { + count++ + } + count += countFetchTreeNodes(n.Trigger) + for _, child := range n.ChildNodes { + count += countFetchTreeNodes(child) + } + return count +} + +// countResponseFields returns the number of Field nodes in the response Data tree, recursing +// through Object and Array nodes. Each Field carries a *FieldInfo whose []string slices make it +// the second-largest contributor to a cached plan's heap after the fetches. +func countResponseFields(node resolve.Node) int { + switch v := node.(type) { + case *resolve.Object: + if v == nil { + return 0 + } + count := len(v.Fields) + for _, f := range v.Fields { + count += countResponseFields(f.Value) + } + return count + case *resolve.Array: + if v == nil { + return 0 + } + return countResponseFields(v.Item) + default: + return 0 + } +} + // sizeAwarePlanCacheEnabled reports whether the execution-plan cache should evict by estimated // retained heap (mondaytweaks.SizeAwarePlanCache) for this engine configuration. The per-config // DisableSizeAwarePlanCache override forces count-based eviction (tests, or a targeted diff --git a/router/core/operation_planner_sizeaware_test.go b/router/core/operation_planner_sizeaware_test.go index cf3353e0e4..d490381f7b 100644 --- a/router/core/operation_planner_sizeaware_test.go +++ b/router/core/operation_planner_sizeaware_test.go @@ -6,6 +6,8 @@ import ( "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" ) // TestEstimatePlanCacheCost verifies the size-aware cost estimate is nil-safe, always @@ -40,6 +42,81 @@ func TestEstimatePlanCacheCost(t *testing.T) { } } +// TestEstimatePlanCacheCostCountsPlanTree verifies the prepared-plan tree walk dominates the +// estimate: a plan retaining several subgraph fetches and a nested response tree must cost far +// more than the AST-only accounting for the same operation document, so Ristretto evicts by the +// heap the plan tree actually retains rather than by operation size alone. +func TestEstimatePlanCacheCostCountsPlanTree(t *testing.T) { + doc := &ast.Document{} + doc.Input.RawBytes = []byte("query{a{b{c}}}") + doc.Fields = make([]ast.Field, 3) + doc.Selections = make([]ast.Selection, 3) + + astOnly := &planWithMetaData{operationDocument: doc} + baseline := estimatePlanCacheCost(astOnly) + + // Two subgraph fetches under a Sequence node (Item != nil ⇒ counted). + fetches := &resolve.FetchTreeNode{ + Kind: resolve.FetchTreeNodeKindSequence, + ChildNodes: []*resolve.FetchTreeNode{ + {Kind: resolve.FetchTreeNodeKindSingle, Item: &resolve.FetchItem{}}, + {Kind: resolve.FetchTreeNodeKindSingle, Item: &resolve.FetchItem{}}, + }, + } + + // Nested response shape: root object → array of objects → leaf field (3 Field nodes). + data := &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("a"), + Value: &resolve.Array{ + Item: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("b"), + Value: &resolve.Object{ + Fields: []*resolve.Field{ + {Name: []byte("c"), Value: &resolve.String{}}, + }, + }, + }, + }, + }, + }, + }, + }, + } + + withPlan := &planWithMetaData{ + operationDocument: doc, + preparedPlan: &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{Fetches: fetches, Data: data}, + }, + } + + prev := mondaytweaks.PlanCacheCostCountsPlanTree.Load() + defer mondaytweaks.PlanCacheCostCountsPlanTree.Store(prev) + + mondaytweaks.PlanCacheCostCountsPlanTree.Store(true) + withTree := estimatePlanCacheCost(withPlan) + + if withTree <= baseline { + t.Fatalf("plan tree walk must increase cost: astOnly=%d withPlan=%d", baseline, withTree) + } + // 2 fetches + 3 response fields must be accounted for on top of the AST baseline. + wantMin := baseline + 2*planCacheCostFetchBytes + 3*planCacheCostFieldBytes + if withTree < wantMin { + t.Fatalf("expected cost >= %d (baseline + 2 fetches + 3 fields), got %d", wantMin, withTree) + } + + // With the flag disabled the tree walk is skipped, so the plan-bearing entry costs the + // same as the AST-only accounting for the same operation document. + mondaytweaks.PlanCacheCostCountsPlanTree.Store(false) + if got := estimatePlanCacheCost(withPlan); got != baseline { + t.Fatalf("flag disabled: want AST-only baseline %d, got %d", baseline, got) + } +} + // TestPlanCacheCostRespectsMode confirms a planner uses the historical unit cost when size- // aware eviction is disabled, and the size-aware estimate when it is enabled. func TestPlanCacheCostRespectsMode(t *testing.T) { diff --git a/router/pkg/mondaytweaks/mondaytweaks.go b/router/pkg/mondaytweaks/mondaytweaks.go index 09288f00e2..38889bf7d8 100644 --- a/router/pkg/mondaytweaks/mondaytweaks.go +++ b/router/pkg/mondaytweaks/mondaytweaks.go @@ -61,6 +61,14 @@ var ( // Corresponds to plan.Configuration.DisableIncludeFieldDependencies. The flag is read // once in factoryresolver.Load() so it takes effect on the next config reload. DisableFieldDependencies atomic.Bool + // PlanCacheCostCountsPlanTree enables the fetch-tree + response-field walk in the + // execution-plan-cache cost estimator (estimatePlanCacheCost). When enabled, the estimate + // adds ~32 KiB per subgraph fetch and ~768 B per response field on top of the AST-only + // accounting, so the size-aware cache evicts by the heap the prepared plan tree actually + // retains rather than by operation-document size alone. Only has an effect when + // SizeAwarePlanCache is enabled. + PlanCacheCostCountsPlanTree atomic.Bool + ) func init() { @@ -72,4 +80,6 @@ func init() { PlanCacheSizeAwareBudgetPerSlotBytes.Store(8 * 1024) SizeAwarePlanCache.Store(true) DisableFieldDependencies.Store(true) + PlanCacheCostCountsPlanTree.Store(true) + } From 9cfc3fb1875413b7557bcd12d90111e7164ee477 Mon Sep 17 00:00:00 2001 From: Michal Budziak Date: Mon, 20 Jul 2026 13:39:28 +0200 Subject: [PATCH 21/29] chore(mondaytweaks): remove shareUpstreamSubscriptionClient, asyncBoundedOldGraphServerShutdown, useNoopUpstreamSubscriptionClientWhenUnused flags All three were disabled (false) or reverted to upstream defaults due to suspected interference with CDN config hot-reload. Remove them along with their supporting infrastructure: noop subscription client files, WebSocketConfiguration field in ExecutorBuildOptions, gracePeriod machinery in http_server, and the sharedSubscriptionClient path in factoryresolver. --- router/core/executor.go | 10 -- router/core/factoryresolver.go | 51 --------- router/core/graph_server.go | 1 - router/core/http_server.go | 55 +-------- .../core/noop_graphql_subscription_client.go | 105 ------------------ .../noop_graphql_subscription_client_test.go | 89 --------------- router/core/router.go | 2 - router/core/transport.go | 2 - router/pkg/mondaytweaks/mondaytweaks.go | 23 ---- 9 files changed, 4 insertions(+), 334 deletions(-) delete mode 100644 router/core/noop_graphql_subscription_client.go delete mode 100644 router/core/noop_graphql_subscription_client_test.go diff --git a/router/core/executor.go b/router/core/executor.go index 6bd4432518..420a9d3291 100644 --- a/router/core/executor.go +++ b/router/core/executor.go @@ -11,7 +11,6 @@ import ( 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/grpcconnector" - "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" pubsub_datasource "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" @@ -63,7 +62,6 @@ type ExecutorBuildOptions struct { TraceClientRequired bool PluginsEnabled bool InstanceData InstanceData - WebSocketConfiguration *config.WebSocketConfiguration } func (b *ExecutorConfigurationBuilder) Build(ctx context.Context, opts *ExecutorBuildOptions) (*Executor, []pubsub_datasource.Provider, error) { @@ -231,14 +229,6 @@ func (b *ExecutorConfigurationBuilder) buildPlannerConfiguration(ctx context.Con subscriptionClientOptions = &SubscriptionClientOptions{} } resolvedSubscriptionClientOptions := *subscriptionClientOptions - if mondaytweaks.UseNoopUpstreamSubscriptionClientWhenUnused.Load() { - resolvedSubscriptionClientOptions.UseNoopClient = shouldUseNoopUpstreamSubscriptionClient( - opts.EngineConfig.GetGraphqlSchema(), - opts.EngineConfig, - opts.RouterEngineConfig.Events, - opts.WebSocketConfiguration, - ) - } loader := NewLoader(ctx, b.trackUsageInfo, NewDefaultFactoryResolver( ctx, diff --git a/router/core/factoryresolver.go b/router/core/factoryresolver.go index a126827eaf..bb84298391 100644 --- a/router/core/factoryresolver.go +++ b/router/core/factoryresolver.go @@ -7,7 +7,6 @@ import ( "net/http" "net/url" "slices" - "sync" "time" "github.com/buger/jsonparser" @@ -80,10 +79,6 @@ type DefaultFactoryResolver struct { transportFactory ApiTransportFactory defaultSubgraphRequestTimeout time.Duration subscriptionClientOptions []graphql_datasource.SubscriptionClientOption - useNoopSubscriptionClient bool - - subscriptionClient graphql_datasource.GraphQLSubscriptionClient - subscriptionClientOnce sync.Once } func NewDefaultFactoryResolver( @@ -137,9 +132,7 @@ func NewDefaultFactoryResolver( graphql_datasource.WithLogger(factoryLogger), } - useNoopSubscriptionClient := false if subscriptionClientOptions != nil { - useNoopSubscriptionClient = subscriptionClientOptions.UseNoopClient if subscriptionClientOptions.PingInterval > 0 { options = append(options, graphql_datasource.WithPingInterval(subscriptionClientOptions.PingInterval)) } @@ -172,7 +165,6 @@ func NewDefaultFactoryResolver( transportFactory: transportFactory, defaultSubgraphRequestTimeout: transportOptions.SubgraphTransportOptions.RequestTimeout, subscriptionClientOptions: options, - useNoopSubscriptionClient: useNoopSubscriptionClient, } } @@ -210,17 +202,10 @@ func (d *DefaultFactoryResolver) ResolveGraphqlFactory(subgraphName string) (pla } func (d *DefaultFactoryResolver) subscriptionClientForFactory() graphql_datasource.GraphQLSubscriptionClient { - if mondaytweaks.ShareUpstreamSubscriptionClient.Load() { - return d.sharedSubscriptionClient() - } return d.newSubscriptionClient() } func (d *DefaultFactoryResolver) newSubscriptionClient() graphql_datasource.GraphQLSubscriptionClient { - if d.useNoopSubscriptionClient { - return noopGraphQLSubscriptionClientInstance - } - if d.transportFactory == nil || d.baseTransport == nil { return graphql_datasource.NewGraphQLSubscriptionClient( d.engineCtx, @@ -246,42 +231,6 @@ func (d *DefaultFactoryResolver) newSubscriptionClient() graphql_datasource.Grap ) } -func (d *DefaultFactoryResolver) sharedSubscriptionClient() graphql_datasource.GraphQLSubscriptionClient { - d.subscriptionClientOnce.Do(func() { - if d.useNoopSubscriptionClient { - d.subscriptionClient = noopGraphQLSubscriptionClientInstance - return - } - - if d.transportFactory == nil || d.baseTransport == nil { - d.subscriptionClient = graphql_datasource.NewGraphQLSubscriptionClient( - d.engineCtx, - d.subscriptionClientOptions..., - ) - return - } - - defaultHTTPClient := &http.Client{ - Timeout: d.defaultSubgraphRequestTimeout, - Transport: d.transportFactory.RoundTripper(d.baseTransport), - } - - streamingClient := &http.Client{ - Transport: d.transportFactory.RoundTripper(d.baseTransport), - } - - d.subscriptionClient = graphql_datasource.NewGraphQLSubscriptionClient( - d.engineCtx, - append([]graphql_datasource.SubscriptionClientOption{ - graphql_datasource.WithUpgradeClient(defaultHTTPClient), - graphql_datasource.WithStreamingClient(streamingClient), - }, d.subscriptionClientOptions...)..., - ) - }) - - return d.subscriptionClient -} - func (d *DefaultFactoryResolver) ResolveStaticFactory() (factory plan.PlannerFactory[staticdatasource.Configuration], err error) { return d.static, nil } diff --git a/router/core/graph_server.go b/router/core/graph_server.go index 944fcb55ec..16a7c7a262 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -1588,7 +1588,6 @@ func (s *graphServer) buildGraphMux( HeartbeatInterval: s.subscriptionHeartbeatInterval, PluginsEnabled: s.plugins.Enabled, InstanceData: s.instanceData, - WebSocketConfiguration: s.webSocketConfiguration, }, ) if err != nil { diff --git a/router/core/http_server.go b/router/core/http_server.go index d8fc8310e4..354b2f0fb8 100644 --- a/router/core/http_server.go +++ b/router/core/http_server.go @@ -15,7 +15,6 @@ import ( "go.uber.org/zap" "github.com/wundergraph/cosmo/router/pkg/health" - "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" ) // serverState holds the mux and graph server together for atomic swaps. @@ -52,10 +51,6 @@ type server struct { healthcheck health.Checker baseURL string listener net.Listener // Pre-bound listener for synchronous port check - // gracePeriod bounds the async old-graph-server in-flight drain on config swap - // (see mondaytweaks.AsyncBoundedOldGraphServerShutdown). Sourced from the router - // grace_period config value. - gracePeriod time.Duration } type httpServerOptions struct { @@ -68,7 +63,6 @@ type httpServerOptions struct { livenessCheckPath string readinessCheckPath string healthCheckPath string - gracePeriod time.Duration } func newServer(opts *httpServerOptions) (*server, error) { @@ -102,7 +96,6 @@ func newServer(opts *httpServerOptions) (*server, error) { healthcheck: opts.healthcheck, baseURL: opts.baseURL, listener: listener, // Store the pre-bound listener - gracePeriod: opts.gracePeriod, } // Store the initial state with health check mux (graphServer nil until first config) @@ -147,53 +140,13 @@ func (s *server) SwapGraphServer(ctx context.Context, svr *graphServer) { // Shutdown the old graph server if it exists. // On first startup, oldState.graphServer is nil. - if oldState == nil || oldState.graphServer == nil { - return - } - old := oldState.graphServer - - if mondaytweaks.AsyncBoundedOldGraphServerShutdown.Load() { - // New traffic already routes to svr after the swap above. Shut the old server - // down OFF this goroutine (SwapGraphServer runs synchronously on the config - // poller) and bound its in-flight drain, so a slow/stuck request can never - // freeze CDN config hot-reload (ticket #3286) or pin the old generation's - // schema + caches in memory. Detach from ctx (which stays alive for the router - // lifetime) but keep its values for tracing, then bound by the grace period. - go func() { - shutdownCtx := context.WithoutCancel(ctx) - if drain := s.oldGraphServerDrainTimeout(); drain > 0 { - var cancel context.CancelFunc - shutdownCtx, cancel = context.WithTimeout(shutdownCtx, drain) - defer cancel() - } - if err := old.Shutdown(shutdownCtx); err != nil { - s.logger.Error("Failed to shutdown old graph server", zap.Error(err)) - } - }() - return - } - - if err := old.Shutdown(ctx); err != nil { - s.logger.Error("Failed to shutdown old graph", zap.Error(err)) - } -} - -// oldGraphServerDrainTimeout bounds the async old-graph-server in-flight drain on a -// config swap. It uses the configured router grace_period; if that is unset (<=0) it -// falls back to defaultOldGraphServerDrainTimeout so the drain is never unbounded — -// an unbounded drain is exactly what froze config reloads (ticket #3286). -func (s *server) oldGraphServerDrainTimeout() time.Duration { - if s.gracePeriod > 0 { - return s.gracePeriod + if oldState != nil && oldState.graphServer != nil { + if err := oldState.graphServer.Shutdown(ctx); err != nil { + s.logger.Error("Failed to shutdown old graph", zap.Error(err)) + } } - return defaultOldGraphServerDrainTimeout } -// defaultOldGraphServerDrainTimeout is the fallback drain bound when grace_period is -// unset. Chosen above the default subgraph request_timeout (60s) so a well-behaved -// in-flight request can finish before the old server is abandoned. -const defaultOldGraphServerDrainTimeout = 90 * time.Second - // listenAndServe starts the server using the pre-bound listener and blocks until shutdown. // This method is called in a goroutine; the port was already bound in newServer(). func (s *server) listenAndServe() error { diff --git a/router/core/noop_graphql_subscription_client.go b/router/core/noop_graphql_subscription_client.go deleted file mode 100644 index 79af8d33f5..0000000000 --- a/router/core/noop_graphql_subscription_client.go +++ /dev/null @@ -1,105 +0,0 @@ -package core - -import ( - "errors" - - nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" - "github.com/wundergraph/cosmo/router/pkg/config" - "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" - "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" - "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/graphql_datasource" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" -) - -var errUpstreamGraphQLSubscriptionsDisabled = errors.New("upstream GraphQL subscriptions are disabled") - -// noopGraphQLSubscriptionClient satisfies graphql-go-tools NewFactory's non-nil -// subscription client requirement without initializing upstream WS/SSE transports. -type noopGraphQLSubscriptionClient struct{} - -func (c *noopGraphQLSubscriptionClient) Subscribe(_ *resolve.Context, _ graphql_datasource.GraphQLSubscriptionOptions, _ resolve.SubscriptionUpdater) error { - return errUpstreamGraphQLSubscriptionsDisabled -} - -var noopGraphQLSubscriptionClientInstance graphql_datasource.GraphQLSubscriptionClient = &noopGraphQLSubscriptionClient{} - -func shouldUseNoopUpstreamSubscriptionClient( - graphqlSchema string, - engineConfig *nodev1.EngineConfiguration, - eventsConfig config.EventsConfiguration, - webSocketConfiguration *config.WebSocketConfiguration, -) bool { - if !schemaHasSubscriptionRootFields(graphqlSchema) { - return true - } - if !clientWebSocketSubscriptionsEnabled(webSocketConfiguration) && !eventSubscriptionsEnabled(engineConfig, eventsConfig) { - return true - } - return false -} - -func schemaHasSubscriptionRootFields(graphqlSchema string) bool { - if graphqlSchema == "" { - return false - } - - doc, report := astparser.ParseGraphqlDocumentString(graphqlSchema) - if report.HasErrors() { - return false - } - if err := asttransform.MergeDefinitionWithBaseSchema(&doc); err != nil { - return false - } - - return subscriptionRootFieldCount(&doc) > 0 -} - -func subscriptionRootFieldCount(doc *ast.Document) int { - if doc.Index.SubscriptionTypeName == nil { - return 0 - } - - node, ok := doc.Index.FirstNodeByNameBytes(doc.Index.SubscriptionTypeName) - if !ok || node.Kind != ast.NodeKindObjectTypeDefinition { - return 0 - } - - return len(doc.ObjectTypeDefinitions[node.Ref].FieldsDefinition.Refs) -} - -func clientWebSocketSubscriptionsEnabled(webSocketConfiguration *config.WebSocketConfiguration) bool { - if webSocketConfiguration == nil { - return true - } - return webSocketConfiguration.Enabled -} - -func eventSubscriptionsEnabled(engineConfig *nodev1.EngineConfiguration, eventsConfig config.EventsConfiguration) bool { - if len(eventsConfig.Providers.Nats) > 0 || - len(eventsConfig.Providers.Kafka) > 0 || - len(eventsConfig.Providers.Redis) > 0 { - return true - } - - if engineConfig == nil { - return false - } - - for _, ds := range engineConfig.GetDatasourceConfigurations() { - if ds.GetKind() == nodev1.DataSourceKind_PUBSUB { - return true - } - customEvents := ds.GetCustomEvents() - if customEvents == nil { - continue - } - if len(customEvents.GetNats()) > 0 || - len(customEvents.GetKafka()) > 0 || - len(customEvents.GetRedis()) > 0 { - return true - } - } - - return false -} diff --git a/router/core/noop_graphql_subscription_client_test.go b/router/core/noop_graphql_subscription_client_test.go deleted file mode 100644 index a4a286c62c..0000000000 --- a/router/core/noop_graphql_subscription_client_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package core - -import ( - "testing" - - nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" - "github.com/wundergraph/cosmo/router/pkg/config" - "github.com/stretchr/testify/require" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/graphql_datasource" -) - -func TestShouldUseNoopUpstreamSubscriptionClient_NoSubscriptionRootFields(t *testing.T) { - schema := `type Query { hello: String }` - - require.True(t, shouldUseNoopUpstreamSubscriptionClient( - schema, - nil, - config.EventsConfiguration{}, - &config.WebSocketConfiguration{Enabled: true}, - )) -} - -func TestShouldUseNoopUpstreamSubscriptionClient_EmptySubscriptionType(t *testing.T) { - schema := `type Query { hello: String } -type Subscription { }` - - require.True(t, shouldUseNoopUpstreamSubscriptionClient( - schema, - nil, - config.EventsConfiguration{}, - &config.WebSocketConfiguration{Enabled: true}, - )) -} - -func TestShouldUseNoopUpstreamSubscriptionClient_ClientWSDisabledWithoutEvents(t *testing.T) { - schema := `type Query { hello: String } -type Subscription { onUpdate: String }` - - require.True(t, shouldUseNoopUpstreamSubscriptionClient( - schema, - &nodev1.EngineConfiguration{}, - config.EventsConfiguration{}, - &config.WebSocketConfiguration{Enabled: false}, - )) -} - -func TestShouldUseNoopUpstreamSubscriptionClient_ClientWSDisabledWithPubSubDatasource(t *testing.T) { - schema := `type Query { hello: String } -type Subscription { onUpdate: String }` - - engineConfig := &nodev1.EngineConfiguration{ - DatasourceConfigurations: []*nodev1.DataSourceConfiguration{ - {Kind: nodev1.DataSourceKind_PUBSUB}, - }, - } - - require.False(t, shouldUseNoopUpstreamSubscriptionClient( - schema, - engineConfig, - config.EventsConfiguration{}, - &config.WebSocketConfiguration{Enabled: false}, - )) -} - -func TestShouldUseNoopUpstreamSubscriptionClient_UpstreamSubscriptionsNeeded(t *testing.T) { - schema := `type Query { hello: String } -type Subscription { onUpdate: String }` - - require.False(t, shouldUseNoopUpstreamSubscriptionClient( - schema, - &nodev1.EngineConfiguration{}, - config.EventsConfiguration{}, - &config.WebSocketConfiguration{Enabled: true}, - )) -} - -func TestNoopGraphQLSubscriptionClient_SubscribeReturnsError(t *testing.T) { - err := noopGraphQLSubscriptionClientInstance.Subscribe(nil, graphql_datasource.GraphQLSubscriptionOptions{}, nil) - require.ErrorIs(t, err, errUpstreamGraphQLSubscriptionsDisabled) -} - -func TestSharedSubscriptionClient_UsesNoopWhenConfigured(t *testing.T) { - resolver := &DefaultFactoryResolver{ - useNoopSubscriptionClient: true, - } - - client := resolver.sharedSubscriptionClient() - require.Same(t, noopGraphQLSubscriptionClientInstance, client) -} diff --git a/router/core/router.go b/router/core/router.go index a40f1bd5a5..190c41c8b1 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -823,7 +823,6 @@ func (r *Router) NewServer(ctx context.Context) (Server, error) { livenessCheckPath: r.livenessCheckPath, readinessCheckPath: r.readinessCheckPath, healthCheckPath: r.healthCheckPath, - gracePeriod: r.routerGracePeriod, }) if err != nil { return nil, fmt.Errorf("failed to create server: %w", err) @@ -1539,7 +1538,6 @@ func (r *Router) Start(ctx context.Context) error { livenessCheckPath: r.livenessCheckPath, readinessCheckPath: r.readinessCheckPath, healthCheckPath: r.healthCheckPath, - gracePeriod: r.routerGracePeriod, }) if err != nil { return fmt.Errorf("failed to create server: %w", err) diff --git a/router/core/transport.go b/router/core/transport.go index 32afe6745e..609229c341 100644 --- a/router/core/transport.go +++ b/router/core/transport.go @@ -223,8 +223,6 @@ type SubscriptionClientOptions struct { AckTimeout time.Duration ReadLimit int64 DefaultErrorExtensionCode string - // UseNoopClient skips upstream WS/SSE transport initialization when subscriptions are not needed. - UseNoopClient bool } func NewTransport(opts *TransportOptions) *TransportFactory { diff --git a/router/pkg/mondaytweaks/mondaytweaks.go b/router/pkg/mondaytweaks/mondaytweaks.go index 38889bf7d8..befafb911f 100644 --- a/router/pkg/mondaytweaks/mondaytweaks.go +++ b/router/pkg/mondaytweaks/mondaytweaks.go @@ -12,18 +12,6 @@ package mondaytweaks import "sync/atomic" var ( - // ShareUpstreamSubscriptionClient uses one upstream GraphQLSubscriptionClient per - // DefaultFactoryResolver instead of one per subgraph factory (behavior-altering). - // Disabled: suspected of interfering with CDN config hot reload (subscription-client - // lifecycle across reloads). Reverts to upstream default (one client per factory). - ShareUpstreamSubscriptionClient atomic.Bool - - // UseNoopUpstreamSubscriptionClientWhenUnused skips upstream WS/SSE transport init - // when subscriptions are not used (behavior-altering). - // Disabled: suspected of interfering with CDN config hot reload (stale noop client - // after a reload that newly requires subscriptions). Reverts to upstream default. - UseNoopUpstreamSubscriptionClientWhenUnused atomic.Bool - // DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled sets PingInterval=0 on // upstream subscription clients when client-facing websocket is disabled. // Re-enabled: client-facing websockets are disabled in prod (websocket.enabled: false), @@ -36,14 +24,6 @@ var ( // operation_subgraph_fetch_count access-log context field. ExposeOperationSubgraphFetchCountContextField atomic.Bool - // AsyncBoundedOldGraphServerShutdown runs the previous graph server's Shutdown OFF the - // config-reload goroutine, with a bounded in-flight drain. The graph-server swap is - // synchronous on the config poller, so a slow/stuck in-flight request draining on the - // old server freezes CDN config hot-reload (ticket #3286, observed >1h) and pins the old - // generation's schema + caches in memory (GC pressure). Detaching + bounding the drain - // (by the configured grace_period) lets reloads proceed and releases the old generation. - AsyncBoundedOldGraphServerShutdown atomic.Bool - // PlanCacheSizeAwareBudgetPerSlotBytes is the per-configured-slot byte budget used to // derive the size-aware execution-plan-cache MaxCost when SizeAwarePlanCache is enabled. PlanCacheSizeAwareBudgetPerSlotBytes atomic.Int64 @@ -72,11 +52,8 @@ var ( ) func init() { - // ShareUpstreamSubscriptionClient and UseNoopUpstreamSubscriptionClientWhenUnused - // default to false (zero value of atomic.Bool), matching the original const values. DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled.Store(true) ExposeOperationSubgraphFetchCountContextField.Store(true) - AsyncBoundedOldGraphServerShutdown.Store(true) PlanCacheSizeAwareBudgetPerSlotBytes.Store(8 * 1024) SizeAwarePlanCache.Store(true) DisableFieldDependencies.Store(true) From 1ec3d80c54cedd4b60c872e9cf51c6cfcdcdfff5 Mon Sep 17 00:00:00 2001 From: Michal Budziak Date: Mon, 20 Jul 2026 13:47:47 +0200 Subject: [PATCH 22/29] chore(mondaytweaks): reduce diff surface against upstream - Inline subscriptionClientForFactory (trivial wrapper after ShareUpstreamSubscriptionClient removal) - Revert NewWebsocketMiddleware second return value (*WebsocketHandler unused) - Revert demo.config.yaml to upstream (local dev additions) --- router/core/executor.go | 34 ++++++++++++++-------------------- router/core/factoryresolver.go | 10 +++------- router/core/graph_server.go | 2 +- router/core/websocket.go | 4 ++-- router/demo.config.yaml | 20 +++++--------------- 5 files changed, 25 insertions(+), 45 deletions(-) diff --git a/router/core/executor.go b/router/core/executor.go index 420a9d3291..6c04b96b47 100644 --- a/router/core/executor.go +++ b/router/core/executor.go @@ -65,7 +65,7 @@ type ExecutorBuildOptions struct { } func (b *ExecutorConfigurationBuilder) Build(ctx context.Context, opts *ExecutorBuildOptions) (*Executor, []pubsub_datasource.Provider, error) { - planConfig, providers, err := b.buildPlannerConfiguration(ctx, opts) + planConfig, providers, err := b.buildPlannerConfiguration(ctx, opts.EngineConfig, opts.Subgraphs, opts.RouterEngineConfig, opts.PluginsEnabled) if err != nil { return nil, nil, fmt.Errorf("failed to build planner configuration: %w", err) } @@ -219,35 +219,29 @@ func (b *ExecutorConfigurationBuilder) Build(ctx context.Context, opts *Executor }, providers, nil } -func (b *ExecutorConfigurationBuilder) buildPlannerConfiguration(ctx context.Context, opts *ExecutorBuildOptions) (*plan.Configuration, []pubsub_datasource.Provider, error) { +func (b *ExecutorConfigurationBuilder) buildPlannerConfiguration(ctx context.Context, engineConfig *nodev1.EngineConfiguration, subgraphs []*nodev1.Subgraph, routerEngineCfg *RouterEngineConfiguration, pluginsEnabled bool) (*plan.Configuration, []pubsub_datasource.Provider, error) { // this loader is used to take the engine config and create a plan config // the plan config is what the engine uses to turn a GraphQL Request into an execution plan // the plan config is stateful as it carries connection pools and other things - subscriptionClientOptions := b.subscriptionClientOptions - if subscriptionClientOptions == nil { - subscriptionClientOptions = &SubscriptionClientOptions{} - } - resolvedSubscriptionClientOptions := *subscriptionClientOptions - loader := NewLoader(ctx, b.trackUsageInfo, NewDefaultFactoryResolver( ctx, b.transportOptions, - &resolvedSubscriptionClientOptions, + b.subscriptionClientOptions, b.baseTripper, b.subgraphTrippers, b.pluginHost, b.logger, - opts.RouterEngineConfig.Execution.EnableNetPoll, + routerEngineCfg.Execution.EnableNetPoll, b.instanceData, ), b.logger, b.subscriptionHooks) // this generates the plan config using the data source factories from the config package - planConfig, providers, err := loader.Load(opts.EngineConfig, opts.Subgraphs, opts.RouterEngineConfig, opts.PluginsEnabled) + planConfig, providers, err := loader.Load(engineConfig, subgraphs, routerEngineCfg, pluginsEnabled) if err != nil { return nil, nil, fmt.Errorf("failed to load configuration: %w", err) } - debug := &opts.RouterEngineConfig.Execution.Debug + debug := &routerEngineCfg.Execution.Debug planConfig.Debug = plan.DebugConfiguration{ PrintOperationTransformations: debug.PrintOperationTransformations, PrintOperationEnableASTRefs: debug.PrintOperationEnableASTRefs, @@ -258,19 +252,19 @@ func (b *ExecutorConfigurationBuilder) buildPlannerConfiguration(ctx context.Con PlanningVisitor: debug.PlanningVisitor, DatasourceVisitor: debug.DatasourceVisitor, } - planConfig.MinifySubgraphOperations = opts.RouterEngineConfig.Execution.MinifySubgraphOperations + planConfig.MinifySubgraphOperations = routerEngineCfg.Execution.MinifySubgraphOperations - planConfig.EnableOperationNamePropagation = opts.RouterEngineConfig.Execution.EnableSubgraphFetchOperationName + planConfig.EnableOperationNamePropagation = routerEngineCfg.Execution.EnableSubgraphFetchOperationName - planConfig.BuildFetchReasons = opts.RouterEngineConfig.Execution.EnableRequireFetchReasons || opts.RouterEngineConfig.Execution.ValidateRequiredExternalFields - planConfig.ValidateRequiredExternalFields = opts.RouterEngineConfig.Execution.ValidateRequiredExternalFields - planConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = opts.RouterEngineConfig.Execution.RelaxSubgraphOperationFieldSelectionMergingNullability + planConfig.BuildFetchReasons = routerEngineCfg.Execution.EnableRequireFetchReasons || routerEngineCfg.Execution.ValidateRequiredExternalFields + planConfig.ValidateRequiredExternalFields = routerEngineCfg.Execution.ValidateRequiredExternalFields + planConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = routerEngineCfg.Execution.RelaxSubgraphOperationFieldSelectionMergingNullability // Enable cost computation when cost control is enabled - if opts.RouterEngineConfig.CostControl != nil && opts.RouterEngineConfig.CostControl.Enabled { + if routerEngineCfg.CostControl != nil && routerEngineCfg.CostControl.Enabled { planConfig.ComputeCosts = true - planConfig.StaticCostDefaultListSize = opts.RouterEngineConfig.CostControl.EstimatedListSize - planConfig.IgnoreImplementingTypeWeights = opts.RouterEngineConfig.CostControl.IgnoreImplementingTypeWeights + planConfig.StaticCostDefaultListSize = routerEngineCfg.CostControl.EstimatedListSize + planConfig.IgnoreImplementingTypeWeights = routerEngineCfg.CostControl.IgnoreImplementingTypeWeights } return planConfig, providers, nil diff --git a/router/core/factoryresolver.go b/router/core/factoryresolver.go index bb84298391..a3c778eb81 100644 --- a/router/core/factoryresolver.go +++ b/router/core/factoryresolver.go @@ -184,7 +184,7 @@ func (d *DefaultFactoryResolver) ResolveGraphqlFactory(subgraphName string) (pla if d.transportFactory == nil || d.baseTransport == nil { // dummy implementation for plan generator that doesn't make requests - return graphql_datasource.NewFactory(d.engineCtx, http.DefaultClient, d.subscriptionClientForFactory()) + return graphql_datasource.NewFactory(d.engineCtx, http.DefaultClient, d.newSubscriptionClient()) } defaultHTTPClient := &http.Client{ @@ -195,14 +195,10 @@ func (d *DefaultFactoryResolver) ResolveGraphqlFactory(subgraphName string) (pla if subgraphClient, ok := d.subgraphHTTPClients[subgraphName]; ok { // it's intentional that we're not using the subgraphClient for subscriptions // custom subgraph clients are intended to be used for custom timeouts, which is not relevant for subscriptions - return graphql_datasource.NewFactory(d.engineCtx, subgraphClient, d.subscriptionClientForFactory()) + return graphql_datasource.NewFactory(d.engineCtx, subgraphClient, d.newSubscriptionClient()) } - return graphql_datasource.NewFactory(d.engineCtx, defaultHTTPClient, d.subscriptionClientForFactory()) -} - -func (d *DefaultFactoryResolver) subscriptionClientForFactory() graphql_datasource.GraphQLSubscriptionClient { - return d.newSubscriptionClient() + return graphql_datasource.NewFactory(d.engineCtx, defaultHTTPClient, d.newSubscriptionClient()) } func (d *DefaultFactoryResolver) newSubscriptionClient() graphql_datasource.GraphQLSubscriptionClient { diff --git a/router/core/graph_server.go b/router/core/graph_server.go index 16a7c7a262..55fcccadb9 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -1944,7 +1944,7 @@ func (s *graphServer) buildGraphMux( }) if s.webSocketConfiguration != nil && s.webSocketConfiguration.Enabled { - wsMiddleware, _ := NewWebsocketMiddleware(graphMuxCtx, WebsocketMiddlewareOptions{ + wsMiddleware := NewWebsocketMiddleware(graphMuxCtx, WebsocketMiddlewareOptions{ OperationProcessor: operationProcessor, OperationBlocker: operationBlocker, Planner: operationPlanner, diff --git a/router/core/websocket.go b/router/core/websocket.go index 37d6624162..0b14a39add 100644 --- a/router/core/websocket.go +++ b/router/core/websocket.go @@ -72,7 +72,7 @@ type WebsocketMiddlewareOptions struct { ApolloCompatibilityFlags config.ApolloCompatibilityFlags } -func NewWebsocketMiddleware(ctx context.Context, opts WebsocketMiddlewareOptions) (func(http.Handler) http.Handler, *WebsocketHandler) { +func NewWebsocketMiddleware(ctx context.Context, opts WebsocketMiddlewareOptions) func(http.Handler) http.Handler { handler := &WebsocketHandler{ ctx: ctx, operationProcessor: opts.OperationProcessor, @@ -150,7 +150,7 @@ func NewWebsocketMiddleware(ctx context.Context, opts WebsocketMiddlewareOptions } handler.handleUpgradeRequest(w, r) }) - }, handler + } } // wsConnectionWrapper is a wrapper around websocket.Conn that allows diff --git a/router/demo.config.yaml b/router/demo.config.yaml index 1ae08ae9b5..1b94fbec6e 100644 --- a/router/demo.config.yaml +++ b/router/demo.config.yaml @@ -3,30 +3,20 @@ # See pkg/config/config.go for the full list of configuration options. # This file is used for the demo environment -version: '1' -log_level: 'debug' - -persisted_operations: - log_unknown: true - cache: - size: 100MB - manifest: - enabled: true - warmup: - enabled: false +version: "1" events: providers: nats: - id: default - url: 'nats://localhost:4222' + url: "nats://localhost:4222" - id: my-nats - url: 'nats://localhost:4222' + url: "nats://localhost:4222" kafka: - id: my-kafka brokers: - - 'localhost:9092' + - "localhost:9092" redis: - id: my-redis urls: - - 'redis://localhost:6379/2' + - "redis://localhost:6379/2" From b69282d2d0740de92aa04cbc0f99fcaac3fc1bc0 Mon Sep 17 00:00:00 2001 From: Michal Budziak Date: Mon, 20 Jul 2026 13:51:02 +0200 Subject: [PATCH 23/29] Revert "patch https://monday.slack.com/archives/C09NXK51KR8/p1783523211012779?thread_ts=1783519368.744379&cid=C09NXK51KR8 (#10)" This reverts commit 96a94740582125b5c5576a75d031fd22f9924d4c. --- router/core/graph_server.go | 73 ------------------------------------- 1 file changed, 73 deletions(-) diff --git a/router/core/graph_server.go b/router/core/graph_server.go index 55fcccadb9..dccb23dcd4 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -2236,25 +2236,6 @@ func (s *graphServer) wait(ctx context.Context) error { // providers during graph server shutdown. const metricsFlushTimeout = 30 * time.Second -func monitor(fn func(elapsed time.Duration)) (stop func()) { - start := time.Now() - - done := make(chan struct{}) - - go func() { - for { - select { - case <-done: - return - case <-time.Tick(10 * time.Second): - fn(time.Since(start)) - } - } - }() - - return func() { close(done) } -} - // flushMeterProviders flushes the OTLP and Prometheus meter providers once. These // providers are shared by every metric store (request, connection, stream, // engine, runtime), so a single flush drains all of their metrics. @@ -2297,31 +2278,11 @@ func (s *graphServer) Shutdown(ctx context.Context) error { var finalErr error - // The swap path calls Shutdown synchronously from the config poller loop, so a - // step that cannot finish silently freezes config updates. Each step below is - // wrapped in a stall log that fires periodically for as long as the step runs, - // so a stuck shutdown names the step while it is still profilable. - shutdownStart := time.Now() - - defer func() { - s.logger.Info("Graph server shutdown complete", - zap.String("elapsed", time.Since(shutdownStart).String()), - zap.String("config_version", s.baseRouterConfigVersion), - ) - }() - // Wait for all in-flight requests to finish. // In the worst case, we wait until the context is done or all requests has timed out. - cancelMonitor := monitor(func(elapsed time.Duration) { - s.logger.Warn("Graph server shutdown is taking a while", - zap.String("step", "in-flight request drain"), - zap.String("step_elapsed", elapsed.String()), - ) - }) if err := s.wait(ctx); err != nil { finalErr = errors.Join(finalErr, fmt.Errorf("failed to wait for in-flight requests: %w", err)) } - cancelMonitor() s.logger.Debug("Shutdown of graph server resources", zap.String("grace_period", s.routerGracePeriod.String()), @@ -2332,18 +2293,11 @@ func (s *graphServer) Shutdown(ctx context.Context) error { // before tearing down the individual metric stores. // As all the stores share the same meter providers, we only need to flush once // before initiating the shutdown of the individual stores. - cancelMonitor = monitor(func(elapsed time.Duration) { - s.logger.Warn("Graph server shutdown is taking a while", - zap.String("step", "metrics flush"), - zap.String("step_elapsed", elapsed.String()), - ) - }) flushCtx, flushCancel := context.WithTimeout(ctx, metricsFlushTimeout) if err := s.flushMeterProviders(flushCtx); err != nil { finalErr = errors.Join(finalErr, fmt.Errorf("failed to flush metrics: %w", err)) } flushCancel() - cancelMonitor() // Ensure that we don't wait indefinitely for shutdown if s.routerGracePeriod > 0 { @@ -2353,13 +2307,6 @@ func (s *graphServer) Shutdown(ctx context.Context) error { ctx = newCtx } - cancelMonitor = monitor(func(elapsed time.Duration) { - s.logger.Warn("Graph server shutdown is taking a while", - zap.String("step", "metric stores shutdown"), - zap.String("step_elapsed", elapsed.String()), - ) - }) - if s.runtimeMetrics != nil { if err := s.runtimeMetrics.Shutdown(); err != nil { finalErr = errors.Join(finalErr, err) @@ -2384,15 +2331,6 @@ func (s *graphServer) Shutdown(ctx context.Context) error { } } - cancelMonitor() - - cancelMonitor = monitor(func(elapsed time.Duration) { - s.logger.Warn("Graph server shutdown is taking a while", - zap.String("step", "graph mux shutdown"), - zap.String("step_elapsed", elapsed.String()), - ) - }) - // Shutdown graphs muxes, which are not reused by the next graph server, to release resources // e.g. planner cache s.graphMuxListLock.Lock() @@ -2410,8 +2348,6 @@ func (s *graphServer) Shutdown(ctx context.Context) error { } } - cancelMonitor() - // Close idle connections on base and subgraph transports s.baseTransport.CloseIdleConnections() for _, subgraphTransport := range s.subgraphTransports { @@ -2419,19 +2355,10 @@ func (s *graphServer) Shutdown(ctx context.Context) error { } if s.connector != nil { - cancelMonitor = monitor(func(elapsed time.Duration) { - s.logger.Warn("Graph server shutdown is taking a while", - zap.String("step", "plugin shutdown"), - zap.String("step_elapsed", elapsed.String()), - ) - }) - s.logger.Debug("Stopping old plugins") if err := s.connector.StopAllProviders(); err != nil { finalErr = errors.Join(finalErr, err) } - - cancelMonitor() } return finalErr From 8977681158db872384b3be30e6da9e903180c1c1 Mon Sep 17 00:00:00 2001 From: Michal Budziak Date: Mon, 20 Jul 2026 13:59:20 +0200 Subject: [PATCH 24/29] Revert "Expose health checks (#16)" This reverts commit 2bedc2aa50491ab53b18c678a7b33fe73f1975c9. --- router/core/modules.go | 7 ++----- router/core/router.go | 7 +++---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/router/core/modules.go b/router/core/modules.go index 5884784240..a05ac63683 100644 --- a/router/core/modules.go +++ b/router/core/modules.go @@ -14,8 +14,6 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/graphqlerrors" "go.uber.org/zap" - - "github.com/wundergraph/cosmo/router/pkg/health" ) var ( @@ -173,9 +171,8 @@ type Cleaner interface { // ModuleContext is a type which defines the lifetime of modules that are registered with the router. type ModuleContext struct { stdContext.Context - Module Module - Logger *zap.Logger - HealthChecks health.Checker + Module Module + Logger *zap.Logger } // WriteResponseError writes the given error as a GraphQL error response to the http.ResponseWriter diff --git a/router/core/router.go b/router/core/router.go index 190c41c8b1..def3d0fd52 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -687,10 +687,9 @@ func (r *Router) initModules(ctx context.Context) error { moduleInstance := moduleInfo.New() mc := &ModuleContext{ - Context: ctx, - Module: moduleInstance, - Logger: r.logger.With(zap.String("module", string(moduleInfo.ID))), - HealthChecks: r.healthcheck, + Context: ctx, + Module: moduleInstance, + Logger: r.logger.With(zap.String("module", string(moduleInfo.ID))), } moduleConfig, ok := r.modulesConfig[string(moduleInfo.ID)] From 9cfbec681ef71632a8c3b305571469332d11b93d Mon Sep 17 00:00:00 2001 From: Michal Budziak Date: Mon, 20 Jul 2026 13:59:57 +0200 Subject: [PATCH 25/29] chore(mondaytweaks): revert ResolveGraphqlFactory to upstream inline form The newSubscriptionClient() extraction was introduced to support the shared/noop subscription client paths, both of which have been removed. Inline the construction back to match upstream exactly. --- router/core/factoryresolver.go | 38 +++++++++++----------------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/router/core/factoryresolver.go b/router/core/factoryresolver.go index a3c778eb81..82b6d388ab 100644 --- a/router/core/factoryresolver.go +++ b/router/core/factoryresolver.go @@ -184,29 +184,10 @@ func (d *DefaultFactoryResolver) ResolveGraphqlFactory(subgraphName string) (pla if d.transportFactory == nil || d.baseTransport == nil { // dummy implementation for plan generator that doesn't make requests - return graphql_datasource.NewFactory(d.engineCtx, http.DefaultClient, d.newSubscriptionClient()) - } - - defaultHTTPClient := &http.Client{ - Timeout: d.defaultSubgraphRequestTimeout, - Transport: d.transportFactory.RoundTripper(d.baseTransport), - } - - if subgraphClient, ok := d.subgraphHTTPClients[subgraphName]; ok { - // it's intentional that we're not using the subgraphClient for subscriptions - // custom subgraph clients are intended to be used for custom timeouts, which is not relevant for subscriptions - return graphql_datasource.NewFactory(d.engineCtx, subgraphClient, d.newSubscriptionClient()) - } - - return graphql_datasource.NewFactory(d.engineCtx, defaultHTTPClient, d.newSubscriptionClient()) -} - -func (d *DefaultFactoryResolver) newSubscriptionClient() graphql_datasource.GraphQLSubscriptionClient { - if d.transportFactory == nil || d.baseTransport == nil { - return graphql_datasource.NewGraphQLSubscriptionClient( - d.engineCtx, + subscriptionClient := graphql_datasource.NewGraphQLSubscriptionClient(d.engineCtx, d.subscriptionClientOptions..., ) + return graphql_datasource.NewFactory(d.engineCtx, http.DefaultClient, subscriptionClient) } defaultHTTPClient := &http.Client{ @@ -218,13 +199,18 @@ func (d *DefaultFactoryResolver) newSubscriptionClient() graphql_datasource.Grap Transport: d.transportFactory.RoundTripper(d.baseTransport), } - return graphql_datasource.NewGraphQLSubscriptionClient( + subscriptionClient := graphql_datasource.NewGraphQLSubscriptionClient( d.engineCtx, - append([]graphql_datasource.SubscriptionClientOption{ - graphql_datasource.WithUpgradeClient(defaultHTTPClient), - graphql_datasource.WithStreamingClient(streamingClient), - }, d.subscriptionClientOptions...)..., + append([]graphql_datasource.SubscriptionClientOption{graphql_datasource.WithUpgradeClient(defaultHTTPClient), graphql_datasource.WithStreamingClient(streamingClient)}, d.subscriptionClientOptions...)..., ) + + if subgraphClient, ok := d.subgraphHTTPClients[subgraphName]; ok { + // it's intentional that we're not using the subgraphClient for subscriptions + // custom subgraph clients are intended to be used for custom timeouts, which is not relevant for subscriptions + return graphql_datasource.NewFactory(d.engineCtx, subgraphClient, subscriptionClient) + } + + return graphql_datasource.NewFactory(d.engineCtx, defaultHTTPClient, subscriptionClient) } func (d *DefaultFactoryResolver) ResolveStaticFactory() (factory plan.PlannerFactory[staticdatasource.Configuration], err error) { From ea1ef6075c81a67f16b6077d442ebf7f0c7d2bd7 Mon Sep 17 00:00:00 2001 From: Michal Budziak Date: Mon, 20 Jul 2026 14:04:35 +0200 Subject: [PATCH 26/29] fix(mondaytweaks): restore accidentally dropped OnEvict nil-guard comment planFallbackCache is conditionally created and slowplancache.Set has a nil receiver guard, but that's non-obvious. The comment explaining why the call is safe was dropped when SkipPlanCacheOnEvictDuringMuxShutdown was removed; restore it. --- router/core/graph_server.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/router/core/graph_server.go b/router/core/graph_server.go index dccb23dcd4..cfcad81a03 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -755,6 +755,9 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e } if srv.cacheWarmup != nil && srv.cacheWarmup.Enabled && srv.cacheWarmup.InMemoryFallback { planCacheConfig.OnEvict = func(item *ristretto.Item[*planWithMetaData]) { + // This could be called before planFallbackCache is set, but it's not a problem + // because there is a nil guard inside, as well as items should not really be evicted + // on startup s.planFallbackCache.Set(item.Key, item.Value, item.Value.planningDuration) } } From 84abf0e2b71f2792356fb501df1b85710343deb7 Mon Sep 17 00:00:00 2001 From: Michal Budziak Date: Mon, 20 Jul 2026 14:10:48 +0200 Subject: [PATCH 27/29] chore(mondaytweaks): remove remaining closeOnce remnant from b887fd8a9 The sync-handler tracking and ShutdownConnections were already removed in 9455ffe24. Remove the leftover closeOnce field and Do wrapper to fully revert websocket.go to upstream. --- router/core/websocket.go | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/router/core/websocket.go b/router/core/websocket.go index 0b14a39add..3fe6ec3345 100644 --- a/router/core/websocket.go +++ b/router/core/websocket.go @@ -445,6 +445,7 @@ func (h *WebsocketHandler) handleUpgradeRequest(w http.ResponseWriter, r *http.R } // Handle messages sync when net poller implementation is not available + go h.handleConnectionSync(handler) } @@ -823,8 +824,6 @@ type WebSocketConnectionHandler struct { apolloCompatibilityFlags config.ApolloCompatibilityFlags clientInfoFromInitialPayload config.WebSocketClientInfoFromInitialPayloadConfiguration - - closeOnce sync.Once } type forwardConfig struct { @@ -1397,21 +1396,19 @@ func (h *WebSocketConnectionHandler) shouldComputeOperationSha256(operationKit * } func (h *WebSocketConnectionHandler) Close(unsubscribe bool, closeKind wsproto.CloseKind) { - h.closeOnce.Do(func() { - if unsubscribe { - // Remove any pending IDs associated with this connection - err := h.graphqlHandler.executor.Resolver.UnsubscribeClient(h.connectionID) - if err != nil { - h.logger.Debug("Unsubscribing client", zap.Error(err)) - } + if unsubscribe { + // Remove any pending IDs associated with this connection + err := h.graphqlHandler.executor.Resolver.UnsubscribeClient(h.connectionID) + if err != nil { + h.logger.Debug("Unsubscribing client", zap.Error(err)) } + } - if err := h.conn.WriteCloseFrame(closeKind.Code, closeKind.Reason); err != nil { - h.logger.Debug("Writing close frame", zap.Error(err)) - } + if err := h.conn.WriteCloseFrame(closeKind.Code, closeKind.Reason); err != nil { + h.logger.Debug("Writing close frame", zap.Error(err)) + } - if err := h.conn.Close(); err != nil { - h.logger.Debug("Closing websocket connection", zap.Error(err)) - } - }) + if err := h.conn.Close(); err != nil { + h.logger.Debug("Closing websocket connection", zap.Error(err)) + } } From 7df21ada6c68d5aece2bc44a5a23972f885afefa Mon Sep 17 00:00:00 2001 From: Damian Rakus Date: Thu, 3 Sep 2026 16:34:31 +0200 Subject: [PATCH 28/29] fix(redis): use SUBSCRIBE not PSUBSCRIBE for ElastiCache Serverless compat --- router/pkg/pubsub/redis/adapter.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/router/pkg/pubsub/redis/adapter.go b/router/pkg/pubsub/redis/adapter.go index 606a473e96..edde6019eb 100644 --- a/router/pkg/pubsub/redis/adapter.go +++ b/router/pkg/pubsub/redis/adapter.go @@ -120,7 +120,6 @@ func (p *ProviderAdapter) Subscribe(ctx context.Context, conf datasource.Subscri zap.String("method", "subscribe"), zap.Strings("channels", subConf.Channels), ) - // Guard the possibly-nil connection: in strict mode a failed Startup leaves p.conn nil // (under skip_unavailable_providers the resilient client is retained instead), so return // an error rather than panicking if Subscribe is somehow reached without a connection. @@ -128,7 +127,14 @@ func (p *ProviderAdapter) Subscribe(ctx context.Context, conf datasource.Subscri return datasource.NewError("redis connection not initialized", nil) } - sub := p.conn.PSubscribe(ctx, subConf.Channels...) + // monday fork: SUBSCRIBE, not PSUBSCRIBE. AWS ElastiCache Serverless lists + // psubscribe/punsubscribe among the commands unavailable on serverless caches, + // so a pattern subscribe is rejected server-side — and go-redis swallows it in + // the Channel() retry loop, so the subscription just silently never delivers. + // Trade-off: glob channels in @edfs__redisSubscribe(channels: [...]) no longer + // match. Channels templated from field arguments are unaffected. + log.Debug("subscribing") + sub := p.conn.Subscribe(ctx, subConf.Channels...) msgChan := sub.Channel() cleanup := func() { From 120e8fd1297770bf47b9f2d5b26acaa0aa7ab934 Mon Sep 17 00:00:00 2001 From: Damian Rakus Date: Mon, 7 Sep 2026 10:05:22 +0200 Subject: [PATCH 29/29] initial test --- .../directive-definition-data.ts | 40 ++ composition/src/router-configuration/types.ts | 16 +- composition/src/utils/string-constants.ts | 2 + composition/src/v1/constants/constants.ts | 3 + .../src/v1/constants/directive-definitions.ts | 31 + composition/src/v1/constants/strings.ts | 2 + .../v1/normalization/normalization-factory.ts | 61 +- composition/src/v1/normalization/utils.ts | 3 + connect/src/wg/cosmo/node/v1/node_pb.ts | 69 ++- proto/wg/cosmo/node/v1/node.proto | 6 + router/core/plan_generator.go | 11 + router/gen/proto/wg/cosmo/node/v1/node.pb.go | 382 +++++++------ router/internal/pusherclient/auth.go | 121 ++++ router/internal/pusherclient/client.go | 532 ++++++++++++++++++ router/internal/pusherclient/decrypt.go | 291 ++++++++++ .../internal/pusherclient/protocol_error.go | 50 ++ router/internal/pusherclient/subscription.go | 128 +++++ router/pkg/config/config.go | 91 ++- router/pkg/config/config.schema.json | 122 ++++ router/pkg/metric/stream_metric_store.go | 7 +- router/pkg/pubsub/datasource/provider.go | 7 +- .../pkg/pubsub/datasource/request_header.go | 29 + .../datasource/subscription_datasource.go | 7 +- router/pkg/pubsub/pubsub.go | 57 +- router/pkg/pubsub/pusher/adapter.go | 395 +++++++++++++ router/pkg/pubsub/pusher/engine_datasource.go | 116 ++++ .../pusher/engine_datasource_factory.go | 106 ++++ router/pkg/pubsub/pusher/entity.go | 135 +++++ router/pkg/pubsub/pusher/provider_builder.go | 79 +++ shared/src/router-config/builder.ts | 8 +- .../router-config/graphql-configuration.ts | 21 +- 31 files changed, 2731 insertions(+), 197 deletions(-) create mode 100644 router/internal/pusherclient/auth.go create mode 100644 router/internal/pusherclient/client.go create mode 100644 router/internal/pusherclient/decrypt.go create mode 100644 router/internal/pusherclient/protocol_error.go create mode 100644 router/internal/pusherclient/subscription.go create mode 100644 router/pkg/pubsub/datasource/request_header.go create mode 100644 router/pkg/pubsub/pusher/adapter.go create mode 100644 router/pkg/pubsub/pusher/engine_datasource.go create mode 100644 router/pkg/pubsub/pusher/engine_datasource_factory.go create mode 100644 router/pkg/pubsub/pusher/entity.go create mode 100644 router/pkg/pubsub/pusher/provider_builder.go diff --git a/composition/src/directive-definition-data/directive-definition-data.ts b/composition/src/directive-definition-data/directive-definition-data.ts index b6e683a92b..9e9190918e 100644 --- a/composition/src/directive-definition-data/directive-definition-data.ts +++ b/composition/src/directive-definition-data/directive-definition-data.ts @@ -25,6 +25,7 @@ import { EDFS_NATS_STREAM_CONFIGURATION, EDFS_NATS_SUBSCRIBE, EDFS_REDIS_PUBLISH, + EDFS_PUSHER_SUBSCRIBE, EDFS_REDIS_SUBSCRIBE, ENUM_UPPER, ENUM_VALUE_UPPER, @@ -104,6 +105,7 @@ import { EDFS_NATS_REQUEST_DEFINITION, EDFS_NATS_SUBSCRIBE_DEFINITION, EDFS_REDIS_PUBLISH_DEFINITION, + EDFS_PUSHER_SUBSCRIBE_DEFINITION, EDFS_REDIS_SUBSCRIBE_DEFINITION, EXTENDS_DEFINITION, EXTERNAL_DEFINITION, @@ -804,6 +806,44 @@ export const REDIS_SUBSCRIBE_DEFINITION_DATA = newDirectiveDefinitionData({ requiredArgumentNames: new Set([CHANNELS]), }); +export const PUSHER_SUBSCRIBE_DEFINITION_DATA = newDirectiveDefinitionData({ + argumentDataByName: new Map([ + [ + CHANNELS, + newDirectiveArgumentData({ + directive: `@${EDFS_PUSHER_SUBSCRIBE}`, + name: CHANNELS, + namedTypeKind: Kind.SCALAR_TYPE_DEFINITION, + typeNode: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.LIST_TYPE, + type: REQUIRED_STRING_TYPE_NODE, + }, + }, + }), + ], + [ + PROVIDER_ID, + newDirectiveArgumentData({ + directive: `@${EDFS_PUSHER_SUBSCRIBE}`, + name: PROVIDER_ID, + namedTypeKind: Kind.SCALAR_TYPE_DEFINITION, + typeNode: REQUIRED_STRING_TYPE_NODE, + defaultValue: { + kind: Kind.STRING, + value: DEFAULT_EDFS_PROVIDER_ID, + }, + }), + ], + ]), + locations: new Set([FIELD_DEFINITION_UPPER]), + name: EDFS_PUSHER_SUBSCRIBE, + node: EDFS_PUSHER_SUBSCRIBE_DEFINITION, + optionalArgumentNames: new Set([PROVIDER_ID]), + requiredArgumentNames: new Set([CHANNELS]), +}); + export const REQUIRE_FETCH_REASONS_DEFINITION_DATA = newDirectiveDefinitionData({ isRepeatable: true, locations: new Set([FIELD_DEFINITION_UPPER, INTERFACE_UPPER, OBJECT_UPPER]), diff --git a/composition/src/router-configuration/types.ts b/composition/src/router-configuration/types.ts index e55d3c7da9..8a2d67f2c6 100644 --- a/composition/src/router-configuration/types.ts +++ b/composition/src/router-configuration/types.ts @@ -13,6 +13,8 @@ export type KafkaEventType = 'subscribe' | 'publish'; export type RedisEventType = 'subscribe' | 'publish'; +export type PusherEventType = 'subscribe'; + export type StreamConfiguration = { consumerInactiveThreshold: number; consumerName: string; @@ -44,7 +46,19 @@ export type RedisEventConfiguration = { type: RedisEventType; }; -export type EventConfiguration = KafkaEventConfiguration | NatsEventConfiguration | RedisEventConfiguration; +export type PusherEventConfiguration = { + fieldName: string; + providerId: string; + providerType: 'pusher'; + channels: string[]; + type: PusherEventType; +}; + +export type EventConfiguration = + | KafkaEventConfiguration + | NatsEventConfiguration + | RedisEventConfiguration + | PusherEventConfiguration; export type SubscriptionFilterValue = boolean | null | number | string; diff --git a/composition/src/utils/string-constants.ts b/composition/src/utils/string-constants.ts index e1221b8204..56527ccd42 100644 --- a/composition/src/utils/string-constants.ts +++ b/composition/src/utils/string-constants.ts @@ -41,6 +41,7 @@ export const EDFS_PUBLISH_RESULT = 'edfs__PublishResult'; export const EDFS_NATS_STREAM_CONFIGURATION = 'edfs__NatsStreamConfiguration'; export const EDFS_REDIS_PUBLISH = 'edfs__redisPublish'; export const EDFS_REDIS_SUBSCRIBE = 'edfs__redisSubscribe'; +export const EDFS_PUSHER_SUBSCRIBE = 'edfs__pusherSubscribe'; export const ENTITIES = 'entities'; export const ENTITIES_FIELD = '_entities'; export const OPENFED_ENTITY_CACHE = 'openfed__entityCache'; @@ -103,6 +104,7 @@ export const PROPAGATE = 'propagate'; export const PROVIDER_TYPE_KAFKA = 'kafka'; export const PROVIDER_TYPE_NATS = 'nats'; export const PROVIDER_TYPE_REDIS = 'redis'; +export const PROVIDER_TYPE_PUSHER = 'pusher'; export const NOT_APPLICABLE = 'N/A'; export const NAME = 'name'; export const NEGATIVE_CACHE_TTL = 'negativeCacheTTL'; diff --git a/composition/src/v1/constants/constants.ts b/composition/src/v1/constants/constants.ts index a91e5bf469..6781321c28 100644 --- a/composition/src/v1/constants/constants.ts +++ b/composition/src/v1/constants/constants.ts @@ -17,6 +17,7 @@ import { EDFS_NATS_REQUEST, EDFS_NATS_SUBSCRIBE, EDFS_REDIS_PUBLISH, + EDFS_PUSHER_SUBSCRIBE, EDFS_REDIS_SUBSCRIBE, EXTENDS, EXTERNAL, @@ -58,6 +59,7 @@ import { EDFS_NATS_REQUEST_DEFINITION, EDFS_NATS_SUBSCRIBE_DEFINITION, EDFS_REDIS_PUBLISH_DEFINITION, + EDFS_PUSHER_SUBSCRIBE_DEFINITION, EDFS_REDIS_SUBSCRIBE_DEFINITION, EXTENDS_DEFINITION, EXTERNAL_DEFINITION, @@ -103,6 +105,7 @@ export const DIRECTIVE_DEFINITION_BY_NAME: ReadonlyMap = new Set, + fieldName: string, + errorMessages: string[], + ): EventConfiguration | undefined { + const channels: string[] = []; + let providerId = DEFAULT_EDFS_PROVIDER_ID; + for (const argumentNode of directive.arguments || []) { + switch (argumentNode.name.value) { + case CHANNELS: { + //@TODO list coercion + if (argumentNode.value.kind !== Kind.LIST) { + errorMessages.push(invalidEventSubjectsErrorMessage(CHANNELS)); + continue; + } + for (const value of argumentNode.value.values) { + if (value.kind !== Kind.STRING || value.value.length < 1) { + errorMessages.push(invalidEventSubjectsItemErrorMessage(CHANNELS)); + break; + } + validateArgumentTemplateReferences(value.value, argumentDataByArgumentName, errorMessages); + channels.push(value.value); + } + break; + } + case PROVIDER_ID: { + if (argumentNode.value.kind !== Kind.STRING || argumentNode.value.value.length < 1) { + errorMessages.push(invalidEventProviderIdErrorMessage); + continue; + } + providerId = argumentNode.value.value; + break; + } + } + } + if (errorMessages.length > 0) { + return; + } + return { + fieldName, + providerId, + providerType: PROVIDER_TYPE_PUSHER, + channels, + type: SUBSCRIBE, + }; + } + validateSubscriptionFilterDirectiveLocation(node: FieldDefinitionNode) { if (!node.directives) { return; @@ -3505,6 +3555,15 @@ export class NormalizationFactory { ); break; } + case EDFS_PUSHER_SUBSCRIBE: { + eventConfiguration = this.getPusherSubscribeConfiguration( + directive, + argumentDataByArgumentName, + fieldName, + errorMessages, + ); + break; + } default: continue; } @@ -3534,7 +3593,7 @@ export class NormalizationFactory { case OperationTypeNode.QUERY: return new Set([EDFS_NATS_REQUEST]); case OperationTypeNode.SUBSCRIPTION: - return new Set([EDFS_KAFKA_SUBSCRIBE, EDFS_NATS_SUBSCRIBE, EDFS_REDIS_SUBSCRIBE]); + return new Set([EDFS_KAFKA_SUBSCRIBE, EDFS_NATS_SUBSCRIBE, EDFS_REDIS_SUBSCRIBE, EDFS_PUSHER_SUBSCRIBE]); } } diff --git a/composition/src/v1/normalization/utils.ts b/composition/src/v1/normalization/utils.ts index cc23287050..f5e6017aa2 100644 --- a/composition/src/v1/normalization/utils.ts +++ b/composition/src/v1/normalization/utils.ts @@ -69,6 +69,7 @@ import { OVERRIDE_DEFINITION_DATA, PROVIDES_DEFINITION_DATA, REDIS_PUBLISH_DEFINITION_DATA, + PUSHER_SUBSCRIBE_DEFINITION_DATA, REDIS_SUBSCRIBE_DEFINITION_DATA, REQUIRE_FETCH_REASONS_DEFINITION_DATA, REQUIRES_DEFINITION_DATA, @@ -96,6 +97,7 @@ import { EDFS_NATS_REQUEST, EDFS_NATS_SUBSCRIBE, EDFS_REDIS_PUBLISH, + EDFS_PUSHER_SUBSCRIBE, EDFS_REDIS_SUBSCRIBE, EXTENDS, EXTERNAL, @@ -490,6 +492,7 @@ export function initializeDirectiveDefinitionDatas(): Map = /*@__PURE__*/ messageDesc(file_wg_cosmo_node_v1_node, 50); +/** + * @generated from message wg.cosmo.node.v1.PusherEventConfiguration + */ +export type PusherEventConfiguration = Message<"wg.cosmo.node.v1.PusherEventConfiguration"> & { + /** + * @generated from field: wg.cosmo.node.v1.EngineEventConfiguration engine_event_configuration = 1; + */ + engineEventConfiguration?: EngineEventConfiguration | undefined; + + /** + * @generated from field: repeated string channels = 2; + */ + channels: string[]; +}; + +/** + * Describes the message wg.cosmo.node.v1.PusherEventConfiguration. + * Use `create(PusherEventConfigurationSchema)` to create a new message. + */ +export const PusherEventConfigurationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_wg_cosmo_node_v1_node, 51); + /** * @generated from message wg.cosmo.node.v1.EngineEventConfiguration */ @@ -1716,7 +1738,7 @@ export type EngineEventConfiguration = Message<"wg.cosmo.node.v1.EngineEventConf * Use `create(EngineEventConfigurationSchema)` to create a new message. */ export const EngineEventConfigurationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 51); + messageDesc(file_wg_cosmo_node_v1_node, 52); /** * @generated from message wg.cosmo.node.v1.DataSourceCustomEvents @@ -1736,6 +1758,11 @@ export type DataSourceCustomEvents = Message<"wg.cosmo.node.v1.DataSourceCustomE * @generated from field: repeated wg.cosmo.node.v1.RedisEventConfiguration redis = 3; */ redis: RedisEventConfiguration[]; + + /** + * @generated from field: repeated wg.cosmo.node.v1.PusherEventConfiguration pusher = 4; + */ + pusher: PusherEventConfiguration[]; }; /** @@ -1743,7 +1770,7 @@ export type DataSourceCustomEvents = Message<"wg.cosmo.node.v1.DataSourceCustomE * Use `create(DataSourceCustomEventsSchema)` to create a new message. */ export const DataSourceCustomEventsSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 52); + messageDesc(file_wg_cosmo_node_v1_node, 53); /** * @generated from message wg.cosmo.node.v1.DataSourceCustom_Static @@ -1760,7 +1787,7 @@ export type DataSourceCustom_Static = Message<"wg.cosmo.node.v1.DataSourceCustom * Use `create(DataSourceCustom_StaticSchema)` to create a new message. */ export const DataSourceCustom_StaticSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 53); + messageDesc(file_wg_cosmo_node_v1_node, 54); /** * @generated from message wg.cosmo.node.v1.ConfigurationVariable @@ -1797,7 +1824,7 @@ export type ConfigurationVariable = Message<"wg.cosmo.node.v1.ConfigurationVaria * Use `create(ConfigurationVariableSchema)` to create a new message. */ export const ConfigurationVariableSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 54); + messageDesc(file_wg_cosmo_node_v1_node, 55); /** * @generated from message wg.cosmo.node.v1.DirectiveConfiguration @@ -1819,7 +1846,7 @@ export type DirectiveConfiguration = Message<"wg.cosmo.node.v1.DirectiveConfigur * Use `create(DirectiveConfigurationSchema)` to create a new message. */ export const DirectiveConfigurationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 55); + messageDesc(file_wg_cosmo_node_v1_node, 56); /** * @generated from message wg.cosmo.node.v1.URLQueryConfiguration @@ -1841,7 +1868,7 @@ export type URLQueryConfiguration = Message<"wg.cosmo.node.v1.URLQueryConfigurat * Use `create(URLQueryConfigurationSchema)` to create a new message. */ export const URLQueryConfigurationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 56); + messageDesc(file_wg_cosmo_node_v1_node, 57); /** * @generated from message wg.cosmo.node.v1.HTTPHeader @@ -1858,7 +1885,7 @@ export type HTTPHeader = Message<"wg.cosmo.node.v1.HTTPHeader"> & { * Use `create(HTTPHeaderSchema)` to create a new message. */ export const HTTPHeaderSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 57); + messageDesc(file_wg_cosmo_node_v1_node, 58); /** * @generated from message wg.cosmo.node.v1.MTLSConfiguration @@ -1885,7 +1912,7 @@ export type MTLSConfiguration = Message<"wg.cosmo.node.v1.MTLSConfiguration"> & * Use `create(MTLSConfigurationSchema)` to create a new message. */ export const MTLSConfigurationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 58); + messageDesc(file_wg_cosmo_node_v1_node, 59); /** * @generated from message wg.cosmo.node.v1.GraphQLSubscriptionConfiguration @@ -1924,7 +1951,7 @@ export type GraphQLSubscriptionConfiguration = Message<"wg.cosmo.node.v1.GraphQL * Use `create(GraphQLSubscriptionConfigurationSchema)` to create a new message. */ export const GraphQLSubscriptionConfigurationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 59); + messageDesc(file_wg_cosmo_node_v1_node, 60); /** * @generated from message wg.cosmo.node.v1.GraphQLFederationConfiguration @@ -1946,7 +1973,7 @@ export type GraphQLFederationConfiguration = Message<"wg.cosmo.node.v1.GraphQLFe * Use `create(GraphQLFederationConfigurationSchema)` to create a new message. */ export const GraphQLFederationConfigurationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 60); + messageDesc(file_wg_cosmo_node_v1_node, 61); /** * @generated from message wg.cosmo.node.v1.InternedString @@ -1965,7 +1992,7 @@ export type InternedString = Message<"wg.cosmo.node.v1.InternedString"> & { * Use `create(InternedStringSchema)` to create a new message. */ export const InternedStringSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 61); + messageDesc(file_wg_cosmo_node_v1_node, 62); /** * @generated from message wg.cosmo.node.v1.SingleTypeField @@ -1987,7 +2014,7 @@ export type SingleTypeField = Message<"wg.cosmo.node.v1.SingleTypeField"> & { * Use `create(SingleTypeFieldSchema)` to create a new message. */ export const SingleTypeFieldSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 62); + messageDesc(file_wg_cosmo_node_v1_node, 63); /** * @generated from message wg.cosmo.node.v1.SubscriptionFieldCondition @@ -2009,7 +2036,7 @@ export type SubscriptionFieldCondition = Message<"wg.cosmo.node.v1.SubscriptionF * Use `create(SubscriptionFieldConditionSchema)` to create a new message. */ export const SubscriptionFieldConditionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 63); + messageDesc(file_wg_cosmo_node_v1_node, 64); /** * @generated from message wg.cosmo.node.v1.SubscriptionFilterCondition @@ -2041,7 +2068,7 @@ export type SubscriptionFilterCondition = Message<"wg.cosmo.node.v1.Subscription * Use `create(SubscriptionFilterConditionSchema)` to create a new message. */ export const SubscriptionFilterConditionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 64); + messageDesc(file_wg_cosmo_node_v1_node, 65); /** * @generated from message wg.cosmo.node.v1.CacheWarmerOperations @@ -2058,7 +2085,7 @@ export type CacheWarmerOperations = Message<"wg.cosmo.node.v1.CacheWarmerOperati * Use `create(CacheWarmerOperationsSchema)` to create a new message. */ export const CacheWarmerOperationsSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 65); + messageDesc(file_wg_cosmo_node_v1_node, 66); /** * @generated from message wg.cosmo.node.v1.Operation @@ -2080,7 +2107,7 @@ export type Operation = Message<"wg.cosmo.node.v1.Operation"> & { * Use `create(OperationSchema)` to create a new message. */ export const OperationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 66); + messageDesc(file_wg_cosmo_node_v1_node, 67); /** * @generated from message wg.cosmo.node.v1.OperationRequest @@ -2107,7 +2134,7 @@ export type OperationRequest = Message<"wg.cosmo.node.v1.OperationRequest"> & { * Use `create(OperationRequestSchema)` to create a new message. */ export const OperationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 67); + messageDesc(file_wg_cosmo_node_v1_node, 68); /** * @generated from message wg.cosmo.node.v1.Extension @@ -2124,7 +2151,7 @@ export type Extension = Message<"wg.cosmo.node.v1.Extension"> & { * Use `create(ExtensionSchema)` to create a new message. */ export const ExtensionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 68); + messageDesc(file_wg_cosmo_node_v1_node, 69); /** * @generated from message wg.cosmo.node.v1.PersistedQuery @@ -2146,7 +2173,7 @@ export type PersistedQuery = Message<"wg.cosmo.node.v1.PersistedQuery"> & { * Use `create(PersistedQuerySchema)` to create a new message. */ export const PersistedQuerySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 69); + messageDesc(file_wg_cosmo_node_v1_node, 70); /** * @generated from message wg.cosmo.node.v1.ClientInfo @@ -2168,7 +2195,7 @@ export type ClientInfo = Message<"wg.cosmo.node.v1.ClientInfo"> & { * Use `create(ClientInfoSchema)` to create a new message. */ export const ClientInfoSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 70); + messageDesc(file_wg_cosmo_node_v1_node, 71); /** * @generated from enum wg.cosmo.node.v1.ArgumentRenderConfiguration diff --git a/proto/wg/cosmo/node/v1/node.proto b/proto/wg/cosmo/node/v1/node.proto index 4e75ed1440..3b230b3b4a 100644 --- a/proto/wg/cosmo/node/v1/node.proto +++ b/proto/wg/cosmo/node/v1/node.proto @@ -466,6 +466,11 @@ message RedisEventConfiguration { repeated string channels = 2; } +message PusherEventConfiguration { + EngineEventConfiguration engine_event_configuration = 1; + repeated string channels = 2; +} + message EngineEventConfiguration { string provider_id = 1; EventType type = 2; @@ -477,6 +482,7 @@ message DataSourceCustomEvents { repeated NatsEventConfiguration nats = 1; repeated KafkaEventConfiguration kafka = 2; repeated RedisEventConfiguration redis = 3; + repeated PusherEventConfiguration pusher = 4; } message DataSourceCustom_Static { diff --git a/router/core/plan_generator.go b/router/core/plan_generator.go index 2cf10f1e76..2efea664e9 100644 --- a/router/core/plan_generator.go +++ b/router/core/plan_generator.go @@ -17,6 +17,7 @@ import ( "github.com/wundergraph/cosmo/router/pkg/metric" "github.com/wundergraph/cosmo/router/pkg/pubsub/kafka" "github.com/wundergraph/cosmo/router/pkg/pubsub/nats" + "github.com/wundergraph/cosmo/router/pkg/pubsub/pusher" "github.com/wundergraph/cosmo/router/pkg/pubsub/redis" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" @@ -397,6 +398,7 @@ func (pg *PlanGenerator) loadConfiguration(routerConfig *nodev1.RouterConfig, lo natSources := map[string]*nats.ProviderAdapter{} kafkaSources := map[string]*kafka.ProviderAdapter{} redisSources := map[string]*redis.ProviderAdapter{} + pusherSources := map[string]*pusher.ProviderAdapter{} for _, ds := range routerConfig.GetEngineConfig().GetDatasourceConfigurations() { if ds.GetKind() != nodev1.DataSourceKind_PUBSUB || ds.GetCustomEvents() == nil { continue @@ -428,6 +430,15 @@ func (pg *PlanGenerator) loadConfiguration(routerConfig *nodev1.RouterConfig, lo }) } } + for _, pusherConfig := range ds.GetCustomEvents().GetPusher() { + providerId := pusherConfig.GetEngineEventConfiguration().GetProviderId() + if _, ok := pusherSources[providerId]; !ok { + pusherSources[providerId] = nil + routerEngineConfig.Events.Providers.Pusher = append(routerEngineConfig.Events.Providers.Pusher, config.PusherEventSource{ + ID: providerId, + }) + } + } } ctx, cancel := context.WithCancel(context.Background()) diff --git a/router/gen/proto/wg/cosmo/node/v1/node.pb.go b/router/gen/proto/wg/cosmo/node/v1/node.pb.go index a35547141d..0080c4d7e8 100644 --- a/router/gen/proto/wg/cosmo/node/v1/node.pb.go +++ b/router/gen/proto/wg/cosmo/node/v1/node.pb.go @@ -3805,6 +3805,58 @@ func (x *RedisEventConfiguration) GetChannels() []string { return nil } +type PusherEventConfiguration struct { + state protoimpl.MessageState `protogen:"open.v1"` + EngineEventConfiguration *EngineEventConfiguration `protobuf:"bytes,1,opt,name=engine_event_configuration,json=engineEventConfiguration,proto3" json:"engine_event_configuration,omitempty"` + Channels []string `protobuf:"bytes,2,rep,name=channels,proto3" json:"channels,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PusherEventConfiguration) Reset() { + *x = PusherEventConfiguration{} + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[51] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PusherEventConfiguration) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PusherEventConfiguration) ProtoMessage() {} + +func (x *PusherEventConfiguration) ProtoReflect() protoreflect.Message { + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[51] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PusherEventConfiguration.ProtoReflect.Descriptor instead. +func (*PusherEventConfiguration) Descriptor() ([]byte, []int) { + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{51} +} + +func (x *PusherEventConfiguration) GetEngineEventConfiguration() *EngineEventConfiguration { + if x != nil { + return x.EngineEventConfiguration + } + return nil +} + +func (x *PusherEventConfiguration) GetChannels() []string { + if x != nil { + return x.Channels + } + return nil +} + type EngineEventConfiguration struct { state protoimpl.MessageState `protogen:"open.v1"` ProviderId string `protobuf:"bytes,1,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` @@ -3817,7 +3869,7 @@ type EngineEventConfiguration struct { func (x *EngineEventConfiguration) Reset() { *x = EngineEventConfiguration{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[51] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3829,7 +3881,7 @@ func (x *EngineEventConfiguration) String() string { func (*EngineEventConfiguration) ProtoMessage() {} func (x *EngineEventConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[51] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3842,7 +3894,7 @@ func (x *EngineEventConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use EngineEventConfiguration.ProtoReflect.Descriptor instead. func (*EngineEventConfiguration) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{51} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{52} } func (x *EngineEventConfiguration) GetProviderId() string { @@ -3874,17 +3926,18 @@ func (x *EngineEventConfiguration) GetFieldName() string { } type DataSourceCustomEvents struct { - state protoimpl.MessageState `protogen:"open.v1"` - Nats []*NatsEventConfiguration `protobuf:"bytes,1,rep,name=nats,proto3" json:"nats,omitempty"` - Kafka []*KafkaEventConfiguration `protobuf:"bytes,2,rep,name=kafka,proto3" json:"kafka,omitempty"` - Redis []*RedisEventConfiguration `protobuf:"bytes,3,rep,name=redis,proto3" json:"redis,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Nats []*NatsEventConfiguration `protobuf:"bytes,1,rep,name=nats,proto3" json:"nats,omitempty"` + Kafka []*KafkaEventConfiguration `protobuf:"bytes,2,rep,name=kafka,proto3" json:"kafka,omitempty"` + Redis []*RedisEventConfiguration `protobuf:"bytes,3,rep,name=redis,proto3" json:"redis,omitempty"` + Pusher []*PusherEventConfiguration `protobuf:"bytes,4,rep,name=pusher,proto3" json:"pusher,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DataSourceCustomEvents) Reset() { *x = DataSourceCustomEvents{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[52] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3896,7 +3949,7 @@ func (x *DataSourceCustomEvents) String() string { func (*DataSourceCustomEvents) ProtoMessage() {} func (x *DataSourceCustomEvents) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[52] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3909,7 +3962,7 @@ func (x *DataSourceCustomEvents) ProtoReflect() protoreflect.Message { // Deprecated: Use DataSourceCustomEvents.ProtoReflect.Descriptor instead. func (*DataSourceCustomEvents) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{52} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{53} } func (x *DataSourceCustomEvents) GetNats() []*NatsEventConfiguration { @@ -3933,6 +3986,13 @@ func (x *DataSourceCustomEvents) GetRedis() []*RedisEventConfiguration { return nil } +func (x *DataSourceCustomEvents) GetPusher() []*PusherEventConfiguration { + if x != nil { + return x.Pusher + } + return nil +} + type DataSourceCustom_Static struct { state protoimpl.MessageState `protogen:"open.v1"` Data *ConfigurationVariable `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` @@ -3942,7 +4002,7 @@ type DataSourceCustom_Static struct { func (x *DataSourceCustom_Static) Reset() { *x = DataSourceCustom_Static{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[53] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3954,7 +4014,7 @@ func (x *DataSourceCustom_Static) String() string { func (*DataSourceCustom_Static) ProtoMessage() {} func (x *DataSourceCustom_Static) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[53] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3967,7 +4027,7 @@ func (x *DataSourceCustom_Static) ProtoReflect() protoreflect.Message { // Deprecated: Use DataSourceCustom_Static.ProtoReflect.Descriptor instead. func (*DataSourceCustom_Static) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{53} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{54} } func (x *DataSourceCustom_Static) GetData() *ConfigurationVariable { @@ -3990,7 +4050,7 @@ type ConfigurationVariable struct { func (x *ConfigurationVariable) Reset() { *x = ConfigurationVariable{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[54] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4002,7 +4062,7 @@ func (x *ConfigurationVariable) String() string { func (*ConfigurationVariable) ProtoMessage() {} func (x *ConfigurationVariable) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[54] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4015,7 +4075,7 @@ func (x *ConfigurationVariable) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigurationVariable.ProtoReflect.Descriptor instead. func (*ConfigurationVariable) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{54} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{55} } func (x *ConfigurationVariable) GetKind() ConfigurationVariableKind { @@ -4063,7 +4123,7 @@ type DirectiveConfiguration struct { func (x *DirectiveConfiguration) Reset() { *x = DirectiveConfiguration{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[55] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4075,7 +4135,7 @@ func (x *DirectiveConfiguration) String() string { func (*DirectiveConfiguration) ProtoMessage() {} func (x *DirectiveConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[55] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4088,7 +4148,7 @@ func (x *DirectiveConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use DirectiveConfiguration.ProtoReflect.Descriptor instead. func (*DirectiveConfiguration) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{55} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{56} } func (x *DirectiveConfiguration) GetDirectiveName() string { @@ -4115,7 +4175,7 @@ type URLQueryConfiguration struct { func (x *URLQueryConfiguration) Reset() { *x = URLQueryConfiguration{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[56] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4127,7 +4187,7 @@ func (x *URLQueryConfiguration) String() string { func (*URLQueryConfiguration) ProtoMessage() {} func (x *URLQueryConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[56] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4140,7 +4200,7 @@ func (x *URLQueryConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use URLQueryConfiguration.ProtoReflect.Descriptor instead. func (*URLQueryConfiguration) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{56} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{57} } func (x *URLQueryConfiguration) GetName() string { @@ -4166,7 +4226,7 @@ type HTTPHeader struct { func (x *HTTPHeader) Reset() { *x = HTTPHeader{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[57] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4178,7 +4238,7 @@ func (x *HTTPHeader) String() string { func (*HTTPHeader) ProtoMessage() {} func (x *HTTPHeader) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[57] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4191,7 +4251,7 @@ func (x *HTTPHeader) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPHeader.ProtoReflect.Descriptor instead. func (*HTTPHeader) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{57} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{58} } func (x *HTTPHeader) GetValues() []*ConfigurationVariable { @@ -4212,7 +4272,7 @@ type MTLSConfiguration struct { func (x *MTLSConfiguration) Reset() { *x = MTLSConfiguration{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[58] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4224,7 +4284,7 @@ func (x *MTLSConfiguration) String() string { func (*MTLSConfiguration) ProtoMessage() {} func (x *MTLSConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[58] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4237,7 +4297,7 @@ func (x *MTLSConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use MTLSConfiguration.ProtoReflect.Descriptor instead. func (*MTLSConfiguration) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{58} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{59} } func (x *MTLSConfiguration) GetKey() *ConfigurationVariable { @@ -4275,7 +4335,7 @@ type GraphQLSubscriptionConfiguration struct { func (x *GraphQLSubscriptionConfiguration) Reset() { *x = GraphQLSubscriptionConfiguration{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[59] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4287,7 +4347,7 @@ func (x *GraphQLSubscriptionConfiguration) String() string { func (*GraphQLSubscriptionConfiguration) ProtoMessage() {} func (x *GraphQLSubscriptionConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[59] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4300,7 +4360,7 @@ func (x *GraphQLSubscriptionConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use GraphQLSubscriptionConfiguration.ProtoReflect.Descriptor instead. func (*GraphQLSubscriptionConfiguration) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{59} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{60} } func (x *GraphQLSubscriptionConfiguration) GetEnabled() bool { @@ -4348,7 +4408,7 @@ type GraphQLFederationConfiguration struct { func (x *GraphQLFederationConfiguration) Reset() { *x = GraphQLFederationConfiguration{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[60] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4360,7 +4420,7 @@ func (x *GraphQLFederationConfiguration) String() string { func (*GraphQLFederationConfiguration) ProtoMessage() {} func (x *GraphQLFederationConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[60] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4373,7 +4433,7 @@ func (x *GraphQLFederationConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use GraphQLFederationConfiguration.ProtoReflect.Descriptor instead. func (*GraphQLFederationConfiguration) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{60} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{61} } func (x *GraphQLFederationConfiguration) GetEnabled() bool { @@ -4400,7 +4460,7 @@ type InternedString struct { func (x *InternedString) Reset() { *x = InternedString{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[61] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4412,7 +4472,7 @@ func (x *InternedString) String() string { func (*InternedString) ProtoMessage() {} func (x *InternedString) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[61] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4425,7 +4485,7 @@ func (x *InternedString) ProtoReflect() protoreflect.Message { // Deprecated: Use InternedString.ProtoReflect.Descriptor instead. func (*InternedString) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{61} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{62} } func (x *InternedString) GetKey() string { @@ -4445,7 +4505,7 @@ type SingleTypeField struct { func (x *SingleTypeField) Reset() { *x = SingleTypeField{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[62] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4457,7 +4517,7 @@ func (x *SingleTypeField) String() string { func (*SingleTypeField) ProtoMessage() {} func (x *SingleTypeField) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[62] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4470,7 +4530,7 @@ func (x *SingleTypeField) ProtoReflect() protoreflect.Message { // Deprecated: Use SingleTypeField.ProtoReflect.Descriptor instead. func (*SingleTypeField) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{62} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{63} } func (x *SingleTypeField) GetTypeName() string { @@ -4497,7 +4557,7 @@ type SubscriptionFieldCondition struct { func (x *SubscriptionFieldCondition) Reset() { *x = SubscriptionFieldCondition{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[63] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4509,7 +4569,7 @@ func (x *SubscriptionFieldCondition) String() string { func (*SubscriptionFieldCondition) ProtoMessage() {} func (x *SubscriptionFieldCondition) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[63] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4522,7 +4582,7 @@ func (x *SubscriptionFieldCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscriptionFieldCondition.ProtoReflect.Descriptor instead. func (*SubscriptionFieldCondition) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{63} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{64} } func (x *SubscriptionFieldCondition) GetFieldPath() []string { @@ -4551,7 +4611,7 @@ type SubscriptionFilterCondition struct { func (x *SubscriptionFilterCondition) Reset() { *x = SubscriptionFilterCondition{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[64] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4563,7 +4623,7 @@ func (x *SubscriptionFilterCondition) String() string { func (*SubscriptionFilterCondition) ProtoMessage() {} func (x *SubscriptionFilterCondition) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[64] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4576,7 +4636,7 @@ func (x *SubscriptionFilterCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscriptionFilterCondition.ProtoReflect.Descriptor instead. func (*SubscriptionFilterCondition) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{64} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{65} } func (x *SubscriptionFilterCondition) GetAnd() []*SubscriptionFilterCondition { @@ -4616,7 +4676,7 @@ type CacheWarmerOperations struct { func (x *CacheWarmerOperations) Reset() { *x = CacheWarmerOperations{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[65] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4628,7 +4688,7 @@ func (x *CacheWarmerOperations) String() string { func (*CacheWarmerOperations) ProtoMessage() {} func (x *CacheWarmerOperations) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[65] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4641,7 +4701,7 @@ func (x *CacheWarmerOperations) ProtoReflect() protoreflect.Message { // Deprecated: Use CacheWarmerOperations.ProtoReflect.Descriptor instead. func (*CacheWarmerOperations) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{65} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{66} } func (x *CacheWarmerOperations) GetOperations() []*Operation { @@ -4661,7 +4721,7 @@ type Operation struct { func (x *Operation) Reset() { *x = Operation{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[66] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4673,7 +4733,7 @@ func (x *Operation) String() string { func (*Operation) ProtoMessage() {} func (x *Operation) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[66] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4686,7 +4746,7 @@ func (x *Operation) ProtoReflect() protoreflect.Message { // Deprecated: Use Operation.ProtoReflect.Descriptor instead. func (*Operation) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{66} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{67} } func (x *Operation) GetRequest() *OperationRequest { @@ -4714,7 +4774,7 @@ type OperationRequest struct { func (x *OperationRequest) Reset() { *x = OperationRequest{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[67] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4726,7 +4786,7 @@ func (x *OperationRequest) String() string { func (*OperationRequest) ProtoMessage() {} func (x *OperationRequest) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[67] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4739,7 +4799,7 @@ func (x *OperationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use OperationRequest.ProtoReflect.Descriptor instead. func (*OperationRequest) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{67} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{68} } func (x *OperationRequest) GetOperationName() string { @@ -4772,7 +4832,7 @@ type Extension struct { func (x *Extension) Reset() { *x = Extension{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[68] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4784,7 +4844,7 @@ func (x *Extension) String() string { func (*Extension) ProtoMessage() {} func (x *Extension) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[68] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4797,7 +4857,7 @@ func (x *Extension) ProtoReflect() protoreflect.Message { // Deprecated: Use Extension.ProtoReflect.Descriptor instead. func (*Extension) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{68} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{69} } func (x *Extension) GetPersistedQuery() *PersistedQuery { @@ -4817,7 +4877,7 @@ type PersistedQuery struct { func (x *PersistedQuery) Reset() { *x = PersistedQuery{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[69] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4829,7 +4889,7 @@ func (x *PersistedQuery) String() string { func (*PersistedQuery) ProtoMessage() {} func (x *PersistedQuery) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[69] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4842,7 +4902,7 @@ func (x *PersistedQuery) ProtoReflect() protoreflect.Message { // Deprecated: Use PersistedQuery.ProtoReflect.Descriptor instead. func (*PersistedQuery) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{69} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{70} } func (x *PersistedQuery) GetSha256Hash() string { @@ -4869,7 +4929,7 @@ type ClientInfo struct { func (x *ClientInfo) Reset() { *x = ClientInfo{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[70] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4881,7 +4941,7 @@ func (x *ClientInfo) String() string { func (*ClientInfo) ProtoMessage() {} func (x *ClientInfo) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[70] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4894,7 +4954,7 @@ func (x *ClientInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientInfo.ProtoReflect.Descriptor instead. func (*ClientInfo) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{70} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{71} } func (x *ClientInfo) GetName() string { @@ -5208,6 +5268,9 @@ const file_wg_cosmo_node_v1_node_proto_rawDesc = "" + "\x06topics\x18\x02 \x03(\tR\x06topics\"\x9f\x01\n" + "\x17RedisEventConfiguration\x12h\n" + "\x1aengine_event_configuration\x18\x01 \x01(\v2*.wg.cosmo.node.v1.EngineEventConfigurationR\x18engineEventConfiguration\x12\x1a\n" + + "\bchannels\x18\x02 \x03(\tR\bchannels\"\xa0\x01\n" + + "\x18PusherEventConfiguration\x12h\n" + + "\x1aengine_event_configuration\x18\x01 \x01(\v2*.wg.cosmo.node.v1.EngineEventConfigurationR\x18engineEventConfiguration\x12\x1a\n" + "\bchannels\x18\x02 \x03(\tR\bchannels\"\xa8\x01\n" + "\x18EngineEventConfiguration\x12\x1f\n" + "\vprovider_id\x18\x01 \x01(\tR\n" + @@ -5215,11 +5278,12 @@ const file_wg_cosmo_node_v1_node_proto_rawDesc = "" + "\x04type\x18\x02 \x01(\x0e2\x1b.wg.cosmo.node.v1.EventTypeR\x04type\x12\x1b\n" + "\ttype_name\x18\x03 \x01(\tR\btypeName\x12\x1d\n" + "\n" + - "field_name\x18\x04 \x01(\tR\tfieldName\"\xd8\x01\n" + + "field_name\x18\x04 \x01(\tR\tfieldName\"\x9c\x02\n" + "\x16DataSourceCustomEvents\x12<\n" + "\x04nats\x18\x01 \x03(\v2(.wg.cosmo.node.v1.NatsEventConfigurationR\x04nats\x12?\n" + "\x05kafka\x18\x02 \x03(\v2).wg.cosmo.node.v1.KafkaEventConfigurationR\x05kafka\x12?\n" + - "\x05redis\x18\x03 \x03(\v2).wg.cosmo.node.v1.RedisEventConfigurationR\x05redis\"V\n" + + "\x05redis\x18\x03 \x03(\v2).wg.cosmo.node.v1.RedisEventConfigurationR\x05redis\x12B\n" + + "\x06pusher\x18\x04 \x03(\v2*.wg.cosmo.node.v1.PusherEventConfigurationR\x06pusher\"V\n" + "\x17DataSourceCustom_Static\x12;\n" + "\x04data\x18\x01 \x01(\v2'.wg.cosmo.node.v1.ConfigurationVariableR\x04data\"\xd5\x02\n" + "\x15ConfigurationVariable\x12?\n" + @@ -5335,8 +5399,7 @@ const file_wg_cosmo_node_v1_node_proto_rawDesc = "" + "\x06DELETE\x10\x03\x12\v\n" + "\aOPTIONS\x10\x042n\n" + "\vNodeService\x12_\n" + - "\fSelfRegister\x12%.wg.cosmo.node.v1.SelfRegisterRequest\x1a&.wg.cosmo.node.v1.SelfRegisterResponse\"\x00B\xcb\x01\n" + - "\x14com.wg.cosmo.node.v1B\tNodeProtoP\x01ZEgithub.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1;nodev1\xa2\x02\x03WCN\xaa\x02\x10Wg.Cosmo.Node.V1\xca\x02\x10Wg\\Cosmo\\Node\\V1\xe2\x02\x1cWg\\Cosmo\\Node\\V1\\GPBMetadata\xea\x02\x13Wg::Cosmo::Node::V1b\x06proto3" + "\fSelfRegister\x12%.wg.cosmo.node.v1.SelfRegisterRequest\x1a&.wg.cosmo.node.v1.SelfRegisterResponse\"\x00b\x06proto3" var ( file_wg_cosmo_node_v1_node_proto_rawDescOnce sync.Once @@ -5351,7 +5414,7 @@ func file_wg_cosmo_node_v1_node_proto_rawDescGZIP() []byte { } var file_wg_cosmo_node_v1_node_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_wg_cosmo_node_v1_node_proto_msgTypes = make([]protoimpl.MessageInfo, 78) +var file_wg_cosmo_node_v1_node_proto_msgTypes = make([]protoimpl.MessageInfo, 79) var file_wg_cosmo_node_v1_node_proto_goTypes = []any{ (ArgumentRenderConfiguration)(0), // 0: wg.cosmo.node.v1.ArgumentRenderConfiguration (ArgumentSource)(0), // 1: wg.cosmo.node.v1.ArgumentSource @@ -5412,62 +5475,63 @@ var file_wg_cosmo_node_v1_node_proto_goTypes = []any{ (*NatsEventConfiguration)(nil), // 56: wg.cosmo.node.v1.NatsEventConfiguration (*KafkaEventConfiguration)(nil), // 57: wg.cosmo.node.v1.KafkaEventConfiguration (*RedisEventConfiguration)(nil), // 58: wg.cosmo.node.v1.RedisEventConfiguration - (*EngineEventConfiguration)(nil), // 59: wg.cosmo.node.v1.EngineEventConfiguration - (*DataSourceCustomEvents)(nil), // 60: wg.cosmo.node.v1.DataSourceCustomEvents - (*DataSourceCustom_Static)(nil), // 61: wg.cosmo.node.v1.DataSourceCustom_Static - (*ConfigurationVariable)(nil), // 62: wg.cosmo.node.v1.ConfigurationVariable - (*DirectiveConfiguration)(nil), // 63: wg.cosmo.node.v1.DirectiveConfiguration - (*URLQueryConfiguration)(nil), // 64: wg.cosmo.node.v1.URLQueryConfiguration - (*HTTPHeader)(nil), // 65: wg.cosmo.node.v1.HTTPHeader - (*MTLSConfiguration)(nil), // 66: wg.cosmo.node.v1.MTLSConfiguration - (*GraphQLSubscriptionConfiguration)(nil), // 67: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration - (*GraphQLFederationConfiguration)(nil), // 68: wg.cosmo.node.v1.GraphQLFederationConfiguration - (*InternedString)(nil), // 69: wg.cosmo.node.v1.InternedString - (*SingleTypeField)(nil), // 70: wg.cosmo.node.v1.SingleTypeField - (*SubscriptionFieldCondition)(nil), // 71: wg.cosmo.node.v1.SubscriptionFieldCondition - (*SubscriptionFilterCondition)(nil), // 72: wg.cosmo.node.v1.SubscriptionFilterCondition - (*CacheWarmerOperations)(nil), // 73: wg.cosmo.node.v1.CacheWarmerOperations - (*Operation)(nil), // 74: wg.cosmo.node.v1.Operation - (*OperationRequest)(nil), // 75: wg.cosmo.node.v1.OperationRequest - (*Extension)(nil), // 76: wg.cosmo.node.v1.Extension - (*PersistedQuery)(nil), // 77: wg.cosmo.node.v1.PersistedQuery - (*ClientInfo)(nil), // 78: wg.cosmo.node.v1.ClientInfo - nil, // 79: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.ConfigByFeatureFlagNameEntry - nil, // 80: wg.cosmo.node.v1.EngineConfiguration.StringStorageEntry - nil, // 81: wg.cosmo.node.v1.CostConfiguration.TypeWeightsEntry - nil, // 82: wg.cosmo.node.v1.CostConfiguration.DirectiveArgumentWeightsEntry - nil, // 83: wg.cosmo.node.v1.FieldWeightConfiguration.ArgumentWeightsEntry - nil, // 84: wg.cosmo.node.v1.FieldWeightConfiguration.DirectiveArgumentWeightsEntry - nil, // 85: wg.cosmo.node.v1.FetchConfiguration.HeaderEntry - (common.EnumStatusCode)(0), // 86: wg.cosmo.common.EnumStatusCode - (common.GraphQLSubscriptionProtocol)(0), // 87: wg.cosmo.common.GraphQLSubscriptionProtocol - (common.GraphQLWebsocketSubprotocol)(0), // 88: wg.cosmo.common.GraphQLWebsocketSubprotocol + (*PusherEventConfiguration)(nil), // 59: wg.cosmo.node.v1.PusherEventConfiguration + (*EngineEventConfiguration)(nil), // 60: wg.cosmo.node.v1.EngineEventConfiguration + (*DataSourceCustomEvents)(nil), // 61: wg.cosmo.node.v1.DataSourceCustomEvents + (*DataSourceCustom_Static)(nil), // 62: wg.cosmo.node.v1.DataSourceCustom_Static + (*ConfigurationVariable)(nil), // 63: wg.cosmo.node.v1.ConfigurationVariable + (*DirectiveConfiguration)(nil), // 64: wg.cosmo.node.v1.DirectiveConfiguration + (*URLQueryConfiguration)(nil), // 65: wg.cosmo.node.v1.URLQueryConfiguration + (*HTTPHeader)(nil), // 66: wg.cosmo.node.v1.HTTPHeader + (*MTLSConfiguration)(nil), // 67: wg.cosmo.node.v1.MTLSConfiguration + (*GraphQLSubscriptionConfiguration)(nil), // 68: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration + (*GraphQLFederationConfiguration)(nil), // 69: wg.cosmo.node.v1.GraphQLFederationConfiguration + (*InternedString)(nil), // 70: wg.cosmo.node.v1.InternedString + (*SingleTypeField)(nil), // 71: wg.cosmo.node.v1.SingleTypeField + (*SubscriptionFieldCondition)(nil), // 72: wg.cosmo.node.v1.SubscriptionFieldCondition + (*SubscriptionFilterCondition)(nil), // 73: wg.cosmo.node.v1.SubscriptionFilterCondition + (*CacheWarmerOperations)(nil), // 74: wg.cosmo.node.v1.CacheWarmerOperations + (*Operation)(nil), // 75: wg.cosmo.node.v1.Operation + (*OperationRequest)(nil), // 76: wg.cosmo.node.v1.OperationRequest + (*Extension)(nil), // 77: wg.cosmo.node.v1.Extension + (*PersistedQuery)(nil), // 78: wg.cosmo.node.v1.PersistedQuery + (*ClientInfo)(nil), // 79: wg.cosmo.node.v1.ClientInfo + nil, // 80: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.ConfigByFeatureFlagNameEntry + nil, // 81: wg.cosmo.node.v1.EngineConfiguration.StringStorageEntry + nil, // 82: wg.cosmo.node.v1.CostConfiguration.TypeWeightsEntry + nil, // 83: wg.cosmo.node.v1.CostConfiguration.DirectiveArgumentWeightsEntry + nil, // 84: wg.cosmo.node.v1.FieldWeightConfiguration.ArgumentWeightsEntry + nil, // 85: wg.cosmo.node.v1.FieldWeightConfiguration.DirectiveArgumentWeightsEntry + nil, // 86: wg.cosmo.node.v1.FetchConfiguration.HeaderEntry + (common.EnumStatusCode)(0), // 87: wg.cosmo.common.EnumStatusCode + (common.GraphQLSubscriptionProtocol)(0), // 88: wg.cosmo.common.GraphQLSubscriptionProtocol + (common.GraphQLWebsocketSubprotocol)(0), // 89: wg.cosmo.common.GraphQLWebsocketSubprotocol } var file_wg_cosmo_node_v1_node_proto_depIdxs = []int32{ - 79, // 0: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.config_by_feature_flag_name:type_name -> wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.ConfigByFeatureFlagNameEntry + 80, // 0: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.config_by_feature_flag_name:type_name -> wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.ConfigByFeatureFlagNameEntry 18, // 1: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfig.engine_config:type_name -> wg.cosmo.node.v1.EngineConfiguration 8, // 2: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfig.subgraphs:type_name -> wg.cosmo.node.v1.Subgraph 18, // 3: wg.cosmo.node.v1.RouterConfig.engine_config:type_name -> wg.cosmo.node.v1.EngineConfiguration 8, // 4: wg.cosmo.node.v1.RouterConfig.subgraphs:type_name -> wg.cosmo.node.v1.Subgraph 9, // 5: wg.cosmo.node.v1.RouterConfig.feature_flag_configs:type_name -> wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs - 86, // 6: wg.cosmo.node.v1.Response.code:type_name -> wg.cosmo.common.EnumStatusCode + 87, // 6: wg.cosmo.node.v1.Response.code:type_name -> wg.cosmo.common.EnumStatusCode 15, // 7: wg.cosmo.node.v1.RegistrationInfo.account_limits:type_name -> wg.cosmo.node.v1.AccountLimits 12, // 8: wg.cosmo.node.v1.SelfRegisterResponse.response:type_name -> wg.cosmo.node.v1.Response 14, // 9: wg.cosmo.node.v1.SelfRegisterResponse.registrationInfo:type_name -> wg.cosmo.node.v1.RegistrationInfo 19, // 10: wg.cosmo.node.v1.EngineConfiguration.datasource_configurations:type_name -> wg.cosmo.node.v1.DataSourceConfiguration 30, // 11: wg.cosmo.node.v1.EngineConfiguration.field_configurations:type_name -> wg.cosmo.node.v1.FieldConfiguration 31, // 12: wg.cosmo.node.v1.EngineConfiguration.type_configurations:type_name -> wg.cosmo.node.v1.TypeConfiguration - 80, // 13: wg.cosmo.node.v1.EngineConfiguration.string_storage:type_name -> wg.cosmo.node.v1.EngineConfiguration.StringStorageEntry + 81, // 13: wg.cosmo.node.v1.EngineConfiguration.string_storage:type_name -> wg.cosmo.node.v1.EngineConfiguration.StringStorageEntry 2, // 14: wg.cosmo.node.v1.DataSourceConfiguration.kind:type_name -> wg.cosmo.node.v1.DataSourceKind 32, // 15: wg.cosmo.node.v1.DataSourceConfiguration.root_nodes:type_name -> wg.cosmo.node.v1.TypeField 32, // 16: wg.cosmo.node.v1.DataSourceConfiguration.child_nodes:type_name -> wg.cosmo.node.v1.TypeField 39, // 17: wg.cosmo.node.v1.DataSourceConfiguration.custom_graphql:type_name -> wg.cosmo.node.v1.DataSourceCustom_GraphQL - 61, // 18: wg.cosmo.node.v1.DataSourceConfiguration.custom_static:type_name -> wg.cosmo.node.v1.DataSourceCustom_Static - 63, // 19: wg.cosmo.node.v1.DataSourceConfiguration.directives:type_name -> wg.cosmo.node.v1.DirectiveConfiguration + 62, // 18: wg.cosmo.node.v1.DataSourceConfiguration.custom_static:type_name -> wg.cosmo.node.v1.DataSourceCustom_Static + 64, // 19: wg.cosmo.node.v1.DataSourceConfiguration.directives:type_name -> wg.cosmo.node.v1.DirectiveConfiguration 35, // 20: wg.cosmo.node.v1.DataSourceConfiguration.keys:type_name -> wg.cosmo.node.v1.RequiredField 35, // 21: wg.cosmo.node.v1.DataSourceConfiguration.provides:type_name -> wg.cosmo.node.v1.RequiredField 35, // 22: wg.cosmo.node.v1.DataSourceConfiguration.requires:type_name -> wg.cosmo.node.v1.RequiredField - 60, // 23: wg.cosmo.node.v1.DataSourceConfiguration.custom_events:type_name -> wg.cosmo.node.v1.DataSourceCustomEvents + 61, // 23: wg.cosmo.node.v1.DataSourceConfiguration.custom_events:type_name -> wg.cosmo.node.v1.DataSourceCustomEvents 36, // 24: wg.cosmo.node.v1.DataSourceConfiguration.entity_interfaces:type_name -> wg.cosmo.node.v1.EntityInterfaceConfiguration 36, // 25: wg.cosmo.node.v1.DataSourceConfiguration.interface_objects:type_name -> wg.cosmo.node.v1.EntityInterfaceConfiguration 24, // 26: wg.cosmo.node.v1.DataSourceConfiguration.cost_configuration:type_name -> wg.cosmo.node.v1.CostConfiguration @@ -5477,32 +5541,32 @@ var file_wg_cosmo_node_v1_node_proto_depIdxs = []int32{ 23, // 30: wg.cosmo.node.v1.EntityCachingConfiguration.cache_populate_configurations:type_name -> wg.cosmo.node.v1.CachePopulateConfiguration 25, // 31: wg.cosmo.node.v1.CostConfiguration.field_weights:type_name -> wg.cosmo.node.v1.FieldWeightConfiguration 26, // 32: wg.cosmo.node.v1.CostConfiguration.list_sizes:type_name -> wg.cosmo.node.v1.FieldListSizeConfiguration - 81, // 33: wg.cosmo.node.v1.CostConfiguration.type_weights:type_name -> wg.cosmo.node.v1.CostConfiguration.TypeWeightsEntry - 82, // 34: wg.cosmo.node.v1.CostConfiguration.directive_argument_weights:type_name -> wg.cosmo.node.v1.CostConfiguration.DirectiveArgumentWeightsEntry - 83, // 35: wg.cosmo.node.v1.FieldWeightConfiguration.argument_weights:type_name -> wg.cosmo.node.v1.FieldWeightConfiguration.ArgumentWeightsEntry - 84, // 36: wg.cosmo.node.v1.FieldWeightConfiguration.directive_argument_weights:type_name -> wg.cosmo.node.v1.FieldWeightConfiguration.DirectiveArgumentWeightsEntry + 82, // 33: wg.cosmo.node.v1.CostConfiguration.type_weights:type_name -> wg.cosmo.node.v1.CostConfiguration.TypeWeightsEntry + 83, // 34: wg.cosmo.node.v1.CostConfiguration.directive_argument_weights:type_name -> wg.cosmo.node.v1.CostConfiguration.DirectiveArgumentWeightsEntry + 84, // 35: wg.cosmo.node.v1.FieldWeightConfiguration.argument_weights:type_name -> wg.cosmo.node.v1.FieldWeightConfiguration.ArgumentWeightsEntry + 85, // 36: wg.cosmo.node.v1.FieldWeightConfiguration.directive_argument_weights:type_name -> wg.cosmo.node.v1.FieldWeightConfiguration.DirectiveArgumentWeightsEntry 1, // 37: wg.cosmo.node.v1.ArgumentConfiguration.source_type:type_name -> wg.cosmo.node.v1.ArgumentSource 28, // 38: wg.cosmo.node.v1.AuthorizationConfiguration.required_or_scopes:type_name -> wg.cosmo.node.v1.Scopes 28, // 39: wg.cosmo.node.v1.AuthorizationConfiguration.required_or_scopes_by_or:type_name -> wg.cosmo.node.v1.Scopes 27, // 40: wg.cosmo.node.v1.FieldConfiguration.arguments_configuration:type_name -> wg.cosmo.node.v1.ArgumentConfiguration 29, // 41: wg.cosmo.node.v1.FieldConfiguration.authorization_configuration:type_name -> wg.cosmo.node.v1.AuthorizationConfiguration - 72, // 42: wg.cosmo.node.v1.FieldConfiguration.subscription_filter_condition:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition + 73, // 42: wg.cosmo.node.v1.FieldConfiguration.subscription_filter_condition:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition 33, // 43: wg.cosmo.node.v1.FieldSetCondition.field_coordinates_path:type_name -> wg.cosmo.node.v1.FieldCoordinates 34, // 44: wg.cosmo.node.v1.RequiredField.conditions:type_name -> wg.cosmo.node.v1.FieldSetCondition - 62, // 45: wg.cosmo.node.v1.FetchConfiguration.url:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 63, // 45: wg.cosmo.node.v1.FetchConfiguration.url:type_name -> wg.cosmo.node.v1.ConfigurationVariable 7, // 46: wg.cosmo.node.v1.FetchConfiguration.method:type_name -> wg.cosmo.node.v1.HTTPMethod - 85, // 47: wg.cosmo.node.v1.FetchConfiguration.header:type_name -> wg.cosmo.node.v1.FetchConfiguration.HeaderEntry - 62, // 48: wg.cosmo.node.v1.FetchConfiguration.body:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 64, // 49: wg.cosmo.node.v1.FetchConfiguration.query:type_name -> wg.cosmo.node.v1.URLQueryConfiguration - 66, // 50: wg.cosmo.node.v1.FetchConfiguration.mtls:type_name -> wg.cosmo.node.v1.MTLSConfiguration - 62, // 51: wg.cosmo.node.v1.FetchConfiguration.base_url:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 62, // 52: wg.cosmo.node.v1.FetchConfiguration.path:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 62, // 53: wg.cosmo.node.v1.FetchConfiguration.http_proxy_url:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 86, // 47: wg.cosmo.node.v1.FetchConfiguration.header:type_name -> wg.cosmo.node.v1.FetchConfiguration.HeaderEntry + 63, // 48: wg.cosmo.node.v1.FetchConfiguration.body:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 65, // 49: wg.cosmo.node.v1.FetchConfiguration.query:type_name -> wg.cosmo.node.v1.URLQueryConfiguration + 67, // 50: wg.cosmo.node.v1.FetchConfiguration.mtls:type_name -> wg.cosmo.node.v1.MTLSConfiguration + 63, // 51: wg.cosmo.node.v1.FetchConfiguration.base_url:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 63, // 52: wg.cosmo.node.v1.FetchConfiguration.path:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 63, // 53: wg.cosmo.node.v1.FetchConfiguration.http_proxy_url:type_name -> wg.cosmo.node.v1.ConfigurationVariable 37, // 54: wg.cosmo.node.v1.DataSourceCustom_GraphQL.fetch:type_name -> wg.cosmo.node.v1.FetchConfiguration - 67, // 55: wg.cosmo.node.v1.DataSourceCustom_GraphQL.subscription:type_name -> wg.cosmo.node.v1.GraphQLSubscriptionConfiguration - 68, // 56: wg.cosmo.node.v1.DataSourceCustom_GraphQL.federation:type_name -> wg.cosmo.node.v1.GraphQLFederationConfiguration - 69, // 57: wg.cosmo.node.v1.DataSourceCustom_GraphQL.upstream_schema:type_name -> wg.cosmo.node.v1.InternedString - 70, // 58: wg.cosmo.node.v1.DataSourceCustom_GraphQL.custom_scalar_type_fields:type_name -> wg.cosmo.node.v1.SingleTypeField + 68, // 55: wg.cosmo.node.v1.DataSourceCustom_GraphQL.subscription:type_name -> wg.cosmo.node.v1.GraphQLSubscriptionConfiguration + 69, // 56: wg.cosmo.node.v1.DataSourceCustom_GraphQL.federation:type_name -> wg.cosmo.node.v1.GraphQLFederationConfiguration + 70, // 57: wg.cosmo.node.v1.DataSourceCustom_GraphQL.upstream_schema:type_name -> wg.cosmo.node.v1.InternedString + 71, // 58: wg.cosmo.node.v1.DataSourceCustom_GraphQL.custom_scalar_type_fields:type_name -> wg.cosmo.node.v1.SingleTypeField 40, // 59: wg.cosmo.node.v1.DataSourceCustom_GraphQL.grpc:type_name -> wg.cosmo.node.v1.GRPCConfiguration 44, // 60: wg.cosmo.node.v1.GRPCConfiguration.mapping:type_name -> wg.cosmo.node.v1.GRPCMapping 42, // 61: wg.cosmo.node.v1.GRPCConfiguration.plugin:type_name -> wg.cosmo.node.v1.PluginConfiguration @@ -5521,40 +5585,42 @@ var file_wg_cosmo_node_v1_node_proto_depIdxs = []int32{ 51, // 74: wg.cosmo.node.v1.TypeFieldMapping.field_mappings:type_name -> wg.cosmo.node.v1.FieldMapping 52, // 75: wg.cosmo.node.v1.FieldMapping.argument_mappings:type_name -> wg.cosmo.node.v1.ArgumentMapping 54, // 76: wg.cosmo.node.v1.EnumMapping.values:type_name -> wg.cosmo.node.v1.EnumValueMapping - 59, // 77: wg.cosmo.node.v1.NatsEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration + 60, // 77: wg.cosmo.node.v1.NatsEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration 55, // 78: wg.cosmo.node.v1.NatsEventConfiguration.stream_configuration:type_name -> wg.cosmo.node.v1.NatsStreamConfiguration - 59, // 79: wg.cosmo.node.v1.KafkaEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration - 59, // 80: wg.cosmo.node.v1.RedisEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration - 5, // 81: wg.cosmo.node.v1.EngineEventConfiguration.type:type_name -> wg.cosmo.node.v1.EventType - 56, // 82: wg.cosmo.node.v1.DataSourceCustomEvents.nats:type_name -> wg.cosmo.node.v1.NatsEventConfiguration - 57, // 83: wg.cosmo.node.v1.DataSourceCustomEvents.kafka:type_name -> wg.cosmo.node.v1.KafkaEventConfiguration - 58, // 84: wg.cosmo.node.v1.DataSourceCustomEvents.redis:type_name -> wg.cosmo.node.v1.RedisEventConfiguration - 62, // 85: wg.cosmo.node.v1.DataSourceCustom_Static.data:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 6, // 86: wg.cosmo.node.v1.ConfigurationVariable.kind:type_name -> wg.cosmo.node.v1.ConfigurationVariableKind - 62, // 87: wg.cosmo.node.v1.HTTPHeader.values:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 62, // 88: wg.cosmo.node.v1.MTLSConfiguration.key:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 62, // 89: wg.cosmo.node.v1.MTLSConfiguration.cert:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 62, // 90: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration.url:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 87, // 91: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration.protocol:type_name -> wg.cosmo.common.GraphQLSubscriptionProtocol - 88, // 92: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration.websocketSubprotocol:type_name -> wg.cosmo.common.GraphQLWebsocketSubprotocol - 72, // 93: wg.cosmo.node.v1.SubscriptionFilterCondition.and:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition - 71, // 94: wg.cosmo.node.v1.SubscriptionFilterCondition.in:type_name -> wg.cosmo.node.v1.SubscriptionFieldCondition - 72, // 95: wg.cosmo.node.v1.SubscriptionFilterCondition.not:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition - 72, // 96: wg.cosmo.node.v1.SubscriptionFilterCondition.or:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition - 74, // 97: wg.cosmo.node.v1.CacheWarmerOperations.operations:type_name -> wg.cosmo.node.v1.Operation - 75, // 98: wg.cosmo.node.v1.Operation.request:type_name -> wg.cosmo.node.v1.OperationRequest - 78, // 99: wg.cosmo.node.v1.Operation.client:type_name -> wg.cosmo.node.v1.ClientInfo - 76, // 100: wg.cosmo.node.v1.OperationRequest.extensions:type_name -> wg.cosmo.node.v1.Extension - 77, // 101: wg.cosmo.node.v1.Extension.persisted_query:type_name -> wg.cosmo.node.v1.PersistedQuery - 10, // 102: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.ConfigByFeatureFlagNameEntry.value:type_name -> wg.cosmo.node.v1.FeatureFlagRouterExecutionConfig - 65, // 103: wg.cosmo.node.v1.FetchConfiguration.HeaderEntry.value:type_name -> wg.cosmo.node.v1.HTTPHeader - 16, // 104: wg.cosmo.node.v1.NodeService.SelfRegister:input_type -> wg.cosmo.node.v1.SelfRegisterRequest - 17, // 105: wg.cosmo.node.v1.NodeService.SelfRegister:output_type -> wg.cosmo.node.v1.SelfRegisterResponse - 105, // [105:106] is the sub-list for method output_type - 104, // [104:105] is the sub-list for method input_type - 104, // [104:104] is the sub-list for extension type_name - 104, // [104:104] is the sub-list for extension extendee - 0, // [0:104] is the sub-list for field type_name + 60, // 79: wg.cosmo.node.v1.KafkaEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration + 60, // 80: wg.cosmo.node.v1.RedisEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration + 60, // 81: wg.cosmo.node.v1.PusherEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration + 5, // 82: wg.cosmo.node.v1.EngineEventConfiguration.type:type_name -> wg.cosmo.node.v1.EventType + 56, // 83: wg.cosmo.node.v1.DataSourceCustomEvents.nats:type_name -> wg.cosmo.node.v1.NatsEventConfiguration + 57, // 84: wg.cosmo.node.v1.DataSourceCustomEvents.kafka:type_name -> wg.cosmo.node.v1.KafkaEventConfiguration + 58, // 85: wg.cosmo.node.v1.DataSourceCustomEvents.redis:type_name -> wg.cosmo.node.v1.RedisEventConfiguration + 59, // 86: wg.cosmo.node.v1.DataSourceCustomEvents.pusher:type_name -> wg.cosmo.node.v1.PusherEventConfiguration + 63, // 87: wg.cosmo.node.v1.DataSourceCustom_Static.data:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 6, // 88: wg.cosmo.node.v1.ConfigurationVariable.kind:type_name -> wg.cosmo.node.v1.ConfigurationVariableKind + 63, // 89: wg.cosmo.node.v1.HTTPHeader.values:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 63, // 90: wg.cosmo.node.v1.MTLSConfiguration.key:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 63, // 91: wg.cosmo.node.v1.MTLSConfiguration.cert:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 63, // 92: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration.url:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 88, // 93: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration.protocol:type_name -> wg.cosmo.common.GraphQLSubscriptionProtocol + 89, // 94: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration.websocketSubprotocol:type_name -> wg.cosmo.common.GraphQLWebsocketSubprotocol + 73, // 95: wg.cosmo.node.v1.SubscriptionFilterCondition.and:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition + 72, // 96: wg.cosmo.node.v1.SubscriptionFilterCondition.in:type_name -> wg.cosmo.node.v1.SubscriptionFieldCondition + 73, // 97: wg.cosmo.node.v1.SubscriptionFilterCondition.not:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition + 73, // 98: wg.cosmo.node.v1.SubscriptionFilterCondition.or:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition + 75, // 99: wg.cosmo.node.v1.CacheWarmerOperations.operations:type_name -> wg.cosmo.node.v1.Operation + 76, // 100: wg.cosmo.node.v1.Operation.request:type_name -> wg.cosmo.node.v1.OperationRequest + 79, // 101: wg.cosmo.node.v1.Operation.client:type_name -> wg.cosmo.node.v1.ClientInfo + 77, // 102: wg.cosmo.node.v1.OperationRequest.extensions:type_name -> wg.cosmo.node.v1.Extension + 78, // 103: wg.cosmo.node.v1.Extension.persisted_query:type_name -> wg.cosmo.node.v1.PersistedQuery + 10, // 104: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.ConfigByFeatureFlagNameEntry.value:type_name -> wg.cosmo.node.v1.FeatureFlagRouterExecutionConfig + 66, // 105: wg.cosmo.node.v1.FetchConfiguration.HeaderEntry.value:type_name -> wg.cosmo.node.v1.HTTPHeader + 16, // 106: wg.cosmo.node.v1.NodeService.SelfRegister:input_type -> wg.cosmo.node.v1.SelfRegisterRequest + 17, // 107: wg.cosmo.node.v1.NodeService.SelfRegister:output_type -> wg.cosmo.node.v1.SelfRegisterResponse + 107, // [107:108] is the sub-list for method output_type + 106, // [106:107] is the sub-list for method input_type + 106, // [106:106] is the sub-list for extension type_name + 106, // [106:106] is the sub-list for extension extendee + 0, // [0:106] is the sub-list for field type_name } func init() { file_wg_cosmo_node_v1_node_proto_init() } @@ -5571,15 +5637,15 @@ func file_wg_cosmo_node_v1_node_proto_init() { file_wg_cosmo_node_v1_node_proto_msgTypes[22].OneofWrappers = []any{} file_wg_cosmo_node_v1_node_proto_msgTypes[29].OneofWrappers = []any{} file_wg_cosmo_node_v1_node_proto_msgTypes[34].OneofWrappers = []any{} - file_wg_cosmo_node_v1_node_proto_msgTypes[59].OneofWrappers = []any{} - file_wg_cosmo_node_v1_node_proto_msgTypes[64].OneofWrappers = []any{} + file_wg_cosmo_node_v1_node_proto_msgTypes[60].OneofWrappers = []any{} + file_wg_cosmo_node_v1_node_proto_msgTypes[65].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_wg_cosmo_node_v1_node_proto_rawDesc), len(file_wg_cosmo_node_v1_node_proto_rawDesc)), NumEnums: 8, - NumMessages: 78, + NumMessages: 79, NumExtensions: 0, NumServices: 1, }, diff --git a/router/internal/pusherclient/auth.go b/router/internal/pusherclient/auth.go new file mode 100644 index 0000000000..7fcb8bd303 --- /dev/null +++ b/router/internal/pusherclient/auth.go @@ -0,0 +1,121 @@ +package pusherclient + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "go.uber.org/zap" +) + +// authResponse is the body monday's POST /pusher/auth returns on success. +type authResponse struct { + Auth string `json:"auth"` + ChannelData string `json:"channel_data"` + SharedKey string `json:"shared_secret"` +} + +// AuthError describes a failed channel authorization. +type AuthError struct { + Channel string + StatusCode int + Body string +} + +func (e *AuthError) Error() string { + return fmt.Sprintf("pusher: authorization for channel %q failed with status %d: %s", e.Channel, e.StatusCode, e.Body) +} + +// signChannel produces the subscription signature Pusher expects for a private +// channel: "::" under the +// app secret>". This is the same computation every server-side Pusher SDK performs +// in its auth endpoint, so it is re-derived on every reconnect with the new +// socket_id. +// +// A presence channel would additionally need channel_data folded into the signed +// string; monday's channels are private, so that is not implemented. +func signChannel(appKey, appSecret, socketID, channel string) string { + mac := hmac.New(sha256.New, []byte(appSecret)) + mac.Write([]byte(socketID + ":" + channel)) + return appKey + ":" + hex.EncodeToString(mac.Sum(nil)) +} + +// needsAuth reports whether a channel has to be authorized before subscribing. +// monday's encrypted channels use the "private-enc_" prefix, which is still a +// private channel as far as Pusher is concerned. +func needsAuth(channel string) bool { + return strings.HasPrefix(channel, "private-") || strings.HasPrefix(channel, "presence-") +} + +// authorize performs a single-channel POST to the configured auth endpoint. The +// monday web client batches these requests; we deliberately keep one request per +// channel here, which is the plain Pusher contract. +func (c *Client) authorize(ctx context.Context, channel, socketID string) (*authResponse, error) { + if c.opts.AppSecret != "" { + auth := signChannel(c.opts.AppKey, c.opts.AppSecret, socketID, channel) + c.logger.Debug("signed pusher channel locally", + zap.String("channel", channel), + zap.String("socket_id", socketID), + ) + return &authResponse{Auth: auth}, nil + } + if c.opts.AuthEndpoint == "" { + return nil, fmt.Errorf("pusher: channel %q requires authorization but neither an auth endpoint nor an app secret is configured", channel) + } + + form := url.Values{} + form.Set("socket_id", socketID) + form.Set("channel_name", channel) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.opts.AuthEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for name, value := range c.opts.AuthHeaders { + req.Header.Set(name, value) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, &AuthError{Channel: channel, StatusCode: resp.StatusCode, Body: truncate(string(body), 512)} + } + + var parsed authResponse + if err := json.Unmarshal(body, &parsed); err != nil { + // Same failure mode as the key endpoint: a 200 with HTML means the monolith + // rendered the login page because the session cookie was missing or expired. + c.logger.Error("auth response is not JSON", + zap.String("channel", channel), + zap.String("endpoint", c.opts.AuthEndpoint), + zap.Int("status", resp.StatusCode), + zap.String("content_type", resp.Header.Get("Content-Type")), + zap.Strings("request_headers_sent", headerNames(c.opts.AuthHeaders)), + zap.String("body", truncate(string(body), 2048)), + zap.Error(err), + ) + return nil, fmt.Errorf("pusher: could not parse auth response for channel %q (status %d, content-type %q, body %s): %w", + channel, resp.StatusCode, resp.Header.Get("Content-Type"), truncate(string(body), 512), err) + } + if parsed.Auth == "" { + return nil, fmt.Errorf("pusher: auth response for channel %q contained no auth signature", channel) + } + + return &parsed, nil +} diff --git a/router/internal/pusherclient/client.go b/router/internal/pusherclient/client.go new file mode 100644 index 0000000000..7aded1ff32 --- /dev/null +++ b/router/internal/pusherclient/client.go @@ -0,0 +1,532 @@ +package pusherclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/gorilla/websocket" + "go.uber.org/zap" +) + +// Pusher protocol event names. See https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol/ +const ( + eventConnectionEstablished = "pusher:connection_established" + eventError = "pusher:error" + eventPing = "pusher:ping" + eventPong = "pusher:pong" + eventSubscribe = "pusher:subscribe" + eventUnsubscribe = "pusher:unsubscribe" + eventSubscriptionSucceeded = "pusher_internal:subscription_succeeded" + eventSubscriptionError = "pusher_internal:subscription_error" + + protocolVersion = "7" + clientName = "cosmo-router-go" + clientVersion = "1.0.0" + + defaultActivityTimeout = 120 * time.Second +) + +// Options configures a Client. +type Options struct { + // AppKey is the public Pusher app key. + AppKey string + // Cluster is the Pusher cluster, e.g. "mt1". Ignored when WSURL is set. + Cluster string + // WSURL overrides the derived WebSocket URL. Used for tests and for + // self-hosted Pusher-protocol servers. + WSURL string + // AuthEndpoint is the absolute URL of the endpoint that signs private and + // presence channel subscriptions, e.g. https://monday.com/pusher/auth. + AuthEndpoint string + // AppSecret makes the client sign private channels itself instead of calling + // AuthEndpoint. The signature is re-derived per connection, so reconnects keep + // working. Requires AppKey, which is part of the signature. + AppSecret string + // AuthHeaders are sent with every authorization request. A session cookie + // belongs here, since monday's /pusher/auth requires an authenticated user. + AuthHeaders map[string]string + // HTTPClient is used for authorization requests. Defaults to a client with a + // 10 second timeout. + HTTPClient *http.Client + // Decryptor transforms payloads before they reach subscribers. Optional. + Decryptor Decryptor + Logger *zap.Logger + // HandshakeTimeout bounds the dial and the wait for the connection handshake. + HandshakeTimeout time.Duration + // PongTimeout is the grace period added to the server-provided activity + // timeout before the connection is considered dead. + PongTimeout time.Duration + // MinReconnectBackoff and MaxReconnectBackoff bound the reconnect delay. + MinReconnectBackoff time.Duration + MaxReconnectBackoff time.Duration + // EventBufferSize is the per-subscription buffer. Events are dropped when a + // subscriber does not keep up. + EventBufferSize int +} + +// Event is a single message received on a channel, after decryption. +type Event struct { + Channel string + Name string + Data []byte +} + +// frame is the Pusher wire format. Data is a JSON-encoded string that itself +// contains JSON, so it is decoded in two steps. +type frame struct { + Event string `json:"event"` + Channel string `json:"channel,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +// Client is a Pusher Channels subscriber. A single WebSocket connection carries +// every channel, and channels are re-subscribed after a reconnect because the +// socket_id — and therefore every auth signature — changes. +type Client struct { + opts Options + httpClient *http.Client + logger *zap.Logger + + ctx context.Context + cancel context.CancelFunc + + mu sync.Mutex + subs map[string][]*Subscription + session *session + closed bool + + wg sync.WaitGroup +} + +// session holds the state that is only valid for one WebSocket connection. +type session struct { + conn *websocket.Conn + socketID string + activityTimeout time.Duration + writeMu sync.Mutex +} + +func (s *session) send(f frame) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.conn.WriteJSON(f) +} + +// New validates the options and returns a client that is not yet connected. +func New(opts Options) (*Client, error) { + if opts.AppKey == "" && opts.WSURL == "" { + return nil, errors.New("pusher: either an app key or an explicit ws url is required") + } + if opts.WSURL == "" && opts.Cluster == "" { + return nil, errors.New("pusher: a cluster is required when no explicit ws url is given") + } + if opts.HandshakeTimeout <= 0 { + opts.HandshakeTimeout = 10 * time.Second + } + if opts.PongTimeout <= 0 { + opts.PongTimeout = 30 * time.Second + } + if opts.MinReconnectBackoff <= 0 { + opts.MinReconnectBackoff = time.Second + } + if opts.MaxReconnectBackoff <= 0 { + opts.MaxReconnectBackoff = 30 * time.Second + } + if opts.EventBufferSize <= 0 { + opts.EventBufferSize = 256 + } + httpClient := opts.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 10 * time.Second} + } + logger := opts.Logger + if logger == nil { + logger = zap.NewNop() + } + + return &Client{ + opts: opts, + httpClient: httpClient, + logger: logger, + subs: map[string][]*Subscription{}, + }, nil +} + +// wsURL builds the connection URL the same way pusher-js does. +func (c *Client) wsURL() string { + if c.opts.WSURL != "" { + return c.opts.WSURL + } + query := url.Values{} + query.Set("protocol", protocolVersion) + query.Set("client", clientName) + query.Set("version", clientVersion) + + return fmt.Sprintf("wss://ws-%s.pusher.com/app/%s?%s", c.opts.Cluster, c.opts.AppKey, query.Encode()) +} + +// Connect establishes the first connection and returns once the handshake +// completed, so that startup failures surface to the caller. Later connection +// losses are handled by a background supervisor. +func (c *Client) Connect(ctx context.Context) error { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return errors.New("pusher: client is closed") + } + if c.ctx != nil { + c.mu.Unlock() + return errors.New("pusher: client is already connected") + } + c.ctx, c.cancel = context.WithCancel(context.Background()) + clientCtx := c.ctx + c.mu.Unlock() + + sess, err := c.dial(ctx) + if err != nil { + return err + } + + c.wg.Add(1) + go func() { + defer c.wg.Done() + c.supervise(clientCtx, sess) + }() + + return nil +} + +// Close terminates the connection and stops the supervisor. +func (c *Client) Close() error { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return nil + } + c.closed = true + cancel := c.cancel + sess := c.session + c.session = nil + c.mu.Unlock() + + if cancel != nil { + cancel() + } + if sess != nil { + c.logger.Info("closing pusher connection", zap.String("socket_id", sess.socketID)) + _ = sess.conn.Close() + } + c.wg.Wait() + + return nil +} + +// dial opens a connection and waits for pusher:connection_established. +func (c *Client) dial(ctx context.Context) (*session, error) { + dialCtx, cancel := context.WithTimeout(ctx, c.opts.HandshakeTimeout) + defer cancel() + + dialer := websocket.Dialer{HandshakeTimeout: c.opts.HandshakeTimeout} + conn, resp, err := dialer.DialContext(dialCtx, c.wsURL(), nil) + if err != nil { + if resp != nil { + return nil, fmt.Errorf("pusher: websocket dial failed with status %d: %w", resp.StatusCode, err) + } + return nil, fmt.Errorf("pusher: websocket dial failed: %w", err) + } + + if deadline, ok := dialCtx.Deadline(); ok { + _ = conn.SetReadDeadline(deadline) + } + + sess := &session{conn: conn, activityTimeout: defaultActivityTimeout} + for { + var f frame + if err := conn.ReadJSON(&f); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("pusher: reading handshake failed: %w", err) + } + + switch f.Event { + case eventConnectionEstablished: + var payload struct { + SocketID string `json:"socket_id"` + ActivityTimeout float64 `json:"activity_timeout"` + } + if err := unmarshalFrameData(f.Data, &payload); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("pusher: could not parse connection_established: %w", err) + } + if payload.SocketID == "" { + _ = conn.Close() + return nil, errors.New("pusher: connection_established contained no socket_id") + } + sess.socketID = payload.SocketID + if payload.ActivityTimeout > 0 { + sess.activityTimeout = time.Duration(payload.ActivityTimeout) * time.Second + } + c.logger.Info("pusher connection established", + zap.String("socket_id", sess.socketID), + zap.Duration("activity_timeout", sess.activityTimeout), + ) + return sess, nil + case eventError: + protoErr := parseProtocolError(f.Data) + _ = conn.Close() + return nil, protoErr + default: + // Pusher may send other frames before the handshake completes; ignore them. + } + } +} + +// supervise serves the given session and reconnects until the client is closed. +func (c *Client) supervise(ctx context.Context, sess *session) { + backoff := c.opts.MinReconnectBackoff + + for { + c.mu.Lock() + c.session = sess + c.mu.Unlock() + + c.resubscribeAll(ctx, sess) + + err := c.serve(ctx, sess) + + c.mu.Lock() + if c.session == sess { + c.session = nil + } + c.mu.Unlock() + _ = sess.conn.Close() + + c.logger.Info("pusher connection dropped", + zap.String("socket_id", sess.socketID), + zap.Error(err), + ) + + if ctx.Err() != nil { + return + } + + var protoErr *ProtocolError + if errors.As(err, &protoErr) && !protoErr.ShouldReconnect() { + c.logger.Error("pusher connection closed permanently, not reconnecting", zap.Error(err)) + return + } + c.logger.Warn("pusher connection lost, reconnecting", zap.Error(err), zap.Duration("backoff", backoff)) + + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + + newSess, dialErr := c.dial(ctx) + if dialErr != nil { + var dialProtoErr *ProtocolError + if errors.As(dialErr, &dialProtoErr) && !dialProtoErr.ShouldReconnect() { + c.logger.Error("pusher reconnect rejected permanently", zap.Error(dialErr)) + return + } + c.logger.Warn("pusher reconnect failed", zap.Error(dialErr)) + backoff = nextBackoff(backoff, c.opts.MaxReconnectBackoff) + continue + } + + backoff = c.opts.MinReconnectBackoff + sess = newSess + } +} + +// serve reads frames until the connection fails or the context is cancelled. +func (c *Client) serve(ctx context.Context, sess *session) error { + // Close the connection when the context is cancelled so the blocking read returns. + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + _ = sess.conn.Close() + case <-done: + } + }() + + // The server pings after activity_timeout of silence, so no traffic within + // that window plus the pong grace period means the connection is dead. + readTimeout := sess.activityTimeout + c.opts.PongTimeout + + for { + if err := sess.conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil { + return err + } + + var f frame + if err := sess.conn.ReadJSON(&f); err != nil { + return err + } + + if err := c.handleFrame(sess, f); err != nil { + return err + } + } +} + +func (c *Client) handleFrame(sess *session, f frame) error { + switch f.Event { + case eventPing: + return sess.send(frame{Event: eventPong}) + case eventPong: + return nil + case eventError: + protoErr := parseProtocolError(f.Data) + if !protoErr.ShouldReconnect() { + return protoErr + } + c.logger.Warn("pusher protocol error", zap.Error(protoErr)) + return nil + case eventSubscriptionSucceeded: + c.logger.Debug("pusher subscription succeeded", zap.String("channel", f.Channel)) + return nil + case eventSubscriptionError: + c.logger.Error("pusher subscription rejected", + zap.String("channel", f.Channel), + zap.String("data", string(f.Data)), + ) + return nil + default: + if f.Channel == "" { + c.logger.Debug("ignoring pusher event without channel", zap.String("event", f.Event)) + return nil + } + c.dispatch(f) + return nil + } +} + +// dispatch decrypts the payload and hands it to every subscriber of the channel. +func (c *Client) dispatch(f frame) { + payload, err := decodeFrameData(f.Data) + if err != nil { + c.logger.Error("could not decode pusher event payload", + zap.String("channel", f.Channel), zap.String("event", f.Event), zap.Error(err)) + return + } + + c.logger.Info("pusher event received", + zap.String("channel", f.Channel), zap.String("event", f.Event), + zap.String("raw_payload", string(payload))) + + if c.opts.Decryptor != nil { + decrypted, err := c.opts.Decryptor.Decrypt(payload) + if err != nil { + c.logger.Error("could not decrypt pusher event payload", + zap.String("channel", f.Channel), zap.String("event", f.Event), zap.Error(err)) + return + } + // The decryptor passes non-encrypted payloads through unchanged; only report a + // decrypted payload when decryption actually ran. + if !bytes.Equal(decrypted, payload) { + c.logger.Info("pusher event decrypted", + zap.String("channel", f.Channel), zap.String("event", f.Event), + zap.String("decrypted_payload", string(decrypted))) + } + payload = decrypted + } + + c.mu.Lock() + subs := make([]*Subscription, len(c.subs[f.Channel])) + copy(subs, c.subs[f.Channel]) + c.mu.Unlock() + + evt := Event{Channel: f.Channel, Name: f.Event, Data: payload} + for _, sub := range subs { + sub.deliver(evt, c.logger) + } +} + +// resubscribeAll subscribes every registered channel on a fresh session. +func (c *Client) resubscribeAll(ctx context.Context, sess *session) { + c.mu.Lock() + channels := make([]string, 0, len(c.subs)) + for channel := range c.subs { + channels = append(channels, channel) + } + c.mu.Unlock() + + for _, channel := range channels { + if err := c.sendSubscribe(ctx, sess, channel); err != nil { + c.logger.Error("could not subscribe to pusher channel", + zap.String("channel", channel), zap.Error(err)) + } + } +} + +// sendSubscribe authorizes the channel if needed and sends pusher:subscribe. +func (c *Client) sendSubscribe(ctx context.Context, sess *session, channel string) error { + data := map[string]string{"channel": channel} + + if needsAuth(channel) { + auth, err := c.authorize(ctx, channel, sess.socketID) + if err != nil { + return err + } + data["auth"] = auth.Auth + if auth.ChannelData != "" { + data["channel_data"] = auth.ChannelData + } + if auth.SharedKey != "" { + data["shared_secret"] = auth.SharedKey + } + } + + encoded, err := json.Marshal(data) + if err != nil { + return err + } + + return sess.send(frame{Event: eventSubscribe, Data: encoded}) +} + +func nextBackoff(current, max time.Duration) time.Duration { + next := current * 2 + if next > max { + return max + } + return next +} + +// unmarshalFrameData decodes the double-encoded data field into target. +func unmarshalFrameData(raw json.RawMessage, target any) error { + payload, err := decodeFrameData(raw) + if err != nil { + return err + } + if len(payload) == 0 { + return nil + } + return json.Unmarshal(payload, target) +} + +// decodeFrameData unwraps the data field. Pusher sends it as a JSON string +// containing JSON, but some servers send the object directly. +func decodeFrameData(raw json.RawMessage) ([]byte, error) { + if len(raw) == 0 { + return nil, nil + } + if raw[0] != '"' { + return raw, nil + } + var asString string + if err := json.Unmarshal(raw, &asString); err != nil { + return nil, err + } + return []byte(asString), nil +} diff --git a/router/internal/pusherclient/decrypt.go b/router/internal/pusherclient/decrypt.go new file mode 100644 index 0000000000..61a11bc307 --- /dev/null +++ b/router/internal/pusherclient/decrypt.go @@ -0,0 +1,291 @@ +package pusherclient + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "sync" + "time" + + "go.uber.org/zap" +) + +// Decryptor transforms a raw event payload before it is handed to a subscriber. +// Payloads that are not encrypted must be returned unchanged. +type Decryptor interface { + Decrypt(payload []byte) ([]byte, error) +} + +// encryptedEnvelope is the payload shape monday.com publishes to its +// "private-enc_" channels. It is not Pusher's native end-to-end encryption. +type encryptedEnvelope struct { + EncryptedBase64 string `json:"encrypted_base64"` + IV string `json:"iv"` + EncDate string `json:"enc_date"` +} + +// MondayDecryptorOptions configures a MondayDecryptor. +type MondayDecryptorOptions struct { + // KeysEndpoint is the absolute URL of monday's key endpoint, which answers with + // {"pusher_enc_keys": {"YYYY-MM-DD": ""}}. + KeysEndpoint string + // StaticKey is used for every payload regardless of its enc_date. It replaces + // KeysEndpoint: when it is set no key request is made at all. Useful for local + // development, where the key endpoint needs a monolith session. + StaticKey string + // Headers are sent with every key request. A session cookie belongs here. + Headers map[string]string + // HTTPClient is optional and defaults to a client with a 10 second timeout. + HTTPClient *http.Client + // RefreshInterval is how often the key set is refetched. Defaults to one hour, + // matching the monday web client. + RefreshInterval time.Duration + Logger *zap.Logger +} + +// MondayDecryptor decrypts monday.com's encrypted channel payloads. The keys +// rotate daily and are addressed by the enc_date carried in each payload. +type MondayDecryptor struct { + opts MondayDecryptorOptions + client *http.Client + logger *zap.Logger + + mu sync.RWMutex + keys map[string]string + lastFetched time.Time +} + +var _ Decryptor = (*MondayDecryptor)(nil) + +// minKeyRefetchInterval throttles the on-miss refetch so a stream of payloads +// referencing an unknown date cannot turn into a request flood. +const minKeyRefetchInterval = 30 * time.Second + +func NewMondayDecryptor(opts MondayDecryptorOptions) (*MondayDecryptor, error) { + if opts.StaticKey == "" && opts.KeysEndpoint == "" { + return nil, errors.New("pusher: either a static encryption key or a keys endpoint is required") + } + if opts.StaticKey != "" && opts.KeysEndpoint != "" { + return nil, errors.New("pusher: a static encryption key and a keys endpoint are mutually exclusive") + } + if opts.RefreshInterval <= 0 { + opts.RefreshInterval = time.Hour + } + client := opts.HTTPClient + if client == nil { + client = &http.Client{Timeout: 10 * time.Second} + } + logger := opts.Logger + if logger == nil { + logger = zap.NewNop() + } + + return &MondayDecryptor{ + opts: opts, + client: client, + logger: logger, + keys: map[string]string{}, + }, nil +} + +// Start fetches the key set once and then refreshes it until ctx is done. With a +// static key it does nothing: there is no key set to fetch or rotate. +func (d *MondayDecryptor) Start(ctx context.Context) error { + if d.opts.StaticKey != "" { + return nil + } + if err := d.fetchKeys(ctx); err != nil { + return err + } + + go func() { + ticker := time.NewTicker(d.opts.RefreshInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := d.fetchKeys(ctx); err != nil { + d.logger.Error("failed to refresh pusher encryption keys", zap.Error(err)) + } + } + } + }() + + return nil +} + +func (d *MondayDecryptor) Decrypt(payload []byte) ([]byte, error) { + var envelope encryptedEnvelope + if err := json.Unmarshal(payload, &envelope); err != nil { + // Not a JSON object, so it cannot be an encrypted envelope. + return payload, nil + } + if envelope.EncryptedBase64 == "" { + return payload, nil + } + + key, err := d.keyForDate(envelope.EncDate) + if err != nil { + return nil, err + } + + return decryptAESCBC([]byte(key), []byte(envelope.IV), envelope.EncryptedBase64) +} + +func (d *MondayDecryptor) keyForDate(date string) (string, error) { + if d.opts.StaticKey != "" { + return d.opts.StaticKey, nil + } + + d.mu.RLock() + key, ok := d.keys[date] + staleEnough := time.Since(d.lastFetched) > minKeyRefetchInterval + d.mu.RUnlock() + if ok { + return key, nil + } + if !staleEnough { + return "", fmt.Errorf("pusher: no encryption key for date %q", date) + } + + // The key set rotates daily, so an unknown date most likely means our cache is + // behind. Refetch once before giving up. + if err := d.fetchKeys(context.Background()); err != nil { + return "", fmt.Errorf("pusher: no encryption key for date %q and refresh failed: %w", date, err) + } + + d.mu.RLock() + defer d.mu.RUnlock() + key, ok = d.keys[date] + if !ok { + return "", fmt.Errorf("pusher: no encryption key for date %q", date) + } + return key, nil +} + +func (d *MondayDecryptor) fetchKeys(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.opts.KeysEndpoint, nil) + if err != nil { + return err + } + for name, value := range d.opts.Headers { + req.Header.Set(name, value) + } + + resp, err := d.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("pusher: encryption keys endpoint returned %d: %s", resp.StatusCode, truncate(string(body), 256)) + } + + var parsed struct { + Keys map[string]string `json:"pusher_enc_keys"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + // A 200 with a non-JSON body is almost always the monolith login page: the + // endpoint runs authenticate_user!, so a missing or expired session cookie + // produces HTML instead of keys. Log the response so that is visible. + d.logger.Error("encryption keys response is not JSON", + zap.String("endpoint", d.opts.KeysEndpoint), + zap.Int("status", resp.StatusCode), + zap.String("content_type", resp.Header.Get("Content-Type")), + zap.Strings("request_headers_sent", headerNames(d.opts.Headers)), + zap.String("body", truncate(string(body), 2048)), + zap.Error(err), + ) + return fmt.Errorf("pusher: could not parse encryption keys response (status %d, content-type %q, body %s): %w", + resp.StatusCode, resp.Header.Get("Content-Type"), truncate(string(body), 512), err) + } + if len(parsed.Keys) == 0 { + return errors.New("pusher: encryption keys response contained no keys") + } + + d.mu.Lock() + d.keys = parsed.Keys + d.lastFetched = time.Now() + d.mu.Unlock() + + return nil +} + +// decryptAESCBC mirrors the monday web client, which calls +// CryptoJS.AES.decrypt(ciphertext, CryptoJS.enc.Utf8.parse(key), {iv: CryptoJS.enc.Utf8.parse(iv), mode: CBC}). +// Passing a WordArray as the key makes CryptoJS use it verbatim, so there is no +// EVP key derivation and no "Salted__" header: key and IV are the raw UTF-8 +// bytes of their strings. +func decryptAESCBC(key, iv []byte, ciphertextBase64 string) ([]byte, error) { + switch len(key) { + case 16, 24, 32: + default: + return nil, fmt.Errorf("pusher: encryption key must be 16, 24 or 32 bytes, got %d", len(key)) + } + if len(iv) != aes.BlockSize { + return nil, fmt.Errorf("pusher: iv must be %d bytes, got %d", aes.BlockSize, len(iv)) + } + + ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64) + if err != nil { + return nil, fmt.Errorf("pusher: could not base64 decode payload: %w", err) + } + if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("pusher: ciphertext length %d is not a multiple of the block size", len(ciphertext)) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + plaintext := make([]byte, len(ciphertext)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(plaintext, ciphertext) + + return removePKCS7Padding(plaintext) +} + +func removePKCS7Padding(data []byte) ([]byte, error) { + padding := int(data[len(data)-1]) + if padding == 0 || padding > aes.BlockSize || padding > len(data) { + return nil, fmt.Errorf("pusher: invalid padding length %d", padding) + } + for _, b := range data[len(data)-padding:] { + if int(b) != padding { + return nil, errors.New("pusher: invalid padding bytes") + } + } + return data[:len(data)-padding], nil +} + +// headerNames lists the header names of a request, without their values: the +// values carry a session credential and must not reach a log. +func headerNames(headers map[string]string) []string { + names := make([]string, 0, len(headers)) + for name := range headers { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +} diff --git a/router/internal/pusherclient/protocol_error.go b/router/internal/pusherclient/protocol_error.go new file mode 100644 index 0000000000..ec8a4a6b51 --- /dev/null +++ b/router/internal/pusherclient/protocol_error.go @@ -0,0 +1,50 @@ +package pusherclient + +import ( + "encoding/json" + "fmt" +) + +// ProtocolError is a pusher:error frame. +// +// The code ranges are defined by the Pusher protocol: +// +// 4000-4099 the connection must not be retried with the same parameters +// 4100-4199 reconnect after a backoff +// 4200-4299 reconnect immediately +type ProtocolError struct { + Code int + Message string +} + +func (e *ProtocolError) Error() string { + return fmt.Sprintf("pusher: protocol error %d: %s", e.Code, e.Message) +} + +// ShouldReconnect reports whether reconnecting can succeed. Codes below 4100 +// signal a client or configuration fault, such as an unknown app key, so +// retrying with the same options is pointless. Codes without a range (0) are +// treated as retryable because they carry no guidance. +func (e *ProtocolError) ShouldReconnect() bool { + return e.Code < 4000 || e.Code >= 4100 +} + +func parseProtocolError(raw json.RawMessage) *ProtocolError { + protoErr := &ProtocolError{} + + var payload struct { + Code *int `json:"code"` + Message string `json:"message"` + } + if err := unmarshalFrameData(raw, &payload); err != nil { + protoErr.Message = string(raw) + return protoErr + } + + if payload.Code != nil { + protoErr.Code = *payload.Code + } + protoErr.Message = payload.Message + + return protoErr +} diff --git a/router/internal/pusherclient/subscription.go b/router/internal/pusherclient/subscription.go new file mode 100644 index 0000000000..a55326d070 --- /dev/null +++ b/router/internal/pusherclient/subscription.go @@ -0,0 +1,128 @@ +package pusherclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "sync" + + "go.uber.org/zap" +) + +// Subscription is a single consumer of one channel. Several subscriptions can +// share a channel; the underlying Pusher subscription is created once and +// removed when the last subscription is closed. +type Subscription struct { + client *Client + channel string + events chan Event + + closeOnce sync.Once +} + +// Channel returns the subscribed channel name. +func (s *Subscription) Channel() string { + return s.channel +} + +// Events returns the stream of events. The channel is closed on Unsubscribe. +func (s *Subscription) Events() <-chan Event { + return s.events +} + +// deliver hands an event to the consumer without blocking the read loop. A slow +// consumer loses events rather than stalling every other channel on the socket. +func (s *Subscription) deliver(evt Event, logger *zap.Logger) { + select { + case s.events <- evt: + default: + logger.Warn("dropping pusher event because the subscriber is not keeping up", + zap.String("channel", evt.Channel), zap.String("event", evt.Name)) + } +} + +// Unsubscribe removes this consumer. When it was the last one for the channel, a +// pusher:unsubscribe is sent. +func (s *Subscription) Unsubscribe() { + s.closeOnce.Do(func() { + last := s.client.removeSubscription(s) + if last { + s.client.sendUnsubscribe(s.channel) + } + close(s.events) + }) +} + +// Subscribe registers a consumer for the given channel. It returns as soon as +// the subscribe frame has been sent; the subscription confirmation is handled +// asynchronously, and a rejection is logged. +func (c *Client) Subscribe(ctx context.Context, channel string) (*Subscription, error) { + if channel == "" { + return nil, errors.New("pusher: channel must not be empty") + } + + sub := &Subscription{ + client: c, + channel: channel, + events: make(chan Event, c.opts.EventBufferSize), + } + + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return nil, errors.New("pusher: client is closed") + } + first := len(c.subs[channel]) == 0 + c.subs[channel] = append(c.subs[channel], sub) + sess := c.session + c.mu.Unlock() + + // Only the first subscription for a channel has to talk to the server. If + // there is no live session, the supervisor subscribes on the next connect. + if first && sess != nil { + if err := c.sendSubscribe(ctx, sess, channel); err != nil { + c.removeSubscription(sub) + return nil, fmt.Errorf("pusher: could not subscribe to channel %q: %w", channel, err) + } + } + + return sub, nil +} + +// removeSubscription drops the subscription and reports whether the channel has +// no consumers left. +func (c *Client) removeSubscription(sub *Subscription) bool { + c.mu.Lock() + defer c.mu.Unlock() + + subs := c.subs[sub.channel] + if idx := slices.Index(subs, sub); idx >= 0 { + subs = slices.Delete(subs, idx, idx+1) + } + if len(subs) == 0 { + delete(c.subs, sub.channel) + return true + } + c.subs[sub.channel] = subs + + return false +} + +func (c *Client) sendUnsubscribe(channel string) { + c.mu.Lock() + sess := c.session + c.mu.Unlock() + if sess == nil { + return + } + + encoded, err := json.Marshal(map[string]string{"channel": channel}) + if err != nil { + return + } + if err := sess.send(frame{Event: eventUnsubscribe, Data: encoded}); err != nil { + c.logger.Debug("could not send pusher unsubscribe", zap.String("channel", channel), zap.Error(err)) + } +} diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index cc1108011c..9638e1cc84 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -483,12 +483,12 @@ type EngineExecutionConfiguration struct { MaxConcurrentResolvers int `envDefault:"1024" env:"ENGINE_MAX_CONCURRENT_RESOLVERS" yaml:"max_concurrent_resolvers,omitempty"` EnableNetPoll bool `envDefault:"true" env:"ENGINE_ENABLE_NET_POLL" yaml:"enable_net_poll"` - ExecutionPlanCacheSize int64 `envDefault:"1024" env:"ENGINE_EXECUTION_PLAN_CACHE_SIZE" yaml:"execution_plan_cache_size,omitempty"` + ExecutionPlanCacheSize int64 `envDefault:"1024" env:"ENGINE_EXECUTION_PLAN_CACHE_SIZE" yaml:"execution_plan_cache_size,omitempty"` // DisableSizeAwarePlanCache forces the execution-plan cache back to count-based eviction // even when mondaytweaks.SizeAwarePlanCache is enabled. It is set programmatically (tests, // or a targeted per-router rollback) and has no env/yaml binding; production leaves it // false and follows the mondaytweaks default. See mondaytweaks.SizeAwarePlanCache. - DisableSizeAwarePlanCache bool `yaml:"-"` + DisableSizeAwarePlanCache bool `yaml:"-"` SlowPlanCacheSize int64 `envDefault:"300" env:"ENGINE_SLOW_PLAN_CACHE_SIZE" yaml:"slow_plan_cache_size,omitempty"` SlowPlanCacheThreshold time.Duration `envDefault:"100ms" env:"ENGINE_SLOW_PLAN_CACHE_THRESHOLD" yaml:"slow_plan_cache_threshold,omitempty"` MinifySubgraphOperations bool `envDefault:"true" env:"ENGINE_MINIFY_SUBGRAPH_OPERATIONS" yaml:"minify_subgraph_operations"` @@ -872,10 +872,91 @@ func (r RedisEventSource) GetID() string { return r.ID } +// PusherEncryptionConfiguration configures the decryption of monday.com's +// encrypted Pusher channels ("private-enc_" prefix), which use a monday-specific +// scheme rather than Pusher's native end-to-end encryption. +type PusherEncryptionConfiguration struct { + Enabled bool `yaml:"enabled"` + // EncryptionKey decrypts every payload regardless of its enc_date. It replaces + // keys_endpoint, and the two are mutually exclusive. Intended for local + // development, where the key endpoint requires a monolith session; in production + // the keys rotate daily, so a pinned key stops working after a rotation. + EncryptionKey string `yaml:"encryption_key,omitempty"` + // KeysEndpoint answers with {"pusher_enc_keys": {"YYYY-MM-DD": ""}}. + KeysEndpoint string `yaml:"keys_endpoint,omitempty"` + // RefreshInterval is how often the key set is refetched. The keys rotate daily. + RefreshInterval time.Duration `yaml:"refresh_interval,omitempty"` +} + +type PusherEventSource struct { + ID string `yaml:"id,omitempty"` + AppKey string `yaml:"app_key,omitempty"` + // Cluster is the Pusher cluster, e.g. "mt1". Ignored when ws_url is set. + Cluster string `yaml:"cluster,omitempty"` + // WSURL overrides the derived wss://ws-.pusher.com endpoint. + WSURL string `yaml:"ws_url,omitempty"` + // AuthEndpoint signs private and presence channel subscriptions. + AuthEndpoint string `yaml:"auth_endpoint,omitempty"` + // AppSecret makes the router sign private channel subscriptions itself, as + // HMAC-SHA256 of ":" under the secret, instead of calling + // auth_endpoint. The two are mutually exclusive. + // + // This skips the monolith entirely, so no session cookie is needed and reconnects + // keep working. It also skips the per-user permission check that /pusher/auth + // performs: the router can then subscribe to any channel of the app, regardless of + // who issued the GraphQL request. + AppSecret string `yaml:"app_secret,omitempty"` + // AuthHeaders are static headers sent with every authorization and encryption key + // request. Use them only for headers that are not specific to a user; a user + // session credential belongs in auth_headers_from_request. + AuthHeaders map[string]string `yaml:"auth_headers,omitempty"` + // AuthHeadersFromRequest lists header names forwarded from the incoming GraphQL + // request to the authorization and encryption key requests, e.g. ["Cookie"]. + // The auth endpoint authorizes a channel for the user behind the credential, so a + // per-user credential must travel with the subscription rather than be configured + // up front. The router keeps one Pusher connection per distinct credential. + // + // The listed headers must also be propagated to this subgraph by the header + // propagation rules; the router only forwards headers those rules produced. + AuthHeadersFromRequest []string `yaml:"auth_headers_from_request,omitempty"` + Encryption PusherEncryptionConfiguration `yaml:"encryption,omitempty"` + // EntityMappings rewrite a channel payload into an entity representation before it + // reaches the resolver, so the router resolves the requested fields from the owning + // subgraph instead of expecting them in the event itself. Without a mapping for a + // field, its payload is forwarded unchanged. + EntityMappings []PusherEntityMapping `yaml:"entity_mappings,omitempty"` +} + +// PusherEntityMapping turns the payload of one subscription field into +// {"__typename": "", "": ""}. +// +// monday's Pusher payloads are change notifications carrying the whole changed +// record, whose field names do not match the federated schema. The resolver only +// needs the entity key, so the payload is reduced to it. +type PusherEntityMapping struct { + // FieldName is the subscription root field this mapping applies to, e.g. + // "boardUpdated". + FieldName string `yaml:"field_name,omitempty"` + // TypeName is the entity type name emitted as __typename, e.g. "Board". + TypeName string `yaml:"type_name,omitempty"` + // KeyField is the field of the representation the value is written to. Defaults to + // "id". + KeyField string `yaml:"key_field,omitempty"` + // IDFrom lists the payload keys holding the entity key, in order of preference, + // e.g. ["board_id"] or ["pulse_id", "item_id"]. Dots address nested objects. + // The first key present and non-null wins. + IDFrom []string `yaml:"id_from,omitempty"` +} + +func (p PusherEventSource) GetID() string { + return p.ID +} + type EventProviders struct { - Nats []NatsEventSource `yaml:"nats,omitempty"` - Kafka []KafkaEventSource `yaml:"kafka,omitempty"` - Redis []RedisEventSource `yaml:"redis,omitempty"` + Nats []NatsEventSource `yaml:"nats,omitempty"` + Kafka []KafkaEventSource `yaml:"kafka,omitempty"` + Redis []RedisEventSource `yaml:"redis,omitempty"` + Pusher []PusherEventSource `yaml:"pusher,omitempty"` } type EventsConfiguration struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 381f061089..325a7e935e 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -3314,6 +3314,128 @@ } } } + }, + "pusher": { + "type": "array", + "description": "Configuration used by the EDFS provider to subscribe to Pusher Channels.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "app_key"], + "properties": { + "id": { + "type": "string", + "description": "The provider ID. The provider ID is used to identify the provider in the configuration." + }, + "app_key": { + "type": "string", + "description": "The Pusher application key." + }, + "cluster": { + "type": "string", + "description": "The Pusher cluster, e.g. \"mt1\". Used to build the WebSocket URL when ws_url is not set." + }, + "ws_url": { + "type": "string", + "description": "Overrides the WebSocket URL derived from the cluster. Useful for tests and self-hosted gateways." + }, + "auth_endpoint": { + "type": "string", + "description": "The URL used to authorize private and presence channels, e.g. \"https://example.monday.com/pusher/auth\"." + }, + "app_secret": { + "type": "string", + "minLength": 1, + "description": "The Pusher application secret. When set, the router signs private channel subscriptions itself (HMAC-SHA256 of \":\") instead of calling auth_endpoint, so no monolith session is needed. Mutually exclusive with auth_endpoint. This also skips the per-user permission check the auth endpoint performs." + }, + "auth_headers": { + "type": "object", + "description": "Static headers sent with every authorization and encryption key request. Use only for headers that are not specific to a user; put a user session credential in auth_headers_from_request instead.", + "additionalProperties": { + "type": "string" + } + }, + "auth_headers_from_request": { + "type": "array", + "description": "Header names forwarded from the incoming GraphQL request to the authorization and encryption key requests, e.g. [\"Cookie\"]. The auth endpoint authorizes channels for the user behind the credential, so the credential must travel with the subscription. The router keeps one Pusher connection per distinct credential. The listed headers must also be propagated to this subgraph by the header propagation rules.", + "items": { + "type": "string" + } + }, + "entity_mappings": { + "type": "array", + "description": "Rewrite the payload of a subscription field into an entity representation, so the router resolves the requested fields from the owning subgraph instead of expecting them in the event. A field without a mapping has its payload forwarded unchanged.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["field_name", "type_name", "id_from"], + "properties": { + "field_name": { + "type": "string", + "minLength": 1, + "description": "The subscription root field this mapping applies to, e.g. \"boardUpdated\"." + }, + "type_name": { + "type": "string", + "minLength": 1, + "description": "The entity type name emitted as __typename, e.g. \"Board\"." + }, + "key_field": { + "type": "string", + "minLength": 1, + "description": "The field of the representation the key is written to.", + "default": "id" + }, + "id_from": { + "type": "array", + "minItems": 1, + "description": "The payload keys holding the entity key, in order of preference, e.g. [\"board_id\"]. Dots address nested objects. The first key present and not null wins.", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "encryption": { + "type": "object", + "additionalProperties": false, + "description": "Payload decryption for monday encrypted channels (private-enc_ prefix).", + "properties": { + "enabled": { + "type": "boolean", + "description": "If enabled, encrypted payloads are decrypted before they are delivered to the subscription.", + "default": false + }, + "encryption_key": { + "type": "string", + "minLength": 1, + "description": "A single encryption key used for every payload, whatever its enc_date. Provide it instead of keys_endpoint. Intended for local development: the monday keys rotate daily, so a pinned key stops working after a rotation." + }, + "keys_endpoint": { + "type": "string", + "description": "The URL returning the encryption keys, e.g. \"https://example.monday.com/pusher/get_encryption_keys\"." + }, + "refresh_interval": { + "type": "string", + "format": "go-duration", + "description": "How often the encryption keys are refreshed. The period is specified as a string with a number and a unit, e.g. 10s, 1m, 1h.", + "duration": { + "minimum": "1s" + }, + "default": "1h" + } + }, + "not": { + "required": ["encryption_key", "keys_endpoint"] + } + } + }, + "not": { + "required": ["app_secret", "auth_endpoint"] + } + } } } }, diff --git a/router/pkg/metric/stream_metric_store.go b/router/pkg/metric/stream_metric_store.go index 361f49388d..1e46d1c712 100644 --- a/router/pkg/metric/stream_metric_store.go +++ b/router/pkg/metric/stream_metric_store.go @@ -15,9 +15,10 @@ import ( type ProviderType string const ( - ProviderTypeKafka ProviderType = "kafka" - ProviderTypeNats ProviderType = "nats" - ProviderTypeRedis ProviderType = "redis" + ProviderTypeKafka ProviderType = "kafka" + ProviderTypeNats ProviderType = "nats" + ProviderTypeRedis ProviderType = "redis" + ProviderTypePusher ProviderType = "pusher" ) // StreamsEvent carries the values for stream metrics attributes. diff --git a/router/pkg/pubsub/datasource/provider.go b/router/pkg/pubsub/datasource/provider.go index c1e9fea184..4dc712691e 100644 --- a/router/pkg/pubsub/datasource/provider.go +++ b/router/pkg/pubsub/datasource/provider.go @@ -52,9 +52,10 @@ type ProviderBuilder[P, E any] interface { type ProviderType string const ( - ProviderTypeNats ProviderType = "nats" - ProviderTypeKafka ProviderType = "kafka" - ProviderTypeRedis ProviderType = "redis" + ProviderTypeNats ProviderType = "nats" + ProviderTypeKafka ProviderType = "kafka" + ProviderTypeRedis ProviderType = "redis" + ProviderTypePusher ProviderType = "pusher" ) // StreamEvents is a list of stream events coming from or going to event providers. diff --git a/router/pkg/pubsub/datasource/request_header.go b/router/pkg/pubsub/datasource/request_header.go new file mode 100644 index 0000000000..753da101db --- /dev/null +++ b/router/pkg/pubsub/datasource/request_header.go @@ -0,0 +1,29 @@ +package datasource + +import ( + "context" + "net/http" +) + +type requestHeaderContextKey struct{} + +// WithRequestHeader attaches the header set the resolver built for this subscription +// to the context handed to Adapter.Subscribe. The header set is the result of the +// subgraph header propagation rules, so it only contains headers the router was +// explicitly configured to forward. +// +// Adapters that authenticate per subscriber (Pusher) read the credential from here. +// Adapters that authenticate once per provider (NATS, Kafka, Redis) ignore it. +func WithRequestHeader(ctx context.Context, header http.Header) context.Context { + if header == nil { + return ctx + } + return context.WithValue(ctx, requestHeaderContextKey{}, header) +} + +// RequestHeaderFromContext returns the propagated request header, or nil when the +// context carries none. +func RequestHeaderFromContext(ctx context.Context) http.Header { + header, _ := ctx.Value(requestHeaderContextKey{}).(http.Header) + return header +} diff --git a/router/pkg/pubsub/datasource/subscription_datasource.go b/router/pkg/pubsub/datasource/subscription_datasource.go index 2ead86bf38..b872df94f6 100644 --- a/router/pkg/pubsub/datasource/subscription_datasource.go +++ b/router/pkg/pubsub/datasource/subscription_datasource.go @@ -52,7 +52,12 @@ func (s *PubSubSubscriptionDataSource[C]) Start(ctx *resolve.Context, header htt zap.String("field_name", conf.RootFieldName()), ) - return s.pubSub.Subscribe(ctx.Context(), conf, NewSubscriptionEventUpdater(conf, s.hooks, updater, logger, s.eventBuilder)) + // The header set is part of the trigger identity (the resolver hashes it into the + // trigger ID), so adapters that authenticate per subscriber can use it without two + // subscribers sharing a trigger. + subscribeCtx := WithRequestHeader(ctx.Context(), header) + + return s.pubSub.Subscribe(subscribeCtx, conf, NewSubscriptionEventUpdater(conf, s.hooks, updater, logger, s.eventBuilder)) } func (s *PubSubSubscriptionDataSource[C]) SubscriptionOnStart(ctx resolve.StartupHookContext, input []byte) (err error) { diff --git a/router/pkg/pubsub/pubsub.go b/router/pkg/pubsub/pubsub.go index 3ccd634fdf..49d3e30dfe 100644 --- a/router/pkg/pubsub/pubsub.go +++ b/router/pkg/pubsub/pubsub.go @@ -13,6 +13,7 @@ import ( pubsub_datasource "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" "github.com/wundergraph/cosmo/router/pkg/pubsub/kafka" "github.com/wundergraph/cosmo/router/pkg/pubsub/nats" + "github.com/wundergraph/cosmo/router/pkg/pubsub/pusher" "github.com/wundergraph/cosmo/router/pkg/pubsub/redis" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "go.uber.org/zap" @@ -73,13 +74,43 @@ func BuildProvidersAndDataSources( var pubSubProviders []pubsub_datasource.Provider var outs []plan.DataSource + // Pusher provider IDs declared in the router configuration. A kafka event whose + // provider ID names one of them is served by the pusher provider instead, with the + // kafka topics used as pusher channels. This lets a subgraph declare a pusher + // subscription with the published @edfs__kafkaSubscribe directive, so no custom + // composition build is needed. + pusherProviderIDs := make(map[string]struct{}, len(config.Providers.Pusher)) + for _, provider := range config.Providers.Pusher { + pusherProviderIDs[provider.ID] = struct{}{} + } + // initialize Kafka providers and data sources kafkaBuilder := kafka.NewProviderBuilder(ctx, logger, hostName, routerListenAddr) kafkaDsConfsWithEvents := []dsConfAndEvents[*nodev1.KafkaEventConfiguration]{} - for _, dsConf := range dsConfs { + // Kafka events redirected to the pusher provider, keyed by data source index. + redirectedToPusher := make(map[int][]*nodev1.PusherEventConfiguration, len(dsConfs)) + for i, dsConf := range dsConfs { + kafkaEvents := make([]*nodev1.KafkaEventConfiguration, 0, len(dsConf.Configuration.GetCustomEvents().GetKafka())) + for _, event := range dsConf.Configuration.GetCustomEvents().GetKafka() { + providerID := event.GetEngineEventConfiguration().GetProviderId() + if _, ok := pusherProviderIDs[providerID]; !ok { + kafkaEvents = append(kafkaEvents, event) + continue + } + logger.Info("serving kafka event with the pusher provider", + zap.String("provider_id", providerID), + zap.String("type_name", event.GetEngineEventConfiguration().GetTypeName()), + zap.String("field_name", event.GetEngineEventConfiguration().GetFieldName()), + zap.Strings("channels", event.GetTopics()), + ) + redirectedToPusher[i] = append(redirectedToPusher[i], &nodev1.PusherEventConfiguration{ + EngineEventConfiguration: event.GetEngineEventConfiguration(), + Channels: event.GetTopics(), + }) + } kafkaDsConfsWithEvents = append(kafkaDsConfsWithEvents, dsConfAndEvents[*nodev1.KafkaEventConfiguration]{ dsConf: &dsConf, - events: dsConf.Configuration.GetCustomEvents().GetKafka(), + events: kafkaEvents, }) } kafkaPubSubProviders, kafkaOuts, err := build(ctx, kafkaBuilder, config.Providers.Kafka, kafkaDsConfsWithEvents, store, hooks, logger, config.SkipUnavailableProviders) @@ -127,6 +158,28 @@ func BuildProvidersAndDataSources( } outs = append(outs, redisOuts...) + // initialize Pusher providers and data sources + pusherBuilder := pusher.NewProviderBuilder(ctx, logger, hostName, routerListenAddr) + pusherDsConfsWithEvents := []dsConfAndEvents[*nodev1.PusherEventConfiguration]{} + for i, dsConf := range dsConfs { + events := dsConf.Configuration.GetCustomEvents().GetPusher() + if redirected := redirectedToPusher[i]; len(redirected) > 0 { + events = append(append([]*nodev1.PusherEventConfiguration{}, events...), redirected...) + } + pusherDsConfsWithEvents = append(pusherDsConfsWithEvents, dsConfAndEvents[*nodev1.PusherEventConfiguration]{ + dsConf: &dsConf, + events: events, + }) + } + pusherPubSubProviders, pusherOuts, err := build(ctx, pusherBuilder, config.Providers.Pusher, pusherDsConfsWithEvents, store, hooks, logger, config.SkipUnavailableProviders) + if err != nil { + return nil, nil, err + } + for _, provider := range pusherPubSubProviders { + pubSubProviders = append(pubSubProviders, provider) + } + outs = append(outs, pusherOuts...) + return pubSubProviders, outs, nil } diff --git a/router/pkg/pubsub/pusher/adapter.go b/router/pkg/pubsub/pusher/adapter.go new file mode 100644 index 0000000000..a63f2830fc --- /dev/null +++ b/router/pkg/pubsub/pusher/adapter.go @@ -0,0 +1,395 @@ +package pusher + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/wundergraph/cosmo/router/internal/pusherclient" + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/metric" + "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" + "go.uber.org/zap" +) + +const pusherReceive = "receive" + +// startupTimeout bounds a connection attempt so a black-holed broker cannot block +// the first subscription for longer than the caller's own timeout. +const startupTimeout = 3 * time.Second + +var _ datasource.Adapter = (*ProviderAdapter)(nil) + +// pooledClient is one Pusher WebSocket connection plus the credential it was +// authorized with. Channel subscriptions on a Pusher connection are authorized once +// against a single socket_id, so subscribers with different credentials cannot share +// a connection: one connection exists per distinct credential. +type pooledClient struct { + client *pusherclient.Client + // refs counts the live channel subscriptions using this connection. The + // connection is closed when it drops to zero. + refs int +} + +// ProviderAdapter subscribes to Pusher channels. It owns a pool of WebSocket +// connections keyed by the forwarded credential. +type ProviderAdapter struct { + ctx context.Context + cancel context.CancelFunc + logger *zap.Logger + source config.PusherEventSource + streamMetricStore metric.StreamMetricStore + // skipUnavailable mirrors events.skip_unavailable_providers. When true, a failed + // initial connection does not fail the subscription; the client reconnects in the + // background and the affected fields recover without a restart. + skipUnavailable bool + + // entityMapper rewrites payloads into entity representations. Nil when the provider + // configures no mapping, which forwards payloads unchanged. + entityMapper *entityMapper + + mu sync.Mutex + clients map[string]*pooledClient + closed bool + + closeWg sync.WaitGroup +} + +func NewProviderAdapter(ctx context.Context, logger *zap.Logger, source config.PusherEventSource, opts datasource.ProviderOpts) (datasource.Adapter, error) { + mapper, err := newEntityMapper(source.EntityMappings) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithCancel(ctx) + if logger == nil { + logger = zap.NewNop() + } + + store := opts.StreamMetricStore + if store == nil { + store = metric.NewNoopStreamMetricStore() + } + + return &ProviderAdapter{ + ctx: ctx, + cancel: cancel, + logger: logger, + source: source, + streamMetricStore: store, + skipUnavailable: opts.SkipUnavailableProviders, + entityMapper: mapper, + clients: make(map[string]*pooledClient), + }, nil +} + +// Startup does not connect: the credential used to authorize channels arrives with +// the subscription request, so connections are created on first use instead. +func (p *ProviderAdapter) Startup(ctx context.Context) error { + return nil +} + +func (p *ProviderAdapter) Shutdown(ctx context.Context) error { + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return nil + } + p.closed = true + clients := make([]*pooledClient, 0, len(p.clients)) + for key, pooled := range p.clients { + clients = append(clients, pooled) + delete(p.clients, key) + } + p.mu.Unlock() + + // Cancel the context to stop the subscriptions + p.cancel() + + // Wait for the subscriptions to be closed + p.closeWg.Wait() + + var firstErr error + for _, pooled := range clients { + if err := pooled.client.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + + return firstErr +} + +// authHeaders merges the static headers with the headers forwarded from the incoming +// request, and returns a key identifying the resulting credential. Forwarded headers +// win over static ones. +func (p *ProviderAdapter) authHeaders(ctx context.Context) (headers map[string]string, credentialKey string) { + headers = make(map[string]string, len(p.source.AuthHeaders)+len(p.source.AuthHeadersFromRequest)) + for name, value := range p.source.AuthHeaders { + headers[name] = value + } + + requestHeader := datasource.RequestHeaderFromContext(ctx) + forwarded := make([]string, 0, len(p.source.AuthHeadersFromRequest)) + for _, name := range p.source.AuthHeadersFromRequest { + value := requestHeader.Get(name) + if value == "" { + continue + } + headers[http.CanonicalHeaderKey(name)] = value + forwarded = append(forwarded, http.CanonicalHeaderKey(name)+": "+value) + } + + if len(forwarded) == 0 { + // No per-request credential: every subscriber shares the static-header client. + return headers, "" + } + + // The key is hashed so the credential itself never reaches a map key that could be + // logged or reported. + sort.Strings(forwarded) + sum := sha256.Sum256([]byte(strings.Join(forwarded, "\n"))) + return headers, hex.EncodeToString(sum[:]) +} + +// acquireClient returns the connection for the given credential, creating and +// connecting it on first use. The caller must call releaseClient once per successful +// call. +func (p *ProviderAdapter) acquireClient(ctx context.Context, credentialKey string, headers map[string]string) (*pusherclient.Client, error) { + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return nil, datasource.NewError("pusher provider is shut down", nil) + } + if pooled, ok := p.clients[credentialKey]; ok { + pooled.refs++ + p.mu.Unlock() + return pooled.client, nil + } + p.mu.Unlock() + + client, err := p.newClient(ctx, headers) + if err != nil { + return nil, err + } + + p.mu.Lock() + if p.closed { + p.mu.Unlock() + _ = client.Close() + return nil, datasource.NewError("pusher provider is shut down", nil) + } + // Another subscription may have created a connection for the same credential + // concurrently; keep the one already in the pool and discard ours. + if pooled, ok := p.clients[credentialKey]; ok { + pooled.refs++ + p.mu.Unlock() + _ = client.Close() + return pooled.client, nil + } + p.clients[credentialKey] = &pooledClient{client: client, refs: 1} + p.mu.Unlock() + + return client, nil +} + +func (p *ProviderAdapter) releaseClient(credentialKey string) { + p.mu.Lock() + pooled, ok := p.clients[credentialKey] + if !ok { + p.mu.Unlock() + return + } + pooled.refs-- + if pooled.refs > 0 { + p.mu.Unlock() + return + } + delete(p.clients, credentialKey) + p.mu.Unlock() + + if err := pooled.client.Close(); err != nil { + p.logger.Debug("closing idle pusher connection", zap.String("provider_id", p.source.ID), zap.Error(err)) + } +} + +// newClient builds and connects one Pusher connection for the given credential. The +// credential is retained by the client for its whole lifetime: a reconnect gets a new +// socket_id, so every channel must be re-authorized. +func (p *ProviderAdapter) newClient(ctx context.Context, headers map[string]string) (*pusherclient.Client, error) { + logger := p.logger.With(zap.String("provider_id", p.source.ID)) + + var decryptor pusherclient.Decryptor + if p.source.Encryption.Enabled { + mondayDecryptor, err := pusherclient.NewMondayDecryptor(pusherclient.MondayDecryptorOptions{ + StaticKey: p.source.Encryption.EncryptionKey, + KeysEndpoint: p.source.Encryption.KeysEndpoint, + Headers: headers, + RefreshInterval: p.source.Encryption.RefreshInterval, + Logger: logger, + }) + if err != nil { + return nil, err + } + // The key set is fetched here so a misconfigured endpoint fails on subscribe + // instead of silently emitting ciphertext on the first event. + if err := mondayDecryptor.Start(p.ctx); err != nil { + if !p.skipUnavailable { + return nil, err + } + logger.Error("could not load pusher encryption keys, events will not be decrypted until the keys become available", + zap.Error(err)) + } + decryptor = mondayDecryptor + } + + client, err := pusherclient.New(pusherclient.Options{ + AppKey: p.source.AppKey, + Cluster: p.source.Cluster, + WSURL: p.source.WSURL, + AuthEndpoint: p.source.AuthEndpoint, + AppSecret: p.source.AppSecret, + AuthHeaders: headers, + Decryptor: decryptor, + Logger: logger, + }) + if err != nil { + return nil, err + } + + connectCtx, cancel := context.WithTimeout(ctx, startupTimeout) + defer cancel() + + if err := client.Connect(connectCtx); err != nil { + if !p.skipUnavailable { + _ = client.Close() + return nil, err + } + // Lenient mode: keep the client. It reconnects in the background and + // subscribes the registered channels once the connection is up. + logger.Error("could not connect to pusher, retrying in the background", zap.Error(err)) + } + + return client, nil +} + +func (p *ProviderAdapter) Subscribe(ctx context.Context, conf datasource.SubscriptionEventConfiguration, updater datasource.SubscriptionEventUpdater) error { + subConf, ok := conf.(*SubscriptionEventConfiguration) + if !ok { + return datasource.NewError("subscription event not supported by pusher provider", nil) + } + + log := p.logger.With( + zap.String("provider_id", conf.ProviderID()), + zap.String("method", "subscribe"), + zap.Strings("channels", subConf.Channels), + ) + + headers, credentialKey := p.authHeaders(ctx) + + log.Debug("subscribing") + + client, err := p.acquireClient(ctx, credentialKey, headers) + if err != nil { + return datasource.NewError("failed to connect to pusher", err) + } + + subscriptions := make([]*pusherclient.Subscription, 0, len(subConf.Channels)) + for _, channel := range subConf.Channels { + subscription, err := client.Subscribe(ctx, channel) + if err != nil { + // Undo the subscriptions we already created so a partial failure does not + // leak channels on the shared connection. + for _, created := range subscriptions { + created.Unsubscribe() + } + p.releaseClient(credentialKey) + return datasource.NewError("failed to subscribe to pusher channel "+channel, err) + } + subscriptions = append(subscriptions, subscription) + } + + if len(subscriptions) == 0 { + p.releaseClient(credentialKey) + return nil + } + + // The pool reference taken by acquireClient is held until every channel goroutine + // of this subscription has stopped. + var channelsWg sync.WaitGroup + p.closeWg.Add(1) + go func() { + defer p.closeWg.Done() + channelsWg.Wait() + p.releaseClient(credentialKey) + }() + + for _, subscription := range subscriptions { + p.closeWg.Add(1) + channelsWg.Add(1) + + go func(subscription *pusherclient.Subscription) { + defer p.closeWg.Done() + defer channelsWg.Done() + defer subscription.Unsubscribe() + + events := subscription.Events() + + for { + select { + case evt, ok := <-events: + if !ok { + log.Debug("subscription closed, stopping", zap.String("message_channel", subscription.Channel())) + return + } + log.Debug("subscription update", + zap.String("message_channel", evt.Channel), + zap.String("event", evt.Name), + ) + p.streamMetricStore.Consume(ctx, metric.StreamsEvent{ + ProviderId: conf.ProviderID(), + StreamOperationName: pusherReceive, + ProviderType: metric.ProviderTypePusher, + DestinationName: evt.Channel, + }) + data, err := p.entityMapper.mapEvent(subConf.FieldName, evt.Data) + if err != nil { + // A payload that cannot be reduced to an entity key carries no + // usable update, so it is dropped rather than forwarded as-is: + // forwarding it would fail in the resolver instead, with less + // context about which event was at fault. + log.Error("could not map pusher event to an entity representation", + zap.String("message_channel", evt.Channel), + zap.String("event", evt.Name), + zap.Error(err), + ) + continue + } + updater.Update([]datasource.StreamEvent{ + &Event{evt: &MutableEvent{Data: data}}, + }) + case <-p.ctx.Done(): + // When the application context is done, we stop the subscription if it is not already done + log.Debug("application context done, stopping subscription") + return + case <-ctx.Done(): + // When the subscription context is done, we stop the subscription if it is not already done + log.Debug("subscription context done, stopping subscription") + return + } + } + }(subscription) + } + + return nil +} + +// Publish is not supported: monday's monolith is the only publisher to these +// channels, and the Pusher client protocol cannot publish at all. +func (p *ProviderAdapter) Publish(ctx context.Context, conf datasource.PublishEventConfiguration, events []datasource.StreamEvent) error { + return datasource.NewError("publish is not supported by the pusher provider", nil) +} diff --git a/router/pkg/pubsub/pusher/engine_datasource.go b/router/pkg/pubsub/pusher/engine_datasource.go new file mode 100644 index 0000000000..4f8f01c897 --- /dev/null +++ b/router/pkg/pubsub/pusher/engine_datasource.go @@ -0,0 +1,116 @@ +package pusher + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + + "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +type Event struct { + evt *MutableEvent +} + +func (e *Event) GetData() []byte { + if e.evt == nil { + return nil + } + return slices.Clone(e.evt.Data) +} + +func (e *Event) Clone() datasource.MutableStreamEvent { + return e.evt.Clone() +} + +type MutableEvent struct { + Data json.RawMessage `json:"data"` +} + +func (e *MutableEvent) GetData() []byte { + if e == nil { + return nil + } + return e.Data +} + +func (e *MutableEvent) SetData(data []byte) { + if e == nil { + return + } + e.Data = data +} + +func (e *MutableEvent) Clone() datasource.MutableStreamEvent { + if e == nil { + return nil + } + + return &MutableEvent{ + Data: slices.Clone(e.Data), + } +} + +// SubscriptionEventConfiguration contains configuration for subscription events +type SubscriptionEventConfiguration struct { + Provider string `json:"providerId"` + Channels []string `json:"channels"` + FieldName string `json:"rootFieldName"` +} + +// ProviderID returns the provider ID +func (s *SubscriptionEventConfiguration) ProviderID() string { + return s.Provider +} + +// ProviderType returns the provider type +func (s *SubscriptionEventConfiguration) ProviderType() datasource.ProviderType { + return datasource.ProviderTypePusher +} + +// RootFieldName returns the root field name +func (s *SubscriptionEventConfiguration) RootFieldName() string { + return s.FieldName +} + +// SubscriptionDataSource implements resolve.SubscriptionDataSource for Pusher +type SubscriptionDataSource struct { + pubSub datasource.Adapter +} + +func (s *SubscriptionDataSource) SubscriptionEventConfiguration(input []byte) datasource.SubscriptionEventConfiguration { + var subscriptionConfiguration SubscriptionEventConfiguration + err := json.Unmarshal(input, &subscriptionConfiguration) + if err != nil { + return nil + } + return &subscriptionConfiguration +} + +// Start starts the subscription +func (s *SubscriptionDataSource) Start(ctx *resolve.Context, header http.Header, input []byte, updater datasource.SubscriptionEventUpdater) error { + subConf := s.SubscriptionEventConfiguration(input) + if subConf == nil { + return fmt.Errorf("no subscription configuration found") + } + + conf, ok := subConf.(*SubscriptionEventConfiguration) + if !ok { + return fmt.Errorf("invalid subscription configuration") + } + + return s.pubSub.Subscribe(ctx.Context(), conf, updater) +} + +// LoadInitialData implements the interface method (not used for this subscription type) +func (s *SubscriptionDataSource) LoadInitialData(ctx context.Context) (initial []byte, err error) { + return nil, nil +} + +// Interface compliance checks +var _ datasource.SubscriptionEventConfiguration = (*SubscriptionEventConfiguration)(nil) +var _ datasource.StreamEvent = (*Event)(nil) +var _ datasource.MutableStreamEvent = (*MutableEvent)(nil) diff --git a/router/pkg/pubsub/pusher/engine_datasource_factory.go b/router/pkg/pubsub/pusher/engine_datasource_factory.go new file mode 100644 index 0000000000..93a321f208 --- /dev/null +++ b/router/pkg/pubsub/pusher/engine_datasource_factory.go @@ -0,0 +1,106 @@ +package pusher + +import ( + "encoding/json" + "fmt" + "slices" + + "github.com/buger/jsonparser" + "github.com/cespare/xxhash/v2" + "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" + "go.uber.org/zap" +) + +type EventType int + +const ( + EventTypeSubscribe EventType = iota +) + +// EngineDataSourceFactory implements the datasource.EngineDataSourceFactory interface for Pusher. +// Only subscriptions are supported. +type EngineDataSourceFactory struct { + PusherAdapter datasource.Adapter + + fieldName string + eventType EventType + channels []string + providerId string + logger *zap.Logger +} + +func (c *EngineDataSourceFactory) GetFieldName() string { + return c.fieldName +} + +// ResolveDataSource is only used for publishing, which Pusher does not support +func (c *EngineDataSourceFactory) ResolveDataSource() (resolve.DataSource, error) { + return nil, fmt.Errorf("failed to configure fetch: publishing is not supported for Pusher") +} + +// ResolveDataSourceInput is only used for publishing, which Pusher does not support +func (c *EngineDataSourceFactory) ResolveDataSourceInput(eventData []byte) (string, error) { + return "", fmt.Errorf("publishing is not supported for Pusher") +} + +// ResolveDataSourceSubscription returns the subscription data source +func (c *EngineDataSourceFactory) ResolveDataSourceSubscription() (datasource.SubscriptionDataSource, error) { + triggerHashInputFn := func(input []byte, xxh *xxhash.Digest) error { + val, _, _, err := jsonparser.Get(input, "channels") + if err != nil { + return err + } + + _, err = xxh.Write(val) + if err != nil { + return err + } + + val, _, _, err = jsonparser.Get(input, "providerId") + if err != nil { + return err + } + + _, err = xxh.Write(val) + return err + } + + eventCreateFn := func(data []byte) datasource.MutableStreamEvent { + return &MutableEvent{Data: data} + } + + return datasource.NewPubSubSubscriptionDataSource[*SubscriptionEventConfiguration]( + c.PusherAdapter, triggerHashInputFn, c.logger, eventCreateFn, + ), nil +} + +// ResolveDataSourceSubscriptionInput builds the input for the subscription data source +func (c *EngineDataSourceFactory) ResolveDataSourceSubscriptionInput() (string, error) { + evtCfg := SubscriptionEventConfiguration{ + Provider: c.providerId, + Channels: c.channels, + FieldName: c.fieldName, + } + object, err := json.Marshal(evtCfg) + if err != nil { + return "", fmt.Errorf("failed to marshal event subscription configuration") + } + return string(object), nil +} + +// TransformEventData expands the argument templates in the channel names +func (c *EngineDataSourceFactory) TransformEventData(extractFn datasource.ArgumentTemplateCallback) error { + extractedChannels := make([]string, 0, len(c.channels)) + for _, rawChannel := range c.channels { + extractedChannel, err := extractFn(rawChannel) + if err != nil { + return nil + } + extractedChannels = append(extractedChannels, extractedChannel) + } + slices.Sort(extractedChannels) + c.channels = extractedChannels + + return nil +} diff --git a/router/pkg/pubsub/pusher/entity.go b/router/pkg/pubsub/pusher/entity.go new file mode 100644 index 0000000000..8840fd7630 --- /dev/null +++ b/router/pkg/pubsub/pusher/entity.go @@ -0,0 +1,135 @@ +package pusher + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/wundergraph/cosmo/router/pkg/config" +) + +// entityMapper reduces a channel payload to an entity representation, keyed by the +// subscription root field the payload was delivered for. +// +// monday publishes change notifications ("project_name_change" carries name, +// pulse_id, board_id, ...), whose keys are not the fields of the federated type. The +// resolver, however, only needs the entity key: given +// {"__typename":"Board","id":"5002284778"} it resolves every requested field from the +// subgraph that owns Board. So a mapping rewrites +// +// {"name":"...","pulse_id":2536911968,"board_id":5002284778,...} +// +// into +// +// {"__typename":"Board","id":"5002284778"} +type entityMapper struct { + byField map[string]config.PusherEntityMapping +} + +func newEntityMapper(mappings []config.PusherEntityMapping) (*entityMapper, error) { + if len(mappings) == 0 { + return nil, nil + } + + byField := make(map[string]config.PusherEntityMapping, len(mappings)) + for _, mapping := range mappings { + if mapping.FieldName == "" { + return nil, fmt.Errorf("pusher: an entity mapping is missing field_name") + } + if mapping.TypeName == "" { + return nil, fmt.Errorf("pusher: entity mapping for field %q is missing type_name", mapping.FieldName) + } + if len(mapping.IDFrom) == 0 { + return nil, fmt.Errorf("pusher: entity mapping for field %q is missing id_from", mapping.FieldName) + } + if mapping.KeyField == "" { + mapping.KeyField = "id" + } + if _, exists := byField[mapping.FieldName]; exists { + return nil, fmt.Errorf("pusher: duplicate entity mapping for field %q", mapping.FieldName) + } + byField[mapping.FieldName] = mapping + } + + return &entityMapper{byField: byField}, nil +} + +// mapEvent returns the representation for the given root field. It returns the +// payload unchanged when no mapping is configured for the field. +func (m *entityMapper) mapEvent(fieldName string, payload []byte) ([]byte, error) { + if m == nil { + return payload, nil + } + mapping, ok := m.byField[fieldName] + if !ok { + return payload, nil + } + + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + return nil, fmt.Errorf("pusher: payload for field %q is not a JSON object, so it cannot be mapped to %s: %w", + fieldName, mapping.TypeName, err) + } + + for _, path := range mapping.IDFrom { + value, found := lookupPath(decoded, path) + if !found { + continue + } + id, err := scalarToString(value) + if err != nil { + return nil, fmt.Errorf("pusher: %q in the payload for field %q cannot be used as %s.%s: %w", + path, fieldName, mapping.TypeName, mapping.KeyField, err) + } + return json.Marshal(map[string]string{ + "__typename": mapping.TypeName, + mapping.KeyField: id, + }) + } + + return nil, fmt.Errorf("pusher: the payload for field %q contains none of %s, so no %s key could be derived", + fieldName, strings.Join(mapping.IDFrom, ", "), mapping.TypeName) +} + +// lookupPath resolves a dot-separated path in a decoded JSON object. A null value +// counts as absent, so the next candidate key is tried. +func lookupPath(object map[string]any, path string) (any, bool) { + current := object + segments := strings.Split(path, ".") + + for i, segment := range segments { + value, ok := current[segment] + if !ok || value == nil { + return nil, false + } + if i == len(segments)-1 { + return value, true + } + nested, ok := value.(map[string]any) + if !ok { + return nil, false + } + current = nested + } + + return nil, false +} + +// scalarToString renders a JSON scalar as the string an ID field expects. IDs arrive +// as JSON numbers in monday's payloads, and json.Unmarshal decodes those into +// float64, so an integer is formatted without an exponent or a fractional part. +func scalarToString(value any) (string, error) { + switch typed := value.(type) { + case string: + return typed, nil + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64), nil + case json.Number: + return typed.String(), nil + case bool: + return strconv.FormatBool(typed), nil + default: + return "", fmt.Errorf("unsupported type %T", value) + } +} diff --git a/router/pkg/pubsub/pusher/provider_builder.go b/router/pkg/pubsub/pusher/provider_builder.go new file mode 100644 index 0000000000..d50c9b1a27 --- /dev/null +++ b/router/pkg/pubsub/pusher/provider_builder.go @@ -0,0 +1,79 @@ +package pusher + +import ( + "context" + "fmt" + + 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/pubsub/datasource" + "go.uber.org/zap" +) + +const providerTypeID = "pusher" + +// ProviderBuilder builds Pusher PubSub providers +type ProviderBuilder struct { + ctx context.Context + logger *zap.Logger + hostName string + routerListenAddr string +} + +// NewProviderBuilder creates a new Pusher PubSub provider builder +func NewProviderBuilder( + ctx context.Context, + logger *zap.Logger, + hostName string, + routerListenAddr string, +) *ProviderBuilder { + return &ProviderBuilder{ + ctx: ctx, + logger: logger, + hostName: hostName, + routerListenAddr: routerListenAddr, + } +} + +// TypeID returns the provider type ID +func (b *ProviderBuilder) TypeID() string { + return providerTypeID +} + +// BuildEngineDataSourceFactory creates a Pusher data source for the given event configuration +func (b *ProviderBuilder) BuildEngineDataSourceFactory(data *nodev1.PusherEventConfiguration, providers map[string]datasource.Provider) (datasource.EngineDataSourceFactory, error) { + providerId := data.GetEngineEventConfiguration().GetProviderId() + provider, ok := providers[providerId] + if !ok { + return nil, fmt.Errorf("failed to get adapter for provider %s with ID %s", b.TypeID(), providerId) + } + + eventType := data.GetEngineEventConfiguration().GetType() + if eventType != nodev1.EventType_SUBSCRIBE { + return nil, fmt.Errorf("unsupported event type for Pusher: %s, only subscriptions are supported", eventType) + } + + return &EngineDataSourceFactory{ + fieldName: data.GetEngineEventConfiguration().GetFieldName(), + eventType: EventTypeSubscribe, + channels: data.GetChannels(), + providerId: providerId, + PusherAdapter: provider, + logger: b.logger, + }, nil +} + +// BuildProvider returns the Pusher PubSub provider for the given event source +func (b *ProviderBuilder) BuildProvider(provider config.PusherEventSource, providerOpts datasource.ProviderOpts) (datasource.Provider, error) { + adapter, err := NewProviderAdapter(b.ctx, b.logger, provider, providerOpts) + if err != nil { + return nil, err + } + eventBuilder := func(data []byte) datasource.MutableStreamEvent { + return &MutableEvent{Data: data} + } + + pubSubProvider := datasource.NewPubSubProvider(provider.ID, providerTypeID, adapter, b.logger, eventBuilder) + + return pubSubProvider, nil +} diff --git a/shared/src/router-config/builder.ts b/shared/src/router-config/builder.ts index dba215d1bf..4d6ca0177a 100644 --- a/shared/src/router-config/builder.ts +++ b/shared/src/router-config/builder.ts @@ -343,12 +343,18 @@ export const buildRouterConfig = function (input: Input): RouterConfig { let kind: DataSourceKind; let customGraphql: DataSourceCustom_GraphQL | undefined; let customEvents: DataSourceCustomEvents | undefined; - if (events.kafka.length > 0 || events.nats.length > 0 || events.redis.length > 0) { + if ( + events.kafka.length > 0 || + events.nats.length > 0 || + events.redis.length > 0 || + events.pusher.length > 0 + ) { kind = DataSourceKind.PUBSUB; customEvents = create(DataSourceCustomEventsSchema, { kafka: events.kafka, nats: events.nats, redis: events.redis, + pusher: events.pusher, }); // PUBSUB data sources cannot have root nodes other than // Query/Mutation/Subscription. Filter rootNodes in place diff --git a/shared/src/router-config/graphql-configuration.ts b/shared/src/router-config/graphql-configuration.ts index 92ec3261a6..9db013b174 100644 --- a/shared/src/router-config/graphql-configuration.ts +++ b/shared/src/router-config/graphql-configuration.ts @@ -15,6 +15,7 @@ import { KafkaEventConfigurationSchema, NatsEventConfigurationSchema, NatsStreamConfigurationSchema, + PusherEventConfigurationSchema, RedisEventConfigurationSchema, RequiredFieldSchema, ScopesSchema, @@ -35,6 +36,7 @@ import type { KafkaEventConfiguration, NatsEventConfiguration, NatsStreamConfiguration, + PusherEventConfiguration, RedisEventConfiguration, RequiredField, Scopes, @@ -49,6 +51,7 @@ import { NatsEventType as CompositionEventType, PROVIDER_TYPE_KAFKA, PROVIDER_TYPE_NATS, + PROVIDER_TYPE_PUSHER, PROVIDER_TYPE_REDIS, RequiredFieldConfiguration, SubscriptionCondition, @@ -141,7 +144,7 @@ export function configurationDatasToDataSourceConfiguration( childNodes: [], keys: [], provides: [], - events: create(DataSourceCustomEventsSchema, { nats: [], kafka: [], redis: [] }), + events: create(DataSourceCustomEventsSchema, { nats: [], kafka: [], redis: [], pusher: [] }), requires: [], entityInterfaces: [], interfaceObjects: [], @@ -176,6 +179,7 @@ export function configurationDatasToDataSourceConfiguration( const natsEventConfigurations: NatsEventConfiguration[] = []; const kafkaEventConfigurations: KafkaEventConfiguration[] = []; const redisEventConfigurations: RedisEventConfiguration[] = []; + const pusherEventConfigurations: PusherEventConfiguration[] = []; for (const event of data.events ?? []) { switch (event.providerType) { case PROVIDER_TYPE_KAFKA: { @@ -229,6 +233,20 @@ export function configurationDatasToDataSourceConfiguration( ); break; } + case PROVIDER_TYPE_PUSHER: { + pusherEventConfigurations.push( + create(PusherEventConfigurationSchema, { + engineEventConfiguration: create(EngineEventConfigurationSchema, { + fieldName: event.fieldName, + providerId: event.providerId, + type: eventType(event.type), + typeName, + }), + channels: event.channels, + }), + ); + break; + } default: { throw new Error(`Fatal: Unknown event provider.`); } @@ -237,6 +255,7 @@ export function configurationDatasToDataSourceConfiguration( output.events.nats.push(...natsEventConfigurations); output.events.kafka.push(...kafkaEventConfigurations); output.events.redis.push(...redisEventConfigurations); + output.events.pusher.push(...pusherEventConfigurations); } return output; }