Life of a request¶
This page follows one POST /graphql from the socket to the response. The machine has five
phases. Two run in C (document, plan), three in Lua (HTTP, execution, log).
stateDiagram-v2
direction TB
[*] --> HTTP: location = /graphql
HTTP --> Rejected: 405 · 406 · 413 · 415 · 400
HTTP --> Document: query, operationName, variables
Document --> RequestError: syntax or validation errors
Document --> Plan: valid document
Plan --> RequestError: operation or variable errors
Plan --> Rejected: mutation over GET (405)
Plan --> Execute: flat plan
state Execute {
[*] --> Resolve
Resolve --> Round: fetches queued
Round --> Resolve: results materialized
Resolve --> Complete: queue empty
Complete --> [*]
}
Execute --> Respond: 200, data (+ errors)
RequestError --> Respond: 400 or 200, errors only
Respond --> Log
Rejected --> Log
Log --> [*]
| Phase | Where | Input → output | Fails with |
|---|---|---|---|
| HTTP | gqlgw.http (Lua) |
request → {query, operationName, variables} |
4xx, no data |
| Document | libgql (C) |
query text → validated document (cached per worker) | request error |
| Plan | libgql (C) |
document + operation + variables → flat plan | request error |
| Execute | gqlgw.exec, gqlgw.fetch (Lua) |
plan + upstream JSON → response JSON | field errors, partial data |
| Log | gqlgw.log (Lua) |
request context → metrics, $gql_* variables |
never |
1. HTTP¶
flowchart TB
a{Accept} -- "graphql-response+json<br/>or none" --> m1[media = GQL]
a -- "application/json, */*" --> m2[media = JSON]
a -- other --> e406([406])
m1 & m2 --> meth{method}
meth -- GET --> g["query, operationName, variables<br/>from the URI"]
meth -- POST --> ct{"Content-Type<br/>application/json?"}
meth -- other --> e405([405])
ct -- no --> e415([415])
ct -- yes --> sz{"body ≤ max_body?"}
sz -- no --> e413([413])
sz -- yes --> js["JSON object with a string query"]
js -- invalid --> e400([400])
js -- valid --> doc[[document]]
The media type decides how request errors are reported later: 400 with
application/graphql-response+json, 200 with application/json (the GraphQL-over-HTTP
convention). See GraphQL over HTTP.
2. Document: parse and validate (C)¶
flowchart TB
q[query text] --> lru{"per-worker LRU<br/>(key = the text)"}
lru -- hit --> doc[gql_doc]
lru -- miss --> lex["lexer → parser<br/>limits: query_bytes, tokens, depth"]
lex --> val["validation: every rule of<br/>the spec, section 5"]
val --> doc
doc --> ok{ok?}
ok -- no --> err(["{errors} : request error"])
ok -- yes --> plan[[plan]]
Validated documents are cached per worker (512 entries), invalid ones too, so a client that repeats a query skips parsing and validation. The validation messages are graphql-js 17's, without the "Did you mean" suggestions.
3. Plan: from a document to flat arrays (C)¶
gql_plan_new(schema, doc, operationName, variables):
- selects the operation (
operationName, or the only one); - coerces the variables and every argument (defaults, input objects,
@oneOf, lists); - runs CollectFields: fragments are inlined,
@skip/@includeevaluated, fields merged by response key; - emits one selection per (concrete object type, merged selection set). Selections are memoized, so the plan is a DAG and fragment-heavy queries stay small.
For { posts(limit: 3) { title author { name } } }:
flowchart LR
s0["sel 0 · Query"] --> f0["posts<br/>args {limit: 3}<br/>route: @fetch posts"]
f0 --> s1["sel 1 · Post"]
s1 --> f1[title]
s1 --> f2["author<br/>route: @fetch + @batch users"]
f2 --> s2["sel 2 · User"]
s2 --> f3[name]
The plan is a few arrays of uint32 (selections, fields, abstract cases) plus a string area for
the response keys and the coerced arguments as JSON. Lua reads them through FFI, without copying.
Introspection fields are rendered to JSON by the C side at this step.
4. Execute: resolution by rounds, then completion (Lua)¶
Execution has two passes. Resolution builds a tree of nodes over the raw upstream JSON and fetches what is missing. Completion walks that tree in plan order and writes the response.
Resolution: what happens to each field¶
flowchart TB
f[plan field of a node] --> t{kind}
t -- "__typename" --> tn[marker: written at completion]
t -- introspection --> raw[JSON already rendered by C]
t -- "has @fetch" --> q["PENDING + queued for the next round"]
t -- "plain field" --> read["value = parent JSON [@from key or field name]"]
read --> mat{materialize}
mat -- "null / missing" --> nul[null]
mat -- list --> each[materialize every item]
mat -- object --> node["new node: resolve its selection"]
mat -- "union / interface" --> disc["type = value[discriminator]<br/>(default __typename)"]
disc --> node
mat -- "scalar / enum" --> leaf[kept raw, coerced at completion]
A field without @fetch never costs a request: it is read from the JSON that the parent
fetch returned. A field with @fetch is queued with its parent node, so its templates can read
the parent's raw JSON ({parent.authorId}).
Rounds¶
flowchart TB
start([root node resolved]) --> qe{queue empty?}
qe -- yes --> done([completion])
qe -- no --> lim{"rounds < limits.rounds?"}
lim -- no --> toomany["every queued field:<br/>error 'Too many nested fetches'"] --> done
lim -- yes --> r["rounds += 1<br/>take the whole queue"]
r --> dispatch["gqlgw.fetch.dispatch(items)<br/>cache · dedup · batching · capture_multi"]
dispatch --> fill["fill every item:<br/>materialize its JSON (may queue children)"]
fill --> qe
Every fetch that is ready leaves in the same round, whatever its depth in the response. The number of rounds is the depth of the fetch chain, not the size of the lists: 100 posts and their 100 authors take 2 rounds. What happens inside a round is detailed in Rounds and batching.
Completion: coercion and null propagation¶
Completion walks the plan in order, so the response keys follow the query. For each value:
- leaves are coerced to their type (
Intmust fit 32 bits,IDaccepts strings and integers, enums must be known values, custom scalars pass through); - errors (failed fetch, failed coercion) become an entry in
errorswithpathandlocations, and the value becomesnull; - a
nullin a non-null position propagates to the nearest nullable parent, as the spec requires.
A real example from the example gateway: post 60 has author_id: "999", which the users
service does not know, and the schema says author: User! inside posts: [Post!]!.
flowchart BT
a["posts[59].author = null<br/>(User! : violation, error recorded)"] --> p["posts[59] → null<br/>(Post! : violation)"]
p --> l["posts → null<br/>([Post!]! : violation)"]
l --> d["data → null"]
{"errors":[{"message":"Cannot return null for non-nullable field Post.author.",
"locations":[{"line":1,"column":25}],"path":["posts",59,"author"]}],
"data":null}
With author: User (nullable), only that author would be null and the other 59 posts would
be returned. Choose non-null types for what the services guarantee.
5. Log¶
The log phase (log_by_lua, generated by gqlc render) records the request metrics:
operation, type, result, duration, rounds, error counts. It also fills the variables of the
gqlgw access log format: rounds=2 sub=2 cache=l1=0;l2=0;l3=0;miss=3.
A complete example¶
{ posts(limit: 3) { title author { name } } }, with Post.author batched and User cached:
sequenceDiagram
autonumber
participant C as client
participant S as gqlgw.serve
participant L as libgql (C)
participant X as exec + fetch
participant K as cache
participant P as posts service
participant U as users service
C->>S: POST /graphql
S->>L: gql_doc_new (LRU miss) then gql_plan_new
L-->>S: plan: posts → title, author → name
S->>X: execute
Note over X: round 1: Query.posts queued
X->>P: GET /posts?limit=3 (capture_multi)
P-->>X: [{id 1, author_id 2}, {id 2, author_id 3}, {id 3, author_id 4}]
Note over X: 3 Post nodes, 3 Post.author queued
Note over X: round 2: same field, same batch path → one group
X->>K: get users:/users/2, /users/3, /users/4
K-->>X: 3 misses
X->>U: GET /users?ids=2,3,4
U-->>X: [{id 2}, {id 3}, {id 4}]
X->>K: set the 3 users (under their single-fetch keys)
Note over X: queue empty → completion
X-->>S: {"data":{"posts":[...]}}
S-->>C: 200, X-GQL-Rounds: 2, X-GQL-Subrequests: 2
The same query a second time: round 1 fetches the posts, round 2 finds the three users in the cache. One subrequest.
Mutations¶
Root fields of a mutation run one after the other, as the spec requires: the first field's
fetch and its whole subtree complete before the second field starts. Below the root, the rounds
work as for queries. Mutations are never cached and cannot be sent with GET.
flowchart LR
m1["renameUser: PATCH /users/1"] --> s1[its subtree, by rounds] --> m2["createPost: POST /posts"] --> s2[its subtree, by rounds]
Limits¶
Limit ([limits]) |
Default | Enforced in | When exceeded |
|---|---|---|---|
query_bytes, tokens, depth |
65536, 20000, 32 | C, parsing | request error |
errors |
100 | C, validation | validation stops after that many errors |
plan_fields, memory |
20000, 16m | C, plan | request error |
rounds |
16 | Lua | field errors on the remaining fetches |
fetches |
1000 | Lua | field errors on the round that would exceed it |
concurrency |
64 | Lua | subrequests sent in slices of 64 |
response_bytes |
16m | Lua | field error for that response |
[gateway] max_body |
1m | nginx + Lua | 413 |