swactor/crates/datastore/tests/stream_integration.rs
Zachery Aaron Shores-Chmielewski 315f0ff8ee feat: data streams primitive (#48)
Allows streaming blobs without interference from the actor runtime.
Co-authored-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
Co-committed-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-02-23 04:47:54 +00:00

415 lines
13 KiB
Rust

//! End-to-end test: store a blob on node A, download via QUIC stream on node B.
use std::sync::Arc;
use std::time::{Duration, Instant};
use distribution::iroh_driver::{IrohDriver, IrohDriverConfig};
use distribution::node::DistributedNodeConfig;
use iroh::RelayMode;
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use swactor_datastore::bridge::{DatastoreGroup, DatastoreGroupConfig};
use swactor_datastore::messages::{DatastoreNodeMsg, DatastoreResponse};
use swactor_std::RuntimeNaming;
fn make_driver_with_streams() -> IrohDriver {
IrohDriver::new(IrohDriverConfig {
secret_key: None,
relay_mode: RelayMode::Disabled,
node: DistributedNodeConfig::default(),
peer_auth: None,
additional_alpns: vec![swactor_streams::ALPN.to_vec()],
})
.expect("create iroh driver")
}
fn make_runtime() -> Arc<Runtime> {
let rt = Runtime::new(RuntimeConfig {
num_threads: 2,
max_actors: 256,
channel_buffer_size: 2000,
..Default::default()
})
.with_extension(Arc::new(swactor_std::StdExtension::new()));
let handle = rt.run().expect("start runtime");
handle.runtime
}
fn spawn_stream_manager(
runtime: &Arc<Runtime>,
driver: &IrohDriver,
) -> swactor::actor::ActorAddress {
let mgr = swactor_streams::StreamManager::new(
driver.endpoint().clone(),
driver.tokio_handle(),
Arc::clone(runtime),
);
let addr = runtime.spawn(mgr).expect("spawn StreamManager");
runtime
.register_name(swactor_streams::STREAM_MANAGER_NAME, addr)
.expect("register StreamManager");
addr
}
fn spawn_datastore(
runtime: &Arc<Runtime>,
driver: &IrohDriver,
mgr_addr: swactor::actor::ActorAddress,
) -> DatastoreGroup {
let node_id = driver.node_id();
let group = DatastoreGroup::spawn(
Arc::clone(runtime),
DatastoreGroupConfig {
node_id,
node_id_hex: format!("{:?}", node_id),
chunk_size: 256,
storage_path: None, // in-memory
auth: None,
gc_interval: u64::MAX,
disseminate_interval: u64::MAX,
},
)
.expect("spawn datastore");
group.configure_streams(mgr_addr, driver.tokio_handle());
group
}
/// Poll for a response from the inbox, routing stream connections between
/// the two nodes. Actor message processing is handled by worker threads.
fn pump_until_response(
rt_a: &Arc<Runtime>,
rt_b: &Arc<Runtime>,
driver_a: &mut IrohDriver,
driver_b: &mut IrohDriver,
mgr_a: swactor::actor::ActorAddress,
mgr_b: swactor::actor::ActorAddress,
inbox: &swactor::runtime::Inbox<DatastoreResponse>,
timeout: Duration,
) -> Option<DatastoreResponse> {
let deadline = Instant::now() + timeout;
let tokio_handle = driver_a.tokio_handle();
loop {
// SWIM protocol ticks
driver_a.recv();
driver_a.tick();
driver_b.recv();
driver_b.tick();
// Route incoming stream connections on node A
for (node_id, conn) in driver_a.drain_other_connections() {
let rt = Arc::clone(rt_a);
let mgr = mgr_a;
let node_bytes = node_id.0;
tokio_handle.spawn(async move {
let _ = swactor_streams::accept::handle_incoming(node_bytes, conn, &rt, mgr).await;
});
}
// Route incoming stream connections on node B
for (node_id, conn) in driver_b.drain_other_connections() {
let rt = Arc::clone(rt_b);
let mgr = mgr_b;
let node_bytes = node_id.0;
tokio_handle.spawn(async move {
let _ = swactor_streams::accept::handle_incoming(node_bytes, conn, &rt, mgr).await;
});
}
// Check for completion
if let Some(resp) = inbox.try_recv() {
return Some(resp);
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn small_blob_transfers_between_two_nodes_via_stream() {
let mut driver_a = make_driver_with_streams();
let mut driver_b = make_driver_with_streams();
let rt_a = make_runtime();
let rt_b = make_runtime();
let mgr_a = spawn_stream_manager(&rt_a, &driver_a);
let mgr_b = spawn_stream_manager(&rt_b, &driver_b);
let _ds_a = spawn_datastore(&rt_a, &driver_a, mgr_a);
let ds_b = spawn_datastore(&rt_b, &driver_b, mgr_b);
let _ = &ds_b; // keep alive
// Have both nodes discover each other via SWIM
let addr_a = driver_a.endpoint_addr();
let addr_b = driver_b.endpoint_addr();
driver_a.join(&[addr_b]);
driver_b.join(&[addr_a]);
// Pump until SWIM membership converges
let swim_deadline = Instant::now() + Duration::from_secs(10);
loop {
driver_a.recv();
driver_a.tick();
driver_b.recv();
driver_b.tick();
let snap_a = driver_a.snapshot();
let snap_b = driver_b.snapshot();
if snap_a.alive_count >= 1 && snap_b.alive_count >= 1 {
break;
}
if Instant::now() >= swim_deadline {
panic!("SWIM convergence timed out");
}
std::thread::sleep(Duration::from_millis(50));
}
// Give worker threads time to process spawned actors
std::thread::sleep(Duration::from_millis(100));
// Store a small blob on Node A
let test_data = b"Hello from node A! This is a stream integration test.";
let put_inbox = rt_a
.new_inbox::<DatastoreResponse>()
.expect("create inbox");
let ds_a_addr = rt_a.where_is("Datastore").expect("Datastore registered on A");
let _ = rt_a.send_to(
ds_a_addr,
DatastoreNodeMsg::Put {
data: test_data.to_vec(),
name: Some("test-blob".into()),
tags: Default::default(),
reply_to: *put_inbox.addr(),
},
);
// Wait for PutOk (worker threads process messages)
let content_hash = loop {
if let Some(resp) = put_inbox.try_recv() {
match resp {
DatastoreResponse::PutOk { content_hash } => break content_hash,
other => panic!("expected PutOk, got: {other:?}"),
}
}
std::thread::sleep(Duration::from_millis(10));
};
// PutOk comes from MetadataActor; BlobStore writes are fire-and-forget.
// Give BlobStore time to finish writing chunks + manifest.
std::thread::sleep(Duration::from_millis(200));
// Download the blob on Node B via stream
let download_inbox = rt_b
.new_inbox::<DatastoreResponse>()
.expect("create inbox");
let ds_b_addr = rt_b.where_is("Datastore").expect("Datastore registered on B");
let _ = rt_b.send_to(
ds_b_addr,
DatastoreNodeMsg::DownloadViaStream {
content_hash,
source_node: driver_a.node_id().0,
reply_to: *download_inbox.addr(),
},
);
// Pump loop until we get a response
let resp = pump_until_response(
&rt_a,
&rt_b,
&mut driver_a,
&mut driver_b,
mgr_a,
mgr_b,
&download_inbox,
Duration::from_secs(15),
);
match resp {
Some(DatastoreResponse::PutOk { content_hash: h }) => {
assert_eq!(h, content_hash, "downloaded blob hash should match");
}
other => panic!("expected PutOk from download, got: {other:?}"),
}
// Verify: read the blob back from Node B's datastore
let verify_inbox = rt_b
.new_inbox::<DatastoreResponse>()
.expect("create inbox");
let _ = rt_b.send_to(
ds_b_addr,
DatastoreNodeMsg::Get {
content_hash,
reply_to: *verify_inbox.addr(),
},
);
let verify_deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some(resp) = verify_inbox.try_recv() {
match resp {
DatastoreResponse::GetOk { entry, manifest } => {
assert_eq!(entry.content_hash, content_hash);
assert_eq!(manifest.total_size, test_data.len() as u64);
break;
}
other => panic!("expected GetOk, got: {other:?}"),
}
}
if Instant::now() >= verify_deadline {
panic!("verify timed out — blob not found on Node B");
}
std::thread::sleep(Duration::from_millis(10));
}
driver_a.shutdown();
driver_b.shutdown();
rt_a.shutdown();
rt_b.shutdown();
}
#[test]
fn multi_chunk_blob_transfers_between_two_nodes_via_stream() {
let mut driver_a = make_driver_with_streams();
let mut driver_b = make_driver_with_streams();
let rt_a = make_runtime();
let rt_b = make_runtime();
let mgr_a = spawn_stream_manager(&rt_a, &driver_a);
let mgr_b = spawn_stream_manager(&rt_b, &driver_b);
let _ds_a = spawn_datastore(&rt_a, &driver_a, mgr_a);
let ds_b = spawn_datastore(&rt_b, &driver_b, mgr_b);
let _ = &ds_b;
// Discover each other via SWIM
let addr_a = driver_a.endpoint_addr();
let addr_b = driver_b.endpoint_addr();
driver_a.join(&[addr_b]);
driver_b.join(&[addr_a]);
let swim_deadline = Instant::now() + Duration::from_secs(10);
loop {
driver_a.recv();
driver_a.tick();
driver_b.recv();
driver_b.tick();
let snap_a = driver_a.snapshot();
let snap_b = driver_b.snapshot();
if snap_a.alive_count >= 1 && snap_b.alive_count >= 1 {
break;
}
if Instant::now() >= swim_deadline {
panic!("SWIM convergence timed out");
}
std::thread::sleep(Duration::from_millis(50));
}
// Give worker threads time to process spawned actors
std::thread::sleep(Duration::from_millis(100));
// 4 chunks at 256 bytes each = 1024 bytes
let test_data: Vec<u8> = (0..1024).map(|i| (i % 251) as u8).collect();
let put_inbox = rt_a
.new_inbox::<DatastoreResponse>()
.expect("create inbox");
let ds_a_addr = rt_a.where_is("Datastore").expect("Datastore registered on A");
let _ = rt_a.send_to(
ds_a_addr,
DatastoreNodeMsg::Put {
data: test_data.clone(),
name: Some("multi-chunk".into()),
tags: Default::default(),
reply_to: *put_inbox.addr(),
},
);
let content_hash = loop {
if let Some(resp) = put_inbox.try_recv() {
match resp {
DatastoreResponse::PutOk { content_hash } => break content_hash,
other => panic!("expected PutOk, got: {other:?}"),
}
}
std::thread::sleep(Duration::from_millis(10));
};
// PutOk comes from MetadataActor; BlobStore writes are fire-and-forget.
std::thread::sleep(Duration::from_millis(200));
// Download on Node B
let download_inbox = rt_b
.new_inbox::<DatastoreResponse>()
.expect("create inbox");
let ds_b_addr = rt_b.where_is("Datastore").expect("Datastore registered on B");
let _ = rt_b.send_to(
ds_b_addr,
DatastoreNodeMsg::DownloadViaStream {
content_hash,
source_node: driver_a.node_id().0,
reply_to: *download_inbox.addr(),
},
);
let resp = pump_until_response(
&rt_a,
&rt_b,
&mut driver_a,
&mut driver_b,
mgr_a,
mgr_b,
&download_inbox,
Duration::from_secs(15),
);
match resp {
Some(DatastoreResponse::PutOk { content_hash: h }) => {
assert_eq!(h, content_hash);
}
other => panic!("expected PutOk from download, got: {other:?}"),
}
// Verify the data on Node B by reading each chunk
let verify_inbox = rt_b
.new_inbox::<DatastoreResponse>()
.expect("create inbox");
let _ = rt_b.send_to(
ds_b_addr,
DatastoreNodeMsg::Get {
content_hash,
reply_to: *verify_inbox.addr(),
},
);
let verify_deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some(resp) = verify_inbox.try_recv() {
match resp {
DatastoreResponse::GetOk { entry, manifest } => {
assert_eq!(entry.content_hash, content_hash);
assert_eq!(manifest.total_size, test_data.len() as u64);
assert!(manifest.chunks.len() > 1, "should be multi-chunk");
break;
}
other => panic!("expected GetOk, got: {other:?}"),
}
}
if Instant::now() >= verify_deadline {
panic!("verify timed out — blob not found on Node B");
}
std::thread::sleep(Duration::from_millis(10));
}
driver_a.shutdown();
driver_b.shutdown();
rt_a.shutdown();
rt_b.shutdown();
}