Powered by AppSignal & Oban Pro

Tutorial: Class Scheduling in Bedrock

livebooks/class_scheduling.livemd

Tutorial: Class Scheduling in Bedrock

Mix.install([
  {:bedrock, git: "https://github.com/jallum/bedrock.git", tag: "0.3.0-rc3"},
  {:kino, "~> 0.16.0"},
])

# If you've used any of these tutorials before, and want to see the latest-greatest, you'll want 
# to use the "Setup without cache" option from the menu above.

Hey There, Stranger!

This tutorial provides a walkthrough of designing and building a simple application in Elixir using Bedrock. In this tutorial, we use a few simple data modeling techniques. This is a translation of the official FoundationDB Class Scheduling Tutorial adapted for Bedrock’s API.

First Steps

In order for your application to use Bedrock, we need to define what this node is allowed to do as part of the cluster. Will it paricipate in controlling activities and elections? Will it only handle storage needs? The Cluster definition is where we will set all of that up. Here’s a simple definition that will give us a place to put things and get us going.

# Create a temporary folder to persist data to

working_dir = Path.join(System.tmp_dir!(), "bedrock_#{:rand.uniform(99999)}")
File.mkdir_p!(working_dir)
working_dir
defmodule Tutorial.Cluster do
  use Bedrock.Cluster,
    otp_app: :tutorial_app,
    name: "tutorial",
    config: [
      # What is this node allowed to be?
      capabilities: [:coordination, :log, :storage],

      # Optional tracing of internal operations
      # trace: [:coordinator, :recovery, :commit_proxy, :log, :storage],

      # Where does coordinator, storage, log data go?
      coordinator: [path: working_dir],
      storage: [path: working_dir],
      log: [path: working_dir]
    ]
end

In a real application, you would start the Bedrock cluster as part of your supervision tree. For this tutorial, we will just start it directly using Kino. You can see the pretty, pretty supervision tree!

Kino.start_child!({Tutorial.Cluster, []})

The Repo module provides a convenient interface for interacting with the database. Let’s define one now.

defmodule Tutorial.Repo do
  use Bedrock.Repo, cluster: Tutorial.Cluster
end

alias Tutorial.Repo
alias Bedrock.{Directory, Subspace, Key}

We’re now ready to use the database. First, let’s write a key-value pair.

Repo.transaction(fn tx ->
  Repo.put(tx, "hello", "world")
  :ok
end)

When this command returns without exception, the modification is durably stored in Bedrock! Under the covers, this function creates a transaction with a single modification. We’ll see later how to do multiple operations in a single transaction. For now, let’s read back the data:

result = Repo.transaction(fn tx ->
  Repo.get(tx, "hello")
end)

"hello " <> result

If this is working, we are ready to start building a real application.

The application

Let’s say we’ve been asked to build a class scheduling system for students and administrators. We’ll walk through the design and implementation of this application.

Requirements

We’ll need to let users list available classes and track which students have signed up for which classes. Here’s a first cut at the functions we’ll need to implement:

available_classes()       # returns list of classes
signup(student_id, class) # signs up a student for a class
drop(student_id, class)   # drops a student from a class

Data model

First, we need to design a data model. A data model is just a method for storing our application data using keys and values in Bedrock. We seem to have two main types of data: (1) a list of classes and (2) a record of which students will attend which classes. Let’s keep attending data like this:

{"attends", student, class} = ""

We’ll just store the key with a blank value to indicate that a student is signed up for a particular class.

We’ll keep data about classes like this:

{"class", class} = seats_available

Similarly, each such key will represent an available class. We’ll use seats_available to record the number of seats available.

Directories and Subspaces

Bedrock includes directories and subspaces that make it easy to model data using this approach. Let’s begin by creating a directory:

# Create root directory layer
root = Directory.root(Repo)

# Create or open scheduling directory
{:ok, scheduling} = Directory.create_or_open(root, ["scheduling"])

The directory provides a subspace where we’ll store our application data. Each subspace has a fixed prefix it uses when defining keys. We decided that we wanted "attends" and "class" as our prefixes, so we’ll create new subspaces for them within the scheduling subspace:

course = Subspace.create(scheduling, {"class"})
attends = Subspace.create(scheduling, {"attends"})

Subspaces have a pack/2 function for defining keys. To store the records for our data model, we can use Subspace.pack(attends, {student, class}) and Subspace.pack(course, class).

Transactions

We’re going to rely on the powerful guarantees of transactions to help keep all of our modifications straight, so let’s look at how Bedrock lets you write a transactional function. Let’s write the very simple add_class function we will use to populate the database’s class list:

defmodule Scheduling1 do
  @total_seats_available 100

  def add_class(course, class) do
    Repo.transaction(fn tx ->
      key = Subspace.pack(course, class)
      value = Key.pack(@total_seats_available)
      Repo.put(tx, key, value)
      :ok
    end)
  end
end

In the add_class/2 function, the transaction is automatically handled by Repo.transaction/1. All Bedrock functions that work with the database accept a transaction context as the first argument.

Making some sample classes

Let’s make some sample classes and put them in the class_names variable.

levels = ["intro", "for dummies", "remedial", "101", "201", "301", "mastery", "lab", "seminar"]
types = ["chem", "bio", "cs", "geometry", "calc", "alg", "film", "music", "art", "dance"]
times = for h <- 2..20, do: "#{h}:00"
class_names = for i <- times, t <- types, l <- levels, do: "#{i} #{t} #{l}"
"Created #{length(class_names)} sample classes"

Initializing the database

We initialize the database with our class list:

defmodule Scheduling2 do
  @total_seats_available 100

  def init(scheduling, course, class_names) do
    # Get the range for the scheduling directory
    scheduling_range = scheduling |> Directory.get_subspace() |> Subspace.range()

    Repo.transaction(fn tx ->
      # Clear the directory
      Repo.clear_range(tx, scheduling_range)

      # Add all classes
      for class_name <- class_names do
        add_class(course, class_name)
      end

      :ok
    end)
  end

  def add_class(course, class) do
    Repo.transaction(fn tx -> 
      key = Subspace.pack(course, class)
      value = Key.pack(@total_seats_available)
      Repo.put(tx, key, value)
      :ok
    end)
  end
end
Scheduling2.init(scheduling, course, class_names)

Listing available classes

Before students can do anything else, they need to be able to retrieve a list of available classes from the database. Because Bedrock sorts its data by key and therefore has efficient range-read capability, we can retrieve all of the classes in a single database call. We find this range of keys with Subspace.range(course):

defmodule Scheduling3 do
  def available_classes(course) do
    course_range = Subspace.range(course)

    Repo.transaction(fn tx ->
      tx
      |> Repo.range(course_range)
      |> Stream.map(fn {encoded_class, _v} ->
        Subspace.unpack(course, encoded_class)
      end)
      |> Enum.to_list()
    end)
  end
end
available = Scheduling3.available_classes(course)
IO.puts("Found #{length(available)} available classes")
IO.puts("First few: #{available |> Enum.take(5) |> inspect}")

The Subspace.range/1 function returns a 2-tuple containing binaries that represent the start and end (exclusive) of the subspace key range. We retrieve all key-value pairs and unpack the key to extract the class name.

Signing up for a class and Dropping a class

We finally get to the crucial functions. A student has decided on a class (by name) and wants to sign up. The signup function will take a student and a class:

defmodule Scheduling4 do
  def signup(attends, student, class) do
    Repo.transaction(fn tx ->
      rec = Subspace.pack(attends, {student, class})
      Repo.put(tx, rec, <<>>)
      :ok
    end)
  end

  def drop(attends, student, class) do
    Repo.transaction(fn tx ->
      rec = Subspace.pack(attends, {student, class})
      Repo.clear(tx, rec)
      :ok
    end)
  end
end

For signup, we simply insert the appropriate record (with a blank value).

For drop, we need to be able to delete a record from the database. We do this with the clear/2 function.

Scheduling4.signup(attends, "Alice", "10:00 alg intro")
Scheduling4.drop(attends, "Alice", "10:00 alg intro")

Done?

We report back to the project leader that our application is done—students can sign up for, drop, and list classes. Unfortunately, we learn that a new problem has been discovered: popular classes are being over-subscribed. Our application now needs to enforce the class size constraint as students add and drop classes.

Seats are limited!

Let’s go back to the data model. Remember that we stored the number of seats in the class in the value of the key-value entry in the class list. Let’s refine that a bit to track the remaining number of seats in the class. Here’s the complete final module:

defmodule Scheduling do
  @total_seats_available 100

  def signup(attends, course, student, class) do
    Repo.transaction(fn tx ->
      rec = Subspace.pack(attends, {student, class})

      case Repo.get(tx, rec) do
        nil ->
          # Not signed up yet, proceed with signup
          class_key = Subspace.pack(course, class)
          seats_data = Repo.get(tx, class_key)
          seats_left = Key.unpack(seats_data)

          if seats_left == 0, do: raise("No remaining seats")

          # Decrement seats and record signup
          Repo.put(tx, class_key, Key.pack(seats_left - 1))
          Repo.put(tx, rec, <<>>)
          :ok

        _existing ->
          # Already signed up
          :ok
      end
    end)
  end

  def drop(attends, course, student, class) do
    Repo.transaction(fn tx ->
      rec = Subspace.pack(attends, {student, class})

      case Repo.get(tx, rec) do
        nil ->
          # Not taking this class
          :ok

        _existing ->
          # Increment seats and remove signup
          class_key = Subspace.pack(course, class)
          seats_data = Repo.get(tx, class_key)
          seats_left = Key.unpack(seats_data)

          Repo.put(tx, class_key, Key.pack(seats_left + 1))
          Repo.clear(tx, rec)
          :ok
      end
    end)
  end

  def available_classes(course) do
    course_range = Subspace.range(course)

    Repo.transaction(fn tx ->
      tx
      |> Repo.range(course_range)
      |> Stream.map(fn {packed_class, packed_seats} ->
        class = Subspace.unpack(course, packed_class)
        availability = Key.unpack(packed_seats)
        {class, availability}
      end)
      |> Stream.filter(fn {_class, availability} -> availability > 0 end)
      |> Stream.map(fn {class, _availability} -> class end)
      |> Enum.to_list()
    end)
  end

  def init(scheduling, course, class_names) do
    scheduling_range = scheduling |> Directory.get_subspace() |> Subspace.range()

    Repo.transaction(fn tx ->
      # Clear the directory
      Repo.clear_range(tx, scheduling_range)

      # Add all classes
      for class_name <- class_names do
        add_class(course, class_name)
      end

      :ok
    end)
  end

  def add_class(course, class) do
    Repo.transaction(fn tx ->
      key = Subspace.pack(course, class)
      value = Key.pack(@total_seats_available)
      Repo.put(tx, key, value)
      :ok
    end)
  end
end
  • available_classes: This is easy – we simply add a condition to check that the value is non-zero.

  • signup: We now have to check that we aren’t already signed up, since we don’t want a double sign up to decrease the number of seats twice. Then we look up how many seats are left to make sure there is a seat remaining so we don’t push the counter into the negative. If there is a seat remaining, we decrement the counter.

  • drop: Once again we check to see if the student is signed up and if not, we can just return as we don’t want to incorrectly increase the number of seats. We then adjust the number of seats by one by taking the current value, incrementing it by one, and then storing back.

Let’s try it out. We choose a course for 100 students named “Bob”, and sign them all up. Then, when Charlie attempts to signup, we get the exception.

# Reinitialize with our final scheduling module
Scheduling.init(scheduling, course, class_names)
# Select the first class that's not completely full
popular_class =
  Scheduling.available_classes(course)
  |> hd()

"Popular class: #{popular_class}"
the_bobs = for i <- 1..100, do: "Bob #{i}"
Enum.each(
  the_bobs,
  &amp;Scheduling.signup(attends, course, &amp;1, popular_class)
)

"All 100 Bobs signed up successfully"
# This should raise "No remaining seats"
try do
  Scheduling.signup(attends, course, "Charlie", popular_class)
  "❌ Charlie signed up (this shouldn't happen)"
catch
  :error, %RuntimeError{message: "No remaining seats"} ->
    "✅ Charlie correctly rejected - no remaining seats"
end

Concurrency and consistency

The signup function is starting to get a bit complex; it now reads and writes a few different key-value pairs in the database. One of the tricky issues in this situation is what happens as multiple clients/students read and modify the database at the same time. Couldn’t two students both see one remaining seat and sign up at the same time?

These are tricky issues without simple answers—unless you have transactions! Because these functions are defined as Bedrock transactions, we can have a simple answer: Each transactional function behaves as if it is the only one modifying the database. There is no way for a transaction to ‘see’ another transaction change the database, and each transaction ensures that either all of its modifications occur or none of them do.

Looking deeper, it is, of course, possible for two transactions to conflict. For example, if two people both see a class with one seat and sign up at the same time, Bedrock must allow only one to succeed. This causes one of the transactions to fail to commit. The transaction will be retried automatically and will eventually lead to the correct result, a "No remaining seats" exception.

Let’s try it out with some aggressive concurrency:

next_popular_class =
  Scheduling.available_classes(course)
  |> hd()

"Next popular class: #{next_popular_class}"
the_dans = for i <- 1..100, do: "Dan #{i}"
the_dans
|> Task.async_stream(&amp;Scheduling.signup(attends, course, &amp;1, next_popular_class))
|> Stream.run()

"All 100 Dans processed with concurrency"
# This should raise "No remaining seats"
try do
  Scheduling.signup(attends, course, "Charlie", next_popular_class)
  "❌ Charlie signed up (this shouldn't happen)"
catch
  :error, %RuntimeError{message: "No remaining seats"} ->
    "✅ Charlie correctly rejected - no remaining seats"
end

As expected, the next_popular_class is no longer available:

available_now = Scheduling.available_classes(course)
"Classes available now: #{length(available_now)}"

Composing transactions

Oh, just one last feature, we’re told. We have students that are trying to switch from one popular class to another. By the time they drop one class to free up a slot for themselves, the open slot in the other class is gone. By the time they see this and try to re-add their old class, that slot is gone too! So, can we make it so that a student can switch from one class to another without this worry?

Fortunately, we have Bedrock, and this sounds an awful lot like the transactional property of atomicity—the all-or-nothing behavior that we already rely on. All we need to do is to compose the drop and signup functions into a new switch function:

def switch(attends, course, student, old_class, new_class) do
  Repo.transaction(fn _tx ->
    signup(attends, course, student, new_class)
    drop(attets, course, student, old_class)
  end)
end

The simplicity of this implementation belies the sophistication of what Bedrock is taking care of for us.

By dropping the old class and signing up for the new one inside a single transaction, we ensure that either both steps happen, or that neither happens. The compositional capability is very powerful.

Also note that, if an exception is raised, for example, in the signup portion, the exception propagates and the transaction object is destroyed, automatically rolling back all database modifications, leaving the database completely unchanged.

Are we done?

Yep, we’re done and ready to deploy. This entire application can be deployed and scaled easily since we store all state in Bedrock.

Deploying and scaling

Since we store all state for this application in Bedrock, deploying and scaling this solution up is impressively painless. Just run a web server, the UI, this back end, and point the whole thing at Bedrock. We can run as many computers with this setup as we want, and they can all hit the database at the same time because of the transactional integrity of Bedrock. Also, since all of the state in the system is stored in the database, any of these computers can fail without any lasting consequences.

Next steps

  • See Bedrock’s documentation for guidance on using directories, subspaces, and keys for effective data modeling
  • Explore Bedrock’s atomic operations for high-concurrency scenarios
  • Try building other data structures using Bedrock’s transactional primitives

This tutorial demonstrates how Bedrock provides the same powerful transactional guarantees and data modeling capabilities as FoundationDB, with a clean Elixir API that feels natural to Elixir developers.