Powered by AppSignal & Oban Pro

Tutorial: Class Scheduling in Bedrock

livebooks/class_scheduling.livemd

Tutorial: Class Scheduling in Bedrock

Mix.install([
  {:bedrock, "~> 0.5"},
  {: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.

Welcome to Bedrock!

We’re going to build something fun together: a class scheduling system that students and administrators can use to manage course enrollments. Along the way, you’ll discover how Bedrock makes it surprisingly easy to build reliable, concurrent applications.

This tutorial is adapted from the FoundationDB Class Scheduling Tutorial, but don’t worry if you haven’t used FoundationDB before—we’ll start from the beginning and build everything step by step.

Setting Up Our Database

Before we can store any data, we need to tell Bedrock what role this node should play in the cluster. Let’s start with a simple setup that can handle everything we need:

# Create a temporary folder to persist data to
working_dir = Path.join(System.tmp_dir!(), "bedrock_#{:rand.uniform(99999)}")
File.mkdir_p!(working_dir)

defmodule Tutorial.Cluster do
  use Bedrock.Cluster,
    otp_app: :tutorial_app,
    name: "tutorial",
    config: [
      # What is this node allowed to be? In Bedrock, nodes can have different capabilities. Some
      # might only store data, others might coordinate cluster operations, and some might handle
      # both. For production deployments, you'd typically split these roles across different
      # machines for better performance and reliability.
      capabilities: [:coordination, :log, :materializer],

      # Single-node tutorials don't meet production durability requirements (replication, etc.)
      # so we relax the checks here.
      durability_mode: :relaxed,

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

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

In a real application, you’d start the cluster as part of your supervision tree, but for this tutorial we’ll just fire it up directly:

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

Now we need a way to talk to our database. The Repo module gives us a clean interface for all our database operations:

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

alias Tutorial.Repo
alias Bedrock.{Directory, Keyspace, Key, Encoding}

Perfect! Let’s test our setup with a quick hello world:

Repo.transact(fn ->
  Repo.put("hello", "world")
end)

When this returns, our data is safely stored in Bedrock. Let’s read it back to make sure everything’s working:

result = Repo.transact(fn ->
  Repo.get("hello")
end)

"hello " <> result

Great! Now we’re ready to build something more interesting.

Our Mission: A Class Scheduling System

Imagine you’re building software for a university. Students need to browse available classes, sign up for the ones they want, and drop classes when their plans change. Meanwhile, administrators need to make sure classes don’t get overbooked.

This might sound simple, but there’s a catch: hundreds of students might try to sign up for the same popular class at exactly the same time. How do we make sure we don’t accidentally let too many people in?

We’ll need three core operations:

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

Thinking About Data

Before we write any code, let’s think about what information we need to track and how to organize it.

We have two main types of data: classes (with their seat counts) and enrollments (which students are taking which classes). In a key-value database like Bedrock, we need to figure out how to represent these as keys and values.

For enrollments, we’ll use keys like this:

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

The key tells us which student is enrolled in which class. We don’t need to store anything in the value—the existence of the key is enough to show the student is enrolled.

For classes, we’ll track available seats:

{"class", class} = seats_available

Each class gets a key, and the value tells us how many seats are still open. Simple!

Organizing Our Data

Now let’s set up the actual storage structure. Think of this like organizing files in folders on your computer—we want to keep our class data separate from our enrollment data so they don’t accidentally interfere with each other.

First, we’ll create a directory for our scheduling app:

root = Directory.root(Repo)
{:ok, scheduling} = Directory.create_or_open(root, ["scheduling"])

Directories are one of Bedrock’s most useful organizational tools. Think of them like folders in a file system—they give us a hierarchical way to organize related data. But directories do more than just organization:

  • Shorter keys: Instead of long, descriptive key names, we can use short names within a directory
  • Atomic operations: You can move or copy entire directory trees atomically

Our scheduling directory creates a dedicated namespace for all our class-related data. This keeps our keys short and fast while preventing naming conflicts with other parts of a larger application. Plus, if we ever needed to archive a semester’s worth of data, we could atomically move the entire directory.

> 🔍 Directory vs Keyspace > > Think of directories as managing the “where” (namespace allocation and hierarchy) while keyspaces manage the “how” (key/value encoding and access patterns). A directory gives you a unique binary prefix and human-readable path. When you convert it to a keyspace, you get the tools to actually store and retrieve data with automatic encoding/decoding.

Now let’s create two “partitions” within our directory—one for classes and one for enrollments. This is where Bedrock’s keyspaces become really helpful:

course = Keyspace.partition(scheduling, "class", key_encoding: Encoding.Tuple, value_encoding: Encoding.Tuple)
attends = Keyspace.partition(scheduling, "attends", key_encoding: Encoding.Tuple)

What’s happening here? We’re creating two separate namespaces—course for our class data and attends for our enrollment data. Bedrock automatically handles all the key prefixing and encoding for us, so we can work with normal Elixir data types instead of worrying about binary formats.

Keyspaces handle two important things for us: namespace separation (so our class data and enrollment data can’t accidentally collide) and automatic encoding (so we can work with Elixir tuples and structs instead of raw binaries).

Why Transactions Matter

Here’s the thing about our scheduling system: we can’t just write data and hope for the best. What if our program crashes halfway through signing up a student? What if two students try to grab the last seat in a class at exactly the same time?

Bedrock’s transactions solve these problems for us. When we wrap operations in Repo.transact/1, we get powerful guarantees:

  • All-or-nothing: Either all our changes succeed, or none of them do
  • Isolation: Multiple operations can run concurrently without stepping on each other
  • Durability: Once a transaction completes, the changes are permanent

Even better, transactions can be nested. This means we can build complex operations by combining simpler ones, and Bedrock makes sure everything stays consistent.

Let’s see this in action:

defmodule Scheduling1 do
  @total_seats_available 100

  def add_class(course, class) do
    Repo.transact(fn ->
      Repo.put(course, class, @total_seats_available)
    end)
  end

  def init(scheduling, course, class_names) do
    Repo.transact(fn ->
      Repo.clear_range(scheduling)

      for class_name <- class_names do
        add_class(course, class_name)
      end

      :ok
    end)
  end
end

The add_class/2 function is straightforward—it just stores a class with 100 available seats. But init/3 is more interesting: it first wipes out any existing data, then calls add_class/2 for each class name.

Notice that init/3 calls add_class/2 from within its own transaction. This is nested transactions in action! Bedrock automatically merges the inner transactions into the outer one, so either all classes get added successfully, or none of them do.

Also notice something we’re not doing: we don’t have to pass around transaction objects or worry about connection management. When you’re inside a Repo.transact/1 block, all database operations automatically use the current transaction.

Building Our Course Catalog

Now let’s create some sample classes to work with. We’ll generate a bunch of different combinations to make our testing more realistic:

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"

Perfect! Now let’s load all these classes into our database:

Scheduling1.init(scheduling, course, class_names)

Browsing Available Classes

Now that we have classes in our database, students need a way to see what’s available. Since Bedrock stores data in sorted order, we can grab all classes with a single, efficient range operation:

defmodule Scheduling2 do
  def available_classes(course) do
    Repo.transact(fn ->
      course |> Repo.get_range() |> Enum.to_list()
    end)
  end
end

Look how clean this is! Repo.get_range(course) automatically figures out which keys belong to our course keyspace and returns a stream of {class_name, seat_count} tuples. No manual key prefixing, no binary decoding—just our data, ready to use.

Let’s see what we’ve got:

available = Scheduling2.available_classes(course)
IO.puts("Found #{length(available)} available classes")
IO.puts("First five: #{available |> Enum.take(5) |> inspect}")

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 Scheduling3 do
  def signup(attends, student, class) do
    Repo.transact(fn ->
      Repo.put(attends, {student, class}, <<>>)
    end)
  end

  def drop(attends, student, class) do
    Repo.transact(fn ->
      Repo.clear(attends, {student, class})
    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.

Scheduling3.signup(attends, "Alice", "10:00 alg intro")
Scheduling3.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.transact(fn ->
      case Repo.get(attends, {student, class}) do
        nil ->
          seats_left = Repo.get(course, class)

          if seats_left == 0 do
            {:error, "No remaining seats"}
          else
            Repo.put(course, class, seats_left - 1)
            Repo.put(attends, {student, class}, <<>>)
          end

        _existing ->
          :ok
      end
    end)
  end

  def drop(attends, course, student, class) do
    Repo.transact(fn ->
      case Repo.get(attends, {student, class}) do
        nil ->
          :ok

        _existing ->
          seats_left = Repo.get(course, class)
          Repo.put(course, class, seats_left + 1)
          Repo.clear(attends, {student, class})
      end
    end)
  end

  def available_classes(course) do
    Repo.transact(fn ->
      course
      |> Repo.get_range()
      |> 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
    Repo.transact(fn ->
      Repo.clear_range(scheduling)

      for class_name <- class_names do
        add_class(course, class_name)
      end

      :ok
    end)
  end

  def add_class(course, class) do
    Repo.transact(fn ->
      Repo.put(course, class, @total_seats_available)
    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 an error.

# 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"
# There should be no remaining seats
case Scheduling.signup(attends, course, "Charlie", popular_class) do
  :ok -> 
    "❌ Charlie signed up (this shouldn't happen)"
  {:error, "No remaining seats"} -> 
    "✅ Charlie correctly rejected - no remaining seats"
end

The Rush for Popular Classes

Here’s where things get interesting. Imagine registration opens for a super popular class with just one seat left. Two hundred students hit “signup” at exactly the same moment. What happens?

In a system without transactions, chaos. Students might see the seat as available, but by the time they finish signing up, someone else has already taken it. Or worse, the system might accidentally let both students register, overselling the class.

But our system uses transactions, which means each signup attempt gets an isolated view of the database. It’s like each student gets their own private copy of the registration system for a split second. Either they successfully grab the seat and everyone else sees the class as full, or they see that it’s already taken.

When two transactions conflict (like two students trying for the same last seat), Bedrock automatically picks a winner and retries the other one. The retry sees the updated state and gets the “No remaining seats” error. No overselling, no inconsistency, no problem.

Let’s put this to the test with some serious 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"
# There should be no remaining seats
case Scheduling.signup(attends, course, "Charlie", next_popular_class) do
  :ok -> 
    "❌ Charlie signed up (this shouldn't happen)"
  {:error, "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)}"

Class Switching: A Perfect Use Case

Here’s a classic problem: a student wants to switch from one class to another, but both classes are nearly full. If they drop their current class first, someone else might grab their spot before they can sign up for the new one. If they try to sign up for the new class first, it might be full.

This is exactly the kind of problem transactions solve. We can make the whole switch operation atomic—either both the drop and signup succeed, or neither does:

def switch(attends, course, student, old_class, new_class) do
  Repo.transact(fn ->
    with :ok <- signup(attends, course, student, new_class),
         :ok <- drop(attends, course, student, old_class) do
      {:ok, :switched}
    end
  end)
end

Beautiful! This simple function gives students a safe way to switch classes without any risk of ending up in neither class or accidentally double-enrolled.

Notice how we’re composing transactions—our switch function calls signup and drop, which are themselves transactional functions. Bedrock automatically nests everything into a single atomic operation. If anything goes wrong at any step, the entire switch gets rolled back.

Mission Accomplished

And that’s it! We’ve built a complete class scheduling system that can handle concurrent signups, prevent overbooking, and even support atomic class switching. The best part? We didn’t have to write a single line of complex concurrency control code.

What Makes This Scale

Because all our state lives in Bedrock, scaling this system is straightforward. Want to handle more students? Spin up more web servers—they can all hit the same database simultaneously without stepping on each other. Need higher availability? Add more Bedrock nodes to your cluster.

The transactional guarantees mean you don’t have to worry about race conditions or partial failures, even under heavy load. Students can hammer the signup buttons all they want, and the system will maintain consistency.

What We’ve Learned

In building this scheduling system, we’ve seen several key Bedrock concepts in action:

  • Keyspaces organize our data and handle encoding automatically
  • Transactions give us rock-solid consistency guarantees without the complexity
  • Nested transactions let us compose operations safely
  • Automatic conflict resolution handles concurrency for us

The beauty of Bedrock is that it makes the hard parts of distributed systems—consistency, concurrency, durability—just work, so you can focus on building your application.

Keep Exploring

Ready for more? Try building other data structures using Bedrock’s transactional primitives, or explore how to model more complex relationships using directories and keyspaces. The same principles we used here scale to much larger, more sophisticated applications.