342 lines
13 KiB
Markdown
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.
|