Neo4j OGM Events - Part 1

· 13 min read

As of version 2.1, Neo4j OGM will support persistence events. Although a date for the release of 2.1 isn’t known at the time of writing, we think this is an important and exciting new feature and so we’ll be writing a series of posts about it over the next few weeks to whet your appetites. In this first post we’ll take a quick tour of the new Events mechanism in the OGM, and provide some examples of how we might use it in our own applications. But first, some background…

OGM persistence strategy

By design, the OGM has a deep persistence strategy. This means that saving an object will by default save any connected objects that also need to be persisted.

Connected objects are any objects reachable in the domain model from the top-level object being saved. Connected objects can be many levels deep in the domain model graph. So while Spring Data Neo4j users could attach event handlers to persistence events for top-level objects they had no way of being notified of events for any connected objects that were also persisted.

In addition, some persistence events generate purely structural changes in a graph database. For example, when a relationship is added or deleted, the nodes either side of the relationship are obviously affected and events should be fired for them.

The new Events mechanism in 2.1 makes all this possible.

Tip: You can find out more about the OGM persistence strategy in the official documentation.

Event Types

There are four types of persistence event exposed by the Events mechanism. Their names should be pretty self-explanatory…

  • Event.TYPE.PRE_SAVE
  • Event.TYPE.POST_SAVE
  • Event.TYPE.PRE_DELETE
  • Event.TYPE.POST_DELETE

Events are fired for each @NodeEntity and @RelationshipEntity object that is directly created, updated or deleted in the graph, or is indirectly affected following a save or delete request of another object. As indicated above, this includes:

  • any top-level objects or objects being created, modified or deleted.
  • any connected objects that have been modified, created or deleted.
  • any objects affected by the creation, modification or removal of a relationship in the graph.

Tip: Events will only fire when one of the session.save() or session.delete() methods is invoked. Directly executing Cypher queries against the database using session.query() will not trigger any events.

Interfaces

The Events mechanism also introduces two new interfaces, Event and EventListener.

The Event interface is implemented by org.neo4j.ogm.session.event.PersistenceEvent. Whenever an application wishes to handle an event it will be given an instance of an Event, which exposes the following methods:

public interface Event {

  Object getObject();
  TYPE getType();

  enum TYPE {
      PRE_SAVE, POST_SAVE, PRE_DELETE, POST_DELETE
  }
}

The EventListener interface provides methods allowing implementing classes to handle each of the different Event types:

public interface EventListener {

  void onPreSave(Event event);
  void onPostSave(Event event);
  void onPreDelete(Event event);
  void onPostDelete(Event event);

}

Tip: although the Event interface allows you to retrieve the Event’s Type, in most cases you won’t need it because the EventListener provides methods to capture each type of event explicitly.

Sample Domain Model

For the purposes of this post, we’re going to set up a simple domain model. For the sake of clarity and simplicity, we’re leaving out accessor methods, and we’ll be accessing the object’s fields directly.

The basic model should be very familiar: a folder can contain zero or more documents, and a document can optionally hold a reference to its containing folder. We’re not making any promises about the robustness of this model - its for demonstration purposes only!

abstract class DomainEntity {
  Long created;
  Long updated;
  Long id;
  UUID uuid;
}

class Document extends DomainEntity {
  String name;
  Folder folder;
}

class Folder extends DomainEntity {
  String name;
  List<Document> documents = new ArrayList();
}

Registering an EventListener

In order to capture and handle persistence events from the OGM, an application must register one or more EventListeners with the Session.

We’d like create an anonymous EventListener whose job is to inject a UUID onto any new object before it gets saved into the graph. One way to do this is like so:

EventListener eventListener = session.register(new EventListener() {

  void onPreSave(Event event) {
    DomainEntity entity = (DomainEntity) event.getObject();
    if (entity.id == null) {
        entity.uuid = UUID.randomUUID();
    }
  }
  void onPostSave(Event event) {}
  void onPreDelete(Event event) {}
  void onPostDelete(Event event) {}
});

Tip: It’s possible and sometimes desirable to add several EventListener objects to the session, depending on the application’s requirements. For example, our business logic might require us to add a UUID to a new object, as well as manage wider concerns such as ensuring that a particular persistence event won’t leave our domain model in a logically inconsistent state. It’s usually a good idea to separate these concerns into different objects with specific responsibilities, rather than having one single object try to do everything.

Using the EventListenerAdapter

Our EventListener is fine, but we’ve had to create three methods for events we don’t intend to handle. It would be preferable if we didn’t have to do this each time we needed an EventListener.

The org.neo4j.ogm.session.event.EventListenerAdapter is an abstract class providing a no-op implementation of the EventListener interface. If you don’t need to handle all the different types of persistence event you can create a subclass of EventListenerAdapter instead and override just the methods for the event types you’re interested in. This is just what we need, so for our demo app, we’ll do this.

class PreSaveEventListener extends EventListenerAdaper {
  @Override
  void onPreSave(Event event) {
    DomainEntity entity = (DomainEntity) event.getObject();
    if (entity.id == null) {
      entity.UUID = UUID.randomUUID();
    }
  }
}

Now, we can create some objects and persist them into the graph. When we do that they will all have UUIDs.

public void demo(Session session) {

	// register our EventListener
	EventListener el = new PreSaveEventListener();
	session.register(el);

	Folder folder = new Folder();
	Document a = new Document();
	Document b = new Document();

	folder.documents.add(a);
	folder.documents.add(b);
	a.folder = folder;
	b.folder = folder;

	session.save(folder);

}

Disposing of an EventListener

Something to bear in mind is that once an EventListener has been registered it will continue to respond to any and all persistence events. Sometimes you may want only to handle events for a short period of time, rather than for the duration of the entire session.

If you’re done with an EventListener you can stop it from firing any more events by invoking session.dispose(...), passing in the EventListener to be disposed of. Our EventListener is scoped to the demo() method, so we’d better dispose of it before the method exits.

public void demo(Session session) {

	// register our EventListener
	EventListener el = new PreSaveEventListener();
	session.register(el);

	// create our domain model
	Folder folder = new Folder();
	Document a = new Document();
	Document b = new Document();

	folder.documents.add(a);
	folder.documents.add(b);
	a.folder = folder;
	b.folder = folder;

	// save it - UUIDs will be injected onto each new object
	session.save(folder);

	// dispose of the event listener
	session.dispose(el);
}

Tip: The process of collecting persistence events prior to dispatching them to any EventListeners adds a small performance overhead to the persistence layer. Consequently, the OGM is configured to suppress the event collection phase if there are no EventListeners registered with the Session. Using dispose() when you’re finished with an EventListener is good practice!

Connected objects

As mentioned previously, events are not only fired for the top-level objects being saved but for all their connected objects as well. The Events mechanism has allowed us to capture events for objects that we didn’t explicitly save. This is why the documents a and b also had UUID’s injected.

In this next example we will make further use of the ability to capture events for connected objects, by ensuring that all DomainEntity objects get an appropriate updated or created timestamp. We’ll modify our PreSaveEventListener accordingly.

class PreSaveEventListener extends EventListenerAdaper {
  @Override
  void onPreSave(Event event) {
    DomainEntity entity = (DomainEntity) event.getObject();
    if (entity.id == null) {
       entity.UUID = UUID.randomUUID();
       entity.created = System.nanoTime();
    } else {
       entity.updated = System.nanoTime();
    }
  }
}

Now we’re going to change the names of both documents but only save one of them.

public void demo(Session session) {

	...

	a.name = "A";
	b.name = "B";

	session.save(a);

	// dispose of the event listener
	session.dispose(el);
}

Because b is reachable from a (via the common shared folder) they will both be persisted. PRE_SAVE and POST_SAVE events will be fired for each of them and they will both have their timestamp updated.

So far so good! Let’s now take a quick look at Events in a bit more detail, particularly when it comes to dealing with multiple top-level objects in a save or delete request.

Events and Types

When we delete a Type, all the nodes with a label corresponding to that Type are deleted in the graph. The affected objects are not enumerated by the Events mechanism (they may not even be known), instead, _DELETE events will be raised for the Type:

// 2 events will be fired when the type is deleted.
// - PRE_DELETE Document.class
// - POST_DELETE Document.class
session.delete(Document.class);

Events and Collections

When saving or deleting a top-level collection of objects, separate events are fired for each object in the collection, rather than for the collection itself:

Document a = new Document();
Document b = new Document();

// 4 events will be fired when the collection is saved.
// - PRE_SAVE a
// - PRE_SAVE b
// - POST_SAVE a
// - POST_SAVE b
session.save(Arrays.asList(a, b));

Ordering of Events

By now you may be wondering how events on multiple objects are ordered. In the previous example, there is an order implied by the save request semantics: first save a and then save b. Are the events for a and b also fired in this implied order?

The answer is that events on multiple objects are only partially ordered. What this means is that all PRE_ events are guaranteed to fire before any POST_ event within the same save or delete request. However, the order in which the different PRE_ events and POST_ events will fire is undefined. So in the above example, the PRE_SAVE event for b might fire before the PRE_SAVE event for a. In many cases, the ordering will be the same as the one implied by the save or delete request, but this isn’t guaranteed and we cannot rely on it.

Relationships

Up till now we’ve mainly been discussing how events fire when the underlying node representing an entity is updated or deleted in the graph. But, as mentioned earlier, events are also fired when a save or delete request results in the modification, addition or deletion of a relationship in the graph.

For example, if we delete a Document object that is a member of a Folder’s documents() collection, events will be fired for the Folder as well as the Document, to reflect the fact that the relationship between the folder and the document has been removed in the graph.

Folder f = new Folder();
Document a = new Document();
f.documents.add(a);
session.save(f);

// Now, if we delete the document, the following events will be fired:
// - PRE_DELETE a
// - POST_DELETE a
// - PRE_SAVE f
// - POST_SAVE f

session.delete(a);

Note that the Folder events are _SAVE events, not _DELETE events. The Folder was not deleted!

Domain consistency - a warning!

The Event mechanism does not maintain the consistency of the domain model. In the example above, the Folder f is still holding a reference to the Document a, even though it no longer exists in the graph…

As always therefore, our application code must take care of domain model consistency:

folder.documents.remove(a);
session.delete(a);

Events are unique

The Event mechanism guarantees not to fire more than one event of the same type for an object within the context of the same save or delete request.

For example, in the following code snippet, even though we’re making changes to both the folder node and its relationships, only a single PRE_SAVE and POST_SAVE event will be fired for the folder, and likewise for the document a.

folder.documents.remove(a); folder.name = “newFolder”; session.save(folder);

Cancelling requests

You may be wondering whether it is possible to cancel a pending save or delete request as a consequence of intercepting and inspecting the PRE_ event. Unfortunately, the answer is ‘no’, because the persistence lifecycle is not interruptible. However you can obtain the same effect by implementing a Transaction-aware EventListener.

So, to finish our demo app, we’ll create a new EventLister that also implements the Transaction interface, passing in the Transation we want to control in its constructor. This EventListener will mark the transaction for cancellation if it detects that any object has been deleted in the graph. If this happens, it will force a rollback when the user tries to commit, and throw a CommitCancelledException for good measure.

class TransactionAwareEventListener extends EventListenerAdapater implements Transaction

  private final Transaction tx;
  private boolean cancelCommit = false;

  public TransactionalEventListener(Transaction tx) {
    this.tx = tx;
  }

  public void onPreDelete(Event event) {
    cancelCommit = true;
  }

  // Transaction implementation
  public void commit() {
    if (!cancelCommit) {
        tx.commit();
    } else {
        rollback();
        throw new CommitCancelledException();
    }
  }

  public void rollback() {
    tx.rollback();
  }

  public void close() {
    tx.close();
  }
}

Let’s add this to our demo. Here’s what it now looks like in full.

public void demo(Session session) {

	// register our main EventListener
	EventListener el = new PreSaveEventListener();
	session.register(el);

	// create our domain model
	Folder folder = new Folder();
	Document a = new Document();
	Document b = new Document();

	folder.documents.add(a);
	folder.documents.add(b);
	a.folder = folder;
	b.folder = folder;

	// all objects get UUID and created timestamp
	session.save(folder);

	a.name = "A";
	b.name = "B";

	// a and b get updated timestamps
	session.save(a);

	// now add an event listener to catch
	// delete attempts and rollback changes
	// if a deletion occurs.
	try(EventListener tael = session.register(new TransactionAwareEventListener(session.beginTransaction()) ) {
           session.delete(a));
           tael.commit();
        }
        catch (CommitCancelledException cce) {
           System.out.println("Domain model was illegally modified!");
        }
        finally {
           session.dispose(tael);  // dispose of the delete detection event listener
        }
	// dispose of main the event listener
	session.dispose(el);

}

Conclusion

Ok, that’s it for this week! We covered quite a lot of ground already but we hope you agree that the upcoming OGM 2.1 release opens up new opportunities for much finer control of your domain model in your Neo4j-powered applications.

Hopefully this post has given you a flavour of how the Events mechanism works, and has opened up some ideas as to the sorts of things you will be able do with it.

In the next post in this series, we’ll dive into the Spring side of things, and find out how to hook up OGM Events to Spring Data Neo4j’s event mechanism.

As always, feedback and suggestions for improvements are very welcome! Please leave your comments below.

Vince Bickers