
Traditional distributed programming is notoriously painful, primarily because we need to reason about the implicit global behavior while writing the explicit local programs that actually run on each node. This fragmented implementation makes programs difficult to debug, understand, and deprives them of type-checking provided by programming languages. Choreographic Programming makes the global behavior explicit by allowing developers to write a single program that requires communication across multiple participants, which is then projected onto each participant to achieve global behavior.
Choreographic programming is implemented in two distinct approaches:
- As a completely new programming language (e.g., Choral), where developers write Choral programs that will be compiled into participant-specific Java programs.
- As a library (e.g., HasChor), leveraging Haskell's type system to ensure static properties of choreographic programming while seamlessly integrating with Haskell's ecosystem.
MoonBit's functional programming features and powerful type system make it particularly suitable for building choreographic programming libraries.
This article demonstrates the core concepts and basic usage of choreographic programming using MoonBit's moonchor library through several examples.
Let's examine a bookstore application involving two roles: Buyer and Seller. The core logic is as follows:
- The buyer sends the desired book title to the seller.
- The seller queries the database and informs the buyer of the price.
- The buyer decides whether to purchase the book.
- If the buyer decides to purchase, the seller deducts the book from inventory and sends the estimated delivery date to the buyer.
- Otherwise, the interaction terminates.
Here, we focus on core logic rather than implementation details, using send
and recv
functions to represent message passing. In the traditional approach, we need to develop two separate applications for buyer and seller. We assume the following helper functions and types exist:
fn get_title() -> String {
"Homotopy Type Theory"
}
fn get_price(title : String) -> Int {
50
}
fn get_budget() -> Int {
100
}
fn (title : String) -> String
get_delivery_date(title : String) -> String {
"2025-10-01"
}
enum Role {
Buyer
Seller
}
async fn[T] async (msg : T, target : Role) -> Unit
send(msg : T, target : enum Role {
Buyer
Seller
}
Role) -> Unit {
...
}
async fn[T] async (source : Role) -> T
recv(source : enum Role {
Buyer
Seller
}
Role) -> T {
...
}
The buyer's application:
async fn book_buyer() -> Unit {
let title = get_title()
async (msg : String, target : Role) -> Unit
send(title, Seller)
let price = async (source : Role) -> Int
recv(Seller)
if price (self_ : Int, other : Int) -> Bool
<= get_budget() {
async (msg : Bool, target : Role) -> Unit
send(true, Seller)
let delivery_date = async (source : Role) -> Unit
recv(Seller)
(input : String) -> Unit
Prints any value that implements the Show
trait to the standard output,
followed by a newline.
Parameters:
value
: The value to be printed. Must implement the Show
trait.
Example:
println(42)
println("Hello, World!")
println([1, 2, 3])
println("The book will be delivered on: \{delivery_date}")
} else {
async (msg : Bool, target : Role) -> Unit
send(false, Seller)
}
}
The seller's application:
async fn book_seller() -> Unit {
let title = async (source : Role) -> String
recv(Buyer)
let price = get_price(title)
async (msg : Int, target : Role) -> Unit
send(price, Buyer)
let decision = async (source : Role) -> Bool
recv(Buyer)
if decision {
let delivery_date = (title : String) -> String
get_delivery_date(title)
async (msg : String, target : Role) -> Unit
send(delivery_date, Buyer)
}
}
These two implementations suffer from at least the following issues:
-
No type safety guarantee: Note that both send
and recv
are generic functions. Type safety is only ensured when the types of sending and receiving messages match; otherwise, runtime errors may occur during (de)serialization. The compiler cannot verify type safety at compile time because it cannot determine which send
corresponds to which recv
. Type safety is dependent on the developer not making mistakes.
-
Potential deadlocks: If the developer accidentally forgets to write some send
in the buyer's program, both buyer and seller may wait indefinitely for each other's messages and be stuck. Alternatively, if a buyer's connection is temporarily interrupted during network communication, the seller will keep waiting for the buyer's message. Both scenarios lead to deadlocks.
-
Explicit synchronization required: To communicate the purchase decision, the buyer must explicitly send a Bool
message. Subsequent coordination requires ensuring both buyer and seller follow the same execution path at the if price <= get_budget()
and if decision
branches - a property that cannot be guaranteed at compile time.
The root cause of these problems lies in splitting what should be a unified coordination logic into two separate implementations based on implementation requirements. Next, we'll examine how choreographic programming addresses these issues.
With choreographic programming, we can write the buyer's and seller's logic in the same function, which then exhibits different behaviors with different parameters when called. We use moonchor's API to define the buyer and seller roles. In moonchor, roles are defined as trait Location
. To provide better static properties, roles are not only values but also unique types that need to implement the Location
trait.
struct Buyer {} derive(trait Show {
output(Self, &Logger) -> Unit
to_string(Self) -> String
}
Trait for types that can be converted to String
Show, trait Hash {
hash_combine(Self, Hasher) -> Unit
hash(Self) -> Int
}
Trait for types that can be hashed
Hash)
impl @moonchor.Location for Buyer with name(_) {
"buyer"
}
struct Seller {} derive(trait Show {
output(Self, &Logger) -> Unit
to_string(Self) -> String
}
Trait for types that can be converted to String
Show, trait Hash {
hash_combine(Self, Hasher) -> Unit
hash(Self) -> Int
}
Trait for types that can be hashed
Hash)
impl @moonchor.Location for Seller with name(_) {
"seller"
}
let buyer : Buyer = Buyer::{ }
let seller : Seller = Seller::{ }
Buyer
and Seller
types don't contain any fields. Types implementing the Location
trait only need to provide a name
method that returns a string as the role's identifier. This name
method is critically important - it serves as the definitive identity marker for roles and provides a final verification mechanism when type checking cannot guarantee type safety. Never assign the same name to different roles, as this will lead to unexpected runtime errors. Later we'll examine how types provide a certain level of safety and why relying solely on types is insufficient.
Next, we define the core logic of the bookstore application, which is referred to as a choreography:
async fn bookshop(ctx : @moonchor.ChoreoContext) -> Unit {
let title_at_buyer = ctx.(Buyer, (Unit) -> String) -> Unit
locally(buyer, _unwrapper => get_title())
let title_at_seller = ctx.(Buyer, Seller, Unit) -> Unit
comm(buyer, seller, title_at_buyer)
let price_at_seller = ctx.(Seller, (Unit) -> Int) -> Unit
locally(seller, fn(unwrapper) {
let title = unwrapper.unwrap(title_at_seller)
get_price(title)
})
let price_at_buyer = ctx.(Seller, Buyer, Unit) -> Unit
comm(seller, buyer, price_at_seller)
let decision_at_buyer = ctx.(Buyer, (Unit) -> Bool) -> Unit
locally(buyer, fn(unwrapper) {
let price = unwrapper.unwrap(price_at_buyer)
price (self_ : Int, other : Int) -> Bool
< get_budget()
})
if ctx.broadcast(buyer, decision_at_buyer) {
let delivery_date_at_seller = ctx.(Seller, (Unit) -> String) -> Unit
locally(seller, unwrapper => (title : String) -> String
get_delivery_date(
unwrapper.unwrap(title_at_seller),
))
let delivery_date_at_buyer = ctx.(Seller, Buyer, Unit) -> Unit
comm(
seller, buyer, delivery_date_at_seller,
)
ctx.(Buyer, (Unit) -> Unit) -> Unit
locally(buyer, fn(unwrapper) {
let delivery_date = unwrapper.unwrap(delivery_date_at_buyer)
(input : String) -> Unit
Prints any value that implements the Show
trait to the standard output,
followed by a newline.
Parameters:
value
: The value to be printed. Must implement the Show
trait.
Example:
println(42)
println("Hello, World!")
println([1, 2, 3])
println("The book will be delivered on \{delivery_date}")
})
|> (t : Unit) -> Unit
Evaluates an expression and discards its result. This is useful when you want
to execute an expression for its side effects but don't care about its return
value, or when you want to explicitly indicate that a value is intentionally
unused.
Parameters:
value
: The value to be ignored. Can be of any type.
Example:
let x = 42
ignore(x) // Explicitly ignore the value
let mut sum = 0
ignore([1, 2, 3].iter().each((x) => { sum = sum + x })) // Ignore the Unit return value of each()
ignore
}
}
This program is somewhat lengthy, so let's analyze it line by line.
The function parameter ctx: @moonchor.ChoreoContext
is the context object provided by moonchor to applications, containing all interfaces for choreographic programming on the application side. First, we use ctx.locally
to execute an operation get_title()
that only needs to run at the buyer role. The first parameter of ctx.locally
is the role. The second parameter is a closure where the content is the operation to execute, with the return value being wrapped as the return value of ctx.locally
. Here, get_title()
returns a String
, while title_at_buyer
has type @moonchor.Located[String, Buyer]
, indicating this value exists at the buyer role and cannot be used by other roles. If you attempt to use title_at_buyer
at the seller role, the compiler will report an error stating that Buyer and Seller are not the same type.
Next, the buyer needs to send the book title to the seller, which we implement using ctx.comm
. The first parameter of ctx.comm
is the sender role, the second is the receiver role, and the third is the message to send. Here, the return value title_at_seller
has type @moonchor.Located[String, Seller]
, indicating this value exists at the seller role. As you might have guessed, ctx.comm
corresponds precisely to the send
and recv
operations. However, here type safety is guaranteed: ctx.comm
is a generic function that ensures (1) the sent and received messages have the same type, and (2) the sender and receiver roles correspond to the type parameters of the parameter and return types, namely @moonchor.Located[T, Sender]
and @moonchor.Located[T, Receiver]
.
Moving forward, the seller queries the database to get the book price. At this step we use the unwrapper
parameter passed to the ctx.locally
closure. This parameter is an object for unpacking Located types, whose type signature also includes a role type parameter. We can understand how it works by examining the signature of Unwrapper::unwrap
: fn[T, L] Unwrapper::unwrap(_ : Unwrapper[L], v : Located[T, L]) -> T
. This means in ctx.locally(buyer, unwrapper => ...)
, unwrapper
has type Unwrapper[Buyer]
, while title_at_seller
has type Located[String, Seller]
, so unwrapper.unwrap(title_at_seller)
yields a result of type String
. This explains why we can use title_at_seller
in the closure but not title_at_buyer
.
Explicit synchronization in the subsequent process is critical. We need a dedicated section to explain that. In choreographic programming, this synchronization is referred to as Knowledge of Choice. In the example above, the buyer needs to know whether to purchase the book, and the seller needs to know the buyer's decision. We use ctx.broadcast
to implement this functionality.
The first parameter of ctx.broadcast
is the sender's role, and the second parameter is the message to be shared with all other roles. In this example, both buyer and seller need to know the purchase decision, so the buyer broadcasts this decision decision_at_buyer
to all participants (here only the seller) via ctx.broadcast
. Interestingly, the return value of broadcast
is a plain type rather than a Located
type, meaning it can be used by all roles directly at the top level without needing to be unwrapped with unwrapper
in locally
. This allows us to use MoonBit's native if
conditional statements for subsequent flows, ensuring both buyer and seller follow the same branch.
As the name suggests, ctx.broadcast
serves to broadcast a value throughout the entire choreography. It can broadcast not just Bool
types but any other type as well. Its results can be applied not only to if
conditions but also to while
loops or any other scenarios requiring common knowledge.
How does such a choreography run? moonchor provides the run_choreo
function to launch a choreography. Currently, due to MoonBit's multi-backend feature, providing stable, portable TCP servers and cross-process communication interfaces presents challenges. Therefore, we'll use coroutines and channels to explore the actual execution process of choreographies. The complete launch code is as follows:
test "Blog: bookshop" {
let backend = @moonchor.make_local_backend([buyer, seller])
@toolkit.run_async(() => (Unit, async (?) -> Unit, Buyer) -> Unit
@moonchor.run_choreo(backend, bookshop, buyer) )
@toolkit.run_async(() => (Unit, async (?) -> Unit, Seller) -> Unit
@moonchor.run_choreo(backend, bookshop, seller) )
}
The above code launches two coroutines that execute the same choreography at the buyer and seller respectively. This can also be understood as the bookshop
function being projected (also called EPP, endpoint projection) into two completely different versions: the "buyer version" and "seller version". In this example, the first parameter of run_choreo
is a Backend
type object that provides the underlying communication mechanism required for choreographic programming. We use the make_local_backend
function to create a local backend (not to be confused with MoonBit's multi-backend mentioned earlier), which can run in local processes using the channel API provided by peter-jerry-ye/async/channel
as the communication foundation. In the future, moonchor will provide more backend implementations, such as HTTP.
We have gained a preliminary understanding of choreographic programming and moonchor. Next, we will formally introduce the APIs we've used along with some unused ones, while explaining some of their underlying principles.
In moonchor, we define roles by implementing the Location
trait. The trait is declared as follows:
pub(open) trait trait Location {
name(Self) -> String
}
Location: trait Show {
output(Self, &Logger) -> Unit
to_string(Self) -> String
}
Trait for types that can be converted to String
Show + trait Hash {
hash_combine(Self, Hasher) -> Unit
hash(Self) -> Int
}
Trait for types that can be hashed
Hash {
name(Self) -> String
}
The Location
trait object implements Eq
:
impl trait Eq {
op_equal(Self, Self) -> Bool
}
Trait for types whose elements can test for equality
Eq for &trait Location {
name(Self) -> String
}
Location with (self : &Location, other : &Location) -> Bool
op_equal(self, other) {
self.name() (self : String, other : String) -> Bool
Tests whether two strings are equal by comparing their characters.
Parameters:
self
: The first string to compare.
other
: The second string to compare.
Returns true
if both strings contain exactly the same sequence of
characters, false
otherwise.
Example:
let str1 = "hello"
let str2 = "hello"
let str3 = "world"
inspect(str1 == str2, content="true")
inspect(str1 == str3, content="false")
== other.name()
}
If two roles' name
methods return the same string, they are considered the same role; otherwise, they are not. When determining whether a value belongs to a certain role, the name
method serves as the definitive arbiter. This means values can have the same type but actually represent different roles. This feature is particularly important when handling dynamically generated roles. For example, in the bookstore scenario, there might be multiple buyers, and the seller needs to handle multiple buyer requests simultaneously, dynamically generating buyer roles based on server connections. In this case, the buyer type would be defined as:
struct DynamicBuyer {
id : String
} derive(trait Show {
output(Self, &Logger) -> Unit
to_string(Self) -> String
}
Trait for types that can be converted to String
Show, trait Hash {
hash_combine(Self, Hasher) -> Unit
hash(Self) -> Int
}
Trait for types that can be hashed
Hash)
impl @moonchor.Location for struct DynamicBuyer {
id: String
}
DynamicBuyer with name(self) {
"buyer-\{self.id}"
}
Since values located at different roles may coexist in a choreography, we need a way to distinguish which role each value is located at. In moonchor, this is represented by the Located[T, L]
type, indicating a value of type T
located at role L
.
type Located[T, L]
type Unwrapper[L]
Located Values
are constructed via ChoreoContext::locally
or ChoreoContext::comm
. Both functions return a Located
value.
To use a Located Value
, we employ the unwrap
method of the Unwrapper
object. These concepts have already been demonstrated in the bookstore application example and won't be elaborated further here.
The most common API we've seen in examples is ChoreoContext::locally
, which is used to perform a local computation at a specific role. Its signature is as follows:
type ChoreoContext
fn[T, L : trait Location {
name(Self) -> String
}
Location] (self : ChoreoContext, location : L, computation : (Unwrapper[L]) -> T) -> Located[T, L]
locally(
self : ChoreoContext,
location : L,
computation : (Unwrapper[L]) -> T
) -> Located[T, L] {
...
}
This API executes the computation
closure at the specified location
role and wraps the result as a Located Value
. The computation
closure takes a single parameter - an unwrapper object of type Unwrapper[L]
, which is used within the closure to unpack Located[T, L]
values into T
types. This API binds computation results to specific roles, ensuring values can only be used at their designated roles. Attempting to use a value at another role or process values from different roles with this unwrapper will trigger compiler errors.
The ChoreoContext::comm
API handles value transmission between roles. Its declaration is as follows:
trait Message: trait ToJson {
to_json(Self) -> Json
}
Trait for types that can be converted to Json
ToJson + trait @json.FromJson {
from_json(Json, @json.JsonPath) -> Self raise @json.JsonDecodeError
}
Trait for types that can be converted from Json
@json.FromJson {}
async fn[T : Message, From : trait Location {
name(Self) -> String
}
Location, To : trait Location {
name(Self) -> String
}
Location] async (self : ChoreoContext, from : From, to : To, value : Located[T, From]) -> Located[T, To]
comm(
self : ChoreoContext,
from : From,
to : To,
value : Located[T, From]
) -> Located[T, To] {
...
}
Sending and receiving typically require serialization and deserialization. In moonchor's current implementation, Json
is the message carrier for convenience. In the future, byte streams may be adopted as a more efficient and universal carrier.
ChoreoContext::comm
has three type parameters: the message type to send, plus the sender and receiver role types From
and To
. These two role types correspond exactly to the method's from
parameter, to
parameter, as well as the value
parameter and return value type. This ensures type safety during message (de)serialization between sender and receiver, and guarantees send/receive operations are properly paired, preventing accidental deadlocks.
When needing to share a value among multiple roles, we use the ChoreoContext::broadcast
API to have a role broadcast a value to all other roles. Its signature is as follows:
async fn[T : Message, L : trait Location {
name(Self) -> String
}
Location] ChoreoContext::async (self : ChoreoContext, loc : L, value : Located[T, L]) -> T
broadcast(
self : ChoreoContext,
loc : L,
value : Located[T, L]
) -> T {
...
}
The broadcast API is similar to the communication API, with two key differences:
- Broadcast doesn't require specifying receiver roles - it defaults to all roles in the choreography;
- The broadcast return value isn't a
Located Value
, but rather the message's type.
These characteristics reveal broadcast's purpose: enabling all roles to access the same value, allowing operations on this value at the choreography's top level rather than being confined within ChoreoContext::locally
. For example, in the bookstore case, both buyer and seller need consensus on the purchase decision to ensure subsequent processes remain synchronized.
The API for running a choreography is as follows:
type Backend
typealias async (ChoreoContext) -> T as Choreo[T]
async fn[T, L : trait Location {
name(Self) -> String
}
Location] async (backend : Backend, choreography : async (ChoreoContext) -> T, role : L) -> T
run_choreo(
backend : Backend,
async (ChoreoContext) -> T
choreography : Choreo[T],
role : L
) -> T {
...
}
It takes three parameters: a backend, a user-written choreography, and the role to execute. The backend contains the concrete implementation of the communication mechanism, while the execution role specifies where this choreography should run. For example, in previous cases, the buyer's program needs to pass a value of type Buyer
here, while the seller needs to pass a value of type Seller
.
moonchor provides a local backend based on coroutines and channels:
fn (locations : Array[&Location]) -> Backend
make_local_backend(locations: type Array[T]
An Array
is a collection of values that supports random access and can
grow in size.
Array[&trait Location {
name(Self) -> String
}
Location]) -> Backend {
...
}
This function establishes communication channels between all roles specified in the parameters, providing concrete communication implementations - namely the send
and recv
methods. The local backend can only be used for monolithic concurrent programs rather than true distributed applications. Well, the backend is pluggable: With other backends implemented based on stable network communication APIs, moonchor can easily be used to build distributed programs.
In this section, we'll explore a more complicated case study - implementing a multi-replica KVStore using moonchor. We'll still only use moonchor's core APIs while fully leveraging MoonBit's generics and first-class functions. Our goal is to explore how MoonBit's powerful expressiveness can enhance choreographic programming functionalities.
First, let's prepare by defining two roles: Client and Server:
struct Server {} derive(trait Hash {
hash_combine(Self, Hasher) -> Unit
hash(Self) -> Int
}
Trait for types that can be hashed
Hash, trait Show {
output(Self, &Logger) -> Unit
to_string(Self) -> String
}
Trait for types that can be converted to String
Show)
struct Client {} derive(trait Hash {
hash_combine(Self, Hasher) -> Unit
hash(Self) -> Int
}
Trait for types that can be hashed
Hash, trait Show {
output(Self, &Logger) -> Unit
to_string(Self) -> String
}
Trait for types that can be converted to String
Show)
impl @moonchor.Location for Server with name(_) {
"server"
}
impl @moonchor.Location for Client with name(_) {
"client"
}
let server : Server = Server::{ }
let client : Client = Client::{ }
To implement a KVStore like Redis, we need to implement two basic interfaces: get and put (corresponding to Redis's get and set). The simplest implementation uses a Map data structure to store key-value pairs:
struct ServerState {
db: type Map[K, V]
Mutable linked hash map that maintains the order of insertion, not thread safe.
Example
let map = { 3: "three", 8 : "eight", 1 : "one"}
assert_eq(map.get(2), None)
assert_eq(map.get(3), Some("three"))
map.set(3, "updated")
assert_eq(map.get(3), Some("updated"))
Map[String, Int]
}
fn struct ServerState {
db: Map[String, Int]
}
ServerState::new() -> struct ServerState {
db: Map[String, Int]
}
ServerState {
{ db: {} }
}
For the KVStore, get and put requests are sent by clients over the network. Before receiving requests, we don't know their specific content. Therefore, we need to define a request type Request
that includes the request type and parameters:
enum Request {
Get(String)
Put(String, Int)
} derive(trait ToJson {
to_json(Self) -> Json
}
Trait for types that can be converted to Json
ToJson, trait @json.FromJson {
from_json(Json, @json.JsonPath) -> Self raise @json.JsonDecodeError
}
Trait for types that can be converted from Json
FromJson)
For convenience, our KVStore only supports String
keys and Int
values. Next, we define a Response
type to represent the server's response to requests:
typealias Int? as Response
The response is an optional integer. For Put
requests, the response is None
; for Get
requests, the response is the corresponding value wrapped in Some
, or None
if the key doesn't exist.
fn (state : ServerState, request : Request) -> Int?
handle_request(state : struct ServerState {
db: Map[String, Int]
}
ServerState, request : enum Request {
Get(String)
Put(String, Int)
}
Request) -> enum Option[A] {
None
Some(A)
}
Response {
match request {
Request::Get(key) => state.db.(self : Map[String, Int], key : String) -> Int?
Get the value associated with a key.
get(key)
Request::Put(key, value) => {
state.db(Map[String, Int], String, Int) -> Unit
[key] = value
None
}
}
}
Our goal is to define two functions, put
and get
, to simulate the client's request initiation process. Their respective tasks are:
- Generate the request at the
Client
, wrapping the key-value pair;
- Send the request to the
Server
;
- The
Server
processes the request using the handle_request
function;
- Send the response back to the
Client
.
As we can see, the logic of put
and get
functions is similar. We can abstract the three processes (2, 3, and 4) into a single function called access_server
.
async fn async (ctx : ?, state_at_server : ?, key : String, value : Int) -> Unit
put_v1(
ctx : @moonchor.ChoreoContext,
state_at_server : @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Server],
key : String,
value : Int
) -> Unit {
let request = ctx.(Client, (Unit) -> Request) -> ?
locally(client, _unwrapper => Request::Put(key, value))
async (ctx : ?, request : ?, state_at_server : ?) -> ?
access_server_v1(ctx, request, state_at_server) |> (t : ?) -> Unit
Evaluates an expression and discards its result. This is useful when you want
to execute an expression for its side effects but don't care about its return
value, or when you want to explicitly indicate that a value is intentionally
unused.
Parameters:
value
: The value to be ignored. Can be of any type.
Example:
let x = 42
ignore(x) // Explicitly ignore the value
let mut sum = 0
ignore([1, 2, 3].iter().each((x) => { sum = sum + x })) // Ignore the Unit return value of each()
ignore
}
async fn async (ctx : ?, state_at_server : ?, key : String) -> ?
get_v1(
ctx : @moonchor.ChoreoContext,
state_at_server : @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Server],
key : String
) -> @moonchor.Located[enum Option[A] {
None
Some(A)
}
Response, Client] {
let request = ctx.(Client, (Unit) -> Request) -> ?
locally(client, _unwrapper => Request::Get(key))
async (ctx : ?, request : ?, state_at_server : ?) -> ?
access_server_v1(ctx, request, state_at_server)
}
async fn async (ctx : ?, request : ?, state_at_server : ?) -> ?
access_server_v1(
ctx : @moonchor.ChoreoContext,
request : @moonchor.Located[enum Request {
Get(String)
Put(String, Int)
}
Request, Client],
state_at_server : @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Server]
) -> @moonchor.Located[enum Option[A] {
None
Some(A)
}
Response, Client] {
let request_at_server = ctx.(Client, Server, ?) -> Unit
comm(client, server, request)
let response = ctx.(Server, (Unit) -> Int?) -> Unit
locally(server, fn(unwrapper) {
let request = unwrapper.unwrap(request_at_server)
let state = unwrapper.unwrap(state_at_server)
(state : ServerState, request : Request) -> Int?
handle_request(state, request)
})
ctx.(Server, Client, Unit) -> ?
comm(server, client, response)
}
With this, our KVStore implementation is complete. We can write a simple choreography to test it:
async fn kvstore_v1(ctx : @moonchor.ChoreoContext) -> Unit {
let state_at_server = ctx.(Server, (Unit) -> ServerState) -> ?
locally(server, _unwrapper => struct ServerState {
db: Map[String, Int]
}
ServerState::new())
async (ctx : ?, state_at_server : ?, key : String, value : Int) -> Unit
put_v1(ctx, state_at_server, "key1", 42)
async (ctx : ?, state_at_server : ?, key : String, value : Int) -> Unit
put_v1(ctx, state_at_server, "key2", 41)
let v1_at_client = async (ctx : ?, state_at_server : ?, key : String) -> ?
get_v1(ctx, state_at_server, "key1")
let v2_at_client = async (ctx : ?, state_at_server : ?, key : String) -> ?
get_v1(ctx, state_at_server, "key2")
ctx.(Client, (Unit) -> Unit) -> Unit
locally(client, fn(unwrapper) {
let v1 = unwrapper.unwrap(v1_at_client).unwrap()
let v2 = unwrapper.unwrap(v2_at_client).unwrap()
if v1 (self : Int, other : Int) -> Int
Adds two 32-bit signed integers. Performs two's complement arithmetic, which
means the operation will wrap around if the result exceeds the range of a
32-bit integer.
Parameters:
self
: The first integer operand.
other
: The second integer operand.
Returns a new integer that is the sum of the two operands. If the
mathematical sum exceeds the range of a 32-bit integer (-2,147,483,648 to
2,147,483,647), the result wraps around according to two's complement rules.
Example:
inspect(42 + 1, content="43")
inspect(2147483647 + 1, content="-2147483648") // Overflow wraps around to minimum value
+ v2 (self : Int, other : Int) -> Bool
Compares two integers for equality.
Parameters:
self
: The first integer to compare.
other
: The second integer to compare.
Returns true
if both integers have the same value, false
otherwise.
Example:
inspect(42 == 42, content="true")
inspect(42 == -42, content="false")
== 83 {
(input : String) -> Unit
Prints any value that implements the Show
trait to the standard output,
followed by a newline.
Parameters:
value
: The value to be printed. Must implement the Show
trait.
Example:
println(42)
println("Hello, World!")
println([1, 2, 3])
println("The server is working correctly")
} else {
panic()
}
})
|> (t : Unit) -> Unit
Evaluates an expression and discards its result. This is useful when you want
to execute an expression for its side effects but don't care about its return
value, or when you want to explicitly indicate that a value is intentionally
unused.
Parameters:
value
: The value to be ignored. Can be of any type.
Example:
let x = 42
ignore(x) // Explicitly ignore the value
let mut sum = 0
ignore([1, 2, 3].iter().each((x) => { sum = sum + x })) // Ignore the Unit return value of each()
ignore
}
test "kvstore v1" {
let backend = @moonchor.make_local_backend([server, client])
@toolkit.run_async(() => (Unit, async (?) -> Unit, Server) -> Unit
@moonchor.run_choreo(backend, kvstore_v1, server))
@toolkit.run_async(() => (Unit, async (?) -> Unit, Client) -> Unit
@moonchor.run_choreo(backend, kvstore_v1, client))
}
This program stores two numbers 42 and 41 under "key1" and "key2" respectively, then retrieves these values from the server and verifies their sum equals 83. If any request returns None or the calculation result isn't 83, the program will panic.
Now, let's enhance the KVStore with fault tolerance. The simplest approach is to create a backup replica that maintains identical data to the primary replica, while performing consistency checks during Get
requests.
We'll create a new role for the backup replica:
struct Backup {} derive(trait Hash {
hash_combine(Self, Hasher) -> Unit
hash(Self) -> Int
}
Trait for types that can be hashed
Hash, trait Show {
output(Self, &Logger) -> Unit
to_string(Self) -> String
}
Trait for types that can be converted to String
Show)
impl @moonchor.Location for Backup with name(_) {
"backup"
}
let backup : Backup = Backup::{ }
Define a function to check consistency: this function verifies whether all replica responses are identical, and panics if inconsistencies are found.
fn (responses : Array[Int?]) -> Unit
check_consistency(responses: type Array[T]
An Array
is a collection of values that supports random access and can
grow in size.
Array[enum Option[A] {
None
Some(A)
}
Response]) -> Unit {
match responses.(self : Array[Int?]) -> Int??
Removes the last element from a array and returns it, or None
if it is empty.
Example
let v = [1, 2, 3]
assert_eq(v.pop(), Some(3))
assert_eq(v, [1, 2])
pop() {
None => return
Some(f) =>
for res in responses {
if res (x : Int?, y : Int?) -> Bool
!= f {
panic()
}
}
}
}
Most other components remain unchanged. We only need to add replica handling in the access_server
function. The new access_server_v2
logic works as follows: after receiving a request, the Server
forwards it to Backup
; then Server
and Backup
process the request separately; after processing, Backup
sends the response back to Server
, where Server
performs consistency checks on both results.
async fn async (ctx : ?, state_at_server : ?, state_at_backup : ?, key : String, value : Int) -> Unit
put_v2(
ctx : @moonchor.ChoreoContext,
state_at_server : @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Server],
state_at_backup : @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Backup],
key : String,
value : Int
) -> Unit {
let request = ctx.(Client, (Unit) -> Request) -> ?
locally(client, _unwrapper => Request::Put(key, value))
async (ctx : ?, request : ?, state_at_server : ?, state_at_backup : ?) -> ?
access_server_v2(ctx, request, state_at_server, state_at_backup) |> (t : ?) -> Unit
Evaluates an expression and discards its result. This is useful when you want
to execute an expression for its side effects but don't care about its return
value, or when you want to explicitly indicate that a value is intentionally
unused.
Parameters:
value
: The value to be ignored. Can be of any type.
Example:
let x = 42
ignore(x) // Explicitly ignore the value
let mut sum = 0
ignore([1, 2, 3].iter().each((x) => { sum = sum + x })) // Ignore the Unit return value of each()
ignore
}
async fn async (ctx : ?, state_at_server : ?, state_at_backup : ?, key : String) -> ?
get_v2(
ctx : @moonchor.ChoreoContext,
state_at_server : @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Server],
state_at_backup : @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Backup],
key : String
) -> @moonchor.Located[enum Option[A] {
None
Some(A)
}
Response, Client] {
let request = ctx.(Client, (Unit) -> Request) -> ?
locally(client, _unwrapper => Request::Get(key))
async (ctx : ?, request : ?, state_at_server : ?, state_at_backup : ?) -> ?
access_server_v2(ctx, request, state_at_server, state_at_backup)
}
async fn async (ctx : ?, request : ?, state_at_server : ?, state_at_backup : ?) -> ?
access_server_v2(
ctx : @moonchor.ChoreoContext,
request : @moonchor.Located[enum Request {
Get(String)
Put(String, Int)
}
Request, Client],
state_at_server : @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Server],
state_at_backup : @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Backup]
) -> @moonchor.Located[enum Option[A] {
None
Some(A)
}
Response, Client] {
let request_at_server = ctx.(Client, Server, ?) -> Unit
comm(client, server, request)
let request_at_backup = ctx.(Server, Backup, Unit) -> Unit
comm(server, backup, request_at_server)
let response_at_backup = ctx.(Backup, (Unit) -> Int?) -> Unit
locally(backup, fn(unwrapper) {
let request = unwrapper.unwrap(request_at_backup)
let state = unwrapper.unwrap(state_at_backup)
(state : ServerState, request : Request) -> Int?
handle_request(state, request)
})
let backup_response_at_server = ctx.(Backup, Server, Unit) -> Unit
comm(backup, server, response_at_backup)
let response_at_server = ctx.(Server, (Unit) -> Int?) -> Unit
locally(server, fn(unwrapper) {
let request = unwrapper.unwrap(request_at_server)
let state = unwrapper.unwrap(state_at_server)
let response = (state : ServerState, request : Request) -> Int?
handle_request(state, request)
let backup_response = unwrapper.unwrap(backup_response_at_server)
(responses : Array[Int?]) -> Unit
check_consistency([response, backup_response])
response
})
ctx.(Server, Client, Unit) -> ?
comm(server, client, response_at_server)
}
As before, we can write a simple choreography to test it:
async fn kvstore_v2(ctx : @moonchor.ChoreoContext) -> Unit {
let state_at_server = ctx.(Server, (Unit) -> ServerState) -> ?
locally(server, _unwrapper => struct ServerState {
db: Map[String, Int]
}
ServerState::new())
let state_at_backup = ctx.(Backup, (Unit) -> ServerState) -> ?
locally(backup, _unwrapper => struct ServerState {
db: Map[String, Int]
}
ServerState::new())
async (ctx : ?, state_at_server : ?, state_at_backup : ?, key : String, value : Int) -> Unit
put_v2(ctx, state_at_server, state_at_backup, "key1", 42)
async (ctx : ?, state_at_server : ?, state_at_backup : ?, key : String, value : Int) -> Unit
put_v2(ctx, state_at_server, state_at_backup, "key2", 41)
let v1_at_client = async (ctx : ?, state_at_server : ?, state_at_backup : ?, key : String) -> ?
get_v2(ctx, state_at_server, state_at_backup, "key1")
let v2_at_client = async (ctx : ?, state_at_server : ?, state_at_backup : ?, key : String) -> ?
get_v2(ctx, state_at_server, state_at_backup, "key2")
ctx.(Client, (Unit) -> Unit) -> Unit
locally(client, fn(unwrapper) {
let v1 = unwrapper.unwrap(v1_at_client).unwrap()
let v2 = unwrapper.unwrap(v2_at_client).unwrap()
if v1 (self : Int, other : Int) -> Int
Adds two 32-bit signed integers. Performs two's complement arithmetic, which
means the operation will wrap around if the result exceeds the range of a
32-bit integer.
Parameters:
self
: The first integer operand.
other
: The second integer operand.
Returns a new integer that is the sum of the two operands. If the
mathematical sum exceeds the range of a 32-bit integer (-2,147,483,648 to
2,147,483,647), the result wraps around according to two's complement rules.
Example:
inspect(42 + 1, content="43")
inspect(2147483647 + 1, content="-2147483648") // Overflow wraps around to minimum value
+ v2 (self : Int, other : Int) -> Bool
Compares two integers for equality.
Parameters:
self
: The first integer to compare.
other
: The second integer to compare.
Returns true
if both integers have the same value, false
otherwise.
Example:
inspect(42 == 42, content="true")
inspect(42 == -42, content="false")
== 83 {
(input : String) -> Unit
Prints any value that implements the Show
trait to the standard output,
followed by a newline.
Parameters:
value
: The value to be printed. Must implement the Show
trait.
Example:
println(42)
println("Hello, World!")
println([1, 2, 3])
println("The server is working correctly")
} else {
panic()
}
})
|> (t : Unit) -> Unit
Evaluates an expression and discards its result. This is useful when you want
to execute an expression for its side effects but don't care about its return
value, or when you want to explicitly indicate that a value is intentionally
unused.
Parameters:
value
: The value to be ignored. Can be of any type.
Example:
let x = 42
ignore(x) // Explicitly ignore the value
let mut sum = 0
ignore([1, 2, 3].iter().each((x) => { sum = sum + x })) // Ignore the Unit return value of each()
ignore
}
test "kvstore 2.0" {
let backend = @moonchor.make_local_backend([server, client, backup])
@toolkit.run_async(() => (Unit, async (?) -> Unit, Server) -> Unit
@moonchor.run_choreo(backend, kvstore_v2, server) )
@toolkit.run_async(() => (Unit, async (?) -> Unit, Client) -> Unit
@moonchor.run_choreo(backend, kvstore_v2, client) )
@toolkit.run_async(() => (Unit, async (?) -> Unit, Backup) -> Unit
@moonchor.run_choreo(backend, kvstore_v2, backup) )
}
During the double replication implementation, we encountered coupled code where server request processing, backup requests, and consistency checking were intertwined.
Using MoonBit's higher-order functions, we can abstract the replication strategy away from the concrete processing logic. Let's analyze what constitutes a replication strategy. It should encapsulate how the server processes requests using replicas after receiving them. The key insight is that the replication strategy itself is request-agnostic and should be decoupled from the actual request handling. This makes the strategy swappable, allowing easy switching between different strategies or implementing new ones in the future.
Of course, real-world replication strategies are far more complicated and often resist clean separation. For this example, we simplify the problem to focus on moonchor's programming capabilities, directly defining the replication strategy as a function determining how the server processes requests after receiving them. We can define it with a type alias:
typealias async (@moonchor.ChoreoContext, @moonchor.Located[enum Request {
Get(String)
Put(String, Int)
}
Request, Server]) -> @moonchor.Located[
enum Option[A] {
None
Some(A)
}
Response,
Server,
] as ReplicationStrategy
Now we can simplify the access_server
implementation by passing the strategy as a parameter:
async fn async (ctx : ?, request : ?, strategy : async (?, ?) -> ?) -> ?
access_server_v3(
ctx: @moonchor.ChoreoContext,
request: @moonchor.Located[enum Request {
Get(String)
Put(String, Int)
}
Request, Client],
strategy: ReplicationStrategy
) -> @moonchor.Located[enum Option[A] {
None
Some(A)
}
Response, Client] {
let request_at_server = ctx.comm(client, server, request)
let response = strategy(ctx, request_at_server)
ctx.comm(server, client, response)
}
async fn async (ctx : ?, strategy : async (?, ?) -> ?, key : String, value : Int) -> Unit
put_v3(
ctx: @moonchor.ChoreoContext,
strategy: ReplicationStrategy,
key: String,
value: Int
) -> Unit {
let request = ctx.(Client, (Unit) -> Request) -> ?
locally(client, _unwrapper => Request::Put(key, value))
async (ctx : ?, request : ?, strategy : async (?, ?) -> ?) -> ?
access_server_v3(ctx, request, strategy) |> (t : ?) -> Unit
Evaluates an expression and discards its result. This is useful when you want
to execute an expression for its side effects but don't care about its return
value, or when you want to explicitly indicate that a value is intentionally
unused.
Parameters:
value
: The value to be ignored. Can be of any type.
Example:
let x = 42
ignore(x) // Explicitly ignore the value
let mut sum = 0
ignore([1, 2, 3].iter().each((x) => { sum = sum + x })) // Ignore the Unit return value of each()
ignore
}
async fn async (ctx : ?, strategy : async (?, ?) -> ?, key : String) -> ?
get_v3(
ctx: @moonchor.ChoreoContext,
strategy: ReplicationStrategy,
key: String
) -> @moonchor.Located[enum Option[A] {
None
Some(A)
}
Response, Client] {
let request = ctx.(Client, (Unit) -> Request) -> ?
locally(client, _unwrapper => Request::Get(key))
async (ctx : ?, request : ?, strategy : async (?, ?) -> ?) -> ?
access_server_v3(ctx, request, strategy)
}
This successfully abstracts the replication strategy from the request handling logic. Below, we reimplement the double replication strategy:
async fn async (state_at_server : ?, state_at_backup : ?) -> async (?, ?) -> ?
double_replication_strategy(
state_at_server: @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Server],
state_at_backup: @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Backup],
) -> ReplicationStrategy {
fn(
ctx: @moonchor.ChoreoContext,
request_at_server: @moonchor.Located[enum Request {
Get(String)
Put(String, Int)
}
Request, Server]
) {
let request_at_backup = ctx.(Server, Backup, ?) -> Unit
comm(server, backup, request_at_server)
let response_at_backup = ctx.(Backup, (Unit) -> Int?) -> Unit
locally(backup, fn(unwrapper) {
let request = unwrapper.unwrap(request_at_backup)
let state = unwrapper.unwrap(state_at_backup)
(state : ServerState, request : Request) -> Int?
handle_request(state, request)
})
let backup_response = ctx.(Backup, Server, Unit) -> Unit
comm(backup, server, response_at_backup)
ctx.(Server, (Unit) -> Int?) -> ?
locally(server, fn(unwrapper) {
let request = unwrapper.unwrap(request_at_server)
let state = unwrapper.unwrap(state_at_server)
let res = (state : ServerState, request : Request) -> Int?
handle_request(state, request)
(responses : Array[Int?]) -> Unit
check_consistency([unwrapper.unwrap(backup_response), res])
res
})
}
}
Note the function signature of double_replication_strategy
- it returns a function of type ReplicationStrategy
. Given two parameters, it constructs a new replication strategy. This demonstrates using higher-order functions to abstract replication strategies, known as higher-order choreography in choreographic programming.
We can test it with a simple choreography:
async fn kvstore_v3(ctx: @moonchor.ChoreoContext) -> Unit {
let state_at_server = ctx.(Server, (Unit) -> ServerState) -> ?
locally(server, _unwrapper => struct ServerState {
db: Map[String, Int]
}
ServerState::new())
let state_at_backup = ctx.(Backup, (Unit) -> ServerState) -> ?
locally(backup, _unwrapper => struct ServerState {
db: Map[String, Int]
}
ServerState::new())
let strategy = async (state_at_server : ?, state_at_backup : ?) -> async (?, ?) -> ?
double_replication_strategy(state_at_server, state_at_backup)
async (ctx : ?, strategy : async (?, ?) -> ?, key : String, value : Int) -> Unit
put_v3(ctx, strategy, "key1", 42)
async (ctx : ?, strategy : async (?, ?) -> ?, key : String, value : Int) -> Unit
put_v3(ctx, strategy, "key2", 41)
let v1_at_client = async (ctx : ?, strategy : async (?, ?) -> ?, key : String) -> ?
get_v3(ctx, strategy, "key1")
let v2_at_client = async (ctx : ?, strategy : async (?, ?) -> ?, key : String) -> ?
get_v3(ctx, strategy, "key2")
ctx.(Client, (Unit) -> Unit) -> Unit
locally(client, fn(unwrapper) {
let v1 = unwrapper.unwrap(v1_at_client).unwrap()
let v2 = unwrapper.unwrap(v2_at_client).unwrap()
if v1 (self : Int, other : Int) -> Int
Adds two 32-bit signed integers. Performs two's complement arithmetic, which
means the operation will wrap around if the result exceeds the range of a
32-bit integer.
Parameters:
self
: The first integer operand.
other
: The second integer operand.
Returns a new integer that is the sum of the two operands. If the
mathematical sum exceeds the range of a 32-bit integer (-2,147,483,648 to
2,147,483,647), the result wraps around according to two's complement rules.
Example:
inspect(42 + 1, content="43")
inspect(2147483647 + 1, content="-2147483648") // Overflow wraps around to minimum value
+ v2 (self : Int, other : Int) -> Bool
Compares two integers for equality.
Parameters:
self
: The first integer to compare.
other
: The second integer to compare.
Returns true
if both integers have the same value, false
otherwise.
Example:
inspect(42 == 42, content="true")
inspect(42 == -42, content="false")
== 83 {
(input : String) -> Unit
Prints any value that implements the Show
trait to the standard output,
followed by a newline.
Parameters:
value
: The value to be printed. Must implement the Show
trait.
Example:
println(42)
println("Hello, World!")
println([1, 2, 3])
println("The server is working correctly")
} else {
panic()
}
})
|> (t : Unit) -> Unit
Evaluates an expression and discards its result. This is useful when you want
to execute an expression for its side effects but don't care about its return
value, or when you want to explicitly indicate that a value is intentionally
unused.
Parameters:
value
: The value to be ignored. Can be of any type.
Example:
let x = 42
ignore(x) // Explicitly ignore the value
let mut sum = 0
ignore([1, 2, 3].iter().each((x) => { sum = sum + x })) // Ignore the Unit return value of each()
ignore
}
test "kvstore 3.0" {
let backend = @moonchor.make_local_backend([server, client, backup])
@toolkit.run_async(() => (Unit, async (?) -> Unit, Server) -> Unit
@moonchor.run_choreo(backend, kvstore_v2, server))
@toolkit.run_async(() => (Unit, async (?) -> Unit, Client) -> Unit
@moonchor.run_choreo(backend, kvstore_v2, client))
@toolkit.run_async(() => (Unit, async (?) -> Unit, Backup) -> Unit
@moonchor.run_choreo(backend, kvstore_v2, backup))
}
To implement new replication strategies like triple replication, we need to define two new Backup
types for differentiation:
struct Backup1 {} derive(trait Hash {
hash_combine(Self, Hasher) -> Unit
hash(Self) -> Int
}
Trait for types that can be hashed
Hash, trait Show {
output(Self, &Logger) -> Unit
to_string(Self) -> String
}
Trait for types that can be converted to String
Show)
impl @moonchor.Location for Backup1 with name(_) {
"backup1"
}
let backup1: Backup1 = Backup1::{}
struct Backup2 {} derive(trait Hash {
hash_combine(Self, Hasher) -> Unit
hash(Self) -> Int
}
Trait for types that can be hashed
Hash, trait Show {
output(Self, &Logger) -> Unit
to_string(Self) -> String
}
Trait for types that can be converted to String
Show)
impl @moonchor.Location for Backup2 with name(_) {
"backup2"
}
let backup2: Backup2 = Backup2::{}
Next, we need to modify the core logic of access_server
. An immediate problem emerges: to have both Backup1
and Backup2
process the request and return responses, we'd need to repeat these statements: let request = unwrapper.unwrap(request_at_backup); let state = unwrapper.unwrap(state_at_backup); handle_request(state, request)
. Code duplication is a code smell that should be abstracted away. Here, moonchor's "roles as types" advantage becomes apparent - we can use MoonBit's parametric polymorphism to abstract the backup processing logic into a polymorphic function do_backup
, which takes a role type parameter B
representing the backup role:
async fn[B : @moonchor.Location] async (ctx : ?, request_at_server : ?, backup : B, state_at_backup : ?) -> ?
do_backup(
ctx : @moonchor.ChoreoContext,
request_at_server : @moonchor.Located[enum Request {
Get(String)
Put(String, Int)
}
Request, Server],
backup : B,
state_at_backup : @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, B]
) -> @moonchor.Located[enum Option[A] {
None
Some(A)
}
Response, Server] {
let request_at_backup = ctx.comm(server, backup, request_at_server)
let response_at_backup = ctx.(B, (Unit) -> Int?) -> Unit
locally(backup, fn(unwrapper) {
let request = unwrapper.unwrap(request_at_backup)
let state = unwrapper.unwrap(state_at_backup)
(state : ServerState, request : Request) -> Int?
handle_request(state, request)
})
ctx.comm(backup, server, response_at_backup)
}
This enables us to freely implement either double or triple replication strategies. For the triple replication strategy, we simply need to call do_backup
twice within the function returned by triple_replication_strategy
:
async fn async (state_at_server : ?, state_at_backup1 : ?, state_at_backup2 : ?) -> async (?, ?) -> ?
triple_replication_strategy(
state_at_server: @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Server],
state_at_backup1: @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Backup1],
state_at_backup2: @moonchor.Located[struct ServerState {
db: Map[String, Int]
}
ServerState, Backup2]
) -> ReplicationStrategy {
fn(
ctx: @moonchor.ChoreoContext,
request_at_server: @moonchor.Located[enum Request {
Get(String)
Put(String, Int)
}
Request, Server]
) {
let backup_response1 = async (ctx : ?, request_at_server : ?, backup : Backup1, state_at_backup : ?) -> ?
do_backup(
ctx, request_at_server, backup1, state_at_backup1,
)
let backup_response2 = async (ctx : ?, request_at_server : ?, backup : Backup2, state_at_backup : ?) -> ?
do_backup(
ctx, request_at_server, backup2, state_at_backup2,
)
ctx.(Server, (Unit) -> Int?) -> ?
locally(server, fn(unwrapper) {
let request = unwrapper.unwrap(request_at_server)
let state = unwrapper.unwrap(state_at_server)
let res = (state : ServerState, request : Request) -> Int?
handle_request(state, request)
(responses : Array[Int?]) -> Unit
check_consistency([
unwrapper.unwrap(backup_response1),
unwrapper.unwrap(backup_response2),
res,
])
res
})
}
}
Since we've successfully separated the replication strategy from the access process, the access_server
, put
, and get
functions require no modifications. Let's test the final KVStore implementation:
async fn kvstore_v4(ctx: @moonchor.ChoreoContext) -> Unit {
let state_at_server = ctx.(Server, (Unit) -> ServerState) -> ?
locally(server, _unwrapper => struct ServerState {
db: Map[String, Int]
}
ServerState::new())
let state_at_backup1 = ctx.(Backup1, (Unit) -> ServerState) -> ?
locally(backup1, _unwrapper => struct ServerState {
db: Map[String, Int]
}
ServerState::new())
let state_at_backup2 = ctx.(Backup2, (Unit) -> ServerState) -> ?
locally(backup2, _unwrapper => struct ServerState {
db: Map[String, Int]
}
ServerState::new())
let strategy = async (state_at_server : ?, state_at_backup1 : ?, state_at_backup2 : ?) -> async (?, ?) -> ?
triple_replication_strategy(
state_at_server, state_at_backup1, state_at_backup2,
)
async (ctx : ?, strategy : async (?, ?) -> ?, key : String, value : Int) -> Unit
put_v3(ctx, strategy, "key1", 42)
async (ctx : ?, strategy : async (?, ?) -> ?, key : String, value : Int) -> Unit
put_v3(ctx, strategy, "key2", 41)
let v1_at_client = async (ctx : ?, strategy : async (?, ?) -> ?, key : String) -> ?
get_v3(ctx, strategy, "key1")
let v2_at_client = async (ctx : ?, strategy : async (?, ?) -> ?, key : String) -> ?
get_v3(ctx, strategy, "key2")
ctx.(Client, (Unit) -> Unit) -> Unit
locally(client, fn(unwrapper) {
let v1 = unwrapper.unwrap(v1_at_client).unwrap()
let v2 = unwrapper.unwrap(v2_at_client).unwrap()
if v1 (self : Int, other : Int) -> Int
Adds two 32-bit signed integers. Performs two's complement arithmetic, which
means the operation will wrap around if the result exceeds the range of a
32-bit integer.
Parameters:
self
: The first integer operand.
other
: The second integer operand.
Returns a new integer that is the sum of the two operands. If the
mathematical sum exceeds the range of a 32-bit integer (-2,147,483,648 to
2,147,483,647), the result wraps around according to two's complement rules.
Example:
inspect(42 + 1, content="43")
inspect(2147483647 + 1, content="-2147483648") // Overflow wraps around to minimum value
+ v2 (self : Int, other : Int) -> Bool
Compares two integers for equality.
Parameters:
self
: The first integer to compare.
other
: The second integer to compare.
Returns true
if both integers have the same value, false
otherwise.
Example:
inspect(42 == 42, content="true")
inspect(42 == -42, content="false")
== 83 {
(input : String) -> Unit
Prints any value that implements the Show
trait to the standard output,
followed by a newline.
Parameters:
value
: The value to be printed. Must implement the Show
trait.
Example:
println(42)
println("Hello, World!")
println([1, 2, 3])
println("The server is working correctly")
} else {
panic()
}
})
|> (t : Unit) -> Unit
Evaluates an expression and discards its result. This is useful when you want
to execute an expression for its side effects but don't care about its return
value, or when you want to explicitly indicate that a value is intentionally
unused.
Parameters:
value
: The value to be ignored. Can be of any type.
Example:
let x = 42
ignore(x) // Explicitly ignore the value
let mut sum = 0
ignore([1, 2, 3].iter().each((x) => { sum = sum + x })) // Ignore the Unit return value of each()
ignore
}
test "kvstore 4.0" {
let backend = @moonchor.make_local_backend([server, client, backup1, backup2])
@toolkit.run_async(() => (Unit, async (?) -> Unit, Server) -> Unit
@moonchor.run_choreo(backend, kvstore_v4, server))
@toolkit.run_async(() => (Unit, async (?) -> Unit, Client) -> Unit
@moonchor.run_choreo(backend, kvstore_v4, client))
@toolkit.run_async(() => (Unit, async (?) -> Unit, Backup1) -> Unit
@moonchor.run_choreo(backend, kvstore_v4, backup1))
@toolkit.run_async(() => (Unit, async (?) -> Unit, Backup2) -> Unit
@moonchor.run_choreo(backend, kvstore_v4, backup2))
}
With this, we've completed the multi-replica KVStore implementation. Throughout this example, we never manually used any send
or recv
to express distributed node interactions. Instead, we leveraged moonchor's choreographic programming capabilities to handle all communication and synchronization processes, avoiding potential type errors, deadlocks, and explicit synchronization issues.
In this article, we've explored the elegance of choreographic programming through moonchor while witnessing MoonBit's powerful expressiveness. For deeper insights into choreographic programming, you may refer to Haskell's library HasChor, the Choral language, or moonchor source code. To try moonchor yourself, simply install it via the command moon add Milky2018/moonchor@0.15.0
.