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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions demo/pkg/subgraphs/employees/subgraph/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,6 @@ type WorkSetup @shareable {

input FindEmployeeCriteria @oneOf {
id: Int
department: Department
title: String
department: Department @cost(weight: 17)
title: String @cost(weight: -3) # totally made-up example for testing
}
21 changes: 19 additions & 2 deletions docs-website/router/security/cost-control.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,25 @@ type Query {
}
```

When specified **on input object fields** — weights on nested input fields are accumulated
based on which fields the client provides in the query.
Only the fields present in the input contribute to the cost:

```graphql
input FindEmployeeCriteria {
id: Int
department: Department @cost(weight: 4)
title: String @cost(weight: 3)
}

type Query {
findEmployeesBy(criteria: FindEmployeeCriteria): [Employee]
}
```

Input object weights are evaluated per request, not cached with the query plan.
Two requests using the same query but different input fields produce different cost estimates.

When specified **on a field returning a list** — the list size multiplies the weight of this field:

```graphql
Expand Down Expand Up @@ -422,7 +441,5 @@ Use `@listSize` to provide realistic estimates for deeply nested structures.

## Features Not Yet Implemented

- `requireOneSlicingArgument` in `@listSize` for validation that exactly one slicing argument is provided
- **Weights on input object fields** — when an argument accepts an input object, the `@cost` weights on its nested fields are not yet accumulated recursively
- **Weights on directive arguments** — `@cost` placed on arguments of custom directives is not accounted for

2 changes: 1 addition & 1 deletion router-tests/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ require (
github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects v0.0.0-20250715110703-10f2e5f9c79e
github.com/wundergraph/cosmo/router v0.0.0-20260319123623-f186a0f724f6
github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.268
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.269
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/sdk/metric v1.39.0
Expand Down
4 changes: 2 additions & 2 deletions router-tests/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,8 @@ github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLV
github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw=
github.com/wundergraph/go-arena v1.1.0 h1:9+wSRkJAkA2vbYHp6s8tEGhPViRGQNGXqPHT0QzhdIc=
github.com/wundergraph/go-arena v1.1.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.268 h1:lP9kWLiPO2U3JuwpQ/WX7nTVfKeMtVab2G3DAFblVA0=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.268/go.mod h1:HjTAO/cuICpu31IfHY9qmSPygx6Gza7Wt9hTSReTI+A=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.269 h1:BFQ4/IFqucZsrmzs6vkqjHC5j2XV6rhnmoMLmtYMcp8=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.269/go.mod h1:HjTAO/cuICpu31IfHY9qmSPygx6Gza7Wt9hTSReTI+A=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
Expand Down
152 changes: 112 additions & 40 deletions router-tests/security/costs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package integration
import (
"context"
"net/http"
"strconv"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -53,7 +52,6 @@ func TestOperationCost(t *testing.T) {

// @listSize(assumedSize: 50) overrides EstimatedListSize; cost = 50 * 2 = 100
estimated := res.Response.Header.Get(core.CostEstimatedHeader)
require.NotEmpty(t, estimated, "estimated cost header should be present")
require.Equal(t, "100", estimated)

// the actual cost should not be calculated nor exposed
Expand Down Expand Up @@ -153,7 +151,6 @@ func TestOperationCost(t *testing.T) {

// @listSize(assumedSize: 50) on employees overrides EstimatedListSize(200)
estimated := res.Response.Header.Get(core.CostEstimatedHeader)
require.NotEmpty(t, estimated, "estimated cost header should be present")
require.Equal(t, "50", estimated)
})
})
Expand Down Expand Up @@ -277,11 +274,9 @@ func TestOperationCost(t *testing.T) {
// upc, repositoryURL, id: 0 (scalars)
// total: (10 + 13) × 10 = 230
estimated := res.Response.Header.Get(core.CostEstimatedHeader)
require.NotEmpty(t, estimated, "estimated cost header should be present")
require.Equal(t, "230", estimated)

actual := res.Response.Header.Get(core.CostActualHeader)
require.NotEmpty(t, actual, "actual cost header should be present")
require.Equal(t, "45", actual)

// Query 2: only employees-subgraph fields — Cosmo @cost(weight: 5) from employees applies
Expand All @@ -294,11 +289,60 @@ func TestOperationCost(t *testing.T) {
require.Equal(t, "150", estimated2)

actual2 := res2.Response.Header.Get(core.CostActualHeader)
require.NotEmpty(t, actual2, "actual cost header should be present")
require.Equal(t, "21", actual2)
})
})

t.Run("input object field cost weight on department", func(t *testing.T) {
t.Parallel()
testenv.Run(t, &testenv.Config{
ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) {
securityConfiguration.CostControl = &config.CostControl{
Enabled: true,
Mode: config.CostControlModeMeasure,
MaxEstimatedLimit: 10000,
EstimatedListSize: 10,
ExposeHeaders: true,
}
},
}, func(t *testing.T, xEnv *testenv.Environment) {
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `{ findEmployeesBy(criteria: { department: ENGINEERING }) { id } }`,
})
require.Contains(t, res.Body, `"data":`)

// 10*1 + 17
require.Equal(t, "27", res.Response.Header.Get(core.CostEstimatedHeader))
// 7*1 + 17
require.Equal(t, "24", res.Response.Header.Get(core.CostActualHeader))
})
})

t.Run("input object field cost weight on title", func(t *testing.T) {
t.Parallel()
testenv.Run(t, &testenv.Config{
ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) {
securityConfiguration.CostControl = &config.CostControl{
Enabled: true,
Mode: config.CostControlModeMeasure,
MaxEstimatedLimit: 10000,
EstimatedListSize: 10,
ExposeHeaders: true,
}
},
}, func(t *testing.T, xEnv *testenv.Environment) {
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `{ findEmployeesBy(criteria: { title: "Founder" }) { id } }`,
})
require.Contains(t, res.Body, `"data":`)

// 10 * 1 - 3
require.Equal(t, "7", res.Response.Header.Get(core.CostEstimatedHeader))
// 1 * 1 - 3
require.Equal(t, "0", res.Response.Header.Get(core.CostActualHeader))
})
})

t.Run("slicingArguments controls list size estimation", func(t *testing.T) {
t.Parallel()
testenv.Run(t, &testenv.Config{
Expand Down Expand Up @@ -714,27 +758,16 @@ func TestOperationCost(t *testing.T) {
// 1st request – plan cache MISS
res1 := xEnv.MakeGraphQLRequestOK(query)
require.Contains(t, res1.Body, `"data":`)

estimated1 := res1.Response.Header.Get(core.CostEstimatedHeader)
actual1 := res1.Response.Header.Get(core.CostActualHeader)
require.NotEmpty(t, estimated1, "first request should have estimated cost header")
require.NotEmpty(t, actual1, "first request should have actual cost header")
require.Equal(t, "MISS", res1.Response.Header.Get(core.ExecutionPlanCacheHeader))
require.Equal(t, "8", res1.Response.Header.Get(core.CostEstimatedHeader))
require.Equal(t, "8", res1.Response.Header.Get(core.CostActualHeader))

// 2nd request – plan cache HIT
res2 := xEnv.MakeGraphQLRequestOK(query)
require.Contains(t, res2.Body, `"data":`)

estimated2 := res2.Response.Header.Get(core.CostEstimatedHeader)
actual2 := res2.Response.Header.Get(core.CostActualHeader)
require.NotEmpty(t, estimated2, "second request should have estimated cost header")
require.NotEmpty(t, actual2, "second request should have actual cost header")

require.Equal(t, estimated1, estimated2,
"estimated cost differs between cache miss (%s) and cache hit (%s) ",
estimated1, estimated2)
require.Equal(t, actual1, actual2,
"actual cost differs between cache miss (%s) and cache hit (%s) ",
actual1, actual2)
require.Equal(t, "HIT", res2.Response.Header.Get(core.ExecutionPlanCacheHeader))
require.Equal(t, "8", res2.Response.Header.Get(core.CostEstimatedHeader))
require.Equal(t, "8", res2.Response.Header.Get(core.CostActualHeader))
})
})

Expand All @@ -759,8 +792,8 @@ func TestOperationCost(t *testing.T) {

estimated1 := res1.Response.Header.Get(core.CostEstimatedHeader)
actual1 := res1.Response.Header.Get(core.CostActualHeader)
require.NotEmpty(t, estimated1, "first request should have estimated cost header")
require.NotEmpty(t, actual1, "first request should have actual cost header")
require.Equal(t, "8", estimated1)
require.Equal(t, "8", actual1)

// 2nd request – plan cache HIT
query2 := testenv.GraphQLRequest{
Expand All @@ -771,15 +804,8 @@ func TestOperationCost(t *testing.T) {

estimated2 := res2.Response.Header.Get(core.CostEstimatedHeader)
actual2 := res2.Response.Header.Get(core.CostActualHeader)
require.NotEmpty(t, estimated2, "second request should have estimated cost header")
require.NotEmpty(t, actual2, "second request should have actual cost header")

require.Equal(t, estimated1, estimated2,
"estimated cost differs between cache miss (%s) and cache hit (%s) ",
estimated1, estimated2)
require.Equal(t, actual1, actual2,
"actual cost differs between cache miss (%s) and cache hit (%s) ",
actual1, actual2)
require.Equal(t, "8", estimated2)
require.Equal(t, "8", actual2)
})
})

Expand Down Expand Up @@ -885,6 +911,57 @@ func TestOperationCost(t *testing.T) {
require.Equal(t, int64(24), totalSum, "total estimated cost sum should be 3×8=24")
})
})

t.Run("input object field costs are consistent across cache hits for different queries", func(t *testing.T) {
t.Parallel()
testenv.Run(t, &testenv.Config{
ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) {
securityConfiguration.CostControl = &config.CostControl{
Enabled: true,
Mode: config.CostControlModeMeasure,
EstimatedListSize: 10,
ExposeHeaders: true,
}
},
}, func(t *testing.T, xEnv *testenv.Environment) {
// 1st request – plan cache MISS
resDept1 := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `{ findEmployeesBy(criteria: { department: ENGINEERING }) { id } }`,
})
require.Contains(t, resDept1.Body, `"data":`)
require.Equal(t, "MISS", resDept1.Response.Header.Get(core.ExecutionPlanCacheHeader))
require.Equal(t, "27", resDept1.Response.Header.Get(core.CostEstimatedHeader))
require.Equal(t, "24", resDept1.Response.Header.Get(core.CostActualHeader))

// 2nd request – plan cache HIT (same normalized query, different input field)
// Cost is recalculated per request based on actual input field values
resTitle1 := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `{ findEmployeesBy(criteria: { title: "Founder" }) { id } }`,
})
require.Contains(t, resTitle1.Body, `"data":`)
require.Equal(t, "HIT", resTitle1.Response.Header.Get(core.ExecutionPlanCacheHeader))
require.Equal(t, "7", resTitle1.Response.Header.Get(core.CostEstimatedHeader))
require.Equal(t, "0", resTitle1.Response.Header.Get(core.CostActualHeader))

// 3rd request – cache HIT, same input field as 1st, different value
resDept2 := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `{ findEmployeesBy(criteria: { department: MARKETING }) { id } }`,
})
require.Contains(t, resDept2.Body, `"data":`)
require.Equal(t, "HIT", resDept2.Response.Header.Get(core.ExecutionPlanCacheHeader))
require.Equal(t, "27", resDept2.Response.Header.Get(core.CostEstimatedHeader))
require.Equal(t, "20", resDept2.Response.Header.Get(core.CostActualHeader))

// 4th request – cache HIT, same input field as 2nd, different value
resTitle2 := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `{ findEmployeesBy(criteria: { title: "Director" }) { id } }`,
})
require.Contains(t, resTitle2.Body, `"data":`)
require.Equal(t, "HIT", resTitle2.Response.Header.Get(core.ExecutionPlanCacheHeader))
require.Equal(t, "7", resTitle2.Response.Header.Get(core.CostEstimatedHeader))
require.Equal(t, "0", resTitle2.Response.Header.Get(core.CostActualHeader))
})
})
})

t.Run("negative weights", func(t *testing.T) {
Expand Down Expand Up @@ -972,12 +1049,7 @@ func TestOperationCost(t *testing.T) {
})
require.Contains(t, res.Body, `"data":`)

estimated := res.Response.Header.Get(core.CostEstimatedHeader)
require.NotEmpty(t, estimated)
estimatedVal, err := strconv.Atoi(estimated)
require.NoError(t, err)
require.Equal(t, estimatedVal, 8, "estimated cost must not be negative")
require.Equal(t, estimatedVal, 8, "negative type weight should reduce cost below baseline of 18")
require.Equal(t, "8", res.Response.Header.Get(core.CostEstimatedHeader))
})
})

Expand Down
Loading
Loading