This is an automated email from the ASF dual-hosted git repository. kenhuuu pushed a commit to branch implicit-tx in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit f3da1c0bfd88899580bd8da23b746ad9a4c33549 Author: Ken Hu <[email protected]> AuthorDate: Tue May 12 16:51:15 2026 -0700 Enable autocommit in GremlinServer This behaves the same as the TraversalOpProcessor would have in the 3.x line. All traversals are now transactional if the underlying Graph supports transactions. Traversals that aren't explicitly in a transaction are now wrapped into their own implicit transaction and the server will autocommit on sucess and rollback on failure. --- docs/src/dev/provider/index.asciidoc | 21 ++++- docs/src/reference/gremlin-applications.asciidoc | 12 ++- docs/src/reference/the-traversal.asciidoc | 8 +- .../server/handler/HttpGremlinEndpointHandler.java | 19 ++++- .../GremlinDriverTransactionIntegrateTest.java | 27 +++++++ .../server/GremlinServerHttpIntegrateTest.java | 91 ++++++++++++++++++---- 6 files changed, 153 insertions(+), 25 deletions(-) diff --git a/docs/src/dev/provider/index.asciidoc b/docs/src/dev/provider/index.asciidoc index 7654807a90..f6d93aaa66 100644 --- a/docs/src/dev/provider/index.asciidoc +++ b/docs/src/dev/provider/index.asciidoc @@ -1260,8 +1260,25 @@ Graph transactions are typically `ThreadLocal`-bound. The server maintains a sin to ensure all operations execute on the same thread. This is an important implementation detail for graph system providers whose `Transaction` implementation relies on thread-local state. -NOTE: Non-transactional requests (those without a `transactionId`) are not affected by any of the transaction-specific -behavior described above. They continue to operate exactly as before. +==== Auto-Commit for Non-Transactional Requests + +Non-transactional requests (those without a `transactionId`) are not affected by any of the transaction-specific +behavior described above. However, if the underlying graph supports transactions, the server must automatically manage +transactions for these requests: + +* Before processing: roll back any stale open transaction to prevent state leakage between requests. +* On success: commit the transaction after the traversal has been fully iterated and serialized. +* On error: roll back the transaction so that partial mutations are not persisted. + +This auto-commit behavior ensures that users who do not use explicit transactions still get durable writes on success +and clean rollback on failure. Graph system providers implementing their own server or HTTP endpoint must replicate +this behavior if their graph implementation supports transactions. The commit occurs after serialization is complete +but before the final response is flushed to the client, so the client only receives a success response after the +data is committed. + +IMPORTANT: This auto-commit/rollback logic must only apply to non-transactional requests. Requests that carry a +`transactionId` are part of a client-managed transaction and must not be auto-committed or auto-rolled-back by the +server. === HTTP Request Interceptor diff --git a/docs/src/reference/gremlin-applications.asciidoc b/docs/src/reference/gremlin-applications.asciidoc index 7a5925eb0a..f4684863e6 100644 --- a/docs/src/reference/gremlin-applications.asciidoc +++ b/docs/src/reference/gremlin-applications.asciidoc @@ -2242,10 +2242,14 @@ above with the use of the `maximumSize`. [[considering-transactions]] ==== Considering Transactions -Non-transactional requests (those without a `transactionId`) behave as self-contained units of work where the graph's -own transaction semantics apply. Each traversal executes within its own transaction as managed by the graph -implementation itself. Transactional requests participate in a transaction opened via `g.tx().begin()`, where the -client explicitly controls the lifecycle through `g.tx().commit()` and `g.tx().rollback()`. +Non-transactional requests (those without a `transactionId`) behave as self-contained units of work. If the underlying +graph supports transactions, the server automatically commits after successfully iterating the traversal and +automatically rolls back on any error. This means that a simple `g.addV('person')` will be persisted immediately upon +success without requiring an explicit `commit()`. Before processing each non-transactional request, the server also +rolls back any stale open transaction to prevent state leakage between requests. + +Transactional requests participate in a transaction opened via `g.tx().begin()`, where the client explicitly controls +the lifecycle through `g.tx().commit()` and `g.tx().rollback()`. IMPORTANT: Understand the transactional capabilities of the graph configured in Gremlin Server. For example, a basic `TinkerGraph` does not support transactions. Use `TinkerTransactionGraph` or another transaction-capable graph diff --git a/docs/src/reference/the-traversal.asciidoc b/docs/src/reference/the-traversal.asciidoc index a081e209b3..bfea6f5265 100644 --- a/docs/src/reference/the-traversal.asciidoc +++ b/docs/src/reference/the-traversal.asciidoc @@ -89,9 +89,11 @@ in relation to the usage convention and graph provider caveats alluded to earlie Focusing on remote contexts first, note that it is still possible to issue traversals from `g`, but those will have a transaction scope outside of `gtx` and will simply behave as self-contained units of work where the graph's own -transaction semantics apply (i.e. one traversal is one transaction). Each isolated transaction will require its own -`Transaction` object. Multiple `begin()` calls on the same `Transaction` object are not permitted and will throw an -`IllegalStateException`: +transaction semantics apply (i.e. one traversal is one transaction). For graphs that support transactions, the server +automatically commits on successful completion of the traversal and rolls back on any error, so a simple +`g.addV('person')` will be persisted immediately upon success without requiring an explicit `commit()`. Each isolated +transaction will require its own `Transaction` object. Multiple `begin()` calls on the same `Transaction` object are +not permitted and will throw an `IllegalStateException`: [source,java] ---- diff --git a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/HttpGremlinEndpointHandler.java b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/HttpGremlinEndpointHandler.java index 7c474d6ba0..511c467fde 100644 --- a/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/HttpGremlinEndpointHandler.java +++ b/gremlin-server/src/main/java/org/apache/tinkerpop/gremlin/server/handler/HttpGremlinEndpointHandler.java @@ -418,6 +418,17 @@ public class HttpGremlinEndpointHandler extends SimpleChannelInboundHandler<Requ } final Bindings mergedBindings = mergeBindingsFromRequest(context, new SimpleBindings(graphManager.getAsBindings())); + + final String traversalSourceName = message.getField(Tokens.ARGS_G); + final Graph graph = traversalSourceName != null + ? graphManager.getTraversalSource(traversalSourceName).getGraph() + : null; + final boolean autoCommit = (context.getTransactionId() == null) && (graph != null) && + graph.features().graph().supportsTransactions(); + + // rollback any stale open transaction before processing + if (autoCommit && graph.tx().isOpen()) graph.tx().rollback(); + final Object result = scriptEngine.eval(message.getGremlin(), mergedBindings); final String bulkingSetting = context.getChannelHandlerContext().channel().attr(StateKey.REQUEST_HEADERS).get().get(Tokens.BULK_RESULTS); @@ -440,7 +451,11 @@ public class HttpGremlinEndpointHandler extends SimpleChannelInboundHandler<Requ itty = IteratorUtils.asIterator(result); handleIterator(context, itty, serializer, false); } - } catch (Exception ex) { + + if (autoCommit && graph.tx().isOpen()) graph.tx().commit(); + } catch (Throwable t) { + if (autoCommit && graph.tx().isOpen()) graph.tx().rollback(); + // TINKERPOP-3144 ensure Traversals are closed when exception thrown. if (itty instanceof TraverserIterator) { CloseableIterator.closeIterator(((TraverserIterator) itty).getTraversal()); @@ -448,7 +463,7 @@ public class HttpGremlinEndpointHandler extends SimpleChannelInboundHandler<Requ CloseableIterator.closeIterator(itty); } - throw ex; + throw t; } } diff --git a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinDriverTransactionIntegrateTest.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinDriverTransactionIntegrateTest.java index 3cb37d06a1..af35cf4392 100644 --- a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinDriverTransactionIntegrateTest.java +++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinDriverTransactionIntegrateTest.java @@ -647,4 +647,31 @@ public class GremlinDriverTransactionIntegrateTest extends AbstractGremlinServer gtx.tx().rollback(); } } + + @Test + public void shouldAutoCommitNonTransactionalMutatingTraversal() throws Exception { + final Client client = cluster.connect().alias(GTX); + + // add a vertex without explicit transaction management - should be auto-committed + client.submit("g.addV('person').property('name','alice')").all().get(); + + // verify persisted on a subsequent request + assertEquals(1L, client.submit("g.V().hasLabel('person').count()").all().get().get(0).getLong()); + } + + @Test + public void shouldAutoRollbackOnFailedNonTransactionalMutatingTraversal() throws Exception { + final Client client = cluster.connect().alias(GTX); + + // submit a traversal that mutates then fails - should be rolled back + try { + client.submit("g.addV('person').fail()").all().get(); + fail("Expected an exception"); + } catch (Exception ex) { + // expected + } + + // verify nothing was persisted + assertEquals(0L, client.submit("g.V().hasLabel('person').count()").all().get().get(0).getLong()); + } } diff --git a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerHttpIntegrateTest.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerHttpIntegrateTest.java index 895b09b75c..7d724975b9 100644 --- a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerHttpIntegrateTest.java +++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerHttpIntegrateTest.java @@ -111,6 +111,8 @@ public class GremlinServerHttpIntegrateTest extends AbstractGremlinServerIntegra settings.maxRequestContentLength = 31; break; case "should200OnPOSTTransactionalGraph": + case "shouldRollbackOnFailedMutatingTraversal": + case "shouldCommitMutatingTraversalWithEmptyResult": useTinkerTransactionGraph(settings); break; case "should200OnPOSTTransactionalGraphInStrictMode": @@ -489,36 +491,97 @@ public class GremlinServerHttpIntegrateTest extends AbstractGremlinServerIntegra } } - /*@Test disabled for now as current implementation doesn't support implicit transactions. + @Test public void should200OnPOSTTransactionalGraph() throws Exception { final CloseableHttpClient httpclient = HttpClients.createDefault(); + + // add a vertex without an explicit transaction - should be auto-committed final HttpPost httppost = new HttpPost(TestClientFactory.createURLString()); httppost.addHeader("Content-Type", "application/json"); - httppost.setEntity(new StringEntity("{\"gremlin\":\"graph.addVertex('name','stephen');g.V().count()\"}", Consts.UTF_8)); + httppost.setEntity(new StringEntity( + "{\"gremlin\":\"g.addV('person').property('name','stephen')\",\"g\":\"g\"}", Consts.UTF_8)); try (final CloseableHttpResponse response = httpclient.execute(httppost)) { assertEquals(200, response.getStatusLine().getStatusCode()); - assertEquals("application/json", response.getEntity().getContentType().getValue()); - final String json = EntityUtils.toString(response.getEntity()); - final JsonNode node = mapper.readTree(json); - assertEquals(1, node.get("result").get(TOKEN_DATA).get(GraphSONTokens.VALUEPROP).get(0).get(GraphSONTokens.VALUEPROP).intValue()); + EntityUtils.consume(response.getEntity()); } - final HttpGet httpget = new HttpGet(TestClientFactory.createURLString("?gremlin=g.V().count()")); - httpget.addHeader("Accept", "application/json"); + // verify the vertex is visible on subsequent requests from potentially different threads + final HttpPost countPost = new HttpPost(TestClientFactory.createURLString()); + countPost.addHeader("Content-Type", "application/json"); + countPost.setEntity(new StringEntity("{\"gremlin\":\"g.V().count()\",\"g\":\"g\"}", Consts.UTF_8)); - // execute this a bunch of times so that there's a good chance a different thread on the server processes - // the request for (int ix = 0; ix < 100; ix++) { - try (final CloseableHttpResponse response = httpclient.execute(httpget)) { + try (final CloseableHttpResponse response = httpclient.execute(countPost)) { assertEquals(200, response.getStatusLine().getStatusCode()); - assertEquals("application/json", response.getEntity().getContentType().getValue()); final String json = EntityUtils.toString(response.getEntity()); final JsonNode node = mapper.readTree(json); - assertEquals(1, node.get("result").get(TOKEN_DATA).get(GraphSONTokens.VALUEPROP).get(0).get(GraphSONTokens.VALUEPROP).intValue()); + assertEquals(1, node.get("result").get(TOKEN_DATA) + .get(GraphSONTokens.VALUEPROP).get(0) + .get(GraphSONTokens.VALUEPROP).intValue()); } } - } */ + } + + @Test + public void shouldRollbackOnFailedMutatingTraversal() throws Exception { + final CloseableHttpClient httpclient = HttpClients.createDefault(); + + // submit a traversal that adds a vertex then fails - should be rolled back + final HttpPost httppost = new HttpPost(TestClientFactory.createURLString()); + httppost.addHeader("Content-Type", "application/json"); + httppost.setEntity(new StringEntity("{\"gremlin\":\"g.addV('person').fail()\",\"g\":\"g\"}", Consts.UTF_8)); + + try (final CloseableHttpResponse response = httpclient.execute(httppost)) { + // the fail() error appears in the response body, not the HTTP status + } + + // verify the vertex was not persisted + final HttpPost countPost = new HttpPost(TestClientFactory.createURLString()); + countPost.addHeader("Content-Type", "application/json"); + countPost.setEntity(new StringEntity( + "{\"gremlin\":\"g.V().hasLabel('person').count()\",\"g\":\"g\"}", Consts.UTF_8)); + + try (final CloseableHttpResponse response = httpclient.execute(countPost)) { + assertEquals(200, response.getStatusLine().getStatusCode()); + final String json = EntityUtils.toString(response.getEntity()); + final JsonNode node = mapper.readTree(json); + assertEquals(0, node.get("result").get(TOKEN_DATA) + .get(GraphSONTokens.VALUEPROP).get(0) + .get(GraphSONTokens.VALUEPROP).intValue()); + } + } + + @Test + public void shouldCommitMutatingTraversalWithEmptyResult() throws Exception { + final CloseableHttpClient httpclient = HttpClients.createDefault(); + + // g.addV() followed by iterate-style consumption returns no results but should still commit + final HttpPost httppost = new HttpPost(TestClientFactory.createURLString()); + httppost.addHeader("Content-Type", "application/json"); + httppost.setEntity(new StringEntity( + "{\"gremlin\":\"g.addV('person').property('name','p').iterate()\",\"g\":\"g\"}", Consts.UTF_8)); + + try (final CloseableHttpResponse response = httpclient.execute(httppost)) { + assertEquals(200, response.getStatusLine().getStatusCode()); + EntityUtils.consume(response.getEntity()); + } + + // verify the vertex was committed despite the empty result set + final HttpPost countPost2 = new HttpPost(TestClientFactory.createURLString()); + countPost2.addHeader("Content-Type", "application/json"); + countPost2.setEntity(new StringEntity( + "{\"gremlin\":\"g.V().hasLabel('person').count()\",\"g\":\"g\"}", Consts.UTF_8)); + + try (final CloseableHttpResponse response = httpclient.execute(countPost2)) { + assertEquals(200, response.getStatusLine().getStatusCode()); + final String json = EntityUtils.toString(response.getEntity()); + final JsonNode node = mapper.readTree(json); + assertEquals(1, node.get("result").get(TOKEN_DATA) + .get(GraphSONTokens.VALUEPROP).get(0) + .get(GraphSONTokens.VALUEPROP).intValue()); + } + } @Test public void should200OnPOSTTransactionalGraphInStrictMode() throws Exception {
