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 = {
};
}
}
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:
- Python
- TypeScript
from_account = Account.ref(request.from_account_id)
const fromAccount = Account.ref(request.fromAccountId);
You can think of a reference like a handle (and it's similar to a "stub" in gRPC parlance).
The type of reference returned from
calling .ref()
is a WeakReference
, where "weak" indicates that
the state may or may not exist.
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.
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:
- Python
- TypeScript
account, response = await Account.Open(
context,
customer_name=request.customer_name,
)
const [account, response] = await Account.open(context, {
customerName: request.customerName,
});
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:
- Python
- TypeScript
account, _ = await Account.Open(
context,
new_account_id,
customer_name=request.customer_name,
)
const [account, response] = await Account.open(context, newAccountId, {
customerName: request.customerName,
});
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:
- Python
- TypeScript
account, response = await Account.idempotently().Open(
context,
customer_name=request.customer_name,
)
const [account, response] = await Account.idempotently().open(context, {
customerName: request.customerName,
});
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:
- Python
- TypeScript
counter = Counter.ref(id)
# Will implicitly construct if not already constructed!
await counter.Increment(context)
const counter = Counter.ref(id);
// Will implicitly construct if not already constructed!
await counter.increment(context);
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).