Transaction management
This page describes only some specific aspects of transaction management when used with the Neo4j Java API and provides some examples of how to avoid deadlocks, and how to register a transaction event listener for a specific database and perform basic operations on top of the transaction change set. Therefore, it is highly recommended that you read Operations Manual → Database internals and transactional behavior before you continue reading this page. |
Overview
Database operations that access the graph, indexes, or schema are performed in a transaction to ensure the ACID properties. Transactions are single-threaded, confined, and independent. Multiple transactions can be started in a single thread and they are independent of each other.
The interaction cycle of working with transactions follows the steps:
-
Begin a transaction.
-
Perform database operations.
-
Commit or roll back the transaction.
It is crucial to finish each transaction because the locks or memory acquired by a transaction are only released upon completion. For more information on locks and deadlocks, see Operations Manual → Locks and deadlocks. |
The idiomatic use of transactions in Neo4j is to use a try-with-resources
statement and declare transaction
as one of the resources.
Then start the transaction and try to perform graph operations.
The last operation in the try
block should commit or roll back the transaction, depending on the business logic.
In this scenario, try-with-resources
is used as a guard against unexpected exceptions and as an additional safety mechanism to ensure that the transaction gets closed no matter what happens inside the statement block.
All non-committed transactions will be rolled back as part of resource cleanup at the end of the statement.
No resource cleanup is required for a transaction that is explicitly committed or rolled back, and the transaction closure is an empty operation.
All modifications performed in a transaction are kept in memory. This means that very large updates must be split into several transactions to avoid running out of memory. |
Deadlock handling an example
Deadlocks caused by the use of other synchronization than the locks managed by Neo4j can still happen. Since all operations in the Neo4j API are thread-safe unless specified otherwise, there is no need for external synchronization. Other code that requires synchronization should be synchronized in such a way that it never performs any Neo4j operation in the synchronized block. |
The following is an example of how deadlocks can be handled in procedures, server extensions, or when using Neo4j embedded.
The full source code used for the code snippet can be found in DeadlockDocTest.java. |
When dealing with deadlocks in code, there are several issues you may want to address:
-
Only do a limited amount of retries, and fail if a threshold is reached.
-
Pause between each attempt to allow the other transaction to finish before trying again.
-
A retry loop can be useful not only for deadlocks but for other types of transient errors as well.
Below is an example that shows how this can be implemented.
This example shows how to use a retry loop for handling deadlocks:
Throwable txEx = null;
int RETRIES = 5;
int BACKOFF = 3000;
for ( int i = 0; i < RETRIES; i++ )
{
try ( Transaction tx = databaseService.beginTx() )
{
Object result = doStuff(tx);
tx.commit();
return result;
}
catch ( Throwable ex )
{
txEx = ex;
// Add whatever exceptions to retry on here
if ( !(ex instanceof DeadlockDetectedException) )
{
break;
}
}
// Wait so that we don't immediately get into the same deadlock
if ( i < RETRIES - 1 )
{
try
{
Thread.sleep( BACKOFF );
}
catch ( InterruptedException e )
{
throw new TransactionFailureException( "Interrupted", e );
}
}
}
if ( txEx instanceof TransactionFailureException )
{
throw ((TransactionFailureException) txEx);
}
else if ( txEx instanceof Error )
{
throw ((Error) txEx);
}
else
{
throw ((RuntimeException) txEx);
}
Transaction events
A neo4j.org.graphdb.event.TransactionEventListener
can be registered to receive Neo4j database transaction events.
Once it has been registered at a org.neo4j.dbms.api.DatabaseManagementService
instance, it receives transaction events for the database with which it was registered.
Listeners get notified about transactions that have performed any write operation, and that will be committed.
If Transaction#commit()
has not been called, or the transaction was rolled back with Transaction#rollback()
, it will be rolled back and no events are sent to the listener.
Before a transaction is committed, the listeners' beforeCommit
method is called with the entire diff of modifications made in the transaction.
At this point the transaction is still running, so changes can still be made.
The method may also throw an exception, which prevents the transaction from being committed.
If the transaction is rolled back, a call to the listener’s afterRollback
method will follow.
The order in which listeners are executed is undefined — there is no guarantee that changes made by one listener will be seen by other listeners. |
If beforeCommit
is successfully executed in all registered listeners, the transaction is committed and the afterCommit
method is called with the same transaction data.
This call also includes the object returned from beforeCommit
.
In afterCommit
, the transaction is closed and access to anything outside org.neo4j.graphdb.event.TransactionData
requires a new transaction to be opened.
A neo4j.org.graphdb.event.TransactionEventListener
gets notified about transactions that have any changes accessible via org.neo4j.graphdb.event.TransactionData
.
Some indexing and schema changes will not trigger these events.
The following example shows how to register a listener for a specific database and perform basic operations on top of the transaction change set.
The full source code used for the code snippet can be found in TransactionEventListenerExample.java. |
Register a transaction event listener and inspect the change set:
public static void main( String[] args ) throws IOException
{
FileUtils.deleteDirectory( HOME_DIRECTORY );
var managementService = new DatabaseManagementServiceBuilder( HOME_DIRECTORY ).build();
var database = managementService.database( DEFAULT_DATABASE_NAME );
var countingListener = new CountingTransactionEventListener();
managementService.registerTransactionEventListener( DEFAULT_DATABASE_NAME, countingListener );
var connectionType = RelationshipType.withName( "CONNECTS" );
try ( var transaction = database.beginTx() )
{
var startNode = transaction.createNode();
var endNode = transaction.createNode();
startNode.createRelationshipTo( endNode, connectionType );
transaction.commit();
}
}
private static class CountingTransactionEventListener implements TransactionEventListener<CreatedEntitiesCounter>
{
@Override
public CreatedEntitiesCounter beforeCommit( TransactionData data, Transaction transaction, GraphDatabaseService databaseService ) throws Exception
{
return new CreatedEntitiesCounter( size( data.createdNodes() ), size( data.createdRelationships() ) );
}
@Override
public void afterCommit( TransactionData data, CreatedEntitiesCounter entitiesCounter, GraphDatabaseService databaseService )
{
System.out.println( "Number of created nodes: " + entitiesCounter.getCreatedNodes() );
System.out.println( "Number of created relationships: " + entitiesCounter.getCreatedRelationships() );
}
@Override
public void afterRollback( TransactionData data, CreatedEntitiesCounter state, GraphDatabaseService databaseService )
{
}
}
private static class CreatedEntitiesCounter
{
private final long createdNodes;
private final long createdRelationships;
public CreatedEntitiesCounter( long createdNodes, long createdRelationships )
{
this.createdNodes = createdNodes;
this.createdRelationships = createdRelationships;
}
public long getCreatedNodes()
{
return createdNodes;
}
public long getCreatedRelationships()
{
return createdRelationships;
}
}