In Memory with External Database

../../_images/example-sharding-db.png

In Memory Service with External Database:

Introduction

In memory services replaces a passive cache. Some benefits:

  • Single data owner encapsulates access
  • Data is loaded and saved where it’s needed.
  • Reduces database contention
  • Rich, custom operations
  • Failover and replication
  • Testing

The in-memory service layer encapsulates the database better than a traditional passive cache. In a cache architecture, an application server must load and save to the cache, possibly conflicting with other servers. The in-memory service loads and saves directly to the database, filling itself on demand.

Like a traditional cache, the in-memory data is partitioned. Each server owns a distinct slice of data. Each service is the sole owner, which means it can use the in-memory data instead of loading from the database.

Architecture

../../_images/example-multi-ext-db.png

In Memory Service Architecture

Using an in-memory service with an external, blocking database requires two services: the in-memory service itself, and a data-access service as a non-blocking gateway to the traditional database.

In our example, the in-memory service is AuctionService and the data-access service is AuctionDataService.

The AuctionService includes a manager at the “/auction” URL, which has the task of instantiating hollow auction instances. Although the manager can validate the URL’s syntax, it does not load or validate the auction itself; single-owner means that only the auction can load or validate itself.

  • AuctionManagerImpl - creates hollow auction instances
  • AuctionService - auction service API
  • AuctionImpl - auction implementation
  • AuctionDataService - data service API to the database
  • AuctionDataServiceImpl - data service implementation

Auction @OnLoad/@OnSave

In-memory items are loaded only once when they are first accessed, and are saved only when a @Modify method is called. Read only methods do not trigger a save.

This load-once works because of the single-owner requirement. Since the service owner is the only entity allowed to modify the data, it knows its in-memory version of the data is valid. Baratine’s in-memory system is designed around this requirement.

Each auction is independently loaded and saved.

@OnLoad - called exactly once before any methods
...
@Modify
@Modify
@Modify
@OnSave - saved after batch completes
...
get()   - non-modify methods do not trigger a save
get()
...
@Modify
@OnSave - saved after batch completes

The @OnLoad should not use any blocking calls because blocking the inbox thread will block processing for all items in the partition node.

In this pattern, since we’re using an external database like MySql or MongoDB, we need to create a data-access service to provide an asynchronous interface for the database.

In-Memory Auction Service

  • AuctionManagerImpl - creates hollow AuctionImpl instances
  • AuctionService - auction API
  • AuctionImpl - auction implementation

AuctionService

The AuctionService API is a simplified interface for an auction item. For compatibility with REST, it has a get() and put() method. The bid() is an example of a richer operation.

Note that AuctionService is a non-blocking interface. For traditional blocking interfaces, we can also provide an AuctionServiceSync. Splitting the two interfaces is useful for code reviews, so async-only services avoid calling synchronous methods by mistake.

AuctionService.java:

package example;

import io.baratine.core.Result;

public interface AuctionService
{
  void get(Result<AuctionData> result);

  void put(AuctionData data);

  void bid(String userId, long value, Result<Boolean> result);
}

Adding a synchronous interface is useful for testing even if you forbid synchronous interfaces as a convention.

AuctionServiceSync.java:

package example;

public interface AuctionServiceSync extends AuctionService
{
  AuctionData get();

  void put(AuctionData data)

  boolean bid(String userId, long amount);
}

AuctionManagerImpl

The auction manager is responsible for instantiating hollow auctions in its @OnLookup method. It must not validate or load the auction because the auction itself is the single owner of its data. It can validate the URL for syntax. The only purpose of AuctionManagerImpl is creating an auction instance with the given URL.

AuctionManagerImpl.java:

package example;

import javax.inject.Inject;
import io.baratine.core.Lookup;
import io.baratine.core.Service;

@Service("public:///auction")
public class AuctionManagerImpl
{
  @Inject @Lookup("/auction-data")
  AuctionDataService _auctionDataStore;

  @OnLookup
  public Object onLookup(String path)
  {
    return new AuctionItem(path, _auctionDataStore);
  }
}

AuctionImpl.java

  • AuctionImpl is responsible for its own load and save
  • @OnLoad occurs only once
  • @OnSave only occurs after a @Modify method

AuctionImpl.java:

package example;

import javax.inject.Inject;
import io.baratine.core.Lookup;
import io.baratine.core.Modify;
import io.baratine.core.Result;
import io.baratine.core.Service;
import io.baratine.store.Store;
import java.util.*;

public class AuctionImpl implements AuctionService
{
  private final String _path;
  private final AuctionDataStore _store;

  private AuctionData _data;

  AuctionImpl(String path, AuctionDataStore store)
  {
    _path = path;
    _store = store;
  }

  public void get(Result<AuctionData> result)
  {
    result.complete(_data);
  }

  @Modify
  public void put(AuctionData update)
  {
    AuctionData data;

    if (_data == null) {
      _data = new AuctionData(update);
    }
    else {
      _data = _data.update(update);
    }
  }

  @Modify
  public void bid(String userId, long amount, Result<Boolean> result)
  {
    boolean isOk = _data.bid(userId, amount);

    result.complete(isOk);
  }

  @OnLoad
  private void onLoad(Result<Void> result)
  {
    _store.load(_path, result.from(v->afterLoad(v)));
  }

  private Void afterLoad(AuctionData data)
  {
    _data = data;

    return null;
  }

  @OnSave
  private void onSave()
  {
    _store.save(_path, _data);
  }
}

Auction Data Service

AuctionDataService.java:

package example;

import io.baratine.core.Result;

public interface AuctionDataService
{
  void load(String path, Result<AuctionData> result);

  void save(String path, AuctionData data);
}

AuctionDataServiceImpl.java

The data service implementation will depend on which backend database you’re using, and how you’re saving the data.

The multiple workers provide an async interface to a blocking system.

AuctionDataServiceImpl.java:

package example;

import io.baratine.core.Service;
import io.baratine.core.Result;
import io.baratine.core.Workers;

@Service("/auction-data")
@Workers(16)
public class AuctionDataServiceImpl extends AuctionDataService
{
  public void load(String path, Result<AuctionData> result)
  {
    try (Connection conn = openConnection()) {
      rs = conn.query(_sqlLoad, path);

      return rs.getObject(1);
    }
  }

  public void save(String path, AuctionData list)
  {
    try (Connection conn = openConnection()) {
      conn.execute(_sqlSave, path, list);
    }
  }
}

Deploying on a single server

Compile the classes and create a jar named auction.jar.

Deploy your new service:

$ bin/baratine start
Baratine/0.10-SNAPSHOT start with watchdog at 127.0.0.1:6700
Baratine/0.10-SNAPSHOT launching watchdog at 127.0.0.1:6700
  starting *:8085 (cluster-8085)
$ bin/baratine deploy auction.jar
  deployed auction.jar to bfs:///config/pods/50-pod.cf

The previous command starts a single auction server. See below for how to configure multiple servers.

Servlet Clients

Deploy the auction.jar and baratine.jar to WEB-INF/lib

  • WEB-INF/lib/auction.jar
  • WEB-INF/lib/baratine.jar
  • WEB-INF/baratine.cf

The baratine.cf tells the Baratine client where the servers are and which port it should use for itself:

cluster {
  server 127.0.0.1 8085;
  server 8084;
}

See below for a discussion of the seed servers when using multiple servers.

Add a client to use the service:

{
  String auctionId = ...;
  String uid = ...;
  long amount = ...;

  ServiceManager manager = ServiceManager.current();

  AuctionServiceSync auction;

  auction = manager.lookup("pod://pod/auction/" + auctionId)
                   .as(AuctionServiceSync.class);

  boolean isOk = auction.bid(uid, amount);
}

Calling your service from HTTP

Test our service by calling the JSON REST endpoint:

$ curl 'http://localhost:8085/s/pod/auction/2702'
{"id" : "/2702", "name" : "Green Eggs and Ham"}

$ curl 'http://localhost:8085/s/pod/auction/2702?m=get'
{"status":"ok", "value":{"id" : "/2702", "name" : "Green Eggs and Ham"}

Deploying on multiple servers

Once you’ve tested on a single server, using multiple servers is straightforward. First, you’ll need to choose a set of seed servers, which are Baratine servers that all servers will contact during initialization.

baratine.cf:

cluster {
  server 10.0.0.1 8085;
  server 10.0.0.2 8085;
  server 10.0.0.3 8085;
  server 8085;
}

starting with configuration:

$ bin/baratine start -cf baratine.cf

On start, the server will contact the seed servers to join the cluster.

Deploying to a pod

The auction is deployed to a pod with the default name “pod”. The default pod type is a solo pod, which is a single active server with two backup servers. By default, the servers will be assigned by the seed servers.

To control which servers are assigned by a pod, create a 00-pod.cf file as follows:

pod pod type="cluster" {
  server 10.0.0.1 8085;
  server 10.0.0.2 8085;
  server 10.0.0.3 8085;
}

And then upload the pod configuration to the Baratine cluster:

$ bin/baratine put 00-pod.cf /config/pods/00-pod.cf

The uploaded file is in Baratine’s distributed filesystem, BFS. All servers in the cluster share the same filesystem.

You can verify the configuration with the ‘cat’ and ‘ls’ commands:

$ bin/baratine ls /config/pods
$ bin/baratine cat /config/pods/00-pod.cf

Servlet web-app client

The client code does not need to change with multiple servers, because the client uses the address “pod://pod/auction/2702”, which is a virtual address. If the deployed servers change, the client will be informed when it contacts the seed servers.

The web-app client does need to know about the seed servers. So the WEB-INF/baratine.cf needs to include them.

WEB-INF/baratine.cf:

cluster {
  server 10.0.0.1 8085;
  server 10.0.0.2 8085;
  server 10.0.0.3 8085;
  server 8084;
}

For this example, notice that the default server port 8084 is different from the default server port in the server’s baratine.cf. This allows for both a Baratine server and a web-app client on the same machine.