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):
- 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.
MATCH (a {id: 1})-[r:FOLLOWS]->(b {id: 2}) DELETE r, and confirm with a MATCH that the edge is gone.
- Restart
graph-node against the same store.
- 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.
Summary
An ordinary authenticated HTTP mutation gets its durable idempotency identity from its
query_id. When the request body carries noquery_id, the server assignshttp-query-Nfrom an in-process counter that restarts at 1 on every process start (src/client/http.rs:493, counter initialized atsrc/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_idin the body):CREATE (a {id: 1})-[:FOLLOWS]->(b {id: 2}). The server assignshttp-query-1and durably records the mutation underhttp-query-1.create.FOLLOWS.1.2.00000000000000000000.MATCH (a {id: 1})-[r:FOLLOWS]->(b {id: 2}) DELETE r, and confirm with aMATCHthat the edge is gone.graph-nodeagainst the same store.CREATE. The counter has reset, so the request reuseshttp-query-1and derives the same durable key as step 1.Observed response to step 4:
The request returns 200 and reports success, but the bookmark shows the storage sequence did not advance, and a
MATCHconfirms 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: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_idand 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.