Skip to main content

Transactions

em.transactional() wraps operations in a single Neo4j transaction. If the callback throws, the transaction is rolled back.

await em.transactional(async (txEm) => {
const user = txEm.create(User, { name: 'Alice' });
txEm.create(Post, { title: 'First Post', author: user });

await txEm.flush();
});

Manual control, when the boundaries are not lexical:

const fork = em.fork();
await fork.begin();

try {
// … operations
await fork.commit();
} catch (e) {
await fork.rollback();
throw e;
}

Transaction boundaries are logged as begin, commit and rollback carrying the same context as every statement — a query log without them cannot be read back as a sequence.

Read-only transactions​

await em.transactional(async (em) => em.find(Book, {}), { readOnly: true });

A read-only transaction opens its session in read access mode, which on a cluster may be routed to a follower. See read replicas.

Options that cannot be honoured​

The rule here is that an option which cannot be honoured must never appear to be honoured. Both of these previously did nothing and said nothing.

Isolation levels​

await em.transactional(cb, { isolationLevel: IsolationLevel.SERIALIZABLE });
// Error: Transaction isolation levels are not supported by the Neo4j driver
// (got 'serializable'). Neo4j transactions are read-committed and the
// level is not configurable, so honouring this option is not possible.

nested propagation​

Neo4j has no savepoints, so nested propagation cannot mean what it means elsewhere.

await em.transactional(cb, { propagation: TransactionPropagation.NESTED });
// Error: `nested` transaction propagation is not supported … Use `requires_new`
// for a transaction that commits independently, or `required` to join
// the surrounding one.

Only the explicit form is refused. Core defaults propagation to NESTED for every em.transactional() call, and the defaulted form is harmless — with no active transaction it opens a plain one, and with an active one it joins, which is exactly what this driver can do. What cannot be honoured is a caller who actually wants savepoint semantics, and that is the caller who writes the option out.

Every other propagation mode works as core defines it: REQUIRED, REQUIRES_NEW, SUPPORTS, NOT_SUPPORTED, MANDATORY, NEVER.

Pessimistic locking​

Neo4j takes write locks implicitly and holds them until the transaction ends; there is no statement to acquire one up front. Both entry points say so:

await em.findOne(Book, { id }, { lockMode: LockMode.PESSIMISTIC_WRITE }); // throws
await em.lock(book, LockMode.PESSIMISTIC_WRITE); // throws

The query-level form previously accepted the option and did nothing, which is the more dangerous of the two — a caller believing rows were locked when they were not.

Serialize contended work inside a single em.transactional(), which takes the write locks as it goes, or use optimistic locking with a version property.

Cancellation​

An AbortSignal is honoured on queries, on em.run() and on transactions:

const controller = new AbortController();

const books = em.find(Book, {}, { signal: controller.signal });
const rows = em.run(cypher, params, { signal: controller.signal });

controller.abort();

A signal that has already fired rejects without sending anything to the server. One that fires mid-flight stops the await; if it fires inside a transaction, that transaction rolls back once the in-flight statement settles.

What the strategies actually do​

Neo4j has no per-query cancel on an open session — the mechanism is terminating the session. So:

inflightQueryAbortStrategyHere
'ignore query'Stop awaiting; the statement runs to completion on the server.
'cancel query'Falls back to session termination, since Neo4j offers nothing lighter.
'kill session'Terminates the session — the native mechanism.

Two consequences worth stating:

  • A statement running inside your transaction is not cancelled by ending its session, because that would break the transaction rather than one query. The abort propagates instead, and the transaction rolls back.
  • Aborting a write is not a rollback of work already committed by the server. As with every other dialect, a partially applied write is possible.

Streaming queries degrade to 'ignore query' — the underlying driver accepts only a plain signal for a streamed read, and an open cursor has no server-side cancel.

Streams inside a transaction​

A stream opened inside em.transactional() runs on the transaction's own session and sees its uncommitted writes. Keep the cursor inside the transaction — an open cursor holds the transaction open with it. See streaming.

Testing​

withRollback runs a callback inside a transaction that is always rolled back — see testing.