GrainContext API

Action Handler API

All Action Handlers are executed within the context of a Grain, allowing for quick access to a Grain's state. As a result, Action Handlers provide access to a GrainContext object which can be used to perform various actions like logging messages, getting counters/gauges, accessing the Grain's state, etc.

Types of Action Handlers

Action Handler methods may be per-action, or batch-oriented. The following example is that of a per-action Action Handler method.

// Receives a single action and is expected to return an ActionResult
public ActionResult handleAction(Action action, GrainContext context) {
    ...
    return ActionResult.success(action);
}

Batch-oriented Action Handlers methods are useful when each action requires a heavy or expensive operation. For example, if an Action Handler initializes a heavy/expensive object, to analyze data, a batch-oriented Action Handler can be advantageous. Instead of initializing that object for each action individually, a batch-oriented Action Handler allows developers to initialize it just once for entire batches of actions. The following example shows a batch-oriented handler.

// Receives a list of actions and is expected to return a list of ActionResults.
public List<ActionResult> handleAction(List<Action> actions, GrainContext context) {
    List<ActionResult> results = new ArrayList<>();

    // Initialize an expensive object.

    for(Action action : actions) {
        ...
        results.add(ActionResult.success(action));
    }
    
    return results;
}

Accessing Grain State

Accessing the Grain's state within an Action Handler is as easy as invoking getKey(), getValue() or mapGet() on the provided GrainContext object. The following example accesses and modifies the Grain's value and a map entry for the "history" map (with map ID = 0).

public ActionResult handleAction(Action action, GrainContext context) {
    // Get key, value and data from a map entry.
    Key key = context.getKey();
    Value value = context.getValue();
    Value dataFromMonday = context.mapGet("history", Key.of(mondayTimestamp));

    ...
    // Perform some transformations and run some business logic.
    ...

    // Store data back into Grain.
    context.setValue(Value.of(newValue));
    context.mapPut("history", Key.of(mondayTimestamp), Value.of(newData));

    return ActionResult.success(action);
}

context.mapQuery(MapQuery) (Java) and context.map_get_range(Map ID, StartKey, EndKey) (Python) also allow scanning for data in a map, similar to how they are used in GrainiteClient API.

In the Java GrainContext API, maps can be accessed by both name (eg: "history") and id (eg: 0). In the Python GrainContext API, maps can be accessed by id only.

Logger

The logger provided by GrainContext via getLogger() should be used for application logs. These logs can then be viewed by running tail -f server in the server's meta directory.

Using standard printing methods (like System.out.println in Java or print in Python) to log information is not recommended as the output won't show up as expected in the server log.

The following example uses the logger for logging application information.

public ActionResult handleAction(Action action, GrainContext context) {
    ...

    context.getLogger().info("Hello World!");

    ...
}

Sending Messages to Grains

Action Handlers also have the ability to send messages to other Grains using sendToGrain(TableName, Key, Operation, ExpectGrainResponse). This method takes a TableName and a Key of the receiving Grain, an Operation which refers to the type of operation to perform on the grain, and an optional ExpectResponse signaling whether a response is expected back for the operation. (Java, Python)

Some of the supported operations are:

  1. GrainOp.Invoke - invokes an Action Handler for the receiving Grain.

  2. GrainOp.Put - inserts the specified value in receiving Grain's value state.

  3. GrainOp.MapPut - inserts the specified value in the specified map of the receiving Grain.

The following example invokes an Action Handler called handleOrders , with the payload "Burger", for #123 Grain in the orders_table using GrainOp.Invoke.

public ActionResult handleAction(Action action, GrainContext context) {
    ...

    GrainOp.Invoke op = new GrainOp.Invoke("handleOrders", Value.of("Burger"));
    context.sendToGrain("orders_table", Key.of("#123"), op, null);

    ...
}

To send messages to Grains in another application, simply pass the app name to sendToGrain().

public ActionResult handleAction(Action action, GrainContext context) {
    ...

    GrainOp.Invoke op = new GrainOp.Invoke("handleOrders", Value.of("Burger"));
    context.sendToGrain("food_app", "orders_table", Key.of("#123"), op, null);

    ...
}

Note that a Grain key can be up to 4KB in size and the value can be up to 512KB in size. The same limits apply to map keys (4KB maximum) and map values (512KB maximum) Future updates to Grainite will increase these limits.

Sending Events to Topics

Similar to sendToGrain , Action Handlers also have the ability to push events to topics using sendToTopic(TopicName, Key, Event, ExpectResponse). This method takes a TopicName , a Key, an Event for the payload, and an optional ExpectResponse signaling whether a response is expected back for the operation.

The following example sends an Event comprising of the key - #123, and the payload "Burger", to orders_topic.

public ActionResult handleAction(Action action, GrainContext context) {
    ...

    context.sendToTopic("orders_topic", Key.of("#123"), Value.of("Burger"), null);

    ...
}

Note that an event key can be up to 4KB in size and the payload can be up to 512KB in size. Future updates to Grainite will increase these limits.

Looking up Grains

It is common for the need to access other Grains' states from within an Action Handler. This is made easy using lookupGrain(AppName, TableName, Key) which takes parameters for the location of the Grain. This method synchronously returns a GrainView which can be used to look up the Grain's state but can not be used to invoke handlers or change its state. To change another Grain's state, use sendToGrain .

The following example accesses another Grain's state in an Action Handler.

// Action Handler invoked for "#123" grain in the order_table for food_app
public ActionResult handleAction(Action action, GrainContext context) {
    ...

    GrainView grain = context.lookupGrain("example_app", "example_table", Key.of("bar"));
    context.getLogger().info("Value of bar grain is: " + grain.getValue().asString());

    ...
}

Building Secondary Indexes with Grainite

As an early access capability, we have introduced the ability to create indexes that are strongly consistent with the data being updated in tables. Indexes created as part of this feature are updated at the same time as the data values are persisted in the Grainite database.

When to use Secondary Indexes

Grains within Grainite tables may be looked up using their key - using the Table.getGrain() API. Secondary indexes allow looking up Grains using other properties of the data.

Let us consider that Grains in a table contain a 'city' and 'zipcode' property that's saved in the value (or in one of the maps). Grain 'A' has city = 'Los Angeles' and zipcode = '90001', while Grain 'B' has city = 'Santa Clara' and zipcode = '95054'. By indexing these properties, one can then search for all Grains in the table with zipcode = '90001' (resulting in Grain 'A') or zipcode > '90000' (fetching both Grain 'A' and Grain 'B'). See Querying Secondary Indexes in the GrainiteClient API.

How to create and delete indexes

Indexes may be created and updated using the GrainContext APIs available to the Action Handlers. As part of updating the data within the Grain, one may invoke the GrainContext.insertIndexEntry() and GrainContext.updateIndexEntry() APIs to add a new entry or to update a previous index entry. One may invoke this API multiple times during a single invocation of an Action Handler to record multiple index entries for the Grain.

Additionally, one may use GrainContext.deleteIndexEntry() to delete a previously created index.

public ActionResult handleNewIndexes(Action action, GrainContext context) {
    ...
    
    // Insert entries for this grain in two indexes--`city` and `zipcode`
    context.insertIndexEntry(Key.of("city"), Value.of("Los Angeles"));
    context.insertIndexEntry(Key.of("zipcode"), Value.of("90001"));
}
public ActionResult handleRemoveIndexes(Action action, GrainContext context) {
    ...
    
    // Remove entry for this grain for index `city`
    context.deleteIndexEntry(Key.of("city"), Value.of("Los Angeles"));
}

This API was introduced in 2326.1.

The context.indexQuery() API allows for querying secondary indexes within an Action Handler.

This is the GrainContext equivalent of the client API - table.find().This API has similar usage and semantics to the client-side API:

Iterator<GrainView> iter = context.queryIndex("income > 100000");
iter.forEachRemaining(grainView -> {
    // Do something with the grain view.
});

One difference is that the client API returns Iterator<Grain>, while the context API returns Iterator<GrainView> - a read-only state of the corresponding Grains.

For optimal performance, we strongly advise against using conjunctions such as AND or OR in the queryIndex API.

Using Counters and Gauges

Grainite makes it convenient to create counters and gauges that can be used by Prometheus out-of-the-box via counter(MetricName, Params) and gauge(MetricName, Params). Both of these methods take a MetricName, as well as optional Params, which are a key-value pair of String to store additional data about particular counters. These params are used as labels by Prometheus. See the monitoring section for documentation on how these counters and gauges can be exposed for external consumption.

The following example increments counters to keep track of some metrics.

public ActionResult handleOrders(Action action, GrainContext context) {
    ...

    context.counter("numOrders").inc();
    ...

    if(isNewCustomer)
        context.counter("numCustomers").inc();

    ...
}

Exception Handlers

exception handlers simplify exception handling for Action Handlers. Exception handlers are invoked when an Action Handler throws a specified exception. Exception handlers can be defined in the exception_handler section and then attached to an action in the app config.

An exception handler takes three arguments:

  1. Action

  2. Throwable

  3. GrainContext

public ActionResult handleRuntimeException(Action action, Throwable t, GrainContext context) {
  if (!(t instanceof RuntimeException)) {
    return ActionResult.failure(action, "Unexpected exception type.");
  } else {
    return ActionResult.success(action, "Expected runtime exception.");
  }
}

Object Pooling

Granite offers an API to reuse objects in Action Handlers. This is beneficial when certain objects are costly to create and are used frequently in Action Handlers.

For instance, consider an Action Handler making frequent HTTP requests to a Webhook. Each time it is called, it initializes an HTTP client, makes the request, then destroys the client. This process is inefficient and it lengthens the execution time for each Action.

Instead, the Handler can use object pooling to share objects, to conserve resources and reduce execution time. This approach is more efficient than creating new objects for each Handler.

There are three parameters that the context.getPooledObject method takes -

  1. The key for the pool

  2. The allocator to construct a new object

  3. The object type.

The object pool can store multiple pools of object instances, and the key is used to distinguish each pool. Each pool can contain up to 20 instances of an object and if an object instance hasn't been used for 10 minutes, it will be removed from the pool.

Here is an example:

public ActionResult handleEvent(Action action, GrainContext context) {
    HttpClient client = context.getPooledObject("192.168.0.0.1:2000", new PoolAllocator() {
        @Override
        public Object allocate() {
          // Initialize Webhook client
          HttpClient webhookClient = ...
          
          return webhookClient;
        }

        @Override
        public void deallocate(Object object) {
          // Cleanup Webhook client
          ((HttpClient)object).close();
        }
    });
    
    // Use client to issue POST request to Webhook.
    ...
    
    // The client is automatically returned to the pool at the end the Action Handler execution.
    return ActionResult.success(action);
}

When there is no instance of an object in the pool, the allocate method is called to build the instance. Similarly, the deallocate method is called when an object is removed from the pool.

Optionally, context.deallocateObject(Object) can be called to manually remove an object from the pool. This can be useful in scenarios where the resource is deemed outdated. For example, if the underlying http connection is broken, deallocateObject can be called on the http client instance.

Last updated