← Table of contents

7.2 The Shared Shelf

The most dangerous errors are the ones the compiler stays silent about. Not because it does not see them - it simply does not check them: its domain is types and ownership, not correctness of behaviour. POST /tasks returned a task. GET /tasks returned an empty list. The compiler is satisfied. That is not its mistake - it is a mistake of design.

What You Need

The first problem is solved: State passes the store to the handlers. But each gets its own copy of the vector. Here is the second problem: sharing between threads, read-only for now.

Arc<T> is a shared-ownership type: Arc::clone does not copy the data - it creates another owner of the same data. How exactly this works is in the Lore at the end of this chapter, if you want to read it before the code.

Arc has a boundary: it allows calling methods that take &self - list and get_task only read, and that is enough for them. add and done modify data - they will stay as stubs for now.

The Build

Wrap Vec<Task> in Arc. Add eprintln! to list and get_task - it will show the vector’s address in memory and prove that requests are looking at the same store. add and done change type too, but remain stubs. In crates/api/src/routes.rs:

// crates/api/src/routes.rs - CHANGED
use axum::{
    Json, Router,
    extract::{Path, State},
    routing::{get, patch, post},
};
use std::sync::Arc;
use serde::Deserialize;
use tq_core::task::Task;

#[derive(Deserialize)]
struct AddRequest {
    title: String,
}

// stub: task is not saved - Arc does not allow &mut
async fn add(_: State<Arc<Vec<Task>>>, Json(req): Json<AddRequest>) -> Json<Task> {
    Json(Task::new(0, &req.title).unwrap())
}

// CHANGED: Arc<Vec<Task>>; eprintln! will show the address - it will be the same for all requests
async fn list(State(tasks): State<Arc<Vec<Task>>>) -> Json<Vec<Task>> {
    eprintln!("list: vec @ {:p}", tasks.as_ptr()); // {:p} - the vector's address in memory
    Json(tasks.to_vec())
}

// CHANGED: Arc<Vec<Task>>; eprintln! for the same proof
async fn get_task(State(tasks): State<Arc<Vec<Task>>>, Path(id): Path<u64>) -> Json<Task> {
    eprintln!("get:  vec @ {:p}", tasks.as_ptr());
    Json(tasks.iter().find(|t| t.id == id).unwrap().clone())
}

// stub: status is not changed - Arc does not allow &mut
async fn done(_: State<Arc<Vec<Task>>>, Path(id): Path<u64>) -> Json<Task> {
    Json(Task::new(id, "stub").unwrap())
}

// CHANGED: router accepts Arc<Vec<Task>>
pub fn router(tasks: Arc<Vec<Task>>) -> Router {
    Router::new()
        .route("/tasks", post(add).get(list))
        .route("/tasks/{id}", get(get_task))
        .route("/tasks/{id}/done", patch(done))
        .with_state(tasks)
}

In crates/api/src/main.rs - one store, wrapped in Arc:

// crates/api/src/main.rs - CHANGED
mod routes;

use axum::{Router, routing::get};
use std::sync::Arc;
use tq_core::task::Task;

async fn health() -> String {
    "tq ok".to_string()
}

#[tokio::main]
async fn main() {
    let tasks = Arc::new(vec![ // CHANGED: Arc<Vec<Task>> instead of Vec<Task>
        Task::new(1, "buy milk").unwrap(),
        Task::new(2, "send report").unwrap(),
    ]);
    let app = Router::new()
        .route("/", get(health))
        .merge(routes::router(tasks));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The Result

Server in one terminal, two requests in another:

$ curl -s localhost:3000/tasks
[{"id":1,"title":"buy milk",...},{"id":2,"title":"send report",...}]

$ curl -s localhost:3000/tasks/1
{"id":1,"title":"buy milk",...}

In the server terminal:

list: vec @ 0x55f3a1b2c4d0
get:  vec @ 0x55f3a1b2c4d0   ← same address

Both requests are working with the same vector.

add and done are still stubbed: how to safely modify shared data is the next step.

The complete tq code for this chapter is in 7-many-hands/02-one-handle/.


Lore: Arc and the Reference Count

Arc stands for Atomically Reference Counted. Each Arc::clone atomically increments the counter; when the last owner goes out of scope, the counter drops to zero and the data is freed. Atomic means that multiple threads can create and destroy owners simultaneously. The cost is two atomic increments per clone; an ordinary reference carries no such cost. For most applications it goes unnoticed.