Quickstart
In this tutorial, you will learn the basics of Hydro by building a keyed counter. You will start by generating a Hydro project from a template. Then, you will create a single counter, and build up to a full key-counter store that partitions its keys across a cluster. Along the way, you will learn the core elements of Hydro, such as live collections, slices, and simulation testing.
On this page, you will explore the Hydro template, which comes with a simple distributed program.
You will learn
- How to use live collections to handle RPC requests and responses
- How to write logic spanning several distributed locations
- How to quote Rust code with
q!to run it on a distributed node - How to test distributed systems with Hydro's deterministic simulator
- How to deploy a Hydro application to the cloud using Hydro Deploy
Creating a Hydro Project
Hydro is a Rust framework, so you will need to install Rust. We recommend using rustup, which allows you to easily manage and update Rust versions. To generate the template, you should install the cargo-generate utility and then expand the template into a folder of your choice.
cargo install cargo-generate
cargo generate gh:hydro-project/hydro-template
# Project Name: my-example-project
cd my-example-project
Hydro code is written with standard Rust tools. You'll manage dependencies, run tests, and launch Hydro programs using Cargo, the standard Rust package manager. We recommend using VS Code with the Rust Analyzer extension, which provides inline errors and code completion
After cd-ing into the generated folder, you can run tests for the included sample:
cargo test
Hello World in Hydro
Hydro projects are typically organized into two folders:
src/contains the main distributed logic and tests (like regular Rust)examples/contains deployment scripts for launching to the cloud (due to Cargo limitations)
Let's take a look at the initial Hydro code in src/lib.rs, which implements an echo server. The top of the file initializes the project (this only needs to be done once, in lib.rs) and imports the Hydro prelude, which includes common Hydro APIs (you will want to do this in each file):
#[cfg(stageleft_runtime)]
hydro_lang::setup!();
use hydro_lang::prelude::*;
Next, the template defines the echo server function. The server takes in a stream of requests and responds with the text of each request capitalized. Hydro uses a stream programming model, which means that instead of the server taking a single request as a parameter, it takes in a stream of requests that arrive over time. Similarly, the responses are sent back as a stream of values.
pub struct EchoServer;
pub fn echo_capitalize<'a>(
input: Stream<String, Process<'a, EchoServer>>,
) -> Stream<String, Process<'a, EchoServer>> {
Let's dive into the parameters of this function. The input is a Stream, a Live Collection where new incoming requests arrive over time. The first type parameter, String, tells us that each incoming request will appear as a String element.
The second type parameter identifies the Location for the live collection. Hydro provides two types of locations: Process (a single machine) or Cluster (a set of machines). All locations in a Hydro program have the same lifetime 'a, which is used to enforce borrowing constraints. By writing code that involves several locations, you can create a distributed system!
Locations are tagged with a type to distinguish them at compile-time. In the template, the input and output streams are both located on Process<'a, EchoServer>, where EchoServer is the tag. Returning a stream from a different location would result in a type error.
Finally, let us take a look at the function body:
pub fn echo_capitalize<'a>(
input: Stream<String, Process<'a, EchoServer>>,
) -> Stream<String, Process<'a, EchoServer>> {
input.map(q!(|s| s.to_uppercase()))
}
To process incoming requests, you use the APIs available on live collections. This example uses Stream::map, which transforms each element of the incoming requests into a element of the outgoing responses. In Hydro, all values and closures passed to such APIs must be quoted. Quoting captures the Rust code so that it can be run on a distributed node. To quote an expression, you wrap it with the q! macro.
Quoting relies on advanced features of Rust, and can sometimes emit strange type errors. The Quoting Errors page covers the limitations of q!. For example, to invoke a local function foo you must use self::foo.
If you forget to wrap an expression in q! when that is required, you'll see an error like:
closure is expected to take 5 arguments, but it takes X arguments
Testing with the Simulator
Once you've written a piece of Hydro code, you'll want to write some unit tests to check its functionality. The Hydro Simulator makes it possible to test distributed executions with standard Rust assertions.
The template includes a unit test for the echo server in the tests module. When writing a simulator test, you should define it as a standard Rust test (#[test] fn) rather than as an asynchronous Tokio test:
#[cfg(test)]
mod tests {
use hydro_lang::prelude::*;
#[test]
fn test_echo_capitalize() {
Next, let's take a look at the setup logic for the test, which:
- Creates the set of distributed locations involved in the test
- Declares an external input to use in the test
- Sets up the echo server using the function you defined before
- Declares an external output for assertions in the test
To create locations, you must first create a FlowBuilder, which will track all the locations used across your distributed system. Then, you can create a process using flow.process(), which returns a Process value.
let flow = FlowBuilder::new();
let process = flow.process();
Now, you can create the echo server! You start by receiving inputs from the test client using sim_input. This returns a tuple with two values: a port handle that the test can use to send requests, and a Stream of the received requests. You can then pass this stream into echo_capitalize, which returns a stream of responses. To read the outputs from the test, you use sim_output. This function returns a port handle that the test can use to receive responses.
let (in_port, requests) = process.sim_input();
let responses = super::echo_capitalize(requests);
let out_port = responses.sim_output();
Finally, you can write the functionality test. To create an exhaustive simulation test, which explores all possible distributed executions, you invoke flow.sim().exhaustive(). This function takes in an async closure, which will be repeatedly invoked with different simulation instances, each testing a different distributed execution.
flow.sim().exhaustive(async || {
in_port.send("hello".to_string());
in_port.send("world".to_string());
out_port.assert_yields_only(["HELLO", "WORLD"]).await;
});
The included test sends the messages "hello" and "world" to the echo server using the send() method. Then, it uses assert_yields_only to check that the responses "HELLO" and "WORLD" are returned and that there are no additional responses. The APIs for receiving responses are async so you must use .await to wait for the assertion to complete.
To run the test, you use the standard cargo test command:
cargo test
# running 1 test
# test tests::test_echo_capitalize ... ok
Deploying the Application
Now that you've tested the echo server, you'll deploy it to run as a real program. The template includes two deployment scripts in the examples/ folder: echo_local.rs for local testing and echo_gcp.rs for deploying to Google Cloud Platform. Let's start with the local deployment.
Local Deployment
Open examples/echo_local.rs. The script starts by setting up a deployment and creating the flow locations:
let mut deployment = Deployment::new();
let flow = hydro_lang::compile::builder::FlowBuilder::new();
let process = flow.process();
let external = flow.external::<()>();
In a deployment script, you create a Deployment object which tracks the physical machines where your program will run. Then, you create a FlowBuilder just like in the simulator tests. However, instead of connecting to ports manually, you'll use bind_single_client to set up networking:
let (_port, input, output) =
process.bind_single_client::<_, _, LinesCodec>(&external, NetworkHint::TcpPort(Some(4000)));
output.complete(hydro_template::echo_capitalize(input));
The bind_single_client method creates a server that receives messages from a single external client. It returns a port handle, a stream of incoming messages, and a sink for sending responses. The method takes a codec (LinesCodec) that encodes and decodes the raw bytes (in this case, treating each line of text as a message). The NetworkHint specifies that the server should listen on port 4000.
Next, you map the logical locations to physical machines using with_process and with_external:
let _nodes = flow
.with_process(&process, deployment.Localhost())
.with_external(&external, deployment.Localhost())
.deploy(&mut deployment);
deployment.deploy().await.unwrap();
println!("Launched Echo Server! Run `nc localhost 4000` to connect.");
deployment.start_ctrl_c().await.unwrap();
The with_process method assigns the process location to run on deployment.Localhost(), which represents your local machine. Similarly, with_external specifies where the external client will connect from (also localhost in this case). Finally, deploy() compiles the program, and start_ctrl_c() runs it.
To run the local deployment:
cargo run --example echo_local
# Launched Echo Server! Run `nc localhost 4000` to connect.
In another terminal, connect using nc:
nc localhost 4000
hello
# HELLO
world
# WORLD
Cloud Deployment
The template also includes deployment scripts that deploy the echo server to the cloud.
Hydro Deploy includes APIs for configuring resources on AWS, Azure, and GCP. Instead of invoking with_process with deployment.Localhost(), you can deploy to the cloud by passing a cloud host. Note that the external client is still mapped to Localhost—you're deploying the server to the cloud while connecting from your local machine.
- GCP
.with_process(
&process,
deployment
.GcpComputeEngineHost()
.project(gcp_project.clone())
.machine_type("e2-micro")
.image("debian-cloud/debian-11")
.region("us-west1-a")
.network(vpc.clone())
.add(),
)
When deploying to the cloud, the port is assigned automatically. You can retrieve it after deployment:
let raw_port = nodes.raw_port(port);
let server_port = raw_port.server_port().await;
println!("Please connect a client to port {:?}", server_port);
Finally, to launch the echo server in the cloud, we run the deploy script. Hydro Deploy will automatically provision the necessary cloud resources, launch your application, and forward the server port to your local machine:
- GCP
cargo run --example echo_gcp -- YOUR_GCP_PROJECT
For GCP deployment, you'll need to set up Google Cloud credentials and have the gcloud and terraform CLIs installed. See the Hydro Deploy documentation for more details.
Next Steps
Congratulations! You've now written, tested, and deployed a simple Hydro program. In the next sections, you will expand your code into a stateful counter service with support for multiple clients. Along the way, you will learn how Hydro helps you avoid distributed bugs at compile-time and how to scale your application across clusters of machines.