clusterflux-public/crates/clusterflux-core/src/vfs.rs
Clusterflux release 2a0f7ded04 Public release release-ea887c8f56cd
Source commit: ea887c8f56cd53985a1179b13e5f1b85c485f584

Public tree identity: sha256:b96a97aacdbc8fd4fc2ea20d0e1450280d46db3f24aabe1475e5fbe20e05f255
2026-07-25 02:55:35 +02:00

322 lines
9.5 KiB
Rust

use std::collections::BTreeMap;
use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
use thiserror::Error;
use crate::{Digest, NodeId, TaskInstanceId};
pub const MAX_VFS_PATH_BYTES: usize = 4096;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct VfsPath(String);
impl VfsPath {
pub fn new(path: impl Into<String>) -> Result<Self, VfsError> {
let path = path.into();
if !path.starts_with("/vfs/") {
return Err(VfsError::invalid(path, "path must start with /vfs/"));
}
if path.len() > MAX_VFS_PATH_BYTES {
return Err(VfsError::invalid(
path,
"path exceeds the 4096-byte protocol limit",
));
}
if path.chars().any(char::is_control) {
return Err(VfsError::invalid(path, "control characters are forbidden"));
}
if path.contains('\\') {
return Err(VfsError::invalid(path, "backslashes are forbidden"));
}
let relative = &path["/vfs/".len()..];
if relative.is_empty() {
return Err(VfsError::invalid(
path,
"path after /vfs/ must not be empty",
));
}
if relative
.split('/')
.any(|component| component.is_empty() || matches!(component, "." | ".."))
{
return Err(VfsError::invalid(
path,
"empty, '.', and '..' path components are forbidden",
));
}
Ok(Self(path))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for VfsPath {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let path = String::deserialize(deserializer)?;
Self::new(path).map_err(D::Error::custom)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VfsObject {
pub path: VfsPath,
pub digest: Digest,
pub size: u64,
pub producer: TaskInstanceId,
pub node: NodeId,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VfsManifest {
pub epoch: u64,
pub producer: TaskInstanceId,
pub node: NodeId,
pub objects: BTreeMap<VfsPath, VfsObject>,
pub large_bytes_uploaded: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SyncPolicy {
MetadataOnly,
ExplicitNode(NodeId),
ExplicitStore(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum VfsSyncDecision {
NoBytesMoved,
MoveBytesToNode(NodeId),
MoveBytesToStore(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReuseDecision {
SameNodeZeroCopy,
NeedsTransfer { from: NodeId, to: NodeId },
Unavailable,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum VfsError {
#[error("invalid VFS path {path:?}: {reason}")]
InvalidPath { path: String, reason: &'static str },
#[error("path is not visible in the published VFS manifest: {0}")]
NotVisible(String),
}
impl VfsError {
fn invalid(path: String, reason: &'static str) -> Self {
Self::InvalidPath { path, reason }
}
}
#[derive(Clone, Debug)]
pub struct VfsOverlay {
task: TaskInstanceId,
node: NodeId,
epoch: u64,
pending: BTreeMap<VfsPath, VfsObject>,
published: BTreeMap<VfsPath, VfsObject>,
}
impl VfsOverlay {
pub fn new(task: TaskInstanceId, node: NodeId) -> Self {
Self {
task,
node,
epoch: 0,
pending: BTreeMap::new(),
published: BTreeMap::new(),
}
}
pub fn write(&mut self, path: VfsPath, digest: Digest, size: u64) -> VfsObject {
let object = VfsObject {
path: path.clone(),
digest,
size,
producer: self.task.clone(),
node: self.node.clone(),
};
self.pending.insert(path, object.clone());
object
}
pub fn flush(&mut self) -> VfsManifest {
self.epoch += 1;
self.published.append(&mut self.pending);
VfsManifest {
epoch: self.epoch,
producer: self.task.clone(),
node: self.node.clone(),
objects: self.published.clone(),
large_bytes_uploaded: false,
}
}
pub fn sync(&self, policy: SyncPolicy) -> VfsSyncDecision {
match policy {
SyncPolicy::MetadataOnly => VfsSyncDecision::NoBytesMoved,
SyncPolicy::ExplicitNode(node) => VfsSyncDecision::MoveBytesToNode(node),
SyncPolicy::ExplicitStore(store) => VfsSyncDecision::MoveBytesToStore(store),
}
}
pub fn read_published<'a>(
manifest: &'a VfsManifest,
path: &VfsPath,
) -> Result<&'a VfsObject, VfsError> {
manifest
.objects
.get(path)
.ok_or_else(|| VfsError::NotVisible(path.as_str().to_owned()))
}
pub fn reuse_for_consumer(
manifest: &VfsManifest,
path: &VfsPath,
consumer_node: &NodeId,
) -> ReuseDecision {
let Some(object) = manifest.objects.get(path) else {
return ReuseDecision::Unavailable;
};
if &object.node == consumer_node {
ReuseDecision::SameNodeZeroCopy
} else {
ReuseDecision::NeedsTransfer {
from: object.node.clone(),
to: consumer_node.clone(),
}
}
}
pub fn discard_unflushed(&mut self) {
self.pending.clear();
}
pub fn pending_len(&self) -> usize {
self.pending.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn path() -> VfsPath {
VfsPath::new("/vfs/artifacts/app").unwrap()
}
#[test]
fn flush_publishes_manifest_without_large_byte_upload() {
let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a"));
overlay.write(path(), Digest::sha256("binary"), 6);
let manifest = overlay.flush();
assert_eq!(manifest.epoch, 1);
assert!(!manifest.large_bytes_uploaded);
assert!(manifest.objects.contains_key(&path()));
}
#[test]
fn downstream_task_can_read_after_flush_but_not_before() {
let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a"));
overlay.write(path(), Digest::sha256("binary"), 6);
let empty = VfsManifest {
epoch: 0,
producer: TaskInstanceId::from("task"),
node: NodeId::from("node-a"),
objects: BTreeMap::new(),
large_bytes_uploaded: false,
};
assert!(VfsOverlay::read_published(&empty, &path()).is_err());
let manifest = overlay.flush();
assert!(VfsOverlay::read_published(&manifest, &path()).is_ok());
}
#[test]
fn sync_is_explicit_and_policy_driven() {
let overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a"));
assert_eq!(
overlay.sync(SyncPolicy::MetadataOnly),
VfsSyncDecision::NoBytesMoved
);
assert_eq!(
overlay.sync(SyncPolicy::ExplicitStore("s3://bucket/app".to_owned())),
VfsSyncDecision::MoveBytesToStore("s3://bucket/app".to_owned())
);
}
#[test]
fn same_node_reuse_avoids_transfer() {
let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a"));
overlay.write(path(), Digest::sha256("binary"), 6);
let manifest = overlay.flush();
assert_eq!(
VfsOverlay::reuse_for_consumer(&manifest, &path(), &NodeId::from("node-a")),
ReuseDecision::SameNodeZeroCopy
);
assert_eq!(
VfsOverlay::reuse_for_consumer(&manifest, &path(), &NodeId::from("node-b")),
ReuseDecision::NeedsTransfer {
from: NodeId::from("node-a"),
to: NodeId::from("node-b")
}
);
}
#[test]
fn unflushed_task_local_changes_can_be_discarded() {
let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a"));
overlay.write(path(), Digest::sha256("binary"), 6);
overlay.discard_unflushed();
assert_eq!(overlay.pending_len(), 0);
}
#[test]
fn vfs_path_rejects_the_complete_hostile_protocol_matrix() {
for invalid in [
"vfs/artifacts/app".to_owned(),
"/vfs/".to_owned(),
"/vfs/artifacts//app".to_owned(),
"/vfs/artifacts/app/".to_owned(),
"/vfs/artifacts/./app".to_owned(),
"/vfs/artifacts/../app".to_owned(),
"/vfs/artifacts\\app".to_owned(),
"/vfs/artifacts/bad\0app".to_owned(),
format!("/vfs/{}", "x".repeat(MAX_VFS_PATH_BYTES)),
] {
assert!(
VfsPath::new(&invalid).is_err(),
"hostile VFS path unexpectedly passed: {invalid:?}"
);
assert!(
serde_json::from_value::<VfsPath>(serde_json::json!(invalid)).is_err(),
"hostile VFS path unexpectedly deserialized"
);
}
}
#[test]
fn vfs_path_accepts_the_4096_byte_boundary() {
let path = format!("/vfs/{}", "x".repeat(MAX_VFS_PATH_BYTES - "/vfs/".len()));
assert_eq!(path.len(), MAX_VFS_PATH_BYTES);
assert_eq!(VfsPath::new(&path).unwrap().as_str(), path);
let overlong = format!("{path}x");
assert_eq!(overlong.len(), MAX_VFS_PATH_BYTES + 1);
assert!(VfsPath::new(overlong).is_err());
}
}