Skip to main content

State

In Reboot, durable data structures are built out of state data types. This page discusses how to (1) declare state types and (2) how to construct and get a reference to an instance of your states.

State schemas and interfaces

Reboot uses an interface definition language (IDL) to define your APIs, including the schemas of your 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 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 uses Protobuf compiler plugins for generating:

  • 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.

State IDs

Every instance of state has a unique ID. The ID can be used to passed to ref() to get a reference to an instance that you can use to call a method on the state:

from_account = Account.ref(request.from_account_id)

You can think of a reference like a handle (and it's similar to a "stub" in gRPC parlance).

important

The type of reference returned from calling .ref() is a WeakReference, where "weak" indicates that the state may or may not exist.

tip

Within one of your servicer methods you can get the ID of the current state instance via context.state_id (Python), context.stateId (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.

tip

You can construct your singletons in the initialize function that you pass to Application.

Constructing instances of state

You construct an instance of your state data types either implicitly or explicitly, depending on whether or not you have explicitliy designated certain methods as constructors in your .proto.

If you have some initialization that must be performed, use explicit construction.

Explicit construction

If you've explicitly defined a constructor in your .proto, then you must explicitly use that constructor for constructing an instance.

Here's an example in a .proto that designates that the Open method is an explicit constructor for Account:

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

Constructors are exposed as static or class methods in the generated code, and return a tuple where the first element is a reference to the constructed state (same thing returned from ref()) and the second is the response from calling the method. For example:

account, response = await Account.Open(
context,
customer_name=request.customer_name,
)
caution

You can only call an explicit constructor once! Otherwise you'll get back a StateAlreadyConstructed error. This can be useful if you're trying to check if you've already constructed the state, but you may also consider using implicit construction and calling a no-op method to ensure that the state has been constructed.

Calling an explicit constructor will generate a new unique ID (a UUID) for the constructed state. Alternatively, you can specify an ID explicitly upon construction by passing it after context. Here is an example of constructing an Account with an explicit ID using the Open constructor:

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

Idempotent construction

You can call explicit constructors using idempotently() external to a Reboot application (i.e., using an ExternalContext) or from a workflow (i.e., using a WorkflowContext) just like you can your other methods:

account, response = await Account.idempotently().Open(
context,
customer_name=request.customer_name,
)

Implicit construction

If a state data type doesn't specify any explicit constructors, new state instances of that type will be implicitly constructed when calling a writer or transaction method. For example:

counter = Counter.ref(id)

# Will implicitly construct if not already constructed!
await counter.Increment(context)
tip

A state will not be implicitly constructed when you call a reader method so you may need to properly ensure you've constructed your state by calling a writer or transaction first (which may just be a no-op method that acts like a "constructor" but can be called multiple times unlike explicit constructors which can only be called once).