Skip to content

Commit c8d0f7a

Browse files
xiangfu0claude
andcommitted
Add experimental openCypher graph query frontend for Pinot
Implements an experimental graph query feature that translates a subset of openCypher into SQL JOINs and executes via Pinot's multi-stage engine (MSE). This enables graph-style traversal queries over existing Pinot tables without introducing native graph runtime operators. Supported Cypher syntax: - MATCH with 1-hop and 2-hop traversals (outgoing, incoming, undirected) - WHERE with comparison operators, AND/OR/NOT, string predicates (STARTS WITH, ENDS WITH, CONTAINS), IN lists - RETURN with DISTINCT, column aliases (AS), aggregations (COUNT, SUM, AVG, MIN, MAX) with auto GROUP BY - ORDER BY (ASC/DESC), SKIP, LIMIT Architecture: - New pinot-graph module (pinot-graph-spi + pinot-graph-planner) - Hand-written recursive descent Cypher parser → IR → SQL generator - POST /query/graph broker endpoint (behind pinot.graph.enabled flag) - Graph schema passed inline in request JSON (vertex/edge → table mapping) Tests: 233 total (211 unit + 12 handler + 10 E2E integration with 2 servers) Closes: #17935 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1329e1f commit c8d0f7a

24 files changed

Lines changed: 7908 additions & 0 deletions

File tree

pinot-broker/pom.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@
4545
<groupId>org.apache.pinot</groupId>
4646
<artifactId>pinot-timeseries-planner</artifactId>
4747
</dependency>
48+
<dependency>
49+
<groupId>org.apache.pinot</groupId>
50+
<artifactId>pinot-graph-spi</artifactId>
51+
</dependency>
52+
<dependency>
53+
<groupId>org.apache.pinot</groupId>
54+
<artifactId>pinot-graph-planner</artifactId>
55+
</dependency>
4856
<dependency>
4957
<groupId>org.slf4j</groupId>
5058
<artifactId>slf4j-api</artifactId>

pinot-broker/src/main/java/org/apache/pinot/broker/api/resources/PinotClientRequest.java

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
import org.apache.pinot.broker.api.HttpRequesterIdentity;
6363
import org.apache.pinot.broker.broker.BrokerAdminApiApplication;
6464
import org.apache.pinot.broker.requesthandler.BrokerRequestHandler;
65+
import org.apache.pinot.broker.requesthandler.GraphRequestHandler;
6566
import org.apache.pinot.common.metrics.BrokerMeter;
6667
import org.apache.pinot.common.metrics.BrokerMetrics;
6768
import org.apache.pinot.common.response.BrokerResponse;
@@ -136,6 +137,8 @@ public class PinotClientRequest {
136137
@Named(BrokerAdminApiApplication.BROKER_INSTANCE_ID)
137138
private String _instanceId;
138139

140+
private volatile GraphRequestHandler _graphRequestHandler;
141+
139142
@GET
140143
@ManagedAsync
141144
@Produces(MediaType.APPLICATION_JSON)
@@ -408,6 +411,66 @@ public void processTimeSeriesInstantQuery(@Suspended AsyncResponse asyncResponse
408411
asyncResponse.resume(Response.ok().entity("{}").build());
409412
}
410413

414+
@POST
415+
@ManagedAsync
416+
@Produces(MediaType.APPLICATION_JSON)
417+
@Path("query/graph")
418+
@ApiOperation(value = "Query Pinot using graph (Cypher) queries (experimental)")
419+
@ApiResponses(value = {
420+
@ApiResponse(code = 200, message = "Query response"),
421+
@ApiResponse(code = 400, message = "Graph query feature is disabled or invalid request"),
422+
@ApiResponse(code = 500, message = "Internal Server Error")
423+
})
424+
@ManualAuthorization
425+
public void processGraphQuery(String requestBody, @Suspended AsyncResponse asyncResponse,
426+
@Context org.glassfish.grizzly.http.server.Request requestContext,
427+
@Context HttpHeaders httpHeaders) {
428+
try {
429+
boolean graphEnabled = _brokerConf.getProperty(CommonConstants.Graph.CONFIG_OF_GRAPH_QUERY_ENABLED,
430+
CommonConstants.Graph.DEFAULT_GRAPH_QUERY_ENABLED);
431+
if (!graphEnabled) {
432+
asyncResponse.resume(Response.status(Response.Status.BAD_REQUEST)
433+
.entity("{\"error\": \"Graph query feature is not enabled. "
434+
+ "Set 'pinot.graph.enabled=true' in broker configuration to enable it.\"}")
435+
.type(MediaType.APPLICATION_JSON)
436+
.build());
437+
return;
438+
}
439+
440+
JsonNode requestJson = JsonUtils.stringToJsonNode(requestBody);
441+
if (!requestJson.has("query")) {
442+
throw new IllegalArgumentException("Payload is missing the required 'query' field (Cypher query string)");
443+
}
444+
if (!requestJson.has("graphSchema")) {
445+
throw new IllegalArgumentException("Payload is missing the required 'graphSchema' field");
446+
}
447+
448+
String cypherQuery = requestJson.get("query").asText();
449+
JsonNode graphSchemaJson = requestJson.get("graphSchema");
450+
451+
GraphRequestHandler graphHandler = getOrCreateGraphRequestHandler();
452+
453+
try (RequestScope requestScope = Tracing.getTracer().createRequestScope()) {
454+
requestScope.setRequestArrivalTimeMillis(System.currentTimeMillis());
455+
BrokerResponse brokerResponse = graphHandler.handleRequest(cypherQuery, graphSchemaJson,
456+
makeHttpIdentity(requestContext), requestScope, httpHeaders);
457+
brokerResponse.emitBrokerResponseMetrics(_brokerMetrics);
458+
asyncResponse.resume(getPinotQueryResponse(brokerResponse, httpHeaders, _brokerMetrics));
459+
}
460+
} catch (IllegalArgumentException e) {
461+
LOGGER.warn("Invalid graph query request", e);
462+
asyncResponse.resume(Response.status(Response.Status.BAD_REQUEST)
463+
.entity("{\"error\": \"" + e.getMessage().replace("\"", "'") + "\"}")
464+
.type(MediaType.APPLICATION_JSON)
465+
.build());
466+
} catch (Exception e) {
467+
LOGGER.error("Caught exception while processing graph query request", e);
468+
_brokerMetrics.addMeteredGlobalValue(BrokerMeter.UNCAUGHT_POST_EXCEPTIONS, 1L);
469+
asyncResponse.resume(new WebApplicationException(e,
470+
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build()));
471+
}
472+
}
473+
411474
@POST
412475
@Produces(MediaType.APPLICATION_JSON)
413476
@Path("query/compare")
@@ -554,6 +617,20 @@ public Map<Long, String> getRunningQueries() {
554617
}
555618
}
556619

620+
private GraphRequestHandler getOrCreateGraphRequestHandler() {
621+
GraphRequestHandler handler = _graphRequestHandler;
622+
if (handler == null) {
623+
synchronized (this) {
624+
handler = _graphRequestHandler;
625+
if (handler == null) {
626+
handler = new GraphRequestHandler(_requestHandler);
627+
_graphRequestHandler = handler;
628+
}
629+
}
630+
}
631+
return handler;
632+
}
633+
557634
private BrokerResponse executeSqlQuery(ObjectNode sqlRequestJson, HttpRequesterIdentity httpRequesterIdentity,
558635
boolean onlyDql, HttpHeaders httpHeaders)
559636
throws Exception {
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.pinot.broker.requesthandler;
20+
21+
import com.fasterxml.jackson.databind.JsonNode;
22+
import com.fasterxml.jackson.databind.node.ObjectNode;
23+
import java.util.HashMap;
24+
import java.util.Iterator;
25+
import java.util.Map;
26+
import javax.annotation.Nullable;
27+
import javax.ws.rs.core.HttpHeaders;
28+
import org.apache.pinot.common.response.BrokerResponse;
29+
import org.apache.pinot.graph.planner.CypherToSqlTranslator;
30+
import org.apache.pinot.graph.spi.GraphSchemaConfig;
31+
import org.apache.pinot.spi.auth.broker.RequesterIdentity;
32+
import org.apache.pinot.spi.trace.RequestContext;
33+
import org.apache.pinot.spi.utils.CommonConstants.Broker.Request;
34+
import org.apache.pinot.spi.utils.JsonUtils;
35+
import org.slf4j.Logger;
36+
import org.slf4j.LoggerFactory;
37+
38+
39+
/**
40+
* Handles graph (Cypher) query requests by translating them to SQL and delegating
41+
* to the multi-stage query engine (MSE) broker request handler.
42+
*
43+
* <p>This handler is instantiated only when the {@code pinot.graph.enabled} feature flag
44+
* is set to {@code true}. It is thread-safe because it delegates all state to the
45+
* underlying {@link BrokerRequestHandler}.
46+
*/
47+
public class GraphRequestHandler {
48+
private static final Logger LOGGER = LoggerFactory.getLogger(GraphRequestHandler.class);
49+
50+
private final BrokerRequestHandler _requestHandler;
51+
52+
public GraphRequestHandler(BrokerRequestHandler requestHandler) {
53+
_requestHandler = requestHandler;
54+
}
55+
56+
/**
57+
* Handles a graph query request by translating Cypher to SQL and delegating to the MSE handler.
58+
*
59+
* @param cypherQuery the Cypher query string
60+
* @param graphSchemaJson the inline graph schema as a JSON node
61+
* @param requesterIdentity the identity of the requester, may be null
62+
* @param requestContext the request context for tracing
63+
* @param httpHeaders the HTTP headers from the original request, may be null
64+
* @return the broker response from executing the translated SQL query
65+
* @throws Exception if translation or query execution fails
66+
*/
67+
public BrokerResponse handleRequest(String cypherQuery, JsonNode graphSchemaJson,
68+
@Nullable RequesterIdentity requesterIdentity, RequestContext requestContext,
69+
@Nullable HttpHeaders httpHeaders)
70+
throws Exception {
71+
// Parse the graph schema from JSON
72+
GraphSchemaConfig schemaConfig = parseGraphSchema(graphSchemaJson);
73+
74+
// Translate Cypher to SQL
75+
String sql;
76+
try {
77+
CypherToSqlTranslator translator = new CypherToSqlTranslator(schemaConfig);
78+
sql = translator.translate(cypherQuery);
79+
} catch (Exception e) {
80+
LOGGER.error("Failed to translate Cypher query to SQL: {}", cypherQuery, e);
81+
throw new IllegalArgumentException("Failed to translate Cypher query to SQL: " + e.getMessage(), e);
82+
}
83+
LOGGER.debug("Translated Cypher query [{}] to SQL [{}]", cypherQuery, sql);
84+
85+
// Build a SQL request JSON and delegate to the request handler.
86+
// The translated SQL uses JOINs, which requires the multi-stage engine.
87+
ObjectNode sqlRequestJson = JsonUtils.newObjectNode();
88+
sqlRequestJson.put(Request.SQL, sql);
89+
90+
// Force multi-stage engine for graph queries since they require JOINs.
91+
// Query options are passed as a semicolon-delimited string.
92+
sqlRequestJson.put(Request.QUERY_OPTIONS,
93+
Request.QueryOptionKey.USE_MULTISTAGE_ENGINE + "=true");
94+
95+
return _requestHandler.handleRequest(sqlRequestJson, null, requesterIdentity, requestContext,
96+
httpHeaders);
97+
}
98+
99+
/**
100+
* Parses the inline graph schema JSON into a {@link GraphSchemaConfig}.
101+
*
102+
* <p>Expected JSON format:</p>
103+
* <pre>
104+
* {
105+
* "vertexLabels": {
106+
* "User": {
107+
* "tableName": "users",
108+
* "primaryKey": "id",
109+
* "properties": { "id": "id", "name": "user_name" }
110+
* }
111+
* },
112+
* "edgeLabels": {
113+
* "FOLLOWS": {
114+
* "tableName": "follows",
115+
* "sourceVertexLabel": "User",
116+
* "sourceKey": "follower_id",
117+
* "targetVertexLabel": "User",
118+
* "targetKey": "followee_id",
119+
* "properties": {}
120+
* }
121+
* }
122+
* }
123+
* </pre>
124+
*/
125+
static GraphSchemaConfig parseGraphSchema(JsonNode schemaJson) {
126+
if (schemaJson == null) {
127+
throw new IllegalArgumentException("Graph schema JSON must not be null");
128+
}
129+
130+
// Parse vertex labels
131+
Map<String, GraphSchemaConfig.VertexLabel> vertexLabels = new HashMap<>();
132+
JsonNode vertexLabelsNode = schemaJson.get("vertexLabels");
133+
if (vertexLabelsNode != null && vertexLabelsNode.isObject()) {
134+
Iterator<Map.Entry<String, JsonNode>> fields = vertexLabelsNode.fields();
135+
while (fields.hasNext()) {
136+
Map.Entry<String, JsonNode> entry = fields.next();
137+
String labelName = entry.getKey();
138+
JsonNode labelNode = entry.getValue();
139+
140+
String tableName = getRequiredField(labelNode, "tableName", "vertexLabels." + labelName);
141+
String primaryKey = getRequiredField(labelNode, "primaryKey", "vertexLabels." + labelName);
142+
Map<String, String> properties = parseStringMap(labelNode.get("properties"));
143+
144+
vertexLabels.put(labelName, new GraphSchemaConfig.VertexLabel(tableName, primaryKey, properties));
145+
}
146+
}
147+
148+
// Parse edge labels
149+
Map<String, GraphSchemaConfig.EdgeLabel> edgeLabels = new HashMap<>();
150+
JsonNode edgeLabelsNode = schemaJson.get("edgeLabels");
151+
if (edgeLabelsNode != null && edgeLabelsNode.isObject()) {
152+
Iterator<Map.Entry<String, JsonNode>> fields = edgeLabelsNode.fields();
153+
while (fields.hasNext()) {
154+
Map.Entry<String, JsonNode> entry = fields.next();
155+
String labelName = entry.getKey();
156+
JsonNode labelNode = entry.getValue();
157+
158+
String tableName = getRequiredField(labelNode, "tableName", "edgeLabels." + labelName);
159+
String sourceVertexLabel = getRequiredField(labelNode, "sourceVertexLabel", "edgeLabels." + labelName);
160+
String sourceKey = getRequiredField(labelNode, "sourceKey", "edgeLabels." + labelName);
161+
String targetVertexLabel = getRequiredField(labelNode, "targetVertexLabel", "edgeLabels." + labelName);
162+
String targetKey = getRequiredField(labelNode, "targetKey", "edgeLabels." + labelName);
163+
Map<String, String> properties = parseStringMap(labelNode.get("properties"));
164+
165+
edgeLabels.put(labelName, new GraphSchemaConfig.EdgeLabel(
166+
tableName, sourceVertexLabel, sourceKey, targetVertexLabel, targetKey, properties));
167+
}
168+
}
169+
170+
if (vertexLabels.isEmpty()) {
171+
throw new IllegalArgumentException("Graph schema must define at least one vertex label");
172+
}
173+
if (edgeLabels.isEmpty()) {
174+
throw new IllegalArgumentException("Graph schema must define at least one edge label");
175+
}
176+
177+
return new GraphSchemaConfig(vertexLabels, edgeLabels);
178+
}
179+
180+
private static String getRequiredField(JsonNode node, String fieldName, String parentPath) {
181+
JsonNode field = node.get(fieldName);
182+
if (field == null || field.isNull()) {
183+
throw new IllegalArgumentException("Missing required field '" + fieldName + "' in " + parentPath);
184+
}
185+
return field.asText();
186+
}
187+
188+
private static Map<String, String> parseStringMap(JsonNode node) {
189+
Map<String, String> map = new HashMap<>();
190+
if (node != null && node.isObject()) {
191+
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
192+
while (fields.hasNext()) {
193+
Map.Entry<String, JsonNode> entry = fields.next();
194+
map.put(entry.getKey(), entry.getValue().asText());
195+
}
196+
}
197+
return map;
198+
}
199+
}

0 commit comments

Comments
 (0)