repositories / smith
smith
There are many coding harnesses - but this one is fast
owned by admin
smith-harness/src/storage.rs
Raw//! SMH-SPEC-SPEC0001: Sessions, bounded storage requests.
//!
//! Bounded storage requests: filesystem reads run on a dedicated worker.
//!
//! Callers own immutable results, never an open reader or ambient I/O in a
//! core state transition.
use smith::config::Config;
use smith::error::{Result, SmithError};
use smith::id::EntryId;
use smith_alloc::{Scope, enter};
use smith_core::session::{EntryFrame, Session};
use smith_core::store::{SessionReader, SessionWriter, open_session};
use std::path::PathBuf;
use std::sync::mpsc::{self, SyncSender};
/// Serialized operations on one session file.
pub struct StorageWorker {
tx: SyncSender<Request>,
thread: Option<std::thread::JoinHandle<()>>,
}
enum Request {
Open(Config, SyncSender<Result<Session>>),
Append(EntryFrame, SyncSender<Result<()>>),
Selected(usize, SyncSender<Result<Session>>),
Verify(usize, SyncSender<Result<()>>),
Fork(EntryId, PathBuf, Config, SyncSender<Result<Session>>),
Window(
EntryId,
usize,
usize,
usize,
SyncSender<Result<Vec<EntryFrame>>>,
),
}
fn read_window(
path: &std::path::Path,
id: EntryId,
start: usize,
limit: usize,
max: usize,
) -> Result<Vec<EntryFrame>> {
SessionReader::open(path).and_then(|reader| {
let mut result = Vec::with_capacity(limit);
let mut position = 0;
reader.replay_at(id, max, |entry| {
if position >= start && result.len() < limit {
result.push(entry);
}
position += 1;
Ok(())
})?;
Ok(result)
})
}
fn stopped() -> SmithError {
SmithError::Session {
code: smith::error::SessionCode::StorageWorkerStopped,
message: "session storage worker stopped".into(),
}
}
impl StorageWorker {
/// Start a dedicated filesystem worker for `path`.
#[must_use]
pub fn start(path: PathBuf) -> Self {
let (tx, rx) = mpsc::sync_channel(1);
let thread = std::thread::spawn(move || {
let _session = enter(Scope::Session);
let mut writer: Option<SessionWriter> = None;
while let Ok(request) = rx.recv() {
let _request = enter(Scope::IoRequest);
match request {
Request::Open(config, reply) => {
let result = open_session(&path, config).map(|opened| {
writer = Some(opened.writer);
opened.session
});
// The requesting client may have stopped waiting.
let _reply_result: std::result::Result<
(),
std::sync::mpsc::SendError<Result<Session>>,
> = reply.send(result);
}
Request::Append(entry, reply) => {
let result = writer.as_mut().ok_or_else(stopped).and_then(|writer| {
let record = smith_core::session::Entry {
id: entry.id,
parent: entry.parent,
timestamp_ms: entry.timestamp_ms,
kind: entry.kind,
content: smith_core::session::Span { offset: 0, len: 0 },
};
writer.append_entry(&record, &entry.content)
});
// The requesting client may have stopped waiting.
let _reply_result: std::result::Result<
(),
std::sync::mpsc::SendError<Result<()>>,
> = reply.send(result);
}
Request::Selected(max, reply) => {
// The requesting client may have stopped waiting.
let _reply_result: std::result::Result<
(),
std::sync::mpsc::SendError<Result<Session>>,
> = reply.send(
SessionReader::open(&path)
.and_then(|reader| reader.selected_session(max)),
);
}
Request::Verify(max, reply) => {
// The requesting client may have stopped waiting.
let _reply_result: std::result::Result<
(),
std::sync::mpsc::SendError<Result<()>>,
> = reply.send(
SessionReader::open(&path).and_then(|reader| reader.verify_full(max)),
);
}
Request::Fork(id, target, config, reply) => {
// The requesting client may have stopped waiting.
let _reply_result: std::result::Result<
(),
std::sync::mpsc::SendError<Result<Session>>,
> = reply.send(
SessionReader::open(&path)
.and_then(|reader| reader.fork_at(id, &target, config))
.map(|opened| opened.session),
);
}
Request::Window(id, start, limit, max, reply) => {
// The requesting client may have stopped waiting.
let _reply_result: std::result::Result<
(),
std::sync::mpsc::SendError<Result<Vec<EntryFrame>>>,
> = reply.send(read_window(&path, id, start, limit, max));
}
}
}
});
Self {
tx,
thread: Some(thread),
}
}
fn request<T>(&self, build: impl FnOnce(SyncSender<Result<T>>) -> Request) -> Result<T> {
let (tx, rx) = mpsc::sync_channel(1);
self.tx.send(build(tx)).map_err(|_| stopped())?;
rx.recv().map_err(|_| stopped())?
}
/// Open or resume a writable session; keep writer ownership on worker.
///
/// # Errors
/// Returns a storage or worker fault.
pub fn open(&self, config: Config) -> Result<Session> {
self.request(|reply| Request::Open(config, reply))
}
/// Persist an entry before it becomes visible in the in-memory session.
///
/// # Errors
/// Returns a storage or worker fault.
pub fn append(&self, entry: EntryFrame) -> Result<()> {
self.request(|reply| Request::Append(entry, reply))
}
/// Refresh the selected-context snapshot without retaining other branches.
///
/// # Errors
/// Returns a storage or worker fault.
pub fn selected(&self, max: usize) -> Result<Session> {
self.request(|reply| Request::Selected(max, reply))
}
/// Verify the full file, not just the selected context.
///
/// # Errors
/// Returns an integrity or worker fault.
pub fn verify(&self, max: usize) -> Result<()> {
self.request(|reply| Request::Verify(max, reply))
}
/// Fork a requested historical tip without reading on the caller thread.
///
/// # Errors
/// Returns a storage or worker fault.
pub fn fork(&self, id: EntryId, target: PathBuf, config: Config) -> Result<Session> {
self.request(|reply| Request::Fork(id, target, config, reply))
}
/// Own a selected branch's bounded visible window, without retaining
/// preceding or subsequent payloads. At most 64 entries per request.
///
/// # Errors
/// Returns a limit, ancestry, storage, or worker fault.
pub fn window(
&self,
id: EntryId,
start: usize,
limit: usize,
max: usize,
) -> Result<Vec<EntryFrame>> {
if limit == 0 || limit > 64 {
return Err(SmithError::Session {
code: smith::error::SessionCode::StorageWindowLimit,
message: "window limit must be 1..=64".into(),
});
}
self.request(|reply| Request::Window(id, start, limit, max, reply))
}
}
impl Drop for StorageWorker {
fn drop(&mut self) {
let (replacement, _rx) = mpsc::sync_channel(1);
drop(std::mem::replace(&mut self.tx, replacement));
if let Some(thread) = self.thread.take() {
// Drop cannot report a worker panic; closed requests report worker failure.
let _join_result: std::thread::Result<()> = thread.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use smith::message::{Message, Role};
use smith_core::session::{EntryContent, EntryFrame};
fn history(path: &std::path::Path, entries: usize) -> EntryId {
use smith_core::frame::Frame;
use smith_core::store::SessionWriter;
let session = Session::new();
let mut writer = SessionWriter::open(path, Config::default_valid()).unwrap();
writer
.append_frame(&Frame::Header {
header: session.header(),
})
.unwrap();
let mut tip = None;
for index in 0..entries {
let entry = EntryFrame::new(
tip,
&EntryContent::Message(Message::with_text(
Role::User,
format!("{index} {}", "x".repeat(32 * 1024)),
)),
)
.unwrap();
tip = Some(entry.id);
writer.append_frame(&Frame::Known { entry }).unwrap();
}
writer.select_entry(tip.unwrap()).unwrap();
tip.unwrap()
}
#[test]
fn worker_resumes_selected_context_and_reads_bounded_windows() {
// SMH-SPEC-SPEC0001: Sessions, bounded working sets and replay.
let dir = std::env::temp_dir().join(format!("smith-storage-worker-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let worker = StorageWorker::start(dir.join("session.smh"));
let cfg = Config::default_valid();
let session = worker.open(cfg.clone()).unwrap();
assert!(session.selected_id().is_none());
// Writer lock remains on the worker after open, including idle periods.
assert_eq!(
smith_core::store::SessionWriter::open(dir.join("session.smh"), cfg.clone())
.unwrap_err()
.code(),
"STORE_BUSY"
);
let root = EntryFrame::new(
None,
&EntryContent::Message(Message::with_text(Role::User, "root")),
)
.unwrap();
let tip = EntryFrame::new(
Some(root.id),
&EntryContent::Message(Message::with_text(Role::Assistant, "tip")),
)
.unwrap();
for entry in [&root, &tip] {
worker.append(entry.clone()).unwrap();
}
assert_eq!(
worker.selected(cfg.frame_bytes_max).unwrap().selected_id(),
Some(tip.id)
);
worker.verify(cfg.frame_bytes_max).unwrap();
assert_eq!(
worker
.window(tip.id, 0, 2, cfg.frame_bytes_max)
.unwrap()
.into_iter()
.map(|entry| entry.id)
.collect::<Vec<_>>(),
[root.id, tip.id]
);
assert_eq!(
worker.window(tip.id, 1, 1, cfg.frame_bytes_max).unwrap()[0].id,
tip.id
);
assert_eq!(
worker
.window(tip.id, 0, 65, cfg.frame_bytes_max)
.unwrap_err()
.code(),
"STORAGE_WINDOW_LIMIT"
);
drop(worker);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn worker_window_peak_does_not_grow_with_history() {
let root =
std::env::temp_dir().join(format!("smith-storage-bounds-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let small_path = root.join("small.smh");
let large_path = root.join("large.smh");
let small_tip = history(&small_path, 4);
let large_tip = history(&large_path, 128);
let small_worker = StorageWorker::start(small_path);
let large_worker = StorageWorker::start(large_path);
let before = smith_alloc::snapshot(smith_alloc::Scope::IoRequest);
let small_rows = small_worker
.window(small_tip, 3, 1, Config::default_valid().frame_bytes_max)
.unwrap();
let after_small = smith_alloc::snapshot(smith_alloc::Scope::IoRequest);
let large_rows = large_worker
.window(large_tip, 127, 1, Config::default_valid().frame_bytes_max)
.unwrap();
let after_large = smith_alloc::snapshot(smith_alloc::Scope::IoRequest);
assert_eq!((small_rows.len(), large_rows.len()), (1, 1));
assert!(
after_small.allocations > before.allocations
&& after_large.allocations > after_small.allocations,
"production worker requests must charge allocations to IoRequest"
);
let retained = after_large.live;
drop((small_rows, large_rows, small_worker, large_worker));
let released = smith_alloc::snapshot(smith_alloc::Scope::IoRequest).live;
assert!(
retained != released,
"freeing rows outside worker scope must release their original IoRequest charge"
);
let _ = std::fs::remove_dir_all(root);
}
}