clotho/docs/TRANSACTIONS.md
2026-05-15 00:09:01 +02:00

13 KiB

Clotho Transactions

Clotho provides two levels of transactional control:

  1. Optimistic locking via @Version — detects concurrent modifications on individual entities at write time. Does not require a database transaction.
  2. Transactional sessions — wraps all reads and writes in a single ArcadeDB transaction, providing atomicity for multi-step operations.

This document covers transactional sessions.


Why transactions?

Without a transaction:

  • Each Gremlin traversal is auto-committed by ArcadeDB immediately.
  • A multi-step save that fails midway leaves the graph in a partial state.
  • Two concurrent sessions can read and overwrite each other's changes without any isolation guarantee.

With a transactional session, all operations share a single transaction scope. Either everything commits or nothing does.


Manual transactional sessions

ClothoSession session = sessionFactory.openTransactionalSession();
try {
    Company acme = new Company();
    acme.setName("Acme Corp");

    Person alice = new Person();
    alice.setName("Alice");

    session.save(alice);
    session.save(acme);
    session.close();   // auto-save dirty entities + tx.commit()
} catch (RuntimeException e) {
    session.rollback();   // tx.rollback(), cache cleared, session unbound
    throw e;
}

Or use try-with-resources (preferred — close() commits on success, rolls back on exception):

try (ClothoSession session = sessionFactory.openTransactionalSession()) {
    session.save(alice);
    session.save(acme);
}   // ← tx.commit() fires here; RuntimeException triggers tx.rollback()

Spring @Transactional

When using the Spring Boot starter, ClothoTransactionManager is registered automatically (conditional on no other PlatformTransactionManager bean being present). This wires Clotho into Spring's standard @Transactional machinery.

Obtaining the session inside a transactional method

Option A — openSession() (recommended): behaves correctly in and out of a transaction. Inside a @Transactional method the active session is borrowed (no new session created); outside, a new non-transactional session is opened.

@Service
@RequiredArgsConstructor
public class PersonService {

    private final ClothoSessionFactory sessionFactory;
    private final PersonRepository personRepo;

    @Transactional
    public void promote(ARID personId) {
        try (ClothoSession session = sessionFactory.openSession()) {  // borrows tx session
            Person p = session.load(Person.class, personId);
            p.setRole("SENIOR");
            // auto-saved on session close; tx committed by Spring tx manager
        }
    }
}

Option B — getCurrentSession(): throws ClothoException if no session is active. Use when you explicitly require a session to already be bound (e.g. in a helper that must only be called from within a @Transactional context).

@Transactional
public void doWork() {
    ClothoSession session = sessionFactory.getCurrentSession();
    // ...
}

Repository queries inside a transaction

ClothoRepository.execute(traversal) (no-session overload) uses the factory's base GraphTraversalSource and is not part of the transaction. Use the session-aware overload instead:

@Transactional
public void updateEngineers() {
    try (ClothoSession session = sessionFactory.openSession()) {
        List<Person> engineers = personRepo.execute(session,
            personRepo.allEntities(), t -> t.has("role", "Engineer"));
        for (Person p : engineers) {
            p.setRole("Senior Engineer");
            // dirty tracking fires; auto-saved on session close
        }
    }
}

Nested @Transactional methods

Spring's default propagation is REQUIRED: an inner @Transactional method joins the outer transaction rather than starting a new one. Clotho honours this via the borrow counter:

@Transactional          // outer — opens a transactional session
public void outer() {
    inner();            // inner joins; no new session or commit
    // commit happens here when outer returns
}

@Transactional          // inner — borrows the outer session
public void inner() {
    // ... same transaction
}

If you need the inner method to run in its own transaction, use @Transactional(propagation = Propagation.REQUIRES_NEW). Clotho does not currently support REQUIRES_NEW natively — the inner openSession() will still borrow the outer session. If true nested transaction isolation is required, manage sessions manually with separate openTransactionalSession() calls.


Apps with another transaction manager (JPA, R2DBC)

ClothoTransactionManager registers with @ConditionalOnMissingBean(PlatformTransactionManager.class). If your application already has a JPA or R2DBC transaction manager, the Clotho bean is skipped.

To use Clotho transactions alongside another tx manager, declare the bean explicitly and qualify it:

@Bean
public ClothoTransactionManager clothoTransactionManager(ClothoSessionFactory factory) {
    return new ClothoTransactionManager(factory);
}

Then annotate service methods:

@Transactional("clothoTransactionManager")
public void myGraphOperation() { ... }

Or declare Clotho's manager as @Primary if it is the dominant transaction manager in your application.


Relationship between @Version and transactions

@Version provides optimistic concurrency control — it detects conflicts at write time by checking the entity's application-managed version counter against what is stored in the database. It works independently of the transactional session API.

Combining both:

  • The batch auto-save at close() provides atomic writing of all dirty entities in one Gremlin request (the only atomicity guarantee available over ArcadeDB's Gremlin WebSocket).
  • @Version prevents lost updates when two sessions modify the same entity concurrently, via a two-step check-then-write that fails the entire batch if any version is stale.

A @Version check failure throws OptimisticLockException during the pre-flight step. Inside a transactional session, the exception propagates out of close(). Since no properties were written during the pre-flight, no rollback is necessary (but close() will attempt g.tx().rollback() regardless, which is a no-op with ArcadeDB Gremlin).


Session auto-close behaviour in transactions

Scenario What happens on session.close()
Non-transactional Auto-saves dirty managed entities; clears cache; unbinds
Transactional (new) Auto-saves dirty managed entities; tx.commit(); clears cache; unbinds
Transactional (borrowed) Decrements borrow count; no-op until count reaches 0
Exception during auto-save tx.rollback(); clears cache; unbinds; re-throws

Call session.rollback() explicitly to abort the transaction at any point without saving. rollback() always resets the borrow count to 0 and unbinds immediately.


Batch auto-save and the safe session pattern

When session.close() triggers auto-save, Clotho collects every dirty MANAGED entity from the session cache — vertices and edges — and persists them in a single Gremlin traversal:

inject(0).union(
  __.V(id1).property("name", "Alice"),   // dirty vertex
  __.V(id2).property("dept", "Eng"),     // dirty vertex
  __.E(eid).property("role", "Senior"),  // dirty edge
  ...
).iterate()

This collapses N round-trips into one, making the auto-save as efficient as possible and treating all updates as a single ArcadeDB request.

Load everything you intend to modify at the start of the session, make your changes, then let close() flush them together:

try (ClothoSession session = sessionFactory.openSession()) {
    Person alice   = session.load(Person.class, aliceId);   // load upfront
    Person bob     = session.load(Person.class, bobId);
    Company acme   = session.load(Company.class, acmeId);

    // ... fetch any read-only helper data here too ...

    alice.setEmail("alice@new.example.com");   // dirty tracking fires
    bob.setRole("Senior Engineer");
    acme.setName("Acme Corp International");
}
// ↑ single batch traversal flushes all three changes here

The more entities you modify before closing, the more work is consolidated into that one round-trip.

Atomicity — what "single request" means here

ArcadeDB auto-commits each incoming Gremlin request as one database operation. Because the auto-save batch is one request, updates to entities that pass their checks are committed together. Any interruption (network failure, server error) affects the entire request.

However, there are two important caveats:

Explicit session.save() calls mid-session are NOT batched. Each call to session.save(entity) issues an immediate individual traversal. Only the auto-save triggered by session.close() uses the batch path.

@Version conflicts fail the whole batch. The batch auto-save runs in two Gremlin round-trips:

  1. Pre-flight — one request checks that every @Version-guarded entity still has its expected version in the database. If any version is stale, OptimisticLockException is thrown immediately and no properties are written.
  2. Batch update — one request writes all dirty properties for all entities, plus the incremented version for each @Version entity.

Concretely: if session has dirty entities A (no @Version), B (@Version, stale), and C (@Version, current), the outcome is:

  • Pre-flight detects B's stale version → OptimisticLockException thrown
  • Neither A, B, nor C has been written yet

There is a narrow TOCTOU window between the pre-flight and the write (a concurrent session could change B's version in that gap), but within that window only already-confirmed entities would be overwritten.

@Version — application-managed OCC

@Version does not rely on ArcadeDB's internal @version property (which is not accessible via the Gremlin WebSocket interface). Instead, Clotho manages the counter as a regular graph property:

  • On create: the version property is initialised to 0 in the graph; the in-memory field is set to 0.
  • On load: the version is read like any other property.
  • On update: the pre-flight filter is .has(versionProp, currentVersion). If matched, the new version currentVersion + 1 is written atomically in the same traversal step. The in-memory field is updated after a successful write.

The property name defaults to "_version" and can be overridden via @Version("myProp").

ArcadeDB Gremlin transaction limitations

ArcadeDB's Gremlin WebSocket interface returns a transaction-bound GraphTraversalSource from g.tx().begin(), but subsequent Gremlin bytecode operations on that source fail at runtime. openTransactionalSession() therefore uses the base traversal source — the same one used by non-transactional sessions. The practical consequences:

  • Each Gremlin request is auto-committed by ArcadeDB immediately.
  • g.tx().commit() / g.tx().rollback() (called by close() / rollback()) are effective no-ops in this mode.
  • The batch auto-save at close() is still a single Gremlin request, so it is atomic at the request level.
  • The borrow counter and Spring @Transactional bridge (ClothoTransactionManager) work correctly and provide the expected API surface.

True multi-request atomicity requires ArcadeDB's native Java or HTTP API directly.

OrientDB Gremlin transaction limitations

OrientDB with orient-transactional=false (required so that addV returns real record IDs immediately rather than temporary #-1:-2 IDs) rejects g.tx() calls entirely, throwing Graph does not support transactions.

At startup, ClothoSessionFactory probes transaction support by calling g.tx().rollback(). If the graph rejects the call with the "does not support transactions" message, all g.tx().commit() and g.tx().rollback() calls are skipped for every session. The transactional flag still drives the batch auto-save path and the borrow/Spring @Transactional semantics — only the explicit g.tx() calls are suppressed.

  • Each Gremlin request auto-commits individually; the batch auto-save in close() provides request-level atomicity (one request = all dirty entity writes).
  • True multi-request transactions require OrientDB's native Java or binary protocol.

Limitations

  • ClothoRepository.execute(traversal) without a session is NOT part of the current transaction. Always use execute(session, traversal) for transactional repository queries.
  • ClothoSession is NOT thread-safe. One session per thread / virtual thread.
  • ArcadeDB requires the schema (vertex and edge types) to exist before any write. Clotho does not manage schema creation.