Micronaut MicroStream

Micronaut integration with MicroStream

Version:

1 Introduction

Integration with Microstream.

2 Release History

For this project, you can find a list of releases (with release notes) here:

3 Dependency

To start, add the micronaut-microstream dependency to your classpath.

implementation("io.micronaut:micronaut-microstream")
<dependency>
    <groupId>io.micronaut</groupId>
    <artifactId>micronaut-microstream</artifactId>
</dependency>

4 Configuration

A Micronaut application can have more than one MicroStream instances. Each MicroStream instance represents one coherent entity graph of persistent data.

The following configuration example configures two beans of type EmbeddedStorageConfigurationProvider with Name Bean Qualifiers: orange and blue.

src/main/resources/application.yml
microstream:
  storage:
    orange: (1)
      root-class: 'io.micronaut.microstream.docs.OneData' (2)
      storage-directory: build/microstream${random.shortuuid}
      channel-count: 4
    blue: (2)
      root-class: 'io.micronaut.microstream.docs.AnotherData' (2)
      storage-directory: build/microstream${random.shortuuid}
      channel-count: 4
      channel-directory-prefix: 'channel_'
      data-file-prefix: 'channel_'
      data-file-suffix: '.dat'
1 Specify a different name qualifier for each Microstream instance
2 Specify the class of the entity graph’s root.

5 Root Instance

The following example creates a Root Instance to store `Customer`s:

package io.micronaut.microstream.docs;

import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;

import java.util.HashMap;
import java.util.Map;

@Introspected // (1)
public class Data {
    private Map<String, Customer> customers = new HashMap<>();

    @NonNull
    public Map<String, Customer> getCustomers() {
        return this.customers;
    }
}
package io.micronaut.microstream.docs

import io.micronaut.core.annotation.Introspected

@Introspected // (1)
class Data {
    Map<String, Customer> customers = [:]
}
package io.micronaut.microstream.docs

import io.micronaut.core.annotation.Introspected

@Introspected // (1)
data class Data(val customers: MutableMap<String, Customer> = mutableMapOf())
1 Annotate the Root class with @Introspected to generate BeanIntrospection metadata at compilation time. This information is used by the Micronaut Framework to instantiate the class without using reflection.
package io.micronaut.microstream.docs;

import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;

import javax.validation.constraints.NotBlank;

@Introspected
public class Customer {
    @NonNull
    @NotBlank
	private final String id;

    @NonNull
    @NotBlank
	private String firstName;

    @Nullable
	private String lastName;

    public Customer(@NonNull String id, @NonNull String firstName, @Nullable String lastName) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
    }

    @NonNull
    public String getId() {
        return id;
    }

    public void setFirstName(@NonNull String firstName) {
        this.firstName = firstName;
    }

    public void setLastName(@Nullable String lastName) {
        this.lastName = lastName;
    }

    @NonNull
    public String getFirstName() {
        return firstName;
    }

    @Nullable
    public String getLastName() {
        return lastName;
    }
}
package io.micronaut.microstream.docs

import io.micronaut.core.annotation.Introspected
import io.micronaut.core.annotation.NonNull
import io.micronaut.core.annotation.Nullable

import javax.validation.constraints.NotBlank

@Introspected
class Customer {
    @NonNull
    @NotBlank
    String id

    @NonNull
    @NotBlank
    String firstName

    @Nullable
    String lastName

    Customer(@NonNull String id, @NonNull String firstName, @Nullable String lastName) {
        this.id = id
        this.firstName = firstName
        this.lastName = lastName
    }
}
package io.micronaut.microstream.docs

import io.micronaut.core.annotation.Introspected

@Introspected
class Customer(val id: String, var firstName: String, var lastName: String?)

You specify the Root class via configuration:

src/main/resources/application.yml
microstream:
  storage:
    main: (1)
      root-class: 'io.micronaut.microstream.docs.Data' (2)
      storage-directory: '/Users/sdelamo/Documents/MicrostreamData'
1 Specify a name qualifier for the Microstream instance
2 Specify the class of the entity graph’s root.

6 Storage

The following example shows how to create implementations for this repository:

package io.micronaut.microstream.docs;

import io.micronaut.core.annotation.NonNull;

import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.Collection;
import java.util.Optional;
import java.util.UUID;

public interface CustomerRepository {

    @NonNull
    Customer save(@NonNull @NotNull @Valid CustomerSave customerSave);

    void update(@NonNull @NotBlank String id,
                @NonNull @NotNull @Valid CustomerSave customerSave);

    @NonNull
    Optional<Customer> findById(@NonNull @NotBlank String id);

    void deleteById(@NonNull @NotBlank String id);
}
package io.micronaut.microstream.docs

import io.micronaut.core.annotation.NonNull

import javax.validation.Valid
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull

interface CustomerRepository {

    @NonNull
    Customer save(@NonNull @NotNull @Valid CustomerSave customer)

    void update(@NonNull @NotBlank String id,
                @NonNull @NotNull @Valid CustomerSave customer)

    @NonNull
    Optional<Customer> findById(@NonNull @NotBlank String id)

    void deleteById(@NonNull @NotBlank String id)
}
package io.micronaut.microstream.docs

import javax.validation.Valid
import javax.validation.constraints.NotBlank

interface CustomerRepository {
    fun save(customerSave: @Valid CustomerSave): Customer
    fun update(id: @NotBlank String, customerSave: @Valid CustomerSave)
    fun findById(id: @NotBlank String): Customer?
    fun deleteById(id: @NotBlank String)
}

6.1 StorageManager

The following example shows how to create a repository which injects the StorageManager and retrieves the Root Instance:

@Singleton
public class CustomerRepositoryImpl implements CustomerRepository {

    private final StorageManager storageManager;

    public CustomerRepositoryImpl(StorageManager storageManager) { // (1)
        this.storageManager = storageManager;
    }

	@Override
    @NonNull
	public Customer save(@NonNull @NotNull @Valid CustomerSave customerSave) {
        return XThreads.executeSynchronized(() -> { // (2)
            String id = UUID.randomUUID().toString();
            Customer customer = new Customer(id, customerSave.getFirstName(), customerSave.getLastName());
            data().getCustomers().put(id, customer);
            storageManager.store(data().getCustomers()); // (3)
            return customer;
        });
	}

    @Override
    public void update(@NonNull @NotBlank String id,
                       @NonNull @NotNull @Valid CustomerSave customerSave) {
        XThreads.executeSynchronized(() -> { // (2)
            Customer c = data().getCustomers().get(id);
            c.setFirstName(customerSave.getFirstName());
            c.setLastName(customerSave.getLastName());
            storageManager.store(c); // (3)
        });
    }

    @Override
    @NonNull
    public Optional<Customer> findById(@NonNull @NotBlank String id) {
        return Optional.ofNullable(data().getCustomers().get(id));
    }

    @Override
    public void deleteById(@NonNull @NotBlank String id) {
        XThreads.executeSynchronized(() -> { // (2)
            data().getCustomers().remove(id);
            storageManager.store(data().getCustomers()); // (3)
        });
    }

    private Data data() {
        return (Data) storageManager.root();
    }
}
@Singleton
class CustomerRepositoryImpl implements CustomerRepository {

    private final StorageManager storageManager

    CustomerRepositoryImpl(StorageManager storageManager) { // (1)
        this.storageManager = storageManager
    }

    @Override
    @NonNull
    Customer save(@NonNull @NotNull @Valid CustomerSave customerSave) {
        XThreads.executeSynchronized(new Supplier<Customer>() { // (2)
            @Override
            Customer get() {
                String id = UUID.randomUUID().toString()
                Customer customer = new Customer(id, customerSave.getFirstName(), customerSave.getLastName())
                data().getCustomers().put(id, customer)
                store(data().getCustomers()) // (3)
                customer
            }
        })
    }

    @Override
    void update(@NonNull @NotBlank String id,
                @NonNull @NotNull @Valid CustomerSave customerSave) {
        XThreads.executeSynchronized(new Runnable() { // (2)
            @Override
            void run() {
                Customer c = data().getCustomers().get(id)
                c.setFirstName(customerSave.getFirstName())
                c.setLastName(customerSave.getLastName())
                store(c) // (3)
            }
        })
    }

    @Override
    @NonNull
    Optional<Customer> findById(@NonNull @NotBlank String id) {
        Optional.ofNullable(data().getCustomers().get(id))
    }

    @Override
    void deleteById(@NonNull @NotBlank String id) {
        XThreads.executeSynchronized(new Runnable() { // (2)
            @Override
            void run() {
                data().getCustomers().remove(id)
                store(data().getCustomers()) // (3)
            }
        })
    }

    private void store(Object instance) {
        storageManager.store(instance)
    }

    private Data data() {
        (Data) storageManager.root()
    }
}
@Singleton
class CustomerRepositoryImpl(private val storageManager: StorageManager) // (1)
    : CustomerRepository {
    override fun save(customerSave: CustomerSave): Customer {
        val id = UUID.randomUUID().toString()
        val customer = Customer(id, customerSave.firstName, customerSave.lastName)
        XThreads.executeSynchronized { // (2)
            data.customers[customer.id] = customer
            storageManager.store(data.customers) // (3)
        }
        return customer
    }

    override fun update(id : String, customerSave: CustomerSave) {
        XThreads.executeSynchronized { // (2)
            val customer : Customer? = data.customers[id]
            if (customer != null) {
                with(customer) {
                    firstName = customerSave.firstName
                    lastName = customerSave.lastName
                }
                storageManager.store(customer) // (3)
            }
        }
    }

    @NonNull
    override fun findById(id: @NotBlank String): Customer? {
        return data.customers[id]
    }

    override fun deleteById(id: @NotBlank String) {
        XThreads.executeSynchronized { // (2)
            data.customers.remove(id)
            storageManager.store(data.customers) // (3)
        }
    }

    private val data: Data
        get() {
            val root = storageManager.root()
            return if (root is Data) root else throw RuntimeException("Root is not Data")
        }
}
1 If your Micronaut application has only a single Microstream Instance, you don’t need to specify a name qualifier to inject StorageManager.
2 When you are working with Microstream technology in a multi-threaded environment, reading and writing to this shared object graph must be synchronized.
3 To store a newly created object, store the "owner" of the object.

6.2 Store Annotation

The following example shows how an equivalent implementation which leverages the @Store annotation to simplify object storage.

@Singleton
public class CustomerRepositoryStoreImpl implements CustomerRepository {

    private final RootProvider<Data> rootProvider;

    public CustomerRepositoryStoreImpl(RootProvider<Data> rootProvider) { // (1)
        this.rootProvider = rootProvider;
    }

    @Override
    @NonNull
    public Customer save(@NonNull @NotNull @Valid CustomerSave customerSave) {
        return addCustomer(rootProvider.root().getCustomers(), customerSave);
    }

    @Override
    public void update(@NonNull @NotBlank String id,
                       @NonNull @NotNull @Valid CustomerSave customerSave) {
        updateCustomer(id, customerSave);
    }

    @Override
    @NonNull
    public Optional<Customer> findById(@NonNull @NotBlank String id) {
        return Optional.ofNullable(rootProvider.root().getCustomers().get(id));
    }

    @Override
    public void deleteById(@NonNull @NotBlank String id) {
        removeCustomer(rootProvider.root().getCustomers(), id);
    }

    @StoreReturn // (2)
    @Nullable
    protected Customer updateCustomer(@NonNull String id,
                                      @NonNull CustomerSave customerSave) {
        Customer c = rootProvider.root().getCustomers().get(id);
        if (c != null) {
            c.setFirstName(customerSave.getFirstName());
            c.setLastName(customerSave.getLastName());
            return c;
        }
        return null;
    }

    @StoreParams("customers") // (3)
    protected Customer addCustomer(@NonNull Map<String, Customer> customers,
                                   @NonNull CustomerSave customerSave) {
        Customer customer = new Customer(UUID.randomUUID().toString(),
            customerSave.getFirstName(),
            customerSave.getLastName());
        customers.put(customer.getId(), customer);
        return customer;
    }

    @StoreParams("customers") // (3)
    protected void removeCustomer(@NonNull Map<String, Customer> customers,
                                  @NonNull String id) {
        customers.remove(id);
    }
}
@Singleton
class CustomerRepositoryStoreImpl implements CustomerRepository {

    private final RootProvider<Data> rootProvider

    CustomerRepositoryStoreImpl(RootProvider<Data> rootProvider) { // (1)
        this.rootProvider = rootProvider
    }

    @Override
    @NonNull
    Customer save(@NonNull @NotNull @Valid CustomerSave customerSave) {
        return addCustomer(rootProvider.root().customers, customerSave)
    }

    @Override
    void update(@NonNull @NotBlank String id,
                @NonNull @NotNull @Valid CustomerSave customerSave) {
        updateCustomer(id, customerSave)
    }

    @Override
    @NonNull
    Optional<Customer> findById(@NonNull @NotBlank String id) {
        Optional.ofNullable(rootProvider.root().customers[id])
    }

    @Override
    void deleteById(@NonNull @NotBlank String id) {
        removeCustomer(rootProvider.root().customers, id)
    }

    @StoreReturn // (2)
    @Nullable
    protected Customer updateCustomer(@NonNull String id,
                                      @NonNull CustomerSave customerSave) {
        Customer c = rootProvider.root().customers[id]
        if (c != null) {
            c.with {
                firstName = customerSave.firstName
                lastName = customerSave.lastName
            }
            return c
        }
        null
    }

    @StoreParams("customers") // (3)
    protected Customer addCustomer(@NonNull Map<String, Customer> customers,
                                   @NonNull CustomerSave customerSave) {
        Customer customer = new Customer(UUID.randomUUID().toString(),
            customerSave.firstName,
            customerSave.lastName)
        customers[customer.id] = customer
        customer
    }

    @StoreParams("customers") // (3)
    protected void removeCustomer(@NonNull Map<String, Customer> customers,
                                  @NonNull String id) {
        customers.remove(id)
    }
}
@Singleton
open class CustomerRepositoryStoreImpl(private val rootProvider: RootProvider<Data>) // (1)
    : CustomerRepository {
    override fun save(customerSave: @Valid CustomerSave): Customer {
        return addCustomer(rootProvider.root().customers, customerSave)
    }

    override fun update(id : @NotBlank String,
                        customerSave: @Valid CustomerSave) {
        updateCustomer(id, customerSave)
    }

    @NonNull
    override fun findById(id: @NotBlank String): Customer? {
        return rootProvider.root().customers[id]
    }

    override fun deleteById(id: @NotBlank String) {
        removeCustomer(rootProvider.root().customers, id)
    }

    @StoreReturn // (2)
    @Nullable
    open fun updateCustomer(id: String, customerSave: CustomerSave): Customer? {
        val c: Customer? = rootProvider.root().customers[id]
        return if (c != null) {
            c.firstName = customerSave.firstName
            c.lastName = customerSave.lastName
            c
        } else null
    }

    @StoreParams("customers") // (3)
    open fun addCustomer(customers: MutableMap<String, Customer>, customerSave: CustomerSave): Customer {
        val customer = Customer(
            UUID.randomUUID().toString(),
            customerSave.firstName,
            customerSave.lastName
        )
        customers[customer.id] = customer
        return customer
    }

    @StoreParams("customers") // (3)
    open fun removeCustomer(customers: MutableMap<String, Customer>, id: String) {
        customers.remove(id)
    }
}
1 You can also inject an instance of RootProvider to easily access the Microstream Root Instance. If your Micronaut application has only a single Microstream Instance, you don’t need to specify a name qualifier to inject it.
2 The rule is: "The Object that has been modified has to be stored!".
3 To store a newly created object, store the "owner" of the object.
@Store only works for synchronous methods. It does not do any logic for methods returning a Publisher or a CompletableFuture. For those scenarios, use directly the StorageManager.

7 Cache

MicroStream can be used as a cache abstraction layer.

7.1 Cache Configuration

To use the MicroStream cache abstraction, you must declare a dependency on

implementation("io.micronaut:micronaut-microstream-cache")
<dependency>
    <groupId>io.micronaut</groupId>
    <artifactId>micronaut-microstream-cache</artifactId>
</dependency>

You can then define a cache by adding the following configuration to your application:

src/main/resources/application.yml
microstream:
  cache:
    counter: (1)
      key-type: java.lang.String
      value-type: java.lang.Long
1 Define a cache named counter which has String keys and Long values.

This cache can then be used via the following:

package io.micronaut.microstream.docs;

import io.micronaut.cache.annotation.CacheConfig;
import io.micronaut.cache.annotation.CachePut;
import io.micronaut.cache.annotation.Cacheable;
import jakarta.inject.Singleton;

import java.util.HashMap;
import java.util.Map;

@Singleton
@CacheConfig("counter") // (1)
public class CounterService {

    Map<String, Long> counters = new HashMap<>();

    @Cacheable // (2)
    public Long currentCount(String name) {
        return counters.get(name);
    }

    @CachePut(parameters = {"name"}) // (3)
    public Long setCount(String name, Long count) {
        counters.put(name, count);
        return count;
    }
}
package io.micronaut.microstream.docs

import io.micronaut.cache.annotation.CacheConfig
import io.micronaut.cache.annotation.CachePut
import io.micronaut.cache.annotation.Cacheable
import jakarta.inject.Singleton

@Singleton
@CacheConfig("counter") // (1)
class CounterService {

    Map<String, Long> counters = [:]

    @Cacheable // (2)
    Long currentCount(String name) {
        return counters.get(name)
    }

    @CachePut(parameters = ["name"]) // (3)
    Long setCount(String name, Long count) {
        counters.put(name, count)
        count
    }
}
package io.micronaut.microstream.docs

import io.micronaut.cache.annotation.CacheConfig
import io.micronaut.cache.annotation.CachePut
import io.micronaut.cache.annotation.Cacheable
import jakarta.inject.Singleton
import java.util.HashMap

@Singleton
@CacheConfig("counter") // (1)
open class CounterService {

    val counters: MutableMap<String, Long> = HashMap()

    @Cacheable // (2)
    open fun currentCount(name: String): Long? = counters[name]

    @CachePut(parameters = ["name"]) // (3)
    open fun setCount(name: String, count: Long): Long {
        counters[name] = count
        return count
    }
}
1 Use the counter cache.
2 The result of this call will be cached.
3 Setting the counter will invalidate the cache for this key.

7.2 Cache Storage

If caching that persists across restarts is required, you can back a MicroStream cache with a Storage Manager.

src/main/resources/application.yml
microstream:
  storage:
    backing: (1)
      storage-directory: "${storageDirectory}"
      channel-count: 4
  cache:
    counter: (2)
      key-type: java.lang.String
      value-type: java.lang.Long
      storage: backing (3)
1 Define a storage manager called backing
2 Define a cache called counter to store Strings keys and Long values
3 Configure the cache to use the backing storage manager

The above example will then persist across restarts.

8 Metrics

You can enable Microstream Metrics collection by enabling Micrometer Metrics.

If you do not wish to collect Microstream metrics, you can set micronaut.metrics.binders.microstream.enabled to false in application.yml.

9 Health

Health Endpoint exposes the status of the MicroStream Instances in your Micronaut Application.

After you add the management dependency, the /health endpoint of a Micronaut application with one MicroStream instance named blue will return:

{
  "name": "application",
  "status": "UP",
  "details": {
    "microstream.blue": {
      "name": "application",
      "status": "UP",
      "details": {
        "startingUp": false,
        "running": true,
        "active": true,
        "acceptingTasks": true,
        "shuttingDown": false,
        "shutdown": false
      }
    },
    ...
  }
}
By default, the details visible above are only shown to authenticated users. See the Health Endpoint documentation for how to configure that setting.

If you wish to disable the Microstream health check while still using the management dependency you can set the property endpoints.health.microstream.enabled to false in your application configuration.

endpoints:
  health:
    microstream:
      enabled: false

10 REST API

The MicroStream Storage isn’t a typical database server with administrative tooling, it is a pure java persistence engine which runs embedded in your application. When developing a MicroStream application, it is useful to be able to inspect the graph that is currently in your storage.

The micronaut-microstream-rest library exposes the MicroStream REST API from inside your Micronaut application.

10.1 Enabling the REST API

To enable it, add the following dependency to your application.

implementation("io.micronaut.microstream:micronaut-microstream-rest")
<dependency>
    <groupId>io.micronaut.microstream</groupId>
    <artifactId>micronaut-microstream-rest</artifactId>
</dependency>

10.2 Configuration options

Once you add the microstream-rest dependency, it exposes the API by default. You can disable it with:

src/main/resources/application.yml
microstream:
  rest:
    enabled: false

The API path is /microstream by default.

When multiple storage managers are defined, the URL must be suffixed by the manager name /microstream/«storage-name».

This prefix can be configured via the configuration.

src/main/resources/application.yml
microstream:
  rest:
    path: custom-prefix

The above configuration would expose the REST API at /custom-prefix. (or /custom-prefix/«storage-name» if you have multiple storage managers defined).

10.3 GUI

The simplest way of interacting with this REST API is to use the MicroStream client GUI.

Instructions on downloading and running this can be found here.

11 Repository

You can find the source code of this project in this repository: