Persisting In-Memory Data for Later Usage
Among the many capabilities of an in-memory data grid (IMDG), caching is one of the most well-known and used. However, as its name implies, data resides in memory. The memory is of finite capacity. In order not to put more data than memory can handle, we must decide how to curate it. Hazelcast comes with some technical rules, such as most-often-used and time-to-live, but what if the eviction rule involves business logic?
In this post, I’d like to describe how to persist cached data in the database when it’s not required anymore; and to reload it from the database when it becomes necessary again. To illustrate that, we will use a simple e-commerce scenario.
Naive Implementation
Chances are, at the birth of an e-commerce application, one is happy to use the database only.
However, when the site becomes popular, and customers start to order more and more, it becomes evident that roundtrips to the database become a performance bottleneck. The first step is to store the cart in memory using a simple hash map.
Migrating to an Enterprise-Grade Implementation
While it’s feasible to make use of a hash map, there are many reasons not to do so. Chief among them is that the size of such a hash map is unbounded. In-memory caching solutions, such as Hazelcast IMDG, provide eviction strategies that allow you to evict items from the map, so that the latter is effectively bounded by a predefined parameter. Strategies are manifold, but here are the most common ones:
- Remove an entry when it becomes stale. This is time-to-live (TTL)-based
- Remove an entry when it’s not used as much compared to other entries. This is Least-Recently-Used (LRU)-based
The code looks something like the following:
public class CartService {
private final HazelcastInstance hazelcast;
public CartService(HazelcastInstance hazelcast) {
this.hazelcast = hazelcast;
}
public void add(User user, Product product, int quantity) {
Assert.isTrue(quantity > 0, "Quantity must be greater than 0");
IMap<Long, List> cart = hazelcast.getMap("default");
cart.executeOnKey(user.getId(), new AbstractEntryProcessor<Long, List>() {
@Override
public Object process(Map.Entry<Long, List> entry) {
CartRow row = new CartRow(product);
List content = entry.getValue();
if (content == null) {
content = new ArrayList<>();
}
if (content.contains(row)) {
int index = content.indexOf(row);
CartRow existingRow = content.get(index);
existingRow.increaseQuantity(quantity);
} else {
content.add(new CartRow(product, quantity));
}
entry.setValue(content);
return null;
}
});
}
public void remove(User user, Product product) {
IMap<Long, List> cart = hazelcast.getMap("default");
cart.executeOnKey(user.getId(), new AbstractEntryProcessor<Long, List>() {
@Override
public Object process(Map.Entry<Long, List> entry) {
List content = entry.getValue();
content.remove(new CartRow(product));
entry.setValue(content);
return null;
}
});
}
Clean Up after Business Events
Those technical safeguards allow you to keep the size of the cached data bounded. However, it’s also a good practice to clean up one’s data when it’s not necessary anymore. For example, web sessions can expire because the user logs out, or because of timeout. When that happens, the related cart needs to be removed from the cache.
In order to achieve that, registering a new HttpSessionListener
is necessary:
public class CartRemovalListener implements HttpSessionListener {
private final HazelcastInstance hazelcast;
public CartRemovalListener(HazelcastInstance hazelcast) {
this.hazelcast = hazelcast;
}
@Override
public void sessionDestroyed(HttpSessionEvent event) {
HttpSession session = event.getSession();
Optional user = Optional.ofNullable((User) session.getAttribute("user"));
user.ifPresent(it -> {
IMap<Long, List> cart = hazelcast.getMap("default");
cart.remove(it.getId());
});
}
}
In Java EE, it can be registered in the web deployment descriptor or using the @WebListener
annotation. In Spring Boot, by adding such a bean to the context, it will be registered automatically.
Persisting the Cart’s Content
And that’s a job well done until the business comes with a new requirement. They would like to send an offer to customers who put items into their cart, but didn’t make the final purchase in the end. That offer would be sent via email and provide a discount as an incentive. It seems we are back to square one: the cart needs to be persisted anyway. But it’s not possible because of its impact on performance. Yet, not all hope is lost. We can continue to use the cart in-memory, but when it’s evicted from the cache, it needs to be automatically persisted in the database.
To implement that, the previous listener needs to be updated like this:
public class CartRemovalListener implements HttpSessionListener {
private final HazelcastInstance hazelcast;
private final CartRowRepository repository;
public CartRemovalListener(HazelcastInstance hazelcast, CartRowRepository repository) {
this.hazelcast = hazelcast;
this.repository = repository;
}
@Override
public void sessionDestroyed(HttpSessionEvent event) {
HttpSession session = event.getSession();
Optional user = Optional.ofNullable((User) session.getAttribute("user"));
user.ifPresent(it -> {
IMap<Long, List> cart = hazelcast.getMap("default");
List rows = cart.get(it.getId());
rows.forEach(row -> row.setUser(it));
repository.saveAll(rows);
cart.remove(it.getId());
});
}
}
Leveraging the Power of the Hazelcast API
While the above solution works, it can be improved. Hazelcast offers a rich eventing model designed around the classical Observer pattern. One can execute code when an entry is added, removed, evicted, etc. Our friend is the MapListener
interface, or more precisely, its child interface EntryRemovedListener<K,V>
. It allows us to execute arbitrary code when an entry is removed from the cache. In our case, it stores the cart’s contents into the database for later usage, as per the business requirement.
The persisting code needs to be migrated to a dedicated listener class:
public class EntryRemovalListener implements EntryRemovedListener<Long, List> {
private final CartRowRepository cartRowrepository;
private final UserRepository userRepository;
public EntryRemovalListener(CartRowRepository cartRowrepository, UserRepository userRepository) {
this.cartRowrepository = cartRowrepository;
this.userRepository = userRepository;
}
@Override
public void entryRemoved(EntryEvent<Long, List> event) {
Optional user = userRepository.findById(event.getKey());
user.ifPresent(it -> {
List rows = event.getOldValue();
rows.forEach(row -> row.setUser(it));
cartRowrepository.saveAll(rows);
});
}
}
The listener itself needs to be registered on the IMap
during startup:
IMap<Long, List> cart = hazelcast.getMap("default");
EntryRemovalListener listener = new EntryRemovalListener(cartRowRepository, userRepository);
cart.addEntryListener(listener, true);
Conclusion
In this post, we demoed how to use a database and Hazelcast IMDG together. We demoed several steps, from the most naive—just using the cache, to the most refined—registering a listener to store the cache’s contents in the database.
The complete code base for this post can be found on GitHub.