mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
Count every list member a graph answers against the request's budget
Red's re-review of v1.37.43: the budget counted resolver calls and objects, but a plain list a resolver returned costs no further call, so five objects whose field answered 2,500 rows each came back as 12,513 objects and 1.16 MB from about fourteen counted resolutions. Each list member answered, plain or object, now costs one, in value's list loop and project's; past fieldLimit the request is refused whole as before.
This commit is contained in:
@@ -81,7 +81,7 @@ zip's is the only GraphQL parser and executor in the estate. `document.go` parse
|
||||
|
||||
- A request is gathered whole before any field resolves: fragments spread once per selection set, as the specification's CollectFields does (a cycle or an unknown fragment is refused at parse, `gqlDoc.check`); `@skip`/`@include` decided, on inline fragments too; fields under one response key merged (two different fields under one key refused); variables put in place. A declared variable given nothing leaves its argument out; a reference to an undeclared one is refused; a given variable the operation does not declare is ignored.
|
||||
- The bounds are checked on the gathered tree and refuse whole, 400 with no data: depth 64 (`depthLimit`, fragments included), 64 root fields (`rootLimit`), 10000 fields in all (`fieldLimit`, which is what stops a fragment chain that doubles per link).
|
||||
- `fieldLimit` is also the RUNTIME budget (`budget`), because data makes a selection a poor guide to the work: a list of objects whose field answers more objects grows by the list's length per level. Every resolver call (a root field, an `Object.Field`) and every object reached (an answer, each list member) is counted across the whole request; past the bound the context every resolver holds is cancelled, no further call is made, and the request is refused whole (400, no data). Thirteen fully connected objects nested four deep (28,561 objects) is refused after at most 10,000 calls (`TestAFanOutPastTheBudgetIsRefused`). Plain data a resolver already returned is not counted: projecting it costs no resolver.
|
||||
- `fieldLimit` is also the RUNTIME budget (`budget`), because data makes a selection a poor guide to the work: a list of objects whose field answers more objects grows by the list's length per level. Every resolver call (a root field, an `Object.Field`), every object reached and every list member answered — plain data or object, in `value`'s list loop and `project`'s — is counted across the whole request; past the bound the context every resolver holds is cancelled, no further call is made, and the request is refused whole (400, no data). It bounds the ANSWER as well as the calls: thirteen fully connected objects nested four deep (28,561 objects) is refused after at most 10,000 calls (`TestAFanOutPastTheBudgetIsRefused`), and five objects whose field answers 2,500 plain rows each is refused too (`TestTheBudgetCountsWhatIsAnswered`; red measured 12,513 objects and 1.16 MB from ~14 calls before members were counted). So one list longer than about 10,000 members cannot be answered in one request; ask it for fewer.
|
||||
- A query's root fields resolve at once, `rootParallel` (8) at a time, a mutation's one after another in the order written. A panic in a field fails that field.
|
||||
- What a caller reads of a resolver's or an op's error is the message of an `HTTPError` with a 4xx status (`graph.told`). Any other error — a store's, a dial, a 5xx, a panic — is logged with the request id and the caller gets `internal error (request <id>)`. The POST handler takes the request's `X-Request-Id` or mints one, sets it on the request (so hops carry it) and on the answer. `Selection.Bind` refuses with 400s in the schema's words (`argument "limit" of "assertions" takes Int, not "x"`, `argument "key" of "entity" is required`); a local op's arguments get the same wording.
|
||||
- A foreign answer that is a Go struct, or a list of them, is projected against its type as a local Out is: an unknown field is named and `__typename` is the type's name. A list of structs is projected member by member against its element type, in local answers too (it used to refuse every `{ items { name } }` as a selection on a scalar).
|
||||
|
||||
+13
-7
@@ -86,10 +86,11 @@ const (
|
||||
// of them doubles at every link — so the selection is counted as the request
|
||||
// is gathered. And data makes the selection a poor guide to the work: a list
|
||||
// of objects whose field answers more objects grows by the list's length at
|
||||
// every level, from a few dozen bytes of query. So while it runs, every
|
||||
// resolver call (a root field, an object's field) and every object reached
|
||||
// (an answer, each member of a list) is counted against the same bound, and
|
||||
// the request is refused whole once the count passes it.
|
||||
// every level, and a field answering a long plain list costs one call and a
|
||||
// body of any size, from a few dozen bytes of query. So while it runs, every
|
||||
// resolver call (a root field, an object's field), every object reached and
|
||||
// every list member answered, plain or object, is counted against the same
|
||||
// bound, and the request is refused whole once the count passes it.
|
||||
fieldLimit = 10000
|
||||
// rootParallel bounds how many root fields of a query resolve at once. They
|
||||
// are independent, so they overlap; each may be a hop to another process, so
|
||||
@@ -349,8 +350,8 @@ func (a *App) graph(ctx context.Context, path, rid string, req GraphRequest, for
|
||||
if b.over.Load() {
|
||||
// Refused whole: what was resolved before the bound is a fragment of an
|
||||
// answer, and a client reading it could not tell what is missing.
|
||||
return refuse(fmt.Errorf("the request needs more than %d resolutions; select fewer fields, "+
|
||||
"or less below a list", fieldLimit))
|
||||
return refuse(fmt.Errorf("the request needs more than %d resolutions (resolver calls, objects "+
|
||||
"and list members answered); select fewer fields, or ask a list for fewer members", fieldLimit))
|
||||
}
|
||||
|
||||
data := make(map[string]any, len(roots))
|
||||
@@ -931,7 +932,7 @@ func (e *graph) value(ctx context.Context, v any, t reflect.Type, sel []Selectio
|
||||
}
|
||||
out := make([]any, rv.Len())
|
||||
for i := range out {
|
||||
if e.b.over.Load() {
|
||||
if !e.b.spend() {
|
||||
return nil
|
||||
}
|
||||
out[i] = e.value(ctx, rv.Index(i).Interface(), nil, sel, sub(path, i))
|
||||
@@ -1083,6 +1084,11 @@ func (e *graph) project(node any, t reflect.Type, sel []Selection, path []any) a
|
||||
}
|
||||
out := make([]any, len(n))
|
||||
for i, el := range n {
|
||||
// Each member answered is counted, plain or not: a list a resolver
|
||||
// already returned costs no further call, and it is still answer.
|
||||
if !e.b.spend() {
|
||||
return nil
|
||||
}
|
||||
out[i] = e.project(el, elem, sel, sub(path, i))
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -1440,3 +1440,63 @@ func TestAListOfObjectsProjects(t *testing.T) {
|
||||
t.Errorf("a field the type does not have was answered: %s", bad.messages())
|
||||
}
|
||||
}
|
||||
|
||||
// shelfHub is an object whose `rows` answer a plain list of a size the caller
|
||||
// names: the shape that let an answer grow without a resolver call to count.
|
||||
type shelfHub struct{ all int }
|
||||
|
||||
func (h shelfHub) Type() string { return "Hub" }
|
||||
|
||||
func (h shelfHub) Field(ctx context.Context, s Selection) (any, error) {
|
||||
switch s.Name {
|
||||
case "next":
|
||||
out := make([]shelfHub, h.all)
|
||||
for i := range out {
|
||||
out[i] = shelfHub{all: h.all}
|
||||
}
|
||||
return out, nil
|
||||
case "rows":
|
||||
var in struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := s.Bind(&in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]book, in.Limit)
|
||||
for i := range rows {
|
||||
rows[i] = book{Title: fmt.Sprint(i)}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
return nil, ErrBadRequest("no field " + s.Name)
|
||||
}
|
||||
|
||||
// ★ The budget counts the ANSWER, not only the calls. A plain list a resolver
|
||||
// returns costs no further call, so a handful of calls can answer an enormous
|
||||
// body: red's measurement was 12,513 objects and 1.16 MB from ~14 counted
|
||||
// resolutions. Every member of a list answered — plain or object — is counted.
|
||||
func TestTheBudgetCountsWhatIsAnswered(t *testing.T) {
|
||||
app := mountFor(t, "type Query { root: Hub }", func(ctx context.Context, kind string, s Selection) (any, error) {
|
||||
return shelfHub{all: 5}, nil
|
||||
})
|
||||
big := askAt(t, app, "/v1/g", `{ root { next { rows(limit: 2500) { title } } } }`)
|
||||
if big.code != http.StatusBadRequest || big.Data != nil || !strings.Contains(big.messages(), "list members") {
|
||||
t.Fatalf("12,500 rows under five calls were answered (%d): %s", big.code, big.messages())
|
||||
}
|
||||
small := askAt(t, app, "/v1/g", `{ root { next { rows(limit: 100) { title } } } }`)
|
||||
if len(small.Errors) != 0 || len(small.Data["root"].(map[string]any)["next"].([]any)) != 5 {
|
||||
t.Fatalf("500 rows were refused: %s", small.messages())
|
||||
}
|
||||
|
||||
// One resolver answering one long plain list is the same answer, and the same bound.
|
||||
flat := mountFor(t, "type Query { a: [Book] }", func(ctx context.Context, kind string, s Selection) (any, error) {
|
||||
rows := make([]map[string]any, fieldLimit+1)
|
||||
for i := range rows {
|
||||
rows[i] = map[string]any{"title": "x"}
|
||||
}
|
||||
return rows, nil
|
||||
})
|
||||
if r := askAt(t, flat, "/v1/g", `{ a { title } }`); r.code != http.StatusBadRequest || r.Data != nil {
|
||||
t.Fatalf("a %d-member list was answered (%d)", fieldLimit+1, r.code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user