Skip to content

@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 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.

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 Replace of an object that carries a stamp never removes a relationship it did not load.
  • The relationship token covers every relationship of the node. A Replace of one list is refused when a relationship of another type was added to the same node; load again, or use update.
  • 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.
  • update is 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.

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 Replace of 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 a saveAll), 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 Replace of 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.
  • saveAll hands stamps back as save does.

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, then Replace. Or name the fields: a named Replace of 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 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.
  • bindObject does not know the stamp. An object of a stamped class bound with bindObject("props", person) carries its stamp under the field’s own name, stamp here. SET p += $props then writes a property called stamp and leaves the real one as it was, so the write is not noticed; SET p = $props removes the real one. Save such an object with the object manager, or leave the stamp field out of what you bind and add Stamps.setClause.
  • A related node is not checked. save writes every non-null field of it, so a stale copy of a related node overwrites another writer’s change to it. update writes 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 saveAll that only adds is not checked, where a save of the same object is.
  • One relationship token covers every relationship of a node. A Replace of 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_ALL has no equivalent on this manager: see What you give up.
  • edges.relate, unrelate and unrelateAll mark both ends. relate leaves 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 GraphObjectManager marks 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.
  • edges runs such a statement again too. GraphObjectManager does 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 StaleObjectException with 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.lock on 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, and edges.relate does 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 @PropertyBag does not read a property beginning __drivine..
  • A stamp field can be filtered and ordered on in the generated query DSL.