Transaction Management

Graph Model provides comprehensive transaction support with full async/await patterns to ensure ACID properties and data consistency in your graph operations.

Key Features

  • Full ACID compliance - Atomicity, Consistency, Isolation, and Durability
  • Async/await support - Modern asynchronous transaction patterns
  • Automatic resource management - Implements IAsyncDisposable and IDisposable
  • Exception safety - Automatic rollback on errors
  • Flexible scope - All operations support optional transaction parameters

Basic Transaction Usage

Simple Transaction Pattern

await using var transaction = await graph.GetTransactionAsync();
try
{
    // Perform your operations within the transaction
    await graph.CreateNodeAsync(person, transaction: transaction);
    await graph.CreateNodeAsync(company, transaction: transaction);

    var worksAt = new WorksIn(person.Id, company.Id);
    await graph.CreateRelationshipAsync(worksAt, transaction: transaction);

    // Explicitly commit if all operations succeed
    await transaction.CommitAsync();
}
catch (Exception ex)
{
    // Automatic rollback on any error
    await transaction.RollbackAsync();
    throw;
}

Automatic Disposal Pattern

Transactions implement IAsyncDisposable, providing automatic rollback if not explicitly committed:

await using (var transaction = await graph.GetTransactionAsync())
{
    await graph.CreateNodeAsync(node, transaction: transaction);
    var existingPerson = await graph.GetNodeAsync<Person>(id, transaction: transaction);
    await graph.UpdateNodeAsync(existingPerson, transaction: transaction);

    if (!string.IsNullOrEmpty(existingPerson.Email))
    {
        await transaction.CommitAsync();
    }
    // If not committed, transaction automatically rolls back on disposal
}

Transaction Scope

All graph operations support an optional transaction parameter:

Node Operations

await using var transaction = await graph.GetTransactionAsync();

// Create
var newPerson = new Person { FirstName = "Alice" };
await graph.CreateNodeAsync(newPerson, transaction: transaction);

// Read
var existingPerson = await graph.GetNodeAsync<Person>(id, transaction: transaction);

// Update
existingPerson.LastName = "Smith";
await graph.UpdateNodeAsync(existingPerson, transaction: transaction);

// Delete
await graph.DeleteNodeAsync(id, transaction: transaction);

Relationship Operations

await using var transaction = await graph.GetTransactionAsync();

// Create
await graph.CreateRelationshipAsync(relationship, transaction: transaction);

// Read
var rel = await graph.GetRelationshipAsync<Knows>(id, transaction: transaction);

// Update
await graph.UpdateRelationshipAsync(relationship, transaction: transaction);

// Delete
await graph.DeleteRelationshipAsync(id, transaction: transaction);

Query Operations

await using var transaction = await graph.GetTransactionAsync();

// Queries also support transactions
var results = await graph.Nodes<Person>(transaction: transaction)
    .Where(p => p.Department == "Engineering")
    .ToListAsync();

var relationships = await graph.Relationships<Knows>(transaction: transaction)
    .Where(k => k.Since > DateTime.UtcNow.AddDays(-7))
    .ToListAsync();

Transaction Isolation

Transactions provide isolation from other concurrent operations:

// Transaction 1
await using var tx1 = await graph.GetTransactionAsync();
var alice = new Person { FirstName = "Alice" };
await graph.CreateNodeAsync(alice, transaction: tx1);

// Transaction 2 (concurrent)
await using var tx2 = await graph.GetTransactionAsync();
// This won't see Alice until tx1 commits
var count = await graph.Nodes<Person>(transaction: tx2).CountAsync();

await tx1.CommitAsync();
// Now tx2 can see Alice

Complex Transaction Scenarios

Conditional Logic

await using var transaction = await graph.GetTransactionAsync();
try
{
    var person = await graph.GetNodeAsync<Person>(personId, transaction: transaction);

    if (person.Status == "Active")
    {
        // Update the person
        person.LastActive = DateTime.UtcNow;
        await graph.UpdateNodeAsync(person, transaction: transaction);

        // Create an activity log
        var activity = new Activity
        {
            PersonId = person.Id,
            Timestamp = DateTime.UtcNow
        };
        await graph.CreateNodeAsync(activity, transaction: transaction);

        // Create relationship
        var performed = new Performed(person.Id, activity.Id);
        await graph.CreateRelationshipAsync(performed, transaction: transaction);

        await transaction.CommitAsync();
    }
    else
    {
        // Don't commit - let it rollback
    }
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

Bulk Operations

await using var transaction = await graph.GetTransactionAsync();
try
{
    // Import a batch of data
    foreach (var personData in importData)
    {
        var person = new Person
        {
            FirstName = personData.FirstName,
            LastName = personData.LastName,
            Email = personData.Email
        };

        await graph.CreateNodeAsync(person, transaction: transaction);

        // Create relationships if needed
        if (personData.ManagerEmail != null)
        {
            var manager = await graph.Nodes<Person>(transaction: transaction)
                .FirstOrDefaultAsync(p => p.Email == personData.ManagerEmail);

            if (manager != null)
            {
                var reportsTo = new ReportsTo(person.Id, manager.Id);
                await graph.CreateRelationshipAsync(reportsTo, transaction: transaction);
            }
        }
    }

    await transaction.CommitAsync();
}
catch (Exception ex)
{
    await transaction.RollbackAsync();
    throw new ImportException("Failed to import data", ex);
}

Validation and Rollback

await using var transaction = await graph.GetTransactionAsync();
try
{
    // Create entities
    await graph.CreateNodeAsync(department, transaction: transaction);
    await graph.CreateNodeAsync(employee, transaction: transaction);

    // Validate business rules
    var employeeCount = await graph.Nodes<Employee>(transaction: transaction)
        .CountAsync(e => e.DepartmentId == department.Id);

    if (employeeCount > department.MaxEmployees)
    {
        // Explicitly rollback due to business rule violation
        await transaction.RollbackAsync();
        throw new BusinessRuleException("Department employee limit exceeded");
    }

    // Create the relationship
    var worksIn = new WorksIn(employee.Id, department.Id);
    await graph.CreateRelationshipAsync(worksIn, transaction: transaction);

    await transaction.CommitAsync();
}
catch
{
    // Ensure rollback even if already rolled back
    try { await transaction.RollbackAsync(); } catch { }
    throw;
}

Error Handling

GraphException

Thrown when there are transaction-specific errors:

try
{
    await using var transaction = await graph.GetTransactionAsync();
    // ... operations ...
    await transaction.CommitAsync();
}
catch (GraphException ex)
{
    // Handle transaction-specific errors
    logger.LogError(ex, "Transaction failed");
}

Nested Transaction Attempts

Graph Model doesn’t support nested transactions. Attempting to start a transaction within another will throw an exception:

await using var transaction1 = await graph.GetTransactionAsync();

// This will NOT create a nested transaction
await using var transaction2 = await graph.GetTransactionAsync();

Transactions Belong to the Graph That Created Them

A transaction is owned by the graph it came from, and passing it to a different graph throws a GraphException — even when both graphs use the same provider and point at the same database:

await using var storeA = new Neo4jGraphStore(uri, user, password, "shared-db");
await using var storeB = new Neo4jGraphStore(uri, user, password, "shared-db");

await using var transaction = await storeA.Graph.GetTransactionAsync();

// Throws: the transaction belongs to storeA's graph, not storeB's.
await storeB.Graph.CreateNodeAsync(person, transaction);

Ownership is decided by graph identity, not by connection settings, so two stores configured identically still do not share transactions. The rejection happens before any work is attempted: neither store is modified, and the transaction stays active and usable with the graph that created it. Applications holding several graph instances — one per tenant, per database, or per test — therefore get an error at the call site instead of silently reading or writing the wrong store.

Best Practices

1. Keep Transactions Short

// Good: Short transaction
await using var tx = await graph.GetTransactionAsync();
await graph.CreateNodeAsync(node, transaction: tx);
await graph.CreateRelationshipAsync(rel, transaction: tx);
await tx.CommitAsync();

// Avoid: Long-running transactions
await using var longRunningTx = await graph.GetTransactionAsync();
var data = await FetchDataFromExternalService(); // Don't do this in transaction
await graph.CreateNodeAsync(data, transaction: longRunningTx);
await longRunningTx.CommitAsync();

2. Use Try-Finally for Critical Cleanup

await using var transaction = await graph.GetTransactionAsync();
try
{
    // Acquire resources
    var lockHandle = await AcquireLock(restartNodeId);
    try
    {
        // Perform operations
        await graph.UpdateNodeAsync(node, transaction: transaction);
        await transaction.CommitAsync();
    }
    finally
    {
        // Always release resources
        await ReleaseLock(lockHandle);
    }
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

3. Consider Read Consistency

When reading data that will be modified, use the same transaction:

await using var transaction = await graph.GetTransactionAsync();

// Read with the transaction to ensure consistency
var person = await graph.GetNodeAsync<Person>(id, transaction: transaction);
var currentFriends = await graph.Relationships<Knows>(transaction: transaction)
    .CountAsync(k => k.StartNodeId == person.Id);

if (currentFriends < 100)
{
    // Safe to add friend - count is consistent with our transaction
    await graph.CreateRelationshipAsync(newFriendship, transaction: transaction);
    await transaction.CommitAsync();
}

4. Handle Partial Failures

var results = new List<ImportResult>();

foreach (var batch in dataBatches)
{
    await using var transaction = await graph.GetTransactionAsync();
    try
    {
        foreach (var item in batch)
        {
            var person = new Person
            {
                FirstName = item.FirstName,
                LastName = item.LastName,
                Email = item.Email
            };

            await graph.CreateNodeAsync(person, transaction: transaction);
        }
        await transaction.CommitAsync();
        results.Add(new ImportResult { Batch = batch, Success = true });
    }
    catch (Exception ex)
    {
        await transaction.RollbackAsync();
        results.Add(new ImportResult
        {
            Batch = batch,
            Success = false,
            Error = ex.Message
        });
        // Continue with next batch
    }
}

Performance Considerations

  1. Transaction Overhead: Each transaction has overhead. Batch related operations together.
  2. Lock Duration: Long transactions can cause lock contention. Keep them short.
  3. Read Operations: If only reading, consider whether you need a transaction.
  4. Deadlock Prevention: Access resources in a consistent order across transactions.

Transaction-Free Operations

For read-only operations or when atomicity isn’t required, you can omit the transaction:

// Simple reads don't require transactions
var person = await graph.GetNodeAsync<Person>(id);
var friends = await graph.Nodes<Person>()
    .Where(p => p.Department == "Sales")
    .ToListAsync();

// Single operations have implicit transactions
await graph.CreateNodeAsync(person); // Atomic by itself

Choose explicit transactions when you need to ensure multiple operations succeed or fail together.