Skip to main content

State

In Reboot, durable state data types are where your development begins. This page discusses how you (1) declare state types and (2) how you (and your users if they are intended to be publicly consumable) can then construct and lookup instances of your states.

Schemas and interfaces

Reboot uses an interface definition language (IDL) to define your APIs, including the schemas of your durable state data types and the methods you can call on those states.

We currently use protocol buffers as our IDL (if you're interested in OpenAPI support, please reach out to us on Discord). From protobuf, we generate rich language-specific client and server code.

To declare a state data type called Hello, put the following in a .proto file:

message Hello {
option (rbt.v1alpha1.state) = {
};
repeated string messages = 1;
}

Reboot uses standard Protobuf definitions, but take note of the rbt options (aka, annotations).

In the above example, rbt.v1alpha1.state declares that message Hello is a Reboot durable state data type.

In addition to defining the state data type, you'll also need to define operations for that type, for example:

service HelloMethods {
// Returns the current list of recorded messages.
rpc Messages(MessagesRequest) returns (MessagesResponse) {
option (rbt.v1alpha1.method).reader = {
};
}

// Adds a new message to the list of recorded messages.
rpc Send(SendRequest) returns (SendResponse) {
option (rbt.v1alpha1.method).writer = {
};
}
}
info

The current convention is that a state's interface has the same name as the state with the suffix Methods.

For each of the rpc methods that you declare, you use the rbt.v1alpha1.method annotation to specify the method kind.

Depending on its kind, a method might be able to only read (e.g., reader) or both read and write (e.g., writer) the state.

In the example above, Hello has just one reader and one writer method, but it can have any number of reader, writer, transaction, and workflow methods.

To learn more about how you implement your state data type's methods see the page on servicers.

Code generation

Code generation is a core part of working with Reboot.

Reboot has Protobuf compiler plugins that generate:

  • Type-safe, language-specific gRPC interfaces for clients and servers, including:
    • React client APIs for streaming, reactive access to your data.
    • Python and TypeScript client APIs.
    • Python and TypeScript backend server APIs.
  • A backwards-compatible schema that is automatically persisted and replicated by Reboot.

Reboot provides the rbt CLI tool to automatically generate code from your .proto file and reload your application when your code changes. You can also choose to use an existing build system to generate code by using Reboot's Protobuf plugins directly.

Construct

You construct an instance of your durable state data types by using the construct() builder method followed by a constructor, i.e., one of your methods explicitly marked as a constructor or implicitly acting as a constructor to initialize the state. For example, on a Greeter state type with a constructor called Create:

greeter, response = await Greeter.construct().Create(
context,
title='Dr',
name='Jonathan',
adjective='best',
)

The first element in the tuple returned is a reference that you can use to make other method calls (same thing returned from calling lookup()) and the second element is the response from calling the method, in the above example, a response from calling Create.

IDs

Every instance of your durable state data types has a unique ID. The ID can be used to lookup an instance in order to call methods on it.

By default, construct() will generate a new unique ID (a UUID) for the constructed state. Alternatively, you can specify an ID explicitly upon construction. Here is an example of constructing an Account with an explicit ID using the Open constructor:

account, _ = await Account.construct(id=new_account_id).Open(
context,
customer_name=request.customer_name,
)

You can get the ID of the current instance from within a state method via the passed-in context, in the field state_id (in Python) or stateId (in TypeScript).

Partitioning

Beyond identifiying an instance of a state data type, the ID is also used to automatically partition your states across CPU cores. In that way, you can think of an ID like a primary key in a database that is used to shard your data.

Singletons

Sometimes you might want a state data type to act as a "singleton" where there is a single well-known ID that is always used. But be warned, to avoid making the singleton a scalability bottleneck you should avoid storing too much data in a single state data type.

You'll probably want to construct your singletons in the initialize function that you pass to Application.

Constructors

You may designate writers or transactions as explicit constructors by giving them an extra annotation.

If a state data type doesn't specify any explicit constructors, new state instances of that type must be constructed by calling a writer or transaction method which acts as an implicit constructor. In this case, consult the state data type's documentation as it may recommend using a particular method for implicit construction.

State data types that have explicitly designated constructor methods must be initialized by a call to one of those methods. Calls to other methods on an uninitialized state will raise an error. Once a state has been created, calls to explicit constructors on that state are no longer valid and will raise an error (the type system will also prevent you from doing so in the first place).

For example, to designate that the Open method is an explicit constructor for Account:

rpc Open(OpenRequest) returns (OpenResponse) {
option (rbt.v1alpha1.method).writer = {
constructor: {},
};
}

Lookup

You can lookup an instance of your state using lookup(), which returns a reference that you can use to call a method on the state.

You can think of a reference like a handle (and it's similar to a "stub" in gRPC parlance). The actual type of reference returned from calling .lookup() is a WeakReference, where "weak" indicates that the state may not exist. Here's an example:

from_account = Account.lookup(request.from_account_id)