@NodeStamp: refusing a save when the node changed
Strongly recommended on any type that is loaded, changed and saved. It plays the role of JPA’s @Version: optimistic locking, with a random value in place of a counter.
@NodeFragment(labels = ["Person"])data class Person( @NodeId val id: String, val name: String, @NodeStamp val stamp: String? = null,)The field is a nullable String on a @NodeFragment, one to a fragment, in Kotlin or Java. It is refused on a @GraphView and on a @RelationshipFragment: a stamp is a node’s.
The stamp
Section titled “The stamp”The stamp is two random tokens of 64 bits each, stored on the node under __drivine.stamp as 3fa9c1d27b40e8a6:91d0f4b2c7ee5a13, whatever the field is called. Loading fills the field. Treat it as opaque.
- The data token, the first, speaks for the node’s own data: its properties and labels.
- The relationship token, the second, speaks for the node’s relationships, of every type and in both directions.
- To mark a node is to replace one of its tokens with a new one. A save marks what it changes, and so must Cypher of your own.
A save replaces the data token when it changes the node: a property that differs, a cleared property that held a value, a label added or dropped. A save that changes nothing leaves it as it is. The relationship token is replaced, at both ends, when a relationship is added or removed or its properties change: by a view saved from either end, by edges, or by the deprecated GraphObjectManager.
Every node a save writes is given a stamp if it has none, whether or not its type declares a @NodeStamp field, and so is the node at each end of a relationship that is written or removed. A stored value that is not two tokens, which no save leaves, counts as none and is replaced whole.
When a save is refused
Section titled “When a save is refused”A save is refused when what it would overwrite has changed since the object was loaded. Every save of an object that carries a stamp compares the data token. A save with Replace overwrites a relationship list, so it compares the relationship token too. A refused save writes nothing, to that node or any other, and throws StaleObjectException.
| Since the object was loaded, another writer… | save |
save with Replace |
update |
|---|---|---|---|
| changed a property or a label of the root | refused | refused | loads again and re-applies the change |
| added or removed a relationship of the root, or changed one’s properties, from either end | applied | refused | applied |
| changed a related node | applied: every non-null field of the related node is written over the change | the same | applied: only the fields the change altered are written |
| deleted the root | refused, deleted is true |
refused, deleted is true |
returns null |
| changed anything with Cypher that does not mark it | not noticed | not noticed | not noticed |
Two cases are decided by the object, not by another writer:
| The object | save |
save with Replace |
update |
|---|---|---|---|
carries a null stamp, or its root declares no @NodeStamp field |
not checked: it makes the node or overwrites it | named fields are replaced unchecked; Replace.all() is refused with IllegalArgumentException |
a change by another writer is not noticed |
| was saved over a node it had not loaded, and carries the stamp that save returned | applied | refused until it is loaded | — |
- So a node’s own data can be saved while others attach relationships to it, two writers can each add a relationship to the same node, and a
Replaceof an object that carries a stamp never removes a relationship it did not load. - The relationship token covers every relationship of the node. A
Replaceof one list is refused when a relationship of another type was added to the same node; load again, or useupdate. - The whole save is one statement, so it is one round trip and atomic, on an engine without transactions too. The statement takes the node’s write lock before it compares, so of several writers holding the same stamp exactly one succeeds and the rest are refused.
- In a view, the root is checked. A node reached through a relationship is written unchecked.
updateis checked against the stamp it loaded, whatever the change does with the object’s.
Handling a refusal:
try { graphObjectManager.save(edited)} catch (stale: StaleObjectException) { if (stale.deleted) { // the node is gone: decide whether to make it again } else { // another writer got there first: stale.foundStamp is what the node carries now. // Load it again and apply the change to what is there, which update does: graphObjectManager.update<Person>(edited.id) { it.copy(name = edited.name) } }}update is the shorter way when the change can be written as a function of the loaded object. It cannot help a type with no @NodeStamp field: there nothing is noticed, and nothing is retried.
The stamp a save hands back
Section titled “The stamp a save hands back”save returns the object with the stamps the save left: the root’s, and that of each related node that declares a stamp field. Use the returned object: if the save changed the node, the one you passed in is now stale.
- A stamp handed back carries a token of the node’s only if what the token speaks for was as the object’s stamp says when the save began. If another writer added or removed a relationship the object does not hold, the object keeps the relationship token it had: its own data can still be saved, and a
Replaceof it is refused until it is loaded again. If another writer changed the data of a node that was written unchecked (a related node, or an object in asaveAll), the object keeps the data token it had, and a save of it is refused until it is loaded again. - An object that carried no stamp is handed the whole stamp of a node its save made. Of a node that was already there it is handed the data token alone: its lists did not come from the store. A save of the returned object that only adds is applied, and a
Replaceof it is refused until the object is loaded. - A node the object holds more than once, in two fields or as the root and in one of its own lists, is handed the stamp the save left each time, where each copy carried the same stamp.
saveAllhands stamps back assavedoes.
Existing data
Section titled “Existing data”A node written before 0.1.0 has no stamp, and a class that gains a @NodeStamp field loads it with the field null.
-
The first save of such an object is not checked, and gives the node a stamp.
-
That save returns the data token alone, the node having been there already, and
Replace.all()is refused for an object whose stamp is null. So to replace the lists of such a node: save it, load it, thenReplace. Or name the fields: a namedReplaceof an object with a null stamp is applied unchecked. -
To give every node of a label its stamp at once, so that the first load already carries one:
persistenceManager.execute(QuerySpecification.withStatement("MATCH (n:Person) WHERE n.`__drivine.stamp` IS NULL SET ${Stamps.setClause("n")}"))
Cypher you write yourself
Section titled “Cypher you write yourself”Cypher should mark what it changes. A write that does not is not noticed: a checked save of an object loaded before it is applied over it. Stamps.setClause marks a node whose mapped properties or labels it changes; Stamps.linksClause marks each end of a relationship it adds or removes, or whose properties it changes:
"MATCH (p:Person {id: \$id}) SET p.name = \$name, ${Stamps.setClause("p")}"
"""MATCH (a:Person {id: \$a}), (b:Person {id: \$b})CREATE (a)-[:KNOWS]->(b)SET ${Stamps.linksClause("a")}, ${Stamps.linksClause("b")}"""- The property’s name holds a dot, so Cypher that names it quotes it:
n.`__drivine.stamp`. - A node that is deleted and created again is noticed without a mark, because it has no stamp.
- Cypher that replaces every property of a node (
SET n = $props) removes its stamp, and the next checked save of an object loaded before is refused as changed. bindObjectdoes not know the stamp. An object of a stamped class bound withbindObject("props", person)carries its stamp under the field’s own name,stamphere.SET p += $propsthen writes a property calledstampand leaves the real one as it was, so the write is not noticed;SET p = $propsremoves the real one. Save such an object with the object manager, or leave the stamp field out of what you bind and addStamps.setClause.
What a stamp does not cover
Section titled “What a stamp does not cover”- A related node is not checked.
savewrites every non-null field of it, so a stale copy of a related node overwrites another writer’s change to it.updatewrites only the fields the change altered. - An object whose stamp is null is not checked. It makes the node or overwrites it, as an upsert does.
- A
saveAllthat only adds is not checked, where asaveof the same object is. - One relationship token covers every relationship of a node. A
Replaceof one list is refused when a relationship of another type was added. - Deleting a node removes its relationships without marking the nodes at their other ends.
- Cypher that does not mark what it changes is not noticed.
- Saving with
CascadeType.DELETE_ALLhas no equivalent on this manager: see What you give up.
Other writers and engines
Section titled “Other writers and engines”edges.relate,unrelateandunrelateAllmark both ends.relateleaves both alone when it finds the relationship there as it is; where several relationships of the type join the two nodes, it writes the properties to each and marks unless every one already carried them.- The deprecated
GraphObjectManagermarks the nodes and relationships it changes, and only those, so a checked save notices its writes. It neither checks a stamp nor hands one back. - On Neo4j and Memgraph, a statement the engine turns away because another writer was changing the same node is run again, six attempts in all, and a checked save is then refused as stale. FalkorDB runs one write at a time and turns none away. Only such a conflict is run again: a lost connection is not, since the save may have been applied.
edgesruns such a statement again too.GraphObjectManagerdoes not, and the engine’s error reaches the caller. On Memgraph two writers that mark the same node at the same moment conflict; on Neo4j two that take the same pair of nodes in opposite order can deadlock.- A checked save the engine turns away on every attempt throws
StaleObjectExceptionwith the engine’s error as its cause, and no found stamp. The contended node can be one the save reaches through a relationship, so the exception says the save was turned away and that the node may have changed, not that it did. Memgraph does this inside a transaction that read the node before another writer changed it: that transaction has failed, and is to be run again from its start. - Indexes and constraints are not affected. A save sets and removes a property
__drivine.lockon each node it writes, within its statement, to hold the node’s write lock while it compares. An unchecked write takes the lock too, so that it says truly whether it changed a node another writer is changing at the same moment, andedges.relatedoes the same for both nodes. A save that may make a node sets and removes__drivine.made, to tell a node it made from one it found. Neither is left on a node. A flat@PropertyBagdoes not read a property beginning__drivine.. - A stamp field can be filtered and ordered on in the generated query DSL.

