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

342 lines
13 KiB
Markdown

# 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
```java
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):
```java
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.
```java
@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).
```java
@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:
```java
@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:
```java
@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:
```java
@Bean
public ClothoTransactionManager clothoTransactionManager(ClothoSessionFactory factory) {
return new ClothoTransactionManager(factory);
}
```
Then annotate service methods:
```java
@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.
### Recommended session pattern
Load everything you intend to modify at the start of the session, make your changes, then
let `close()` flush them together:
```java
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.