Maelstrom Distributed Systems Workbench

repository·main·Indexed 25 days ago

https://github.com/jepsen-io/maelstrom

A workbench for learning and testing distributed systems that provides a simulated network environment and standardized workloads. It allows developers to implement distributed algorithms in any language using a JSON protocol over STDIN and STDOUT. Features include fault injection via a 'nemesis' for network partitions and node failures, and analysis tools for visualizing concurrency and consistency anomalies. The project includes implementation examples and libraries for C++, Go, Python, and Rust.

Tokens
33.6K
Snippets
87
Records
145
Agent score
87%

What's inside Maelstrom

  1. Overview of Maelstrom

    main

    Maelstrom is a workbench for learning distributed systems by writing your own. It provides a standardized testing environment where you can implement distributed algorithms (like Raft, CRDTs, or transactional KV stores) in any language.

    Key features include:

    • Language Agnostic: Nodes are plain binaries that communicate via a simple JSON protocol over STDIN and STDOUT.
    • Simulated Network: Maelstrom routes messages via a simulated network, allowing for controlled experimentation with latency, message loss, and network partitions.
    • Fault Injection: Uses a 'nemesis' to inject faults like network partitions, killing/restarting nodes, or pausing processes.
    • Analysis Tools: Generates timeline visualizations, statistics, timeseries graphs, and Lamport diagrams to help understand concurrency and consistency anomalies.
  2. Use the maelstrom-go library to implement a Maelstrom node

    main
    The maelstrom-go package provides a Go implementation of a Maelstrom Node. It includes basic message handling, an event loop, and a client interface to the key/value store, helping developers avoid boilerplate when implementing new Maelstrom nodes.
  3. Understand the Broadcast workload API

    main

    The broadcast workload allows you to build a system that shares messages across a cluster. The API consists of three primary message types:

    • topology: Informs the node of the network topology (a map of node IDs to their neighbors).
    • broadcast: A request to send a message into the network.
    • read: A request to read all messages currently present on a single node.

    Maelstrom verifies the system by ensuring every broadcast message is eventually present on every node and tracks delivery latency and message counts.

  4. Understand Maelstrom workload semantics

    main

    A workload in Maelstrom defines the operational semantics of a distributed system being tested. It specifies:

    • Operations: The specific actions performed by the system.
    • Client Behavior: How clients submit requests and how they mix different operation types (e.g., mixing broadcast and read operations).
    • Request/Response Semantics: What requests mean, what responses are expected, and which errors are permissible.
    • Safety Checking: How Maelstrom validates the resulting history (e.g., checking for message loss or convergence time).

    When implementing a node for a specific workload, you must handle the RPC message structures defined by that workload's semantics.

  5. Broadcast Vote Requests

    main

    When a node becomes a candidate, it must broadcast a request_vote RPC to all other nodes in the cluster.

    The request_vote message body should include:

    • type: 'request_vote'
    • term: The candidate's current term
    • candidate_id: The ID of the candidate
    • last_log_index: The index of the candidate's last log entry
    • last_log_term: The term of the candidate's last log entry

    Nodes should also implement a maybe_step_down!(remote_term) check: if a received message contains a term higher than the current term, the node must advance its term and transition to the :follower state.

    # Request that other nodes vote for us as a leader.
    def request_votes!
      @lock.synchronize do
        votes = Set.new [@node.node_id]
        term = @term
    
        @node.brpc!(
          type: 'request_vote',
          term: term,
          candidate_id: @node.node_id,
          last_log_index: @log.size,
          last_log_term: @log.last[:term]
        ) do |res|
          @lock.synchronize do
            body = res[:body]
            maybe_step_down! body[:term]
            if @state == :candidate && @term == term && @term == body[:term] && body[:vote_granted]
              votes << res[:src]
              @node.log "Have votes: #{votes}"
            end
          end
        end
      end
    end
  6. Replicate logs as a Raft leader

    main

    Leaders must periodically replicate unacknowledged log entries to followers using append_entries RPCs. This process also serves as a heartbeat mechanism.

    Replication Logic

    1. Check Timing: Only replicate if the node is a :leader and @min_replication_interval has elapsed.
    2. Determine Entries: For each peer, determine the next index to send (@next_index[node]) and retrieve entries from the log using Log#from_index.
    3. Send RPC: If there are new entries or if the @heartbeat_interval has passed, send an append_entries RPC containing:
      • type: 'append_entries'
      • term: current term
      • leader_id: current node ID
      • prev_log_index: ni - 1
      • prev_log_term: term of the entry at ni - 1
      • entries: the slice of log entries
      • leader_commit: current @commit_index
    4. Handle Response:
      • If success is true: Advance @next_index[node] and @match_index[node] to the new end of the replicated segment.
      • If success is false: Decrement @next_index[node] and retry.
      • If the response contains a higher term, the leader must step down.
      def replicate_log!(force)
        @lock.synchronize do
          elapsed_time = Time.now - @last_replication
          replicated = false
          term = @term
    
          if @state == :leader and @min_replication_interval < elapsed_time
            @node.other_node_ids.each do |node|
              ni = @next_index[node]
              entries = @log.from_index ni
    
              if 0 < entries.size or @heartbeat_interval < elapsed_time
                @node.log "Replicating #{ni}+ to #{node}"
                replicated = true
    
                @node.rpc!(node, {
                  type:           'append_entries',
                  term:           @term,
                  leader_id:      @node.node_id,
                  prev_log_index: ni - 1,
                  prev_log_term:  @log[ni - 1][:term],
                  entries:        entries,
                  leader_commit:  @commit_index
                }) do |res|
                  body = res[:body]
                  @lock.synchronize do
                    maybe_step_down! body[:term]
                    if @state == :leader and body[:term] == @term
                      reset_step_down_deadline!
                      if body[:success]
                        @next_index[node] = [@next_index[node], (ni + entries.size)].max
                        @match_index[node] = [@match_index[node], (ni + entries.size - 1)].max
                        @node.log "Next index: #{@next_index}"
                      else
                        @next_index[node] -= 1
                      end
                    end
                  end
                end
              end
            end
          end
    
          if replicated
            @last_replication = Time.now
          end
        end
      end
  7. Implement a Maelstrom Node

    main

    To write a distributed system node for Maelstrom, your program must follow these communication rules:

    • Input: Read network messages as JSON from STDIN.
    • Output: Write network messages as JSON to STDOUT.
    • Logging: Write all logs to STDERR.

    Because Maelstrom communicates via standard streams, you can implement nodes in any language (Bash, Rust, Go, Python, etc.).

  8. Implement request proxying to leaders in Raft

    main

    To improve request success rates, implement a mechanism where nodes proxy client requests to the known leader instead of failing.

    1. Maintain a @leader variable in the Raft state.
    2. Reset @leader to nil on state transitions (e.g., become_follower!, become_candidate!, become_leader!).
    3. Update @leader when receiving an append_entries request from a valid leader.
    4. In the client request handler (client_req!), if the current node is not the leader but @leader is set, use @node.rpc! to proxy the request to the leader.
    # Example of proxying logic in client_req!
    def client_req!(msg)
      @lock.synchronize do
        if @state == :leader
          # ... handle as leader
        elsif @leader
          # Proxy to the known leader
          @node.rpc! @leader, msg[:body] do |res|
            @node.reply! msg, res[:body]
          end
        else
          raise RPCError.temporarily_unavailable "not a leader"
        end
      end
    end
  9. Build the C++ Maelstrom Node implementation

    main

    The C++ implementation requires the Boost C++ Libraries (specifically Boost.JSON) to be installed on your system. You can build the project using the provided Makefile.

    Note: You may need to update the hard-coded Boost header and library paths in the Makefile to match your system's configuration. Specifically, check the CXXFLAGS and LDFLAGS variables.

    Key Makefile variables:

    • CXX: The C++ compiler (defaults to g++).
    • CXXFLAGS: Compiler flags (includes -std=c++17 and Boost header paths).
    • LDFLAGS: Linker flags (includes Boost library paths).
    • DEBUG: Set to 1 to enable debug flags.
  10. Locate and access Maelstrom test results

    main

    Test results are stored in the store/<test-name>/<timestamp>/ directory.

    To quickly find relevant directories, use these symlinks:

    • store/latest: Points to the most recently completed test.
    • store/current: Points to the currently executing or most recently completed test.

    You can view results via the command line, a file explorer, or by launching Maelstrom's built-in web server.

    java -jar maelstrom.jar serve
    # or if using lein
    lein run serve
  11. Simulate network partitions with Maelstrom nemesis

    main

    To test how your system handles network failures, you can use the --nemesis flag with the maelstrom test command. Using partition will simulate periods where messages are lost between specific nodes, which can lead to inconsistent state across your cluster.

    Example command to run a broadcast test with a partition nemesis:

    ./maelstrom test -w broadcast --bin broadcast.rb --time-limit 20 --nemesis partition
    $ ./maelstrom test -w broadcast --bin broadcast.rb --time-limit 20 --nemesis partition
  12. Handle the Maelstrom initialization sequence

    main

    At the start of a test, Maelstrom sends an init message to every node. Your node must process this message to learn its own identity and the cluster topology.

    1. Receive init: The message contains node_id (your ID) and node_ids (the list of all nodes in the cluster).
    2. Store Identity: You must remember your node_id and use it as the src in all subsequent messages you send.
    3. Respond with init_ok: You must respond to the init message with a message of type init_ok containing the in_reply_to field matching the msg_id of the init message.

    Example init message:

    {
      "type":     "init",
      "msg_id":   1,
      "node_id":  "n3",
      "node_ids": ["n1", "n2", "n3"]
    }

    Example init_ok response:

    {
      "type":        "init_ok",
      "in_reply_to": 1
    }
    {
      "type":     "init",
      "msg_id":   1,
      "node_id":  "n3",
      "node_ids": ["n1", "n2", "n3"]
    }