Skip to main content

State schema and API

Reboot uses an interface definition language (IDL) to define your APIs. Currently we support protocol buffers (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.

Your application's API includes both the schema of your durable state data types as well as the operations, i.e., methods, that can be called on them.

Reboot apps are built by transactionally composing these methods, each of which is called on a particular state. This page explains the types of Reboot methods in depth.

Code generation

Code generation is a core part of working with Reboot. Reboot applications are defined using .proto files with a small number of Reboot-specific annotations.

From a simple Protobuf definition, Reboot automatically generates:

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

Protobuf definition

A Reboot app is defined by a .proto file like the following:

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

service HelloInterface {
// 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 = {
};
}
}

This interface is mostly a plain Protobuf definition, but take note of the rbt annotations:

  • The rbt.v1alpha1.state annotation declares that message Hello is a Reboot durable state data type.
  • The rbt.v1alpha1.method annotation specifies the kind of each method of your state.
    • When a method (defined on HelloInterface) runs, it gets passed the state (Hello) for the specified ID.
    • 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 a reader and writer method, but it can have any number of reader, writer, transaction, and workflow methods.

Singletons

Some times 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 want to try and avoid storing too much state in a single state data type.

Here's an example of a constructing a singleton Bank with the well-known ID SINGLETON_BANK_ID stored as a constant that can be used in multiple files across your application:

bank = Bank.lookup(SINGLETON_BANK_ID)

await bank.idempotently().SignUp(
context,
customer_name="Initial User",
)

Methods

Safety guarantees are a core feature of Reboot. Based on an RPC’s method kind, Reboot will guarantee answers to questions such as:

  • Will an RPC perform any mutations of a state?
  • Will an RPC perform any mutations on other states?
  • If this RPC performs mutations on other states, when do those effects become visible?
  • What other RPCs can run concurrently with this RPC?

Enforcing these guarantees naturally creates a safety hierarchy, in which methods can only call other methods that enforce similar or stronger guarantees. This allows Reboot to:

  • Safely maximize the number of operations running in parallel on a state, while still preserving safety guarantees.
  • Take over complicated safety semantics for you, like locking and transactionality across states.
  • Monitor the call graph, to guarantee that transitive calls don't have unintended side-effects, even as code changes.

The four kinds of methods are described below.

reader

reader methods are allowed to read from state. They are not allowed to mutate state in any way.

Every reader is given a read-only snapshot of the state; therefore, any number of readers can execute concurrently on a given state instance. They return a response and have no effects. readers cannot call other methods that do have effects, as doing so would indirectly violate the safety guarantees of the reader. They can, however, call readers on any state machine.

Learn more about readers.

writer

writer RPCs can both read from and mutate state.

writer execution happens serially on a given state, allowing each update to happen atomically. To enforce write atomicity, writers can call any readers, but they cannot call other writers.

In addition to returning a response and updating state, a writer can also schedule async tasks, which are atomically started (or enqueued) if and only if the writer completes successfully.

Learn more about writers.

transaction

transactions combine multiple read/write operations into a single atomic action, often across multiple states.

All of the reads and writes included in the transaction will happen atomically: once a state is involved in a transaction, no other RPCs may mutate its state until that transaction is complete. If any part of the transaction fails, the entire transaction will be rolled back.

transactions may call readers, writers, and other transactions.

Learn more about transactions.

workflow

Coming soon!

Errors

When a Reboot RPC executes, the result will fall into one of three classes:

  1. All operations completed normally. The RPC should return a successful response, and its effects should be persisted.
  2. An expected normal-course-of-business error was encountered and the call aborted with a specified error. The method's effects should not happen, the caller/user should get a clear and helpful error message, and the application should continue processing requests.
  3. An internal error was encountered and some unexpected error was thrown. The application is in trouble and should stop processing requests, and developers should be made aware of the issue.

It's important that a Reboot system be able to identify and differentiate between these scenario types. Reboot must know when a call has failed so that the method's effects (and potentially the effects of its caller) can be canceled. It must also know what communication mechanism is most appropriate for reporting the error to RPC developers and clients.

For that reason, Reboot includes a mechanism for methods to enumerate the expected errors (class 2) that they may throw in normal-course-of-business. Throwing any unexpected error can then be treated as a bug to be reported (class 3), while returning effects indicates successful completion of the method (class 1).

Reporting Expected Errors in Reboot

Reboot methods should enumerate all errors that are expected as part of normal business in their proto definitions.

For example, a Withdraw method for a bank account might raise an OverdraftError if the account doesn't have sufficient funds for the requested withdrawal:

rpc Withdraw(WithdrawRequest) returns (WithdrawResponse) {
option (rbt.v1alpha1.method) = {
writer: {},
errors: [ "OverdraftError" ],
};
}

These errors are themselves defined by proto messages.

// Error returned when a withdrawal would overdraft the account.
message OverdraftError {
// Amount that we would have overdraft by.
uint32 amount = 1;
}
note

For a complete enumeration, a method must explicitly list all the errors it expects to return, including the errors from all methods it calls.

Reboot will use the proto-defined error messages to generate raisable types for use in code. Each method gets a wrapper type, MethodNameAborted, which can contain any of the error types listed in the RPC definition. This wrapper allows clients to catch all possible errors, even when the server's list of error types is incomplete or gets updated.

async def Withdraw(
self,
context: WriterContext,
state: Account.State,
request: WithdrawRequest,
) -> WithdrawResponse:
updated_balance = state.balance - request.amount
if updated_balance < 0:
raise Account.WithdrawAborted(
OverdraftError(amount=-updated_balance)
)
state.balance = updated_balance
return WithdrawResponse(updated_balance=updated_balance)