No history yet

Implementing the Database Core

Building the Core

With our design complete, it's time to build the engine of our columnar database. The core consists of three main parts that work together: a system for getting data in (ingestion), a way to store it efficiently (storage management), and a method for retrieving it (query processing). We'll build each of these using Rust, focusing on creating a simple but functional foundation.

Data Ingestion

Data ingestion is the process of loading data into our database. The goal is to take data from an external format, like a CSV file, and convert it into our internal columnar structure. This involves reading the data row by row, parsing it, and then appending each value to the correct column vector.

Imagine we have a Table struct that holds several named Columns, where each Column is essentially a vector of a specific data type. The ingestion logic would read a record, identify the value for each field, and push it onto the corresponding column's vector.

use std::collections::HashMap;

// A simplified representation of our in-memory columnar storage.
struct Table {
    columns: HashMap<String, Vec<String>>,
}

impl Table {
    fn new() -> Self {
        Table {
            columns: HashMap::new(),
        }
    }

    // Ingests a single row of data, represented as a map from column name to value.
    fn ingest_row(&mut self, row: HashMap<String, String>) {
        for (col_name, value) in row {
            // Find the right column vector and add the new value.
            // If the column doesn't exist, create it first.
            self.columns.entry(col_name).or_insert_with(Vec::new).push(value);
        }
    }
}

This code provides a basic structure. A real implementation would need to handle different data types (integers, floats, etc.), manage errors during parsing, and process data in large batches for better performance.

Storage Management

Once data is ingested into our in-memory column vectors, we need to save it to disk. This is where storage management comes in. True to our columnar design, we won't store the data in rows. Instead, we'll store each column as a separate block of data, often in its own file.

This separation is the key to a columnar database's performance. When a query only needs a few columns from a table with hundreds of them, the database can read just the files for the columns it needs, skipping the rest. This drastically reduces the amount of data read from the disk.

Writing data to disk in Rust can be done using the standard library's file system module. For simplicity, we can serialize a vector of data and write the resulting bytes to a file. A production system would use a more robust binary format like Apache Arrow or Parquet, but the principle is the same.

use std::fs::File;
use std::io::Write;

// A simple function to write a vector of integers to a file.
// Note: This is a very basic serialization. Real databases use more complex formats.
fn write_column_to_disk(column_name: &str, data: &Vec<i32>) -> std::io::Result<()> {
    let mut file = File::create(format!("{}.col", column_name))?;

    for &value in data {
        // Write each integer as 4 bytes in big-endian order.
        file.write_all(&value.to_be_bytes())?;
    }
    
    Ok(())
}

Query Processing

Query processing is where the magic happens. This is the component that interprets a user's request, fetches the necessary data from storage, and computes a result. A query processor typically has a few stages, but we'll focus on the core execution logic.

Consider a query like SELECT name FROM users WHERE age > 30. Our query processor would:

  1. Identify that it only needs two columns: name and age.
  2. Load just the name.col and age.col files from disk.
  3. Iterate through the age column. For each age, it checks if it's greater than 30.
  4. If the condition is met, it retrieves the name from the name column at the same position (or index).
  5. It collects all matching names and returns them to the user.

This process is highly efficient because we never touch the data for any other columns, saving massive amounts of I/O.

// This function simulates a simple 'SELECT name WHERE age > threshold' query.
fn query_names_by_age(
    names: &Vec<String>, 
    ages: &Vec<u32>, 
    age_threshold: u32
) -> Vec<String> {
    let mut results = Vec::new();

    // Iterate through the ages with their index.
    for (i, &age) in ages.iter().enumerate() {
        if age > age_threshold {
            // If the age matches, get the name at the same index.
            // We use .get() for safety, in case the columns have different lengths.
            if let Some(name) = names.get(i) {
                results.push(name.clone());
            }
        }
    }

    results
}

Now that you've seen how the core components work, let's test your knowledge.

Quiz Questions 1/5

What are the three core components of the described columnar database engine?

Quiz Questions 2/5

During data ingestion from a source like a CSV file, each value is read and appended to its corresponding ________.

These three components—ingestion, storage, and querying—form the backbone of our database. By implementing them in Rust, we leverage the language's performance and safety to build a solid foundation.