feat(core): add compact durable world storage and verified CLI
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "shacraft-tools"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
shacraft-core.workspace = true
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Local storage probe. JSON-lines session is a test/CLI interface, not MCP.
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shacraft_core::{BlockChange, Pos, WorldStore};
|
||||
use std::{
|
||||
io::{self, BufRead, Write},
|
||||
path::PathBuf,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about = "Shacraft Core storage tools (stage 1)")]
|
||||
struct Args {
|
||||
#[arg(long, default_value = "data")]
|
||||
data: PathBuf,
|
||||
#[arg(long, default_value_t = 64)]
|
||||
cache: usize,
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Read JSON requests, one per line. Flush each reply after the API returns.
|
||||
Session,
|
||||
/// Show storage metrics; estimated payload and OS RSS remain separate.
|
||||
Stats,
|
||||
/// Exercise shared snapshots, independent edits, undo and reset.
|
||||
Demo,
|
||||
/// Reproducible storage workload. Requires an empty world store.
|
||||
Benchmark {
|
||||
#[arg(long, default_value_t = 256)]
|
||||
sections: usize,
|
||||
#[arg(long, default_value_t = 100)]
|
||||
worlds: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum Request {
|
||||
List,
|
||||
Register {
|
||||
state: String,
|
||||
},
|
||||
Registry,
|
||||
Create {
|
||||
name: String,
|
||||
template: Option<String>,
|
||||
},
|
||||
Revision {
|
||||
world: String,
|
||||
},
|
||||
Get {
|
||||
world: String,
|
||||
pos: Pos,
|
||||
},
|
||||
Read {
|
||||
world: String,
|
||||
min: Pos,
|
||||
max: Pos,
|
||||
},
|
||||
Edit {
|
||||
world: String,
|
||||
expected_revision: u64,
|
||||
operation_id: String,
|
||||
changes: Vec<BlockChange>,
|
||||
},
|
||||
Undo {
|
||||
world: String,
|
||||
expected_revision: u64,
|
||||
operation_id: String,
|
||||
target_operation: String,
|
||||
},
|
||||
Reset {
|
||||
world: String,
|
||||
expected_revision: u64,
|
||||
operation_id: String,
|
||||
},
|
||||
Stats,
|
||||
Flush,
|
||||
}
|
||||
|
||||
fn execute(store: &mut WorldStore, request: Request) -> Result<Value> {
|
||||
Ok(match request {
|
||||
Request::List => json!(store.list_worlds()?),
|
||||
Request::Register { state } => json!(store.register_block(&state)?),
|
||||
Request::Registry => json!(store.registry()),
|
||||
Request::Create { name, template } => {
|
||||
store.create_world(&name, template.as_deref())?;
|
||||
json!({"name":name, "revision":store.revision(&name)?})
|
||||
}
|
||||
Request::Revision { world } => json!(store.revision(&world)?),
|
||||
Request::Get { world, pos } => json!(store.get_block(&world, pos)?),
|
||||
Request::Read { world, min, max } => json!(store.read_region(&world, min, max)?),
|
||||
Request::Edit {
|
||||
world,
|
||||
expected_revision,
|
||||
operation_id,
|
||||
changes,
|
||||
} => json!(store.edit(&world, expected_revision, &operation_id, changes)?),
|
||||
Request::Undo {
|
||||
world,
|
||||
expected_revision,
|
||||
operation_id,
|
||||
target_operation,
|
||||
} => json!(store.undo(&world, expected_revision, &operation_id, &target_operation)?),
|
||||
Request::Reset {
|
||||
world,
|
||||
expected_revision,
|
||||
operation_id,
|
||||
} => json!(store.reset_world(&world, expected_revision, &operation_id)?),
|
||||
Request::Stats => metrics(store),
|
||||
Request::Flush => {
|
||||
store.flush()?;
|
||||
json!({"flushed":true})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn os_memory() -> Value {
|
||||
// Linux measurements. Unavailable on other platforms is null, never zero.
|
||||
let read = std::fs::read_to_string("/proc/self/status").ok();
|
||||
let field = |key: &str| -> Option<u64> {
|
||||
read.as_ref()?
|
||||
.lines()
|
||||
.find(|line| line.starts_with(key))?
|
||||
.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()?
|
||||
.checked_mul(1024)
|
||||
};
|
||||
json!({"rss_bytes":field("VmRSS:"), "peak_rss_bytes":field("VmHWM:"),
|
||||
"source":"/proc/self/status (null when unavailable)"})
|
||||
}
|
||||
|
||||
fn metrics(store: &WorldStore) -> Value {
|
||||
json!({"storage":store.stats(), "process":os_memory()})
|
||||
}
|
||||
|
||||
fn session(store: &mut WorldStore) -> Result<()> {
|
||||
let stdin = io::stdin();
|
||||
let mut input = stdin.lock();
|
||||
let stdout = io::stdout();
|
||||
let mut output = stdout.lock();
|
||||
const MAX_LINE: usize = 8 * 1024 * 1024;
|
||||
loop {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
let available = input.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
break;
|
||||
}
|
||||
let n = available
|
||||
.iter()
|
||||
.position(|&c| c == b'\n')
|
||||
.map_or(available.len(), |n| n + 1);
|
||||
if line.len().saturating_add(n) > MAX_LINE {
|
||||
bail!("request exceeds 8 MiB");
|
||||
}
|
||||
line.extend_from_slice(&available[..n]);
|
||||
input.consume(n);
|
||||
if line.last() == Some(&b'\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
let result = serde_json::from_slice::<Request>(&line)
|
||||
.context("invalid request")
|
||||
.and_then(|request| execute(store, request));
|
||||
let reply = match result {
|
||||
Ok(result) => json!({"ok":true, "result":result}),
|
||||
Err(error) => json!({"ok":false, "error":format!("{error:#}")}),
|
||||
};
|
||||
serde_json::to_writer(&mut output, &reply)?;
|
||||
writeln!(output)?;
|
||||
output.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_empty(store: &WorldStore) -> Result<()> {
|
||||
if !store.list_worlds()?.is_empty() {
|
||||
bail!("demo/benchmark requires an empty store; use a fresh --data directory");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn demo(store: &mut WorldStore) -> Result<Value> {
|
||||
require_empty(store)?;
|
||||
let stone = store.register_block("shacraft:stone")?;
|
||||
store.create_world("template", None)?;
|
||||
let changes = (-16..16)
|
||||
.flat_map(|x| {
|
||||
(-16..16).map(move |z| BlockChange {
|
||||
pos: [x, 0, z],
|
||||
block: stone,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
store.edit("template", 0, "build-floor", changes)?;
|
||||
store.create_world("arena_a", Some("template"))?;
|
||||
store.create_world("arena_b", Some("template"))?;
|
||||
let edited = store.edit(
|
||||
"arena_a",
|
||||
0,
|
||||
"break-floor",
|
||||
vec![BlockChange {
|
||||
pos: [0, 0, 0],
|
||||
block: 0,
|
||||
}],
|
||||
)?;
|
||||
anyhow::ensure!(
|
||||
store.get_block("arena_b", [0, 0, 0])? == stone,
|
||||
"fork isolation failed"
|
||||
);
|
||||
let undo = store.undo("arena_a", edited.revision, "restore-floor", "break-floor")?;
|
||||
let reset = store.reset_world("arena_a", undo.revision, "reset-match")?;
|
||||
store.flush()?;
|
||||
Ok(
|
||||
json!({"scenario":"shared floor -> isolated edit -> undo -> reset", "worlds":store.list_worlds()?,
|
||||
"edit":edited,"undo":undo,"reset":reset,"metrics":metrics(store)}),
|
||||
)
|
||||
}
|
||||
|
||||
fn benchmark(store: &mut WorldStore, sections: usize, worlds: usize) -> Result<Value> {
|
||||
require_empty(store)?;
|
||||
anyhow::ensure!((1..=4096).contains(§ions), "sections must be 1..4096");
|
||||
anyhow::ensure!((1..=1000).contains(&worlds), "worlds must be 1..1000");
|
||||
let start = Instant::now();
|
||||
let stone = store.register_block("shacraft:stone")?;
|
||||
store.create_world("template", None)?;
|
||||
// Each section differs, so this probes eviction as well as content sharing.
|
||||
for section in 0..sections {
|
||||
let sx = (section % 64) as i32 * 16;
|
||||
let sz = (section / 64) as i32 * 16;
|
||||
let changes = (0..256)
|
||||
.map(|cell| BlockChange {
|
||||
pos: [sx + cell % 16, 0, sz + cell / 16],
|
||||
block: if cell as usize == section % 256 {
|
||||
0
|
||||
} else {
|
||||
stone
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
store.edit(
|
||||
"template",
|
||||
store.revision("template")?,
|
||||
&format!("seed-{section}"),
|
||||
changes,
|
||||
)?;
|
||||
}
|
||||
let seeded_ms = start.elapsed().as_millis();
|
||||
let after_template = metrics(store);
|
||||
let fork_start = Instant::now();
|
||||
for world in 0..worlds {
|
||||
store.create_world(&format!("arena_{world}"), Some("template"))?;
|
||||
}
|
||||
let fork_ms = fork_start.elapsed().as_millis();
|
||||
let after_forks = metrics(store);
|
||||
for section in 0..sections {
|
||||
let pos = [(section % 64) as i32 * 16, 0, (section / 64) as i32 * 16];
|
||||
store.get_block("template", pos)?;
|
||||
}
|
||||
for world in 0..worlds {
|
||||
let name = format!("arena_{world}");
|
||||
store.edit(
|
||||
&name,
|
||||
0,
|
||||
"isolated",
|
||||
vec![BlockChange {
|
||||
pos: [1, 1, 1],
|
||||
block: stone,
|
||||
}],
|
||||
)?;
|
||||
store.reset_world(&name, 1, "reset")?;
|
||||
anyhow::ensure!(store.get_block(&name, [1, 1, 1])? == 0, "reset failed");
|
||||
}
|
||||
store.flush()?;
|
||||
Ok(
|
||||
json!({"scenario":"storage-only; no players, rendering or tick simulation",
|
||||
"comparison":"no Paper baseline measured", "sections":sections, "forked_worlds":worlds,
|
||||
"seed_ms":seeded_ms, "fork_ms":fork_ms, "elapsed_ms":start.elapsed().as_millis(),
|
||||
"after_template":after_template,"after_forks":after_forks,"after_access_edit_reset":metrics(store)}),
|
||||
)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let mut store = WorldStore::open(&args.data, args.cache)?;
|
||||
let result = match args.command {
|
||||
Command::Session => return session(&mut store),
|
||||
Command::Stats => metrics(&store),
|
||||
Command::Demo => demo(&mut store)?,
|
||||
Command::Benchmark { sections, worlds } => benchmark(&mut store, sections, worlds)?,
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user