clotho/README.md

21 KiB

Clotho

A Java OGM (Object-Graph Mapper) for ArcadeDB and OrientDB, built on Apache TinkerPop/Gremlin.

Clotho is not a 1:1 node-to-object mapper. A "Clotho object" is a subgraph — a root vertex plus a declared set of edges and adjacent vertices spanning any number of hops. Complex graph traversals and analytics remain the developer's responsibility via raw Gremlin. Clotho handles bounded object loading and persistence.


Requirements

  • Java 21+
  • Maven 3.9+
  • ArcadeDB (recommended) or OrientDB

Database Support

Database Support level
ArcadeDB Full — all features including transactions, @Version OCC, and batch auto-save
OrientDB Supported — transactions are degraded (per-request semantics only); @Version OCC still works

Clotho connects over the Gremlin WebSocket endpoint (DriverRemoteConnection). No JDBC or native driver is needed.


Installation

<dependency>
    <groupId>com.binarygolem.clotho</groupId>
    <artifactId>clotho-spring-boot-starter</artifactId>
    <version>0.1.0-SNAPSHOT</version>
</dependency>

No @Enable* annotation is needed. Autoconfiguration activates automatically when the starter is on the classpath.

Without Spring (core only)

<dependency>
    <groupId>com.binarygolem.clotho</groupId>
    <artifactId>clotho-core</artifactId>
    <version>0.1.0-SNAPSHOT</version>
</dependency>

Configuration

ArcadeDB (application.properties)

# Required — Gremlin WebSocket endpoint
clotho.gremlin.url=ws://localhost:2480/gremlin

# Credentials
clotho.gremlin.username=root
clotho.gremlin.password=playwithdata

# Traversal source name as configured on ArcadeDB
clotho.gremlin.traversal-source=g

# Packages to scan for @VertexType, @EdgeType, and @ClothoRepository classes
clotho.packages=com.example.myapp.model,com.example.myapp.repository

OrientDB (application.properties)

clotho.gremlin.url=ws://localhost:8182/gremlin
clotho.gremlin.username=root
clotho.gremlin.password=secret
clotho.gremlin.traversal-source=g
clotho.packages=com.example.myapp.model,com.example.myapp.repository

OrientDB limitation: when OrientDB is configured with orient-transactional=false, Clotho detects this at startup and skips g.tx() calls. openTransactionalSession() and Spring @Transactional still compile and run without errors, but they provide no rollback guarantee. @Version OCC works regardless.

Full configuration reference

Property Default Description
clotho.gremlin.url ws://localhost:8182/gremlin WebSocket URL for the Gremlin endpoint
clotho.gremlin.username "" Database username
clotho.gremlin.password "" Database password
clotho.gremlin.traversal-source g Traversal source name on the server
clotho.packages (none) Comma-separated base packages to scan — required
clotho.default-direction OUT Default edge direction for @Via fields (OUT, IN, BOTH)
clotho.unknown-type-behavior STRICT Behaviour when a graph label has no registered Java class: STRICT (throw), NEAREST_ANCESTOR, GENERIC

Core Concepts

Schema-first

Clotho does not generate DDL. Your ArcadeDB or OrientDB schema (vertex types, edge types, properties) must exist before the application starts. Use your database's schema management tooling to create and evolve the schema.

Object boundary

Clotho loads a bounded subgraph per object. Fields annotated with @Include and @Via declare which adjacent edges and vertices belong to an object's boundary. Everything within the boundary is fetched in a single Gremlin project() query — no N+1 problem.


Defining Entities

Vertex type

Vertex classes must extend ClothoVertex and carry @VertexType with the ArcadeDB label.

@VertexType("Person")
@Getter
public class Person extends ClothoVertex {

    @Property("name")
    private @Nullable String name;

    @Property("email")
    private @Nullable String email;

    @Property("age")
    private @Nullable Integer age;

    // Edges and adjacent vertices that belong to this object's boundary
    @Include
    @Via("WORKS_AT")
    private @Nullable List<WorksAt> jobs;

    // Setters MUST call notifyFieldChanged — do NOT use Lombok @Setter on these fields
    public void setName(@Nullable String name) {
        this.name = name;
        notifyFieldChanged("name");
    }

    public void setEmail(@Nullable String email) {
        this.email = email;
        notifyFieldChanged("email");
    }

    public void setAge(@Nullable Integer age) {
        this.age = age;
        notifyFieldChanged("age");
    }

    public void setJobs(@Nullable List<WorksAt> jobs) {
        this.jobs = jobs;
        notifyFieldChanged("jobs");
    }
}

Dirty tracking rule: Any field annotated with @Property, @Include, @OutVertex, @InVertex, or @Version on a ClothoVertex or ClothoEdge subclass must have a manual setter that calls notifyFieldChanged("fieldName"). Lombok @Setter silently bypasses dirty tracking and must not be used on those fields. Lombok is fine for id, outVertexId, and inVertexId because those are managed by the framework.

Edge type

Edge classes must extend ClothoEdge and carry @EdgeType with the edge label.

@EdgeType("WORKS_AT")
@Getter
public class WorksAt extends ClothoEdge {

    public static final String LABEL = "WORKS_AT";

    @Property("role")
    private @Nullable String role;

    @Property("startYear")
    private @Nullable Integer startYear;

    @OutVertex
    private @Nullable Person person;   // source vertex

    @InVertex
    private @Nullable Company company; // target vertex

    public void setRole(@Nullable String role) {
        this.role = role;
        notifyFieldChanged("role");
    }

    public void setStartYear(@Nullable Integer startYear) {
        this.startYear = startYear;
        notifyFieldChanged("startYear");
    }

    public void setCompany(@Nullable Company company) {
        this.company = company;
        notifyFieldChanged("company");
    }
}

Vertex with no included fields

@VertexType("Company")
@Getter
public class Company extends ClothoVertex {

    @Property("name")
    private @Nullable String name;

    @Property("industry")
    private @Nullable String industry;

    public void setName(@Nullable String name) {
        this.name = name;
        notifyFieldChanged("name");
    }

    public void setIndustry(@Nullable String industry) {
        this.industry = industry;
        notifyFieldChanged("industry");
    }
}

Session Usage

ClothoSession is the unit of work. Always use it in a try-with-resources block.

Create

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

    WorksAt job = new WorksAt();
    job.setRole("Senior Engineer");
    job.setStartYear(2020);
    job.setCompany(acme);

    Person alice = new Person();
    alice.setName("Alice Smith");
    alice.setEmail("alice@example.com");
    alice.setJobs(List.of(job));

    session.save(alice); // cascades: alice → job → acme
    System.out.println(alice.getId()); // ARID like "#10:0"
}

Load

try (ClothoSession session = sessionFactory.openSession()) {
    Person alice = session.load(Person.class, ARID.of("#10:0"));
    // alice.getJobs() is already populated — no extra round-trip needed
    System.out.println(alice.getName());
}

Update (auto-save on close)

try (ClothoSession session = sessionFactory.openSession()) {
    Person alice = session.load(Person.class, id);
    alice.setEmail("updated@example.com"); // dirty tracking fires

    // No explicit save() needed — session.close() auto-saves all dirty MANAGED entities
}

You can also call session.save(entity) explicitly at any point.

Delete

try (ClothoSession session = sessionFactory.openSession()) {
    Person alice = session.load(Person.class, id);
    session.delete(alice);
}

No automatic cascade delete: removing an entity from an @Include collection does not delete it from the graph. Call session.delete() explicitly.

Transactions (ArcadeDB)

try (ClothoSession session = sessionFactory.openTransactionalSession()) {
    Company newCo = new Company();
    newCo.setName("NewCo");

    Person bob = new Person();
    bob.setName("Bob");

    session.save(newCo);
    session.save(bob);
} // commits on close; RuntimeException triggers rollback

Repositories

Extend ClothoRepository<T> and annotate with @ClothoRepository. Spring picks it up automatically when it is in a scanned package.

@ClothoRepository
public class PersonRepository extends ClothoRepository<Person> {

    public PersonRepository(ClothoSessionFactory factory) {
        super(factory);
    }

    // Built-in: findAll(), findById(), count(), save(), delete(), findWithPagination()

    public List<Person> findByName(String name) {
        return execute(g().V().has("Person", "name", name));
    }

    public List<Person> findOlderThan(int minAge) {
        return execute(allEntities(), t -> t.has("age", P.gt(minAge)));
    }

    public List<Person> findEmployed() {
        return execute(allEntities(), t -> t.where(__.outE(WorksAt.LABEL)));
    }

    // Pass a session to get MANAGED entities — mutations auto-save on session close
    public List<Person> findCoworkers(Person person, ClothoSession session) {
        return execute(session,
            g().V(person.getId().asString()).as("self")
               .out(WorksAt.LABEL).in(WorksAt.LABEL)
               .where(P.neq("self"))
        );
    }

    public long countByIndustryConn(String industry) {
        return (long) executeRaw(
            g().V().has("Company", "industry", industry)
               .in(WorksAt.LABEL).count()
        ).get(0);
    }
}

Built-in repository operations

Method Description
findAll() All entities of this type with boundaries populated
findAll(filter) Filtered variant
findById(ARID) Returns null when not found
count() / count(filter) Element count
findWithPagination(offset, limit) Paginated fetch
save(T) Persist entity
delete(T) / delete(ARID) Remove entity

Spring @Transactional

When using the Spring Boot starter, ClothoTransactionManager is registered automatically. Annotate service methods with Spring's @Transactional as usual.

@Service
@RequiredArgsConstructor
public class HiringService {

    private final ClothoSessionFactory sessionFactory;

    @Transactional
    public void hire(ARID personId, ARID companyId, String role) {
        try (ClothoSession session = sessionFactory.openSession()) { // borrows the tx session
            Person person = session.load(Person.class, personId);
            Company company = session.load(Company.class, companyId);

            WorksAt job = new WorksAt();
            job.setRole(role);
            job.setStartYear(LocalDate.now().getYear());
            job.setCompany(company);

            person.setJobs(List.of(job));
            // session.close() is a no-op when borrowing — Spring commits on method return
        }
    }
}

OrientDB: @Transactional compiles and runs without errors on OrientDB, but provides no rollback guarantee in orient-transactional=false mode.


Optimistic Locking (@Version)

Add @Version to a Long field on any entity to enable optimistic concurrency control (OCC). Clotho stores the version as a regular graph property (defaulting to _version), checks it before every write, and increments it on success. A concurrent modification throws OptimisticLockException before any data is written.

@VertexType("Order")
@Getter
public class Order extends ClothoVertex {

    @Property("status")
    private @Nullable String status;

    @Version                        // stored as "_version" property; override with @Version("myProp")
    private @Nullable Long version;

    public void setStatus(@Nullable String status) {
        this.status = status;
        notifyFieldChanged("status");
    }
}
int attempts = 0;
while (attempts++ < 3) {
    try (ClothoSession session = sessionFactory.openSession()) {
        Order order = session.load(Order.class, orderId);
        order.setStatus("SHIPPED");
        session.save(order);        // version pre-flight runs here; auto-save on close also works
        break;
    } catch (OptimisticLockException e) {
        if (attempts == 3) throw e;
        // reload fresh state and retry
    }
}

Both ArcadeDB and OrientDB support @Version — it uses a plain graph property, not any database-internal version mechanism.


Custom Version-Checked Updates

session.save() and auto-save handle OCC for field mutations on registered entities. For arbitrary Gremlin write traversals — multi-hop rewrites, conditional graph surgery, updates spanning unregistered types — use session.executeUpdate().

The contract is always the same: read first (so Clotho has the baseline version), declare which elements the traversal touches, then write.

With registered entities

try (ClothoSession session = sessionFactory.openSession()) {
    Order order   = session.load(Order.class, orderId);
    Account acct  = session.load(Account.class, accountId);

    // Arbitrary traversal — Clotho pre-flights versions before it fires
    session.executeUpdate(
        g().V(orderId.asString())
           .property("status", "SHIPPED")
           .sideEffect(__.inV("PLACED_BY").property("orderCount", newCount)),
        order, acct   // both @Version-checked; if either is stale → OptimisticLockException
    );
}

After a successful call, version fields are incremented in both the graph and in memory, and dirty fields are cleared so the session's auto-save on close does not re-apply the same writes.

With unregistered types (VersionGuard)

For graph elements not mapped to a Clotho entity class — GenericVertex, GenericEdge, or any vertex/edge from an unregistered label — use VersionGuard. No entity class declaration is needed.

ARID id = ARID.of("#10:0");

// Read the version from the graph
long ver = (long) executeRaw(g().V(id.asString()).values("_version")).get(0);

// Write with OCC — stale version → OptimisticLockException before the traversal fires
session.executeUpdate(
    g().V(id.asString()).property("status", "archived"),
    VersionGuard.onVertex(id, ver)
);
// Edge guard
session.executeUpdate(
    g().E(edgeId.asString()).property("weight", 0.5),
    VersionGuard.onEdge(edgeId, edgeVersion)
);

// Custom version property name (when not using the default "_version")
VersionGuard.onVertex(id, "myVersionProp", 5L)

// Mix entity guards and VersionGuards in one call
session.executeUpdate(
    complexTraversal,
    registeredOrder,                            // ClothoEntity — @Version checked
    VersionGuard.onVertex(unregisteredId, ver)  // raw vertex — VersionGuard checked
);

Race condition behaviour

The version pre-flight is atomic: if any guarded element is stale, OptimisticLockException is thrown before the traversal runs. This means either all guards pass and the traversal executes, or nothing is written.

// Concurrent writers both read version 0
// Writer A succeeds → graph version becomes 1
// Writer B (still holding version 0) → OptimisticLockException, traversal never ran

The retry pattern is the same regardless of whether you use entities or VersionGuard:

int attempts = 0;
while (attempts++ < 3) {
    try (ClothoSession session = sessionFactory.openSession()) {
        Order order = session.load(Order.class, orderId);
        session.executeUpdate(
            g().V(orderId.asString()).property("status", "SHIPPED"),
            order
        );
        break;
    } catch (OptimisticLockException e) {
        if (attempts == 3) throw e;
    }
}

executeRaw is for reads only

Use executeRaw() for traversals that return scalars, counts, or property values — things that produce no entity. Never use it to write to versioned elements; it bypasses all version checking entirely.


Lazy Loading

By default, all @Include fields are loaded eagerly when their parent vertex is loaded. Declare fetchType = FetchType.LAZY to skip a field during the initial load. Populate it on demand with session.loadField().

@VertexType("Department")
@Getter
public class Department extends ClothoVertex {

    @Property("name")
    private @Nullable String name;

    @Include(fetchType = FetchType.LAZY)
    @Via("MEMBER_OF")
    @Direction(TraversalDirection.IN)
    private @Nullable List<Person> members; // null until explicitly loaded

    // setter...
}
try (ClothoSession session = sessionFactory.openSession()) {
    Department dept = session.load(Department.class, deptId);
    // dept.getMembers() is null here

    session.loadField(dept, "members");
    // now populated
    System.out.println(dept.getMembers().size());
}

Subgraph Queries

A @SubgraphType is a composite Java object assembled from multiple vertices and edges via a Gremlin select() traversal. It does not extend ClothoVertex or ClothoEdge, and it has no ID of its own.

@SubgraphType
public class EmploymentSummary {
    Person person;               // field name matches Gremlin as("person")
    @Alias("employer") Company company; // Gremlin alias "employer", field name "company"
    WorksAt job;
}
@ClothoRepository
public class EmploymentRepository extends ClothoRepository<Person> {

    public EmploymentRepository(ClothoSessionFactory factory) {
        super(factory);
    }

    public List<EmploymentSummary> findAll() {
        return executeSubgraph(EmploymentSummary.class,
            g().V().hasLabel("Person").as("person")
               .outE(WorksAt.LABEL).as("job")
               .inV().as("employer")
               .select("person", "job", "employer")
        );
    }
}

Inheritance

Single-parent

Simply extend the parent Clotho class.

@VertexType("Manager")
public class Manager extends Employee { ... }

Diamond (multi-parent) inheritance

ArcadeDB supports multi-parent inheritance that Java cannot express. Use additionalParents to declare extra ArcadeDB parent labels.

@VertexType(value = "SeniorManager", additionalParents = {"Auditor"})
public class SeniorManager extends Manager { ... }

Divergent Java/ArcadeDB hierarchy

When the ArcadeDB parent label differs from the Java parent class label, use parentLabel.

@VertexType(value = "ContractEmployee", parentLabel = "Employee")
public class ContractEmployee extends Person { ... }

Without Spring

Construct ClothoSessionFactory manually using its builder.

Cluster cluster = Cluster.build()
    .addContactPoint("localhost")
    .port(2480)
    .credentials("root", "playwithdata")
    .create();

DriverRemoteConnection connection = DriverRemoteConnection.using(cluster, "g");
GraphTraversalSource g = AnonymousTraversalSource.traversal().withRemote(connection);

TypeRegistry registry = TypeRegistry.scan("com.example.myapp.model");

ClothoSessionFactory sessionFactory = ClothoSessionFactory.builder()
    .g(g)
    .registry(registry)
    .unknownTypeBehavior(UnknownTypeBehavior.STRICT)
    .build();

// Use sessionFactory ...

// Shutdown
sessionFactory.close();
connection.close();
cluster.close();

Important Limitations

  • No DDL generation. Clotho is schema-first. Create and migrate your database schema externally.
  • Not thread-safe. Sessions bind to the current thread via ThreadLocal. Use one session per thread (or per virtual thread).
  • No transparent proxies. Lazy loading requires an explicit session.loadField() call. There is no bytecode enhancement.
  • No automatic cascade delete. Removing an entity from an @Include collection does not delete it from the graph. Call session.delete() explicitly.
  • Manual setters required. @Property, @Include, @OutVertex, @InVertex, and @Version fields must use hand-written setters that call notifyFieldChanged(). Lombok @Setter must not be used on those fields.
  • OrientDB transactions. In orient-transactional=false mode, openTransactionalSession() and @Transactional work at per-request granularity only. No multi-request rollback is available.

Further Reading

Document Contents
docs/TYPE_SYSTEM.md Entity hierarchy, annotation reference, inheritance, result mapping
docs/OBJECT_BOUNDARY.md @Include/@Via, multi-hop, edge-only inclusion, cycle detection
docs/ANNOTATIONS.md Complete annotation reference with all attributes
docs/QUERY_MODEL.md Repository pattern, @SubgraphType assembly, return type rules
docs/SPRING_INTEGRATION.md Autoconfigure, @Transactional, full application.properties reference
docs/TRANSACTIONS.md Transactional sessions, Spring @Transactional, @Version interaction, ArcadeDB/OrientDB limitations
docs/ARCHITECTURE.md Module breakdown, data flow, session identity map, package structure