Coordinate parallel transactions

When working with a Neo4j cluster, causal consistency is enforced by default in most cases, which guarantees that a query is able to read changes made by previous queries. The same does not happen by default for multiple transactions running in parallel though. In that case, you can use bookmarks to have one transaction wait for the result of another to be propagated across the cluster before running its own work. This is not a requirement, and you should only use bookmarks if you need casual consistency across different transactions.

A bookmark is a token that represents some state of the database. By passing one or multiple bookmarks along with a query, the server will make sure that the query does not get executed before the represented state(s) have been established.

Bookmarks with ExecuteQuery()

When querying the database with ExecuteQuery(), the driver manages bookmarks for you. In this case, you have the guarantee that subsequent queries can read previous changes without taking further action.

neo4j.ExecuteQuery(ctx, driver, "<QUERY 1>", nil,
    neo4j.EagerResultTransformer,
    neo4j.ExecuteQueryWithDatabase("neo4j"))

// subsequent ExecuteQuery calls will be causally chained

neo4j.ExecuteQuery(ctx, driver, "<QUERY 2>", nil,  // can read result of <QUERY 1>
    neo4j.EagerResultTransformer,
    neo4j.ExecuteQueryWithDatabase("neo4j"))
neo4j.ExecuteQuery(ctx, driver, "<QUERY 3>", nil,  // can read result of <QUERY 2>
    neo4j.EagerResultTransformer,
    neo4j.ExecuteQueryWithDatabase("neo4j"))

To disable bookmark management and causal consistency, use the configuration callback neo4j.ExecuteQueryWithoutBookmarkManager() in ExecuteQuery() calls.

neo4j.ExecuteQuery(
    ctx, driver, "<QUERY>", nil, neo4j.EagerResultTransformer,
    neo4j.ExecuteQueryWithDatabase("neo4j"),
    neo4j.ExecuteQueryWithoutBookmarkManager())

Bookmarks within a single session

Bookmark management happens automatically for queries run within a single session, so that you can trust that queries inside one session are causally chained.

session := driver.NewSession(ctx, neo4j.SessionConfig{DatabaseName: "neo4j"})
defer session.Close(ctx)
session.ExecuteWrite(ctx,
    func(tx neo4j.ManagedTransaction) (any, error) {
        return tx.Run(ctx, "<QUERY 1>", nil)
})
session.ExecuteWrite(ctx,
    func(tx neo4j.ManagedTransaction) (any, error) {
        return tx.Run(ctx, "<QUERY 2>", nil)  // can read QUERY 1
})
session.ExecuteWrite(ctx,
    func(tx neo4j.ManagedTransaction) (any, error) {
        return tx.Run(ctx, "<QUERY 3>", nil)  // can read QUERY 1 and 2
})

Bookmarks across multiple sessions

If your application uses multiple sessions, you may need to ensure that one session has completed all its transactions before another session is allowed to run its queries.

In the example below, sessionA and sessionB are allowed to run concurrently, while sessionC waits until their results have been propagated. This guarantees the Person nodes sessionC wants to act on actually exist.

Coordinate multiple sessions using bookmarks
package main

import (
    "fmt"
    "context"
    "github.com/neo4j/neo4j-go-driver/v5/neo4j"
)

func main() {
    ctx := context.Background()

    // Connection to database
    dbUri := "<URI for Neo4j database>"
    dbUser := "<Username>"
    dbPassword := "<Password>"
    driver, err := neo4j.NewDriverWithContext(
        dbUri,
        neo4j.BasicAuth(dbUser, dbPassword, ""))
    if err != nil {
        panic(err)
    }
    defer driver.Close(ctx)
    err = driver.VerifyConnectivity(ctx)
    if err != nil {
        panic(err)
    }

    // Bookmarks holder
    var savedBookmarks neo4j.Bookmarks

    // All function calls below may return errors,
    // we don't catch them here for simplicity.

    // Create the first person and employment relationship
    sessionA := driver.NewSession(ctx, neo4j.SessionConfig{DatabaseName: "neo4j"})
    createPerson(ctx, sessionA, "Alice")
    employ(ctx, sessionA, "Alice", "Wayne Enterprises")
    savedBookmarks = neo4j.CombineBookmarks(savedBookmarks, sessionA.LastBookmarks())  (1)
    sessionA.Close(ctx)

    // Create the second person and employment relationship
    sessionB := driver.NewSession(ctx, neo4j.SessionConfig{DatabaseName: "neo4j"})
    createPerson(ctx, sessionB, "Bob")
    employ(ctx, sessionB, "Bob", "LexCorp")
    savedBookmarks = neo4j.CombineBookmarks(savedBookmarks, sessionB.LastBookmarks())  (1)
    sessionB.Close(ctx)

    // Create a friendship between the two people created above
    sessionC := driver.NewSession(ctx, neo4j.SessionConfig{
        DatabaseName: "neo4j",
        Bookmarks: savedBookmarks,  (2)
    })
    createFriendship(ctx, sessionC, "Alice", "Bob")
    printFriendships(ctx, sessionC)
}

// Create a Person node
func createPerson(ctx context.Context, session neo4j.SessionWithContext, name string) (any, error) {
    return session.ExecuteWrite(ctx,
        func(tx neo4j.ManagedTransaction) (any, error) {
            return tx.Run(ctx,
                "MERGE (:Person {name: $name})",
                map[string]any{"name": name})
        })
}

// Create an employment relationship to a pre-existing company node
// This relies on the person first having been created
func employ(ctx context.Context, session neo4j.SessionWithContext, personName string, companyName string) (any, error) {
    return session.ExecuteWrite(ctx,
        func(tx neo4j.ManagedTransaction) (any, error) {
            return session.Run(ctx, `
                MATCH (person:Person {name: $person_name})
                MATCH (company:Company {name: $company_name})
                MERGE (person)-[:WORKS_FOR]->(company)
                `, map[string]any{
                    "personName": personName,
                    "companyName": companyName,
                })
        })
}

// Create a friendship between two people
func createFriendship(ctx context.Context, session neo4j.SessionWithContext, nameA string, nameB string) (any, error) {
    return session.ExecuteWrite(ctx,
        func(tx neo4j.ManagedTransaction) (any, error) {
            return session.Run(ctx, `
                MATCH (a:Person {name: $nameA})
                MATCH (b:Person {name: $nameB})
                MERGE (a)-[:KNOWS]->(b)
                `, map[string]any{
                    "nameA": nameA,
                    "nameB": nameB,
                })
        })
}

// Retrieve and display all friendships
func printFriendships(ctx context.Context, session neo4j.SessionWithContext) (any, error) {
    return session.ExecuteRead(ctx,
        func(tx neo4j.ManagedTransaction) (any, error) {
            result, err := session.Run(ctx,
                "MATCH (a)-[:KNOWS]->(b) RETURN a.name, b.name",
                nil)
            if err != nil {
                return nil, err
            }
            records, _ := result.Collect(ctx)
            for _, record := range records {
                nameA, _ := record.Get("a.name")
                nameB, _ := record.Get("b.name")
                fmt.Println(nameA, "knows", nameB)
            }
            return nil, nil
        })
}
1 Collect and combine bookmarks from different sessions using SessionWithContext.LastBookmarks() and neo4j.CombineBookmarks().
2 Use them to initialize another session with the Bookmarks config parameter.

driver passing bookmarks

The use of bookmarks can negatively impact performance, since all queries are forced to wait for the latest changes to be propagated across the cluster. For simple use-cases, try to group queries within a single transaction, or within a single session.

Mix ExecuteQuery() and sessions

To ensure causal consistency among transactions executed partly with ExecuteQuery() and partly with sessions, you can use the parameter BookmarkManager upon session creation, setting it to driver.ExecuteQueryBookmarkManager(). Since that is the default bookmark manager for ExecuteQuery() calls, this will ensure that all work is executed under the same bookmark manager and thus causally consistent.

neo4j.ExecuteQuery(ctx, driver, "<QUERY 1>", nil,
    neo4j.EagerResultTransformer,
    neo4j.ExecuteQueryWithDatabase("neo4j"))

session := driver.NewSession(ctx, neo4j.SessionConfig{
    DatabaseName: "neo4j",
    BookmarkManager: driver.ExecuteQueryBookmarkManager(),
})
// every query inside this session will be causally chained
// (i.e., can read what was written by <QUERY 1>)
session.ExecuteWrite(ctx,
    func(tx neo4j.ManagedTransaction) (any, error) {
        return tx.Run(ctx, "<QUERY 2>", nil)
})
session.Close(ctx)

// subsequent ExecuteQuery calls will be causally chained
// (i.e., can read what was written by <QUERY 2>)
neo4j.ExecuteQuery(ctx, driver, "<QUERY 3>", nil,
    neo4j.EagerResultTransformer,
    neo4j.ExecuteQueryWithDatabase("neo4j"))

Glossary

LTS

A Long Term Support release is one guaranteed to be supported for a number of years. Neo4j 4.4 is LTS, and Neo4j 5 will also have an LTS version.

Aura

Aura is Neo4j’s fully managed cloud service. It comes with both free and paid plans.

Cypher

Cypher is Neo4j’s graph query language that lets you retrieve data from the database. It is like SQL, but for graphs.

APOC

Awesome Procedures On Cypher (APOC) is a library of (many) functions that can not be easily expressed in Cypher itself.

Bolt

Bolt is the protocol used for interaction between Neo4j instances and drivers. It listens on port 7687 by default.

ACID

Atomicity, Consistency, Isolation, Durability (ACID) are properties guaranteeing that database transactions are processed reliably. An ACID-compliant DBMS ensures that the data in the database remains accurate and consistent despite failures.

eventual consistency

A database is eventually consistent if it provides the guarantee that all cluster members will, at some point in time, store the latest version of the data.

causal consistency

A database is causally consistent if read and write queries are seen by every member of the cluster in the same order. This is stronger than eventual consistency.

NULL

The null marker is not a type but a placeholder for absence of value. For more information, see Cypher → Working with null.

transaction

A transaction is a unit of work that is either committed in its entirety or rolled back on failure. An example is a bank transfer: it involves multiple steps, but they must all succeed or be reverted, to avoid money being subtracted from one account but not added to the other.

backpressure

Backpressure is a force opposing the flow of data. It ensures that the client is not being overwhelmed by data faster than it can handle.

transaction function

A transaction function is a callback executed by an ExecuteRead or ExecuteWrite call. The driver automatically re-executes the callback in case of server failure.

DriverWithContext

A DriverWithContext object holds the details required to establish connections with a Neo4j database.