feat(data-plane): add descriptor-based I/O
Add descriptor lifecycle, mapping, errno, namespace reservations, stream cursor operations, Python bindings, guarantees, and benchmarks.
This commit is contained in:
parent
2760a2b549
commit
291698c56f
18 changed files with 4916 additions and 1139 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -947,6 +947,7 @@ dependencies = [
|
||||||
name = "data-plane"
|
name = "data-plane"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"criterion",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"libc",
|
"libc",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@ use std::time::Duration;
|
||||||
use data_plane::blob::{Blob, BlobView, ContentDigest, WritableArenaView};
|
use data_plane::blob::{Blob, BlobView, ContentDigest, WritableArenaView};
|
||||||
use data_plane::bootstrap as dp_bootstrap;
|
use data_plane::bootstrap as dp_bootstrap;
|
||||||
use data_plane::data_plane::{
|
use data_plane::data_plane::{
|
||||||
BlobWriter, DataPlane, DataPlaneBootstrap, StreamReader, StreamWriter, parse_actor_address,
|
BlobWriter, DataPlane, DataPlaneBootstrap, Descriptor, DescriptorMapping, MapRequest,
|
||||||
|
MapTarget, Protection, Sharing, StreamReader, StreamWriter, parse_actor_address,
|
||||||
};
|
};
|
||||||
use data_plane::path::DataPath;
|
use data_plane::path::DataPath;
|
||||||
use data_plane::protocol::{
|
use data_plane::protocol::{
|
||||||
BlobFailure, DataPlaneError, JobCapability, register_data_plane_codecs,
|
AccessMode, BlobAllocation, BlobFailure, DataPlaneError, DescriptorKind, Errno, JobCapability,
|
||||||
|
OpenOptions, register_data_plane_codecs,
|
||||||
};
|
};
|
||||||
use distribution::node::DistributedNodeConfig;
|
use distribution::node::DistributedNodeConfig;
|
||||||
use distribution::transport_bridge::{
|
use distribution::transport_bridge::{
|
||||||
|
|
@ -23,7 +25,8 @@ use futures_lite::future;
|
||||||
use iroh::{EndpointAddr, RelayMode};
|
use iroh::{EndpointAddr, RelayMode};
|
||||||
use iroh_driver::{IrohDriver, IrohDriverConfig};
|
use iroh_driver::{IrohDriver, IrohDriverConfig};
|
||||||
use parking_lot::Mutex as ParkingMutex;
|
use parking_lot::Mutex as ParkingMutex;
|
||||||
use pyo3::exceptions::{PyBufferError, PyPermissionError, PyRuntimeError};
|
use pyo3::buffer::PyBuffer;
|
||||||
|
use pyo3::exceptions::{PyBufferError, PyOSError, PyPermissionError, PyRuntimeError, PyValueError};
|
||||||
use pyo3::ffi;
|
use pyo3::ffi;
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
use pyo3::types::{PyAny, PyBytes, PyModule};
|
use pyo3::types::{PyAny, PyBytes, PyModule};
|
||||||
|
|
@ -36,6 +39,44 @@ use swactor_transport::{CodecRegistry, CodecRemoteSink, TransportRouter};
|
||||||
const ROUTE_POLL: Duration = Duration::from_millis(5);
|
const ROUTE_POLL: Duration = Duration::from_millis(5);
|
||||||
const ROUTE_DEADLINE: Duration = Duration::from_secs(5);
|
const ROUTE_DEADLINE: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
const O_RDONLY: i32 = 0;
|
||||||
|
const O_WRONLY: i32 = 1;
|
||||||
|
const O_RDWR: i32 = 2;
|
||||||
|
const O_ACCMODE: i32 = 3;
|
||||||
|
const O_CREAT: i32 = 0o100;
|
||||||
|
const O_EXCL: i32 = 0o200;
|
||||||
|
const O_TRUNC: i32 = 0o1000;
|
||||||
|
const O_NONBLOCK: i32 = 0o4000;
|
||||||
|
const KNOWN_OPEN_FLAGS: i32 = O_ACCMODE | O_CREAT | O_EXCL | O_TRUNC | O_NONBLOCK;
|
||||||
|
|
||||||
|
fn python_open_options(flags: i32, length: Option<u64>) -> PyResult<OpenOptions> {
|
||||||
|
if flags & !KNOWN_OPEN_FLAGS != 0 {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"unsupported descriptor open flags: {:#x}",
|
||||||
|
flags & !KNOWN_OPEN_FLAGS
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let access = match flags & O_ACCMODE {
|
||||||
|
O_RDONLY => AccessMode::ReadOnly,
|
||||||
|
O_WRONLY => AccessMode::WriteOnly,
|
||||||
|
O_RDWR => AccessMode::ReadWrite,
|
||||||
|
_ => return Err(PyValueError::new_err("invalid descriptor access mode")),
|
||||||
|
};
|
||||||
|
let options = OpenOptions {
|
||||||
|
access,
|
||||||
|
create: flags & O_CREAT != 0,
|
||||||
|
exclusive: flags & O_EXCL != 0,
|
||||||
|
truncate: flags & O_TRUNC != 0,
|
||||||
|
nonblocking: flags & O_NONBLOCK != 0,
|
||||||
|
allocation: length.map(|length| BlobAllocation {
|
||||||
|
length,
|
||||||
|
digest: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
options.validate().map_err(raw_data_plane_error)?;
|
||||||
|
Ok(options)
|
||||||
|
}
|
||||||
|
|
||||||
pyo3::create_exception!(swactor, SwactorError, pyo3::exceptions::PyException);
|
pyo3::create_exception!(swactor, SwactorError, pyo3::exceptions::PyException);
|
||||||
pyo3::create_exception!(swactor, BootstrapError, SwactorError);
|
pyo3::create_exception!(swactor, BootstrapError, SwactorError);
|
||||||
pyo3::create_exception!(swactor, DataPathError, SwactorError);
|
pyo3::create_exception!(swactor, DataPathError, SwactorError);
|
||||||
|
|
@ -50,8 +91,8 @@ fn bootstrap_error(message: impl Into<String>) -> PyErr {
|
||||||
fn data_plane_error(error: DataPlaneError) -> PyErr {
|
fn data_plane_error(error: DataPlaneError) -> PyErr {
|
||||||
match error {
|
match error {
|
||||||
DataPlaneError::InvalidPath(reason) => PyErr::new::<DataPathError, _>(reason),
|
DataPlaneError::InvalidPath(reason) => PyErr::new::<DataPathError, _>(reason),
|
||||||
DataPlaneError::Unauthorized { path, operation } => {
|
DataPlaneError::Unauthorized { path, access } => {
|
||||||
PyPermissionError::new_err(format!("{operation:?} is not authorized for {path}"))
|
PyPermissionError::new_err(format!("{access:?} is not authorized for {path}"))
|
||||||
}
|
}
|
||||||
DataPlaneError::PathNotFound(path) => {
|
DataPlaneError::PathNotFound(path) => {
|
||||||
PyErr::new::<DataPathError, _>(format!("data path not found: {path}"))
|
PyErr::new::<DataPathError, _>(format!("data path not found: {path}"))
|
||||||
|
|
@ -69,6 +110,29 @@ fn data_plane_error(error: DataPlaneError) -> PyErr {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn raw_data_plane_error(error: DataPlaneError) -> PyErr {
|
||||||
|
let code = match error.errno() {
|
||||||
|
Errno::Eacces => libc::EACCES,
|
||||||
|
Errno::Eagain => libc::EAGAIN,
|
||||||
|
Errno::Ebadf => libc::EBADF,
|
||||||
|
Errno::Ebusy => libc::EBUSY,
|
||||||
|
Errno::Ecanceled => libc::ECANCELED,
|
||||||
|
Errno::Econnreset => libc::ECONNRESET,
|
||||||
|
Errno::Eexist => libc::EEXIST,
|
||||||
|
Errno::Einval => libc::EINVAL,
|
||||||
|
Errno::Eio => libc::EIO,
|
||||||
|
Errno::Enodev => libc::ENODEV,
|
||||||
|
Errno::Enoent => libc::ENOENT,
|
||||||
|
Errno::Enomem => libc::ENOMEM,
|
||||||
|
Errno::Enospc => libc::ENOSPC,
|
||||||
|
Errno::Enotsup => libc::ENOTSUP,
|
||||||
|
Errno::Enxio => libc::ENXIO,
|
||||||
|
Errno::Epipe => libc::EPIPE,
|
||||||
|
Errno::Estale => libc::ESTALE,
|
||||||
|
};
|
||||||
|
PyOSError::new_err((code, error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
fn bootstrap_env(name: &str) -> PyResult<String> {
|
fn bootstrap_env(name: &str) -> PyResult<String> {
|
||||||
std::env::var(name)
|
std::env::var(name)
|
||||||
.map_err(|_| bootstrap_error(format!("bootstrap environment variable {name} is not set")))
|
.map_err(|_| bootstrap_error(format!("bootstrap environment variable {name} is not set")))
|
||||||
|
|
@ -239,6 +303,39 @@ pub struct PyDataPlane {
|
||||||
|
|
||||||
#[pymethods]
|
#[pymethods]
|
||||||
impl PyDataPlane {
|
impl PyDataPlane {
|
||||||
|
#[pyo3(signature = (path, flags, *, length = None))]
|
||||||
|
fn open<'py>(
|
||||||
|
&self,
|
||||||
|
py: Python<'py>,
|
||||||
|
path: String,
|
||||||
|
flags: i32,
|
||||||
|
length: Option<u64>,
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let path = DataPath::parse(path).map_err(|error| {
|
||||||
|
raw_data_plane_error(DataPlaneError::InvalidPath(error.to_string()))
|
||||||
|
})?;
|
||||||
|
let options = python_open_options(flags, length)?;
|
||||||
|
let data_plane = self.inner.clone();
|
||||||
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||||
|
let descriptor = data_plane
|
||||||
|
.open(&path, options)
|
||||||
|
.await
|
||||||
|
.map_err(raw_data_plane_error)?;
|
||||||
|
let kind = descriptor.kind();
|
||||||
|
let capabilities = descriptor.capabilities().bits();
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
Py::new(
|
||||||
|
py,
|
||||||
|
PyDescriptor {
|
||||||
|
descriptor: Arc::new(tokio::sync::Mutex::new(descriptor)),
|
||||||
|
kind,
|
||||||
|
capabilities,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn read_blob<'py>(&self, py: Python<'py>, path: String) -> PyResult<Bound<'py, PyAny>> {
|
fn read_blob<'py>(&self, py: Python<'py>, path: String) -> PyResult<Bound<'py, PyAny>> {
|
||||||
let data_plane = self.inner.clone();
|
let data_plane = self.inner.clone();
|
||||||
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||||
|
|
@ -294,6 +391,258 @@ impl PyDataPlane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn byte_buffer(object: &Bound<'_, PyAny>, writable: bool) -> PyResult<PyBuffer<u8>> {
|
||||||
|
let buffer = PyBuffer::<u8>::get(object)?;
|
||||||
|
if writable && buffer.readonly() {
|
||||||
|
return Err(PyBufferError::new_err("buffer is read-only"));
|
||||||
|
}
|
||||||
|
if !buffer.is_c_contiguous() {
|
||||||
|
return Err(PyBufferError::new_err("buffer is not C-contiguous"));
|
||||||
|
}
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn mutable_buffer_bytes<'a>(buffer: &'a PyBuffer<u8>) -> &'a mut [u8] {
|
||||||
|
// SAFETY: `byte_buffer` checked writability and contiguity, and the
|
||||||
|
// retained `PyBuffer` keeps the exporter and pointer valid.
|
||||||
|
unsafe { std::slice::from_raw_parts_mut(buffer.buf_ptr().cast(), buffer.len_bytes()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn buffer_bytes<'a>(buffer: &'a PyBuffer<u8>) -> &'a [u8] {
|
||||||
|
// SAFETY: `byte_buffer` checked contiguity and retains the exporter.
|
||||||
|
unsafe { std::slice::from_raw_parts(buffer.buf_ptr().cast_const().cast(), buffer.len_bytes()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyclass(name = "Descriptor")]
|
||||||
|
pub struct PyDescriptor {
|
||||||
|
descriptor: Arc<tokio::sync::Mutex<Descriptor>>,
|
||||||
|
kind: DescriptorKind,
|
||||||
|
capabilities: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pymethods]
|
||||||
|
impl PyDescriptor {
|
||||||
|
#[getter]
|
||||||
|
fn kind(&self) -> &'static str {
|
||||||
|
match self.kind {
|
||||||
|
DescriptorKind::Blob => "blob",
|
||||||
|
DescriptorKind::Stream => "stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[getter]
|
||||||
|
fn capabilities(&self) -> u16 {
|
||||||
|
self.capabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read<'py>(&self, py: Python<'py>, size: usize) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let descriptor = self.descriptor.clone();
|
||||||
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||||
|
let mut bytes = vec![0_u8; size];
|
||||||
|
let count = descriptor
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.read(&mut bytes)
|
||||||
|
.await
|
||||||
|
.map_err(raw_data_plane_error)?;
|
||||||
|
bytes.truncate(count);
|
||||||
|
Python::with_gil(|py| Ok(PyBytes::new(py, &bytes).unbind()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn readinto<'py>(
|
||||||
|
&self,
|
||||||
|
py: Python<'py>,
|
||||||
|
buffer: &Bound<'_, PyAny>,
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let buffer = byte_buffer(buffer, true)?;
|
||||||
|
let descriptor = self.descriptor.clone();
|
||||||
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||||
|
// SAFETY: the owned `PyBuffer` remains alive through completion or
|
||||||
|
// cancellation and no pointer is retained by the Rust primitive.
|
||||||
|
let bytes = unsafe { mutable_buffer_bytes(&buffer) };
|
||||||
|
descriptor
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.read(bytes)
|
||||||
|
.await
|
||||||
|
.map_err(raw_data_plane_error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write<'py>(&self, py: Python<'py>, bytes: Vec<u8>) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let descriptor = self.descriptor.clone();
|
||||||
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||||
|
descriptor
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.write(&bytes)
|
||||||
|
.await
|
||||||
|
.map_err(raw_data_plane_error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn writefrom<'py>(
|
||||||
|
&self,
|
||||||
|
py: Python<'py>,
|
||||||
|
buffer: &Bound<'_, PyAny>,
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let buffer = byte_buffer(buffer, false)?;
|
||||||
|
let descriptor = self.descriptor.clone();
|
||||||
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||||
|
// SAFETY: the owned `PyBuffer` retains a contiguous exporter until
|
||||||
|
// the descriptor primitive completes or is cancelled.
|
||||||
|
let bytes = unsafe { buffer_bytes(&buffer) };
|
||||||
|
descriptor
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.write(bytes)
|
||||||
|
.await
|
||||||
|
.map_err(raw_data_plane_error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyo3(signature = (*, offset = 0, length, writable = false))]
|
||||||
|
fn map<'py>(
|
||||||
|
&self,
|
||||||
|
py: Python<'py>,
|
||||||
|
offset: u64,
|
||||||
|
length: u64,
|
||||||
|
writable: bool,
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let descriptor = self.descriptor.clone();
|
||||||
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||||
|
let mapping = descriptor
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.map(MapRequest {
|
||||||
|
protection: if writable {
|
||||||
|
Protection::ReadWrite
|
||||||
|
} else {
|
||||||
|
Protection::Read
|
||||||
|
},
|
||||||
|
sharing: Sharing::Shared,
|
||||||
|
target: MapTarget::Host,
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
})
|
||||||
|
.map_err(raw_data_plane_error)?;
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
Py::new(
|
||||||
|
py,
|
||||||
|
PyDescriptorMapping {
|
||||||
|
inner: Some(mapping),
|
||||||
|
exports: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let descriptor = self.descriptor.clone();
|
||||||
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||||
|
descriptor
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.close()
|
||||||
|
.await
|
||||||
|
.map_err(raw_data_plane_error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn abort<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let descriptor = self.descriptor.clone();
|
||||||
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||||
|
descriptor
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.abort()
|
||||||
|
.await
|
||||||
|
.map_err(raw_data_plane_error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyclass(name = "DescriptorMapping")]
|
||||||
|
pub struct PyDescriptorMapping {
|
||||||
|
inner: Option<DescriptorMapping>,
|
||||||
|
exports: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pymethods]
|
||||||
|
impl PyDescriptorMapping {
|
||||||
|
fn __enter__(slf: PyRef<'_, Self>) -> PyResult<PyRef<'_, Self>> {
|
||||||
|
if slf.inner.is_none() {
|
||||||
|
return Err(PyBufferError::new_err("descriptor mapping is closed"));
|
||||||
|
}
|
||||||
|
Ok(slf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn __exit__(
|
||||||
|
&mut self,
|
||||||
|
_exception_type: &Bound<'_, PyAny>,
|
||||||
|
_exception: &Bound<'_, PyAny>,
|
||||||
|
_traceback: &Bound<'_, PyAny>,
|
||||||
|
) -> PyResult<bool> {
|
||||||
|
self.close()?;
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&mut self) -> PyResult<()> {
|
||||||
|
if self.exports != 0 {
|
||||||
|
return Err(PyBufferError::new_err(
|
||||||
|
"cannot close a descriptor mapping with active buffer exports",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.inner.take();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn __getbuffer__(
|
||||||
|
slf: Bound<'_, Self>,
|
||||||
|
view: *mut ffi::Py_buffer,
|
||||||
|
flags: c_int,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
let (pointer, length, readonly) = {
|
||||||
|
let mut borrowed = slf.borrow_mut();
|
||||||
|
let inner = borrowed
|
||||||
|
.inner
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| PyBufferError::new_err("descriptor mapping is closed"))?;
|
||||||
|
match inner {
|
||||||
|
DescriptorMapping::ReadOnly(mapping) => {
|
||||||
|
(mapping.as_ptr().cast_mut(), mapping.len(), true)
|
||||||
|
}
|
||||||
|
DescriptorMapping::WritableReadOnly(mapping) => {
|
||||||
|
(mapping.as_ptr(), mapping.len(), true)
|
||||||
|
}
|
||||||
|
DescriptorMapping::Writable(mapping) => (mapping.as_ptr(), mapping.len(), false),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// SAFETY: the mapping owns the stable arena lease and the Python
|
||||||
|
// buffer retains `slf` until release.
|
||||||
|
unsafe {
|
||||||
|
fill_buffer(
|
||||||
|
view,
|
||||||
|
flags,
|
||||||
|
pointer,
|
||||||
|
length,
|
||||||
|
readonly,
|
||||||
|
slf.clone().into_any(),
|
||||||
|
)
|
||||||
|
}?;
|
||||||
|
slf.borrow_mut().exports += 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn __releasebuffer__(&mut self, view: *mut ffi::Py_buffer) {
|
||||||
|
self.exports = self.exports.saturating_sub(1);
|
||||||
|
// SAFETY: `fill_buffer` allocated the format string for this export.
|
||||||
|
unsafe { release_buffer_format(view) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[pyclass(name = "Blob")]
|
#[pyclass(name = "Blob")]
|
||||||
pub struct PyBlob {
|
pub struct PyBlob {
|
||||||
inner: Blob,
|
inner: Blob,
|
||||||
|
|
@ -651,6 +1000,26 @@ impl PyStreamReader {
|
||||||
Python::with_gil(|py| Ok(bytes.map(|bytes| PyBytes::new(py, &bytes).unbind())))
|
Python::with_gil(|py| Ok(bytes.map(|bytes| PyBytes::new(py, &bytes).unbind())))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn readinto<'py>(
|
||||||
|
&self,
|
||||||
|
py: Python<'py>,
|
||||||
|
buffer: &Bound<'_, PyAny>,
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let buffer = byte_buffer(buffer, true)?;
|
||||||
|
let reader = self.reader.clone();
|
||||||
|
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||||
|
// SAFETY: the owned `PyBuffer` retains the writable exporter for
|
||||||
|
// the complete async operation and cancellation path.
|
||||||
|
let bytes = unsafe { mutable_buffer_bytes(&buffer) };
|
||||||
|
reader
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.read_into(bytes)
|
||||||
|
.await
|
||||||
|
.map_err(data_plane_error)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[pyclass(name = "StreamWriter")]
|
#[pyclass(name = "StreamWriter")]
|
||||||
|
|
@ -1208,7 +1577,16 @@ pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||||
module.add("BlobError", module.py().get_type::<BlobError>())?;
|
module.add("BlobError", module.py().get_type::<BlobError>())?;
|
||||||
module.add("SessionError", module.py().get_type::<SessionError>())?;
|
module.add("SessionError", module.py().get_type::<SessionError>())?;
|
||||||
module.add("StreamError", module.py().get_type::<StreamError>())?;
|
module.add("StreamError", module.py().get_type::<StreamError>())?;
|
||||||
|
module.add("O_RDONLY", O_RDONLY)?;
|
||||||
|
module.add("O_WRONLY", O_WRONLY)?;
|
||||||
|
module.add("O_RDWR", O_RDWR)?;
|
||||||
|
module.add("O_CREAT", O_CREAT)?;
|
||||||
|
module.add("O_EXCL", O_EXCL)?;
|
||||||
|
module.add("O_TRUNC", O_TRUNC)?;
|
||||||
|
module.add("O_NONBLOCK", O_NONBLOCK)?;
|
||||||
module.add_class::<PyDataPlane>()?;
|
module.add_class::<PyDataPlane>()?;
|
||||||
|
module.add_class::<PyDescriptor>()?;
|
||||||
|
module.add_class::<PyDescriptorMapping>()?;
|
||||||
module.add_class::<PyBlob>()?;
|
module.add_class::<PyBlob>()?;
|
||||||
module.add_class::<PyBlobView>()?;
|
module.add_class::<PyBlobView>()?;
|
||||||
module.add_class::<PyStreamWriter>()?;
|
module.add_class::<PyStreamWriter>()?;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import errno
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
|
|
@ -150,7 +151,8 @@ def test_run_attaches_before_main_and_maps_blob_buffer_directly(monkeypatch, hos
|
||||||
_PY_RELEASE_BUFFER(ctypes.byref(exported))
|
_PY_RELEASE_BUFFER(ctypes.byref(exported))
|
||||||
|
|
||||||
public = {name for name in dir(ctx.data) if not name.startswith("_")}
|
public = {name for name in dir(ctx.data) if not name.startswith("_")}
|
||||||
assert {"read_blob", "write_blob", "read_stream", "write_stream"} <= public
|
assert {"open", "read_blob", "write_blob", "read_stream", "write_stream"} <= public
|
||||||
|
assert isinstance(swactor.O_RDONLY, int)
|
||||||
assert not public & {
|
assert not public & {
|
||||||
"actor",
|
"actor",
|
||||||
"actor_id",
|
"actor_id",
|
||||||
|
|
@ -201,6 +203,54 @@ def test_write_blob_seals_cleanly_and_exception_aborts(monkeypatch, host):
|
||||||
|
|
||||||
swactor.run(main)
|
swactor.run(main)
|
||||||
|
|
||||||
|
def test_raw_descriptor_blob_io_mapping_and_errno(monkeypatch, host):
|
||||||
|
install_host_env(monkeypatch, host)
|
||||||
|
|
||||||
|
async def main(ctx):
|
||||||
|
logical = "/runs/self/results/raw-python"
|
||||||
|
writer = await ctx.data.open(
|
||||||
|
logical,
|
||||||
|
swactor.O_WRONLY | swactor.O_CREAT | swactor.O_TRUNC,
|
||||||
|
length=8,
|
||||||
|
)
|
||||||
|
assert isinstance(writer, swactor.Descriptor)
|
||||||
|
assert writer.kind == "blob"
|
||||||
|
assert await writer.write(b"abc") == 3
|
||||||
|
assert await writer.writefrom(b"defgh") == 5
|
||||||
|
await writer.close()
|
||||||
|
with pytest.raises(OSError) as closed:
|
||||||
|
await writer.close()
|
||||||
|
assert closed.value.errno == errno.EBADF
|
||||||
|
|
||||||
|
reader = await ctx.data.open(logical, swactor.O_RDONLY)
|
||||||
|
destination = bytearray(b"\xa5" * 10)
|
||||||
|
assert await reader.readinto(memoryview(destination)[1:7]) == 6
|
||||||
|
assert destination == b"\xa5abcdef\xa5\xa5\xa5"
|
||||||
|
assert await reader.read(8) == b"gh"
|
||||||
|
assert await reader.read(8) == b""
|
||||||
|
with pytest.raises(OSError) as wrong_access:
|
||||||
|
await reader.write(b"x")
|
||||||
|
assert wrong_access.value.errno == errno.EBADF
|
||||||
|
|
||||||
|
mapping = await reader.map(length=8)
|
||||||
|
assert isinstance(mapping, swactor.DescriptorMapping)
|
||||||
|
exported = memoryview(mapping)
|
||||||
|
assert exported.readonly
|
||||||
|
await reader.close()
|
||||||
|
assert bytes(exported) == b"abcdefgh"
|
||||||
|
with pytest.raises(BufferError, match="active buffer exports"):
|
||||||
|
mapping.close()
|
||||||
|
exported.release()
|
||||||
|
mapping.close()
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
await ctx.data.open("/models/raw-missing", swactor.O_RDONLY)
|
||||||
|
with pytest.raises(OSError) as unsupported:
|
||||||
|
await ctx.data.open(logical, swactor.O_RDONLY | swactor.O_NONBLOCK)
|
||||||
|
assert unsupported.value.errno in {errno.ENOTSUP, errno.EOPNOTSUPP}
|
||||||
|
|
||||||
|
swactor.run(main)
|
||||||
|
|
||||||
|
|
||||||
def test_missing_path_and_authorization_are_typed(monkeypatch, host):
|
def test_missing_path_and_authorization_are_typed(monkeypatch, host):
|
||||||
install_host_env(monkeypatch, host)
|
install_host_env(monkeypatch, host)
|
||||||
|
|
@ -217,14 +267,16 @@ def test_missing_path_and_authorization_are_typed(monkeypatch, host):
|
||||||
def test_native_stream_round_trip_has_ordered_eof(monkeypatch, host):
|
def test_native_stream_round_trip_has_ordered_eof(monkeypatch, host):
|
||||||
install_host_env(monkeypatch, host)
|
install_host_env(monkeypatch, host)
|
||||||
received = []
|
received = []
|
||||||
|
raw_received = []
|
||||||
|
|
||||||
async def main(ctx):
|
async def main(ctx):
|
||||||
async def receive():
|
async def receive():
|
||||||
reader = await ctx.data.read_stream(
|
reader = await ctx.data.read_stream(
|
||||||
"/runs/self/results/predictions"
|
"/runs/self/results/predictions"
|
||||||
)
|
)
|
||||||
while (chunk := await reader.read()) is not None:
|
buffer = bytearray(3)
|
||||||
received.append(chunk)
|
while (count := await reader.readinto(buffer)) != 0:
|
||||||
|
received.append(bytes(buffer[:count]))
|
||||||
|
|
||||||
receiver = asyncio.create_task(receive())
|
receiver = asyncio.create_task(receive())
|
||||||
async with ctx.data.write_stream(
|
async with ctx.data.write_stream(
|
||||||
|
|
@ -234,8 +286,20 @@ def test_native_stream_round_trip_has_ordered_eof(monkeypatch, host):
|
||||||
await stream.write(b"result")
|
await stream.write(b"result")
|
||||||
await receiver
|
await receiver
|
||||||
|
|
||||||
|
raw_reader, raw_writer = await asyncio.gather(
|
||||||
|
ctx.data.open("/runs/self/results/predictions", swactor.O_RDONLY),
|
||||||
|
ctx.data.open("/runs/self/results/predictions", swactor.O_WRONLY),
|
||||||
|
)
|
||||||
|
assert await raw_writer.write(b"raw-stream") == len(b"raw-stream")
|
||||||
|
await raw_writer.close()
|
||||||
|
buffer = bytearray(4)
|
||||||
|
while (count := await raw_reader.readinto(buffer)) != 0:
|
||||||
|
raw_received.append(bytes(buffer[:count]))
|
||||||
|
await raw_reader.close()
|
||||||
|
|
||||||
swactor.run(main)
|
swactor.run(main)
|
||||||
assert b"".join(received) == b"native-result"
|
assert b"".join(received) == b"native-result"
|
||||||
|
assert b"".join(raw_received) == b"raw-stream"
|
||||||
assert "SWACTOR_DATA_PLANE_OUTPUT" not in host.env()
|
assert "SWACTOR_DATA_PLANE_OUTPUT" not in host.env()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,15 @@ parking_lot = "0.12"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
criterion = { version = "0.5", default-features = false }
|
||||||
futures-lite = "2"
|
futures-lite = "2"
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "descriptor_performance"
|
||||||
|
harness = false
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
|
|
|
||||||
81
crates/data-plane/benches/descriptor_performance.rs
Executable file
81
crates/data-plane/benches/descriptor_performance.rs
Executable file
|
|
@ -0,0 +1,81 @@
|
||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
|
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
|
||||||
|
use data_plane::arena::{ArenaConfig, ArenaManager, NodeId};
|
||||||
|
use data_plane::byte_ring::{ByteRingSpec, Endpoint, RecordKind, Role, attach, install};
|
||||||
|
|
||||||
|
const PAYLOAD_LEN: usize = 64 * 1024;
|
||||||
|
|
||||||
|
fn endpoints() -> (ArenaManager, Endpoint, Endpoint) {
|
||||||
|
let mut arena = ArenaManager::boot(ArenaConfig {
|
||||||
|
node_id: NodeId(91),
|
||||||
|
reservation_ceiling: 2 << 20,
|
||||||
|
base_alignment: 64,
|
||||||
|
})
|
||||||
|
.expect("benchmark arena");
|
||||||
|
let handle = install(
|
||||||
|
&mut arena,
|
||||||
|
ByteRingSpec {
|
||||||
|
capacity: (PAYLOAD_LEN + 5) as u64,
|
||||||
|
generation: 1,
|
||||||
|
alignment: 64,
|
||||||
|
request_id: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("benchmark ring");
|
||||||
|
let producer = attach(&arena, handle, Role::Producer).expect("benchmark producer");
|
||||||
|
let consumer = attach(&arena, handle, Role::Consumer).expect("benchmark consumer");
|
||||||
|
(arena, producer, consumer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor_stream_throughput(criterion: &mut Criterion) {
|
||||||
|
let payload = vec![0x5a_u8; PAYLOAD_LEN];
|
||||||
|
let mut destination = vec![0_u8; PAYLOAD_LEN];
|
||||||
|
let (_arena, mut producer, mut consumer) = endpoints();
|
||||||
|
let mut group = criterion.benchmark_group("descriptor_stream_read");
|
||||||
|
group.throughput(Throughput::Bytes(PAYLOAD_LEN as u64));
|
||||||
|
|
||||||
|
group.bench_with_input(
|
||||||
|
BenchmarkId::new("partial_allocation_free", PAYLOAD_LEN),
|
||||||
|
&PAYLOAD_LEN,
|
||||||
|
|bencher, _| {
|
||||||
|
bencher.iter(|| {
|
||||||
|
producer
|
||||||
|
.send_record(RecordKind::Data, &payload)
|
||||||
|
.expect("publish payload");
|
||||||
|
let cursor = consumer
|
||||||
|
.record_cursor()
|
||||||
|
.expect("read cursor")
|
||||||
|
.expect("committed record");
|
||||||
|
let count = consumer
|
||||||
|
.copy_record_range(cursor, 0, &mut destination)
|
||||||
|
.expect("copy payload");
|
||||||
|
consumer
|
||||||
|
.release_record_cursor(cursor)
|
||||||
|
.expect("release payload");
|
||||||
|
criterion::black_box(count)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
group.bench_with_input(
|
||||||
|
BenchmarkId::new("legacy_allocating_record", PAYLOAD_LEN),
|
||||||
|
&PAYLOAD_LEN,
|
||||||
|
|bencher, _| {
|
||||||
|
bencher.iter(|| {
|
||||||
|
producer
|
||||||
|
.send_record(RecordKind::Data, &payload)
|
||||||
|
.expect("publish payload");
|
||||||
|
let (_, bytes) = consumer
|
||||||
|
.recv_record()
|
||||||
|
.expect("receive payload")
|
||||||
|
.expect("committed record");
|
||||||
|
criterion::black_box(bytes)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(benches, descriptor_stream_throughput);
|
||||||
|
criterion_main!(benches);
|
||||||
|
|
@ -7,6 +7,8 @@ use std::ptr::NonNull;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest as _, Sha256};
|
use sha2::{Digest as _, Sha256};
|
||||||
|
|
||||||
|
|
@ -251,27 +253,60 @@ impl Blob {
|
||||||
self.guard.lease
|
self.guard.lease
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn map(&self) -> Result<BlobView, BlobError> {
|
pub(crate) fn copy_at(&self, offset: u64, destination: &mut [u8]) -> Result<usize, BlobError> {
|
||||||
|
if offset >= self.metadata.length || destination.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let count = destination
|
||||||
|
.len()
|
||||||
|
.min(usize::try_from(self.metadata.length - offset).unwrap_or(usize::MAX));
|
||||||
|
let view = self.map_range(offset, count as u64)?;
|
||||||
|
destination[..count].copy_from_slice(view.as_ref());
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn map_range(&self, offset: u64, length: u64) -> Result<BlobView, BlobError> {
|
||||||
|
let end = offset
|
||||||
|
.checked_add(length)
|
||||||
|
.ok_or(BlobError::RangeOutOfBounds {
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
arena_size: self.metadata.length,
|
||||||
|
})?;
|
||||||
|
if end > self.metadata.length {
|
||||||
|
return Err(BlobError::RangeOutOfBounds {
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
arena_size: self.metadata.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
validate_mapped_header(
|
validate_mapped_header(
|
||||||
&self.guard.arena,
|
&self.guard.arena,
|
||||||
self.guard.lease,
|
self.guard.lease,
|
||||||
&self.metadata,
|
&self.metadata,
|
||||||
BlobSharedState::Sealed,
|
BlobSharedState::Sealed,
|
||||||
)?;
|
)?;
|
||||||
let payload_offset = self.guard.lease.offset.checked_add(BLOB_HEADER_LEN).ok_or(
|
let payload_offset = self
|
||||||
BlobError::RangeOutOfBounds {
|
.guard
|
||||||
offset: self.guard.lease.offset,
|
.lease
|
||||||
length: self.guard.lease.length,
|
.offset
|
||||||
|
.checked_add(BLOB_HEADER_LEN)
|
||||||
|
.and_then(|start| start.checked_add(offset))
|
||||||
|
.ok_or(BlobError::RangeOutOfBounds {
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
arena_size: self.guard.arena.len() as u64,
|
arena_size: self.guard.arena.len() as u64,
|
||||||
},
|
})?;
|
||||||
)?;
|
let range = mapped_range(&self.guard.arena, payload_offset, length)?;
|
||||||
let range = mapped_range(&self.guard.arena, payload_offset, self.guard.lease.length)?;
|
|
||||||
Ok(BlobView {
|
Ok(BlobView {
|
||||||
guard: self.guard.clone(),
|
guard: self.guard.clone(),
|
||||||
payload_offset: range.start,
|
payload_offset: range.start,
|
||||||
length: range.len(),
|
length: range.len(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
pub fn map(&self) -> Result<BlobView, BlobError> {
|
||||||
|
self.map_range(0, self.metadata.length)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct BlobView {
|
pub struct BlobView {
|
||||||
|
|
@ -315,12 +350,17 @@ impl Deref for BlobView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) trait WritableViewObserver: Send + Sync + 'static {
|
||||||
|
fn view_released(&self);
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct WritableBlobLease {
|
pub(crate) struct WritableBlobLease {
|
||||||
arena: Arc<MappedArena>,
|
arena: Arc<MappedArena>,
|
||||||
lease: BlobLease,
|
lease: BlobLease,
|
||||||
metadata: BlobMetadata,
|
metadata: BlobMetadata,
|
||||||
active_view: AtomicBool,
|
active_view: AtomicBool,
|
||||||
finished: AtomicBool,
|
finished: AtomicBool,
|
||||||
|
view_observer: Mutex<Option<Arc<dyn WritableViewObserver>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WritableBlobLease {
|
impl WritableBlobLease {
|
||||||
|
|
@ -339,6 +379,7 @@ impl WritableBlobLease {
|
||||||
metadata,
|
metadata,
|
||||||
active_view: AtomicBool::new(false),
|
active_view: AtomicBool::new(false),
|
||||||
finished: AtomicBool::new(false),
|
finished: AtomicBool::new(false),
|
||||||
|
view_observer: Mutex::new(None),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -350,23 +391,111 @@ impl WritableBlobLease {
|
||||||
&self.metadata
|
&self.metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn map(self: &Arc<Self>) -> Result<WritableArenaView, BlobError> {
|
pub(crate) fn set_view_observer(&self, observer: Arc<dyn WritableViewObserver>) {
|
||||||
|
*self.view_observer.lock() = Some(observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_active_view(&self) -> bool {
|
||||||
|
self.active_view.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn copy_at(&self, offset: u64, destination: &mut [u8]) -> Result<usize, BlobError> {
|
||||||
|
if self.finished.load(Ordering::Acquire) {
|
||||||
|
return Err(BlobError::AlreadyFinished);
|
||||||
|
}
|
||||||
|
if self.active_view.load(Ordering::Acquire) {
|
||||||
|
return Err(BlobError::ActiveWritableView);
|
||||||
|
}
|
||||||
|
if offset >= self.metadata.length || destination.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let count = destination
|
||||||
|
.len()
|
||||||
|
.min(usize::try_from(self.metadata.length - offset).unwrap_or(usize::MAX));
|
||||||
|
let payload_offset = self.lease.offset + BLOB_HEADER_LEN + offset;
|
||||||
|
let range = mapped_range(&self.arena, payload_offset, count as u64)?;
|
||||||
|
// SAFETY: the mapped range was bounds-checked and no mutable view is active.
|
||||||
|
let source = unsafe {
|
||||||
|
std::slice::from_raw_parts(
|
||||||
|
self.arena.ptr_at(range.start).as_ptr().cast_const(),
|
||||||
|
range.len(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
destination[..count].copy_from_slice(source);
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn copy_from(&self, offset: u64, source: &[u8]) -> Result<usize, BlobError> {
|
||||||
|
if self.finished.load(Ordering::Acquire) {
|
||||||
|
return Err(BlobError::AlreadyFinished);
|
||||||
|
}
|
||||||
|
if self.active_view.load(Ordering::Acquire) {
|
||||||
|
return Err(BlobError::ActiveWritableView);
|
||||||
|
}
|
||||||
|
let length = source.len() as u64;
|
||||||
|
let end = offset
|
||||||
|
.checked_add(length)
|
||||||
|
.ok_or(BlobError::RangeOutOfBounds {
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
arena_size: self.metadata.length,
|
||||||
|
})?;
|
||||||
|
if end > self.metadata.length {
|
||||||
|
return Err(BlobError::RangeOutOfBounds {
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
arena_size: self.metadata.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if source.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let payload_offset = self.lease.offset + BLOB_HEADER_LEN + offset;
|
||||||
|
let range = mapped_range(&self.arena, payload_offset, length)?;
|
||||||
|
// SAFETY: the mapped range was bounds-checked and no other mutable view is active.
|
||||||
|
let destination = unsafe {
|
||||||
|
std::slice::from_raw_parts_mut(self.arena.ptr_at(range.start).as_ptr(), range.len())
|
||||||
|
};
|
||||||
|
destination.copy_from_slice(source);
|
||||||
|
Ok(source.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn map_range(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
offset: u64,
|
||||||
|
length: u64,
|
||||||
|
) -> Result<WritableArenaView, BlobError> {
|
||||||
|
let end = offset
|
||||||
|
.checked_add(length)
|
||||||
|
.ok_or(BlobError::RangeOutOfBounds {
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
arena_size: self.metadata.length,
|
||||||
|
})?;
|
||||||
|
if end > self.metadata.length {
|
||||||
|
return Err(BlobError::RangeOutOfBounds {
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
arena_size: self.metadata.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
if self.finished.load(Ordering::Acquire) {
|
if self.finished.load(Ordering::Acquire) {
|
||||||
return Err(BlobError::AlreadyFinished);
|
return Err(BlobError::AlreadyFinished);
|
||||||
}
|
}
|
||||||
self.active_view
|
self.active_view
|
||||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||||
.map_err(|_| BlobError::ActiveWritableView)?;
|
.map_err(|_| BlobError::ActiveWritableView)?;
|
||||||
let payload_offset =
|
let payload_offset = self
|
||||||
self.lease
|
.lease
|
||||||
.offset
|
.offset
|
||||||
.checked_add(BLOB_HEADER_LEN)
|
.checked_add(BLOB_HEADER_LEN)
|
||||||
|
.and_then(|start| start.checked_add(offset))
|
||||||
.ok_or(BlobError::RangeOutOfBounds {
|
.ok_or(BlobError::RangeOutOfBounds {
|
||||||
offset: self.lease.offset,
|
offset,
|
||||||
length: self.lease.length,
|
length,
|
||||||
arena_size: self.arena.len() as u64,
|
arena_size: self.arena.len() as u64,
|
||||||
})?;
|
})?;
|
||||||
let range = match mapped_range(&self.arena, payload_offset, self.lease.length) {
|
let range = match mapped_range(&self.arena, payload_offset, length) {
|
||||||
Ok(range) => range,
|
Ok(range) => range,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.active_view.store(false, Ordering::Release);
|
self.active_view.store(false, Ordering::Release);
|
||||||
|
|
@ -379,6 +508,9 @@ impl WritableBlobLease {
|
||||||
length: range.len(),
|
length: range.len(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
pub(crate) fn map(self: &Arc<Self>) -> Result<WritableArenaView, BlobError> {
|
||||||
|
self.map_range(0, self.metadata.length)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn seal(&self) -> Result<BlobMetadata, BlobError> {
|
pub(crate) fn seal(&self) -> Result<BlobMetadata, BlobError> {
|
||||||
if self.active_view.load(Ordering::Acquire) {
|
if self.active_view.load(Ordering::Acquire) {
|
||||||
|
|
@ -483,6 +615,10 @@ impl DerefMut for WritableArenaView {
|
||||||
impl Drop for WritableArenaView {
|
impl Drop for WritableArenaView {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.owner.active_view.store(false, Ordering::Release);
|
self.owner.active_view.store(false, Ordering::Release);
|
||||||
|
let observer = self.owner.view_observer.lock().clone();
|
||||||
|
if let Some(observer) = observer {
|
||||||
|
observer.view_released();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ pub const OFF_CAPACITY: u64 = 8;
|
||||||
pub const OFF_GENERATION: u64 = 16;
|
pub const OFF_GENERATION: u64 = 16;
|
||||||
pub const OFF_COMMIT: u64 = 24;
|
pub const OFF_COMMIT: u64 = 24;
|
||||||
pub const OFF_CONSUME: u64 = 32;
|
pub const OFF_CONSUME: u64 = 32;
|
||||||
|
pub const OFF_TERMINAL: u64 = 40;
|
||||||
|
|
||||||
const ZERO_CHUNK: usize = 4 * 1024;
|
const ZERO_CHUNK: usize = 4 * 1024;
|
||||||
|
|
||||||
|
|
@ -128,6 +129,30 @@ pub struct RecordMeta {
|
||||||
pub len: u64,
|
pub len: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RecordCursor {
|
||||||
|
kind: RecordKind,
|
||||||
|
payload_start: u64,
|
||||||
|
payload_len: u64,
|
||||||
|
record_start: u64,
|
||||||
|
record_len: u64,
|
||||||
|
generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordCursor {
|
||||||
|
pub const fn kind(self) -> RecordKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(self) -> usize {
|
||||||
|
self.payload_len as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn is_empty(self) -> bool {
|
||||||
|
self.payload_len == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Borrowed committed record. Dropping the view releases the complete framed
|
/// Borrowed committed record. Dropping the view releases the complete framed
|
||||||
/// record back to the producer.
|
/// record back to the producer.
|
||||||
pub struct PinnedRecord<'a> {
|
pub struct PinnedRecord<'a> {
|
||||||
|
|
@ -557,6 +582,15 @@ pub fn attach_mapped(
|
||||||
Ok(endpoint)
|
Ok(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn mark_peer_terminated_mapped(
|
||||||
|
arena: &crate::mapped_arena::MappedArena,
|
||||||
|
handle: RingHandle,
|
||||||
|
) -> Result<(), AttachError> {
|
||||||
|
let endpoint = attach_mapped(arena, handle, Role::Producer)?;
|
||||||
|
endpoint.atomic(OFF_TERMINAL).store(1, Ordering::Release);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn lease_ring(
|
fn lease_ring(
|
||||||
arena: &mut ArenaManager,
|
arena: &mut ArenaManager,
|
||||||
request_id: u64,
|
request_id: u64,
|
||||||
|
|
@ -572,6 +606,7 @@ fn lease_ring(
|
||||||
(1, Some(ArenaEvent::RingLeaseRejected { reason, .. })) => {
|
(1, Some(ArenaEvent::RingLeaseRejected { reason, .. })) => {
|
||||||
Err(InstallError::LeaseRejected(reason))
|
Err(InstallError::LeaseRejected(reason))
|
||||||
}
|
}
|
||||||
|
|
||||||
(1, Some(ArenaEvent::RingLeaseQueued { .. })) => Err(InstallError::LeaseQueued),
|
(1, Some(ArenaEvent::RingLeaseQueued { .. })) => Err(InstallError::LeaseQueued),
|
||||||
_ => Err(InstallError::UnexpectedLeaseOutcome),
|
_ => Err(InstallError::UnexpectedLeaseOutcome),
|
||||||
}
|
}
|
||||||
|
|
@ -586,6 +621,13 @@ impl Endpoint {
|
||||||
self.role
|
self.role
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn peer_terminated(&self) -> Result<bool, FlowError> {
|
||||||
|
self.validate_fixed().map_err(FlowError::Corrupt)?;
|
||||||
|
self.validate_generation(self.info.generation)
|
||||||
|
.map_err(FlowError::Corrupt)?;
|
||||||
|
Ok(self.atomic(OFF_TERMINAL).load(Ordering::Acquire) != 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Current published producer and consumer positions. This role-neutral
|
/// Current published producer and consumer positions. This role-neutral
|
||||||
/// observation is used only to wait for an already-committed clean close
|
/// observation is used only to wait for an already-committed clean close
|
||||||
/// to enter the downstream bounded transport.
|
/// to enter the downstream bounded transport.
|
||||||
|
|
@ -764,6 +806,25 @@ impl Endpoint {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn copy_into_slice(&self, stream_pos: u64, destination: &mut [u8]) {
|
||||||
|
let capacity = self.info.capacity as usize;
|
||||||
|
let start = (stream_pos % self.info.capacity) as usize;
|
||||||
|
let first = destination.len().min(capacity - start);
|
||||||
|
// SAFETY: the caller validated the source range against committed bytes.
|
||||||
|
unsafe {
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
self.data_ptr().add(start),
|
||||||
|
destination.as_mut_ptr(),
|
||||||
|
first,
|
||||||
|
);
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
self.data_ptr(),
|
||||||
|
destination.as_mut_ptr().add(first),
|
||||||
|
destination.len() - first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn copy_out(&self, stream_pos: u64, len: u64) -> Vec<u8> {
|
fn copy_out(&self, stream_pos: u64, len: u64) -> Vec<u8> {
|
||||||
let capacity = self.info.capacity as usize;
|
let capacity = self.info.capacity as usize;
|
||||||
let start = (stream_pos % self.info.capacity) as usize;
|
let start = (stream_pos % self.info.capacity) as usize;
|
||||||
|
|
@ -805,6 +866,14 @@ impl Endpoint {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn writable_payload_capacity(&self) -> Result<u64, FlowError> {
|
||||||
|
self.check("writable_payload_capacity", Role::Producer)?;
|
||||||
|
self.validate_generation(self.info.generation)
|
||||||
|
.map_err(FlowError::Corrupt)?;
|
||||||
|
let used = self.commit_cursor() - self.consume_cursor();
|
||||||
|
Ok((self.info.capacity - used).saturating_sub(RECORD_HEADER_LEN))
|
||||||
|
}
|
||||||
|
|
||||||
/// Copy bytes into the reserved (producer-side) span.
|
/// Copy bytes into the reserved (producer-side) span.
|
||||||
pub fn write(&self, reservation: &Reservation, bytes: &[u8]) -> Result<(), FlowError> {
|
pub fn write(&self, reservation: &Reservation, bytes: &[u8]) -> Result<(), FlowError> {
|
||||||
self.check("write", Role::Producer)?;
|
self.check("write", Role::Producer)?;
|
||||||
|
|
@ -996,6 +1065,97 @@ impl Endpoint {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Acquire an owned cursor for the next complete record. The cursor does
|
||||||
|
/// not release capacity and remains valid only while that record is first.
|
||||||
|
pub fn record_cursor(&self) -> Result<Option<RecordCursor>, FlowError> {
|
||||||
|
self.check("record_cursor", Role::Consumer)?;
|
||||||
|
self.validate_generation(self.info.generation)
|
||||||
|
.map_err(FlowError::Corrupt)?;
|
||||||
|
let (commit, consume) = (self.commit_cursor(), self.consume_cursor());
|
||||||
|
let readable = commit - consume;
|
||||||
|
if readable < RECORD_HEADER_LEN {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let mut prefix = [0_u8; RECORD_HEADER_LEN as usize];
|
||||||
|
self.copy_into_slice(consume, &mut prefix);
|
||||||
|
let kind = RecordKind::from_byte(prefix[0])
|
||||||
|
.ok_or(FlowError::BadRecord(RecordError::InvalidKind(prefix[0])))?;
|
||||||
|
let payload_len = u64::from(u32::from_le_bytes(prefix[1..5].try_into().unwrap()));
|
||||||
|
let record_len = RECORD_HEADER_LEN + payload_len;
|
||||||
|
if record_len > self.info.capacity {
|
||||||
|
return Err(FlowError::BadRecord(RecordError::LengthExceedsCapacity {
|
||||||
|
len: payload_len,
|
||||||
|
capacity: self.info.capacity,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if readable < record_len {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(RecordCursor {
|
||||||
|
kind,
|
||||||
|
payload_start: consume + RECORD_HEADER_LEN,
|
||||||
|
payload_len,
|
||||||
|
record_start: consume,
|
||||||
|
record_len,
|
||||||
|
generation: self.info.generation,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn copy_record_range(
|
||||||
|
&self,
|
||||||
|
cursor: RecordCursor,
|
||||||
|
offset: u64,
|
||||||
|
destination: &mut [u8],
|
||||||
|
) -> Result<usize, FlowError> {
|
||||||
|
self.check("copy_record_range", Role::Consumer)?;
|
||||||
|
let generation = self.field_u64(OFF_GENERATION);
|
||||||
|
if generation != cursor.generation {
|
||||||
|
return Err(FlowError::StaleReservation {
|
||||||
|
reservation: cursor.generation,
|
||||||
|
ring: generation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let consume = self.consume_cursor();
|
||||||
|
if consume != cursor.record_start {
|
||||||
|
return Err(FlowError::PinnedRecordMoved {
|
||||||
|
expected: cursor.record_start,
|
||||||
|
found: consume,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if offset > cursor.payload_len {
|
||||||
|
return Err(FlowError::BeyondCommitted {
|
||||||
|
requested: offset,
|
||||||
|
readable: cursor.payload_len,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let count = destination
|
||||||
|
.len()
|
||||||
|
.min(usize::try_from(cursor.payload_len - offset).unwrap_or(usize::MAX));
|
||||||
|
self.copy_into_slice(cursor.payload_start + offset, &mut destination[..count]);
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release_record_cursor(&mut self, cursor: RecordCursor) -> Result<(), FlowError> {
|
||||||
|
self.check("release_record_cursor", Role::Consumer)?;
|
||||||
|
let generation = self.field_u64(OFF_GENERATION);
|
||||||
|
if generation != cursor.generation {
|
||||||
|
return Err(FlowError::StaleReservation {
|
||||||
|
reservation: cursor.generation,
|
||||||
|
ring: generation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let consume = self.consume_cursor();
|
||||||
|
if consume != cursor.record_start {
|
||||||
|
return Err(FlowError::PinnedRecordMoved {
|
||||||
|
expected: cursor.record_start,
|
||||||
|
found: consume,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.atomic(OFF_CONSUME)
|
||||||
|
.store(cursor.record_start + cursor.record_len, Ordering::Release);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Receive one complete record; `Ok(None)` when nothing (or only a
|
/// Receive one complete record; `Ok(None)` when nothing (or only a
|
||||||
/// torn, uncommitted prefix) is readable.
|
/// torn, uncommitted prefix) is readable.
|
||||||
pub fn recv_record(&mut self) -> Result<Option<(RecordKind, Vec<u8>)>, FlowError> {
|
pub fn recv_record(&mut self) -> Result<Option<(RecordKind, Vec<u8>)>, FlowError> {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -39,6 +39,12 @@ pub enum EntryKind {
|
||||||
Stream,
|
Stream,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NamespaceNode {
|
||||||
|
pub kind: EntryKind,
|
||||||
|
pub revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum StreamRole {
|
pub enum StreamRole {
|
||||||
Source,
|
Source,
|
||||||
|
|
@ -64,6 +70,7 @@ pub struct StreamMatch {
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum NamespaceError {
|
pub enum NamespaceError {
|
||||||
PathNotFound(DataPath),
|
PathNotFound(DataPath),
|
||||||
|
PathExists(DataPath),
|
||||||
WrongEntryType {
|
WrongEntryType {
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
expected: EntryKind,
|
expected: EntryKind,
|
||||||
|
|
@ -89,6 +96,7 @@ impl fmt::Display for NamespaceError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::PathNotFound(path) => write!(f, "data path not found: {path}"),
|
Self::PathNotFound(path) => write!(f, "data path not found: {path}"),
|
||||||
|
Self::PathExists(path) => write!(f, "data path already exists: {path}"),
|
||||||
Self::WrongEntryType {
|
Self::WrongEntryType {
|
||||||
path,
|
path,
|
||||||
expected,
|
expected,
|
||||||
|
|
@ -140,6 +148,7 @@ pub enum DataDirectoryIn {
|
||||||
length: u64,
|
length: u64,
|
||||||
recovery: SourceRecovery,
|
recovery: SourceRecovery,
|
||||||
operation_id: OperationId,
|
operation_id: OperationId,
|
||||||
|
reservation: Option<OperationId>,
|
||||||
reply_to: ActorAddress,
|
reply_to: ActorAddress,
|
||||||
},
|
},
|
||||||
Resolve {
|
Resolve {
|
||||||
|
|
@ -147,6 +156,23 @@ pub enum DataDirectoryIn {
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
reply_to: ActorAddress,
|
reply_to: ActorAddress,
|
||||||
},
|
},
|
||||||
|
Lookup {
|
||||||
|
request_id: DirectoryRequestId,
|
||||||
|
path: DataPath,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
},
|
||||||
|
ReserveBlob {
|
||||||
|
request_id: DirectoryRequestId,
|
||||||
|
path: DataPath,
|
||||||
|
operation_id: OperationId,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
},
|
||||||
|
ReleaseBlobReservation {
|
||||||
|
request_id: DirectoryRequestId,
|
||||||
|
path: DataPath,
|
||||||
|
operation_id: OperationId,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
},
|
||||||
Unregister {
|
Unregister {
|
||||||
request_id: DirectoryRequestId,
|
request_id: DirectoryRequestId,
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
|
|
@ -160,6 +186,8 @@ pub enum DataDirectoryIn {
|
||||||
descriptor: Vec<u8>,
|
descriptor: Vec<u8>,
|
||||||
endpoint: ActorAddress,
|
endpoint: ActorAddress,
|
||||||
replace: bool,
|
replace: bool,
|
||||||
|
ensure: bool,
|
||||||
|
expected_revision: Option<u64>,
|
||||||
operation_id: OperationId,
|
operation_id: OperationId,
|
||||||
reply_to: ActorAddress,
|
reply_to: ActorAddress,
|
||||||
},
|
},
|
||||||
|
|
@ -193,6 +221,21 @@ pub enum DataDirectoryOut {
|
||||||
authority_epoch: u64,
|
authority_epoch: u64,
|
||||||
result: Result<BlobBinding, NamespaceError>,
|
result: Result<BlobBinding, NamespaceError>,
|
||||||
},
|
},
|
||||||
|
LookedUp {
|
||||||
|
request_id: DirectoryRequestId,
|
||||||
|
authority_epoch: u64,
|
||||||
|
result: Result<NamespaceNode, NamespaceError>,
|
||||||
|
},
|
||||||
|
BlobReserved {
|
||||||
|
request_id: DirectoryRequestId,
|
||||||
|
authority_epoch: u64,
|
||||||
|
result: Result<(), NamespaceError>,
|
||||||
|
},
|
||||||
|
BlobReservationReleased {
|
||||||
|
request_id: DirectoryRequestId,
|
||||||
|
authority_epoch: u64,
|
||||||
|
result: Result<(), NamespaceError>,
|
||||||
|
},
|
||||||
Unregistered {
|
Unregistered {
|
||||||
request_id: DirectoryRequestId,
|
request_id: DirectoryRequestId,
|
||||||
authority_epoch: u64,
|
authority_epoch: u64,
|
||||||
|
|
@ -214,6 +257,9 @@ impl DataDirectoryOut {
|
||||||
match self {
|
match self {
|
||||||
Self::Registered { request_id, .. }
|
Self::Registered { request_id, .. }
|
||||||
| Self::Resolved { request_id, .. }
|
| Self::Resolved { request_id, .. }
|
||||||
|
| Self::LookedUp { request_id, .. }
|
||||||
|
| Self::BlobReserved { request_id, .. }
|
||||||
|
| Self::BlobReservationReleased { request_id, .. }
|
||||||
| Self::Unregistered { request_id, .. }
|
| Self::Unregistered { request_id, .. }
|
||||||
| Self::StreamOpened { request_id, .. }
|
| Self::StreamOpened { request_id, .. }
|
||||||
| Self::StreamClosed { request_id, .. } => *request_id,
|
| Self::StreamClosed { request_id, .. } => *request_id,
|
||||||
|
|
@ -229,10 +275,22 @@ pub enum NamespaceRequest {
|
||||||
length: u64,
|
length: u64,
|
||||||
recovery: SourceRecovery,
|
recovery: SourceRecovery,
|
||||||
operation_id: OperationId,
|
operation_id: OperationId,
|
||||||
|
reservation: Option<OperationId>,
|
||||||
},
|
},
|
||||||
Resolve {
|
Resolve {
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
},
|
},
|
||||||
|
Lookup {
|
||||||
|
path: DataPath,
|
||||||
|
},
|
||||||
|
ReserveBlob {
|
||||||
|
path: DataPath,
|
||||||
|
operation_id: OperationId,
|
||||||
|
},
|
||||||
|
ReleaseBlobReservation {
|
||||||
|
path: DataPath,
|
||||||
|
operation_id: OperationId,
|
||||||
|
},
|
||||||
Unregister {
|
Unregister {
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
operation_id: OperationId,
|
operation_id: OperationId,
|
||||||
|
|
@ -243,6 +301,8 @@ pub enum NamespaceRequest {
|
||||||
endpoint: ActorAddress,
|
endpoint: ActorAddress,
|
||||||
descriptor: Vec<u8>,
|
descriptor: Vec<u8>,
|
||||||
replace: bool,
|
replace: bool,
|
||||||
|
ensure: bool,
|
||||||
|
expected_revision: Option<u64>,
|
||||||
operation_id: OperationId,
|
operation_id: OperationId,
|
||||||
},
|
},
|
||||||
CloseStream {
|
CloseStream {
|
||||||
|
|
@ -259,6 +319,11 @@ pub enum NamespaceClientIn {
|
||||||
Cancel {
|
Cancel {
|
||||||
reply_to: ActorAddress,
|
reply_to: ActorAddress,
|
||||||
},
|
},
|
||||||
|
CancelBlobReservation {
|
||||||
|
path: DataPath,
|
||||||
|
operation_id: OperationId,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
},
|
||||||
DirectoryReply(DataDirectoryOut),
|
DirectoryReply(DataDirectoryOut),
|
||||||
TransferFailed {
|
TransferFailed {
|
||||||
destination: ActorAddress,
|
destination: ActorAddress,
|
||||||
|
|
@ -288,6 +353,7 @@ struct PendingStream {
|
||||||
descriptor: Vec<u8>,
|
descriptor: Vec<u8>,
|
||||||
reply_to: ActorAddress,
|
reply_to: ActorAddress,
|
||||||
revision: u64,
|
revision: u64,
|
||||||
|
opened_from_revision: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StreamOpenRequest {
|
struct StreamOpenRequest {
|
||||||
|
|
@ -296,6 +362,8 @@ struct StreamOpenRequest {
|
||||||
role: StreamRole,
|
role: StreamRole,
|
||||||
endpoint: ActorAddress,
|
endpoint: ActorAddress,
|
||||||
replace: bool,
|
replace: bool,
|
||||||
|
ensure: bool,
|
||||||
|
expected_revision: Option<u64>,
|
||||||
descriptor: Vec<u8>,
|
descriptor: Vec<u8>,
|
||||||
operation_id: OperationId,
|
operation_id: OperationId,
|
||||||
reply_to: ActorAddress,
|
reply_to: ActorAddress,
|
||||||
|
|
@ -316,6 +384,7 @@ pub struct DataDirectoryActor {
|
||||||
store: NamespaceStore,
|
store: NamespaceStore,
|
||||||
sources: BTreeMap<DataPath, RuntimeSource>,
|
sources: BTreeMap<DataPath, RuntimeSource>,
|
||||||
streams: BTreeMap<DataPath, RuntimeStream>,
|
streams: BTreeMap<DataPath, RuntimeStream>,
|
||||||
|
blob_reservations: BTreeMap<DataPath, OperationId>,
|
||||||
authority_epoch: u64,
|
authority_epoch: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,6 +408,7 @@ impl DataDirectoryActor {
|
||||||
store,
|
store,
|
||||||
sources,
|
sources,
|
||||||
streams: BTreeMap::new(),
|
streams: BTreeMap::new(),
|
||||||
|
blob_reservations: BTreeMap::new(),
|
||||||
authority_epoch,
|
authority_epoch,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -375,6 +445,9 @@ impl DataDirectoryActor {
|
||||||
operation_id: OperationId,
|
operation_id: OperationId,
|
||||||
retired: Option<ActorAddress>,
|
retired: Option<ActorAddress>,
|
||||||
) -> Result<MutationReceipt, NamespaceError> {
|
) -> Result<MutationReceipt, NamespaceError> {
|
||||||
|
if self.blob_reservations.contains_key(&path) {
|
||||||
|
return Err(NamespaceError::PathExists(path));
|
||||||
|
}
|
||||||
let request = MutationRequest::BindStream { path: path.clone() };
|
let request = MutationRequest::BindStream { path: path.clone() };
|
||||||
if let Some(replayed) = self.replay(operation_id, &request) {
|
if let Some(replayed) = self.replay(operation_id, &request) {
|
||||||
return replayed;
|
return replayed;
|
||||||
|
|
@ -388,6 +461,7 @@ impl DataDirectoryActor {
|
||||||
let mut next = self.store.snapshot().clone();
|
let mut next = self.store.snapshot().clone();
|
||||||
next.next_revision = next_revision;
|
next.next_revision = next_revision;
|
||||||
next.bindings.remove(&path);
|
next.bindings.remove(&path);
|
||||||
|
next.stream_nodes.insert(path.clone(), revision);
|
||||||
if let Some(retired) = retired
|
if let Some(retired) = retired
|
||||||
&& !next.retirements.contains(&retired)
|
&& !next.retirements.contains(&retired)
|
||||||
{
|
{
|
||||||
|
|
@ -440,10 +514,42 @@ impl DataDirectoryActor {
|
||||||
role,
|
role,
|
||||||
endpoint,
|
endpoint,
|
||||||
replace,
|
replace,
|
||||||
|
ensure,
|
||||||
|
expected_revision,
|
||||||
descriptor,
|
descriptor,
|
||||||
operation_id,
|
operation_id,
|
||||||
reply_to,
|
reply_to,
|
||||||
} = request;
|
} = request;
|
||||||
|
if !ensure {
|
||||||
|
let snapshot = self.store.snapshot();
|
||||||
|
let Some(current_revision) = snapshot.stream_nodes.get(&path).copied() else {
|
||||||
|
let error = if snapshot.bindings.contains_key(&path) {
|
||||||
|
NamespaceError::WrongEntryType {
|
||||||
|
path,
|
||||||
|
expected: EntryKind::Stream,
|
||||||
|
found: EntryKind::Blob,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NamespaceError::PathNotFound(path)
|
||||||
|
};
|
||||||
|
self.send_stream_result(ctx, request_id, reply_to, Err(error));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let pending_from_expected = matches!(
|
||||||
|
self.streams.get(&path),
|
||||||
|
Some(RuntimeStream::Pending(pending))
|
||||||
|
if pending.opened_from_revision == expected_revision
|
||||||
|
);
|
||||||
|
if expected_revision != Some(current_revision) && !pending_from_expected {
|
||||||
|
self.send_stream_result(
|
||||||
|
ctx,
|
||||||
|
request_id,
|
||||||
|
reply_to,
|
||||||
|
Err(NamespaceError::PathReplaced(path)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(RuntimeStream::Pending(pending)) = self.streams.get_mut(&path)
|
if let Some(RuntimeStream::Pending(pending)) = self.streams.get_mut(&path)
|
||||||
&& pending.operation_id == operation_id
|
&& pending.operation_id == operation_id
|
||||||
{
|
{
|
||||||
|
|
@ -583,6 +689,7 @@ impl DataDirectoryActor {
|
||||||
request_id,
|
request_id,
|
||||||
reply_to,
|
reply_to,
|
||||||
revision: receipt.revision,
|
revision: receipt.revision,
|
||||||
|
opened_from_revision: (!ensure).then_some(expected_revision).flatten(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -620,6 +727,41 @@ impl DataDirectoryActor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reserve_blob(
|
||||||
|
&mut self,
|
||||||
|
path: DataPath,
|
||||||
|
operation_id: OperationId,
|
||||||
|
) -> Result<(), NamespaceError> {
|
||||||
|
if self.store.snapshot().bindings.contains_key(&path)
|
||||||
|
|| self.store.snapshot().stream_nodes.contains_key(&path)
|
||||||
|
{
|
||||||
|
return Err(NamespaceError::PathExists(path));
|
||||||
|
}
|
||||||
|
match self.blob_reservations.get(&path) {
|
||||||
|
Some(existing) if *existing == operation_id => Ok(()),
|
||||||
|
Some(_) => Err(NamespaceError::PathExists(path)),
|
||||||
|
None => {
|
||||||
|
self.blob_reservations.insert(path, operation_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_blob_reservation(
|
||||||
|
&mut self,
|
||||||
|
path: &DataPath,
|
||||||
|
operation_id: OperationId,
|
||||||
|
) -> Result<(), NamespaceError> {
|
||||||
|
match self.blob_reservations.get(path) {
|
||||||
|
Some(existing) if *existing == operation_id => {
|
||||||
|
self.blob_reservations.remove(path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Some(_) => Err(NamespaceError::OperationConflict(operation_id)),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn register(
|
fn register(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
|
|
@ -627,6 +769,7 @@ impl DataDirectoryActor {
|
||||||
length: u64,
|
length: u64,
|
||||||
recovery: SourceRecovery,
|
recovery: SourceRecovery,
|
||||||
operation_id: OperationId,
|
operation_id: OperationId,
|
||||||
|
reservation: Option<OperationId>,
|
||||||
retired: Option<ActorAddress>,
|
retired: Option<ActorAddress>,
|
||||||
) -> Result<MutationReceipt, NamespaceError> {
|
) -> Result<MutationReceipt, NamespaceError> {
|
||||||
let request = MutationRequest::Register {
|
let request = MutationRequest::Register {
|
||||||
|
|
@ -637,6 +780,12 @@ impl DataDirectoryActor {
|
||||||
if let Some(replayed) = self.replay(operation_id, &request) {
|
if let Some(replayed) = self.replay(operation_id, &request) {
|
||||||
return replayed;
|
return replayed;
|
||||||
}
|
}
|
||||||
|
match self.blob_reservations.get(&path) {
|
||||||
|
Some(existing) if Some(*existing) == reservation => {}
|
||||||
|
Some(_) => return Err(NamespaceError::PathExists(path)),
|
||||||
|
None if reservation.is_some() => return Err(NamespaceError::PathReplaced(path)),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
let revision = self.store.snapshot().next_revision;
|
let revision = self.store.snapshot().next_revision;
|
||||||
let next_revision = revision
|
let next_revision = revision
|
||||||
.checked_add(1)
|
.checked_add(1)
|
||||||
|
|
@ -645,6 +794,7 @@ impl DataDirectoryActor {
|
||||||
let receipt = MutationReceipt { revision };
|
let receipt = MutationReceipt { revision };
|
||||||
let mut next = self.store.snapshot().clone();
|
let mut next = self.store.snapshot().clone();
|
||||||
next.next_revision = next_revision;
|
next.next_revision = next_revision;
|
||||||
|
next.stream_nodes.remove(&path);
|
||||||
next.bindings.insert(
|
next.bindings.insert(
|
||||||
path.clone(),
|
path.clone(),
|
||||||
PersistedBinding {
|
PersistedBinding {
|
||||||
|
|
@ -666,12 +816,15 @@ impl DataDirectoryActor {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
self.store.commit(next)?;
|
self.store.commit(next)?;
|
||||||
|
if reservation.is_some() {
|
||||||
|
self.blob_reservations.remove(&path);
|
||||||
|
}
|
||||||
self.sources.insert(path, RuntimeSource::Available(source));
|
self.sources.insert(path, RuntimeSource::Available(source));
|
||||||
Ok(receipt)
|
Ok(receipt)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve(&self, path: &DataPath) -> Result<BlobBinding, NamespaceError> {
|
fn resolve(&self, path: &DataPath) -> Result<BlobBinding, NamespaceError> {
|
||||||
if self.streams.contains_key(path) {
|
if self.store.snapshot().stream_nodes.contains_key(path) {
|
||||||
return Err(NamespaceError::WrongEntryType {
|
return Err(NamespaceError::WrongEntryType {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
expected: EntryKind::Blob,
|
expected: EntryKind::Blob,
|
||||||
|
|
@ -699,17 +852,43 @@ impl DataDirectoryActor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lookup(&self, path: &DataPath) -> Result<NamespaceNode, NamespaceError> {
|
||||||
|
if self.blob_reservations.contains_key(path) {
|
||||||
|
return Err(NamespaceError::PathExists(path.clone()));
|
||||||
|
}
|
||||||
|
let snapshot = self.store.snapshot();
|
||||||
|
match (snapshot.bindings.get(path), snapshot.stream_nodes.get(path)) {
|
||||||
|
(Some(binding), None) => Ok(NamespaceNode {
|
||||||
|
kind: EntryKind::Blob,
|
||||||
|
revision: binding.revision,
|
||||||
|
}),
|
||||||
|
(None, Some(revision)) => Ok(NamespaceNode {
|
||||||
|
kind: EntryKind::Stream,
|
||||||
|
revision: *revision,
|
||||||
|
}),
|
||||||
|
(None, None) => Err(NamespaceError::PathNotFound(path.clone())),
|
||||||
|
(Some(_), Some(_)) => Err(NamespaceError::Storage(format!(
|
||||||
|
"data path {path} is bound as both blob and stream"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn unregister(
|
fn unregister(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
operation_id: OperationId,
|
operation_id: OperationId,
|
||||||
retired: Option<ActorAddress>,
|
retired: Option<ActorAddress>,
|
||||||
) -> Result<MutationReceipt, NamespaceError> {
|
) -> Result<MutationReceipt, NamespaceError> {
|
||||||
|
if self.blob_reservations.contains_key(&path) {
|
||||||
|
return Err(NamespaceError::PathExists(path));
|
||||||
|
}
|
||||||
let request = MutationRequest::Unregister { path: path.clone() };
|
let request = MutationRequest::Unregister { path: path.clone() };
|
||||||
if let Some(replayed) = self.replay(operation_id, &request) {
|
if let Some(replayed) = self.replay(operation_id, &request) {
|
||||||
return replayed;
|
return replayed;
|
||||||
}
|
}
|
||||||
if !self.store.snapshot().bindings.contains_key(&path) {
|
if !self.store.snapshot().bindings.contains_key(&path)
|
||||||
|
&& !self.store.snapshot().stream_nodes.contains_key(&path)
|
||||||
|
{
|
||||||
let mut next = self.store.snapshot().clone();
|
let mut next = self.store.snapshot().clone();
|
||||||
next.operations.insert(
|
next.operations.insert(
|
||||||
operation_id,
|
operation_id,
|
||||||
|
|
@ -732,6 +911,7 @@ impl DataDirectoryActor {
|
||||||
let mut next = self.store.snapshot().clone();
|
let mut next = self.store.snapshot().clone();
|
||||||
next.next_revision = next_revision;
|
next.next_revision = next_revision;
|
||||||
next.bindings.remove(&path);
|
next.bindings.remove(&path);
|
||||||
|
next.stream_nodes.remove(&path);
|
||||||
if let Some(retired) = retired
|
if let Some(retired) = retired
|
||||||
&& !next.retirements.contains(&retired)
|
&& !next.retirements.contains(&retired)
|
||||||
{
|
{
|
||||||
|
|
@ -769,6 +949,7 @@ impl ActorInterface for DataDirectoryActor {
|
||||||
length,
|
length,
|
||||||
recovery,
|
recovery,
|
||||||
operation_id,
|
operation_id,
|
||||||
|
reservation,
|
||||||
reply_to,
|
reply_to,
|
||||||
} => {
|
} => {
|
||||||
let logical = path.clone();
|
let logical = path.clone();
|
||||||
|
|
@ -780,7 +961,15 @@ impl ActorInterface for DataDirectoryActor {
|
||||||
RuntimeSource::Available(actor) if *actor != source => Some(*actor),
|
RuntimeSource::Available(actor) if *actor != source => Some(*actor),
|
||||||
RuntimeSource::Available(_) | RuntimeSource::Unavailable(_) => None,
|
RuntimeSource::Available(_) | RuntimeSource::Unavailable(_) => None,
|
||||||
});
|
});
|
||||||
let result = self.register(path, source, length, recovery, operation_id, retired);
|
let result = self.register(
|
||||||
|
path,
|
||||||
|
source,
|
||||||
|
length,
|
||||||
|
recovery,
|
||||||
|
operation_id,
|
||||||
|
reservation,
|
||||||
|
retired,
|
||||||
|
);
|
||||||
if result.is_ok()
|
if result.is_ok()
|
||||||
&& let Some(retired) = retired
|
&& let Some(retired) = retired
|
||||||
{
|
{
|
||||||
|
|
@ -813,6 +1002,53 @@ impl ActorInterface for DataDirectoryActor {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
DataDirectoryIn::Lookup {
|
||||||
|
request_id,
|
||||||
|
path,
|
||||||
|
reply_to,
|
||||||
|
} => {
|
||||||
|
let result = self.lookup(&path);
|
||||||
|
let _ = ctx.send(
|
||||||
|
reply_to,
|
||||||
|
NamespaceClientIn::DirectoryReply(DataDirectoryOut::LookedUp {
|
||||||
|
request_id,
|
||||||
|
authority_epoch: self.authority_epoch,
|
||||||
|
result,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DataDirectoryIn::ReserveBlob {
|
||||||
|
request_id,
|
||||||
|
path,
|
||||||
|
operation_id,
|
||||||
|
reply_to,
|
||||||
|
} => {
|
||||||
|
let result = self.reserve_blob(path, operation_id);
|
||||||
|
let _ = ctx.send(
|
||||||
|
reply_to,
|
||||||
|
NamespaceClientIn::DirectoryReply(DataDirectoryOut::BlobReserved {
|
||||||
|
request_id,
|
||||||
|
authority_epoch: self.authority_epoch,
|
||||||
|
result,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DataDirectoryIn::ReleaseBlobReservation {
|
||||||
|
request_id,
|
||||||
|
path,
|
||||||
|
operation_id,
|
||||||
|
reply_to,
|
||||||
|
} => {
|
||||||
|
let result = self.release_blob_reservation(&path, operation_id);
|
||||||
|
let _ = ctx.send(
|
||||||
|
reply_to,
|
||||||
|
NamespaceClientIn::DirectoryReply(DataDirectoryOut::BlobReservationReleased {
|
||||||
|
request_id,
|
||||||
|
authority_epoch: self.authority_epoch,
|
||||||
|
result,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
DataDirectoryIn::Unregister {
|
DataDirectoryIn::Unregister {
|
||||||
request_id,
|
request_id,
|
||||||
path,
|
path,
|
||||||
|
|
@ -864,6 +1100,8 @@ impl ActorInterface for DataDirectoryActor {
|
||||||
endpoint,
|
endpoint,
|
||||||
descriptor,
|
descriptor,
|
||||||
replace,
|
replace,
|
||||||
|
ensure,
|
||||||
|
expected_revision,
|
||||||
operation_id,
|
operation_id,
|
||||||
reply_to,
|
reply_to,
|
||||||
} => self.open_stream(
|
} => self.open_stream(
|
||||||
|
|
@ -874,6 +1112,8 @@ impl ActorInterface for DataDirectoryActor {
|
||||||
role,
|
role,
|
||||||
endpoint,
|
endpoint,
|
||||||
replace,
|
replace,
|
||||||
|
ensure,
|
||||||
|
expected_revision,
|
||||||
descriptor,
|
descriptor,
|
||||||
operation_id,
|
operation_id,
|
||||||
reply_to,
|
reply_to,
|
||||||
|
|
@ -948,6 +1188,7 @@ impl NamespaceClientActor {
|
||||||
length,
|
length,
|
||||||
recovery,
|
recovery,
|
||||||
operation_id,
|
operation_id,
|
||||||
|
reservation,
|
||||||
} => DataDirectoryIn::Register {
|
} => DataDirectoryIn::Register {
|
||||||
request_id,
|
request_id,
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
|
|
@ -955,6 +1196,7 @@ impl NamespaceClientActor {
|
||||||
length: *length,
|
length: *length,
|
||||||
recovery: recovery.clone(),
|
recovery: recovery.clone(),
|
||||||
operation_id: *operation_id,
|
operation_id: *operation_id,
|
||||||
|
reservation: *reservation,
|
||||||
reply_to: ctx.self_addr(),
|
reply_to: ctx.self_addr(),
|
||||||
},
|
},
|
||||||
NamespaceRequest::Resolve { path } => DataDirectoryIn::Resolve {
|
NamespaceRequest::Resolve { path } => DataDirectoryIn::Resolve {
|
||||||
|
|
@ -962,6 +1204,25 @@ impl NamespaceClientActor {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
reply_to: ctx.self_addr(),
|
reply_to: ctx.self_addr(),
|
||||||
},
|
},
|
||||||
|
NamespaceRequest::Lookup { path } => DataDirectoryIn::Lookup {
|
||||||
|
request_id,
|
||||||
|
path: path.clone(),
|
||||||
|
reply_to: ctx.self_addr(),
|
||||||
|
},
|
||||||
|
NamespaceRequest::ReserveBlob { path, operation_id } => DataDirectoryIn::ReserveBlob {
|
||||||
|
request_id,
|
||||||
|
path: path.clone(),
|
||||||
|
operation_id: *operation_id,
|
||||||
|
reply_to: ctx.self_addr(),
|
||||||
|
},
|
||||||
|
NamespaceRequest::ReleaseBlobReservation { path, operation_id } => {
|
||||||
|
DataDirectoryIn::ReleaseBlobReservation {
|
||||||
|
request_id,
|
||||||
|
path: path.clone(),
|
||||||
|
operation_id: *operation_id,
|
||||||
|
reply_to: ctx.self_addr(),
|
||||||
|
}
|
||||||
|
}
|
||||||
NamespaceRequest::Unregister { path, operation_id } => DataDirectoryIn::Unregister {
|
NamespaceRequest::Unregister { path, operation_id } => DataDirectoryIn::Unregister {
|
||||||
request_id,
|
request_id,
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
|
|
@ -974,6 +1235,8 @@ impl NamespaceClientActor {
|
||||||
endpoint,
|
endpoint,
|
||||||
descriptor,
|
descriptor,
|
||||||
replace,
|
replace,
|
||||||
|
ensure,
|
||||||
|
expected_revision,
|
||||||
operation_id,
|
operation_id,
|
||||||
} => DataDirectoryIn::OpenStream {
|
} => DataDirectoryIn::OpenStream {
|
||||||
request_id,
|
request_id,
|
||||||
|
|
@ -982,6 +1245,8 @@ impl NamespaceClientActor {
|
||||||
endpoint: *endpoint,
|
endpoint: *endpoint,
|
||||||
descriptor: descriptor.clone(),
|
descriptor: descriptor.clone(),
|
||||||
replace: *replace,
|
replace: *replace,
|
||||||
|
ensure: *ensure,
|
||||||
|
expected_revision: *expected_revision,
|
||||||
operation_id: *operation_id,
|
operation_id: *operation_id,
|
||||||
reply_to: ctx.self_addr(),
|
reply_to: ctx.self_addr(),
|
||||||
},
|
},
|
||||||
|
|
@ -1058,6 +1323,25 @@ impl ActorInterface for NamespaceClientActor {
|
||||||
self.pending
|
self.pending
|
||||||
.retain(|_, pending| pending.reply_to != reply_to);
|
.retain(|_, pending| pending.reply_to != reply_to);
|
||||||
}
|
}
|
||||||
|
NamespaceClientIn::CancelBlobReservation {
|
||||||
|
path,
|
||||||
|
operation_id,
|
||||||
|
reply_to,
|
||||||
|
} => {
|
||||||
|
self.pending
|
||||||
|
.retain(|_, pending| pending.reply_to != reply_to);
|
||||||
|
if let Some(directory) = self.discovery.current_directory() {
|
||||||
|
let _ = ctx.send(
|
||||||
|
directory,
|
||||||
|
DataDirectoryIn::ReleaseBlobReservation {
|
||||||
|
request_id: DirectoryRequestId(0),
|
||||||
|
path,
|
||||||
|
operation_id,
|
||||||
|
reply_to: ctx.self_addr(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
NamespaceClientIn::DirectoryReply(reply) => {
|
NamespaceClientIn::DirectoryReply(reply) => {
|
||||||
if let Some(pending) = self.pending.remove(&reply.request_id()) {
|
if let Some(pending) = self.pending.remove(&reply.request_id()) {
|
||||||
let _ = ctx.send(pending.reply_to, reply);
|
let _ = ctx.send(pending.reply_to, reply);
|
||||||
|
|
@ -1155,6 +1439,7 @@ impl NamespaceClient {
|
||||||
length,
|
length,
|
||||||
recovery,
|
recovery,
|
||||||
operation_id,
|
operation_id,
|
||||||
|
reservation: None,
|
||||||
})
|
})
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
|
|
@ -1174,6 +1459,15 @@ impl NamespaceClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn lookup(&self, path: DataPath) -> Result<NamespaceNode, NamespaceError> {
|
||||||
|
match self.request(NamespaceRequest::Lookup { path }).await? {
|
||||||
|
DataDirectoryOut::LookedUp { result, .. } => result,
|
||||||
|
other => Err(NamespaceError::Protocol(format!(
|
||||||
|
"expected lookup reply, received {other:?}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn unregister(
|
pub async fn unregister(
|
||||||
&self,
|
&self,
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
|
|
@ -1204,6 +1498,8 @@ impl NamespaceClient {
|
||||||
role,
|
role,
|
||||||
endpoint,
|
endpoint,
|
||||||
replace,
|
replace,
|
||||||
|
ensure: true,
|
||||||
|
expected_revision: None,
|
||||||
descriptor: Vec::new(),
|
descriptor: Vec::new(),
|
||||||
operation_id,
|
operation_id,
|
||||||
})
|
})
|
||||||
|
|
@ -1335,6 +1631,7 @@ impl DirectoryClient {
|
||||||
length,
|
length,
|
||||||
recovery,
|
recovery,
|
||||||
operation_id,
|
operation_id,
|
||||||
|
reservation: None,
|
||||||
reply_to: *inbox.addr(),
|
reply_to: *inbox.addr(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -1370,6 +1667,29 @@ impl DirectoryClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn lookup(&self, path: DataPath) -> Result<NamespaceNode, NamespaceError> {
|
||||||
|
let inbox = self
|
||||||
|
.runtime
|
||||||
|
.new_inbox::<NamespaceClientIn>()
|
||||||
|
.map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?;
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
self.directory,
|
||||||
|
DataDirectoryIn::Lookup {
|
||||||
|
request_id: self.request_id(),
|
||||||
|
path,
|
||||||
|
reply_to: *inbox.addr(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?;
|
||||||
|
match self.receive(&inbox).await? {
|
||||||
|
DataDirectoryOut::LookedUp { result, .. } => result,
|
||||||
|
other => Err(NamespaceError::Protocol(format!(
|
||||||
|
"expected lookup reply, received {other:?}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn unregister(
|
pub async fn unregister(
|
||||||
&self,
|
&self,
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
|
|
@ -1426,6 +1746,8 @@ impl DirectoryClient {
|
||||||
role,
|
role,
|
||||||
endpoint,
|
endpoint,
|
||||||
replace,
|
replace,
|
||||||
|
ensure: true,
|
||||||
|
expected_revision: None,
|
||||||
operation_id,
|
operation_id,
|
||||||
descriptor: Vec::new(),
|
descriptor: Vec::new(),
|
||||||
reply_to: *inbox.addr(),
|
reply_to: *inbox.addr(),
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,8 @@ pub struct NamespaceSnapshot {
|
||||||
pub authority_epoch: u64,
|
pub authority_epoch: u64,
|
||||||
pub next_revision: u64,
|
pub next_revision: u64,
|
||||||
pub bindings: BTreeMap<DataPath, PersistedBinding>,
|
pub bindings: BTreeMap<DataPath, PersistedBinding>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stream_nodes: BTreeMap<DataPath, u64>,
|
||||||
pub operations: BTreeMap<OperationId, PersistedOperation>,
|
pub operations: BTreeMap<OperationId, PersistedOperation>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub retirements: Vec<ActorAddress>,
|
pub retirements: Vec<ActorAddress>,
|
||||||
|
|
@ -121,6 +123,7 @@ impl Default for NamespaceSnapshot {
|
||||||
next_revision: 1,
|
next_revision: 1,
|
||||||
bindings: BTreeMap::new(),
|
bindings: BTreeMap::new(),
|
||||||
operations: BTreeMap::new(),
|
operations: BTreeMap::new(),
|
||||||
|
stream_nodes: BTreeMap::new(),
|
||||||
retirements: Vec::new(),
|
retirements: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -143,6 +146,10 @@ impl NamespaceSnapshot {
|
||||||
.bindings
|
.bindings
|
||||||
.values()
|
.values()
|
||||||
.any(|binding| binding.revision == 0 || binding.revision >= self.next_revision)
|
.any(|binding| binding.revision == 0 || binding.revision >= self.next_revision)
|
||||||
|
|| self
|
||||||
|
.stream_nodes
|
||||||
|
.values()
|
||||||
|
.any(|revision| *revision == 0 || *revision >= self.next_revision)
|
||||||
{
|
{
|
||||||
return Err(NamespaceStoreError::Corrupt(
|
return Err(NamespaceStoreError::Corrupt(
|
||||||
"binding revision is outside the committed revision range".to_owned(),
|
"binding revision is outside the committed revision range".to_owned(),
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@ use serde::{Deserialize, Serialize};
|
||||||
use swactor::actor::ActorAddress;
|
use swactor::actor::ActorAddress;
|
||||||
use swactor_transport::{CodecRegistry, JsonCodec, NetworkMessage};
|
use swactor_transport::{CodecRegistry, JsonCodec, NetworkMessage};
|
||||||
|
|
||||||
use crate::blob::{BlobError, BlobLease, BlobMetadata};
|
use crate::blob::{BlobError, BlobLease, BlobMetadata, ContentDigest};
|
||||||
use crate::byte_ring::{RingHandle, Role};
|
use crate::byte_ring::{RingHandle, Role};
|
||||||
use crate::ids::BlobLeaseId;
|
use crate::ids::BlobLeaseId;
|
||||||
use crate::namespace::{EntryKind, NamespaceError, StreamIncarnation, StreamMatch};
|
use crate::namespace::{
|
||||||
|
EntryKind, NamespaceError, NamespaceNode, OperationId, StreamIncarnation, StreamMatch,
|
||||||
|
};
|
||||||
use crate::path::DataPath;
|
use crate::path::DataPath;
|
||||||
use crate::stream_transport::{StreamPeerDescriptor, StreamTransportEvent};
|
use crate::stream_transport::{StreamPeerDescriptor, StreamTransportEvent};
|
||||||
|
|
||||||
|
|
@ -45,11 +47,155 @@ impl JobCapability {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum DataOperation {
|
pub enum AccessMode {
|
||||||
ReadBlob,
|
ReadOnly,
|
||||||
WriteBlob,
|
WriteOnly,
|
||||||
ReadStream,
|
ReadWrite,
|
||||||
WriteStream,
|
}
|
||||||
|
|
||||||
|
impl AccessMode {
|
||||||
|
pub const fn can_read(self) -> bool {
|
||||||
|
matches!(self, Self::ReadOnly | Self::ReadWrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn can_write(self) -> bool {
|
||||||
|
matches!(self, Self::WriteOnly | Self::ReadWrite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BlobAllocation {
|
||||||
|
pub length: u64,
|
||||||
|
pub digest: Option<ContentDigest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct OpenOptions {
|
||||||
|
pub access: AccessMode,
|
||||||
|
pub create: bool,
|
||||||
|
pub exclusive: bool,
|
||||||
|
pub truncate: bool,
|
||||||
|
pub nonblocking: bool,
|
||||||
|
pub allocation: Option<BlobAllocation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenOptions {
|
||||||
|
pub const fn read_only() -> Self {
|
||||||
|
Self {
|
||||||
|
access: AccessMode::ReadOnly,
|
||||||
|
create: false,
|
||||||
|
exclusive: false,
|
||||||
|
truncate: false,
|
||||||
|
nonblocking: false,
|
||||||
|
allocation: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn staged_blob(length: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
access: AccessMode::WriteOnly,
|
||||||
|
create: true,
|
||||||
|
exclusive: false,
|
||||||
|
truncate: true,
|
||||||
|
nonblocking: false,
|
||||||
|
allocation: Some(BlobAllocation {
|
||||||
|
length,
|
||||||
|
digest: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<(), DataPlaneError> {
|
||||||
|
if self.nonblocking {
|
||||||
|
return Err(DataPlaneError::Unsupported(
|
||||||
|
"O_NONBLOCK is not supported".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.exclusive && !self.create {
|
||||||
|
return Err(DataPlaneError::InvalidArgument(
|
||||||
|
"O_EXCL requires O_CREAT".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (self.create || self.truncate) && !self.access.can_write() {
|
||||||
|
return Err(DataPlaneError::InvalidArgument(
|
||||||
|
"creation and truncation require write access".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.allocation.is_some() && !(self.access.can_write() && self.truncate) {
|
||||||
|
return Err(DataPlaneError::InvalidArgument(
|
||||||
|
"blob allocation requires a truncating writable open".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OpenOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::read_only()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum DescriptorKind {
|
||||||
|
Blob,
|
||||||
|
Stream,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DescriptorCapabilities(u16);
|
||||||
|
|
||||||
|
impl DescriptorCapabilities {
|
||||||
|
pub const READ: Self = Self(1 << 0);
|
||||||
|
pub const WRITE: Self = Self(1 << 1);
|
||||||
|
pub const MAP_HOST: Self = Self(1 << 2);
|
||||||
|
pub const MAP_DEVICE: Self = Self(1 << 3);
|
||||||
|
pub const SEEK: Self = Self(1 << 4);
|
||||||
|
pub const POLL: Self = Self(1 << 5);
|
||||||
|
pub const CONTROL: Self = Self(1 << 6);
|
||||||
|
|
||||||
|
pub const fn empty() -> Self {
|
||||||
|
Self(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn contains(self, capability: Self) -> bool {
|
||||||
|
self.0 & capability.0 == capability.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn union(self, capability: Self) -> Self {
|
||||||
|
Self(self.0 | capability.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn bits(self) -> u16 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Errno {
|
||||||
|
Eacces,
|
||||||
|
Eagain,
|
||||||
|
Ebadf,
|
||||||
|
Ebusy,
|
||||||
|
Ecanceled,
|
||||||
|
Econnreset,
|
||||||
|
Eexist,
|
||||||
|
Einval,
|
||||||
|
Eio,
|
||||||
|
Enodev,
|
||||||
|
Enoent,
|
||||||
|
Enomem,
|
||||||
|
Enospc,
|
||||||
|
Enotsup,
|
||||||
|
Enxio,
|
||||||
|
Epipe,
|
||||||
|
Estale,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum OpenPolicy {
|
||||||
|
Ordinary,
|
||||||
|
EnsureStream { replace: bool },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -98,19 +244,27 @@ impl From<BlobError> for BlobFailure {
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum DataPlaneError {
|
pub enum DataPlaneError {
|
||||||
InvalidPath(String),
|
InvalidPath(String),
|
||||||
|
InvalidArgument(String),
|
||||||
InvalidCapability,
|
InvalidCapability,
|
||||||
Attachment(AttachmentFailure),
|
Attachment(AttachmentFailure),
|
||||||
SessionNotRunning,
|
SessionNotRunning,
|
||||||
SessionFailed(String),
|
SessionFailed(String),
|
||||||
Unauthorized {
|
Unauthorized {
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
operation: DataOperation,
|
access: AccessMode,
|
||||||
},
|
},
|
||||||
PathNotFound(DataPath),
|
PathNotFound(DataPath),
|
||||||
|
PathExists(DataPath),
|
||||||
SourceFailure(String),
|
SourceFailure(String),
|
||||||
ArenaExhausted,
|
ArenaExhausted,
|
||||||
Blob(BlobFailure),
|
Blob(BlobFailure),
|
||||||
OperationCancelled,
|
OperationCancelled,
|
||||||
|
BadDescriptor,
|
||||||
|
Unsupported(String),
|
||||||
|
MappingUnsupported,
|
||||||
|
Busy(String),
|
||||||
|
Stale(String),
|
||||||
|
BrokenPipe,
|
||||||
WrongEntryType {
|
WrongEntryType {
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
expected: EntryKind,
|
expected: EntryKind,
|
||||||
|
|
@ -126,18 +280,26 @@ impl fmt::Display for DataPlaneError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::InvalidPath(reason) => write!(f, "invalid data path: {reason}"),
|
Self::InvalidPath(reason) => write!(f, "invalid data path: {reason}"),
|
||||||
|
Self::InvalidArgument(reason) => write!(f, "invalid argument: {reason}"),
|
||||||
Self::InvalidCapability => f.write_str("invalid job capability"),
|
Self::InvalidCapability => f.write_str("invalid job capability"),
|
||||||
Self::Attachment(reason) => write!(f, "data-plane attachment failed: {reason:?}"),
|
Self::Attachment(reason) => write!(f, "data-plane attachment failed: {reason:?}"),
|
||||||
Self::SessionNotRunning => f.write_str("data-plane session is not running"),
|
Self::SessionNotRunning => f.write_str("data-plane session is not running"),
|
||||||
Self::SessionFailed(reason) => write!(f, "data-plane session failed: {reason}"),
|
Self::SessionFailed(reason) => write!(f, "data-plane session failed: {reason}"),
|
||||||
Self::Unauthorized { path, operation } => {
|
Self::Unauthorized { path, access } => {
|
||||||
write!(f, "{operation:?} is not authorized for {path}")
|
write!(f, "{access:?} access is not authorized for {path}")
|
||||||
}
|
}
|
||||||
Self::PathNotFound(path) => write!(f, "data path not found: {path}"),
|
Self::PathNotFound(path) => write!(f, "data path not found: {path}"),
|
||||||
|
Self::PathExists(path) => write!(f, "data path already exists: {path}"),
|
||||||
Self::SourceFailure(reason) => write!(f, "blob source failed: {reason}"),
|
Self::SourceFailure(reason) => write!(f, "blob source failed: {reason}"),
|
||||||
Self::ArenaExhausted => f.write_str("data-plane arena is exhausted"),
|
Self::ArenaExhausted => f.write_str("data-plane arena is exhausted"),
|
||||||
Self::Blob(reason) => write!(f, "blob lease failure: {reason:?}"),
|
Self::Blob(reason) => write!(f, "blob lease failure: {reason:?}"),
|
||||||
Self::OperationCancelled => f.write_str("data-plane operation was cancelled"),
|
Self::OperationCancelled => f.write_str("data-plane operation was cancelled"),
|
||||||
|
Self::BadDescriptor => f.write_str("bad descriptor"),
|
||||||
|
Self::Unsupported(reason) => write!(f, "operation is not supported: {reason}"),
|
||||||
|
Self::MappingUnsupported => f.write_str("object does not support mapping"),
|
||||||
|
Self::Busy(reason) => write!(f, "resource is busy: {reason}"),
|
||||||
|
Self::Stale(reason) => write!(f, "stale capability: {reason}"),
|
||||||
|
Self::BrokenPipe => f.write_str("stream peer is closed"),
|
||||||
Self::WrongEntryType {
|
Self::WrongEntryType {
|
||||||
path,
|
path,
|
||||||
expected,
|
expected,
|
||||||
|
|
@ -154,6 +316,44 @@ impl fmt::Display for DataPlaneError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DataPlaneError {
|
||||||
|
pub const fn errno(&self) -> Errno {
|
||||||
|
match self {
|
||||||
|
Self::InvalidPath(_) | Self::InvalidArgument(_) | Self::InvalidCapability => {
|
||||||
|
Errno::Einval
|
||||||
|
}
|
||||||
|
Self::Attachment(_)
|
||||||
|
| Self::SessionNotRunning
|
||||||
|
| Self::SessionFailed(_)
|
||||||
|
| Self::SourceFailure(_)
|
||||||
|
| Self::StreamFault(_) => Errno::Eio,
|
||||||
|
Self::Unauthorized { .. } => Errno::Eacces,
|
||||||
|
Self::PathNotFound(_) => Errno::Enoent,
|
||||||
|
Self::PathExists(_) => Errno::Eexist,
|
||||||
|
Self::ArenaExhausted => Errno::Enospc,
|
||||||
|
Self::Blob(BlobFailure::Bounds | BlobFailure::Length { .. }) => Errno::Einval,
|
||||||
|
Self::Blob(BlobFailure::StaleGeneration { .. }) | Self::Stale(_) => Errno::Estale,
|
||||||
|
Self::Blob(BlobFailure::Access) | Self::BadDescriptor | Self::StreamClosed => {
|
||||||
|
Errno::Ebadf
|
||||||
|
}
|
||||||
|
Self::Blob(BlobFailure::ActiveWritableView) | Self::Busy(_) => Errno::Ebusy,
|
||||||
|
Self::Blob(
|
||||||
|
BlobFailure::Digest
|
||||||
|
| BlobFailure::State { .. }
|
||||||
|
| BlobFailure::InvalidLease
|
||||||
|
| BlobFailure::AlreadyFinished,
|
||||||
|
) => Errno::Eio,
|
||||||
|
Self::OperationCancelled => Errno::Ecanceled,
|
||||||
|
Self::Unsupported(_) => Errno::Enotsup,
|
||||||
|
Self::MappingUnsupported => Errno::Enodev,
|
||||||
|
Self::BrokenPipe => Errno::Epipe,
|
||||||
|
Self::WrongEntryType { .. } => Errno::Enxio,
|
||||||
|
Self::PathReplaced(_) => Errno::Estale,
|
||||||
|
Self::PeerLost => Errno::Econnreset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl std::error::Error for DataPlaneError {}
|
impl std::error::Error for DataPlaneError {}
|
||||||
|
|
||||||
impl From<BlobError> for DataPlaneError {
|
impl From<BlobError> for DataPlaneError {
|
||||||
|
|
@ -170,33 +370,24 @@ pub enum HostSessionIn {
|
||||||
job_capability: JobCapability,
|
job_capability: JobCapability,
|
||||||
child_node: Option<[u8; 32]>,
|
child_node: Option<[u8; 32]>,
|
||||||
},
|
},
|
||||||
OpenReadBlob {
|
Open {
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
|
options: OpenOptions,
|
||||||
|
policy: OpenPolicy,
|
||||||
child_session: ActorAddress,
|
child_session: ActorAddress,
|
||||||
operation: ActorAddress,
|
operation: ActorAddress,
|
||||||
},
|
},
|
||||||
CancelReadBlob {
|
OpenResolved {
|
||||||
operation: ActorAddress,
|
operation: ActorAddress,
|
||||||
|
result: Result<NamespaceNode, NamespaceError>,
|
||||||
},
|
},
|
||||||
OpenWriteBlob {
|
BlobReserved {
|
||||||
|
operation: ActorAddress,
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
length: u64,
|
reservation: OperationId,
|
||||||
child_session: ActorAddress,
|
result: Result<(), NamespaceError>,
|
||||||
operation: ActorAddress,
|
|
||||||
},
|
},
|
||||||
OpenReadStream {
|
CancelOpen {
|
||||||
path: DataPath,
|
|
||||||
child_session: ActorAddress,
|
|
||||||
operation: ActorAddress,
|
|
||||||
replace: bool,
|
|
||||||
},
|
|
||||||
OpenWriteStream {
|
|
||||||
path: DataPath,
|
|
||||||
child_session: ActorAddress,
|
|
||||||
operation: ActorAddress,
|
|
||||||
replace: bool,
|
|
||||||
},
|
|
||||||
CancelStream {
|
|
||||||
operation: ActorAddress,
|
operation: ActorAddress,
|
||||||
},
|
},
|
||||||
StreamControl {
|
StreamControl {
|
||||||
|
|
@ -253,16 +444,13 @@ pub enum ChildSessionIn {
|
||||||
error: DataPlaneError,
|
error: DataPlaneError,
|
||||||
},
|
},
|
||||||
AttachmentDeadline,
|
AttachmentDeadline,
|
||||||
ReadBlob {
|
Open {
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
|
options: OpenOptions,
|
||||||
|
policy: OpenPolicy,
|
||||||
reply_to: ActorAddress,
|
reply_to: ActorAddress,
|
||||||
},
|
},
|
||||||
CancelRead {
|
CancelOpen {
|
||||||
reply_to: ActorAddress,
|
|
||||||
},
|
|
||||||
OpenWriteBlob {
|
|
||||||
path: DataPath,
|
|
||||||
length: u64,
|
|
||||||
reply_to: ActorAddress,
|
reply_to: ActorAddress,
|
||||||
},
|
},
|
||||||
OpenReadStream {
|
OpenReadStream {
|
||||||
|
|
@ -270,11 +458,6 @@ pub enum ChildSessionIn {
|
||||||
reply_to: ActorAddress,
|
reply_to: ActorAddress,
|
||||||
replace: bool,
|
replace: bool,
|
||||||
},
|
},
|
||||||
OpenWriteStream {
|
|
||||||
path: DataPath,
|
|
||||||
reply_to: ActorAddress,
|
|
||||||
replace: bool,
|
|
||||||
},
|
|
||||||
CancelStream {
|
CancelStream {
|
||||||
reply_to: ActorAddress,
|
reply_to: ActorAddress,
|
||||||
},
|
},
|
||||||
|
|
@ -356,6 +539,10 @@ pub enum HostStreamIn {
|
||||||
PeerTerminated {
|
PeerTerminated {
|
||||||
incarnation: StreamIncarnation,
|
incarnation: StreamIncarnation,
|
||||||
error: DataPlaneError,
|
error: DataPlaneError,
|
||||||
|
reply_to: Option<ActorAddress>,
|
||||||
|
},
|
||||||
|
PeerTerminationAck {
|
||||||
|
incarnation: StreamIncarnation,
|
||||||
},
|
},
|
||||||
ReleaseComplete(Result<(), DataPlaneError>),
|
ReleaseComplete(Result<(), DataPlaneError>),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,357 +1,6 @@
|
||||||
#![cfg(target_os = "linux")]
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
use std::sync::Arc;
|
include!("data_plane_test_support.inc");
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use data_plane::arena::{ArenaConfig, ArenaManager, NodeId};
|
|
||||||
use data_plane::blob::{
|
|
||||||
BLOB_HEADER_LEN, Blob, BlobError, BlobLease, BlobMetadata, BlobSharedState, LeaseReleaser,
|
|
||||||
};
|
|
||||||
use data_plane::bootstrap::{self, BootstrapSpec};
|
|
||||||
use data_plane::data_plane::DataPlaneBootstrap;
|
|
||||||
use data_plane::host::{HostDataPlaneConfig, HostDataPlaneSessionActor};
|
|
||||||
use data_plane::path::{DataPath, JobContext};
|
|
||||||
use data_plane::protocol::{DataPlaneError, JobCapability};
|
|
||||||
use futures_lite::future::{self, FutureExt};
|
|
||||||
use swactor::Error;
|
|
||||||
use swactor::actor::ActorAddress;
|
|
||||||
use swactor::config::RuntimeConfig;
|
|
||||||
use swactor::runtime::{RemoteSink, Runtime, RuntimeParts};
|
|
||||||
use swactor_engine::{Engine, TokioBackend, TokioConfig};
|
|
||||||
|
|
||||||
const CAPABILITY: JobCapability = JobCapability::new([9; 32]);
|
|
||||||
const ARENA_GENERATION: u64 = 17;
|
|
||||||
const SESSION_GENERATION: u64 = 29;
|
|
||||||
const WEIGHTS: &[u8] = b"0123456789abcdefghijklmn";
|
|
||||||
static STREAM_TEST_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
|
||||||
|
|
||||||
struct DirectRuntimeSink {
|
|
||||||
destination: Runtime,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RemoteSink for DirectRuntimeSink {
|
|
||||||
fn send(
|
|
||||||
&self,
|
|
||||||
address: ActorAddress,
|
|
||||||
message: Box<dyn std::any::Any + Send>,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
self.destination.deliver_raw(address, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BlackHoleSink;
|
|
||||||
|
|
||||||
impl RemoteSink for BlackHoleSink {
|
|
||||||
fn send(
|
|
||||||
&self,
|
|
||||||
_address: ActorAddress,
|
|
||||||
_message: Box<dyn std::any::Any + Send>,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Harness {
|
|
||||||
_host_engine: Engine,
|
|
||||||
_child_engine: Engine,
|
|
||||||
_temp: TempState,
|
|
||||||
bootstrap: DataPlaneBootstrap,
|
|
||||||
}
|
|
||||||
|
|
||||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
|
|
||||||
|
|
||||||
struct TempState {
|
|
||||||
root: std::path::PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TempState {
|
|
||||||
fn new() -> Self {
|
|
||||||
let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let root = std::env::temp_dir().join(format!(
|
|
||||||
"swactor-actor-blob-{}-{sequence}",
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
std::fs::create_dir_all(&root).unwrap();
|
|
||||||
Self { root }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TempState {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = std::fs::remove_dir_all(&self.root);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path(value: &str) -> DataPath {
|
|
||||||
DataPath::parse(value).expect("test path")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn runtime_parts() -> (RuntimeParts, Runtime) {
|
|
||||||
let parts = RuntimeParts::new(RuntimeConfig {
|
|
||||||
worker_count: 1,
|
|
||||||
..RuntimeConfig::default()
|
|
||||||
});
|
|
||||||
let runtime = parts.runtime().clone();
|
|
||||||
(parts, runtime)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LoopbackSender {
|
|
||||||
runtime: Runtime,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl data_plane::blob_transfer::BlobTransferSender for LoopbackSender {
|
|
||||||
fn start_file(
|
|
||||||
&self,
|
|
||||||
request: data_plane::blob_transfer::FileTransferRequest,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use std::os::unix::fs::FileExt;
|
|
||||||
let mut bytes = vec![0_u8; request.length as usize];
|
|
||||||
request
|
|
||||||
.file
|
|
||||||
.read_exact_at(&mut bytes, request.offset)
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
self.runtime
|
|
||||||
.send_to(
|
|
||||||
request.offer.destination,
|
|
||||||
data_plane::blob_transfer::BlobTransferEvent::Chunk {
|
|
||||||
transfer_id: request.offer.transfer_id,
|
|
||||||
bytes,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
self.runtime
|
|
||||||
.send_to(
|
|
||||||
request.offer.destination,
|
|
||||||
data_plane::blob_transfer::BlobTransferEvent::Finished {
|
|
||||||
transfer_id: request.offer.transfer_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
request.completion.complete(Ok(()));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DirectReceiver;
|
|
||||||
|
|
||||||
impl data_plane::blob_transfer::BlobTransferReceiver for DirectReceiver {
|
|
||||||
fn open(
|
|
||||||
&self,
|
|
||||||
destination: ActorAddress,
|
|
||||||
transfer_id: data_plane::blob_transfer::BlobTransferId,
|
|
||||||
) -> Result<data_plane::blob_transfer::BlobTransferOffer, String> {
|
|
||||||
Ok(data_plane::blob_transfer::BlobTransferOffer {
|
|
||||||
transfer_id,
|
|
||||||
destination,
|
|
||||||
failure_proxy: None,
|
|
||||||
transport: Vec::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cancel(&self, _offer: &data_plane::blob_transfer::BlobTransferOffer) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StaticDiscovery(ActorAddress);
|
|
||||||
|
|
||||||
impl data_plane::namespace::NamespaceDiscovery for StaticDiscovery {
|
|
||||||
fn current_directory(&self) -> Option<ActorAddress> {
|
|
||||||
Some(self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NoopSourceRegistrar;
|
|
||||||
|
|
||||||
impl data_plane::source::BlobSourcePublisher for NoopSourceRegistrar {
|
|
||||||
fn publish_source(&self, _source: ActorAddress) -> Result<(), String> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RejectingStreamTransport;
|
|
||||||
|
|
||||||
impl data_plane::stream_transport::StreamTransport for RejectingStreamTransport {
|
|
||||||
fn descriptor(&self) -> Result<data_plane::stream_transport::StreamPeerDescriptor, String> {
|
|
||||||
Ok(data_plane::stream_transport::StreamPeerDescriptor(vec![1]))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_source(
|
|
||||||
&self,
|
|
||||||
_request: data_plane::stream_transport::StreamSourceRequest,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
Err("injected source transport failure".to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_sink(
|
|
||||||
&self,
|
|
||||||
_request: data_plane::stream_transport::StreamSinkRequest,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
Err("injected sink transport failure".to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn source_progress(&self, _incarnation: data_plane::namespace::StreamIncarnation) {}
|
|
||||||
|
|
||||||
fn sink_progress(&self, _incarnation: data_plane::namespace::StreamIncarnation) {}
|
|
||||||
|
|
||||||
fn terminate(&self, _incarnation: data_plane::namespace::StreamIncarnation) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CollectBytes(Arc<parking_lot::Mutex<Vec<u8>>>);
|
|
||||||
|
|
||||||
impl data_plane::data_plane::StreamConsumer for CollectBytes {
|
|
||||||
fn consume(&self, bytes: &[u8]) -> Result<(), String> {
|
|
||||||
self.0.lock().extend_from_slice(bytes);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn harness(arena_bytes: u64) -> Harness {
|
|
||||||
harness_with_transport(
|
|
||||||
arena_bytes,
|
|
||||||
Arc::new(data_plane::stream_transport::LocalStreamTransport::new()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn harness_with_transport(
|
|
||||||
arena_bytes: u64,
|
|
||||||
stream_transport: Arc<dyn data_plane::stream_transport::StreamTransport>,
|
|
||||||
) -> Harness {
|
|
||||||
let temp = TempState::new();
|
|
||||||
let mut arena = ArenaManager::boot(ArenaConfig {
|
|
||||||
node_id: NodeId(1),
|
|
||||||
reservation_ceiling: arena_bytes,
|
|
||||||
base_alignment: 64,
|
|
||||||
})
|
|
||||||
.expect("host arena");
|
|
||||||
let handoff = bootstrap::write_bootstrap(
|
|
||||||
&mut arena,
|
|
||||||
BootstrapSpec {
|
|
||||||
arena_generation: ARENA_GENERATION,
|
|
||||||
alignment: 64,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("bootstrap");
|
|
||||||
|
|
||||||
let (host_parts, host_runtime) = runtime_parts();
|
|
||||||
let (child_parts, child_runtime) = runtime_parts();
|
|
||||||
host_runtime.set_remote_sink(Arc::new(DirectRuntimeSink {
|
|
||||||
destination: child_runtime.clone(),
|
|
||||||
}));
|
|
||||||
child_runtime.set_remote_sink(Arc::new(DirectRuntimeSink {
|
|
||||||
destination: host_runtime.clone(),
|
|
||||||
}));
|
|
||||||
let host_engine = Engine::new(
|
|
||||||
host_parts,
|
|
||||||
TokioBackend::new(TokioConfig {
|
|
||||||
worker_threads: 1,
|
|
||||||
..TokioConfig::default()
|
|
||||||
})
|
|
||||||
.expect("host backend"),
|
|
||||||
)
|
|
||||||
.expect("host engine");
|
|
||||||
let child_engine = Engine::new(
|
|
||||||
child_parts,
|
|
||||||
TokioBackend::new(TokioConfig {
|
|
||||||
worker_threads: 1,
|
|
||||||
..TokioConfig::default()
|
|
||||||
})
|
|
||||||
.expect("child backend"),
|
|
||||||
)
|
|
||||||
.expect("child engine");
|
|
||||||
|
|
||||||
let sender: Arc<dyn data_plane::blob_transfer::BlobTransferSender> = Arc::new(LoopbackSender {
|
|
||||||
runtime: host_runtime.clone(),
|
|
||||||
});
|
|
||||||
let directory_actor = data_plane::namespace::DataDirectoryActor::recover(
|
|
||||||
temp.root.join("namespace.json"),
|
|
||||||
|_record, _length| {
|
|
||||||
Err(data_plane::namespace::NamespaceError::SourceRecovery(
|
|
||||||
"unexpected recovery".to_owned(),
|
|
||||||
))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let directory = host_runtime.spawn(directory_actor).unwrap();
|
|
||||||
let directory_client =
|
|
||||||
data_plane::namespace::DirectoryClient::new(host_runtime.clone(), directory);
|
|
||||||
for (logical, name, bytes) in [
|
|
||||||
("/models/tiny-linear/weights", "weights.bin", WEIGHTS),
|
|
||||||
("/models/second", "second.bin", b"second-blob".as_slice()),
|
|
||||||
] {
|
|
||||||
let file_path = temp.root.join(name);
|
|
||||||
std::fs::write(&file_path, bytes).unwrap();
|
|
||||||
let source = data_plane::source::FileBlobSourceActor::open(
|
|
||||||
host_runtime.clone(),
|
|
||||||
Arc::clone(&sender),
|
|
||||||
&file_path,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let length = source.length();
|
|
||||||
let recovery = source.recovery();
|
|
||||||
let source = host_runtime.spawn(source).unwrap();
|
|
||||||
future::block_on(directory_client.register(
|
|
||||||
path(logical),
|
|
||||||
source,
|
|
||||||
length,
|
|
||||||
recovery,
|
|
||||||
data_plane::namespace::OperationId::from_u128(u128::from(length) + 1),
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let proxy = host_runtime
|
|
||||||
.spawn(data_plane::namespace::NamespaceClientActor::new(
|
|
||||||
host_engine.handle(),
|
|
||||||
host_runtime.create_sender(),
|
|
||||||
Arc::new(StaticDiscovery(directory)),
|
|
||||||
Duration::from_millis(5),
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
let namespace = data_plane::namespace::NamespaceClient::new(host_runtime.clone(), proxy);
|
|
||||||
let host_session = host_runtime
|
|
||||||
.spawn(
|
|
||||||
HostDataPlaneSessionActor::new(HostDataPlaneConfig {
|
|
||||||
runtime: host_runtime.clone(),
|
|
||||||
arena,
|
|
||||||
arena_generation: ARENA_GENERATION,
|
|
||||||
session_generation: SESSION_GENERATION,
|
|
||||||
capability: CAPABILITY,
|
|
||||||
job_context: JobContext {
|
|
||||||
run_id: "run-7".to_owned(),
|
|
||||||
read_prefixes: vec![path("/models"), path("/runs/run-7/results")],
|
|
||||||
write_prefixes: vec![path("/runs/run-7/results")],
|
|
||||||
},
|
|
||||||
namespace: Some(namespace),
|
|
||||||
transfer_receiver: Some(Arc::new(DirectReceiver)),
|
|
||||||
source_sender: Some(sender),
|
|
||||||
source_publisher: Some(Arc::new(NoopSourceRegistrar)),
|
|
||||||
route_registrar: None,
|
|
||||||
stream_transport: Some(stream_transport),
|
|
||||||
})
|
|
||||||
.expect("host session config"),
|
|
||||||
)
|
|
||||||
.expect("spawn host session");
|
|
||||||
let bootstrap = future::block_on(DataPlaneBootstrap::attach(
|
|
||||||
handoff.arena_fd,
|
|
||||||
child_runtime,
|
|
||||||
host_session,
|
|
||||||
CAPABILITY,
|
|
||||||
))
|
|
||||||
.expect("routed attachment");
|
|
||||||
|
|
||||||
Harness {
|
|
||||||
_host_engine: host_engine,
|
|
||||||
_child_engine: child_engine,
|
|
||||||
_temp: temp,
|
|
||||||
bootstrap,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct NoopReleaser;
|
|
||||||
|
|
||||||
impl LeaseReleaser for NoopReleaser {
|
|
||||||
fn release(&self, _lease: BlobLease) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attachment_without_a_host_reply_fails_on_actor_deadline() {
|
fn attachment_without_a_host_reply_fails_on_actor_deadline() {
|
||||||
|
|
@ -874,3 +523,190 @@ fn actor_stream_consumer_registers_before_writer_and_collects_to_eof() {
|
||||||
completion.wait().expect("collector completes");
|
completion.wait().expect("collector completes");
|
||||||
assert_eq!(&*observed.lock(), b"actor-consumer");
|
assert_eq!(&*observed.lock(), b"actor-consumer");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_blob_descriptor_enforces_offsets_rights_and_terminal_state() {
|
||||||
|
let harness = harness(2 << 20);
|
||||||
|
let data_plane = harness.bootstrap.data_plane.clone();
|
||||||
|
let logical = path("/runs/self/results/raw-blob");
|
||||||
|
|
||||||
|
future::block_on(async {
|
||||||
|
let mut writer = data_plane
|
||||||
|
.open(&logical, OpenOptions::staged_blob(8))
|
||||||
|
.await
|
||||||
|
.expect("open staged descriptor");
|
||||||
|
assert_eq!(writer.kind(), DescriptorKind::Blob);
|
||||||
|
assert!(
|
||||||
|
writer
|
||||||
|
.capabilities()
|
||||||
|
.contains(DescriptorCapabilities::WRITE)
|
||||||
|
);
|
||||||
|
assert_eq!(writer.write(b"abc").await.expect("first write"), 3);
|
||||||
|
assert_eq!(writer.write(b"defgh").await.expect("second write"), 5);
|
||||||
|
assert_eq!(writer.last_route(), Some(TransferRoute::Staged));
|
||||||
|
assert_eq!(
|
||||||
|
writer
|
||||||
|
.write(b"!")
|
||||||
|
.await
|
||||||
|
.expect_err("growth rejected")
|
||||||
|
.errno(),
|
||||||
|
Errno::Enotsup
|
||||||
|
);
|
||||||
|
writer.close().await.expect("publish");
|
||||||
|
assert_eq!(
|
||||||
|
writer.close().await.expect_err("double close").errno(),
|
||||||
|
Errno::Ebadf
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut reader = data_plane
|
||||||
|
.open(&logical, OpenOptions::read_only())
|
||||||
|
.await
|
||||||
|
.expect("open published descriptor");
|
||||||
|
assert_eq!(reader.kind(), DescriptorKind::Blob);
|
||||||
|
let mut first = [0xa5; 5];
|
||||||
|
reader
|
||||||
|
.read_exact(&mut first[..3])
|
||||||
|
.await
|
||||||
|
.expect("exact prefix read");
|
||||||
|
assert_eq!(&first, b"abc\xa5\xa5");
|
||||||
|
let mut rest = [0_u8; 8];
|
||||||
|
assert_eq!(reader.read(&mut rest).await.expect("remaining read"), 5);
|
||||||
|
assert_eq!(reader.read(&mut rest).await.expect("eof"), 0);
|
||||||
|
assert_eq!(
|
||||||
|
reader.write(b"x").await.expect_err("wrong access").errno(),
|
||||||
|
Errno::Ebadf
|
||||||
|
);
|
||||||
|
assert_eq!(reader.read(&mut []).await.expect("zero length"), 0);
|
||||||
|
reader.close().await.expect("close reader");
|
||||||
|
|
||||||
|
let mut source = data_plane
|
||||||
|
.open(&logical, OpenOptions::read_only())
|
||||||
|
.await
|
||||||
|
.expect("open arena-region source");
|
||||||
|
let target_path = path("/runs/self/results/raw-region-target");
|
||||||
|
let mut target = data_plane
|
||||||
|
.open(&target_path, OpenOptions::staged_blob(8))
|
||||||
|
.await
|
||||||
|
.expect("open arena-region target");
|
||||||
|
let mut target_mapping = target
|
||||||
|
.map(MapRequest {
|
||||||
|
protection: Protection::ReadWrite,
|
||||||
|
sharing: Sharing::Shared,
|
||||||
|
target: MapTarget::Host,
|
||||||
|
offset: 0,
|
||||||
|
length: 8,
|
||||||
|
})
|
||||||
|
.expect("map arena target");
|
||||||
|
assert_eq!(target_mapping.route(), TransferRoute::Direct);
|
||||||
|
let count = source
|
||||||
|
.read_into(RegionSlice::arena(
|
||||||
|
target_mapping.as_mut().expect("writable arena region"),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("read into arena region");
|
||||||
|
assert_eq!(count, 8);
|
||||||
|
assert_eq!(target_mapping.as_ref(), b"abcdefgh");
|
||||||
|
drop(target_mapping);
|
||||||
|
target.abort().await.expect("abort arena target");
|
||||||
|
source.close().await.expect("close arena source");
|
||||||
|
assert_eq!(
|
||||||
|
reader
|
||||||
|
.read(&mut rest)
|
||||||
|
.await
|
||||||
|
.expect_err("read after close")
|
||||||
|
.errno(),
|
||||||
|
Errno::Ebadf
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_stream_descriptor_hides_record_boundaries_and_preserves_eof() {
|
||||||
|
let _stream_test = STREAM_TEST_LOCK.lock();
|
||||||
|
let harness = harness(2 << 20);
|
||||||
|
let data_plane = harness.bootstrap.data_plane.clone();
|
||||||
|
let logical = path("/runs/self/results/raw-stream");
|
||||||
|
|
||||||
|
future::block_on(async {
|
||||||
|
let typed_reader = data_plane.read_stream(&logical);
|
||||||
|
let typed_writer = data_plane.write_stream(&logical);
|
||||||
|
let (typed_reader, typed_writer) = future::zip(typed_reader, typed_writer).await;
|
||||||
|
let mut typed_reader = typed_reader.expect("seed stream reader");
|
||||||
|
let mut typed_writer = typed_writer.expect("seed stream writer");
|
||||||
|
typed_writer.close().await.expect("seed close");
|
||||||
|
assert_eq!(typed_reader.read().await.expect("seed eof"), None);
|
||||||
|
|
||||||
|
let raw_reader = data_plane.open(&logical, OpenOptions::read_only());
|
||||||
|
let raw_writer = data_plane.open(
|
||||||
|
&logical,
|
||||||
|
OpenOptions {
|
||||||
|
access: AccessMode::WriteOnly,
|
||||||
|
..OpenOptions::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (raw_reader, raw_writer) = future::zip(raw_reader, raw_writer).await;
|
||||||
|
let mut reader = raw_reader.expect("raw stream reader");
|
||||||
|
let mut writer = raw_writer.expect("raw stream writer");
|
||||||
|
assert_eq!(reader.kind(), DescriptorKind::Stream);
|
||||||
|
writer
|
||||||
|
.write_all(b"abcdefgh")
|
||||||
|
.await
|
||||||
|
.expect("stream write all");
|
||||||
|
writer.close().await.expect("writer close");
|
||||||
|
|
||||||
|
let mut chunk = [0_u8; 3];
|
||||||
|
assert_eq!(reader.read(&mut chunk).await.expect("chunk one"), 3);
|
||||||
|
assert_eq!(&chunk, b"abc");
|
||||||
|
assert_eq!(reader.read(&mut chunk).await.expect("chunk two"), 3);
|
||||||
|
assert_eq!(&chunk, b"def");
|
||||||
|
assert_eq!(reader.read(&mut chunk).await.expect("chunk three"), 2);
|
||||||
|
assert_eq!(&chunk[..2], b"gh");
|
||||||
|
assert_eq!(reader.read(&mut chunk).await.expect("stream eof"), 0);
|
||||||
|
assert_eq!(reader.read(&mut chunk).await.expect("sticky eof"), 0);
|
||||||
|
reader.close().await.expect("reader close");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_blob_mapping_is_bounded_and_can_outlive_descriptor_close() {
|
||||||
|
let harness = harness(2 << 20);
|
||||||
|
let data_plane = harness.bootstrap.data_plane.clone();
|
||||||
|
let logical = path("/runs/self/results/raw-mapped-blob");
|
||||||
|
|
||||||
|
future::block_on(async {
|
||||||
|
let mut writer = data_plane
|
||||||
|
.open(&logical, OpenOptions::staged_blob(6))
|
||||||
|
.await
|
||||||
|
.expect("open mapped writer");
|
||||||
|
let mut mapping = writer
|
||||||
|
.map(MapRequest {
|
||||||
|
protection: Protection::ReadWrite,
|
||||||
|
sharing: Sharing::Shared,
|
||||||
|
target: MapTarget::Host,
|
||||||
|
offset: 1,
|
||||||
|
length: 4,
|
||||||
|
})
|
||||||
|
.expect("bounded writable mapping");
|
||||||
|
assert_eq!(mapping.route(), TransferRoute::Direct);
|
||||||
|
mapping
|
||||||
|
.as_mut()
|
||||||
|
.expect("writable mapping")
|
||||||
|
.copy_from_slice(b"data");
|
||||||
|
writer.close().await.expect("deferred close intent");
|
||||||
|
assert_eq!(mapping.as_ref(), b"data");
|
||||||
|
drop(mapping);
|
||||||
|
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||||
|
let blob = loop {
|
||||||
|
match data_plane.read_blob(&logical).await {
|
||||||
|
Ok(blob) => break blob,
|
||||||
|
Err(DataPlaneError::PathNotFound(_)) if std::time::Instant::now() < deadline => {
|
||||||
|
future::yield_now().await;
|
||||||
|
}
|
||||||
|
Err(error) => panic!("deferred publication failed: {error}"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let view = blob.map().expect("published mapping");
|
||||||
|
assert_eq!(&view[..], b"\0data\0");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -557,3 +557,73 @@ fn writable_record_is_invisible_until_commit() {
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(observed, (0..17).collect::<Vec<_>>());
|
assert_eq!(observed, (0..17).collect::<Vec<_>>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_record_cursor_keeps_capacity_pinned_until_full_release() {
|
||||||
|
let (arena, handle) = installed(32, 1);
|
||||||
|
let mut producer = attach(&arena, handle, Role::Producer).expect("producer");
|
||||||
|
let mut consumer = attach(&arena, handle, Role::Consumer).expect("consumer");
|
||||||
|
producer
|
||||||
|
.send_record(RecordKind::Data, b"abcdefgh")
|
||||||
|
.expect("record");
|
||||||
|
|
||||||
|
let cursor = consumer
|
||||||
|
.record_cursor()
|
||||||
|
.expect("cursor")
|
||||||
|
.expect("record visible");
|
||||||
|
let mut first = [0xa5; 5];
|
||||||
|
assert_eq!(
|
||||||
|
consumer
|
||||||
|
.copy_record_range(cursor, 0, &mut first[..3])
|
||||||
|
.expect("partial prefix"),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
assert_eq!(&first, b"abc\xa5\xa5");
|
||||||
|
assert!(matches!(
|
||||||
|
producer.reserve(20),
|
||||||
|
Err(FlowError::InsufficientSpace { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut rest = [0_u8; 8];
|
||||||
|
assert_eq!(
|
||||||
|
consumer
|
||||||
|
.copy_record_range(cursor, 3, &mut rest)
|
||||||
|
.expect("partial suffix"),
|
||||||
|
5
|
||||||
|
);
|
||||||
|
assert_eq!(&rest[..5], b"defgh");
|
||||||
|
consumer
|
||||||
|
.release_record_cursor(cursor)
|
||||||
|
.expect("release complete record");
|
||||||
|
assert!(producer.reserve(20).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_record_cursor_hides_payload_wraparound() {
|
||||||
|
let (arena, handle) = installed(32, 1);
|
||||||
|
let mut producer = attach(&arena, handle, Role::Producer).expect("producer");
|
||||||
|
let mut consumer = attach(&arena, handle, Role::Consumer).expect("consumer");
|
||||||
|
producer
|
||||||
|
.send_record(RecordKind::Data, &[1; 18])
|
||||||
|
.expect("advance cursor");
|
||||||
|
consumer.recv_record().expect("consume advance");
|
||||||
|
producer
|
||||||
|
.send_record(RecordKind::Data, b"0123456789abcde")
|
||||||
|
.expect("wrapped record");
|
||||||
|
|
||||||
|
let cursor = consumer
|
||||||
|
.record_cursor()
|
||||||
|
.expect("cursor")
|
||||||
|
.expect("wrapped record visible");
|
||||||
|
let mut observed = [0_u8; 15];
|
||||||
|
assert_eq!(
|
||||||
|
consumer
|
||||||
|
.copy_record_range(cursor, 0, &mut observed)
|
||||||
|
.expect("copy wrapped payload"),
|
||||||
|
observed.len()
|
||||||
|
);
|
||||||
|
assert_eq!(&observed, b"0123456789abcde");
|
||||||
|
consumer
|
||||||
|
.release_record_cursor(cursor)
|
||||||
|
.expect("release wrapped record");
|
||||||
|
}
|
||||||
|
|
|
||||||
364
crates/data-plane/tests/data_plane_test_support.inc
Normal file
364
crates/data-plane/tests/data_plane_test_support.inc
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use data_plane::arena::{ArenaConfig, ArenaManager, NodeId};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use data_plane::blob::{
|
||||||
|
BLOB_HEADER_LEN, Blob, BlobError, BlobLease, BlobMetadata, BlobSharedState, LeaseReleaser,
|
||||||
|
};
|
||||||
|
use data_plane::bootstrap::{self, BootstrapSpec};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use data_plane::data_plane::{
|
||||||
|
DataPlaneBootstrap, MapRequest, MapTarget, Protection, RegionSlice, Sharing, TransferRoute,
|
||||||
|
};
|
||||||
|
use data_plane::host::{HostDataPlaneConfig, HostDataPlaneSessionActor};
|
||||||
|
use data_plane::path::{DataPath, JobContext};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use data_plane::protocol::{
|
||||||
|
AccessMode, DataPlaneError, DescriptorCapabilities, DescriptorKind, Errno, JobCapability,
|
||||||
|
OpenOptions,
|
||||||
|
};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use futures_lite::future::{self, FutureExt};
|
||||||
|
use swactor::Error;
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
use swactor::config::RuntimeConfig;
|
||||||
|
use swactor::runtime::{RemoteSink, Runtime, RuntimeParts};
|
||||||
|
use swactor_engine::{Engine, TokioBackend, TokioConfig};
|
||||||
|
|
||||||
|
const CAPABILITY: JobCapability = JobCapability::new([9; 32]);
|
||||||
|
const ARENA_GENERATION: u64 = 17;
|
||||||
|
const SESSION_GENERATION: u64 = 29;
|
||||||
|
const WEIGHTS: &[u8] = b"0123456789abcdefghijklmn";
|
||||||
|
static STREAM_TEST_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
||||||
|
|
||||||
|
struct DirectRuntimeSink {
|
||||||
|
destination: Runtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteSink for DirectRuntimeSink {
|
||||||
|
fn send(
|
||||||
|
&self,
|
||||||
|
address: ActorAddress,
|
||||||
|
message: Box<dyn std::any::Any + Send>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.destination.deliver_raw(address, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct BlackHoleSink;
|
||||||
|
|
||||||
|
impl RemoteSink for BlackHoleSink {
|
||||||
|
fn send(
|
||||||
|
&self,
|
||||||
|
_address: ActorAddress,
|
||||||
|
_message: Box<dyn std::any::Any + Send>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Harness {
|
||||||
|
_host_engine: Engine,
|
||||||
|
_child_engine: Engine,
|
||||||
|
_temp: TempState,
|
||||||
|
bootstrap: DataPlaneBootstrap,
|
||||||
|
}
|
||||||
|
|
||||||
|
static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
struct TempState {
|
||||||
|
root: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempState {
|
||||||
|
fn new() -> Self {
|
||||||
|
let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"swactor-actor-blob-{}-{sequence}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
Self { root }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempState {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(value: &str) -> DataPath {
|
||||||
|
DataPath::parse(value).expect("test path")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_parts() -> (RuntimeParts, Runtime) {
|
||||||
|
let parts = RuntimeParts::new(RuntimeConfig {
|
||||||
|
worker_count: 1,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
});
|
||||||
|
let runtime = parts.runtime().clone();
|
||||||
|
(parts, runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoopbackSender {
|
||||||
|
runtime: Runtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl data_plane::blob_transfer::BlobTransferSender for LoopbackSender {
|
||||||
|
fn start_file(
|
||||||
|
&self,
|
||||||
|
request: data_plane::blob_transfer::FileTransferRequest,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use std::os::unix::fs::FileExt;
|
||||||
|
let mut bytes = vec![0_u8; request.length as usize];
|
||||||
|
request
|
||||||
|
.file
|
||||||
|
.read_exact_at(&mut bytes, request.offset)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
request.offer.destination,
|
||||||
|
data_plane::blob_transfer::BlobTransferEvent::Chunk {
|
||||||
|
transfer_id: request.offer.transfer_id,
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
request.offer.destination,
|
||||||
|
data_plane::blob_transfer::BlobTransferEvent::Finished {
|
||||||
|
transfer_id: request.offer.transfer_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
request.completion.complete(Ok(()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DirectReceiver;
|
||||||
|
|
||||||
|
impl data_plane::blob_transfer::BlobTransferReceiver for DirectReceiver {
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
destination: ActorAddress,
|
||||||
|
transfer_id: data_plane::blob_transfer::BlobTransferId,
|
||||||
|
) -> Result<data_plane::blob_transfer::BlobTransferOffer, String> {
|
||||||
|
Ok(data_plane::blob_transfer::BlobTransferOffer {
|
||||||
|
transfer_id,
|
||||||
|
destination,
|
||||||
|
failure_proxy: None,
|
||||||
|
transport: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel(&self, _offer: &data_plane::blob_transfer::BlobTransferOffer) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StaticDiscovery(ActorAddress);
|
||||||
|
|
||||||
|
impl data_plane::namespace::NamespaceDiscovery for StaticDiscovery {
|
||||||
|
fn current_directory(&self) -> Option<ActorAddress> {
|
||||||
|
Some(self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NoopSourceRegistrar;
|
||||||
|
|
||||||
|
impl data_plane::source::BlobSourcePublisher for NoopSourceRegistrar {
|
||||||
|
fn publish_source(&self, _source: ActorAddress) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RejectingStreamTransport;
|
||||||
|
|
||||||
|
impl data_plane::stream_transport::StreamTransport for RejectingStreamTransport {
|
||||||
|
fn descriptor(&self) -> Result<data_plane::stream_transport::StreamPeerDescriptor, String> {
|
||||||
|
Ok(data_plane::stream_transport::StreamPeerDescriptor(vec![1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_source(
|
||||||
|
&self,
|
||||||
|
_request: data_plane::stream_transport::StreamSourceRequest,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
Err("injected source transport failure".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_sink(
|
||||||
|
&self,
|
||||||
|
_request: data_plane::stream_transport::StreamSinkRequest,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
Err("injected sink transport failure".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_progress(&self, _incarnation: data_plane::namespace::StreamIncarnation) {}
|
||||||
|
|
||||||
|
fn sink_progress(&self, _incarnation: data_plane::namespace::StreamIncarnation) {}
|
||||||
|
|
||||||
|
fn terminate(&self, _incarnation: data_plane::namespace::StreamIncarnation) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct CollectBytes(Arc<parking_lot::Mutex<Vec<u8>>>);
|
||||||
|
|
||||||
|
impl data_plane::data_plane::StreamConsumer for CollectBytes {
|
||||||
|
fn consume(&self, bytes: &[u8]) -> Result<(), String> {
|
||||||
|
self.0.lock().extend_from_slice(bytes);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn harness(arena_bytes: u64) -> Harness {
|
||||||
|
harness_with_transport(
|
||||||
|
arena_bytes,
|
||||||
|
Arc::new(data_plane::stream_transport::LocalStreamTransport::new()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn harness_with_transport(
|
||||||
|
arena_bytes: u64,
|
||||||
|
stream_transport: Arc<dyn data_plane::stream_transport::StreamTransport>,
|
||||||
|
) -> Harness {
|
||||||
|
let temp = TempState::new();
|
||||||
|
let mut arena = ArenaManager::boot(ArenaConfig {
|
||||||
|
node_id: NodeId(1),
|
||||||
|
reservation_ceiling: arena_bytes,
|
||||||
|
base_alignment: 64,
|
||||||
|
})
|
||||||
|
.expect("host arena");
|
||||||
|
let handoff = bootstrap::write_bootstrap(
|
||||||
|
&mut arena,
|
||||||
|
BootstrapSpec {
|
||||||
|
arena_generation: ARENA_GENERATION,
|
||||||
|
alignment: 64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("bootstrap");
|
||||||
|
|
||||||
|
let (host_parts, host_runtime) = runtime_parts();
|
||||||
|
let (child_parts, child_runtime) = runtime_parts();
|
||||||
|
host_runtime.set_remote_sink(Arc::new(DirectRuntimeSink {
|
||||||
|
destination: child_runtime.clone(),
|
||||||
|
}));
|
||||||
|
child_runtime.set_remote_sink(Arc::new(DirectRuntimeSink {
|
||||||
|
destination: host_runtime.clone(),
|
||||||
|
}));
|
||||||
|
let host_engine = Engine::new(
|
||||||
|
host_parts,
|
||||||
|
TokioBackend::new(TokioConfig {
|
||||||
|
worker_threads: 1,
|
||||||
|
..TokioConfig::default()
|
||||||
|
})
|
||||||
|
.expect("host backend"),
|
||||||
|
)
|
||||||
|
.expect("host engine");
|
||||||
|
let child_engine = Engine::new(
|
||||||
|
child_parts,
|
||||||
|
TokioBackend::new(TokioConfig {
|
||||||
|
worker_threads: 1,
|
||||||
|
..TokioConfig::default()
|
||||||
|
})
|
||||||
|
.expect("child backend"),
|
||||||
|
)
|
||||||
|
.expect("child engine");
|
||||||
|
|
||||||
|
let sender: Arc<dyn data_plane::blob_transfer::BlobTransferSender> = Arc::new(LoopbackSender {
|
||||||
|
runtime: host_runtime.clone(),
|
||||||
|
});
|
||||||
|
let directory_actor = data_plane::namespace::DataDirectoryActor::recover(
|
||||||
|
temp.root.join("namespace.json"),
|
||||||
|
|_record, _length| {
|
||||||
|
Err(data_plane::namespace::NamespaceError::SourceRecovery(
|
||||||
|
"unexpected recovery".to_owned(),
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let directory = host_runtime.spawn(directory_actor).unwrap();
|
||||||
|
let directory_client =
|
||||||
|
data_plane::namespace::DirectoryClient::new(host_runtime.clone(), directory);
|
||||||
|
for (logical, name, bytes) in [
|
||||||
|
("/models/tiny-linear/weights", "weights.bin", WEIGHTS),
|
||||||
|
("/models/second", "second.bin", b"second-blob".as_slice()),
|
||||||
|
] {
|
||||||
|
let file_path = temp.root.join(name);
|
||||||
|
std::fs::write(&file_path, bytes).unwrap();
|
||||||
|
let source = data_plane::source::FileBlobSourceActor::open(
|
||||||
|
host_runtime.clone(),
|
||||||
|
Arc::clone(&sender),
|
||||||
|
&file_path,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let length = source.length();
|
||||||
|
let recovery = source.recovery();
|
||||||
|
let source = host_runtime.spawn(source).unwrap();
|
||||||
|
future::block_on(directory_client.register(
|
||||||
|
path(logical),
|
||||||
|
source,
|
||||||
|
length,
|
||||||
|
recovery,
|
||||||
|
data_plane::namespace::OperationId::from_u128(u128::from(length) + 1),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let proxy = host_runtime
|
||||||
|
.spawn(data_plane::namespace::NamespaceClientActor::new(
|
||||||
|
host_engine.handle(),
|
||||||
|
host_runtime.create_sender(),
|
||||||
|
Arc::new(StaticDiscovery(directory)),
|
||||||
|
Duration::from_millis(5),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let namespace = data_plane::namespace::NamespaceClient::new(host_runtime.clone(), proxy);
|
||||||
|
let host_session = host_runtime
|
||||||
|
.spawn(
|
||||||
|
HostDataPlaneSessionActor::new(HostDataPlaneConfig {
|
||||||
|
runtime: host_runtime.clone(),
|
||||||
|
arena,
|
||||||
|
arena_generation: ARENA_GENERATION,
|
||||||
|
session_generation: SESSION_GENERATION,
|
||||||
|
capability: CAPABILITY,
|
||||||
|
job_context: JobContext {
|
||||||
|
run_id: "run-7".to_owned(),
|
||||||
|
read_prefixes: vec![path("/models"), path("/runs/run-7/results")],
|
||||||
|
write_prefixes: vec![path("/runs/run-7/results")],
|
||||||
|
},
|
||||||
|
namespace: Some(namespace),
|
||||||
|
transfer_receiver: Some(Arc::new(DirectReceiver)),
|
||||||
|
source_sender: Some(sender),
|
||||||
|
source_publisher: Some(Arc::new(NoopSourceRegistrar)),
|
||||||
|
route_registrar: None,
|
||||||
|
stream_transport: Some(stream_transport),
|
||||||
|
})
|
||||||
|
.expect("host session config"),
|
||||||
|
)
|
||||||
|
.expect("spawn host session");
|
||||||
|
let bootstrap = future::block_on(DataPlaneBootstrap::attach(
|
||||||
|
handoff.arena_fd,
|
||||||
|
child_runtime,
|
||||||
|
host_session,
|
||||||
|
CAPABILITY,
|
||||||
|
))
|
||||||
|
.expect("routed attachment");
|
||||||
|
|
||||||
|
Harness {
|
||||||
|
_host_engine: host_engine,
|
||||||
|
_child_engine: child_engine,
|
||||||
|
_temp: temp,
|
||||||
|
bootstrap,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct NoopReleaser;
|
||||||
|
|
||||||
|
impl LeaseReleaser for NoopReleaser {
|
||||||
|
fn release(&self, _lease: BlobLease) {}
|
||||||
|
}
|
||||||
612
crates/data-plane/tests/descriptor_guarantees.rs
Executable file
612
crates/data-plane/tests/descriptor_guarantees.rs
Executable file
|
|
@ -0,0 +1,612 @@
|
||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
|
include!("data_plane_test_support.inc");
|
||||||
|
|
||||||
|
use std::alloc::{GlobalAlloc, Layout, System};
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
struct ThreadCountingAllocator;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static COUNT_ALLOCATIONS: Cell<bool> = const { Cell::new(false) };
|
||||||
|
static ALLOCATION_COUNT: Cell<usize> = const { Cell::new(0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[global_allocator]
|
||||||
|
static COUNTING_ALLOCATOR: ThreadCountingAllocator = ThreadCountingAllocator;
|
||||||
|
|
||||||
|
unsafe impl GlobalAlloc for ThreadCountingAllocator {
|
||||||
|
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||||
|
COUNT_ALLOCATIONS.with(|enabled| {
|
||||||
|
if enabled.get() {
|
||||||
|
ALLOCATION_COUNT.with(|count| count.set(count.get() + 1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// SAFETY: this allocator delegates the unchanged layout to `System`.
|
||||||
|
unsafe { System.alloc(layout) }
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
|
||||||
|
// SAFETY: `pointer` came from `System` with this layout.
|
||||||
|
unsafe { System.dealloc(pointer, layout) };
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||||
|
COUNT_ALLOCATIONS.with(|enabled| {
|
||||||
|
if enabled.get() {
|
||||||
|
ALLOCATION_COUNT.with(|count| count.set(count.get() + 1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// SAFETY: this allocator delegates the unchanged layout to `System`.
|
||||||
|
unsafe { System.alloc_zeroed(layout) }
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
|
||||||
|
COUNT_ALLOCATIONS.with(|enabled| {
|
||||||
|
if enabled.get() {
|
||||||
|
ALLOCATION_COUNT.with(|count| count.set(count.get() + 1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// SAFETY: `pointer` and `layout` came from `System`.
|
||||||
|
unsafe { System.realloc(pointer, layout, size) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_allocation_count() {
|
||||||
|
ALLOCATION_COUNT.with(|count| count.set(0));
|
||||||
|
COUNT_ALLOCATIONS.with(|enabled| enabled.set(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_allocation_count() -> usize {
|
||||||
|
COUNT_ALLOCATIONS.with(|enabled| enabled.set(false));
|
||||||
|
ALLOCATION_COUNT.with(Cell::get)
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUIRED_MUTATION_TARGETS: &[(&str, &str)] = &[
|
||||||
|
(
|
||||||
|
"omit_access_check",
|
||||||
|
"forbidden_actions_report_stable_errno_and_do_not_mutate_live_state",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"advance_requested_count",
|
||||||
|
"generated_blob_sequences_preserve_exact_tagged_prefixes",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"early_stream_eof",
|
||||||
|
"raw_stream_descriptor_hides_record_boundaries_and_preserves_eof",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"release_partial_record",
|
||||||
|
"partial_record_cursor_keeps_capacity_pinned_until_full_release",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"duplicate_stream_prefix",
|
||||||
|
"raw_stream_descriptor_hides_record_boundaries_and_preserves_eof",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cross_wire_open_grant",
|
||||||
|
"concurrent_descriptor_opens_keep_paths_and_payloads_correlated",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"publish_aborted_blob",
|
||||||
|
"legal_blob_sequences_cover_boundaries_offsets_mapping_close_and_abort",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"reclaim_live_mapping",
|
||||||
|
"raw_blob_mapping_is_bounded_and_can_outlive_descriptor_close",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"writable_read_only_export",
|
||||||
|
"test_raw_descriptor_blob_io_mapping_and_errno",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"retain_cancelled_waiter",
|
||||||
|
"cancellation_and_transport_faults_reclaim_waiters_and_preserve_unrelated_progress",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"double_publication",
|
||||||
|
"raw_blob_descriptor_enforces_offsets_rights_and_terminal_state",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"authorize_unresolved_alias",
|
||||||
|
"missing_and_unauthorized_paths_are_rejected",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"silently_stage_direct_map",
|
||||||
|
"raw_blob_mapping_is_bounded_and_can_outlive_descriptor_close",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"touch_outside_region",
|
||||||
|
"generated_blob_sequences_preserve_exact_tagged_prefixes",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"leak_failed_open_lease",
|
||||||
|
"cancelled_write_open_releases_queued_grant",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mutation_adequacy_targets_cover_every_required_bad_behavior() {
|
||||||
|
let mut identifiers: Vec<&str> = REQUIRED_MUTATION_TARGETS
|
||||||
|
.iter()
|
||||||
|
.map(|(identifier, _)| *identifier)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(identifiers.len(), 15);
|
||||||
|
identifiers.sort_unstable();
|
||||||
|
identifiers.dedup();
|
||||||
|
assert_eq!(identifiers.len(), 15, "mutation identifiers must be unique");
|
||||||
|
assert!(
|
||||||
|
REQUIRED_MUTATION_TARGETS
|
||||||
|
.iter()
|
||||||
|
.all(|(_, oracle)| !oracle.is_empty())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legal_blob_sequences_cover_boundaries_offsets_mapping_close_and_abort() {
|
||||||
|
let harness = harness(2 << 20);
|
||||||
|
let data_plane = harness.bootstrap.data_plane.clone();
|
||||||
|
|
||||||
|
future::block_on(async {
|
||||||
|
for (case, length) in [0_usize, 1, 7, 64].into_iter().enumerate() {
|
||||||
|
let logical = path(&format!("/runs/self/results/legal-{case}"));
|
||||||
|
let payload: Vec<u8> = (0..length).map(|index| index as u8).collect();
|
||||||
|
let mut writer = data_plane
|
||||||
|
.open(&logical, OpenOptions::staged_blob(length as u64))
|
||||||
|
.await
|
||||||
|
.expect("legal staged open");
|
||||||
|
for chunk in payload.chunks(3) {
|
||||||
|
writer.write_all(chunk).await.expect("legal partial write");
|
||||||
|
}
|
||||||
|
writer.close().await.expect("legal publication");
|
||||||
|
|
||||||
|
let mut reader = data_plane
|
||||||
|
.open(&logical, OpenOptions::read_only())
|
||||||
|
.await
|
||||||
|
.expect("legal read open");
|
||||||
|
let mapping = reader
|
||||||
|
.map(MapRequest {
|
||||||
|
protection: Protection::Read,
|
||||||
|
sharing: Sharing::Shared,
|
||||||
|
target: MapTarget::Host,
|
||||||
|
offset: 0,
|
||||||
|
length: length as u64,
|
||||||
|
})
|
||||||
|
.expect("legal read mapping");
|
||||||
|
assert_eq!(mapping.as_ref(), payload);
|
||||||
|
assert_eq!(mapping.route(), TransferRoute::Direct);
|
||||||
|
|
||||||
|
let mut observed = Vec::new();
|
||||||
|
let mut destination = [0xa5_u8; 11];
|
||||||
|
loop {
|
||||||
|
let count = reader
|
||||||
|
.read(&mut destination[..(case + 1).min(11)])
|
||||||
|
.await
|
||||||
|
.expect("legal sequential read");
|
||||||
|
if count == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
observed.extend_from_slice(&destination[..count]);
|
||||||
|
}
|
||||||
|
assert_eq!(observed, payload);
|
||||||
|
assert_eq!(reader.read(&mut destination).await.expect("sticky eof"), 0);
|
||||||
|
reader.close().await.expect("legal reader close");
|
||||||
|
}
|
||||||
|
|
||||||
|
let aborted = path("/runs/self/results/legal-abort");
|
||||||
|
let mut writer = data_plane
|
||||||
|
.open(&aborted, OpenOptions::staged_blob(4))
|
||||||
|
.await
|
||||||
|
.expect("abort candidate");
|
||||||
|
writer.write_all(b"nope").await.expect("staged bytes");
|
||||||
|
writer.abort().await.expect("explicit abort");
|
||||||
|
assert_eq!(
|
||||||
|
data_plane
|
||||||
|
.open(&aborted, OpenOptions::read_only())
|
||||||
|
.await
|
||||||
|
.expect_err("aborted blob is absent")
|
||||||
|
.errno(),
|
||||||
|
Errno::Enoent
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(8))]
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_blob_sequences_preserve_exact_tagged_prefixes(
|
||||||
|
payload in prop::collection::vec(any::<u8>(), 0..96),
|
||||||
|
write_size in 1_usize..17,
|
||||||
|
read_size in 1_usize..17,
|
||||||
|
) {
|
||||||
|
let harness = harness(2 << 20);
|
||||||
|
let data_plane = harness.bootstrap.data_plane.clone();
|
||||||
|
future::block_on(async {
|
||||||
|
let logical = path("/runs/self/results/property-sequence");
|
||||||
|
let mut writer = data_plane
|
||||||
|
.open(&logical, OpenOptions::staged_blob(payload.len() as u64))
|
||||||
|
.await
|
||||||
|
.expect("property writer");
|
||||||
|
for chunk in payload.chunks(write_size) {
|
||||||
|
writer.write_all(chunk).await.expect("property write");
|
||||||
|
}
|
||||||
|
writer.close().await.expect("property publish");
|
||||||
|
|
||||||
|
let mut reader = data_plane
|
||||||
|
.open(&logical, OpenOptions::read_only())
|
||||||
|
.await
|
||||||
|
.expect("property reader");
|
||||||
|
let mut destination = vec![0xa5_u8; read_size + 2];
|
||||||
|
let mut observed = Vec::new();
|
||||||
|
loop {
|
||||||
|
destination.fill(0xa5);
|
||||||
|
let count = reader
|
||||||
|
.read(&mut destination[1..=read_size])
|
||||||
|
.await
|
||||||
|
.expect("property read");
|
||||||
|
assert_eq!(destination[0], 0xa5);
|
||||||
|
assert_eq!(destination[read_size + 1], 0xa5);
|
||||||
|
if count == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
observed.extend_from_slice(&destination[1..1 + count]);
|
||||||
|
}
|
||||||
|
// Kills: advance offset by requested rather than completed bytes,
|
||||||
|
// touch destination outside the returned prefix, duplicate/drop data.
|
||||||
|
prop_assert_eq!(observed, payload);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forbidden_actions_report_stable_errno_and_do_not_mutate_live_state() {
|
||||||
|
let harness = harness(2 << 20);
|
||||||
|
let data_plane = harness.bootstrap.data_plane.clone();
|
||||||
|
let logical = path("/runs/self/results/negative");
|
||||||
|
|
||||||
|
future::block_on(async {
|
||||||
|
let mut writer = data_plane
|
||||||
|
.open(&logical, OpenOptions::staged_blob(4))
|
||||||
|
.await
|
||||||
|
.expect("negative fixture writer");
|
||||||
|
writer.write_all(b"safe").await.expect("fixture bytes");
|
||||||
|
assert_eq!(
|
||||||
|
writer
|
||||||
|
.read(&mut [0_u8; 1])
|
||||||
|
.await
|
||||||
|
.expect_err("wrong access")
|
||||||
|
.errno(),
|
||||||
|
Errno::Ebadf
|
||||||
|
);
|
||||||
|
writer.close().await.expect("fixture publication");
|
||||||
|
|
||||||
|
let invalid = OpenOptions {
|
||||||
|
exclusive: true,
|
||||||
|
..OpenOptions::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
data_plane
|
||||||
|
.open(&logical, invalid)
|
||||||
|
.await
|
||||||
|
.expect_err("invalid flags")
|
||||||
|
.errno(),
|
||||||
|
Errno::Einval
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
data_plane
|
||||||
|
.open(
|
||||||
|
&logical,
|
||||||
|
OpenOptions {
|
||||||
|
nonblocking: true,
|
||||||
|
..OpenOptions::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("unsupported nonblocking")
|
||||||
|
.errno(),
|
||||||
|
Errno::Enotsup
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
data_plane
|
||||||
|
.open(&path("/models/missing-raw"), OpenOptions::read_only())
|
||||||
|
.await
|
||||||
|
.expect_err("missing path")
|
||||||
|
.errno(),
|
||||||
|
Errno::Enoent
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
data_plane
|
||||||
|
.open(
|
||||||
|
&path("/models/tiny-linear/weights"),
|
||||||
|
OpenOptions::staged_blob(1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("unauthorized write")
|
||||||
|
.errno(),
|
||||||
|
Errno::Eacces
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
data_plane
|
||||||
|
.open(
|
||||||
|
&logical,
|
||||||
|
OpenOptions {
|
||||||
|
exclusive: true,
|
||||||
|
..OpenOptions::staged_blob(4)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("exclusive existing")
|
||||||
|
.errno(),
|
||||||
|
Errno::Eexist
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut reader = data_plane
|
||||||
|
.open(&logical, OpenOptions::read_only())
|
||||||
|
.await
|
||||||
|
.expect("negative fixture reader");
|
||||||
|
assert_eq!(
|
||||||
|
reader
|
||||||
|
.map(MapRequest {
|
||||||
|
protection: Protection::Read,
|
||||||
|
sharing: Sharing::Shared,
|
||||||
|
target: MapTarget::Host,
|
||||||
|
offset: 3,
|
||||||
|
length: 2,
|
||||||
|
})
|
||||||
|
.expect_err("mapping overrun")
|
||||||
|
.errno(),
|
||||||
|
Errno::Einval
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reader
|
||||||
|
.write(b"x")
|
||||||
|
.await
|
||||||
|
.expect_err("read-only write")
|
||||||
|
.errno(),
|
||||||
|
Errno::Ebadf
|
||||||
|
);
|
||||||
|
let mut bytes = [0_u8; 4];
|
||||||
|
reader
|
||||||
|
.read_exact(&mut bytes)
|
||||||
|
.await
|
||||||
|
.expect("state unchanged");
|
||||||
|
assert_eq!(&bytes, b"safe");
|
||||||
|
reader.close().await.expect("first close");
|
||||||
|
assert_eq!(
|
||||||
|
reader.close().await.expect_err("double close").errno(),
|
||||||
|
Errno::Ebadf
|
||||||
|
);
|
||||||
|
// Kills: omitted access checks, mutation after failed bounds checks,
|
||||||
|
// silent flag downgrade, and revival after close.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exclusive_create_is_atomic_and_abort_releases_its_reservation() {
|
||||||
|
let harness = harness(2 << 20);
|
||||||
|
let data_plane = harness.bootstrap.data_plane.clone();
|
||||||
|
let logical = path("/runs/self/results/exclusive-race");
|
||||||
|
let options = OpenOptions {
|
||||||
|
exclusive: true,
|
||||||
|
..OpenOptions::staged_blob(4)
|
||||||
|
};
|
||||||
|
|
||||||
|
future::block_on(async {
|
||||||
|
let first = data_plane.open(&logical, options.clone());
|
||||||
|
let second = data_plane.open(&logical, options.clone());
|
||||||
|
let (first, second) = future::zip(first, second).await;
|
||||||
|
let (mut winner, loser) = match (first, second) {
|
||||||
|
(Ok(winner), Err(loser)) | (Err(loser), Ok(winner)) => (winner, loser),
|
||||||
|
(Ok(_), Ok(_)) => panic!("both exclusive creators succeeded"),
|
||||||
|
(Err(first), Err(second)) => {
|
||||||
|
panic!("both exclusive creators failed: {first}; {second}")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(loser.errno(), Errno::Eexist);
|
||||||
|
winner.abort().await.expect("abort exclusive winner");
|
||||||
|
|
||||||
|
let mut replacement = data_plane
|
||||||
|
.open(&logical, options)
|
||||||
|
.await
|
||||||
|
.expect("reservation released after abort");
|
||||||
|
replacement
|
||||||
|
.abort()
|
||||||
|
.await
|
||||||
|
.expect("abort replacement reservation");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_stream_abort_completes_before_peer_observes_broken_pipe() {
|
||||||
|
let _stream_test = STREAM_TEST_LOCK.lock();
|
||||||
|
let harness = harness(2 << 20);
|
||||||
|
let data_plane = harness.bootstrap.data_plane.clone();
|
||||||
|
let logical = path("/runs/self/results/raw-broken-pipe");
|
||||||
|
|
||||||
|
future::block_on(async {
|
||||||
|
let seed_reader = data_plane.read_stream(&logical);
|
||||||
|
let seed_writer = data_plane.write_stream(&logical);
|
||||||
|
let (seed_reader, seed_writer) = future::zip(seed_reader, seed_writer).await;
|
||||||
|
let mut seed_reader = seed_reader.expect("seed reader");
|
||||||
|
let mut seed_writer = seed_writer.expect("seed writer");
|
||||||
|
seed_writer.close().await.expect("seed writer close");
|
||||||
|
assert_eq!(seed_reader.read().await.expect("seed eof"), None);
|
||||||
|
|
||||||
|
let reader = data_plane.open(&logical, OpenOptions::read_only());
|
||||||
|
let writer = data_plane.open(
|
||||||
|
&logical,
|
||||||
|
OpenOptions {
|
||||||
|
access: AccessMode::WriteOnly,
|
||||||
|
..OpenOptions::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (reader, writer) = future::zip(reader, writer).await;
|
||||||
|
let mut reader = reader.expect("raw reader");
|
||||||
|
let mut writer = writer.expect("raw writer");
|
||||||
|
reader.abort().await.expect("reader abort completion");
|
||||||
|
assert_eq!(
|
||||||
|
writer
|
||||||
|
.write(b"late")
|
||||||
|
.await
|
||||||
|
.expect_err("write after reader abort")
|
||||||
|
.errno(),
|
||||||
|
Errno::Epipe
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_descriptor_opens_keep_paths_and_payloads_correlated() {
|
||||||
|
let harness = harness(8 << 20);
|
||||||
|
let data_plane = harness.bootstrap.data_plane.clone();
|
||||||
|
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
for index in 0_u8..16 {
|
||||||
|
let data_plane = data_plane.clone();
|
||||||
|
scope.spawn(move || {
|
||||||
|
future::block_on(async move {
|
||||||
|
let logical = path(&format!("/runs/self/results/concurrent-{index}"));
|
||||||
|
let payload = [index; 32];
|
||||||
|
let mut writer = data_plane
|
||||||
|
.open(&logical, OpenOptions::staged_blob(payload.len() as u64))
|
||||||
|
.await
|
||||||
|
.expect("concurrent writer");
|
||||||
|
writer
|
||||||
|
.write_all(&payload)
|
||||||
|
.await
|
||||||
|
.expect("concurrent payload");
|
||||||
|
writer.close().await.expect("concurrent publish");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
for index in 0_u8..16 {
|
||||||
|
let data_plane = data_plane.clone();
|
||||||
|
scope.spawn(move || {
|
||||||
|
future::block_on(async move {
|
||||||
|
let logical = path(&format!("/runs/self/results/concurrent-{index}"));
|
||||||
|
let mut reader = data_plane
|
||||||
|
.open(&logical, OpenOptions::read_only())
|
||||||
|
.await
|
||||||
|
.expect("concurrent reader");
|
||||||
|
let mut payload = [0_u8; 32];
|
||||||
|
reader
|
||||||
|
.read_exact(&mut payload)
|
||||||
|
.await
|
||||||
|
.expect("correlated read");
|
||||||
|
assert_eq!(payload, [index; 32]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Kills: accepting a grant for the wrong operation/path or crossing leases.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancellation_and_transport_faults_reclaim_waiters_and_preserve_unrelated_progress() {
|
||||||
|
let _stream_test = STREAM_TEST_LOCK.lock();
|
||||||
|
let harness = harness(2 << 20);
|
||||||
|
let data_plane = harness.bootstrap.data_plane.clone();
|
||||||
|
let cancelled_path = path("/runs/self/results/cancelled-open");
|
||||||
|
|
||||||
|
future::block_on(async {
|
||||||
|
let mut cancelled = Box::pin(data_plane.read_stream(&cancelled_path));
|
||||||
|
assert!(future::poll_once(cancelled.as_mut()).await.is_none());
|
||||||
|
drop(cancelled);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
|
||||||
|
let reader = data_plane.read_stream(&cancelled_path);
|
||||||
|
let writer = data_plane.write_stream(&cancelled_path);
|
||||||
|
let (reader, writer) = future::zip(reader, writer).await;
|
||||||
|
let mut reader = reader.expect("replacement reader");
|
||||||
|
let mut writer = writer.expect("replacement writer");
|
||||||
|
writer.close().await.expect("replacement close");
|
||||||
|
assert_eq!(reader.read().await.expect("replacement eof"), None);
|
||||||
|
|
||||||
|
let mut unrelated = data_plane
|
||||||
|
.open(&path("/models/second"), OpenOptions::read_only())
|
||||||
|
.await
|
||||||
|
.expect("unrelated progress");
|
||||||
|
let mut bytes = [0_u8; 11];
|
||||||
|
unrelated
|
||||||
|
.read_exact(&mut bytes)
|
||||||
|
.await
|
||||||
|
.expect("unrelated read");
|
||||||
|
assert_eq!(&bytes, b"second-blob");
|
||||||
|
});
|
||||||
|
|
||||||
|
let fault_harness = harness_with_transport(2 << 20, Arc::new(RejectingStreamTransport));
|
||||||
|
let fault_plane = fault_harness.bootstrap.data_plane.clone();
|
||||||
|
let fault_path = path("/runs/self/results/transport-fault");
|
||||||
|
future::block_on(async {
|
||||||
|
let mut reader_open = Box::pin(fault_plane.read_stream(&fault_path));
|
||||||
|
assert!(future::poll_once(reader_open.as_mut()).await.is_none());
|
||||||
|
let writer_error = match fault_plane.write_stream(&fault_path).await {
|
||||||
|
Ok(_) => panic!("faulted writer opened"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
let reader_error = match reader_open.await {
|
||||||
|
Ok(_) => panic!("faulted reader opened"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
reader_error,
|
||||||
|
DataPlaneError::PeerLost | DataPlaneError::StreamFault(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
writer_error,
|
||||||
|
DataPlaneError::PeerLost | DataPlaneError::StreamFault(_)
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut unrelated = fault_plane
|
||||||
|
.open(&path("/models/second"), OpenOptions::read_only())
|
||||||
|
.await
|
||||||
|
.expect("fault isolation");
|
||||||
|
let mut bytes = [0_u8; 11];
|
||||||
|
unrelated
|
||||||
|
.read_exact(&mut bytes)
|
||||||
|
.await
|
||||||
|
.expect("fault-isolated read");
|
||||||
|
assert_eq!(&bytes, b"second-blob");
|
||||||
|
});
|
||||||
|
// Kills: retain a cancelled waiter, complete only one matched endpoint,
|
||||||
|
// and propagate one descriptor fault into unrelated operations.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn steady_state_blob_primitives_allocate_no_heap_memory() {
|
||||||
|
let harness = harness(2 << 20);
|
||||||
|
let data_plane = harness.bootstrap.data_plane.clone();
|
||||||
|
future::block_on(async {
|
||||||
|
let logical = path("/runs/self/results/allocation-count");
|
||||||
|
let mut writer = data_plane
|
||||||
|
.open(&logical, OpenOptions::staged_blob(64))
|
||||||
|
.await
|
||||||
|
.expect("allocation writer");
|
||||||
|
let payload = [7_u8; 64];
|
||||||
|
start_allocation_count();
|
||||||
|
let result = writer.write(&payload).await;
|
||||||
|
let write_allocations = finish_allocation_count();
|
||||||
|
assert_eq!(result.expect("allocation-count write"), payload.len());
|
||||||
|
assert_eq!(write_allocations, 0, "blob write primitive allocated");
|
||||||
|
writer.close().await.expect("allocation publish");
|
||||||
|
|
||||||
|
let mut reader = data_plane
|
||||||
|
.open(&logical, OpenOptions::read_only())
|
||||||
|
.await
|
||||||
|
.expect("allocation reader");
|
||||||
|
let mut destination = [0_u8; 64];
|
||||||
|
start_allocation_count();
|
||||||
|
let result = reader.read(&mut destination).await;
|
||||||
|
let read_allocations = finish_allocation_count();
|
||||||
|
assert_eq!(result.expect("allocation-count read"), destination.len());
|
||||||
|
assert_eq!(read_allocations, 0, "blob read primitive allocated");
|
||||||
|
assert_eq!(destination, payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Seeds for failure cases proptest has generated in the past. It is
|
||||||
|
# automatically read and these particular cases re-run before any
|
||||||
|
# novel cases are generated.
|
||||||
|
#
|
||||||
|
# It is recommended to check this file in to source control so that
|
||||||
|
# everyone who runs the test benefits from these saved cases.
|
||||||
|
cc ebf4fc53222ac2fcd156f9e3f34dcde38fa3a79d784320c929ba2e2d43e6f9de # shrinks to actions = [89, 2, 155]
|
||||||
|
|
@ -115,6 +115,13 @@ fn namespace_mutations_are_linearizable_and_durable() {
|
||||||
.await
|
.await
|
||||||
.expect("register first source");
|
.expect("register first source");
|
||||||
assert_eq!(registered.revision, 1);
|
assert_eq!(registered.revision, 1);
|
||||||
|
let node = directory
|
||||||
|
.client
|
||||||
|
.lookup(logical.clone())
|
||||||
|
.await
|
||||||
|
.expect("lookup first blob node");
|
||||||
|
assert_eq!(node.kind, EntryKind::Blob);
|
||||||
|
assert_eq!(node.revision, registered.revision);
|
||||||
|
|
||||||
let selected_first = directory
|
let selected_first = directory
|
||||||
.client
|
.client
|
||||||
|
|
@ -186,6 +193,12 @@ fn stream_rendezvous_is_symmetric_and_incarnations_are_isolated() {
|
||||||
OperationId::from_u128(10),
|
OperationId::from_u128(10),
|
||||||
));
|
));
|
||||||
assert!(future::poll_once(source_open.as_mut()).await.is_none());
|
assert!(future::poll_once(source_open.as_mut()).await.is_none());
|
||||||
|
let node = directory
|
||||||
|
.client
|
||||||
|
.lookup(logical.clone())
|
||||||
|
.await
|
||||||
|
.expect("lookup ensured stream node");
|
||||||
|
assert_eq!(node.kind, EntryKind::Stream);
|
||||||
|
|
||||||
let sink_match = directory
|
let sink_match = directory
|
||||||
.client
|
.client
|
||||||
|
|
@ -592,7 +605,7 @@ struct ModelBinding {
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
enum TypedModelEntry {
|
enum TypedModelEntry {
|
||||||
Blob(ModelBinding),
|
Blob(ModelBinding),
|
||||||
Stream(data_plane::namespace::StreamMatch),
|
Stream(Option<data_plane::namespace::StreamMatch>),
|
||||||
}
|
}
|
||||||
|
|
||||||
proptest! {
|
proptest! {
|
||||||
|
|
@ -714,15 +727,16 @@ proptest! {
|
||||||
next_operation += 1;
|
next_operation += 1;
|
||||||
let source_match = future::block_on(source_open).expect("source match");
|
let source_match = future::block_on(source_open).expect("source match");
|
||||||
prop_assert_eq!(&source_match, &sink_match);
|
prop_assert_eq!(&source_match, &sink_match);
|
||||||
model.insert(logical, TypedModelEntry::Stream(sink_match));
|
model.insert(logical, TypedModelEntry::Stream(Some(sink_match)));
|
||||||
}
|
}
|
||||||
2 => {
|
2 => {
|
||||||
if let Some(TypedModelEntry::Stream(binding)) = model.get(&logical) {
|
if let Some(TypedModelEntry::Stream(binding)) = model.get_mut(&logical)
|
||||||
|
&& let Some(binding) = binding.take()
|
||||||
|
{
|
||||||
future::block_on(directory.client.close_stream(
|
future::block_on(directory.client.close_stream(
|
||||||
logical.clone(),
|
logical.clone(),
|
||||||
binding.incarnation,
|
binding.incarnation,
|
||||||
)).expect("close current stream");
|
)).expect("close current stream");
|
||||||
model.remove(&logical);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue