Stack logo
Sync up on the latest from Convex.
Jamie Turner's avatar
Jamie Turner
2 years ago

Convex gets Rusty with Santa

Convex gets Rusty with Santa

At Convex we like Rust. After all, we’ve used Rust in production for almost ten years, and the Convex backend is even built in Rust! So when we began to add support for new languages beyond JavaScript/TypeScript, Rust was near the top of the list.

We’re very pleased to announce our Rust client is now up on GitHub and crates.io! This means you can write Rust programs that read, write, and subscribe to records in your Convex deployments, just as with our original JavaScript/TypeScript library.

So what might you do with this new Rust crate?

Haskell helps Convex onto the nice list

I know, I know—this article is about using Rust with Convex. So why is the Haskell programming language getting involved? Well, Convex’s overall architectural principles are heavily influenced by ideas from Haskell. In particular, Convex’s decision to represent transactions as side-effect-free code is an idea that Haskell advanced beautifully with its Software Transactional Memory (STM) implementation and library.

In 2007, in the book Beautiful Code, Haskell creator Simon Peyton-Jones contributed a wonderful chapter called “Beautiful Concurrency.” Inside, he introduced STM as a way to model concurrent systems that desire atomic changes to shared state without using pessimistic locking.

The chapter contained a Haskell program demonstrating STM in practice, implementing a contrived scenario Simon dubbed “The Santa Claus problem.” The full chapter provides a great amount of detail on this problem, but here’s a summary:

  1. At the North Pole, nine reindeer (Simon generously included Rudolph), ten elves, and Santa all need to work together following some rules
  2. If all nine reindeer are ready to work, Santa gathers them up and delivers presents with them
  3. Otherwise, if at least three elves are ready to work, Santa goes into his study to build presents with exactly three of the elves
  4. If neither group is ready, Santa waits. He doesn't work alone!
  5. After a shift working with Santa, each elf and reindeer goes alone on vacation for a while, unavailable to work until they return

In Beautiful Code, Simon implemented each of these characters as an actor in a separate Haskell thread, using STM to coordinate their shared state. I decided to recreate my own solution in a multi-process, multi-host distributed system, using the Convex backend to coordinate state. Read on to see how!

Making a flight plan

It will help to understand our scenario's rules if we make some algorithmic pseudocode. First, let’s write down the logic for the elves and reindeer. They both have the same routine, so this routine will be for a generic “worker.”

register new ("elf" | "reindeer") as state "ready"
loop {
  wait until Santa says our state is "working"
  wait (aka "work") until Santa says our state is "vacationing"
  sleep for a random amount of time -- on vacation
  set our own state to "ready"
}

Now, let’s write down Santa’s logic:

loop {
  do {
    poll everyone with state as "ready"
  } until all 9 reindeer or at least 3 elves are "ready"

  if 9 reindeer are "ready" {
    the work group = all nine reindeer
  } else {
    the work group = any three elves who are "ready"
  }

  atomically set every member of the work group to "working"
  sleep a random amount of time -- the group is working
  atomically set every member of the work group to "vacationing" 
}

Polar architecture

Based on our pseudocode exploration above, we can model the shared state of this problem as a table of records where each record represents an individual worker. We have exactly two requirements for these records:

  1. We need to distinguish between “reindeer” and “elf” workers, since Santa’s selection behavior is different for each (namely, preferring reindeer, and working with the whole set vs. a subset)
  2. We need to track the current state of each of these workers as either “ready”, “working”, or “vacationing”. This is effectively how Santa and the workers will communicate with each other.

Time to get coding! First, let’s lock down the Convex schema in convex/schema.ts:

import { defineSchema, defineTable } from "convex/schema";
import { v } from "convex/values";

export default defineSchema({
	workers: defineTable({
    workerType: v.union(v.literal("reindeer"), v.literal("elves")),
    state: v.union(
      v.literal("ready"),
      v.literal("working"),
      v.literal("vacationing")
    ),
  }),
});
The workers table contains the two fields to fulfill the two requirements above. We’ll use a union of literals from Convex’s value library to represent both the worker type and the worker state’s valid set of values.

Now, in a file called convex/worker.ts let’s implement the Convex query and mutation functions that will soon service our worker actor:

import { Doc, Id } from "./_generated/dataModel";
import {
  DatabaseReader,
  DatabaseWriter,
  mutation,
  query,
} from "./_generated/server";

// Initial call. Create a new worker with an initially ready-to-work state.
export const insertReadyWorker = mutation(
  async (
    { db }: { db: DatabaseWriter },
    { workerType }: { workerType: "reindeer" | "elves" }
  ) => {
    const newId = await db.insert("workers", { workerType, state: "ready" });
    return newId;
  }
);

// Check if Santa has told us it's time to work.
export const isTimeToWork = query(
  async ({ db }: { db: DatabaseReader }, { id }: { id: Id<"workers"> }) => {
    return (await db.get(id))!.state == "working";
  }
);

// Check if Santa has told us our work is done.
export const isTimeToVacation = query(
  async ({ db }: { db: DatabaseReader }, { id }: { id: Id<"workers"> }) => {
    return (await db.get(id))!.state == "vacationing";
  }
);

// After we're done vacationing, mark ourselves ready to work again.
export const markBackFromVacation = mutation(
  async ({ db }: { db: DatabaseWriter }, { id }: { id: Id<"workers"> }) => {
    await db.patch(id, {
      state: "ready",
    });
  }
);

Each of these functions provides the server-side capabilities for the Rust app’s worker routine, which we’ll get to momentarily. Notice these functions are only about maintaining one actor’s state. There is no collective querying or mutation–the worker only cares about their own tasks.

Santa, meanwhile, is the one who decides what the group does, so his module of Convex functions (convex/santa.ts) will contain queries and mutations that span all worker records.

According to our pseudocode, he’ll need some kind of query to check if there is a group ready to work:

export const newGroupReady = query(async ({ db }): Promise<"reindeer" | "elves" | null> => {
  // Quick invariant check. No one should be working if we don't expect it.
  const workersBusy = await anyoneWorking({ db });
  if (workersBusy) {
    throw "Busy workers before Santa said to go?";
  }
  // No one working. Do we have another workgroup?

	// Helper function that queries workerType = reindeer + "ready"
  const reindeer = await waitingReindeer({ db });
  if (reindeer.length == 9) {
    return "reindeer";
  }
	// Helper function that queries workerType = elves + "ready"
  const elves = await waitingElves({ db });
  if (elves.length >= 3) {
    return "elves";
  }
  return null;
});
If we have nine reindeer or at least three elves, this query function returns the appropriate worker type that Santa should engage in work. Santa can subscribe to this until the result is non-null.

Once a group is ready, Santa needs a mutation to schedule that group to work and one more to finish that work and put them on vacation:

// Grab the appropriate group and mark them working. The given `work`
// was returned as ready from the `newGroupReady` query.
export const dispatchGroup = mutation(
  async ({ db }, { work }: { work: "reindeer" | "elves" }) => {
    if (await anyoneWorking({ db })) {
      throw "should never try to kick off a workgroup when work is already happening";
    }
    if (work == "reindeer") {
      const reindeer = await waitingReindeer({ db });
      assert(reindeer.length === 9);
      for (const r of reindeer) {
        await db.patch(r._id, { state: "working" });
      }
    } else if (work == "elves") {
      // Then, kick off elves to work.
      const elves = await waitingElves({ db });
      assert(elves.length >= 3);
      const wakeElves = elves.slice(0, 3);
      for (const e of wakeElves) {
        await db.patch(e._id, { state: "working" });
      }
    } else {
      throw "Uh, what kind of job is this?";
    }
  }
);

// Release the current workgroup. Put them on vacation.
export const releaseGroup = mutation(async ({ db }) => {
  const workers = await currentWorkers({ db });
  for (const w of workers) {
    await db.patch(w._id, {
      state: "vacationing",
    });
  }
});

And that’s basically everything we need. Time to build the actor routines that use these Convex functions in a Rust app!

Rusty Routines

Rust apps communicate with Convex using a client from the Convex crate. Creating a new Rust client is easy:

let mut convex = ConvexClient::new(&cli.deployment_url).await.unwrap();
Note that we’ll make liberal use of unwrap() for concision in this example project, but your Real Production™ apps should handle the errors!

Invoking mutations is a client method that takes a name and arguments just like JavaScript/TypeScript. It returns a future since the Convex client is 100% async.

Subscriptions are exposed as a futures::Stream, yielding new values whenever Convex pushes them over the wire when dependent backend data changes.

In Rust, arguments to all Convex functions are provided as a BTreeMap, where each value is a Convex-specific Value type that represents all the types you can pass to Convex functions.

And that’s all we need to know about the Convex crate API. With those basic ingredients covered, let’s now walk through the Rust function for Santa's implementation line by line. We’ll use these few Convex client methods to invoke the Convex functions in santa.ts to create our actor.

First, the preamble…

async fn santa(deployment_url: String, max_work_ms: Arc<AtomicU64>) {
    let mut convex = ConvexClient::new(&deployment_url).await.unwrap();
    let mut rng = rand::rngs::StdRng::from_entropy();
    loop {
Create the Convex client and initialize a random number generator for creating random sleep timings for the work duration. Then enter the main loop body of the routine.

Our pseudocode outlined the beginning of Santa's algorithm as:

  do {
    poll everyone with state as "ready"
  } until all 9 reindeer or at least 3 elves are "ready"
  if 9 reindeer are "ready" {
    the work group = all nine reindeer
  } else {
    the work group = any three elves who are "ready"
  }

You may recall this logic is already in our deployment’s query function newGroupReady. This will return null if no group is ready, or a group name if so. All that remains is to write this bit of Rust, which blocks on a subscription until a group type is returned:

    // Wait for a new group of workers to be ready!
    let mut sub = convex
        .subscribe("santa:newGroupReady", maplit::btreemap! {})
        .await
        .unwrap();
    let mut job = String::new();
    while let Some(result) = sub.next().await {
        if let FunctionResult::Value(Value::String(given_job)) = result {
            job = given_job;
            break;
        }
    }
    drop(sub);
Consume values from the subscription stream (ignoring nulls) until we get a string value indicating a particular type of worker group is ready.

And to recap the rest of our Santa algorithm pseudocode:

  atomically set every member of the work group to "working"
  sleep a random amount of time -- working
  atomically set every member of the work group to "vacationing"

Right. So, atomically (which we get for free in Convex mutations) set everyone in the workgroup to state “working”:

    // Kick off work.
    convex
        .mutation(
            "santa:dispatchGroup",
             maplit::btreemap! {
                 "work".to_owned() => Value::String(job),
             },
        )
        .await
        .unwrap();
Call the dispatchGroup mutation, passing along the job type we were just told is ready to go.

Our Santa routine will now sleep for a random duration to simulate the time it takes to finish the work.

    // Let the group "work" with Santa.
    tokio::time::sleep(
        Duration::from_millis(
            rng.gen_range(0..max_work_ms.load(Ordering::Relaxed)),
        )
    )

Then, we need to call a mutation to atomically release the current workers so they stop working and go on vacation:

    // Release them from their duties.
    convex
        .mutation("santa:releaseGroup", maplit::btreemap! {})
        .await
        .unwrap();
   } // go back to the top of the loop and wait for the next group!
}

We’ll skip a detailed examination of the worker routine’s Rust code. In brief, it uses the Convex functions from workers.ts in a loop similar to Santa:

  1. It uses subscriptions to wait until Santa puts them to work and then again until he releases them
  2. It calls a mutation to re-mark itself as ready to work after a random delay for “vacation”

The Arctic in Action

Let’s see this simulation run!

The running Rust program on the left side of this video contains all 20 actors in separate threads with separate Convex connections–19 worker threads (nine reindeer, ten elves) and one Santa thread. Their only shared state is in the Convex deployment they access over the network.

On the right, we can see the Convex dashboard update as the collective state machine cycles:

And since Convex is great at web stuff, I built a tiny bonus React app. The app provides a nifty visual depiction of the North Pole state and additionally lets you adjust two Convex-backed values that parameterize how long the Rust program sleeps during vacation and work periods:

Want to run it yourself, or go deeper into the code? The whole codebase is up on GitHub.

Ho! Ho! Workflow!

Some may argue this Santa situation is pretty silly. What’s the point?

Well, what we’ve really created is a distributed workflow system where different programs depend on each other’s state to advance a collective process. And because of the Convex foundation, this workflow utilizes ACID semantics to guarantee appropriate coordinated behavior.

This scenario represents one common pattern where we’re excited to see folks use Rust (and Python!) with Convex. After all, there are numerous background, batch, command line, data-intensive programs out there quietly intermingling with and enhancing the UI-centric web and mobile apps we interact with and love.

And now with Convex, you can use a single, powerful, consistent set of abstractions to build these hybrid systems with ease.

Build in minutes, scale forever.

Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started