Skip to content

planner: reserve a variable name for dependent step node IDs - #17

Merged
cideM merged 1 commit into
masterfrom
fix/node-id-variable-collision
Sep 3, 2026
Merged

cideM merged 1 commit into
masterfrom
fix/node-id-variable-collision

Conversation

@cideM

@cideM cideM commented Sep 2, 2026 •

Copy link
Copy Markdown

This is a bug fix for something I discovered while working on other features. Definitely something I'd want to upstream.

EDIT: I created a PR upstream as well nautilus#238

Summary

Fix bug: when a query spanning two services declared a variable named $id and also used it as a field argument in the second service, every dependent node(id:) step overwrote the client's value with the ID of the object being resolved. The client's $id now keeps its own value.

The ID variable of dependent step queries is now named _gateway_node_id instead of id.

Any client query that declares a variable _gateway_node_id is rejected.

Longer Explanation

I believe that there is a bug hiding in the way dependent steps are planned and executed.

Let's take the query below:

query ($id: ID!, $category: String!) {
  allUsers {
    favoriteCatPhoto(category: $category, owner: $id) {
      URL
    }
  }
}

favoriteCatPhoto is owned by a second service. The idea behind the request is that owner: $id acts like a filter, so we only get cat photos where the owner is set to one specific ID (keep that in mind).

At runtime, the above query results in one query to the service that owns allUsers and from that we generate multiple, dependent steps, one for each user. Those dependent steps are sent to the second service in the form of node(id: ...) queries.

Those dependent step queries obviously need a user ID. What the code currently does is it checks if the query, as sent by the client, already has an id variable. If so, this variable is re-used.

// if the original query didn't have an id arg we need to add one
if variables.ForName("id") == nil {
	operation.VariableDefinitions = append(operation.VariableDefinitions, &ast.VariableDefinition{
		Variable: "id",
		Type:     ast.NonNullNamedType("ID", &ast.Position{}),
	})
}

And on the execution side, the node ID is written into the variables under that same name for every dependent step:

// execute.go, executeOneStep
// save the id as a variable to the query
variables["id"] = pointData.ID

In this case, this means that the existing id variable will be populated with each user's ID.

In the end, we generate the following query:

query ($category: String!, $id: ID!) {
  node(id: $id) {
    ... on User {
      favoriteCatPhoto(category: $category, owner: $id) {
        URL
      }
    }
  }
}

Both node(id: $id) and owner: $id now read from the same variable, and the executor sets it to u1, then u2, then u3, one dependent step per user.

The problem here is that the $id variable is now ambiguous as to what it does: originally it was meant to be a single user ID, for the filtering, but now it also carries the meaning of "each user's ID". As a result, the client gets back the wrong result.

There's a second error mode: if the client declares $id: String!, the reused declaration keeps the client's String! type, so the remote rejects node(id: $id) against node(id: ID!).

My proposed fix is to use a variable name that's unlikely to conflict, like _gateway_node_id (TestPlanQuery_clientIDVariableSurvivesNodeStep.). Also add a check that the client query does not already use that variable (TestPlanQuery_rejectsReservedNodeIDVariable).

@cideM
cideM requested a review from a team as a code owner September 2, 2026 13:24
@coveralls

coveralls commented Sep 2, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 33768000178

Coverage increased (+0.07%) to 91.352%

Details

  • Coverage increased (+0.07%) from the base build.
  • Patch coverage: 1 uncovered change across 1 file (17 of 18 lines covered, 94.44%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
plan.go 17 16 94.12%
Total (2 files) 18 17 94.44%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 3018
Covered Lines: 2757
Line Coverage: 91.35%
Coverage Strength: 395.38 hits per line

💛 - Coveralls

Base automatically changed from sync/upstream-catchup-2026-09 to master September 2, 2026 13:59
A query whose fields span two services is split into a plan where the
second step re-enters the remote service through node(id: ...), once per
object the first step returned. The planner declared that argument's
variable as $id, and only when the client had not already declared one
by that name; otherwise it reused the client's declaration. The executor
then assigned the node ID under the same name on every dependent step.

$id is an ordinary name for a client to choose, so the two collided.
Given:

    query ($id: ID!, $category: String!) {
      allUsers {
        favoriteCatPhoto(category: $category, owner: $id) { URL }
      }
    }

where favoriteCatPhoto is owned by a second service, the dependent step
ran once per user with $id set to that user's ID. The owner argument,
which the client meant to pin to a single ID, was silently rewritten to
u1, then u2, then u3. Nothing surfaced an error: the query is valid and
both node(id:) and owner take an ID, so every request succeeded and
returned the wrong photos.

Reusing the client's declaration also inherited its type. A client
declaring $id as String! and using it for a String! argument, which is
legal on its own terms, produced node(id: $id) against node(id: ID!) and
the remote service rejected the entire step.

Give the gateway a name of its own, _gateway_node_id. Since it can never
be one of the client's variables, declare it unconditionally rather than
conditionally reusing whatever is there, and reject operations that
declare it instead of overwriting them silently.
@cideM
cideM force-pushed the fix/node-id-variable-collision branch from ce3a182 to f4eed97 Compare September 3, 2026 14:37
@cideM
cideM merged commit 2194a3a into master Sep 3, 2026
8 checks passed
@cideM
cideM deleted the fix/node-id-variable-collision branch September 3, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants