Finicky Whiskers (pt. 3): The Microservices
Matt Butcher
finicky whiskers
webassembly
microservices
rust
ruby
In the first part of this series, we introduced Finicky Whiskers, the world’s most adorable manual load generator. In this game, you must click as fast as you can to feed Slats the Cat whatever food that she currently desires. The problem is, she keeps changing her mind. Can you feed this fickle feline before the timer runs out?
In the second part, we looked at the Spin Fileserver microservice and got a better understanding for how the Spin Framework executes microservices. We also looked at how generic microservices like Spin Fileserver can be re-used within other applications.
For the third part, we now focus on a few of the microservices that manage the gameplay of Finicky Whiskers.
An Architectural Overview
When you first point your browser to finickywhiskers.com, the Spin Fileserver sends your browser around thirty files. HTML, CSS, JavaScript, and images are all delivered to your browser, which dutifully displays them. Once you click the Start
button, the game begins.
- When the game starts, the
session
microservice delivers a session token together with a time table for which foods Slats wants and when.
- Each time you click on a food button, whether it’s the right one or the wrong one, it sends a request to the
tally
microservice, which tallies a single event by validating the session and then sending an event to Redis
- The
morsel
microservice listens on Redis for new feeding events. When it gets an event, it validates the data and then adds the event to the score (stored in Redis).
- Once every second, the browser requests the score from the
scoreboard
microservice, which checks Redis for the current score.
At the end of the game, scoreboard
is called one last time to display the final score.
Redis performs two tasks. First, tally
publishes events to a Redis channel, and those events are consumed by the morsel
microservice. Second, each session’s score is kept in Redis’ key/value storage.
Let’s take a deeper look at each microservice.
The Session Microservice
The session
microservice is written in Ruby. The Ruby scripts are not compiled to WebAssembly. Instead, the Ruby interpreter itself is WebAssembly. It loads and executes the Ruby scripts on demand. Like any other Ruby program, session
makes use of several Gems to provide common features.
We talk a lot about how we think the micro part of microservices is really important. So it should come as no surprise that the session
microservice’s code is only 24 lines of code:
puts 'Content-Type: application/json; charset=UTF-8'
puts 'Status: 200'
puts
require 'json'
require 'ulid'
FLAVORS = %i[chicken fish beef veg]
TIMEOUT = 30_000
def random_flavor
FLAVORS.at(Random.rand(0..3))
end
menu = []
offset = 0
while offset < TIMEOUT
menu.push({ demand: random_flavor, offset: offset })
offset += Random.rand(1000..3000)
end
puts JSON.generate({ id: ULID.generate, menu: menu })
The above sends a JSON document back to the browser that defines a new session ID and provides a list of timings for what food Slats will want.
Ruby does not have a native Spin SDK available yet, so we use the Wagi executor to run this as a Wagi app.
You may notice above that we use a little trick for Finicky Whiskers: We don’t store the session ID. We use a ULID, which generates a unique ID that also has a time stamp embedded in it. Based on that creation time stamp, we can then determine whether the session is live (e.g. is within the 30 seconds of gameplay).
Once the browser has a fresh session, the game can begin. The JavaScript code in the browser changes the desired food based on the data it received from session
. And each time you click on a food button, the browser sends a request to tally
, our next service.
The Tally Microservice
Tally performs possibly the simplest job of all of the Finicky Whiskers microservices. It receives an event over HTTP, does a tiny bit of verification, and then pushes the event onto a Redis channel.
While the session
microservice is written in Ruby, we wrote Tally in Rust. the tally
microservice is around 200 lines of code, in part because we actually wrote unit tests for it. But here’s the most important function:
#[http_component]
fn tally_point(req: Request) -> Result<Response> {
// This gets info out of query params
match parse_query_params(req.uri()) {
Ok(tally) => {
// Should store something in Redis.
match serde_json::to_string(&tally) {
Ok(payload) => {
if let Err(e) = publish(payload.clone()) {
eprintln!("Error sending to Redis: {}", e)
} else {
println!("Sent message to Redis: {}", payload)
}
}
Err(e) => eprintln!("Error serializing JSON: {}", e),
}
// Send a response
let msg = format!("ULID: {:?}", tally.ulid);
Ok(http::Response::builder()
.status(200)
.body(Some(msg.into()))?)
}
Err(e) => Err(e),
}
}
The #[http_component]
macro declares this function to be a Spin HTTP component. HTTP components take a Request
and return a Response
.
This function parses the relevant information out of the URL’s query parameters (parse_query_params()
), and then creates a JSON document that it publishes to a Redis channel. Then it sends a simple 200 OK
response back to the browser.
This microservice is designed to just do one small unit of work as fast as possible. Once the message is published to the Redis channel, the morsel
microservice manages scoring.
The Morsel Microservice
The rest of the services in Finicky Whiskers are triggered by HTTP events. But morsel
is different. Spin subscribes to a Redis channel on morsel
’s behalf, and as Spin receives an event on that channel, it starts a morsel
instance.
When morsel
receives an event, it parses the JSON payload and then updates the score.
The score is stored in Redis’ key/value storage, using the session ID (the ULID) as the key and a serialized JSON document as the value. Each time, morsel
fetches the current score data (or creates a new empty score if necessary) and then increments the score.
Like tally
, the morsel
service is written in Rust. Scoreboard is under 100 lines of code (in part because I did not do a good job writing unit tests). Here, we’ll look at just the main function.
#[redis_component]
fn on_message(msg: Bytes) -> anyhow::Result<()> {
let address = std::env::var(REDIS_ADDRESS_ENV)?;
let tally_mon: Tally = serde_json::from_slice(&msg)?;
if !tally_mon.correct {
return Ok(());
}
let id: rusty_ulid::Ulid = tally_mon.ulid.parse()?;
let mut scorecard = match redis::get(&address, &id.to_string()) {
Err(_) => Scorecard::new(id),
Ok(data) => serde_json::from_slice(&data).unwrap_or_else(|_| Scorecard::new(id)),
};
match tally_mon.food.as_str() {
"chicken" => scorecard.chicken += 1,
"fish" => scorecard.fish += 1,
"beef" => scorecard.beef += 1,
"veg" => scorecard.veg += 1,
_ => {}
};
scorecard.total += 1;
if let Ok(talled_mon) = serde_json::to_vec(&scorecard) {
redis::set(&address, &id.to_string(), &talled_mon)
.map_err(|_| anyhow::anyhow!("Error saving to Redis"))?;
}
Ok(())
}
Instead of declaring this an HTTP component, we use the #[redis_component]
macro to make this a Redis responder. After parsing the incoming JSON payload, we do a few things:
- Validate the ULID
- Fetch the current score, or generate a default empty scorecard
- Increment the count for the specific food Slats ate
- Increment the total score
- Store the new score (again as JSON)
Note that we need to do only minimal validation because the tally
module did the big stuff. You might notice specifically that we do not check whether the timer has expired here. That’s a feature; tally
checks the time. That way, if the Redis channel gets bogged down, we don’t drop scores when morsel
runs. While we’ve never seen this happen, it certainly could. (We currently only run one Redis server in production, so this could happen someday.)
While morsel
is writing scores as often as new events come in, the scoreboard
microservice runs less frequently.
The Scoreboard Microservice
Like the last two services, scoreboard
is also written in Rust. (We really like Rust!) The browser calls scoreboard
once per second during the game, and then it updates the display.
This microservice is also under 100 lines of code. Here’s the important part.
#[http_component]
fn scoreboard(req: Request) -> Result<Response> {
let ulid = get_ulid(req.uri())?;
let score = match get_scores(&ulid) {
Ok(scores) => scores,
Err(e) => {
eprintln!("Error fetching scorecard: {}", e);
// Return a blank scorecard.
Scorecard::new(ulid)
}
};
let msg = serde_json::to_string(&score)?;
Ok(http::Response::builder()
.status(200)
.body(Some(msg.into()))?)
}
the get_scores()
function, which is not shown, just reads the scorecard from Redis and then serializes the data into JSON and sends it back to the client. The only reason this even parses the JSON payload is to make sure that it is well-formed before sending it back to the client. If we were really worried about speed, we could probably eliminate the parse/serialize combo and send back the raw data from Redis.
Conclusion
We’ve taken a look at four of the Finicky Whiskers microservices: session
, tally
, morsel
, and scoreboard
. And we’ve gotten a good look at how we use Redis. We’ve seen an example of a Ruby service running in Wagi mode as well as two HTTP services and a Redis service — all written in Rust using the Spin SDK.
In the next post we’ll get a behind-the-scenes look at the infrastructure we use to run Finicky Whiskers. That means Spin (for microservices) and Docker (for Redis). But we’ll also see Nomad and Consul. So visit us next week for the last installment of the Finicky Whiskers series.
Read Next
Finicky Whiskers (pt. 4): “Spin, Containers, Nomad, and Infrastructure.”