Skip to main content

MoonBit Pearls Vol 4: Choreographic Programming with Moonchor

· 24 min read

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.

Guided Tour: Bookstore Application

Let's examine a bookstore application involving two roles: Buyer and Seller. The core logic is as follows:

  1. The buyer sends the desired book title to the seller.
  2. The seller queries the database and informs the buyer of the price.
  3. The buyer decides whether to purchase the book.
  4. If the buyer decides to purchase, the seller deducts the book from inventory and sends the estimated delivery date to the buyer.
  5. Otherwise, the interaction terminates.

Traditional Implementation

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 
() -> String
get_title
() ->
String
String
{
"Homotopy Type Theory" } fn
(title : String) -> Int
get_price
(
String
title
:
String
String
) ->
Int
Int
{
50 } fn
() -> Int
get_budget
() ->
Int
Int
{
100 } fn
(title : String) -> String
get_delivery_date
(
String
title
:
String
String
) ->
String
String
{
"2025-10-01" } enum Role {
Role
Buyer
Role
Seller
} async fn[T]
async (msg : T, target : Role) -> Unit
send
(
T
msg
:

type parameter T

T
,
Role
target
:
enum Role {
  Buyer
  Seller
}
Role
) ->
Unit
Unit
{
... } async fn[T]
async (source : Role) -> T
recv
(
Role
source
:
enum Role {
  Buyer
  Seller
}
Role
) ->

type parameter T

T
{
... }

The buyer's application:

async fn 
async () -> Unit
book_buyer
() ->
Unit
Unit
{
let
String
title
=
() -> String
get_title
()
async (msg : String, target : Role) -> Unit
send
(
String
title
,
Role
Seller
)
let
Int
price
=
async (source : Role) -> Int
recv
(
Role
Seller
)
if
Int
price
(self_ : Int, other : Int) -> Bool
<=
() -> Int
get_budget
() {
async (msg : Bool, target : Role) -> Unit
send
(true,
Role
Seller
)
let
Unit
delivery_date
=
async (source : Role) -> Unit
recv
(
Role
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: \{
Unit
delivery_date
}")
} else {
async (msg : Bool, target : Role) -> Unit
send
(false,
Role
Seller
)
} }

The seller's application:

async fn 
async () -> Unit
book_seller
() ->
Unit
Unit
{
let
String
title
=
async (source : Role) -> String
recv
(
Role
Buyer
)
let
Int
price
=
(title : String) -> Int
get_price
(
String
title
)
async (msg : Int, target : Role) -> Unit
send
(
Int
price
,
Role
Buyer
)
let
Bool
decision
=
async (source : Role) -> Bool
recv
(
Role
Buyer
)
if
Bool
decision
{
let
String
delivery_date
=
(title : String) -> String
get_delivery_date
(
String
title
)
async (msg : String, target : Role) -> Unit
send
(
String
delivery_date
,
Role
Buyer
)
} }

These two implementations suffer from at least the following issues:

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

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

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

moonchor Implementation

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
struct Buyer {
}
Buyer
with
(_/0) -> String
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
struct Seller {
}
Seller
with
(_/0) -> String
name
(_) {
"seller" } let
Buyer
buyer
:
struct Buyer {
}
Buyer
=
struct Buyer {
}
Buyer
::{ }
let
Seller
seller
:
struct Seller {
}
Seller
=
struct 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 
async (ctx : ?) -> Unit
bookshop
(
?
ctx
: @moonchor.ChoreoContext) ->
Unit
Unit
{
let
Unit
title_at_buyer
=
?
ctx
.
(Buyer, (Unit) -> String) -> Unit
locally
(
Buyer
buyer
,
Unit
_unwrapper
=>
() -> String
get_title
())
let
Unit
title_at_seller
=
?
ctx
.
(Buyer, Seller, Unit) -> Unit
comm
(
Buyer
buyer
,
Seller
seller
,
Unit
title_at_buyer
)
let
Unit
price_at_seller
=
?
ctx
.
(Seller, (Unit) -> Int) -> Unit
locally
(
Seller
seller
, fn(
Unit
unwrapper
) {
let
String
title
=
Unit
unwrapper
.
(Unit) -> String
unwrap
(
Unit
title_at_seller
)
(title : String) -> Int
get_price
(
String
title
)
}) let
Unit
price_at_buyer
=
?
ctx
.
(Seller, Buyer, Unit) -> Unit
comm
(
Seller
seller
,
Buyer
buyer
,
Unit
price_at_seller
)
let
Unit
decision_at_buyer
=
?
ctx
.
(Buyer, (Unit) -> Bool) -> Unit
locally
(
Buyer
buyer
, fn(
Unit
unwrapper
) {
let
Int
price
=
Unit
unwrapper
.
(Unit) -> Int
unwrap
(
Unit
price_at_buyer
)
Int
price
(self_ : Int, other : Int) -> Bool
<
() -> Int
get_budget
()
}) if
?
ctx
.
(Buyer, Unit) -> Bool
broadcast
(
Buyer
buyer
,
Unit
decision_at_buyer
) {
let
Unit
delivery_date_at_seller
=
?
ctx
.
(Seller, (Unit) -> String) -> Unit
locally
(
Seller
seller
,
Unit
unwrapper
=>
(title : String) -> String
get_delivery_date
(
Unit
unwrapper
.
(Unit) -> String
unwrap
(
Unit
title_at_seller
),
)) let
Unit
delivery_date_at_buyer
=
?
ctx
.
(Seller, Buyer, Unit) -> Unit
comm
(
Seller
seller
,
Buyer
buyer
,
Unit
delivery_date_at_seller
,
)
?
ctx
.
(Buyer, (Unit) -> Unit) -> Unit
locally
(
Buyer
buyer
, fn(
Unit
unwrapper
) {
let
Unit
delivery_date
=
Unit
unwrapper
.
(Unit) -> Unit
unwrap
(
Unit
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 \{
Unit
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.

Knowledge of Choice

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.

Launch Code

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 
Unit
backend
=
(Array[Buyer]) -> Unit
@moonchor.make_local_backend
([
Buyer
buyer
,
Seller
seller
])
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Buyer) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
bookshop
,
Buyer
buyer
) )
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Seller) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
bookshop
,
Seller
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.

API and Partial Principles

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.

Roles

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
{
(Self) -> String
name
(

type parameter Self

Self
) ->
String
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
(
&Location
self
,
&Location
other
) {
&Location
self
.
(&Location) -> String
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")
==
&Location
other
.
(&Location) -> String
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 {
  
String
id
:
String
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
(Unit) -> String
name
(
Unit
self
) {
"buyer-\{
Unit
self
.
String
id
}"
}

Located Values

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.

Local Computation

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
(
ChoreoContext
self
:
type ChoreoContext
ChoreoContext
,
L
location
:

type parameter L

L
,
(Unwrapper[L]) -> T
computation
: (
type Unwrapper[L]
Unwrapper
[

type parameter L

L
]) ->

type parameter T

T
) ->
type Located[T, L]
Located
[

type parameter T

T
,

type parameter L

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.

Communication

The ChoreoContext::comm API handles value transmission between roles. Its declaration is as follows:

trait 
trait Message {
}
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 :
trait Message {
}
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
(
ChoreoContext
self
:
type ChoreoContext
ChoreoContext
,
From
from
:

type parameter From

From
,
To
to
:

type parameter To

To
,
Located[T, From]
value
:
type Located[T, L]
Located
[

type parameter T

T
,

type parameter From

From
]
) ->
type Located[T, L]
Located
[

type parameter T

T
,

type parameter To

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.

Broadcast

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 : 
trait Message {
}
Message
, L :
trait Location {
  name(Self) -> String
}
Location
]
type ChoreoContext
ChoreoContext
::
async (self : ChoreoContext, loc : L, value : Located[T, L]) -> T
broadcast
(
ChoreoContext
self
:
type ChoreoContext
ChoreoContext
,
L
loc
:

type parameter L

L
,
Located[T, L]
value
:
type Located[T, L]
Located
[

type parameter T

T
,

type parameter L

L
]
) ->

type parameter T

T
{
... }

The broadcast API is similar to the communication API, with two key differences:

  1. Broadcast doesn't require specifying receiver roles - it defaults to all roles in the choreography;
  2. 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.

Backend and Execution

The API for running a choreography is as follows:

type Backend

typealias async (
type ChoreoContext
ChoreoContext
) ->

type parameter T

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
:
type Backend
Backend
,
async (ChoreoContext) -> T
choreography
: Choreo[

type parameter T

T
],
L
role
:

type parameter L

L
) ->

type parameter T

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
(
Array[&Location]
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
]) ->
type Backend
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.

(Optional Reading) Case Study: Multi-Replica KVStore

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.

Basic Implementation

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
struct Server {
}
Server
with
(_/0) -> String
name
(_) {
"server" } impl @moonchor.Location for
struct Client {
}
Client
with
(_/0) -> String
name
(_) {
"client" } let
Server
server
:
struct Server {
}
Server
=
struct Server {
}
Server
::{ }
let
Client
client
:
struct Client {
}
Client
=
struct 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 {
  
Map[String, Int]
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
String
,
Int
Int
]
} fn
struct ServerState {
  db: Map[String, Int]
}
ServerState
::
() -> ServerState
new
() ->
struct ServerState {
  db: Map[String, Int]
}
ServerState
{
{
Map[String, Int]
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 {
  
(String) -> Request
Get
(
String
String
)
(String, Int) -> Request
Put
(
String
String
,
Int
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
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
(
ServerState
state
:
struct ServerState {
  db: Map[String, Int]
}
ServerState
,
Request
request
:
enum Request {
  Get(String)
  Put(String, Int)
}
Request
) ->
enum Option[A] {
  None
  Some(A)
}
Response
{
match
Request
request
{
Request::
(String) -> Request
Get
(
String
key
) =>
ServerState
state
.
Map[String, Int]
db
.
(self : Map[String, Int], key : String) -> Int?

Get the value associated with a key.

get
(
String
key
)
Request::
(String, Int) -> Request
Put
(
String
key
,
Int
value
) => {
ServerState
state
.
Map[String, Int]
db
(Map[String, Int], String, Int) -> Unit
[
key] =
Int
value
Int?
None
} } }

Our goal is to define two functions, put and get, to simulate the client's request initiation process. Their respective tasks are:

  1. Generate the request at the Client, wrapping the key-value pair;
  2. Send the request to the Server;
  3. The Server processes the request using the handle_request function;
  4. 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
,
struct Server {
}
Server
],
String
key
:
String
String
,
Int
value
:
Int
Int
) ->
Unit
Unit
{
let
?
request
=
?
ctx
.
(Client, (Unit) -> Request) -> ?
locally
(
Client
client
,
Unit
_unwrapper
=> Request::
(String, Int) -> Request
Put
(
String
key
,
Int
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
,
struct Server {
}
Server
],
String
key
:
String
String
) -> @moonchor.Located[
enum Option[A] {
  None
  Some(A)
}
Response
,
struct Client {
}
Client
] {
let
?
request
=
?
ctx
.
(Client, (Unit) -> Request) -> ?
locally
(
Client
client
,
Unit
_unwrapper
=> Request::
(String) -> Request
Get
(
String
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
,
struct Client {
}
Client
],
?
state_at_server
: @moonchor.Located[
struct ServerState {
  db: Map[String, Int]
}
ServerState
,
struct Server {
}
Server
]
) -> @moonchor.Located[
enum Option[A] {
  None
  Some(A)
}
Response
,
struct Client {
}
Client
] {
let
Unit
request_at_server
=
?
ctx
.
(Client, Server, ?) -> Unit
comm
(
Client
client
,
Server
server
,
?
request
)
let
Unit
response
=
?
ctx
.
(Server, (Unit) -> Int?) -> Unit
locally
(
Server
server
, fn(
Unit
unwrapper
) {
let
Request
request
=
Unit
unwrapper
.
(Unit) -> Request
unwrap
(
Unit
request_at_server
)
let
ServerState
state
=
Unit
unwrapper
.
(?) -> ServerState
unwrap
(
?
state_at_server
)
(state : ServerState, request : Request) -> Int?
handle_request
(
ServerState
state
,
Request
request
)
})
?
ctx
.
(Server, Client, Unit) -> ?
comm
(
Server
server
,
Client
client
,
Unit
response
)
}

With this, our KVStore implementation is complete. We can write a simple choreography to test it:

async fn 
async (ctx : ?) -> Unit
kvstore_v1
(
?
ctx
: @moonchor.ChoreoContext) ->
Unit
Unit
{
let
?
state_at_server
=
?
ctx
.
(Server, (Unit) -> ServerState) -> ?
locally
(
Server
server
,
Unit
_unwrapper
=>
struct ServerState {
  db: Map[String, Int]
}
ServerState
::
() -> 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
client
, fn(
Unit
unwrapper
) {
let
Int
v1
=
Unit
unwrapper
.
(?) -> Unit
unwrap
(
?
v1_at_client
).
() -> Int
unwrap
()
let
Int
v2
=
Unit
unwrapper
.
(?) -> Unit
unwrap
(
?
v2_at_client
).
() -> Int
unwrap
()
if
Int
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
+
Int
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 {
() -> Unit
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
Unit
backend
=
(Array[Server]) -> Unit
@moonchor.make_local_backend
([
Server
server
,
Client
client
])
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Server) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
kvstore_v1
,
Server
server
))
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Client) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
kvstore_v1
,
Client
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.

Double Replication

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
struct Backup {
}
Backup
with
(_/0) -> String
name
(_) {
"backup" } let
Backup
backup
:
struct Backup {
}
Backup
=
struct 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
(
Array[Int?]
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
Unit
{
match
Array[Int?]
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
() {
Int??
None
=> return
(Int?) -> Int??
Some
(
Int?
f
) =>
for
Int?
res
in
Array[Int?]
responses
{
if
Int?
res
(x : Int?, y : Int?) -> Bool
!=
Int?
f
{
() -> Unit
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
,
struct Server {
}
Server
],
?
state_at_backup
: @moonchor.Located[
struct ServerState {
  db: Map[String, Int]
}
ServerState
,
struct Backup {
}
Backup
],
String
key
:
String
String
,
Int
value
:
Int
Int
) ->
Unit
Unit
{
let
?
request
=
?
ctx
.
(Client, (Unit) -> Request) -> ?
locally
(
Client
client
,
Unit
_unwrapper
=> Request::
(String, Int) -> Request
Put
(
String
key
,
Int
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
,
struct Server {
}
Server
],
?
state_at_backup
: @moonchor.Located[
struct ServerState {
  db: Map[String, Int]
}
ServerState
,
struct Backup {
}
Backup
],
String
key
:
String
String
) -> @moonchor.Located[
enum Option[A] {
  None
  Some(A)
}
Response
,
struct Client {
}
Client
] {
let
?
request
=
?
ctx
.
(Client, (Unit) -> Request) -> ?
locally
(
Client
client
,
Unit
_unwrapper
=> Request::
(String) -> Request
Get
(
String
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
,
struct Client {
}
Client
],
?
state_at_server
: @moonchor.Located[
struct ServerState {
  db: Map[String, Int]
}
ServerState
,
struct Server {
}
Server
],
?
state_at_backup
: @moonchor.Located[
struct ServerState {
  db: Map[String, Int]
}
ServerState
,
struct Backup {
}
Backup
]
) -> @moonchor.Located[
enum Option[A] {
  None
  Some(A)
}
Response
,
struct Client {
}
Client
] {
let
Unit
request_at_server
=
?
ctx
.
(Client, Server, ?) -> Unit
comm
(
Client
client
,
Server
server
,
?
request
)
let
Unit
request_at_backup
=
?
ctx
.
(Server, Backup, Unit) -> Unit
comm
(
Server
server
,
Backup
backup
,
Unit
request_at_server
)
let
Unit
response_at_backup
=
?
ctx
.
(Backup, (Unit) -> Int?) -> Unit
locally
(
Backup
backup
, fn(
Unit
unwrapper
) {
let
Request
request
=
Unit
unwrapper
.
(Unit) -> Request
unwrap
(
Unit
request_at_backup
)
let
ServerState
state
=
Unit
unwrapper
.
(?) -> ServerState
unwrap
(
?
state_at_backup
)
(state : ServerState, request : Request) -> Int?
handle_request
(
ServerState
state
,
Request
request
)
}) let
Unit
backup_response_at_server
=
?
ctx
.
(Backup, Server, Unit) -> Unit
comm
(
Backup
backup
,
Server
server
,
Unit
response_at_backup
)
let
Unit
response_at_server
=
?
ctx
.
(Server, (Unit) -> Int?) -> Unit
locally
(
Server
server
, fn(
Unit
unwrapper
) {
let
Request
request
=
Unit
unwrapper
.
(Unit) -> Request
unwrap
(
Unit
request_at_server
)
let
ServerState
state
=
Unit
unwrapper
.
(?) -> ServerState
unwrap
(
?
state_at_server
)
let
Int?
response
=
(state : ServerState, request : Request) -> Int?
handle_request
(
ServerState
state
,
Request
request
)
let
Int?
backup_response
=
Unit
unwrapper
.
(Unit) -> Int?
unwrap
(
Unit
backup_response_at_server
)
(responses : Array[Int?]) -> Unit
check_consistency
([
Int?
response
,
Int?
backup_response
])
Int?
response
})
?
ctx
.
(Server, Client, Unit) -> ?
comm
(
Server
server
,
Client
client
,
Unit
response_at_server
)
}

As before, we can write a simple choreography to test it:

async fn 
async (ctx : ?) -> Unit
kvstore_v2
(
?
ctx
: @moonchor.ChoreoContext) ->
Unit
Unit
{
let
?
state_at_server
=
?
ctx
.
(Server, (Unit) -> ServerState) -> ?
locally
(
Server
server
,
Unit
_unwrapper
=>
struct ServerState {
  db: Map[String, Int]
}
ServerState
::
() -> ServerState
new
())
let
?
state_at_backup
=
?
ctx
.
(Backup, (Unit) -> ServerState) -> ?
locally
(
Backup
backup
,
Unit
_unwrapper
=>
struct ServerState {
  db: Map[String, Int]
}
ServerState
::
() -> 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
client
, fn(
Unit
unwrapper
) {
let
Int
v1
=
Unit
unwrapper
.
(?) -> Unit
unwrap
(
?
v1_at_client
).
() -> Int
unwrap
()
let
Int
v2
=
Unit
unwrapper
.
(?) -> Unit
unwrap
(
?
v2_at_client
).
() -> Int
unwrap
()
if
Int
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
+
Int
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 {
() -> Unit
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
Unit
backend
=
(Array[Server]) -> Unit
@moonchor.make_local_backend
([
Server
server
,
Client
client
,
Backup
backup
])
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Server) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
kvstore_v2
,
Server
server
) )
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Client) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
kvstore_v2
,
Client
client
) )
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Backup) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
kvstore_v2
,
Backup
backup
) )
}

Abstracting Replication Strategy with Higher-Order Functions

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
,
struct Server {
}
Server
]) -> @moonchor.Located[
enum Option[A] {
  None
  Some(A)
}
Response
,
struct Server {
}
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
,
struct Client {
}
Client
],
async (?, ?) -> ?
strategy
: ReplicationStrategy
) -> @moonchor.Located[
enum Option[A] {
  None
  Some(A)
}
Response
,
struct Client {
}
Client
] {
let
?
request_at_server
=
?
ctx
.
(Client, Server, ?) -> ?
comm
(
Client
client
,
Server
server
,
?
request
)
let
?
response
=
async (?, ?) -> ?
strategy
(
?
ctx
,
?
request_at_server
)
?
ctx
.
(Server, Client, ?) -> ?
comm
(
Server
server
,
Client
client
,
?
response
)
} async fn
async (ctx : ?, strategy : async (?, ?) -> ?, key : String, value : Int) -> Unit
put_v3
(
?
ctx
: @moonchor.ChoreoContext,
async (?, ?) -> ?
strategy
: ReplicationStrategy,
String
key
:
String
String
,
Int
value
:
Int
Int
) ->
Unit
Unit
{
let
?
request
=
?
ctx
.
(Client, (Unit) -> Request) -> ?
locally
(
Client
client
,
Unit
_unwrapper
=> Request::
(String, Int) -> Request
Put
(
String
key
,
Int
value
))
async (ctx : ?, request : ?, strategy : async (?, ?) -> ?) -> ?
access_server_v3
(
?
ctx
,
?
request
,
async (?, ?) -> ?
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,
async (?, ?) -> ?
strategy
: ReplicationStrategy,
String
key
:
String
String
) -> @moonchor.Located[
enum Option[A] {
  None
  Some(A)
}
Response
,
struct Client {
}
Client
] {
let
?
request
=
?
ctx
.
(Client, (Unit) -> Request) -> ?
locally
(
Client
client
,
Unit
_unwrapper
=> Request::
(String) -> Request
Get
(
String
key
))
async (ctx : ?, request : ?, strategy : async (?, ?) -> ?) -> ?
access_server_v3
(
?
ctx
,
?
request
,
async (?, ?) -> ?
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
,
struct Server {
}
Server
],
?
state_at_backup
: @moonchor.Located[
struct ServerState {
  db: Map[String, Int]
}
ServerState
,
struct Backup {
}
Backup
],
) -> ReplicationStrategy { fn(
?
ctx
: @moonchor.ChoreoContext,
?
request_at_server
: @moonchor.Located[
enum Request {
  Get(String)
  Put(String, Int)
}
Request
,
struct Server {
}
Server
]
) { let
Unit
request_at_backup
=
?
ctx
.
(Server, Backup, ?) -> Unit
comm
(
Server
server
,
Backup
backup
,
?
request_at_server
)
let
Unit
response_at_backup
=
?
ctx
.
(Backup, (Unit) -> Int?) -> Unit
locally
(
Backup
backup
, fn(
Unit
unwrapper
) {
let
Request
request
=
Unit
unwrapper
.
(Unit) -> Request
unwrap
(
Unit
request_at_backup
)
let
ServerState
state
=
Unit
unwrapper
.
(?) -> ServerState
unwrap
(
?
state_at_backup
)
(state : ServerState, request : Request) -> Int?
handle_request
(
ServerState
state
,
Request
request
)
}) let
Unit
backup_response
=
?
ctx
.
(Backup, Server, Unit) -> Unit
comm
(
Backup
backup
,
Server
server
,
Unit
response_at_backup
)
?
ctx
.
(Server, (Unit) -> Int?) -> ?
locally
(
Server
server
, fn(
Unit
unwrapper
) {
let
Request
request
=
Unit
unwrapper
.
(?) -> Request
unwrap
(
?
request_at_server
)
let
ServerState
state
=
Unit
unwrapper
.
(?) -> ServerState
unwrap
(
?
state_at_server
)
let
Int?
res
=
(state : ServerState, request : Request) -> Int?
handle_request
(
ServerState
state
,
Request
request
)
(responses : Array[Int?]) -> Unit
check_consistency
([
Unit
unwrapper
.
(Unit) -> Int?
unwrap
(
Unit
backup_response
),
Int?
res
])
Int?
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 
async (ctx : ?) -> Unit
kvstore_v3
(
?
ctx
: @moonchor.ChoreoContext) ->
Unit
Unit
{
let
?
state_at_server
=
?
ctx
.
(Server, (Unit) -> ServerState) -> ?
locally
(
Server
server
,
Unit
_unwrapper
=>
struct ServerState {
  db: Map[String, Int]
}
ServerState
::
() -> ServerState
new
())
let
?
state_at_backup
=
?
ctx
.
(Backup, (Unit) -> ServerState) -> ?
locally
(
Backup
backup
,
Unit
_unwrapper
=>
struct ServerState {
  db: Map[String, Int]
}
ServerState
::
() -> ServerState
new
())
let
async (?, ?) -> ?
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
,
async (?, ?) -> ?
strategy
, "key1", 42)
async (ctx : ?, strategy : async (?, ?) -> ?, key : String, value : Int) -> Unit
put_v3
(
?
ctx
,
async (?, ?) -> ?
strategy
, "key2", 41)
let
?
v1_at_client
=
async (ctx : ?, strategy : async (?, ?) -> ?, key : String) -> ?
get_v3
(
?
ctx
,
async (?, ?) -> ?
strategy
, "key1")
let
?
v2_at_client
=
async (ctx : ?, strategy : async (?, ?) -> ?, key : String) -> ?
get_v3
(
?
ctx
,
async (?, ?) -> ?
strategy
, "key2")
?
ctx
.
(Client, (Unit) -> Unit) -> Unit
locally
(
Client
client
, fn(
Unit
unwrapper
) {
let
Int
v1
=
Unit
unwrapper
.
(?) -> Unit
unwrap
(
?
v1_at_client
).
() -> Int
unwrap
()
let
Int
v2
=
Unit
unwrapper
.
(?) -> Unit
unwrap
(
?
v2_at_client
).
() -> Int
unwrap
()
if
Int
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
+
Int
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 {
() -> Unit
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
Unit
backend
=
(Array[Server]) -> Unit
@moonchor.make_local_backend
([
Server
server
,
Client
client
,
Backup
backup
])
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Server) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
kvstore_v2
,
Server
server
))
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Client) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
kvstore_v2
,
Client
client
))
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Backup) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
kvstore_v2
,
Backup
backup
))
}

Implementing Role-Polymorphism Through Parametric Polymorphism

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
struct Backup1 {
}
Backup1
with
(_/0) -> String
name
(_) {
"backup1" } let
Backup1
backup1
:
struct Backup1 {
}
Backup1
=
struct 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
struct Backup2 {
}
Backup2
with
(_/0) -> String
name
(_) {
"backup2" } let
Backup2
backup2
:
struct Backup2 {
}
Backup2
=
struct 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
,
struct Server {
}
Server
],
B
backup
:

type parameter B

B
,
?
state_at_backup
: @moonchor.Located[
struct ServerState {
  db: Map[String, Int]
}
ServerState
,

type parameter B

B
]
) -> @moonchor.Located[
enum Option[A] {
  None
  Some(A)
}
Response
,
struct Server {
}
Server
] {
let
Unit
request_at_backup
=
?
ctx
.
(Server, B, ?) -> Unit
comm
(
Server
server
,
B
backup
,
?
request_at_server
)
let
Unit
response_at_backup
=
?
ctx
.
(B, (Unit) -> Int?) -> Unit
locally
(
B
backup
, fn(
Unit
unwrapper
) {
let
Request
request
=
Unit
unwrapper
.
(Unit) -> Request
unwrap
(
Unit
request_at_backup
)
let
ServerState
state
=
Unit
unwrapper
.
(?) -> ServerState
unwrap
(
?
state_at_backup
)
(state : ServerState, request : Request) -> Int?
handle_request
(
ServerState
state
,
Request
request
)
})
?
ctx
.
(B, Server, Unit) -> ?
comm
(
B
backup
,
Server
server
,
Unit
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
,
struct Server {
}
Server
],
?
state_at_backup1
: @moonchor.Located[
struct ServerState {
  db: Map[String, Int]
}
ServerState
,
struct Backup1 {
}
Backup1
],
?
state_at_backup2
: @moonchor.Located[
struct ServerState {
  db: Map[String, Int]
}
ServerState
,
struct Backup2 {
}
Backup2
]
) -> ReplicationStrategy { fn(
?
ctx
: @moonchor.ChoreoContext,
?
request_at_server
: @moonchor.Located[
enum Request {
  Get(String)
  Put(String, Int)
}
Request
,
struct Server {
}
Server
]
) { let
?
backup_response1
=
async (ctx : ?, request_at_server : ?, backup : Backup1, state_at_backup : ?) -> ?
do_backup
(
?
ctx
,
?
request_at_server
,
Backup1
backup1
,
?
state_at_backup1
,
) let
?
backup_response2
=
async (ctx : ?, request_at_server : ?, backup : Backup2, state_at_backup : ?) -> ?
do_backup
(
?
ctx
,
?
request_at_server
,
Backup2
backup2
,
?
state_at_backup2
,
)
?
ctx
.
(Server, (Unit) -> Int?) -> ?
locally
(
Server
server
, fn(
Unit
unwrapper
) {
let
Request
request
=
Unit
unwrapper
.
(?) -> Request
unwrap
(
?
request_at_server
)
let
ServerState
state
=
Unit
unwrapper
.
(?) -> ServerState
unwrap
(
?
state_at_server
)
let
Int?
res
=
(state : ServerState, request : Request) -> Int?
handle_request
(
ServerState
state
,
Request
request
)
(responses : Array[Int?]) -> Unit
check_consistency
([
Unit
unwrapper
.
(?) -> Int?
unwrap
(
?
backup_response1
),
Unit
unwrapper
.
(?) -> Int?
unwrap
(
?
backup_response2
),
Int?
res
,
])
Int?
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 
async (ctx : ?) -> Unit
kvstore_v4
(
?
ctx
: @moonchor.ChoreoContext) ->
Unit
Unit
{
let
?
state_at_server
=
?
ctx
.
(Server, (Unit) -> ServerState) -> ?
locally
(
Server
server
,
Unit
_unwrapper
=>
struct ServerState {
  db: Map[String, Int]
}
ServerState
::
() -> ServerState
new
())
let
?
state_at_backup1
=
?
ctx
.
(Backup1, (Unit) -> ServerState) -> ?
locally
(
Backup1
backup1
,
Unit
_unwrapper
=>
struct ServerState {
  db: Map[String, Int]
}
ServerState
::
() -> ServerState
new
())
let
?
state_at_backup2
=
?
ctx
.
(Backup2, (Unit) -> ServerState) -> ?
locally
(
Backup2
backup2
,
Unit
_unwrapper
=>
struct ServerState {
  db: Map[String, Int]
}
ServerState
::
() -> ServerState
new
())
let
async (?, ?) -> ?
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
,
async (?, ?) -> ?
strategy
, "key1", 42)
async (ctx : ?, strategy : async (?, ?) -> ?, key : String, value : Int) -> Unit
put_v3
(
?
ctx
,
async (?, ?) -> ?
strategy
, "key2", 41)
let
?
v1_at_client
=
async (ctx : ?, strategy : async (?, ?) -> ?, key : String) -> ?
get_v3
(
?
ctx
,
async (?, ?) -> ?
strategy
, "key1")
let
?
v2_at_client
=
async (ctx : ?, strategy : async (?, ?) -> ?, key : String) -> ?
get_v3
(
?
ctx
,
async (?, ?) -> ?
strategy
, "key2")
?
ctx
.
(Client, (Unit) -> Unit) -> Unit
locally
(
Client
client
, fn(
Unit
unwrapper
) {
let
Int
v1
=
Unit
unwrapper
.
(?) -> Unit
unwrap
(
?
v1_at_client
).
() -> Int
unwrap
()
let
Int
v2
=
Unit
unwrapper
.
(?) -> Unit
unwrap
(
?
v2_at_client
).
() -> Int
unwrap
()
if
Int
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
+
Int
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 {
() -> Unit
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
Unit
backend
=
(Array[Server]) -> Unit
@moonchor.make_local_backend
([
Server
server
,
Client
client
,
Backup1
backup1
,
Backup2
backup2
])
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Server) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
kvstore_v4
,
Server
server
))
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Client) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
kvstore_v4
,
Client
client
))
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Backup1) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
kvstore_v4
,
Backup1
backup1
))
(() -> Unit) -> Unit
@toolkit.run_async
(() =>
(Unit, async (?) -> Unit, Backup2) -> Unit
@moonchor.run_choreo
(
Unit
backend
,
async (ctx : ?) -> Unit
kvstore_v4
,
Backup2
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.

Conclusion

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.