Create a New Rust Project with Cargo

Mục tiêu: Initialize a new Rust binary application named ‘distributed-kv’ using the ‘cargo new’ command. This task creates the basic project structure, including the ‘Cargo.toml’ manifest file and the ‘src/main.rs’ entry point.


Welcome to the first step of building your Distributed Key-Value Store in Rust! Every great project starts with a solid foundation, and for a Rust project, that foundation is created using cargo, the official build tool and package manager. Let’s create the skeleton for our application.

Understanding cargo

Before we type the command, it’s important to understand the tool we’re using. cargo is to Rust what npm is to Node.js or maven is to Java. It handles a wide range of tasks for you, including:

  • Project Scaffolding: Creating a standard directory structure for new projects.
  • Dependency Management: Downloading and compiling the external libraries (called “crates” in Rust) your project needs.
  • Building and Compilation: Compiling your code into an executable binary or a library.
  • Testing: Running the unit and integration tests you write.
  • And much more: Running code, generating documentation, publishing your own crates, etc.

By using cargo, we ensure our project follows community-standard conventions, making it easier for others (and our future selves) to understand and contribute to.

Creating the Project

To create a new project, we use the cargo new command. This command sets up a new directory with all the necessary files to get started. Open your terminal and run the following command:

cargo new distributed-kv

After you run this, cargo will print a confirmation message:

Created binary (application) `distributed-kv` package

Let’s break down that command:

  • cargo: The Rust build tool.
  • new: The subcommand to create a new Rust package.
  • distributed-kv: The name of our project. Cargo will create a directory with this name.

cargo has created a “binary (application)” package by default. This is exactly what we need, as we are building a server that can be executed directly. The alternative is a “library” package, which is intended to be included as a dependency in other projects.

Exploring the Project Structure

Now, let’s look at what cargo has created for us. Navigate into the new directory:

cd distributed-kv

If you list the files inside, you will see the following structure:

.
├── .gitignore
├── Cargo.toml
└── src
    └── main.rs

Let’s examine each part:

  • .gitignore: A standard Git ignore file. cargo thoughtfully includes this, pre-populated with common files that shouldn’t be checked into version control, like the target directory where compiled artifacts are stored.
  • src/main.rs: This is the heart of our application, the crate root. For a binary package, main.rs is the entry point. It already contains a simple “Hello, world!” program:
// src/main.rs

fn main() {
    println!("Hello, world!");
}
  • Cargo.toml: This is the manifest file for our Rust package, written in the TOML (Tom’s Obvious, Minimal Language) format. It contains metadata and a list of dependencies for our project. It’s the central configuration file that cargo uses. Its initial content will look like this:
# Cargo.toml

[package]
name = "distributed-kv"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
  • The [package] section contains basic information about our project. The edition field specifies which “edition” of the Rust language our code uses. Editions are a mechanism to introduce new features without breaking older code. 2021 is the latest edition.
  • The [dependencies] section is where we will list all the external crates our project needs. It is currently empty.

Verifying the Setup

To ensure everything is working correctly, let’s compile and run our newly created program. From inside the distributed-kv directory, run:

cargo run

cargo will first compile the project (you’ll see a line like Compiling distributed-kv v0.1.0 (...)) and then run the resulting executable. The output should be:

Hello, world!

Seeing this message confirms that your Rust installation is working and the project has been initialized successfully. This simple program is the seed from which our powerful distributed server will grow.

What’s Next?

We have successfully laid the groundwork for our project. The next task is to add the necessary dependencies to our Cargo.toml file. These crates will provide us with the tools for asynchronous programming (tokio), data serialization/deserialization (serde, bincode), and robust error handling (anyhow), which are all essential for the system we’re about to build.

Further Reading

Add tokio, serde, bincode, and anyhow as dependencies in Cargo.toml.

Mục tiêu:


Excellent! You’ve successfully initialized your Rust project and have a clean slate to begin building. As we saw in the last task, the Cargo.toml file is the manifest for our project, and its [dependencies] section is where we declare the external libraries, or “crates,” that our project will rely on.

Now, it’s time to pull in the essential tools that will form the backbone of our distributed key-value store. We won’t be building everything from the ground up; instead, we’ll leverage the powerful Rust ecosystem to handle complex tasks like asynchronous networking and data serialization.

Why We Need Dependencies

Writing a networked application involves many solved problems: How do you handle thousands of connections concurrently without grinding to a halt? How do you reliably send complex data structures over a network stream of bytes? How do you manage errors gracefully?

The Rust community has produced high-quality crates to solve these very problems. By adding them as dependencies, we can focus on the unique logic of our distributed system rather than reinventing the wheel. cargo makes this process incredibly simple.

Let’s add our four key dependencies to the Cargo.toml file and explore the role each one will play.

The Four Pillars of Our Application

Open your Cargo.toml file. It should currently look like this:

[package]
name = "distributed-kv"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

We will add our dependencies under the [dependencies] section. Here is the updated file:

[package]
name = "distributed-kv"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
anyhow = "1.0"
bincode = "1.3"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

Let’s break down what each of these crates does and why it’s essential for our project.

1. tokio: The Asynchronous Runtime

  • What it is: tokio is the de-facto standard for writing asynchronous applications in Rust. An “asynchronous runtime” provides the machinery needed to run async code, managing tasks, I/O events, and timers efficiently.
  • Why we need it: Our server needs to handle multiple network connections simultaneously. A traditional approach might use one operating system thread per connection, which is resource-intensive and doesn’t scale well. tokio allows us to handle many connections concurrently on a small number of threads using non-blocking I/O. This is the key to building a high-performance network service. We will use tokio for everything related to networking, like creating our TCP server (TcpListener), handling connections (TcpStream), and running concurrent tasks (tokio::spawn).
  • The features = ["full"] flag: tokio is a large library with many optional components. To keep compile times fast and binary sizes small, you can enable only the features you need. The "full" feature flag is a convenient shortcut that enables everything, including networking, multi-threaded scheduler, timers, and I/O utilities, all of which we will need for this project.

2. serde: The Serialization/Deserialization Framework

  • What it is: serde (short for Serialization/Deserialization) is a framework for converting Rust data structures into a format that can be stored or transmitted (like bytes for the network) and then converting them back.
  • Why we need it: Our server nodes and clients need a common language to communicate. We will define commands like Set { key, value } in Rust structs. To send such a command over the network, we must first serialize it into a stream of bytes. The receiving end will then deserialize those bytes back into the original Rust struct. serde provides the generic framework to do this seamlessly.
  • The features = ["derive"] flag: This is one of serde’s most powerful features. It enables a procedural macro that lets us add #[derive(Serialize, Deserialize)] on top of our structs and enums. With this single line of code, serde will automatically generate all the code required to serialize and deserialize that data structure, saving us from writing a lot of tedious and error-prone boilerplate.

3. bincode: The Data Format

  • What it is: While serde provides the framework, it doesn’t dictate a specific data format. You can use it with many formats like JSON, YAML, or others. bincode is a crate that implements a compact binary format that plugs into serde.
  • Why we need it: For machine-to-machine communication over a network, text-based formats like JSON are often unnecessarily verbose and slow to parse. bincode is a perfect fit for our needs because it’s extremely fast and produces a very compact representation of our data. Since all our nodes will be written in Rust, we don’t need a human-readable format, so we can prioritize performance.

4. anyhow: Ergonomic Error Handling

  • What it is: anyhow is a library designed to make error handling simpler and more ergonomic. It provides a universal anyhow::Error type that can wrap any error, along with a anyhow::Result<T> type alias.
  • Why we need it: In a complex project, functions can fail for many different reasons: network I/O errors, serialization errors, logic errors, etc. Without anyhow, you often end up writing lots of code to convert between different error types or defining complex custom error enums. anyhow simplifies this by allowing any function that can fail to return an anyhow::Result. It makes propagating errors up the call stack trivial while still preserving the original error context for logging and debugging.

What Happens Next?

Now that you’ve updated Cargo.toml, the next time you run a command like cargo build or cargo run, cargo will perform the following steps:

  1. Read the [dependencies] list in Cargo.toml.
  2. Download the specified versions of tokio, serde, bincode, and anyhow (and all of their dependencies) from the official Rust crate registry, crates.io.
  3. Compile all these downloaded crates.
  4. It will also create a Cargo.lock file. This file records the exact version of every crate used in the build. This ensures that anyone else who builds your project (or you, on a different machine) will use the exact same dependency versions, guaranteeing a reproducible build. You should commit Cargo.lock to your version control system.

You don’t need to run any command just yet. We’ll build the project in a later task.

On to the Next Task!

With our core dependencies in place, we are now ready to start writing the heart of our key-value store. The next task is to create the KvStore struct, which will be the central component for storing our data.

Further Reading

To deepen your understanding of the tools we’ve just added, I highly recommend exploring their official documentation:

Implement the Core KvStore Data Structure

Mục tiêu: Define the KvStore struct, the core data structure for our in-memory key-value store. This struct will encapsulate a HashMap to manage the storage of keys and values.


Having successfully configured our project’s dependencies, we’ve laid the essential groundwork. Now, we can transition from setup to actual implementation. The very first piece of our application we’ll build is its heart: the data store itself. We’ll create a structure that will be responsible for holding all our keys and values in memory.

Introducing the KvStore Struct

In Rust, a struct (short for structure) is a custom data type that lets you package together and name multiple related values that make up a meaningful group. For our project, we need a structure to represent the entire key-value store. We’ll call it KvStore.

This KvStore will, for now, be a simple wrapper around the standard library’s HashMap. This practice is known as encapsulation. Instead of having various parts of our program interact directly with a raw HashMap, they will interact with our KvStore. This is a powerful design principle because it hides the internal implementation details. If we later decide to change from a HashMap to a more complex data structure (like a B-Tree for sorted keys), we would only need to change the internals of KvStore; the rest of our application’s code that uses KvStore wouldn’t need to change at all.

The Power of HashMap

A HashMap<K, V> is Rust’s primary implementation of a hash map data structure. It stores a mapping of keys of type K to values of type V. Here’s why it’s the perfect choice for our initial implementation:

  • Fast Lookups: On average, a HashMap can retrieve a value for a given key in O(1), or constant time. This means that the time it takes to find an item is independent of the number of items in the map, making it extremely fast and scalable.
  • Key-Value Pairs: It’s designed specifically for the key-value storage model that is the entire premise of our project.
  • Generic: HashMap is generic over its key and value types. For our store, we will use String for both, creating a HashMap<String, String>. This is a versatile and straightforward choice for storing arbitrary string data.

Implementing the KvStore

Let’s modify our src/main.rs file to define this new struct. We will place it above the main function.

First, we need to tell our program that we want to use the HashMap type, which lives in Rust’s standard collection library. We do this with a use statement at the top of the file. Then, we define the KvStore struct containing a single field, which we’ll call map, of type HashMap<String, String>.

Here is the updated content for src/main.rs:

// src/main.rs

// Import the HashMap data structure from the standard library's collections module.
use std::collections::HashMap;

/// Represents the in-memory key-value store.
/// It encapsulates a HashMap to store the actual data.
struct KvStore {
    /// The `map` field will hold our key-value pairs.
    /// Both keys and values are of type `String`.
    map: HashMap<String, String>,
}

fn main() {
    println!("Hello, world!");
}

Let’s break down this small but significant change:

  1. use std::collections::HashMap;: This line brings the HashMap type into the current scope, allowing us to use it without having to write its full path (std::collections::HashMap) every time.
  2. struct KvStore { ... }: This defines our new custom type, KvStore.
  3. map: HashMap<String, String>,: This declares a field named map inside our struct. The type of this field is HashMap<String, String>, meaning it’s a hash map where both the keys and the values must be of the String type.

With this simple definition, we now have a blueprint for our key-value store. We can create instances of KvStore, and each instance will have its own internal HashMap to store data.

The Road Ahead: Preparing for Concurrency

While this is a great start, our current KvStore has a major limitation: it is not “thread-safe.” Our server will eventually handle multiple client connections at the same time, each running in a separate asynchronous task (which can be thought of as a lightweight thread).

What happens if two tasks try to write to the map at the exact same moment? This situation is called a data race, and it can lead to memory corruption and unpredictable behavior. Fortunately, Rust’s strict compiler will prevent us from even compiling code that could lead to a data race when sharing data across threads.

This is where the next task becomes crucial. We will wrap our HashMap in two special types provided by Rust’s standard library: Arc and RwLock. These “smart pointers” will allow us to share our KvStore safely across multiple tasks, ensuring that only one task can write to the data at a time, while multiple tasks can read it simultaneously. This is the foundation for building a robust, concurrent server.

Further Reading

Make KvStore Thread-Safe with Arc and RwLock

Mục tiêu: Refactor the KvStore struct to support concurrent access by wrapping its internal HashMap with Arc and RwLock, preparing it for a multi-tasked server environment.


Excellent work defining the basic KvStore struct. You’ve created the logical container for our data. However, as we look ahead to building a network server, we must confront a fundamental challenge in systems programming: concurrency.

Our server will eventually use tokio to handle many client connections simultaneously, each running in its own asynchronous task. If multiple tasks try to access our HashMap at the same time, we could face disastrous problems like data races, leading to corrupted data or crashes. Rust’s powerful ownership system and type safety will actually prevent us from compiling such unsafe code. To solve this, we need to explicitly tell the compiler how we intend to share data safely between tasks.

This is where two of Rust’s most important concurrency primitives come into play: Arc and RwLock. Let’s wrap our HashMap in these smart pointers to make it ready for a multi-tasked environment.

Understanding the Concurrency Primitives

Arc: The Atomic Reference Counter

Imagine you have a single piece of data (our HashMap) and you want to give access to it to multiple independent tasks. Who “owns” the data? If one task finishes, should the data be destroyed, even if other tasks are still using it? This is a classic ownership problem.

Arc<T>, short for Atomically Reference Counted pointer, solves this. It’s a “smart pointer” that enables shared ownership of a value of type T.

  • Shared Ownership: You can clone an Arc, which doesn’t copy the data inside it. Instead, it creates a new pointer to the same data on the heap and increments an internal counter.
  • Atomic: The “Atomic” part is crucial. It means that the reference count is incremented and decremented in a way that is safe to do from multiple threads (or async tasks) at the same time, without corrupting the count.
  • Lifetime Management: The data wrapped by the Arc is only deallocated and cleaned up when the very last Arc pointer to it goes out of scope. This ensures the data lives as long as at least one task needs it.

By wrapping our HashMap in an Arc, we can give each async task a clone of the Arc, allowing all of them to share access to the same HashMap instance in memory.

RwLock: The Read-Write Lock

While Arc lets us share the HashMap, it only gives us shared immutable access. We can’t change the HashMap through an Arc because that would be unsafe. What if two tasks tried to write a value for the same key at the exact same time?

This is the problem that RwLock<T>, or a Read-Write Lock, is designed to solve. It provides a mechanism for mutual exclusion, ensuring that access to the data is synchronized. It follows a specific set of rules:

  • Many Readers: Any number of tasks can acquire a “read lock” simultaneously. As long as they are only reading the data, there’s no conflict.
  • OR One Writer: To modify the data, a task must acquire a “write lock”. The RwLock will only grant a write lock when no other locks (read or write) are held. While a write lock is active, all other tasks that try to get any kind of lock will be forced to wait.

This “many readers or one writer” pattern is a perfect performance optimization for a key-value store, where you typically expect far more read operations (get) than write operations (set, delete). It allows high concurrency for reads while guaranteeing safety for writes.

Combining Arc and RwLock

When we put these two types together, we get Arc<RwLock<HashMap<String, String>>>. Let’s break down this nested structure from the inside out:

  1. HashMap<String, String>: This is our raw data store.
  2. RwLock<...>: This wraps the HashMap, enforcing the “many readers or one writer” access policy. It protects the data from concurrent modification.
  3. Arc<...>: This wraps the RwLock, allowing multiple tasks to safely share ownership of the lock and the data it protects.

This combination is a canonical pattern in concurrent Rust programming for sharing mutable state.

Updating the KvStore Struct

Let’s modify src/main.rs to reflect this new, thread-safe design. We need to import Arc and RwLock from the standard library’s sync module and update the type of the map field.

// src/main.rs

use std::collections::HashMap;
// Add the following `use` statement to bring Arc and RwLock into scope.
use std::sync::{Arc, RwLock};

/// Represents the in-memory key-value store.
/// It encapsulates a HashMap protected for concurrent access.
struct KvStore {
    /// The `map` field is now wrapped in `Arc` and `RwLock` to allow for
    // thread-safe, shared, mutable access.
    map: Arc<RwLock<HashMap<String, String>>>,
}

fn main() {
    println!("Hello, world!");
}

By making this change, we’ve fundamentally transformed KvStore from a simple data structure into a robust, concurrency-ready component that can serve as the backbone of our networked server.

What’s Next?

Our KvStore struct now holds a more complex type. We can no longer create an instance of it as easily as before. The next logical step is to implement an associated function, often called new(), that will handle the details of properly initializing the Arc, RwLock, and the inner HashMap. This will provide a clean and simple way for our application to create a new, empty store.

Further Reading

Implement a Constructor for the KvStore Struct

Mục tiêu: Create a ‘new’ associated function for the KvStore struct to encapsulate its complex initialization logic involving Arc, RwLock, and HashMap, following Rust’s standard constructor pattern.


In the previous task, we fortified our KvStore struct for the world of concurrency by wrapping our HashMap in an Arc<RwLock<...>>. This is a powerful pattern, but it introduces initialization complexity. Creating a new instance is no longer a simple struct instantiation; it involves nesting constructors: Arc::new(RwLock::new(HashMap::new())).

To manage this complexity and provide a clean, user-friendly API for our own code, we will follow a standard Rust convention: creating an associated function named new to act as a constructor. This encapsulates the setup logic, ensuring that a KvStore is always created correctly.

Implementing a Constructor with impl

In Rust, we add functionality to a struct by using an impl (implementation) block. This is where we’ll define all the methods and associated functions related to KvStore.

An associated function is a function that is associated with a type. It’s like a static method in languages like Java or C#. It doesn’t take self as a parameter because it’s called on the type itself (e.g., KvStore::new()), not on an instance of the type. new is the most common name for an associated function that creates a new instance of a struct.

Let’s add an impl block for KvStore and define our new function within it.

Here are the changes for your src/main.rs file:

// src/main.rs

use std::collections::HashMap;
use std::sync::{Arc, RwLock};

/// Represents the in-memory key-value store.
/// It encapsulates a HashMap protected for concurrent access.
struct KvStore {
    /// The `map` field is now wrapped in `Arc` and `RwLock` to allow for
    // thread-safe, shared, mutable access.
    map: Arc<RwLock<HashMap<String, String>>>,
}

// Add the following implementation block for KvStore.
impl KvStore {
    /// Creates a new, empty `KvStore`.
    ///
    /// This function initializes the internal HashMap, wraps it in a RwLock for
    /// synchronized access, and then wraps that in an Arc to allow for shared
    /// ownership across multiple tasks.
    pub fn new() -> Self {
        KvStore {
            map: Arc::new(RwLock::new(HashMap::new())),
        }
    }
}

fn main() {
    // You could now create an instance like this (though we're not using it yet):
    // let store = KvStore::new();
    println!("Hello, world!");
}

Deconstructing the new Function

Let’s break down this new impl block line by line:

  1. impl KvStore { ... }: This line opens an implementation block for our KvStore struct. All the functions defined inside this block will be associated with KvStore.
  2. pub fn new() -> Self: This is the function signature.

    • pub: This is short for “public”. It makes the new function accessible from outside the current module. While everything is in one file for now, this is a crucial best practice for when we organize our code into separate files later.
    • fn new(): This defines a function named new that takes no arguments.
    • -> Self: This specifies the return type. Self (with a capital ‘S’) is a special keyword in an impl block that is an alias for the type the block is for. So, in this context, -> Self is exactly the same as -> KvStore. It signifies that this function returns a new instance of KvStore.
  3. KvStore { ... }: This is the body of the function. We are constructing and returning a KvStore instance directly.
  4. map: Arc::new(RwLock::new(HashMap::new())): This is the core logic where the initialization happens. It’s executed from the inside out:

    • HashMap::new(): First, a new, empty HashMap is created.
    • RwLock::new(...): The new HashMap is then passed to the RwLock constructor, which wraps it and returns a RwLock<HashMap<String, String>>. This lock will protect the map from concurrent writes.
    • Arc::new(...): Finally, the RwLock is passed to the Arc constructor, which wraps it in an atomic reference counter. This allows the lock (and the data it protects) to be safely shared across multiple owners (our future async tasks).
    • The resulting Arc<RwLock<HashMap<String, String>>> is assigned to the map field of our new KvStore instance.

By creating this new function, we’ve established a clean and reliable entry point for creating our store. Any other part of our program can now simply call KvStore::new() without needing to worry about the complex, nested structure inside. This is a perfect example of encapsulation in action.

What’s Next?

We now have a struct and a way to create an instance of it. The next logical step is to add behavior. We’ll start by implementing the get method. This will be our first look at how to interact with the data protected by the RwLock, specifically by acquiring a read lock to safely retrieve a value.

Further Reading

Implement the get(key: &str) method which acquires a read lock and retrieves a value.

Mục tiêu:


Having established a constructor with KvStore::new(), we now have a way to create an empty, thread-safe store. The next logical step is to add behavior to it. Let’s start with the most fundamental operation of any key-value store: retrieving data. We will implement a get method that allows a user to look up a value by its key.

This task is our first foray into interacting with the data protected by the RwLock. We’ll see how to acquire a “read lock” to safely access the underlying HashMap without preventing other clients from reading at the same time.

Methods vs. Associated Functions

In the last task, we created an associated function (new). It’s associated with the KvStore type itself (KvStore::new()). Now, we’re creating a method. A method is a function that operates on a specific instance of a struct. The key difference in Rust is that a method’s first parameter is always self, &self, or &mut self, which represents the instance the method is being called on.

Our get method will take &self as its first parameter, indicating that it borrows the KvStore instance immutably. This is appropriate because getting a value doesn’t need to change the store itself.

The Art of Acquiring a Read Lock

To access the HashMap hidden inside our Arc<RwLock<...>>, we must first ask the RwLock for permission. Since we only want to read the data, we’ll request a read lock. This is done by calling the .read() method on the RwLock.

The .read() method doesn’t return a direct reference to the HashMap. Instead, it returns a Result<RwLockReadGuard<...>, ...>.

  • Why a Result? The operation can fail. What if another task that had a write lock panicked (i.e., crashed unexpectedly)? The data inside the lock could be in an inconsistent, corrupted state. To prevent other tasks from accessing this potentially corrupt data, the lock becomes “poisoned.” If you try to acquire a lock that has been poisoned, the .read() or .write() call will return an Err. For our application, if the central data store gets poisoned, it’s a catastrophic failure, and the simplest, safest thing to do is to stop the program. We can achieve this by calling .unwrap() on the Result, which will give us the value if it’s Ok or panic if it’s Err.
  • What is an RwLockReadGuard? On a successful lock, we get back a “read guard.” This is a special smart pointer that serves two critical purposes:

    1. Dereferencing: It implements the Deref trait, which means you can use it just as if it were a direct reference to the HashMap inside. When you call .get(key) on the guard, Rust automatically dereferences the guard and calls the method on the HashMap.
    2. RAII (Resource Acquisition Is Initialization): This is the most brilliant part. The read lock is held for as long as the RwLockReadGuard exists. When the guard goes out of scope at the end of our get method, its destructor is automatically called, which releases the lock. This is a core Rust safety feature. It makes it impossible to forget to release a lock, a common and dangerous bug in other languages.

Implementing the get Method

Let’s add the get method to our impl block in src/main.rs.

// In src/main.rs

// ... (use statements and KvStore struct definition remain the same)

impl KvStore {
    /// Creates a new, empty `KvStore`.
    pub fn new() -> Self {
        KvStore {
            map: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    /// Retrieves the value associated with a given key.
    ///
    /// This method acquires a read lock on the internal map. It returns `Some(String)`
    /// if the key exists, and `None` if it does not.
    ///
    /// # Arguments
    ///
    /// * `key` - A string slice that holds the key of the value to retrieve.
    pub fn get(&self, key: &str) -> Option<String> {
        // 1. Acquire a read lock on the RwLock.
        //    .read() returns a Result, which we .unwrap(). Panicking on a poisoned
        //    lock is an acceptable strategy for this project. The result is an
        //    `RwLockReadGuard`.
        let map_guard = self.map.read().unwrap();

        // 2. The guard acts like a reference to the HashMap. We can call HashMap methods on it.
        //    `map_guard.get(key)` returns an `Option<&String>`.
        //
        // 3. We cannot return a reference (`&String`) from this function because the
        //    reference is tied to the lifetime of `map_guard`. The guard, and thus
        //    the lock, is released when this function ends.
        //
        // 4. To solve this, we `clone()` the value. This creates a new, owned `String`
        //    that the caller can own without worrying about the lock's lifetime.
        //    The `map` method on `Option` is a convenient way to apply a function
        //    (like `clone`) to the value inside a `Some`, while doing nothing for a `None`.
        map_guard.get(key).cloned()
    }
}

// ... (main function remains the same)

Code Walkthrough

  1. pub fn get(&self, key: &str) -> Option<String>: We define a public method get.

    • It takes &self to borrow the KvStore instance.
    • It takes a key of type &str. This is more flexible than String as it allows callers to pass string literals or slices without needing to create a new String.
    • It returns an Option<String>. It’s Option because the key might not exist. It’s String (an owned value) rather than &String (a reference) because the value we find inside the map is protected by the RwLockReadGuard. That guard will be dropped at the end of this function, releasing the lock. If we returned a reference, it would point to memory that is no longer safely accessible—a “dangling reference” that Rust’s borrow checker correctly forbids. Therefore, we must return a copy of the value.
  2. let map_guard = self.map.read().unwrap();: This is where we acquire the lock. self.map accesses the Arc, which is transparently dereferenced to the RwLock. We call .read(), and then .unwrap() to get the guard or panic.
  3. map_guard.get(key).cloned(): This is the lookup and cloning logic, chained together in an idiomatic Rust way.

    • map_guard.get(key): We call the HashMap’s get method on our guard. This returns an Option<&String>.
    • .cloned(): This is a handy method on Option. It’s equivalent to map(|s| s.clone()). If the Option is Some(&String), it clones the inner value to produce a Some(String). If the Option is None, it remains None. This elegantly handles both the cloning and the “key not found” case in a single, expressive line.

What’s Next?

You have successfully implemented the read path for your key-value store! Now that clients can retrieve data, the next logical step is to provide a way to put data into the store. We’ll implement the set method next, which will involve acquiring a write lock—a more exclusive type of lock that ensures only one client can modify the data at a time.

Further Reading

Implement the set(key: String, value: String) method which acquires a write lock to insert or update a value.

Mục tiêu:


Excellent! You’ve successfully implemented the get method, which handles the read path of our key-value store. You learned how to acquire a non-exclusive read lock using RwLock::read() to allow multiple concurrent readers. Now, it’s time to tackle the other side of the coin: writing data to the store.

We will now implement the set method. This operation is fundamentally different from get because it modifies the underlying data. To prevent data corruption, we must ensure that only one task can modify the HashMap at any given time. This requires us to acquire an exclusive write lock.

The Exclusive Write Lock

While a RwLock allows for any number of concurrent readers, it enforces a strict rule for writers: there can be only one writer at a time, and while a writer holds the lock, no readers are allowed. This is the core safety guarantee that prevents data races.

To get this exclusive lock, we will use the .write() method on our RwLock.

  • Acquisition: Calling self.map.write() will attempt to acquire the write lock. If other tasks are currently holding read locks or if another task holds the write lock, this call will block and wait until all other locks are released.
  • The Write Guard: Similar to how .read() returned an RwLockReadGuard, a successful call to .write() returns a Result containing an RwLockWriteGuard. We will again use .unwrap() to handle the Result, causing a panic if the lock is poisoned.
  • Mutable Access: The crucial difference is that an RwLockWriteGuard provides mutable access (&mut) to the data it protects. This allows us to call methods like insert on the HashMap to change its contents.
  • RAII for Safety: Just like the read guard, the RwLockWriteGuard adheres to the RAII principle. The write lock is held for as long as the guard exists. When the guard goes out of scope at the end of the method, the lock is automatically released, allowing other waiting readers or writers to proceed.

Implementing the set Method

Let’s add the set method to our impl KvStore block. It will take ownership of a key and a value and insert them into the map.

Here is the code to add to your impl block in src/main.rs:

// In src/main.rs -> impl KvStore

    /// Inserts a key-value pair into the store.
    ///
    /// This method acquires a write lock on the internal map. If the key already
    /// exists, its value is updated.
    ///
    /// # Arguments
    ///
    /// * `key` - The key, of type `String`.
    /// * `value` - The value, of type `String`.
    pub fn set(&self, key: String, value: String) {
        // 1. Acquire a write lock. The `mut` keyword is needed because we are
        //    going to modify the data protected by the lock. The guard provides
        //    mutable access to the HashMap.
        let mut map_guard = self.map.write().unwrap();

        // 2. The guard can now be used to call mutable methods on the HashMap,
        //    like `insert`. This takes ownership of the `key` and `value`.
        map_guard.insert(key, value);

        // 3. The lock is automatically released here when `map_guard` goes out of scope.
    }

Your full impl KvStore block should now look like this, with the changes highlighted:

impl KvStore {
    /// Creates a new, empty `KvStore`.
    pub fn new() -> Self {
        KvStore {
            map: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    /// Retrieves the value associated with a given key.
    pub fn get(&self, key: &str) -> Option<String> {
        let map_guard = self.map.read().unwrap();
        map_guard.get(key).cloned()
    }

/// Inserts a key-value pair into the store.
    ///
    /// This method acquires a write lock on the internal map. If the key already
    /// exists, its value is updated.
    ///
    /// # Arguments
    ///
    /// * `key` - The key, of type `String`.
    /// * `value` - The value, of type `String`.
    pub fn set(&self, key: String, value: String) {
        // 1. Acquire a write lock. The `mut` keyword is needed because we are
        //    going to modify the data protected by the lock. The guard provides
        //    mutable access to the HashMap.
        let mut map_guard = self.map.write().unwrap();

        // 2. The guard can now be used to call mutable methods on the HashMap,
        //    like `insert`. This takes ownership of the `key` and `value`.
        map_guard.insert(key, value);

        // 3. The lock is automatically released here when `map_guard` goes out of scope.
    }

}

Deconstructing the set Method

  1. pub fn set(&self, key: String, value: String):

    • Like get, this method takes &self because it operates on an instance of KvStore.
    • Unlike get, the key and value are of type String, not &str. This is a deliberate design choice. The HashMap::insert method needs to take ownership of the key and value to store them. By defining our function to take String, we are making it clear to the caller that ownership of the data will be transferred into the store.
    • The method returns () (the unit type), indicating that it doesn’t return any value upon success.
  2. let mut map_guard = self.map.write().unwrap();:

    • We call self.map.write() to request exclusive access.
    • We use let mut because the RwLockWriteGuard gives us mutable access to the underlying data, and to call mutable methods through the guard, the guard variable itself must be declared as mutable.
  3. map_guard.insert(key, value);:

    • This is where the magic happens. Because map_guard dereferences to a mutable reference (&mut HashMap<...>), we can call HashMap’s insert method on it.
    • This method inserts our key-value pair into the map. If the key already exists, the old value is overwritten with the new one. The key and value variables are moved into the HashMap, and their ownership is transferred.

With this method in place, we now have a safe and correct way to write data into our shared store, even in a highly concurrent environment.

What’s Next?

You have now implemented the read (get) and write (set) operations. The final core operation for our key-value store is to remove data. In the next task, you’ll implement the delete method, which will give you another opportunity to practice using a write lock to safely modify the store.

Further Reading

Implement the delete Method in a Rust Key-Value Store

Mục tiêu: Implement the delete method for a concurrent key-value store in Rust. This task involves using an RwLock to acquire an exclusive write lock, safely removing an entry from the internal HashMap, and returning the removed value.


Excellent work implementing the set method. You’ve now mastered how to acquire an exclusive write lock to safely modify the state of your key-value store. With the ability to get and set data, our KvStore is becoming quite functional. The final piece of the core functionality is the ability to remove data.

We will now implement the delete method. Conceptually, this operation is very similar to set because it also modifies the internal HashMap. Therefore, it will also require an exclusive write lock to ensure that the removal happens atomically and safely, without interference from any other concurrent operations.

The Logic of Deletion

Just like inserting a value, deleting a value is a mutable operation. The process will follow the exact same locking pattern we used for the set method:

  1. Request a Write Lock: We’ll call self.map.write() to ask for exclusive access to the data. This call will block until it’s safe to proceed (i.e., no other locks are held).
  2. Get a Mutable Guard: Upon success, we’ll get an RwLockWriteGuard. We must declare the variable holding the guard as mut to be able to call mutable methods through it.
  3. Perform the Operation: We’ll use the guard to call the HashMap’s remove method.
  4. Release the Lock: The lock will be released automatically when the guard goes out of scope at the end of the method, thanks to Rust’s RAII (Resource Acquisition Is Initialization) pattern.

The HashMap::remove method is the standard library function for this task. It takes a key as an argument and, if that key is present in the map, it removes the key and its associated value. A very useful feature of remove is that it returns the value that was removed, wrapped in an Option. If the key existed, it returns Some(value); if the key was not found in the map, it returns None. This is perfect for our delete method’s signature, as it allows the caller to know both whether the deletion was successful and what the old value was.

Implementing the delete Method

Let’s add the delete method to our impl KvStore block in src/main.rs.

Here is the code to add. Notice how closely it mirrors the structure of the set method, reinforcing the pattern for write operations.

// In src/main.rs -> impl KvStore

    /// Deletes a key-value pair from the store.
    ///
    /// This method acquires a write lock on the internal map. If the key exists,
    /// the key-value pair is removed, and the old value is returned.
    ///
    /// # Arguments
    ///
    /// * `key` - The key to delete, as a string slice.
    ///
    /// # Returns
    ///
    /// An `Option<String>` containing the value of the removed key, or `None` if
    /// the key did not exist.
    pub fn delete(&self, key: &str) -> Option<String> {
        // 1. Acquire an exclusive write lock, making the guard mutable.
        let mut map_guard = self.map.write().unwrap();

        // 2. Call the `remove` method on the HashMap via the mutable guard.
        //    The `remove` method itself returns `Option<String>`, which is exactly
        //    what we want to return from this function.
        map_guard.remove(key)

        // 3. The write lock is automatically released here as `map_guard` goes out of scope.
    }

For clarity, here is your complete impl KvStore block with the new delete method highlighted. You can see how the three core methods (get, set, delete) now provide a complete and safe API for interacting with the store.

impl KvStore {
    /// Creates a new, empty `KvStore`.
    pub fn new() -> Self {
        KvStore {
            map: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    /// Retrieves the value associated with a given key.
    pub fn get(&self, key: &str) -> Option<String> {
        let map_guard = self.map.read().unwrap();
        map_guard.get(key).cloned()
    }

    /// Inserts a key-value pair into the store.
    pub fn set(&self, key: String, value: String) {
        let mut map_guard = self.map.write().unwrap();
        map_guard.insert(key, value);
    }

/// Deletes a key-value pair from the store.
    ///
    /// This method acquires a write lock on the internal map. If the key exists,
    /// the key-value pair is removed, and the old value is returned.
    ///
    /// # Arguments
    ///
    /// * `key` - The key to delete, as a string slice.
    ///
    /// # Returns
    ///
    /// An `Option<String>` containing the value of the removed key, or `None` if
    /// the key did not exist.
    pub fn delete(&self, key: &str) -> Option<String> {
        // 1. Acquire an exclusive write lock, making the guard mutable.
        let mut map_guard = self.map.write().unwrap();

        // 2. Call the `remove` method on the HashMap via the mutable guard.
        //    The `remove` method itself returns `Option<String>`, which is exactly
        //    what we want to return from this function.
        map_guard.remove(key)

        // 3. The write lock is automatically released here as `map_guard` goes out of scope.
    }

}

Deconstructing the delete Method

  1. pub fn delete(&self, key: &str) -> Option<String>:

    • The method is public and takes an immutable reference to self.
    • The key is a &str. The HashMap::remove method is flexible and can perform a lookup using a borrowed key, which is more efficient than requiring an owned String.
    • It returns an Option<String>, directly passing through the result from HashMap::remove. This informs the caller if the key existed and what its value was.
  2. let mut map_guard = self.map.write().unwrap();: This line is identical in function to the one in set. It acquires the exclusive write lock required for any mutation of the map.
  3. map_guard.remove(key): This is the core logic. We call remove on the map_guard. The guard dereferences to a mutable reference to the HashMap, allowing this mutable operation. The Option<String> returned by remove is then returned directly from our delete function.

What’s Next?

Congratulations! You have now implemented all the fundamental CRUD (Create, Read, Update, Delete) operations for your in-memory, thread-safe key-value store. You have a solid, reusable component. However, how can we be sure it actually works as expected? Good software engineering practice dictates that we should write automated tests to verify the correctness of our code.

In the next and final task of this step, you will write unit tests for the get, set, and delete methods to ensure they are bug-free and behave exactly as intended.

Further Reading

Implement Unit Tests for the Rust KvStore

Mục tiêu: Add a test module and write unit tests to verify the get, set, and delete operations of the KvStore using Rust’s built-in testing framework.


Congratulations! You have successfully implemented the complete set of core operations for your key-value store: get, set, and delete. The KvStore is now functionally complete as an in-memory data structure. However, a crucial part of software development is verification. How do we prove that our code works correctly under various conditions? The answer is automated testing.

In this final task of the first step, we will write unit tests for our KvStore methods. This will give us confidence that our implementation is solid before we start building a network layer on top of it.

The Culture of Testing in Rust

Rust has a fantastic, built-in testing framework that is a first-class citizen of the language and its tooling. You don’t need to install any external test runners or assertion libraries; everything you need is provided out of the box.

Tests in Rust are typically placed in the same file as the code they are testing, inside a dedicated module. This has two major benefits:

  1. Colocation: Tests live right next to the code, making it easy to find and update them when the implementation changes.
  2. Private Access: It allows tests to access private functions and types within the module if needed, although we will only be testing our public API here.

This module is annotated with #[cfg(test)]. This is a conditional compilation attribute that tells the compiler, “Only compile this code when I’m running tests.” This ensures that your test code and any test-only dependencies are completely excluded from your final, optimized release build, resulting in a smaller and more efficient binary.

Setting Up the Test Module

Let’s add a new module named tests to the bottom of our src/main.rs file. This is where all our unit tests for KvStore will live.

Each test is just a regular Rust function that is annotated with the #[test] attribute. The test runner will execute any function with this attribute. Inside a test, we use assertion macros like assert_eq! (asserts that two values are equal) or assert! (asserts that a boolean expression is true) to check for correct behavior. If an assertion fails, the test fails, and cargo will report the failure.

Append the following code to the end of your src/main.rs file:

// This is a special attribute that tells the Rust compiler to only
// compile and run this module when we execute `cargo test`.
#[cfg(test)]
mod tests {
    // `use super::*;` brings all items from the parent module (the one this
    // module is defined in) into the current scope. In our case, it imports
    // the `KvStore` struct so we can use it in our tests.
    use super::*;

    /// A simple test to verify that setting a value and then getting it
    /// returns the same value.
    #[test]
    fn test_set_and_get() {
        // Arrange: Create a new, empty store.
        let store = KvStore::new();
        let key = "key1".to_string();
        let value = "value1".to_string();

        // Act: Set a key-value pair.
        store.set(key.clone(), value.clone());

        // Assert: Get the key and check if the value is correct.
        assert_eq!(store.get(&key), Some(value));
    }

    /// Tests that setting a new value for an existing key overwrites the old one.
    #[test]
    fn test_overwrite_value() {
        // Arrange
        let store = KvStore::new();
        let key = "key1".to_string();

        // Act: Set an initial value, then set a new value for the same key.
        store.set(key.clone(), "value1".to_string());
        store.set(key.clone(), "value2".to_string());

        // Assert: The get operation should return the new, overwritten value.
        assert_eq!(store.get(&key), Some("value2".to_string()));
    }

    /// Tests that getting a key that does not exist returns `None`.
    #[test]
    fn test_get_non_existent() {
        // Arrange: An empty store.
        let store = KvStore::new();

        // Act & Assert: Getting a key that was never set should result in None.
        assert_eq!(store.get("non_existent_key"), None);
    }

    /// Tests the full lifecycle: set, then delete, then get.
    #[test]
    fn test_delete_value() {
        // Arrange
        let store = KvStore::new();
        let key = "key1".to_string();
        let value = "value1".to_string();
        store.set(key.clone(), value.clone());

        // Act: Delete the key.
        let deleted_value = store.delete(&key);

        // Assert
        // 1. The delete operation should return the value that was removed.
        assert_eq!(deleted_value, Some(value));
        // 2. Getting the key after deletion should return None.
        assert_eq!(store.get(&key), None);
    }

    /// Tests that deleting a key that does not exist returns `None`.
    #[test]
    fn test_delete_non_existent() {
        // Arrange
        let store = KvStore::new();

        // Act & Assert: Deleting a key that was never set should return None.
        assert_eq!(store.delete("non_existent_key"), None);
    }
}

Deconstructing the Tests

  • #[cfg(test)] and mod tests { ... }: Defines our special test module.
  • use super::*;: A crucial line that imports everything from the parent module (which is our main file’s scope). Without this, the test module wouldn’t know what KvStore is.
  • #[test]: This attribute marks the following function as a test case. cargo test will find and run it.
  • Test Functions: We’ve created several small, focused tests, each verifying a specific piece of behavior:
    • test_set_and_get: The most basic “happy path” test.
    • test_overwrite_value: Ensures set correctly updates existing keys.
    • test_get_non_existent: Checks the behavior for a key that isn’t in the map.
    • test_delete_value: Verifies that delete removes the value and that get subsequently fails to find it.
    • test_delete_non_existent: Checks the edge case of deleting a key that isn’t there.
  • assert_eq!(left, right): This macro is the workhorse of our tests. It checks if the left and right arguments are equal. If they are not, it panics (which fails the test) and prints a helpful message showing the two values.

Running Your Tests

Now, open your terminal in the project’s root directory (distributed-kv) and run the test suite:

cargo test

cargo will compile your code (including the tests module) and then run all the functions marked with #[test]. If everything is correct, you should see output that looks something like this:

   Compiling distributed-kv v0.1.0 (/path/to/your/project/distributed-kv)
    Finished test [unoptimized + debuginfo] target(s) in 0.50s
     Running unittests (target/debug/deps/distributed_kv-...)

running 5 tests
test tests::test_delete_non_existent ... ok
test tests::test_delete_value ... ok
test tests::test_get_non_existent ... ok
test tests::test_overwrite_value ... ok
test tests::test_set_and_get ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Seeing test result: ok is a great feeling. It means the core logic of our KvStore is behaving exactly as we expect.

A Major Milestone Achieved!

You have now completed the first major step of this project. You have built a robust, thread-safe, and now fully tested in-memory key-value store. This component is the solid foundation upon which we will build the rest of our distributed system.

What’s Next?

With our storage engine complete, the next step is to make it accessible to the outside world. We will dive into the world of networking with tokio. We’ll create a TCP server that listens for incoming connections. To communicate with clients, we’ll define a clear request/response protocol using serde and bincode to serialize our commands over the network.

Further Reading