Pi deploy follow-ups: XDG default_path + parallel restore
Two fixes surfaced by the vision-02 Pi 5 measurement: ## XDG default_path `Manifest::default_path` was hardcoded to `/var/lib/claw-store/projects.toml`. That path is read-only under the user-mode systemd unit's `ProtectSystem=strict`, and creating it needs root — awful for a runner install. Precedence, matching XDG Base Directory: 1. `$XDG_STATE_HOME/claw-store/projects.toml` 2. `$HOME/.local/state/claw-store/projects.toml` 3. `/var/lib/claw-store/projects.toml` (system fallback) User-mode installs now write in $HOME by default; system installs (root, no HOME set) still land in /var/lib. +1 test: `default_path_honours_xdg_state_home` — covers all three precedence branches. Env mutation is process-global so the test saves + restores. ## Parallel restore on cache HIT Pi restore of 947 MiB via `BlobGetStream` took ~18s (~53 MiB/s) single-stream. Per-stream throughput ceilings on the connection type cap sequential fetches; parallel chunk fetches stack their contributions. - New `call_blob_get_parallel(conn, blob_id, concurrency) -> Option<Vec<u8>>` in `rpc/client.rs`. `JoinSet` + `Semaphore`, reassembles by chunk index at manifest-known offsets so out-of-order arrival is fine. - `claw-cargo build --parallel-restore N` (default 8). `N <= 1` falls through to `BlobGetStream` for parity. - Memory: `total_size + 4 MiB × in-flight` — dominated by the reassembly buffer, not the fanout. +1 test: `parallel_blob_get_reassembles_multi_chunk_blob_byte_equal` covers roundtrip byte-equality vs BlobGetStream, tail-chunk offset, concurrency=1 correctness, and NotFound → None. 259 tests pass (+2). Pre-existing macOS failure unchanged.
This commit is contained in:
@@ -677,6 +677,95 @@ pub async fn prewarm_missing_chunks_between(
|
||||
Ok((uploaded, total))
|
||||
}
|
||||
|
||||
/// Field finding 2026-07-12 (Pi restore = 18s single-stream): fetch a
|
||||
/// blob by pulling its chunks in parallel and reassembling in memory.
|
||||
/// Faster than `call_blob_get_stream` on connections with per-stream
|
||||
/// throughput ceilings — parallel streams stack their contributions.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. `LoadManifest` upstream (small).
|
||||
/// 2. Spawn N concurrent `GetChunk` tasks bounded by a semaphore.
|
||||
/// 3. Assemble the results in manifest order into a `Vec<u8>` sized
|
||||
/// to `manifest.total_size`.
|
||||
///
|
||||
/// `concurrency <= 1` degrades to sequential (matches
|
||||
/// `call_blob_get_stream` semantics but keeps the code path uniform).
|
||||
/// Memory ceiling: `total_size + 4 MiB × in-flight` — dominated by
|
||||
/// the reassembled blob buffer itself.
|
||||
pub async fn call_blob_get_parallel(
|
||||
conn: &Connection,
|
||||
id: &BlobId,
|
||||
concurrency: usize,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let manifest = match call_blob_load_manifest(conn, id).await? {
|
||||
Some(m) => m,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let concurrency = concurrency.max(1);
|
||||
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(concurrency));
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for (idx, hash) in manifest.chunks.iter().copied().enumerate() {
|
||||
let permit = sem
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.context("acquiring get_parallel semaphore permit")?;
|
||||
let conn = conn.clone();
|
||||
set.spawn(async move {
|
||||
let _permit = permit;
|
||||
let bytes = call_get_chunk(&conn, &hash).await?.with_context(|| {
|
||||
format!(
|
||||
"manifest referenced chunk {} but GetChunk returned NotFound",
|
||||
hash.to_hex()
|
||||
)
|
||||
})?;
|
||||
Ok::<(usize, Vec<u8>), anyhow::Error>((idx, bytes))
|
||||
});
|
||||
}
|
||||
// Assemble in manifest order. Pre-size the outer vec so we can
|
||||
// slot each chunk's bytes at the right offset without copying.
|
||||
let mut out = vec![0u8; manifest.total_size as usize];
|
||||
// Chunk boundaries: chunk i starts at i * CHUNK_SIZE.
|
||||
let chunk_size = crate::cluster::blob::CHUNK_SIZE;
|
||||
let mut first_err: Option<anyhow::Error> = None;
|
||||
while let Some(join) = set.join_next().await {
|
||||
match join {
|
||||
Ok(Ok((idx, bytes))) => {
|
||||
let start = idx * chunk_size;
|
||||
let end = start + bytes.len();
|
||||
if end > out.len() {
|
||||
if first_err.is_none() {
|
||||
first_err = Some(anyhow::anyhow!(
|
||||
"chunk {} at idx {} would overflow reassembly buffer \
|
||||
(end {}, total_size {})",
|
||||
manifest.chunks[idx].to_hex(),
|
||||
idx,
|
||||
end,
|
||||
manifest.total_size
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out[start..end].copy_from_slice(&bytes);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
if first_err.is_none() {
|
||||
first_err = Some(e);
|
||||
}
|
||||
}
|
||||
Err(join_err) => {
|
||||
if first_err.is_none() {
|
||||
first_err = Some(anyhow::Error::from(join_err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(e) = first_err {
|
||||
return Err(e).context("parallel chunk fetch failed");
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// Phase 5k: parallel-fanout variant of [`prewarm_missing_chunks_between`].
|
||||
///
|
||||
/// Runs the has→get→put pipeline for each chunk concurrently, bounded
|
||||
|
||||
Reference in New Issue
Block a user