From 3f094720873eb2c8fa48a97c22c47869032439d9 Mon Sep 17 00:00:00 2001 From: Rasmus Neikes Date: Sat, 16 May 2026 13:24:56 +0200 Subject: [PATCH] updated versioning to cover bases types, added gitea support --- .../clotho/mapping/SubgraphPersistor.java | 135 ++++++++++++ .../clotho/repository/ClothoRepository.java | 88 ++++++++ .../clotho/session/ClothoSession.java | 77 ++++++- .../clotho/mapping/SubgraphPersistorTest.java | 208 ++++++++++++++++++ clotho-dev-app/pom.xml | 4 + pom.xml | 12 + 6 files changed, 521 insertions(+), 3 deletions(-) diff --git a/clotho-core/src/main/java/com/binarygolem/clotho/mapping/SubgraphPersistor.java b/clotho-core/src/main/java/com/binarygolem/clotho/mapping/SubgraphPersistor.java index 5339d84..499d0e8 100644 --- a/clotho-core/src/main/java/com/binarygolem/clotho/mapping/SubgraphPersistor.java +++ b/clotho-core/src/main/java/com/binarygolem/clotho/mapping/SubgraphPersistor.java @@ -12,6 +12,7 @@ import com.binarygolem.clotho.model.ClothoEntity; import com.binarygolem.clotho.model.ClothoVertex; import com.binarygolem.clotho.model.EntityState; import com.binarygolem.clotho.model.TraversalDirection; +import com.binarygolem.clotho.model.VersionGuard; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversal; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__; @@ -88,6 +89,116 @@ public final class SubgraphPersistor { } } + /** + * Executes an arbitrary developer-supplied Gremlin update traversal under optimistic + * concurrency protection for the declared {@code guarded} entities. + * + *

Execution proceeds in three Gremlin round-trips: + *

    + *
  1. Version pre-flight: verify every guarded entity that carries + * {@code @Version} still holds its expected version in the graph. + * Any mismatch throws {@link OptimisticLockException} immediately — the + * user traversal is never executed.
  2. + *
  3. User traversal: {@code userTraversal.iterate()}.
  4. + *
  5. Version increment: a {@code union()} of + * {@code .V/E(id).property(versionProp, v+1)} branches — one per + * {@code @Version}-carrying guarded entity.
  6. + *
+ * + *

Entities without a {@code @Version} field are silently skipped in steps 1 and 3; + * the user traversal still executes regardless. Entities where the {@code @Version} + * value is {@code null} (not yet persisted) are also skipped. + * + *

After execution, {@link ClothoEntity#clearDirtyFields()} is called on every + * guarded entity to prevent the session's auto-save from re-applying the same + * mutations on close. + * + *

TOCTOU caveat: a narrow gap exists between the pre-flight (step 1) and + * the version increment (step 3). This is the same inherent limitation as + * {@link #persistAllDirty} and cannot be eliminated without native ArcadeDB + * transaction support over the Gremlin WebSocket interface. + * + * @param userTraversal the arbitrary update traversal to execute; consumed by + * {@code .iterate()} — do not reuse after this call + * @param guarded the entities whose {@code @Version} fields gate the write + * @throws OptimisticLockException if any versioned guarded entity is stale + */ + public void executeVersionedWrite(GraphTraversal userTraversal, + List guarded) { + List versionChecks = new ArrayList<>(); + for (ClothoEntity entity : guarded) collectVersionExpectationUnconditional(entity, versionChecks); + runVersionedWrite(userTraversal, versionChecks); + Map versionMap = new HashMap<>(); + for (VersionedExpectation ve : versionChecks) versionMap.put(ve.id(), ve); + for (ClothoEntity entity : guarded) { + bumpVersionField(entity, versionMap); + entity.clearDirtyFields(); + } + } + + /** + * Variant of {@link #executeVersionedWrite(GraphTraversal, List)} that accepts + * {@link VersionGuard} instances instead of loaded Clotho entities. + * + *

Use this when the graph elements being updated are not mapped to a registered + * Clotho entity class — for example {@link com.binarygolem.clotho.model.GenericVertex}, + * {@link com.binarygolem.clotho.model.GenericEdge}, or vertices and edges from labels + * with no {@code @VertexType}/{@code @EdgeType} declaration. + * + *

The same three-round-trip execution model and TOCTOU caveat apply. + * Because {@link VersionGuard} is a value type, there is no in-memory state to + * update after the call — the caller must create a new guard (or reload the entity) + * before the next version-checked write. + * + * @param userTraversal the arbitrary update traversal to execute; consumed — do not reuse + * @param guards version expectations for each graph element the traversal touches + * @throws OptimisticLockException if any guard's expected version does not match the graph + */ + public void executeVersionedWrite(GraphTraversal userTraversal, + VersionGuard... guards) { + List versionChecks = java.util.Arrays.stream(guards) + .map(vg -> new VersionedExpectation( + vg.id(), vg.expectedVersion(), vg.versionProperty(), vg.vertex())) + .toList(); + runVersionedWrite(userTraversal, versionChecks); + } + + /** + * Shared execution core for both {@code executeVersionedWrite} overloads: + * pre-flight check → user traversal → version increment. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private void runVersionedWrite(GraphTraversal userTraversal, + List versionChecks) { + // --- 1. Version pre-flight --- + if (!versionChecks.isEmpty()) { + long matched = (long) unionOf(g.inject(0), + versionChecks.stream().map(VersionedExpectation::freshBranch).toList()) + .count().next(); + if (matched < versionChecks.size()) { + Set present = new HashSet<>( + (List) (List) unionOf(g.inject(0), + versionChecks.stream().map(VersionedExpectation::freshBranch).toList()) + .toList() + ); + VersionedExpectation conflict = versionChecks.stream() + .filter(c -> !present.contains(c.id().asString())) + .findFirst().orElseThrow(); + throw new OptimisticLockException(conflict.id(), conflict.expectedVersion()); + } + } + + // --- 2. Execute the developer's traversal --- + userTraversal.iterate(); + + // --- 3. Version increment --- + if (!versionChecks.isEmpty()) { + unionOf(g.inject(0), + versionChecks.stream().map(SubgraphPersistor::buildVersionIncrementBranch).toList()) + .iterate(); + } + } + /** * Persists all dirty MANAGED entities as two Gremlin round-trips that together provide * all-or-nothing semantics for {@code @Version} conflicts: @@ -188,6 +299,30 @@ public final class SubgraphPersistor { checks.add(new VersionedExpectation(entity.getId(), ver, vProp, entity instanceof ClothoVertex)); } + /** + * Like {@link #collectVersionExpectation} but ignores dirty-field status. + * Used by {@link #executeVersionedWrite} where the caller declares which entities + * their traversal touches — regardless of whether any setter was called locally. + */ + private static void collectVersionExpectationUnconditional(ClothoEntity entity, + List checks) { + if (entity.getId() == null) return; + Field vf = FieldMetadataCache.get(entity.getClass()).versionField(); + if (vf == null) return; + Long ver = (Long) readField(vf, entity); + if (ver == null) return; + String vProp = vf.getAnnotation(com.binarygolem.clotho.annotation.Version.class).value(); + checks.add(new VersionedExpectation(entity.getId(), ver, vProp, entity instanceof ClothoVertex)); + } + + /** Builds a version-only increment branch — no dirty field writes. */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private static GraphTraversal buildVersionIncrementBranch(VersionedExpectation ve) { + String idStr = ve.id().asString(); + GraphTraversal t = ve.isVertex() ? __.V(idStr) : __.E(idStr); + return t.property(ve.versionProp(), ve.expectedVersion() + 1); + } + /** Builds one anonymous update branch: writes dirty @Property fields and increments @Version. */ @SuppressWarnings({"unchecked", "rawtypes"}) private void buildUpdateBranch(ClothoEntity entity, boolean isVertex, diff --git a/clotho-core/src/main/java/com/binarygolem/clotho/repository/ClothoRepository.java b/clotho-core/src/main/java/com/binarygolem/clotho/repository/ClothoRepository.java index 9516c8f..392afbb 100644 --- a/clotho-core/src/main/java/com/binarygolem/clotho/repository/ClothoRepository.java +++ b/clotho-core/src/main/java/com/binarygolem/clotho/repository/ClothoRepository.java @@ -12,6 +12,7 @@ import com.binarygolem.clotho.model.ARID; import com.binarygolem.clotho.model.ClothoEdge; import com.binarygolem.clotho.model.ClothoEntity; import com.binarygolem.clotho.model.ClothoVertex; +import com.binarygolem.clotho.model.VersionGuard; import com.binarygolem.clotho.session.ClothoSession; import com.binarygolem.clotho.session.ClothoSessionFactory; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversal; @@ -20,6 +21,7 @@ import org.apache.tinkerpop.gremlin.structure.Edge; import org.apache.tinkerpop.gremlin.structure.Vertex; import org.jspecify.annotations.Nullable; +import java.lang.reflect.Field; import java.lang.reflect.ParameterizedType; import java.util.ArrayList; import java.util.List; @@ -253,6 +255,43 @@ public abstract class ClothoRepository { .toList(); } + /** + * Like {@link #executeSubgraph(Class, GraphTraversal)} but integrates with the session's + * identity map. Every {@link ClothoVertex} and {@link ClothoEdge} field declared on the + * assembled {@code @SubgraphType} POJO is registered as MANAGED — mutations via setters + * are tracked and auto-saved on session close, with full {@code @Version} pre-flight + * checking applied to every dirty entity. + * + *

Use this when complex multi-hop queries produce results you intend to mutate: + *

{@code
+     * try (ClothoSession session = sessionFactory.openSession()) {
+     *     List results = employmentRepo.executeSubgraph(
+     *         session, EmploymentSummary.class,
+     *         g().V().hasLabel("Person").as("person")
+     *                .outE("WORKS_AT").as("job")
+     *                .inV().as("company")
+     *                .select("person", "job", "company"));
+     *     results.forEach(s -> s.person().setStatus("ACTIVE")); // dirty — auto-saved
+     * } // pre-flight version check + batch write on close
+     * }
+ */ + @SuppressWarnings("unchecked") + public List executeSubgraph(ClothoSession session, Class subgraphType, + GraphTraversal traversal) { + return traversal.toList().stream() + .map(item -> { + if (!(item instanceof Map m)) { + throw new ClothoException( + "executeSubgraph() expected Map results but got: " + + item.getClass().getName()); + } + S assembled = assembler.assemble(subgraphType, (Map) m); + registerSubgraphEntities(assembled, session); + return assembled; + }) + .toList(); + } + /** * Executes a traversal and returns the raw results without any Clotho mapping. * Use for scalar results such as counts, property values, or aggregations. @@ -266,6 +305,17 @@ public abstract class ClothoRepository { return (List) traversal.toList(); } + private void registerSubgraphEntities(Object subgraphPojo, ClothoSession session) { + for (Field field : subgraphPojo.getClass().getDeclaredFields()) { + try { + field.setAccessible(true); + Object value = field.get(subgraphPojo); + if (value instanceof ClothoVertex v) session.register(v); + else if (value instanceof ClothoEdge e) session.register(e); + } catch (IllegalAccessException ignored) {} + } + } + // --- Built-in operations --- /** Returns all entities of this repository's type, with boundaries populated. */ @@ -339,6 +389,44 @@ public abstract class ClothoRepository { } } + /** + * Executes an arbitrary Gremlin update traversal with {@code @Version} pre-flight + * checking on the declared {@code guarded} entities, integrating with the session's + * identity map and auto-save lifecycle. + * + *

All guarded entities that carry {@code @Version} are verified before the traversal + * fires. A stale version throws {@link com.binarygolem.clotho.exception.OptimisticLockException} + * and the traversal is never executed. On success, version fields are incremented and + * {@code clearDirtyFields()} is called on every guarded entity so the session's + * auto-save on close does not re-apply the same writes. + * + *

Guarded entities must be loaded via the session before being passed here. + * + * @param session the active Clotho session + * @param traversal the update traversal; consumed — do not reuse after this call + * @param guarded entities whose {@code @Version} fields gate the write + * @throws com.binarygolem.clotho.exception.OptimisticLockException if any versioned + * entity is stale + */ + public void executeUpdate(ClothoSession session, GraphTraversal traversal, + ClothoEntity... guarded) { + session.executeUpdate(traversal, guarded); + } + + /** + * Variant of {@link #executeUpdate(ClothoSession, GraphTraversal, ClothoEntity...)} + * for graph elements not mapped to a registered Clotho entity class. + * + * @param session the active Clotho session + * @param traversal the update traversal; consumed — do not reuse after this call + * @param guards version expectations for each graph element the traversal touches + * @throws com.binarygolem.clotho.exception.OptimisticLockException if any guard is stale + */ + public void executeUpdate(ClothoSession session, GraphTraversal traversal, + VersionGuard... guards) { + session.executeUpdate(traversal, guards); + } + /** * Removes the entity from the graph. * diff --git a/clotho-core/src/main/java/com/binarygolem/clotho/session/ClothoSession.java b/clotho-core/src/main/java/com/binarygolem/clotho/session/ClothoSession.java index 1dc2337..515c7f5 100644 --- a/clotho-core/src/main/java/com/binarygolem/clotho/session/ClothoSession.java +++ b/clotho-core/src/main/java/com/binarygolem/clotho/session/ClothoSession.java @@ -12,6 +12,8 @@ import com.binarygolem.clotho.model.ClothoEdge; import com.binarygolem.clotho.model.ClothoEntity; import com.binarygolem.clotho.model.ClothoVertex; import com.binarygolem.clotho.model.EntityState; +import com.binarygolem.clotho.model.VersionGuard; +import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversal; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; import org.jspecify.annotations.Nullable; @@ -225,6 +227,67 @@ public class ClothoSession implements AutoCloseable { return transactional; } + /** + * Executes an arbitrary Gremlin update traversal with {@code @Version} pre-flight + * checking on the declared {@code guarded} entities. + * + *

All guarded entities that carry {@code @Version} are verified before the traversal + * fires. A stale version throws {@link com.binarygolem.clotho.exception.OptimisticLockException} + * and the traversal is never executed. On success, version fields are incremented in + * both the graph and in memory, and {@code clearDirtyFields()} is called on every + * guarded entity so the session's auto-save on close does not re-apply the same writes. + * + *

Guarded entities must be loaded via this session (or explicitly + * {@link #register}-ed) before being passed here so that Clotho holds the correct + * baseline version. + * + *

Example: + *

{@code
+     * Order order = session.load(Order.class, orderId);
+     * Account acct = session.load(Account.class, accountId);
+     *
+     * session.executeUpdate(
+     *     g().V(orderId.asString()).property("status", "SHIPPED")
+     *        .sideEffect(__.inV("PLACED_BY").property("orderCount", newCount)),
+     *     order, acct
+     * );
+     * }
+ * + * @param traversal the update traversal; consumed — do not reuse after this call + * @param guarded entities whose {@code @Version} fields gate the write + * @throws com.binarygolem.clotho.exception.OptimisticLockException if any versioned + * entity is stale + */ + public void executeUpdate(GraphTraversal traversal, ClothoEntity... guarded) { + persistor.executeVersionedWrite(traversal, List.of(guarded)); + } + + /** + * Variant of {@link #executeUpdate(GraphTraversal, ClothoEntity...)} for graph elements + * that are not mapped to a registered Clotho entity class — such as + * {@link com.binarygolem.clotho.model.GenericVertex}, + * {@link com.binarygolem.clotho.model.GenericEdge}, or any vertex/edge from a label + * with no {@code @VertexType}/{@code @EdgeType} declaration. + * + *

Example — reading the version and writing with OCC on an unregistered vertex: + *

{@code
+     * ARID id = ARID.of("#10:0");
+     * long ver = (long) executeRaw(g().V(id.asString()).values("_version")).get(0);
+     *
+     * session.executeUpdate(
+     *     g().V(id.asString()).property("status", "archived"),
+     *     VersionGuard.onVertex(id, ver)
+     * );
+     * }
+ * + * @param traversal the update traversal; consumed — do not reuse after this call + * @param guards version expectations for each graph element the traversal touches + * @throws com.binarygolem.clotho.exception.OptimisticLockException if any guard is stale + */ + public void executeUpdate(GraphTraversal traversal, VersionGuard... guards) { + persistor.executeVersionedWrite(traversal, guards); + } + /** * Evicts all loaded objects from the session identity map without touching the database. * Forces a fresh graph fetch on the next {@link #load} call for any previously-loaded ID. @@ -305,15 +368,23 @@ public class ClothoSession implements AutoCloseable { Object value = readField(field, vertex); if (value instanceof List list) { for (Object item : list) { - if (item instanceof ClothoEdge e) registerEdge(e); + if (item instanceof ClothoEdge e) register(e); else if (item instanceof ClothoVertex v) register(v); } - } else if (value instanceof ClothoEdge e) registerEdge(e); + } else if (value instanceof ClothoEdge e) register(e); else if (value instanceof ClothoVertex v) register(v); } } - private void registerEdge(ClothoEdge edge) { + /** + * Registers an already-hydrated edge (and its {@code @OutVertex}/{@code @InVertex} + * vertices) into this session's identity map as MANAGED. + * After this call, dirty-field tracking and auto-save-on-close apply to the edge. + * + *

Entities already cached by this session are skipped, so calling register() + * on a graph with shared references is safe and cycle-free. + */ + public void register(ClothoEdge edge) { if (edge.getId() == null || edgeCache.containsKey(edge.getId())) return; markManaged(edge); edgeCache.put(edge.getId(), edge); diff --git a/clotho-core/src/test/java/com/binarygolem/clotho/mapping/SubgraphPersistorTest.java b/clotho-core/src/test/java/com/binarygolem/clotho/mapping/SubgraphPersistorTest.java index 243a231..03c99c6 100644 --- a/clotho-core/src/test/java/com/binarygolem/clotho/mapping/SubgraphPersistorTest.java +++ b/clotho-core/src/test/java/com/binarygolem/clotho/mapping/SubgraphPersistorTest.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test; import com.binarygolem.clotho.annotation.Version; import com.binarygolem.clotho.exception.OptimisticLockException; import com.binarygolem.clotho.model.EntityState; +import com.binarygolem.clotho.model.VersionGuard; import java.util.List; @@ -488,4 +489,211 @@ class SubgraphPersistorTest { assertEquals(4L, g.V("vi-6").values("_version").next()); assertEquals(4L, y.version); } + + // --- executeVersionedWrite with ClothoEntity --- + + @Test + void executeVersionedWrite_entity_executesTraversalAndIncrementsVersion() { + graph.addVertex(T.id, "ev-1", T.label, "VersionedItem", "label", "original", "_version", 0L); + + VersionedItem item = new VersionedItem(); + item.setId(ARID.of("ev-1")); + item.version = 0L; + item.setState(EntityState.MANAGED); + + persistor.executeVersionedWrite( + g.V("ev-1").property("label", "from-traversal"), + List.of(item)); + + assertEquals("from-traversal", g.V("ev-1").values("label").next()); + assertEquals(1L, g.V("ev-1").values("_version").next(), "version must be incremented in graph"); + assertEquals(1L, item.version, "in-memory version must be bumped"); + } + + @Test + void executeVersionedWrite_entity_throwsBeforeTraversalOnStaleVersion() { + graph.addVertex(T.id, "ev-2", T.label, "VersionedItem", "label", "untouched", "_version", 3L); + + VersionedItem item = new VersionedItem(); + item.setId(ARID.of("ev-2")); + item.version = 1L; // stale — graph has 3 + item.setState(EntityState.MANAGED); + + assertThrows(OptimisticLockException.class, () -> + persistor.executeVersionedWrite( + g.V("ev-2").property("label", "should-not-apply"), + List.of(item))); + + assertEquals("untouched", g.V("ev-2").values("label").next(), "traversal must not have run"); + assertEquals(3L, g.V("ev-2").values("_version").next(), "version must be unchanged"); + } + + @Test + void executeVersionedWrite_entity_clearsDirtyFieldsOnSuccess() { + graph.addVertex(T.id, "ev-3", T.label, "VersionedItem", "label", "old", "_version", 0L); + + VersionedItem item = new VersionedItem(); + item.setId(ARID.of("ev-3")); + item.version = 0L; + item.setState(EntityState.MANAGED); + item.setLabel("dirty"); // marks field dirty + + persistor.executeVersionedWrite( + g.V("ev-3").property("label", "via-traversal"), + List.of(item)); + + assertTrue(item.getDirtyFields().isEmpty(), "dirty fields must be cleared after executeVersionedWrite"); + } + + @Test + void executeVersionedWrite_entity_skipsVersionCheckForEntityWithoutVersionField() { + // Employee has no @Version — executeVersionedWrite should still execute the traversal + graph.addVertex(T.id, "ev-4", T.label, "Employee", "name", "before"); + + Employee emp = new Employee(); + emp.setId(ARID.of("ev-4")); + emp.setState(EntityState.MANAGED); + + assertDoesNotThrow(() -> + persistor.executeVersionedWrite( + g.V("ev-4").property("name", "after"), + List.of(emp))); + + assertEquals("after", g.V("ev-4").values("name").next()); + } + + @Test + void executeVersionedWrite_entity_simulatesRaceCondition() { + // Both "sessions" load version 0 + graph.addVertex(T.id, "ev-5", T.label, "VersionedItem", "label", "initial", "_version", 0L); + + VersionedItem sessionA = new VersionedItem(); + sessionA.setId(ARID.of("ev-5")); + sessionA.version = 0L; + sessionA.setState(EntityState.MANAGED); + + VersionedItem sessionB = new VersionedItem(); + sessionB.setId(ARID.of("ev-5")); + sessionB.version = 0L; // same stale snapshot + sessionB.setState(EntityState.MANAGED); + + // Session A writes first — succeeds, version is now 1 in the graph + persistor.executeVersionedWrite( + g.V("ev-5").property("label", "written-by-A"), + List.of(sessionA)); + assertEquals(1L, g.V("ev-5").values("_version").next()); + + // Session B attempts to write — pre-flight detects stale version 0 + assertThrows(OptimisticLockException.class, () -> + persistor.executeVersionedWrite( + g.V("ev-5").property("label", "written-by-B"), + List.of(sessionB))); + + // Session A's write survives; session B's traversal never ran + assertEquals("written-by-A", g.V("ev-5").values("label").next()); + assertEquals(1L, g.V("ev-5").values("_version").next()); + } + + // --- executeVersionedWrite with VersionGuard --- + + @Test + void executeVersionedWrite_guard_executesTraversalAndIncrementsVersion() { + graph.addVertex(T.id, "gv-1", T.label, "AnyLabel", "status", "pending", "_version", 0L); + + persistor.executeVersionedWrite( + g.V("gv-1").property("status", "done"), + VersionGuard.onVertex(ARID.of("gv-1"), 0L)); + + assertEquals("done", g.V("gv-1").values("status").next()); + assertEquals(1L, g.V("gv-1").values("_version").next()); + } + + @Test + void executeVersionedWrite_guard_throwsBeforeTraversalOnStaleVersion() { + graph.addVertex(T.id, "gv-2", T.label, "AnyLabel", "status", "original", "_version", 5L); + + assertThrows(OptimisticLockException.class, () -> + persistor.executeVersionedWrite( + g.V("gv-2").property("status", "overwrite-attempt"), + VersionGuard.onVertex(ARID.of("gv-2"), 2L))); // stale — graph has 5 + + assertEquals("original", g.V("gv-2").values("status").next(), "traversal must not have run"); + assertEquals(5L, g.V("gv-2").values("_version").next()); + } + + @Test + void executeVersionedWrite_guard_worksOnUnregisteredVertexType() { + // Label "UnknownThing" has no @VertexType class — VersionGuard works regardless + graph.addVertex(T.id, "gv-3", T.label, "UnknownThing", "value", "old", "_version", 0L); + + assertDoesNotThrow(() -> + persistor.executeVersionedWrite( + g.V("gv-3").property("value", "new"), + VersionGuard.onVertex(ARID.of("gv-3"), 0L))); + + assertEquals("new", g.V("gv-3").values("value").next()); + assertEquals(1L, g.V("gv-3").values("_version").next()); + } + + @Test + void executeVersionedWrite_guard_worksOnEdge() { + var empV = graph.addVertex(T.id, "gv-emp", T.label, "Employee"); + var coV = graph.addVertex(T.id, "gv-co", T.label, "Company"); + empV.addEdge("WORKS_AT", coV, T.id, "gv-e1", "role", "junior", "_version", 0L); + + persistor.executeVersionedWrite( + g.E("gv-e1").property("role", "senior"), + VersionGuard.onEdge(ARID.of("gv-e1"), 0L)); + + assertEquals("senior", g.E("gv-e1").values("role").next()); + assertEquals(1L, g.E("gv-e1").values("_version").next()); + } + + @Test + void executeVersionedWrite_guard_customVersionProperty() { + graph.addVertex(T.id, "gv-4", T.label, "AnyLabel", "data", "before", "rev", 7L); + + persistor.executeVersionedWrite( + g.V("gv-4").property("data", "after"), + VersionGuard.onVertex(ARID.of("gv-4"), "rev", 7L)); + + assertEquals("after", g.V("gv-4").values("data").next()); + assertEquals(8L, g.V("gv-4").values("rev").next()); + } + + @Test + void executeVersionedWrite_guard_multipleGuards_allPassOrNoneFire() { + graph.addVertex(T.id, "gv-5", T.label, "AnyLabel", "x", "A", "_version", 1L); + graph.addVertex(T.id, "gv-6", T.label, "AnyLabel", "x", "B", "_version", 2L); + + // Both versions correct — traversal fires, both versions increment + persistor.executeVersionedWrite( + g.V("gv-5").property("x", "A2").V("gv-6").property("x", "B2"), + VersionGuard.onVertex(ARID.of("gv-5"), 1L), + VersionGuard.onVertex(ARID.of("gv-6"), 2L)); + + assertEquals("A2", g.V("gv-5").values("x").next()); + assertEquals(2L, g.V("gv-5").values("_version").next()); + assertEquals("B2", g.V("gv-6").values("x").next()); + assertEquals(3L, g.V("gv-6").values("_version").next()); + } + + @Test + void executeVersionedWrite_guard_simulatesRaceCondition() { + graph.addVertex(T.id, "gv-7", T.label, "AnyLabel", "val", "start", "_version", 0L); + + // Writer A reads version 0 and succeeds + persistor.executeVersionedWrite( + g.V("gv-7").property("val", "A-wins"), + VersionGuard.onVertex(ARID.of("gv-7"), 0L)); + assertEquals(1L, g.V("gv-7").values("_version").next()); + + // Writer B had also read version 0 — now stale + assertThrows(OptimisticLockException.class, () -> + persistor.executeVersionedWrite( + g.V("gv-7").property("val", "B-loses"), + VersionGuard.onVertex(ARID.of("gv-7"), 0L))); + + assertEquals("A-wins", g.V("gv-7").values("val").next(), "B's traversal must not have run"); + } } diff --git a/clotho-dev-app/pom.xml b/clotho-dev-app/pom.xml index 8f38ce1..f3ebcfe 100644 --- a/clotho-dev-app/pom.xml +++ b/clotho-dev-app/pom.xml @@ -18,6 +18,10 @@ exploratory testing during library development. Not a production artifact. + + true + + diff --git a/pom.xml b/pom.xml index fa2cc40..bbec509 100644 --- a/pom.xml +++ b/pom.xml @@ -19,8 +19,20 @@ clotho-dev-app + + + gitea + ${gitea.url} + + + gitea + ${gitea.url} + + + 21 + https://gitea.dev.binarygolem.com/api/packages/binarygolem/maven 21 21 UTF-8