updated versioning to cover bases types, added gitea support

This commit is contained in:
Rasmus Neikes 2026-05-16 13:24:56 +02:00
parent 586c3ed592
commit 3f09472087
6 changed files with 521 additions and 3 deletions

View File

@ -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.
*
* <p>Execution proceeds in three Gremlin round-trips:
* <ol>
* <li><b>Version pre-flight</b>: 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.</li>
* <li><b>User traversal</b>: {@code userTraversal.iterate()}.</li>
* <li><b>Version increment</b>: a {@code union()} of
* {@code .V/E(id).property(versionProp, v+1)} branches one per
* {@code @Version}-carrying guarded entity.</li>
* </ol>
*
* <p>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.
*
* <p>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.
*
* <p><b>TOCTOU caveat:</b> 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<? extends ClothoEntity> guarded) {
List<VersionedExpectation> versionChecks = new ArrayList<>();
for (ClothoEntity entity : guarded) collectVersionExpectationUnconditional(entity, versionChecks);
runVersionedWrite(userTraversal, versionChecks);
Map<ARID, VersionedExpectation> 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.
*
* <p>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.
*
* <p>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<VersionedExpectation> 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<VersionedExpectation> 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<String> present = new HashSet<>(
(List<String>) (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<VersionedExpectation> 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,

View File

@ -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<T extends ClothoEntity> {
.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.
*
* <p>Use this when complex multi-hop queries produce results you intend to mutate:
* <pre>{@code
* try (ClothoSession session = sessionFactory.openSession()) {
* List<EmploymentSummary> 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
* }</pre>
*/
@SuppressWarnings("unchecked")
public <S> List<S> executeSubgraph(ClothoSession session, Class<S> 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<String, Object>) 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<T extends ClothoEntity> {
return (List<R>) 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<T extends ClothoEntity> {
}
}
/**
* 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.
*
* <p>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.
*
* <p>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.
*

View File

@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>Example:
* <pre>{@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
* );
* }</pre>
*
* @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.
*
* <p>Example reading the version and writing with OCC on an unregistered vertex:
* <pre>{@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)
* );
* }</pre>
*
* @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.
*
* <p>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);

View File

@ -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");
}
}

View File

@ -18,6 +18,10 @@
exploratory testing during library development. Not a production artifact.
</description>
<properties>
<maven.deploy.skip>true</maven.deploy.skip>
</properties>
<dependencies>
<dependency>

12
pom.xml
View File

@ -19,8 +19,20 @@
<module>clotho-dev-app</module>
</modules>
<distributionManagement>
<repository>
<id>gitea</id>
<url>${gitea.url}</url>
</repository>
<snapshotRepository>
<id>gitea</id>
<url>${gitea.url}</url>
</snapshotRepository>
</distributionManagement>
<properties>
<java.version>21</java.version>
<gitea.url>https://gitea.dev.binarygolem.com/api/packages/binarygolem/maven</gitea.url>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>