Skip to content

the HTTP API reuses restartable query ids as durable mutation idempotency keys, so a post-restart write is silently dropped or rejected #159

Description

@AyushbhaiPatel

Summary

An ordinary authenticated HTTP mutation gets its durable idempotency identity from its query_id. When the request body carries no query_id, the server assigns http-query-N from an in-process counter that restarts at 1 on every process start (src/client/http.rs:493, counter initialized at src/client/http.rs:192), and the shared query service falls back to exactly that value as the durable mutation identity (src/client/service.rs:2369-2375). The field's own documentation says a query id "is not a durable mutation identity" (src/client/service.rs:498-500). The shard then persists an idempotency record under the derived key with no TTL and no cleanup when the target entity is later deleted.

The result is that two different mutations in different process lifetimes can collide on the same durable key. Depending on the payload, the second mutation is either silently skipped while the client receives a success response, or rejected with an opaque internal error.

Reproduction

Against a source build at 6a2fbb1, single local node per the README, using the README quickstart's own request shape (no query_id in the body):

  1. As the first HTTP request of the process, CREATE (a {id: 1})-[:FOLLOWS]->(b {id: 2}). The server assigns http-query-1 and durably records the mutation under http-query-1.create.FOLLOWS.1.2.00000000000000000000.
  2. MATCH (a {id: 1})-[r:FOLLOWS]->(b {id: 2}) DELETE r, and confirm with a MATCH that the edge is gone.
  3. Restart graph-node against the same store.
  4. As the first HTTP request after the restart, send the identical CREATE. The counter has reset, so the request reuses http-query-1 and derives the same durable key as step 1.

Observed response to step 4:

{"query_id":"http-query-1","columns":[],"rows":[],"read_epoch":null,"next_cursor":null,"bookmark":"sgk:1:...:2"}

The request returns 200 and reports success, but the bookmark shows the storage sequence did not advance, and a MATCH confirms the edge was never created. The write was absorbed by the stale idempotency record from the previous process lifetime.

If the recreated edge carries a different payload instead, for example CREATE (a {id: 1})-[:FOLLOWS {since: 2020}]->(b {id: 2}), the brand-new request fails with {"error":{"code":"internal","message":"internal query execution error"}} (HTTP 500), and the server log shows the collision:

idempotency key conflict for relationship-create request key http-query-1.create.FOLLOWS.1.2.00000000000000000000: this key already stored a result for a different edge or payload

Scope

The derived shard keys embed the target entity, so a collision requires the same operation on the same entity under the same recycled key. That is exactly the delete-then-recreate pattern above, deterministic import jobs re-run after a restart, and writer failover: a freshly started node's counter also begins at 1, so the failover case behaves like the restart case. The collision is also reachable without a restart when a client supplies its own query_id and reuses it across logically different mutations on the same entity. Two principals sharing a cell can cross-deduplicate the same way, because the fallback path skips the principal scoping that caller-supplied idempotency keys receive (src/client/service.rs:2392-2405).

Bolt is already protected against all of this: when the caller supplies no key, it mints bolt-mutation-v1-{Ulid} per statement (src/client/bolt.rs:1336-1341), matching the architecture note that durable mutation identities are caller-scoped keys or generated values with global uniqueness (architecture.md:411). The HTTP adapter is the only transport that feeds a resettable counter into durable state.

I have a fix ready that mints the same class of globally unique identity for HTTP requests and will open a PR referencing this issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions