1. Overview

Xodus is an open-source embedded database built by JetBrains. We can use it as an alternative to relational databases. Using Xodus we obtain a high-performance transactional key-value store and an object-oriented data model. This storage features an append-only mechanism that minimizes random IO overhead and provides snapshot isolation by default.

In Xodus we have the snapshot isolation, guaranteeing a consistent snapshot of the entire database for all reads within a transaction. For each committed transaction we’ll have a new snapshot (version) of the database, enabling subsequent transactions to reference this snapshot.

In this tutorial, we’ll cover the majority of the features of the Xodus database concept.

2. How Data-Handling Works in Xodus

The data-handling process in Xodus is illustrated in the following diagram:

Xodus

Here, we have the Environment class that handles all the synchronization between log files and the in-memory store. The EntityStore class serves as a wrapper around the environment, simplifying the process of data manipulation.

3. Environments

Environments is a lowest-level Xodus API. We can utilize it as a transactional key-value storage. Let’s build an example repository where we’ll use Environments.

3.1. Dependencies

We’ll start adding the xodus-openAPI dependency:

<dependency>
    <groupId>org.jetbrains.xodus</groupId>
    <artifactId>xodus-openAPI</artifactId>
    <version>2.0.1</version>
</dependency>

3.2. save()

Now, let’s create a repository class with the saving logic:

public class TaskEnvironmentRepository {
    private static final String DB_PATH = "db\\.myAppData";
    private static final String TASK_STORE = "TaskStore";

    public void save(String taskId, String taskDescription) {
        try (Environment environment = openEnvironmentExclusively()) {
            Transaction writeTransaction = environment.beginExclusiveTransaction();

            try {
                Store taskStore = environment.openStore(TASK_STORE,
                  StoreConfig.WITHOUT_DUPLICATES, writeTransaction);

                ArrayByteIterable id = StringBinding.stringToEntry(taskId);
                ArrayByteIterable value = StringBinding.stringToEntry(taskDescription);

                taskStore.put(writeTransaction, id, value);
            } catch (Exception e) {
                writeTransaction.abort();
            } finally {
                if (!writeTransaction.isFinished()) {
                    writeTransaction.commit();
                }
            }
        }
    }

    private Environment openEnvironmentExclusively() {
        return Environments.newInstance(DB_PATH);
    }
}

Here, we’ve specified the path to the database files directory – all the files will be created automatically. Then, we opened the environment and created the exclusive transition – all the transactions should be committed or aborted after data processing.

After that, we created the store to manipulate the data. Using the store, we put our ID and value into the database. It’s important to transform all the data into ArrayByteIterable.

3.3. findOne()

Now, let’s add the findOne() method to our repository:

public String findOne(String taskId) {
    try (Environment environment = openEnvironmentExclusively()) {
        Transaction readonlyTransaction = environment.beginReadonlyTransaction();

        try {
            Store taskStore = environment.openStore(TASK_STORE,
              StoreConfig.WITHOUT_DUPLICATES, readonlyTransaction);

            ArrayByteIterable id = StringBinding.stringToEntry(taskId);

            ByteIterable result = taskStore.get(readonlyTransaction, id);

            return result == null ? null : StringBinding.entryToString(result);
        } finally {
            readonlyTransaction.abort();
        }
    }
}

Here, similarly, we create the environment instance. We’ll use the read-only transaction here since we’re implementing the read operation. We call the get() method of the store instance to get the task description by ID. After the read operation, we don’t have anything to commit, so we’ll abort the transaction. 

3.4. findAll()

To iterate the storage, we need to use Cursors. Let’s implement the findAll() method using the cursor:

public Map<String, String> findAll() {
    try (Environment environment = openEnvironmentExclusively()) {
        Transaction readonlyTransaction = environment.beginReadonlyTransaction();

        try {
            Store taskStore = environment.openStore(TASK_STORE,
              StoreConfig.WITHOUT_DUPLICATES, readonlyTransaction);

            Map<String, String> result = new HashMap<>();
            try (Cursor cursor = taskStore.openCursor(readonlyTransaction)) {
                while (cursor.getNext()) {
                    result.put(StringBinding.entryToString(cursor.getKey()),
                      StringBinding.entryToString(cursor.getValue()));
                }
            }

            return result;
        } finally {
            readonlyTransaction.abort();
        }
    }
}

In the read-only transaction, we opened the store and created the cursor without any criteria.  Then, iterating the store, we populated the map with all the combinations of IDs and task descriptions. It’s important to close the cursor after the processing.

3.5. deleteAll()

Now, we’ll add the deleteAll() method to our repository:

public void deleteAll() {
    try (Environment environment = openEnvironmentExclusively()) {
        Transaction exclusiveTransaction = environment.beginExclusiveTransaction();

        try {
            Store taskStore = environment.openStore(TASK_STORE,
              StoreConfig.WITHOUT_DUPLICATES, exclusiveTransaction);

            try (Cursor cursor = taskStore.openCursor(exclusiveTransaction)) {
                while (cursor.getNext()) {
                    taskStore.delete(exclusiveTransaction, cursor.getKey());
                }
            }
        } finally {
            exclusiveTransaction.commit();
        }
    }
}

In this implementation, we follow the same approach with cursors to iterate all the items. For each item key, we call the store’s delete() method. Finally, we commit all the changes.

4. Entity Stores

In the Entity Stores layer, we access data as entities with attributes and links. We use the Entity Store API, which offers richer options for querying data and a higher level of abstraction.

4.1. Dependencies

To start using entity store, we need to add the following xodus-entity-store dependency:

<dependency>
    <groupId>org.jetbrains.xodus</groupId>
    <artifactId>xodus-entity-store</artifactId>
    <version>2.0.1</version>
</dependency>

4.2. save()

Now, let’s add support for saving logic. First of all, we’ll create our model class:

public class TaskEntity {
    private final String description;
    private final String labels;

    public TaskEntity(String description, String labels) {
        this.description = description;
        this.labels = labels;
    }

    // getters
}

We’ve created a TaskEntity with a few properties. Now, we’ll create a repository class with the logic for saving it:

public class TaskEntityStoreRepository {
    private static final String DB_PATH = "db\\.myAppData";
    private static final String ENTITY_TYPE = "Task";

    public EntityId save(TaskEntity taskEntity) {
        try (PersistentEntityStore entityStore = openStore()) {
            AtomicReference<EntityId> idHolder = new AtomicReference<>();

            entityStore.executeInTransaction(txn -> {
                final Entity message = txn.newEntity(ENTITY_TYPE);
                message.setProperty("description", taskEntity.getDescription());
                message.setProperty("labels", taskEntity.getLabels());

                idHolder.set(message.getId());
            });

            return idHolder.get();
        }
    }

    private PersistentEntityStore openStore() {
        return PersistentEntityStores.newInstance(DB_PATH);
    }
}

Here, we opened an instance of PersistentEntityStore, then started an exclusive transaction and created an instance of jetbrains.exodus.entitystore.Entity, mapping all properties from our TaskEntity to it. The EntityStore entity exists only within the transaction, so we need to map it into our DTO to use it outside the repository. Finally, we returned the EntityId from the save method. This EntityId contains the entity type and a unique, generated ID.

4.3. findOne()

Now, let’s add the findOne() method to our TaskEntityStoreRepository:

public TaskEntity findOne(EntityId taskId) {
    try (PersistentEntityStore entityStore = openStore()) {
        AtomicReference<TaskEntity> taskEntity = new AtomicReference<>();

        entityStore.executeInReadonlyTransaction(
          txn -> taskEntity.set(mapToTaskEntity(txn.getEntity(taskId))));

        return taskEntity.get();
    }
}

Here, we access the entity in the read-only transaction and map it into our TaskEntity. In the mapping method, we implement the following logic:

private TaskEntity mapToTaskEntity(Entity entity) {
    return new TaskEntity(entity.getProperty("description").toString(),
      entity.getProperty("labels").toString());
}

We’ve got the entity properties and created the TaskEntity instance using them.

4.4. findAll()

Let’s add the findAll() method:

public List<TaskEntity> findAll() {
    try (PersistentEntityStore entityStore = openStore()) {
        List<TaskEntity> result = new ArrayList<>();

        entityStore.executeInReadonlyTransaction(txn -> txn.getAll(ENTITY_TYPE)
          .forEach(entity -> result.add(mapToTaskEntity(entity))));

        return result;
    }
}

As we can see, the implementation is much shorter in the Environments analog.  We’ve called the entity store transaction getAll() method and mapped each item from the result into the TaskEntity.

4.5. deleteAll()

Now, let’s add the deleteAll() method to our TaskEntityStoreRepository:

public void deleteAll() {
    try (PersistentEntityStore entityStore = openStore()) {
        entityStore.clear();
    }
}

Here, we have to call the clear() method of the PersistentEntityStore, and all the items will be removed.

5. Conclusion

In this article, we explored JetBrains Xodus. We examined the main APIs of this database and demonstrated how to perform basic operations with it. This database can be a valuable addition to the set of embedded databases.

As always, the code is available over on GitHub.