Skip to content

Commit 99acfad

Browse files
committed
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 to IR to SQL generator - POST /query/graph broker endpoint (behind pinot.graph.enabled flag) - Graph schema passed inline in request JSON (vertex/edge to table mapping) Tests: 233 total (211 unit + 12 handler + 10 E2E integration with 2 servers) Closes: #17935
1 parent 2892236 commit 99acfad

25 files changed

Lines changed: 7903 additions & 0 deletions

File tree

pinot-bom/pom.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,16 @@
208208
<artifactId>pinot-timeseries-planner</artifactId>
209209
<version>${project.version}</version>
210210
</dependency>
211+
<dependency>
212+
<groupId>org.apache.pinot</groupId>
213+
<artifactId>pinot-graph-spi</artifactId>
214+
<version>${project.version}</version>
215+
</dependency>
216+
<dependency>
217+
<groupId>org.apache.pinot</groupId>
218+
<artifactId>pinot-graph-planner</artifactId>
219+
<version>${project.version}</version>
220+
</dependency>
211221
<dependency>
212222
<groupId>org.apache.pinot</groupId>
213223
<artifactId>pinot-sql-ddl</artifactId>

pinot-broker/pom.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@
4949
<groupId>org.apache.pinot</groupId>
5050
<artifactId>pinot-timeseries-planner</artifactId>
5151
</dependency>
52+
<dependency>
53+
<groupId>org.apache.pinot</groupId>
54+
<artifactId>pinot-graph-spi</artifactId>
55+
</dependency>
56+
<dependency>
57+
<groupId>org.apache.pinot</groupId>
58+
<artifactId>pinot-graph-planner</artifactId>
59+
</dependency>
5260
<dependency>
5361
<groupId>org.slf4j</groupId>
5462
<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
@@ -64,6 +64,7 @@
6464
import org.apache.pinot.broker.api.HttpRequesterIdentity;
6565
import org.apache.pinot.broker.broker.BrokerAdminApiApplication;
6666
import org.apache.pinot.broker.requesthandler.BrokerRequestHandler;
67+
import org.apache.pinot.broker.requesthandler.GraphRequestHandler;
6768
import org.apache.pinot.common.metrics.BrokerMeter;
6869
import org.apache.pinot.common.metrics.BrokerMetrics;
6970
import org.apache.pinot.common.response.BrokerResponse;
@@ -138,6 +139,8 @@ public class PinotClientRequest {
138139
@Named(BrokerAdminApiApplication.BROKER_INSTANCE_ID)
139140
private String _instanceId;
140141

142+
private volatile GraphRequestHandler _graphRequestHandler;
143+
141144
@GET
142145
@ManagedAsync
143146
@Produces(MediaType.APPLICATION_JSON)
@@ -487,6 +490,66 @@ public void processTimeSeriesInstantQuery(@Suspended AsyncResponse asyncResponse
487490
asyncResponse.resume(Response.ok().entity("{}").build());
488491
}
489492

493+
@POST
494+
@ManagedAsync
495+
@Produces(MediaType.APPLICATION_JSON)
496+
@Path("query/graph")
497+
@ApiOperation(value = "Query Pinot using graph (Cypher) queries (experimental)")
498+
@ApiResponses(value = {
499+
@ApiResponse(code = 200, message = "Query response"),
500+
@ApiResponse(code = 400, message = "Graph query feature is disabled or invalid request"),
501+
@ApiResponse(code = 500, message = "Internal Server Error")
502+
})
503+
@ManualAuthorization
504+
public void processGraphQuery(String requestBody, @Suspended AsyncResponse asyncResponse,
505+
@Context org.glassfish.grizzly.http.server.Request requestContext,
506+
@Context HttpHeaders httpHeaders) {
507+
try {
508+
boolean graphEnabled = _brokerConf.getProperty(CommonConstants.Graph.CONFIG_OF_GRAPH_QUERY_ENABLED,
509+
CommonConstants.Graph.DEFAULT_GRAPH_QUERY_ENABLED);
510+
if (!graphEnabled) {
511+
asyncResponse.resume(Response.status(Response.Status.BAD_REQUEST)
512+
.entity("{\"error\": \"Graph query feature is not enabled. "
513+
+ "Set 'pinot.graph.enabled=true' in broker configuration to enable it.\"}")
514+
.type(MediaType.APPLICATION_JSON)
515+
.build());
516+
return;
517+
}
518+
519+
JsonNode requestJson = JsonUtils.stringToJsonNode(requestBody);
520+
if (!requestJson.has("query")) {
521+
throw new IllegalArgumentException("Payload is missing the required 'query' field (Cypher query string)");
522+
}
523+
if (!requestJson.has("graphSchema")) {
524+
throw new IllegalArgumentException("Payload is missing the required 'graphSchema' field");
525+
}
526+
527+
String cypherQuery = requestJson.get("query").asText();
528+
JsonNode graphSchemaJson = requestJson.get("graphSchema");
529+
530+
GraphRequestHandler graphHandler = getOrCreateGraphRequestHandler();
531+
532+
try (RequestScope requestScope = Tracing.getTracer().createRequestScope()) {
533+
requestScope.setRequestArrivalTimeMillis(System.currentTimeMillis());
534+
BrokerResponse brokerResponse = graphHandler.handleRequest(cypherQuery, graphSchemaJson,
535+
makeHttpIdentity(requestContext), requestScope, httpHeaders);
536+
brokerResponse.emitBrokerResponseMetrics(_brokerMetrics);
537+
asyncResponse.resume(getPinotQueryResponse(brokerResponse, httpHeaders, _brokerMetrics));
538+
}
539+
} catch (IllegalArgumentException e) {
540+
LOGGER.warn("Invalid graph query request", e);
541+
asyncResponse.resume(Response.status(Response.Status.BAD_REQUEST)
542+
.entity("{\"error\": \"" + e.getMessage().replace("\"", "'") + "\"}")
543+
.type(MediaType.APPLICATION_JSON)
544+
.build());
545+
} catch (Exception e) {
546+
LOGGER.error("Caught exception while processing graph query request", e);
547+
_brokerMetrics.addMeteredGlobalValue(BrokerMeter.UNCAUGHT_POST_EXCEPTIONS, 1L);
548+
asyncResponse.resume(new WebApplicationException(e,
549+
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build()));
550+
}
551+
}
552+
490553
@POST
491554
@Produces(MediaType.APPLICATION_JSON)
492555
@Path("query/compare")
@@ -647,6 +710,20 @@ public Map<Long, String> getRunningQueries() {
647710
}
648711
}
649712

713+
private GraphRequestHandler getOrCreateGraphRequestHandler() {
714+
GraphRequestHandler handler = _graphRequestHandler;
715+
if (handler == null) {
716+
synchronized (this) {
717+
handler = _graphRequestHandler;
718+
if (handler == null) {
719+
handler = new GraphRequestHandler(_requestHandler);
720+
_graphRequestHandler = handler;
721+
}
722+
}
723+
}
724+
return handler;
725+
}
726+
650727
private BrokerResponse executeSqlQuery(ObjectNode sqlRequestJson, HttpRequesterIdentity httpRequesterIdentity,
651728
boolean onlyDql, HttpHeaders httpHeaders)
652729
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)