Skip to content

@NodeStamp: refusing a save when the node changed

Strongly recommended on any class that is loaded, changed and saved. It plays the part of JPA’s @Version: optimistic locking, with a random value in place of a counter.

@NodeFragment({ labels: ['Person'] })
export class Person {
@NodeId() id: string;
name: string;
@NodeStamp() stamp?: string;
}

The field is an optional string on a @NodeFragment, one to a fragment. A stamp is a node’s, so it is refused on a view and on a relationship fragment.

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 properties and labels.
  • The relationship token, the second, speaks for its relationships, of every type, in both directions.
  • To mark a node is to replace one of its tokens. A save marks what it changes, and so must Cypher of your own.

Every node a save writes is given a stamp if it has none, whether or not its class declares a @NodeStamp field.

A save is refused when what it would overwrite changed since the object was loaded. Every save of an object that carries a stamp compares the data token. A save with replace compares the relationship token too. A refused save writes nothing 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 applied refused applied
changed a related node applied: the related node is written over the change the same applied
deleted the root refused, deleted is true refused, deleted is true returns undefined
changed anything with Cypher that doesn’t mark it not noticed not noticed not noticed

An object whose stamp is undefined isn’t checked, unless it is saved with from: it makes the node or overwrites it. The save is one statement that takes the node’s write lock before it compares, so of several writers holding the same stamp exactly one succeeds.

On Neo4j a load can be handed a stamp newer than its data. Neo4j reads under read committed, without a snapshot, so a load that runs while another writer commits can find that writer’s stamp and the data from before its write. update therefore compares each field of the root as it loaded it too, under the node’s lock, and loads again if one differs: a field’s value (a @JsonPacked field’s as the text the node held), a @PropertyBag’s keys and their values, and the labels a @NodeLabels field reads. A field the object doesn’t hold and its class doesn’t initialize isn’t compared. A save checks the same when you give it the object as loaded, as from. A @JsonPacked field of a from that a load made is compared as the text the node held; of one that no load made, such as a copy, as a save packs it, so JSON another writer packed with other spacing has the save refused, though nothing changed. Without from a save has only the stamp to go on, and in that moment it can write over the other writer’s change: hold on to what you loaded and pass it, or change the node with update. Where only the stamp makes the round trip, as through a web form, the stamp is all there is. Memgraph reads from a snapshot, FalkorDB runs no read beside a write, and Oracle and Postgres read a statement whole, so on them the stamp is what was read.

try {
await gom.save(edited);
} catch (e) {
if (e instanceof StaleObjectException && !e.deleted) {
// another writer got there first: e.foundStamp is what the node carries now
await gom.update(Person, edited.id, (it) => { it.name = edited.name; });
} else {
throw e;
}
}

A node written before stamps has none, and loads with the field undefined. The first save of it isn’t checked and gives it a stamp. To stamp every node of a label at once:

await pm.execute(
new QuerySpecification(`MATCH (n:Person) WHERE n.\`__drivine.stamp\` IS NULL SET ${Stamps.setClause('n')}`)
);

Cypher should mark what it changes: a write that doesn’t is not noticed, and a checked save of an object loaded before it is applied over it. Stamps.setClause marks a node whose properties or labels you change, and Stamps.linksClause each end of a relationship you add, remove or change:

`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`. Cypher that replaces every property (SET n = $props) removes the stamp, and the next checked save of an object loaded before is refused.

  • A related node isn’t checked: save writes it whole. update writes only what the change altered.
  • An object with no stamp, saved without from, isn’t checked, nor is a fragment in a saveAll.
  • One relationship token covers every relationship: 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.