Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/server-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -1061,3 +1061,18 @@ A missing label returns HTTP 404 and identifies the missing schema name.
No rebuild task is submitted for a missing label. Recreate the intended schema
or correct the label name before retrying. This also handles a label removed
before its builder can schedule the rebuild.

## Create a graph with a copied schema

With Server API version 0.73 or newer,
`POST /graphspaces/{space}/graphs/{target}/schema-clone?source={source}` creates a
new graph in the same graph space and synchronously copies the source's property
keys, vertex labels, and indexes. Sources with any edge label are rejected before
creating the target: some schema stores do not preserve the hierarchy metadata
needed to copy edge labels safely. The JSON body may contain a
`nickname` string. HTTP 201 confirms schema copying completed; graph vertices and
edges are not copied. Stop changes to the source schema for the duration of the
operation. If copying fails after graph creation, the target may be visible with a
partial schema. Inspect it and remove it explicitly before retrying. This API is
absent from older Server versions; clients must not substitute configuration-only
`clone_graph_name` and claim a schema copy succeeded.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
# hugegraph-common follows the project version defined by ${revision} in the root pom.xml,
# and VersionInBash needs to be updated in this file.
Version=${revision}
ApiVersion=0.72
ApiVersion=0.73
ApiCheckBeginVersion=1.0
ApiCheckEndVersion=2.0
VersionInBash=1.8.0
2 changes: 1 addition & 1 deletion hugegraph-server/hugegraph-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@
</manifest>
<manifestEntries>
<!-- Bump when the public REST API contract changes -->
<Implementation-Version>0.72.0.0</Implementation-Version>
<Implementation-Version>0.73.0.0</Implementation-Version>
</manifestEntries>
</archive>
</configuration>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,31 @@ public Object create(@Context GraphManager manager,
return result;
}

@POST
@Timed
@Path("{name}/schema-clone")
@StatusFilter.Status(StatusFilter.Status.CREATED)
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON_WITH_CHARSET)
@RolesAllowed({"space"})
public Object cloneSchema(@Context GraphManager manager,
@PathParam("graphspace") String graphSpace,
@PathParam("name") String name,
@QueryParam("source") String source,
Map<String, Object> options) {
E.checkArgument(StringUtils.isNotBlank(source), "The source graph is required");
E.checkArgument(options == null || options.keySet().stream().allMatch("nickname"::equals),
"Only nickname is supported when cloning a schema");
Object nickname = options == null ? null : options.get("nickname");
E.checkArgument(nickname == null || nickname instanceof String,
"The nickname must be a string");
HugeGraph graph = manager.cloneGraphSchema(graphSpace, source, name,
(String) nickname,
HugeGraphAuthProxy.username());
return ImmutableMap.of("name", graph.name(), "nickname", graph.nickname(),
"backend", graph.backend());
}

/**
* Create graph via text/plain (hugegraph-client compatibility).
* Client sends: POST /graphspaces/{graphspace}/graphs/{name}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import java.text.ParseException;
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
Expand Down Expand Up @@ -109,6 +110,8 @@
import org.apache.hugegraph.rpc.RpcServer;
import org.apache.hugegraph.serializer.JsonSerializer;
import org.apache.hugegraph.serializer.Serializer;
import org.apache.hugegraph.schema.SchemaManager;
import org.apache.hugegraph.struct.schema.SchemaElement;
import org.apache.hugegraph.server.RestServer;
import org.apache.hugegraph.space.GraphSpace;
import org.apache.hugegraph.space.SchemaTemplate;
Expand Down Expand Up @@ -1366,6 +1369,54 @@ public HugeGraph cloneGraph(String graphspace, String name, String newName, Map<
return this.createGraph(graphspace, newName, creator, newConfigs, true);
}

/** Create a graph with the source schema only; graph elements are not copied. */
public HugeGraph cloneGraphSchema(String graphspace, String source, String target,
String nickname, String creator) {
HugeGraph sourceGraph = this.graph(spaceGraphName(graphspace, source));
E.checkArgumentNotNull(sourceGraph,
"The clone source graph '%s' doesn't exist in graphspace '%s'",
source, graphspace);
SchemaManager sourceSchema = sourceGraph.schema();
// Some schema stores do not persist edge-label hierarchy metadata. A
// normal label cannot then be distinguished from a parent or child.
// Refuse all edge labels until the storage contract can prove fidelity.
E.checkArgument(sourceSchema.getEdgeLabels().isEmpty(),
"Schema cloning does not currently support graphs with edge labels");
Map<String, Object> overrides = new HashMap<>();
// A source template may have changed since it was applied. Copy the actual schema.
overrides.put(CoreOptions.SCHEMA_INIT_TEMPLATE.name(), "");
overrides.put("creator", creator);
if (nickname != null) {
overrides.put("nickname", nickname);
} else {
overrides.put("nickname", target);
}
HugeGraph graph = this.cloneGraph(graphspace, source, target, overrides);
try {
SchemaManager schema = graph.schema();
schema.copyFromWithoutEdges(sourceSchema);
E.checkState(sourceSchema.getEdgeLabels().isEmpty(),
"Source edge labels changed during schema cloning");
// Synchronous builders wait for tasks, but a completed task can have failed.
checkCopiedSchema(schema.getPropertyKeys());
checkCopiedSchema(schema.getVertexLabels());
checkCopiedSchema(schema.getEdgeLabels());
checkCopiedSchema(schema.getIndexLabels());
} catch (RuntimeException failure) {
// The graph can already be visible to other clients. Never delete it implicitly.
LOG.warn("Schema copy to graph '{}' failed", target, failure);
throw new HugeException("Failed to copy schema to graph '%s'; " +
"the target may be partially created and needs inspection", target);
}
return graph;
}

private static void checkCopiedSchema(Collection<? extends SchemaElement> elements) {
for (SchemaElement element : elements) {
E.checkState(element.status().ok(), "A copied schema element is not ready");
}
}

private void loadGraph(Map<String, Map<String, Object>> graphConfigs) {
// Load graph
for (Map.Entry<String, Map<String, Object>> conf : graphConfigs.entrySet()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ public final class ApiVersion {
* [0.70] PR-2242: Add edge-existence RESTful API
* [0.71] PR-2286: Support Arthas API & Metric API prometheus format
* [0.72] PR-3159: Support GraphSpace-wide default-role management APIs
* [0.73] Support same-GraphSpace schema-only graph cloning
*/

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,36 @@ public List<IndexLabel> getIndexLabels() {
}

public void copyFrom(SchemaManager schema) {
this.copyFrom(schema, true);
}

/** Copy only schemas known to contain no edge labels. */
public void copyFromWithoutEdges(SchemaManager schema) {
this.copyFrom(schema, false);
}

private void copyFrom(SchemaManager schema, boolean allowEdges) {
// Use a single edge snapshot. If labels appear after the initial API
// preflight, do not copy them with potentially missing hierarchy data.
List<EdgeLabel> edgeLabels = schema.getEdgeLabels();
E.checkArgument(allowEdges || edgeLabels.isEmpty(),
"Schema cloning does not currently support graphs with edge labels");
for (PropertyKey pk : schema.getPropertyKeys()) {
new PropertyKeyBuilder(this.transaction, this.graph, pk).create();
}
for (VertexLabel vl : schema.getVertexLabels()) {
new VertexLabelBuilder(this.transaction, this.graph, vl).create();
}
for (EdgeLabel el : schema.getEdgeLabels()) {
new EdgeLabelBuilder(this.transaction, this.graph, el).create();
// A sublabel refers to its parent by name during creation.
for (EdgeLabel el : edgeLabels) {
if (!el.edgeLabelType().sub()) {
new EdgeLabelBuilder(this.transaction, this.graph, el).create();
}
}
for (EdgeLabel el : edgeLabels) {
if (el.edgeLabelType().sub()) {
new EdgeLabelBuilder(this.transaction, this.graph, el).create();
}
}
for (IndexLabel il : schema.getIndexLabels()) {
new IndexLabelBuilder(this.transaction, this.graph, il).create();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,21 @@ public EdgeLabelBuilder(ISchemaTransaction transaction,
HugeGraphSupplier origin = copy.graph();
this.id = null;
this.name = copy.name();
this.links = mapPairId2Name(origin, copy.links());
// Parent links are derived from its children and cannot be set at creation.
this.links = copy.edgeLabelType().parent() ? new HashSet<>() :
mapPairId2Name(origin, copy.links());
this.edgeLabelType = copy.edgeLabelType();
if (copy.edgeLabelType().sub()) {
this.fatherLabel = mapElId2Name(origin, copy.fatherId());
}
this.frequency = copy.frequency();
this.properties = mapPkId2Name(origin, copy.properties());
this.sortKeys = mapPkId2Name(origin, copy.sortKeys());
// A SINGLE parent may acquire sort keys from a MULTIPLE child. They
// cannot be supplied while recreating the SINGLE parent; creating the
// children will restore those derived keys. MULTIPLE parents have
// their own required sort keys, which must be preserved.
this.sortKeys = copy.edgeLabelType().parent() && copy.frequency() != Frequency.MULTIPLE ?
new ArrayList<>() : mapPkId2Name(origin, copy.sortKeys());
this.nullableKeys = mapPkId2Name(origin, copy.nullableKeys());
this.ttl = copy.ttl();
this.ttlStartTime = copy.ttlStartTimeName();
Expand Down Expand Up @@ -226,12 +233,22 @@ public EdgeLabel create() {
*/
private boolean hasSameProperties(EdgeLabel existedEdgeLabel) {
HugeGraph graph = this.graph();
for (Pair<Id, Id> link : existedEdgeLabel.links()) {
String sourceName = graph.vertexLabel(link.getLeft()).name();
String targetName = graph.vertexLabel(link.getRight()).name();
if (!this.links.contains(Pair.of(sourceName, targetName))) {
boolean parent = this.edgeLabelType.parent() &&
existedEdgeLabel.edgeLabelType().parent();
if (parent) {
// Parent links are derived from sub-labels and are not supplied
// while recreating the parent itself.
if (!this.links.isEmpty()) {
return false;
}
} else {
for (Pair<Id, Id> link : existedEdgeLabel.links()) {
String sourceName = graph.vertexLabel(link.getLeft()).name();
String targetName = graph.vertexLabel(link.getRight()).name();
if (!this.links.contains(Pair.of(sourceName, targetName))) {
return false;
}
}
}

if ((this.frequency == Frequency.DEFAULT &&
Expand Down Expand Up @@ -263,15 +280,22 @@ private boolean hasSameProperties(EdgeLabel existedEdgeLabel) {
}
}

List<Id> existedSortKeys = existedEdgeLabel.sortKeys();
if (this.sortKeys.size() != existedSortKeys.size()) {
return false;
}
for (String key : this.sortKeys) {
PropertyKey propertyKey = graph.propertyKey(key);
if (!existedSortKeys.contains(propertyKey.id())) {
// A SINGLE parent may acquire sort keys from a MULTIPLE child. The
// empty builder value represents those derived keys. MULTIPLE parent
// sort keys are explicit and must still match strictly.
boolean derivedSortKeys = parent && this.sortKeys.isEmpty() &&
existedEdgeLabel.frequency() == Frequency.SINGLE;
if (!derivedSortKeys) {
List<Id> existedSortKeys = existedEdgeLabel.sortKeys();
if (this.sortKeys.size() != existedSortKeys.size()) {
return false;
}
for (String key : this.sortKeys) {
PropertyKey propertyKey = graph.propertyKey(key);
if (!existedSortKeys.contains(propertyKey.id())) {
return false;
}
}
}

Set<Id> existedNullableKeys = existedEdgeLabel.nullableKeys();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ public IndexLabelBuilder(ISchemaTransaction transaction,
HugeGraph graph, IndexLabel copy) {
super(transaction, graph);
E.checkNotNull(copy, "copy");
// Get base element from self graph
SchemaLabel schemaLabel = IndexLabel.getBaseLabel(graph,
// The copied ID belongs to the source graph, not the target graph.
SchemaLabel schemaLabel = IndexLabel.getBaseLabel(copy.graph(),
copy.baseType(),
copy.baseValue());
this.id = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

import com.google.common.collect.ImmutableMap;

import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.core.Response;

/**
Expand Down Expand Up @@ -69,6 +70,92 @@ public void teardown() throws Exception {
super.teardown();
}

@Test
public void testCloneSchemaWithoutGraphData() {
String source = GRAPHS_PATH + "/schema_source";
String target = GRAPHS_PATH + "/schema_target";
try {
assertResponseStatus(201, createGraphInRocksDB(SPACE, "schema_source"));
createAndAssert(source + "/schema/propertykeys",
"{\"name\":\"name\",\"data_type\":\"TEXT\"," +
"\"cardinality\":\"SINGLE\"}", 202);
createAndAssert(source + "/schema/vertexlabels",
"{\"name\":\"person\",\"id_strategy\":\"AUTOMATIC\"," +
"\"properties\":[\"name\"],\"nullable_keys\":[]}");
createAndAssert(source + "/schema/indexlabels",
"{\"name\":\"personByName\",\"base_type\":\"VERTEX_LABEL\"," +
"\"base_value\":\"person\",\"index_type\":\"SECONDARY\"," +
"\"fields\":[\"name\"],\"rebuild\":false}", 202);
createAndAssert(source + "/graph/vertices",
"{\"label\":\"person\",\"properties\":{\"name\":\"alice\"}}");

Response cloned = client().target(baseUrl()).path(target + "/schema-clone")
.queryParam("source", "schema_source").request()
.post(Entity.json("{\"nickname\":\"SchemaCopy\"}"));
String result = assertResponseStatus(201, cloned);
Assert.assertEquals("schema_target", JsonUtil.fromJson(result, Map.class).get("name"));
Assert.assertEquals("SchemaCopy", JsonUtil.fromJson(result, Map.class).get("nickname"));
Assert.assertEquals("name", assertJsonContains(assertResponseStatus(200,
client().get(target + "/schema/propertykeys/name")), "name"));
Assert.assertEquals("person", assertJsonContains(assertResponseStatus(200,
client().get(target + "/schema/vertexlabels/person")), "name"));
Assert.assertEquals("person", assertJsonContains(assertResponseStatus(200,
client().get(target + "/schema/indexlabels/personByName")), "base_value"));
String vertices = assertResponseStatus(200, client().get(target + "/graph/vertices"));
Assert.assertTrue(readList(vertices, "vertices", Map.class).isEmpty());
} finally {
Map<String, Object> confirm = ImmutableMap.of(
"confirm_message", "I'm sure to drop the graph");
client().delete(target, confirm);
client().delete(source, confirm);
}
}

@Test
public void testSchemaCloneRejectsOrdinaryEdgeBeforeCreatingTarget() {
String source = GRAPHS_PATH + "/schema_edge_source";
String target = GRAPHS_PATH + "/schema_edge_target";
try {
assertResponseStatus(201, createGraphInRocksDB(SPACE, "schema_edge_source"));
createAndAssert(source + "/schema/vertexlabels",
"{\"name\":\"person\",\"id_strategy\":\"AUTOMATIC\"}");
createAndAssert(source + "/schema/edgelabels",
"{\"name\":\"knows\",\"source_label\":\"person\"," +
"\"target_label\":\"person\",\"frequency\":\"SINGLE\"}");
Response response = client().target(baseUrl()).path(target + "/schema-clone")
.queryParam("source", "schema_edge_source").request()
.post(Entity.json("{}"));
String error = assertResponseStatus(400, response);
Assert.assertTrue(error.contains("edge labels"));
Assert.assertTrue(client().get(target).getStatus() >= 400);
} finally {
Map<String, Object> confirm = ImmutableMap.of(
"confirm_message", "I'm sure to drop the graph");
client().delete(target, confirm);
client().delete(source, confirm);
}
}

@Test
public void testSchemaCloneRejectsMissingSourceBeforeCreatingTarget() {
String target = GRAPHS_PATH + "/invalid_schema_target";
Response response = client().target(baseUrl()).path(target + "/schema-clone")
.queryParam("source", "missing_source").request()
.post(Entity.json("{}"));
Assert.assertTrue(response.getStatus() >= 400);
Assert.assertTrue(client().get(target).getStatus() >= 400);
}

@Test
public void testSchemaCloneRejectsDataOption() {
String target = GRAPHS_PATH + "/invalid_data_target";
Response response = client().target(baseUrl()).path(target + "/schema-clone")
.queryParam("source", "hugegraph").request()
.post(Entity.json("{\"load_data\":true}"));
Assert.assertTrue(response.getStatus() >= 400);
Assert.assertTrue(client().get(target).getStatus() >= 400);
}

// ------------------------------------------------------------------ //
// GET /graphspaces/{gs}/graphs/profile
// ------------------------------------------------------------------ //
Expand Down
Loading
Loading