13 KiB
Clotho Transactions
Clotho provides two levels of transactional control:
- Optimistic locking via
@Version— detects concurrent modifications on individual entities at write time. Does not require a database transaction. - 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). @Versionprevents 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.
Recommended session pattern
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:
- Pre-flight — one request checks that every
@Version-guarded entity still has its expected version in the database. If any version is stale,OptimisticLockExceptionis thrown immediately and no properties are written. - Batch update — one request writes all dirty properties for all entities, plus the
incremented version for each
@Versionentity.
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 →
OptimisticLockExceptionthrown - 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
0in the graph; the in-memory field is set to0. - On load: the version is read like any other property.
- On update: the pre-flight filter is
.has(versionProp, currentVersion). If matched, the new versioncurrentVersion + 1is 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 byclose()/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
@Transactionalbridge (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 useexecute(session, traversal)for transactional repository queries.ClothoSessionis 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.