Enterprise Java

How does Hibernate READ_ONLY CacheConcurrencyStrategy work

Introduction

As I previously explained, enterprise caching requires diligence. Because data is duplicated between the database (system of record) and the caching layer, we need to make sure the two separate data sources don’t drift apart.

If the cached data is immutable (neither the database nor the cache are able modify it), we can safely cache it without worrying of any consistency issues. Read-only data is always a good candidate for application-level caching, improving read performance without having to relax consistency guarantees.

Read-only second-level caching

For testing the read-only second-level cache strategy, we going to use the following domain model:

repositorycommitchangereadonlycacheconcurrencystrategy

The Repository is the root entity, being the parent of any Commit entity. Each Commit has a list of Change components (embeddable value types).

All entities are cached as read-only elements:

@org.hibernate.annotations.Cache(
    usage = CacheConcurrencyStrategy.READ_ONLY
)

Persisting entities

The read-only second-level cache uses a read-through caching strategy, entities being cached upon fetching.

doInTransaction(session -> {
    Repository repository = 
        new Repository("Hibernate-Master-Class");
    session.persist(repository);
});

When an entity is persisted only the database contains a copy of this entity. The system of record is passed to the caching layer when the entity gets fetched for the first time.

@Test
public void testRepositoryEntityLoad() {
    LOGGER.info("Read-only entities are read-through");

    doInTransaction(session -> {
        Repository repository = (Repository) 
            session.get(Repository.class, 1L);
        assertNotNull(repository);
    });

    doInTransaction(session -> {
        LOGGER.info("Load Repository from cache");
        session.get(Repository.class, 1L);
    });
}

This test generates the output:

--Read-only entities are read-through

SELECT readonlyca0_.id   AS id1_2_0_,
       readonlyca0_.NAME AS name2_2_0_
FROM   repository readonlyca0_
WHERE  readonlyca0_.id = 1 

--JdbcTransaction - committed JDBC Connection

--Load Repository from cache

--JdbcTransaction - committed JDBC Connection

Once the entity is loaded into the second-level cache, any subsequent call will be served by the cache, therefore bypassing the database.

Updating entities

Read-only cache entries are not allowed to be updated. Any such attempt ends up in an exception being thrown:

@Test
public void testReadOnlyEntityUpdate() {
    try {
        LOGGER.info("Read-only cache entries cannot be updated");
        doInTransaction(session -> {
            Repository repository = (Repository) 
                session.get(Repository.class, 1L);
            repository.setName(
                "High-Performance Hibernate"
            );
        });
    } catch (Exception e) {
        LOGGER.error("Expected", e);
    }
}

Running this test generates the following output:

--Read-only cache entries cannot be updated

SELECT readonlyca0_.id   AS id1_2_0_,
       readonlyca0_.NAME AS name2_2_0_
FROM   repository readonlyca0_
WHERE  readonlyca0_.id = 1 

UPDATE repository
SET    NAME = 'High-Performance Hibernate'
WHERE  id = 1 

--JdbcTransaction - rolled JDBC Connection

--ERROR Expected
--java.lang.UnsupportedOperationException: Can't write to a readonly object

Because read-only cache entities are practically immutable it’s good practice to attribute them the Hibernate specific @Immutable annotation.

Deleting entities

Read-only cache entries are removed when the associated entity is deleted as well:

@Test
public void testReadOnlyEntityDelete() {
    LOGGER.info("Read-only cache entries can be deleted");
    doInTransaction(session -> {
        Repository repository = (Repository) 
            session.get(Repository.class, 1L);
        assertNotNull(repository);
        session.delete(repository);
    });
    doInTransaction(session -> {
        Repository repository = (Repository) 
            session.get(Repository.class, 1L);
        assertNull(repository);
    });
}

Generating the following output:

--Read-only cache entries can be deleted

SELECT readonlyca0_.id   AS id1_2_0_,
       readonlyca0_.NAME AS name2_2_0_
FROM   repository readonlyca0_
WHERE  readonlyca0_.id = 1;

DELETE FROM repository
WHERE  id = 1

--JdbcTransaction - committed JDBC Connection

SELECT readonlyca0_.id   AS id1_2_0_,
       readonlyca0_.NAME AS name2_2_0_
FROM   repository readonlyca0_
WHERE  readonlyca0_.id = 1; 

--JdbcTransaction - committed JDBC Connection

The remove entity state transition is enqueued by PersistenceContext, and at flush time, both the database and the second-level cache will delete the associated entity record.

Collection caching

The Commit entity has a collection of Change components.

@ElementCollection
@CollectionTable(
    name="commit_change",
    joinColumns=@JoinColumn(name="commit_id")
)
private List<Change> changes = new ArrayList<>();

Although the Commit entity is cached as a read-only element, the Change collection is ignored by the second-level cache.

@Test
public void testCollectionCache() {
    LOGGER.info("Collections require separate caching");
    doInTransaction(session -> {
        Repository repository = (Repository) 
            session.get(Repository.class, 1L);
        Commit commit = new Commit(repository);
        commit.getChanges().add(
            new Change("README.txt", "0a1,5...")
        );
        commit.getChanges().add(
            new Change("web.xml", "17c17...")
        );
        session.persist(commit);
    });
    doInTransaction(session -> {
        LOGGER.info("Load Commit from database");
        Commit commit = (Commit) 
            session.get(Commit.class, 1L);
        assertEquals(2, commit.getChanges().size());
    });
    doInTransaction(session -> {
        LOGGER.info("Load Commit from cache");
        Commit commit = (Commit) 
            session.get(Commit.class, 1L);
        assertEquals(2, commit.getChanges().size());
    });
}

Running this test generates the following output:

--Collections require separate caching

SELECT readonlyca0_.id   AS id1_2_0_,
       readonlyca0_.NAME AS name2_2_0_
FROM   repository readonlyca0_
WHERE  readonlyca0_.id = 1;


INSERT INTO commit
            (id, repository_id)
VALUES      (DEFAULT, 1);
			 
INSERT INTO commit_change
            (commit_id, diff, path)
VALUES      (1, '0a1,5...', 'README.txt');		 

INSERT INTO commit_change
            (commit_id, diff, path)
VALUES      (1, '17c17...', 'web.xml');
			 
--JdbcTransaction - committed JDBC Connection

--Load Commit from database

SELECT readonlyca0_.id   AS id1_2_0_,
       readonlyca0_.NAME AS name2_2_0_
FROM   repository readonlyca0_
WHERE  readonlyca0_.id = 1;


SELECT changes0_.commit_id AS commit_i1_0_0_,
       changes0_.diff      AS diff2_1_0_,
       changes0_.path      AS path3_1_0_
FROM   commit_change changes0_
WHERE  changes0_.commit_id = 1 

--JdbcTransaction - committed JDBC Connection

--Load Commit from cache

SELECT changes0_.commit_id AS commit_i1_0_0_,
       changes0_.diff      AS diff2_1_0_,
       changes0_.path      AS path3_1_0_
FROM   commit_change changes0_
WHERE  changes0_.commit_id = 1 

--JdbcTransaction - committed JDBC Connection

Although the Commit entity is retrieved from the cache, the Change collection is always fetched from the database. Since the Changes are immutable too, we would like to cache them as well, to save unnecessary database round-trips.

Enabling Collection cache support

Collections are not cached by default, and to enable this behavior, we have to annotate them with the a cache concurrency strategy:

@ElementCollection
@CollectionTable(
    name="commit_change",
    joinColumns=@JoinColumn(name="commit_id")
)
@org.hibernate.annotations.Cache(
    usage = CacheConcurrencyStrategy.READ_ONLY
)
private List<Change> changes = new ArrayList<>();

Re-running the previous test generate the following output:

--Collections require separate caching

SELECT readonlyca0_.id   AS id1_2_0_,
       readonlyca0_.NAME AS name2_2_0_
FROM   repository readonlyca0_
WHERE  readonlyca0_.id = 1;


INSERT INTO commit
            (id, repository_id)
VALUES      (DEFAULT, 1);
			 
INSERT INTO commit_change
            (commit_id, diff, path)
VALUES      (1, '0a1,5...', 'README.txt');		 

INSERT INTO commit_change
            (commit_id, diff, path)
VALUES      (1, '17c17...', 'web.xml');
			 
--JdbcTransaction - committed JDBC Connection

--Load Commit from database

SELECT readonlyca0_.id   AS id1_2_0_,
       readonlyca0_.NAME AS name2_2_0_
FROM   repository readonlyca0_
WHERE  readonlyca0_.id = 1;


SELECT changes0_.commit_id AS commit_i1_0_0_,
       changes0_.diff      AS diff2_1_0_,
       changes0_.path      AS path3_1_0_
FROM   commit_change changes0_
WHERE  changes0_.commit_id = 1 

--JdbcTransaction - committed JDBC Connection

--Load Commit from cache

--JdbcTransaction - committed JDBC Connection

Once the collection is cached, we can fetch the Commit entity along with all its Changes without hitting the database.

Conclusion

Read-only entities are safe for caching and we can load an entire immutable entity graph using the second-level cache only. Because the cache is read-through, entities are cached upon being fetched from the database. The read-only cache is not write-through because persisting an entity only materializes into a new database row, without propagating to the cache as well.

Vlad Mihalcea

Vlad Mihalcea is a software architect passionate about software integration, high scalability and concurrency challenges.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Inline Feedbacks
View all comments
Back to top button