use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; use data_plane::host::{HostRouteRegistrar, HostRouteWatch}; use data_plane::namespace::{ DataDirectoryActor, DataDirectoryIn, DataDirectoryOut, DirectoryClient, DirectoryRequestId, EntryKind, NamespaceClient, NamespaceClientActor, NamespaceClientIn, NamespaceDiscovery, NamespaceError, NamespaceRequest, OperationId, RetirementRetry, SourceRecovery, StreamRole, }; use data_plane::path::DataPath; use futures_lite::future; use parking_lot::RwLock; use proptest::prelude::*; use swactor::actor::ActorAddress; use swactor::config::RuntimeConfig; use swactor::runtime::{Runtime, RuntimeParts}; use swactor_engine::{Engine, SteppingBackend, TokioBackend, TokioConfig}; static NEXT_TEMP: AtomicU64 = AtomicU64::new(1); struct TempState { root: PathBuf, } impl TempState { fn new(label: &str) -> Self { let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); let root = std::env::temp_dir().join(format!( "swactor-namespace-{label}-{}-{sequence}", std::process::id() )); std::fs::create_dir_all(&root).expect("create temporary namespace directory"); Self { root } } fn store(&self) -> PathBuf { self.root.join("namespace.json") } } impl Drop for TempState { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.root); } } struct DirectoryHarness { engine: Engine, runtime: Runtime, client: DirectoryClient, } fn path(value: &str) -> DataPath { DataPath::parse(value).expect("test path") } fn source(byte: u8) -> ActorAddress { ActorAddress([byte; 32]) } fn recovery(actor: ActorAddress) -> SourceRecovery { SourceRecovery::Actor { actor, node: [9; 32], owner: None, } } fn spawn_directory(store: &Path) -> DirectoryHarness { let parts = RuntimeParts::new(RuntimeConfig { worker_count: 1, ..RuntimeConfig::default() }); let runtime = parts.runtime().clone(); let engine = Engine::new( parts, TokioBackend::new(TokioConfig { worker_threads: 1, ..TokioConfig::default() }) .expect("tokio backend"), ) .expect("directory engine"); let actor = DataDirectoryActor::recover( store, Some(RetirementRetry::new( engine.handle(), runtime.create_sender(), Duration::from_millis(50), None, )), |record, _length| match record { SourceRecovery::Actor { actor, node, .. } => Ok((*actor, *node)), SourceRecovery::File { path } => Err(NamespaceError::SourceRecovery(format!( "test cannot recover file source {}", path.display() ))), }, ) .expect("recover directory"); let directory = runtime.spawn(actor).expect("spawn directory actor"); DirectoryHarness { engine, runtime: runtime.clone(), client: DirectoryClient::new(runtime, directory), } } #[test] fn namespace_mutations_are_linearizable_and_durable() { let state = TempState::new("linearizable"); let directory = spawn_directory(&state.store()); let logical = path("/models/tiny-linear/weights"); let first = source(1); let second = source(2); future::block_on(async { let registered = directory .client .register( logical.clone(), first, [9; 32], 24, recovery(first), OperationId::from_u128(1), ) .await .expect("register first source"); 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 .client .resolve(logical.clone()) .await .expect("resolve first source"); assert_eq!(selected_first.source, first); assert_eq!(selected_first.length, 24); assert_eq!(selected_first.revision, 1); let replaced = directory .client .register( logical.clone(), second, [9; 32], 32, recovery(second), OperationId::from_u128(2), ) .await .expect("replace source"); assert_eq!(replaced.revision, 2); let selected_second = directory .client .resolve(logical.clone()) .await .expect("resolve replacement"); assert_eq!(selected_second.source, second); assert_eq!(selected_second.length, 32); assert_eq!(selected_second.revision, 2); // A completed resolve is a binding snapshot. Later replacement cannot // mutate the source selected by the earlier logical read. assert_eq!(selected_first.source, first); assert_eq!(selected_first.revision, 1); let removed = directory .client .unregister(logical.clone(), OperationId::from_u128(3)) .await .expect("unregister source"); assert_eq!(removed.revision, 3); assert!(matches!( directory.client.resolve(logical.clone()).await, Err(NamespaceError::PathNotFound(found)) if found == logical )); }); // The acknowledged removal is recovered from a fresh actor instance. let recovered = spawn_directory(&state.store()); assert!(matches!( future::block_on(recovered.client.resolve(logical.clone())), Err(NamespaceError::PathNotFound(found)) if found == logical )); } #[test] fn rename_is_atomic_replayable_and_revisioned() { let state = TempState::new("rename"); let directory = spawn_directory(&state.store()); let source_path = path("/rename/source"); let destination_path = path("/rename/destination"); let source_actor = source(7); let destination_actor = source(8); let owner = source(9); future::block_on(async { directory .client .register( source_path.clone(), source_actor, [9; 32], 7, SourceRecovery::Actor { actor: source_actor, node: [9; 32], owner: Some(owner), }, OperationId::from_u128(100), ) .await .unwrap(); directory .client .register( destination_path.clone(), destination_actor, [9; 32], 8, recovery(destination_actor), OperationId::from_u128(101), ) .await .unwrap(); assert!(matches!( directory .client .rename( source_path.clone(), destination_path.clone(), false, OperationId::from_u128(102), ) .await, Err(NamespaceError::PathExists(path)) if path == destination_path )); assert_eq!( directory .client .resolve(source_path.clone()) .await .unwrap() .source, source_actor ); assert_eq!( directory .client .resolve(destination_path.clone()) .await .unwrap() .source, destination_actor ); let renamed = directory .client .rename( source_path.clone(), destination_path.clone(), true, OperationId::from_u128(103), ) .await .unwrap(); assert_eq!(renamed.revision, 3); let replayed = directory .client .rename( source_path.clone(), destination_path.clone(), true, OperationId::from_u128(103), ) .await .unwrap(); assert_eq!(replayed, renamed); assert!(matches!( directory.client.lookup(source_path.clone()).await, Err(NamespaceError::PathNotFound(path)) if path == source_path )); let resolved = directory .client .resolve(destination_path.clone()) .await .unwrap(); assert_eq!(resolved.source, source_actor); assert_eq!(resolved.revision, renamed.revision); assert_eq!(resolved.owner, Some(owner)); }); let recovered = spawn_directory(&state.store()); let resolved = future::block_on(recovered.client.resolve(destination_path)).unwrap(); assert_eq!(resolved.source, source_actor); assert_eq!(resolved.revision, 3); assert_eq!(resolved.owner, Some(owner)); } #[test] fn mutation_rejections_are_sticky_across_reservation_lifecycle() { let state = TempState::new("sticky-rejections"); let directory = spawn_directory(&state.store()); let source_path = path("/sticky/source"); let reserved_path = path("/sticky/reserved"); let unbound_path = path("/sticky/unbound"); let stream_path = path("/sticky/stream"); let source_actor = source(9); future::block_on(async { directory .client .register( source_path.clone(), source_actor, [9; 32], 5, recovery(source_actor), OperationId::from_u128(200), ) .await .unwrap(); // A concurrent blob upload holds a reservation on the rename target. directory .client .reserve_blob(reserved_path.clone(), OperationId::from_u128(201)) .await .unwrap(); assert!(matches!( directory .client .rename( source_path.clone(), reserved_path.clone(), true, OperationId::from_u128(202), ) .await, Err(NamespaceError::PathExists(path)) if path == reserved_path )); // The upload is abandoned and its reservation released. Retrying the // SAME operation id must replay the rejection: an id the client saw // rejected may never commit later, whatever else changed. directory .client .release_blob_reservation(reserved_path.clone(), OperationId::from_u128(201)) .await .unwrap(); assert!(matches!( directory .client .rename( source_path.clone(), reserved_path.clone(), true, OperationId::from_u128(202), ) .await, Err(NamespaceError::PathExists(path)) if path == reserved_path )); assert_eq!( directory .client .resolve(source_path.clone()) .await .unwrap() .source, source_actor ); // Unregister against a reserved-but-unbound path is sticky the same way. directory .client .reserve_blob(unbound_path.clone(), OperationId::from_u128(203)) .await .unwrap(); assert!(matches!( directory .client .unregister(unbound_path.clone(), OperationId::from_u128(204)) .await, Err(NamespaceError::PathExists(path)) if path == unbound_path )); directory .client .release_blob_reservation(unbound_path.clone(), OperationId::from_u128(203)) .await .unwrap(); assert!(matches!( directory .client .unregister(unbound_path.clone(), OperationId::from_u128(204)) .await, Err(NamespaceError::PathExists(path)) if path == unbound_path )); // Stream binding against a reserved path replays its rejection too. directory .client .reserve_blob(stream_path.clone(), OperationId::from_u128(205)) .await .unwrap(); assert!(matches!( directory .client .replace_with_stream( stream_path.clone(), StreamRole::Source, source(4), OperationId::from_u128(206), ) .await, Err(NamespaceError::PathExists(path)) if path == stream_path )); directory .client .release_blob_reservation(stream_path.clone(), OperationId::from_u128(205)) .await .unwrap(); assert!(matches!( directory .client .replace_with_stream( stream_path.clone(), StreamRole::Source, source(4), OperationId::from_u128(206), ) .await, Err(NamespaceError::PathExists(path)) if path == stream_path )); }); } #[test] fn committed_mutations_replay_even_when_path_is_reserved_again() { let state = TempState::new("replay-across-reservation"); let directory = spawn_directory(&state.store()); let logical = path("/replay/reserved"); let blob = source(3); future::block_on(async { directory .client .register( logical.clone(), blob, [9; 32], 12, recovery(blob), OperationId::from_u128(300), ) .await .unwrap(); let removed = directory .client .unregister(logical.clone(), OperationId::from_u128(301)) .await .unwrap(); // A later upload reserves the freed path. A duplicate delivery of the // already-acknowledged unregister must still replay its receipt // instead of failing against the unrelated reservation. directory .client .reserve_blob(logical.clone(), OperationId::from_u128(302)) .await .unwrap(); let replayed = directory .client .unregister(logical.clone(), OperationId::from_u128(301)) .await .unwrap(); assert_eq!(replayed, removed); }); } #[test] fn active_stream_rename_is_typed_and_quiescent_stream_rename_succeeds() { let state = TempState::new("stream-rename"); let directory = spawn_directory(&state.store()); let source_path = path("/rename/stream-source"); let destination_path = path("/rename/stream-destination"); future::block_on(async { let mut source_open = Box::pin(directory.client.open_stream( source_path.clone(), StreamRole::Source, source(30), OperationId::from_u128(110), )); assert!(future::poll_once(source_open.as_mut()).await.is_none()); let sink_match = directory .client .open_stream( source_path.clone(), StreamRole::Sink, source(31), OperationId::from_u128(111), ) .await .unwrap(); let source_match = source_open.await.unwrap(); assert_eq!(source_match, sink_match); let missing_source = path("/rename/missing-source"); assert!(matches!( directory .client .rename( missing_source.clone(), source_path.clone(), true, OperationId::from_u128(114), ) .await, Err(NamespaceError::PathNotFound(path)) if path == missing_source )); assert!(matches!( directory .client .rename( source_path.clone(), destination_path.clone(), false, OperationId::from_u128(112), ) .await, Err(NamespaceError::StreamActive(path)) if path == source_path )); directory .client .close_stream(source_path.clone(), source_match.incarnation) .await .unwrap(); let renamed = directory .client .rename( source_path.clone(), destination_path.clone(), false, OperationId::from_u128(113), ) .await .unwrap(); let node = directory .client .lookup(destination_path.clone()) .await .unwrap(); assert_eq!(node.kind, EntryKind::Stream); assert_eq!(node.revision, renamed.revision); assert!(matches!( directory.client.lookup(source_path.clone()).await, Err(NamespaceError::PathNotFound(path)) if path == source_path )); }); } #[test] fn stream_rendezvous_is_symmetric_and_incarnations_are_isolated() { let state = TempState::new("stream-rendezvous"); let directory = spawn_directory(&state.store()); let logical = path("/runs/7/results"); future::block_on(async { let mut source_open = Box::pin(directory.client.open_stream( logical.clone(), StreamRole::Source, source(10), OperationId::from_u128(10), )); 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 .client .open_stream( logical.clone(), StreamRole::Sink, source(11), OperationId::from_u128(11), ) .await .expect("sink matches source"); let source_match = source_open.await.expect("source matches sink"); assert_eq!(source_match, sink_match); assert_eq!(source_match.source, source(10)); assert_eq!(source_match.sink, source(11)); directory .client .close_stream(logical.clone(), source_match.incarnation) .await .expect("close first incarnation"); directory .client .close_stream(logical.clone(), source_match.incarnation) .await .expect("duplicate close is idempotent"); assert!(matches!( directory .client .open_stream( logical.clone(), StreamRole::Source, source(10), OperationId::from_u128(10), ) .await, Err(NamespaceError::StaleIncarnation { .. }) )); assert!(matches!( directory .client .open_stream( logical.clone(), StreamRole::Sink, source(11), OperationId::from_u128(11), ) .await, Err(NamespaceError::StaleIncarnation { .. }) )); let mut sink_open = Box::pin(directory.client.open_stream( logical.clone(), StreamRole::Sink, source(12), OperationId::from_u128(12), )); assert!(future::poll_once(sink_open.as_mut()).await.is_none()); assert!(matches!( directory .client .open_stream( logical.clone(), StreamRole::Source, source(10), OperationId::from_u128(10), ) .await, Err(NamespaceError::StaleIncarnation { .. }) )); let second_source = directory .client .open_stream( logical, StreamRole::Source, source(13), OperationId::from_u128(13), ) .await .expect("source matches waiting sink"); let second_sink = sink_open.await.expect("sink matches source"); assert_eq!(second_source, second_sink); assert_ne!(source_match.incarnation, second_source.incarnation); }); } #[test] fn typed_paths_require_explicit_rebinding() { let state = TempState::new("typed-path"); let directory = spawn_directory(&state.store()); let logical = path("/typed/value"); let blob_source = source(20); future::block_on(async { directory .client .register( logical.clone(), blob_source, [9; 32], 4, recovery(blob_source), OperationId::from_u128(20), ) .await .expect("register blob"); assert!(matches!( directory .client .open_stream( logical.clone(), StreamRole::Source, source(21), OperationId::from_u128(21), ) .await, Err(NamespaceError::WrongEntryType { expected: EntryKind::Stream, found: EntryKind::Blob, .. }) )); let mut source_open = Box::pin(directory.client.replace_with_stream( logical.clone(), StreamRole::Source, source(22), OperationId::from_u128(22), )); assert!(future::poll_once(source_open.as_mut()).await.is_none()); assert!(matches!( directory.client.resolve(logical.clone()).await, Err(NamespaceError::WrongEntryType { expected: EntryKind::Blob, found: EntryKind::Stream, .. }) )); let sink_match = directory .client .open_stream( logical, StreamRole::Sink, source(23), OperationId::from_u128(23), ) .await .expect("match rebound stream"); assert_eq!( source_open.await.expect("rebound source matched"), sink_match ); }); } #[test] fn replacing_waiting_stream_displaces_old_open() { let state = TempState::new("stream-displacement"); let directory = spawn_directory(&state.store()); let logical = path("/replace/waiting"); future::block_on(async { let mut old_open = Box::pin(directory.client.open_stream( logical.clone(), StreamRole::Source, source(30), OperationId::from_u128(30), )); assert!(future::poll_once(old_open.as_mut()).await.is_none()); let mut replacement = Box::pin(directory.client.replace_with_stream( logical.clone(), StreamRole::Source, source(31), OperationId::from_u128(31), )); assert!(future::poll_once(replacement.as_mut()).await.is_none()); assert!(matches!( old_open.await, Err(NamespaceError::PathReplaced(found)) if found == logical )); let sink_match = directory .client .open_stream( logical, StreamRole::Sink, source(32), OperationId::from_u128(32), ) .await .expect("sink matches replacement"); assert_eq!( replacement.await.expect("replacement source matched"), sink_match ); assert_eq!(sink_match.source, source(31)); }); } #[test] fn duplicate_stream_role_fails_without_replacing_the_waiter() { let state = TempState::new("duplicate-stream-role"); let directory = spawn_directory(&state.store()); let logical = path("/duplicate/source"); future::block_on(async { let mut first = Box::pin(directory.client.open_stream( logical.clone(), StreamRole::Source, source(51), OperationId::from_u128(51), )); assert!(future::poll_once(first.as_mut()).await.is_none()); assert!(matches!( directory .client .open_stream( logical.clone(), StreamRole::Source, source(52), OperationId::from_u128(52), ) .await, Err(NamespaceError::DuplicateStreamRole { role: StreamRole::Source, .. }) )); let matched = directory .client .open_stream( logical, StreamRole::Sink, source(53), OperationId::from_u128(53), ) .await .expect("sink matches original source"); assert_eq!(matched.source, source(51)); assert_eq!(first.await.expect("original source survives"), matched); }); } #[test] fn committed_mutation_retry_has_at_most_once_effect() { let state = TempState::new("idempotent"); let logical = path("/models/a"); let actor = source(7); let operation = OperationId::from_u128(99); let first_runtime = spawn_directory(&state.store()); let first_receipt = future::block_on(first_runtime.client.register( logical.clone(), actor, [9; 32], 8, recovery(actor), operation, )) .expect("initial registration"); assert_eq!(first_receipt.revision, 1); drop(first_runtime); let recovered = spawn_directory(&state.store()); let retried = future::block_on(recovered.client.register( logical.clone(), actor, [9; 32], 8, recovery(actor), operation, )) .expect("retry committed registration"); assert_eq!(retried, first_receipt); assert_eq!( future::block_on(recovered.client.resolve(logical.clone())) .expect("resolve recovered binding") .revision, 1 ); let conflict = future::block_on(recovered.client.register( logical, source(8), [9; 32], 9, recovery(source(8)), operation, )); assert!(matches!(conflict, Err(NamespaceError::OperationConflict(id)) if id == operation)); } struct StaticDiscovery { directory: Arc>>, } impl NamespaceDiscovery for StaticDiscovery { fn current_directory(&self) -> Option { *self.directory.read() } } #[test] fn cancelled_stream_open_never_resurrects_across_reordering() { let state = TempState::new("cancel-tombstone"); let harness = spawn_directory(&state.store()); let directory = harness.client.directory(); let logical = path("/runs/9/parked-sink"); let endpoint = source(21); let operation = OperationId::from_u128(21); let replies = harness .runtime .new_inbox::() .expect("reply inbox"); // The cancel races ahead of the open it retracts (the open frame was // lost): the persisted rejection must turn the open's late arrival into // a typed failure, never a fresh pending endpoint. harness .runtime .send_to( directory, DataDirectoryIn::CancelStream { request_id: DirectoryRequestId(1), path: logical.clone(), operation_id: operation, reply_to: Some(*replies.addr()), }, ) .expect("send cancel before open"); match future::block_on(replies.recv()) { NamespaceClientIn::DirectoryReply(DataDirectoryOut::StreamCancelled { result: Ok(()), .. }) => {} other => panic!("expected cancel acknowledgement, got {other:?}"), } harness .runtime .send_to( directory, DataDirectoryIn::OpenStream { request_id: DirectoryRequestId(2), path: logical.clone(), role: StreamRole::Sink, descriptor: Vec::new(), endpoint, replace: false, ensure: true, expected_revision: None, operation_id: operation, reply_to: *replies.addr(), }, ) .expect("send late open replay"); match future::block_on(replies.recv()) { NamespaceClientIn::DirectoryReply(DataDirectoryOut::StreamOpened { result: Err(NamespaceError::PathReplaced(_)), .. }) => {} other => panic!("tombstoned open must replay its rejection, got {other:?}"), } assert!( matches!( future::block_on(harness.client.lookup(logical.clone())), Err(NamespaceError::PathNotFound(_)) ), "a cancelled open must not leave a stream node behind" ); // Once the open did land, cancelling it and replaying it again must be // equally terminal: no resurrected waiter holds the path hostage. let second = OperationId::from_u128(22); harness .runtime .send_to( directory, DataDirectoryIn::OpenStream { request_id: DirectoryRequestId(3), path: logical.clone(), role: StreamRole::Sink, descriptor: Vec::new(), endpoint, replace: false, ensure: true, expected_revision: None, operation_id: second, reply_to: *replies.addr(), }, ) .expect("send fresh open"); let node = future::block_on(harness.client.lookup(logical.clone())) .expect("waiting sink publishes its stream node"); assert!(node.active, "waiting sink keeps the node active"); harness .runtime .send_to( directory, DataDirectoryIn::CancelStream { request_id: DirectoryRequestId(4), path: logical.clone(), operation_id: second, reply_to: Some(*replies.addr()), }, ) .expect("cancel the waiting open"); match future::block_on(replies.recv()) { NamespaceClientIn::DirectoryReply(DataDirectoryOut::StreamCancelled { result: Ok(()), .. }) => {} other => panic!("expected second cancel acknowledgement, got {other:?}"), } harness .runtime .send_to( directory, DataDirectoryIn::OpenStream { request_id: DirectoryRequestId(5), path: logical.clone(), role: StreamRole::Sink, descriptor: Vec::new(), endpoint, replace: false, ensure: true, expected_revision: None, operation_id: second, reply_to: *replies.addr(), }, ) .expect("replay the cancelled open"); match future::block_on(replies.recv()) { NamespaceClientIn::DirectoryReply(DataDirectoryOut::StreamOpened { result: Err(NamespaceError::StaleIncarnation { .. }), .. }) => {} other => panic!("replayed cancelled open must be stale, got {other:?}"), } let node = future::block_on(harness.client.lookup(logical.clone())) .expect("stream node survives until unlink"); assert!(!node.active, "cancelled sink must release the path"); future::block_on( harness .client .unregister(logical.clone(), OperationId::from_u128(23)), ) .expect("quiescent stream node unlinks"); } struct ForwardingSink { destination: Runtime, } impl swactor::runtime::RemoteSink for ForwardingSink { fn send( &self, address: ActorAddress, message: Box, ) -> Result<(), swactor::Error> { self.destination.deliver_raw(address, message) } } struct CancelDroppingSink { destination: Runtime, drop_next_cancel: AtomicBool, } impl swactor::runtime::RemoteSink for CancelDroppingSink { fn send( &self, address: ActorAddress, message: Box, ) -> Result<(), swactor::Error> { if let Some(frame) = message.downcast_ref::() && matches!(frame, DataDirectoryIn::CancelStream { .. }) && self.drop_next_cancel.swap(false, Ordering::SeqCst) { // Simulate one lost frame on the at-most-once path. return Ok(()); } self.destination.deliver_raw(address, message) } } #[test] fn cancelled_stream_open_retracts_despite_a_lost_cancel_frame() { let state = TempState::new("cancel-retry"); let directory_harness = spawn_directory(&state.store()); let directory = directory_harness.client.directory(); let client_parts = RuntimeParts::new(RuntimeConfig { worker_count: 1, ..RuntimeConfig::default() }); let client_runtime = client_parts.runtime().clone(); let client_engine = Engine::new( client_parts, TokioBackend::new(TokioConfig { worker_threads: 1, ..TokioConfig::default() }) .expect("tokio backend"), ) .expect("client engine"); client_runtime.set_remote_sink(Arc::new(CancelDroppingSink { destination: directory_harness.runtime.clone(), drop_next_cancel: AtomicBool::new(true), })); directory_harness .runtime .set_remote_sink(Arc::new(ForwardingSink { destination: client_runtime.clone(), })); let discovered = Arc::new(RwLock::new(Some(directory))); let proxy = client_runtime .spawn(NamespaceClientActor::new( client_engine.handle(), client_runtime.create_sender(), Arc::new(StaticDiscovery { directory: Arc::clone(&discovered), }), Duration::from_millis(20), )) .expect("spawn namespace proxy"); let open_replies = client_runtime .new_inbox::() .expect("open reply inbox"); client_runtime .send_to( proxy, NamespaceClientIn::Request { request: NamespaceRequest::OpenStream { path: path("/runs/11/parked"), role: StreamRole::Sink, endpoint: source(31), descriptor: Vec::new(), replace: false, ensure: true, expected_revision: None, operation_id: OperationId::from_u128(31), }, reply_to: *open_replies.addr(), }, ) .expect("send parked sink open"); let bound = Instant::now() + Duration::from_secs(10); loop { let active = matches!( future::block_on(directory_harness.client.lookup(path("/runs/11/parked"))), Ok(node) if node.active ); if active { break; } assert!( Instant::now() < bound, "parked sink open never reached the directory" ); std::thread::yield_now(); } // The waiter's endpoint going away must retract the directory entry even // though the first cancel frame is dropped in flight. client_runtime .send_to( proxy, NamespaceClientIn::Cancel { reply_to: *open_replies.addr(), }, ) .expect("cancel parked open"); loop { let node = future::block_on(directory_harness.client.lookup(path("/runs/11/parked"))) .expect("stream node survives the cancel"); if !node.active { break; } assert!( Instant::now() < bound, "a lost cancel frame must not strand the pending endpoint forever" ); std::thread::yield_now(); } future::block_on( directory_harness .client .unregister(path("/runs/11/parked"), OperationId::from_u128(32)), ) .expect("quiescent parked stream unlinks after cancel"); } #[test] fn stream_retractions_outlive_the_request_deadline() { let state = TempState::new("retraction-deadline"); let directory_harness = spawn_directory(&state.store()); let directory = directory_harness.client.directory(); let client_parts = RuntimeParts::new(RuntimeConfig { worker_count: 1, ..RuntimeConfig::default() }); let client_runtime = client_parts.runtime().clone(); let client_engine = Engine::new( client_parts, TokioBackend::new(TokioConfig { worker_threads: 1, ..TokioConfig::default() }) .expect("tokio backend"), ) .expect("client engine"); client_runtime.set_remote_sink(Arc::new(ForwardingSink { destination: directory_harness.runtime.clone(), })); directory_harness .runtime .set_remote_sink(Arc::new(ForwardingSink { destination: client_runtime.clone(), })); let discovered = Arc::new(RwLock::new(None)); let proxy = client_runtime .spawn(NamespaceClientActor::new_with_deadline( client_engine.handle(), client_runtime.create_sender(), Arc::new(StaticDiscovery { directory: Arc::clone(&discovered), }), Duration::from_millis(20), // Ordinary requests must expire within this bound; stream // retractions must outlive it. Duration::from_millis(150), )) .expect("spawn namespace proxy"); let open_replies = client_runtime .new_inbox::() .expect("open reply inbox"); let parked = path("/runs/13/parked"); let operation = OperationId::from_u128(41); // Park a sink open while no directory is discoverable, then cancel it: // the cancel must survive an authority outage far longer than the // request deadline and still tombstone the open once the authority // returns. Without that survival a reordered late replay of the open // resurrects a pending endpoint nobody will ever close. client_runtime .send_to( proxy, NamespaceClientIn::Request { request: NamespaceRequest::OpenStream { path: parked.clone(), role: StreamRole::Sink, endpoint: source(41), descriptor: Vec::new(), replace: false, ensure: true, expected_revision: None, operation_id: operation, }, reply_to: *open_replies.addr(), }, ) .expect("send parked sink open"); client_runtime .send_to( proxy, NamespaceClientIn::Cancel { reply_to: *open_replies.addr(), }, ) .expect("cancel parked open"); // Outage: several request-deadline lifetimes with retry ticks flowing. let settle = Instant::now() + Duration::from_millis(700); while Instant::now() < settle { client_runtime .send_to(proxy, NamespaceClientIn::Retry) .expect("drive retry tick"); std::thread::yield_now(); } *discovered.write() = Some(directory); // Give the deadline-surviving retraction clear time to reach the // directory and persist its tombstone (the engine tick plus a wide // margin), then prove the tombstone exists by replaying the cancelled // open: it must be rejected, never resurrected as a pending endpoint. let settle_authority = Instant::now() + Duration::from_millis(1500); while Instant::now() < settle_authority { client_runtime .send_to(proxy, NamespaceClientIn::Retry) .expect("drive retry tick"); std::thread::yield_now(); } assert!( matches!( future::block_on(directory_harness.client.lookup(parked.clone())), Err(NamespaceError::PathNotFound(_)) ), "a deadline-surviving cancel leaves no stream node behind" ); // The tombstone must reject a reordered replay of the same open. let replies = directory_harness .runtime .new_inbox::() .expect("reply inbox"); directory_harness .runtime .send_to( directory, DataDirectoryIn::OpenStream { request_id: DirectoryRequestId(77), path: parked.clone(), role: StreamRole::Sink, descriptor: Vec::new(), endpoint: source(41), replace: false, ensure: true, expected_revision: None, operation_id: operation, reply_to: *replies.addr(), }, ) .expect("send late open replay"); match future::block_on(replies.recv()) { NamespaceClientIn::DirectoryReply(DataDirectoryOut::StreamOpened { result: Err(NamespaceError::PathReplaced(_)), .. }) => {} other => panic!("late open replay must hit the cancel tombstone, got {other:?}"), } } #[test] fn expired_stream_open_retracts_its_parked_endpoint() { let state = TempState::new("expired-open-retract"); let directory_harness = spawn_directory(&state.store()); let directory = directory_harness.client.directory(); let client_parts = RuntimeParts::new(RuntimeConfig { worker_count: 1, ..RuntimeConfig::default() }); let client_runtime = client_parts.runtime().clone(); let client_engine = Engine::new( client_parts, TokioBackend::new(TokioConfig { worker_threads: 1, ..TokioConfig::default() }) .expect("tokio backend"), ) .expect("client engine"); client_runtime.set_remote_sink(Arc::new(ForwardingSink { destination: directory_harness.runtime.clone(), })); directory_harness .runtime .set_remote_sink(Arc::new(ForwardingSink { destination: client_runtime.clone(), })); let discovered = Arc::new(RwLock::new(Some(directory))); let proxy = client_runtime .spawn(NamespaceClientActor::new_with_deadline( client_engine.handle(), client_runtime.create_sender(), Arc::new(StaticDiscovery { directory: Arc::clone(&discovered), }), Duration::from_millis(20), // The open below parks without a reply; it must expire within // this bound and its retraction must still reach the directory. Duration::from_millis(150), )) .expect("spawn namespace proxy"); // Callers of the proxy receive the raw directory reply (the proxy // unwraps NamespaceClientIn::DirectoryReply before forwarding). let open_replies = client_runtime .new_inbox::() .expect("open reply inbox"); let parked = path("/runs/17/parked"); let operation = OperationId::from_u128(51); // A first-role stream open that reaches the directory registers its // endpoint and then parks: no reply exists until the peer role arrives. // This is the gated-stream shape that strands in production — the sink // never comes, the caller's request deadline fires while the endpoint // is committed in the directory. client_runtime .send_to( proxy, NamespaceClientIn::Request { request: NamespaceRequest::OpenStream { path: parked.clone(), role: StreamRole::Source, endpoint: source(51), descriptor: Vec::new(), replace: false, ensure: true, expected_revision: None, operation_id: operation, }, reply_to: *open_replies.addr(), }, ) .expect("send parked source open"); let bound = Instant::now() + Duration::from_secs(10); loop { let active = matches!( future::block_on(directory_harness.client.lookup(parked.clone())), Ok(node) if node.active ); if active { break; } assert!( Instant::now() < bound, "parked source open never reached the directory" ); std::thread::yield_now(); } // Nobody cancels and no sink arrives: drive retry ticks past the request // deadline. The caller must observe the typed deadline failure... let expire = Instant::now() + Duration::from_millis(700); while Instant::now() < expire { client_runtime .send_to(proxy, NamespaceClientIn::Retry) .expect("drive retry tick"); std::thread::yield_now(); } match future::block_on(open_replies.recv()) { DataDirectoryOut::StreamOpened { result: Err(NamespaceError::DirectoryUnavailable(_)), .. } => {} other => panic!("parked open must expire at the deadline, got {other:?}"), } // ...and the expiry must retract the parked endpoint so the path can be // unlinked. Pre-fix, the endpoint survived forever and unregister // failed with WrongEntryType (the campaign's permanent-ENXIO wedge). let retract = Instant::now() + Duration::from_millis(1500); loop { let inactive = matches!( future::block_on(directory_harness.client.lookup(parked.clone())), Ok(node) if !node.active ); if inactive { break; } assert!( Instant::now() < retract, "expired open must retract its parked endpoint" ); client_runtime .send_to(proxy, NamespaceClientIn::Retry) .expect("drive retry tick"); std::thread::yield_now(); } future::block_on( directory_harness .client .unregister(parked, OperationId::from_u128(52)), ) .expect("quiescent expired stream unlinks"); } #[test] fn unresolved_request_waits_for_recovered_authority() { let state = TempState::new("restart"); let harness = spawn_directory(&state.store()); let logical = path("/models/restartable"); let source = source(44); future::block_on(harness.client.register( logical.clone(), source, [9; 32], 16, recovery(source), OperationId::from_u128(500), )) .expect("seed durable binding"); let old_directory = harness.client.directory(); harness.runtime.stop_actor(old_directory).unwrap(); let discovered = Arc::new(RwLock::new(None)); let proxy = harness .runtime .spawn(NamespaceClientActor::new( harness.engine.handle(), harness.runtime.create_sender(), Arc::new(StaticDiscovery { directory: Arc::clone(&discovered), }), Duration::from_millis(5), )) .expect("spawn namespace client"); let client = NamespaceClient::new(harness.runtime.clone(), proxy); future::block_on(async { let mut resolving = Box::pin(client.resolve(logical.clone())); assert!( future::poll_once(resolving.as_mut()).await.is_none(), "resolve must remain pending while authority is absent" ); let recovered = DataDirectoryActor::recover( state.store(), Some(RetirementRetry::new( harness.engine.handle(), harness.runtime.create_sender(), Duration::from_millis(50), None, )), |record, _length| match record { SourceRecovery::Actor { actor, node, .. } => Ok((*actor, *node)), SourceRecovery::File { path } => Err(NamespaceError::SourceRecovery(format!( "test cannot recover file source {}", path.display() ))), }, ) .expect("recover directory state"); let recovered = harness .runtime .spawn(recovered) .expect("spawn recovered directory"); *discovered.write() = Some(recovered); harness .runtime .send_to(proxy, NamespaceClientIn::Retry) .expect("trigger rediscovery retry"); let binding = resolving.await.expect("resolve after authority recovery"); assert_eq!(binding.source, source); assert_eq!(binding.length, 16); assert_eq!(binding.revision, 1); }); } #[test] fn unresolved_request_fails_bounded_when_authority_is_lost() { let discovered = Arc::new(RwLock::new(None)); let parts = RuntimeParts::new(RuntimeConfig { worker_count: 1, ..RuntimeConfig::default() }); let runtime = parts.runtime().clone(); let engine = Engine::new( parts, TokioBackend::new(TokioConfig { worker_threads: 1, ..TokioConfig::default() }) .expect("tokio backend"), ) .expect("client engine"); let proxy = runtime .spawn(NamespaceClientActor::new_with_deadline( engine.handle(), runtime.create_sender(), Arc::new(StaticDiscovery { directory: Arc::clone(&discovered), }), Duration::from_millis(5), Duration::from_millis(150), )) .expect("spawn namespace client"); let client = NamespaceClient::new(runtime.clone(), proxy); let outcome = future::block_on(async { let bound = Instant::now() + Duration::from_secs(5); let mut resolving = Box::pin(client.resolve(path("/models/lost"))); loop { if let Some(outcome) = future::poll_once(resolving.as_mut()).await { return outcome; } assert!( Instant::now() < bound, "authority loss must fail the request within the deadline, not hang" ); std::thread::yield_now(); } }); assert!( matches!(outcome, Err(NamespaceError::DirectoryUnavailable(_))), "expected a bounded directory-unavailable failure, got {outcome:?}" ); } #[test] fn namespace_process_restart_helper() { let Ok(mode) = std::env::var("SWACTOR_NAMESPACE_HELPER_MODE") else { return; }; let store = PathBuf::from( std::env::var_os("SWACTOR_NAMESPACE_HELPER_STORE").expect("helper store path"), ); let directory = spawn_directory(&store); let logical = path("/models/process-restart"); match mode.as_str() { "write" => { let actor = source(55); let receipt = future::block_on(directory.client.register( logical, actor, [9; 32], 32, recovery(actor), OperationId::from_u128(900), )) .expect("helper durable registration"); assert_eq!(receipt.revision, 1); } "read" => { let binding = future::block_on(directory.client.resolve(logical)).expect("helper recovery"); assert_eq!(binding.source, source(55)); assert_eq!(binding.length, 32); assert_eq!(binding.revision, 1); } other => panic!("unknown namespace helper mode {other}"), } } #[test] fn namespace_client_routes_remote_transfer_failures_to_local_binding() { let state = TempState::new("transfer-failure"); let harness = spawn_directory(&state.store()); let proxy = harness .runtime .spawn(NamespaceClientActor::new( harness.engine.handle(), harness.runtime.create_sender(), Arc::new(StaticDiscovery { directory: Arc::new(RwLock::new(Some(harness.client.directory()))), }), Duration::from_millis(5), )) .unwrap(); let destination = harness .runtime .new_inbox::() .unwrap(); let transfer_id = data_plane::blob_transfer::BlobTransferId(71); harness .runtime .send_to( proxy, NamespaceClientIn::TransferFailed { destination: *destination.addr(), transfer_id, reason: "injected source failure".to_owned(), }, ) .unwrap(); assert_eq!( future::block_on(destination.recv()), data_plane::blob_transfer::BlobTransferEvent::Failed { transfer_id, reason: "injected source failure".to_owned(), } ); } #[derive(Clone, Debug)] struct ModelBinding { source: ActorAddress, length: u64, revision: u64, } #[derive(Clone, Debug)] enum TypedModelEntry { Blob(ModelBinding), Stream(Option), } proptest! { #![proptest_config(ProptestConfig { cases: 8, max_shrink_iters: 128, ..ProptestConfig::default() })] #[test] fn legal_action_sequences_preserve_namespace_guarantees(actions in prop::collection::vec(any::(), 1..48)) { let state = TempState::new("stateful"); let directory = spawn_directory(&state.store()); let paths = [path("/models/a"), path("/models/b"), path("/runs/7/result")]; let mut model = BTreeMap::::new(); let mut next_revision = 1_u64; let mut next_operation = 1_u128; for (step, action) in actions.into_iter().enumerate() { let logical = paths[usize::from(action) % paths.len()].clone(); match action % 4 { 0 | 1 => { let actor = source(action.wrapping_add(step as u8).wrapping_add(1)); let length = u64::from(action) + 1; let operation = OperationId::from_u128(next_operation); next_operation += 1; let receipt = future::block_on(directory.client.register( logical.clone(), actor, [9; 32], length, recovery(actor), operation, )).expect("model registration"); prop_assert_eq!(receipt.revision, next_revision); model.insert(logical, ModelBinding { source: actor, length, revision: next_revision }); next_revision += 1; } 2 if model.contains_key(&logical) => { let operation = OperationId::from_u128(next_operation); next_operation += 1; let receipt = future::block_on(directory.client.unregister(logical.clone(), operation)) .expect("model unregistration"); prop_assert_eq!(receipt.revision, next_revision); model.remove(&logical); next_revision += 1; } _ => { let observed = future::block_on(directory.client.resolve(logical.clone())); match model.get(&logical) { Some(expected) => { let observed = observed.expect("model binding must resolve"); prop_assert_eq!(observed.source, expected.source); prop_assert_eq!(observed.length, expected.length); prop_assert_eq!(observed.revision, expected.revision); } None => prop_assert!(matches!( observed, Err(NamespaceError::PathNotFound(found)) if found == logical )), } } } for (path, expected) in &model { let observed = future::block_on(directory.client.resolve(path.clone())) .expect("all model bindings remain resolvable"); prop_assert_eq!(observed.source, expected.source); prop_assert_eq!(observed.length, expected.length); prop_assert_eq!(observed.revision, expected.revision); } } } #[test] fn typed_binding_action_strings_match_reference_model(actions in prop::collection::vec(any::(), 1..64)) { let state = TempState::new("typed-stateful"); let directory = spawn_directory(&state.store()); let paths = [path("/state/a"), path("/state/b"), path("/state/c")]; let mut model = BTreeMap::::new(); let mut next_operation = 10_000_u128; for (step, action) in actions.into_iter().enumerate() { let logical = paths[usize::from(action) % paths.len()].clone(); match action % 4 { 0 => { let actor = source(action.wrapping_add(step as u8).wrapping_add(1)); let length = u64::from(action) + 1; let receipt = future::block_on(directory.client.register( logical.clone(), actor, [9; 32], length, recovery(actor), OperationId::from_u128(next_operation), )).expect("blob rebind"); next_operation += 1; model.insert(logical, TypedModelEntry::Blob(ModelBinding { source: actor, length, revision: receipt.revision, })); } 1 => { if let Some(TypedModelEntry::Stream(binding)) = model.get_mut(&logical) && let Some(binding) = binding.take() { future::block_on( directory .client .close_stream(logical.clone(), binding.incarnation), ) .expect("quiesce prior stream before the next legal replacement"); } let source_actor = source(action.wrapping_add(41)); let sink_actor = source(action.wrapping_add(97)); let mut source_open = Box::pin(directory.client.replace_with_stream( logical.clone(), StreamRole::Source, source_actor, OperationId::from_u128(next_operation), )); next_operation += 1; prop_assert!(future::block_on(future::poll_once(source_open.as_mut())).is_none()); let sink_match = future::block_on(directory.client.open_stream( logical.clone(), StreamRole::Sink, sink_actor, OperationId::from_u128(next_operation), )).expect("sink match"); next_operation += 1; let source_match = future::block_on(source_open).expect("source match"); prop_assert_eq!(&source_match, &sink_match); model.insert(logical, TypedModelEntry::Stream(Some(sink_match))); } 2 => { if let Some(TypedModelEntry::Stream(binding)) = model.get_mut(&logical) && let Some(binding) = binding.take() { future::block_on(directory.client.close_stream( logical.clone(), binding.incarnation, )).expect("close current stream"); } } _ => { let observed = future::block_on(directory.client.resolve(logical.clone())); match model.get(&logical) { Some(TypedModelEntry::Blob(expected)) => { let observed = observed.expect("blob resolves"); prop_assert_eq!(observed.source, expected.source); prop_assert_eq!(observed.length, expected.length); prop_assert_eq!(observed.revision, expected.revision); } Some(TypedModelEntry::Stream(_)) => { let wrong_type = matches!( observed, Err(NamespaceError::WrongEntryType { expected: EntryKind::Blob, found: EntryKind::Stream, .. }) ); prop_assert!(wrong_type, "blob lookup must reject a stream binding"); } None => prop_assert!(matches!( observed, Err(NamespaceError::PathNotFound(found)) if found == logical )), } } } } } } #[test] fn retirement_retry_redrives_until_acknowledged() { struct RetireProbe { retire_count: Arc, acknowledge: Arc, acknowledgement_count: Arc, } impl swactor::actor::ActorInterface for RetireProbe { type Incoming = data_plane::source::BlobSourceIn; type Response = (); fn handle(&mut self, ctx: &swactor::actor::Ctx<'_>, message: Self::Incoming) { if let data_plane::source::BlobSourceIn::Retire { reply_to } = message && let Some(reply_to) = reply_to { self.retire_count.fetch_add(1, Ordering::SeqCst); if self.acknowledge.load(Ordering::SeqCst) { if ctx .send( reply_to, DataDirectoryIn::SourceRetired { source: ctx.self_addr(), }, ) .is_ok() { self.acknowledgement_count.fetch_add(1, Ordering::SeqCst); } } } } } let state = TempState::new("retire-retry"); let harness = spawn_directory(&state.store()); let retire_count = Arc::new(AtomicU64::new(0)); let acknowledge = Arc::new(AtomicBool::new(false)); let acknowledgement_count = Arc::new(AtomicU64::new(0)); let probe = harness .runtime .spawn(RetireProbe { retire_count: Arc::clone(&retire_count), acknowledge: Arc::clone(&acknowledge), acknowledgement_count: Arc::clone(&acknowledgement_count), }) .expect("spawn retire probe"); let logical = path("/models/retire-retry"); future::block_on(harness.client.register( logical.clone(), probe, [9; 32], 8, recovery(probe), OperationId::from_u128(1), )) .expect("register probe source"); future::block_on( harness .client .unregister(logical.clone(), OperationId::from_u128(2)), ) .expect("unregister probe source"); // No other directory traffic follows the unregister: only the periodic // retry tick may re-deliver Retire. Poll past several tick periods. let redrive_deadline = Instant::now() + Duration::from_secs(2); let mut redriven = 0; while retire_count.load(Ordering::SeqCst) < 2 { assert!( Instant::now() < redrive_deadline, "retirement was not re-driven without directory traffic (observed {redriven})" ); redriven = retire_count.load(Ordering::SeqCst); std::thread::yield_now(); } // Once the source acknowledges, retries must stop. A tick can already // be in flight when the acknowledgement lands, so require the count to // stay unchanged for a full quiet window rather than stopping at the // first acknowledgement. acknowledge.store(true, Ordering::SeqCst); let settle_deadline = Instant::now() + Duration::from_secs(2); while acknowledgement_count.load(Ordering::SeqCst) == 0 { assert!( Instant::now() < settle_deadline, "acknowledged Retire was never observed after enabling replies" ); std::thread::yield_now(); } let mut last = retire_count.load(Ordering::SeqCst); loop { let quiet_until = Instant::now() + Duration::from_millis(300); while Instant::now() < quiet_until { std::thread::yield_now(); } let observed = retire_count.load(Ordering::SeqCst); if observed == last { break; } last = observed; assert!( Instant::now() < settle_deadline, "retirement retries never stopped after SourceRetired (observed {observed})" ); } } #[test] fn retirement_retries_do_not_amplify_with_directory_traffic() { struct SilentRetireProbe { retire_count: Arc, } impl swactor::actor::ActorInterface for SilentRetireProbe { type Incoming = data_plane::source::BlobSourceIn; type Response = (); fn handle(&mut self, _ctx: &swactor::actor::Ctx<'_>, message: Self::Incoming) { if matches!(message, data_plane::source::BlobSourceIn::Retire { .. }) { self.retire_count.fetch_add(1, Ordering::SeqCst); } } } // A directory with no retirement retry tick: every Retire delivery is // then attributable to a specific trigger (enqueue, on_start, or — the // bug under test — unrelated inbound traffic). let state = TempState::new("retire-amplify"); let parts = RuntimeParts::new(RuntimeConfig { worker_count: 1, ..RuntimeConfig::default() }); let runtime = parts.runtime().clone(); let engine = Engine::new( parts, TokioBackend::new(TokioConfig { worker_threads: 1, ..TokioConfig::default() }) .expect("tokio backend"), ) .expect("directory engine"); let actor = DataDirectoryActor::recover(&state.store(), None, |record, _length| match record { SourceRecovery::Actor { actor, node, .. } => Ok((*actor, *node)), SourceRecovery::File { path } => Err(NamespaceError::SourceRecovery(format!( "test cannot recover file source {}", path.display() ))), }) .expect("recover directory"); let directory = runtime.spawn(actor).expect("spawn directory actor"); let client = DirectoryClient::new(runtime.clone(), directory); // Register then unregister several paths so the directory holds several // pending retirements aimed at live-but-silent sources. const RETIREMENTS: u64 = 4; let retire_count = Arc::new(AtomicU64::new(0)); for index in 0..RETIREMENTS { let probe = runtime .spawn(SilentRetireProbe { retire_count: Arc::clone(&retire_count), }) .expect("spawn silent retire probe"); let logical = path(&format!("/models/retire-amplify/{index}")); future::block_on(client.register( logical.clone(), probe, [9; 32], 8, recovery(probe), OperationId::from_u128(u128::from(index) + 1), )) .expect("register probe source"); future::block_on( client.unregister(logical, OperationId::from_u128(100 + u128::from(index))), ) .expect("unregister probe source"); } // Discard the initial delivery (queue_retirement sends immediately). let baseline = { let settle = Instant::now() + Duration::from_secs(2); loop { let quiet_until = Instant::now() + Duration::from_millis(20); while Instant::now() < quiet_until { std::thread::yield_now(); } let observed = retire_count.load(Ordering::SeqCst); if observed >= RETIREMENTS { break observed; } assert!( Instant::now() < settle, "initial Retire deliveries never arrived (observed {observed})" ); } }; let quiet_until = Instant::now() + Duration::from_millis(100); while Instant::now() < quiet_until { std::thread::yield_now(); } let observed = retire_count.load(Ordering::SeqCst); assert_eq!( observed, baseline, "retirements were re-driven without any trigger" ); // A burst of unrelated read traffic must not multiply pending // retirements: every lookup re-fanned every retirement before the fix, // coupling the outbound Retire frame rate to the inbound request rate. const LOOKUPS: u64 = 64; for _ in 0..LOOKUPS { let _ = future::block_on(client.lookup(path("/models/absent"))); } let quiet_until = Instant::now() + Duration::from_millis(100); while Instant::now() < quiet_until { std::thread::yield_now(); } let observed = retire_count.load(Ordering::SeqCst); assert_eq!( observed, baseline, "directory traffic amplified retirement re-sends: {LOOKUPS} lookups drove \ {} extra Retire deliveries for {RETIREMENTS} pending retirements", observed - baseline ); drop(engine); } #[derive(Default)] struct Routes { callbacks: parking_lot::Mutex>>, routable: parking_lot::Mutex>>, } impl HostRouteRegistrar for Routes { fn register_child(&self, _: ActorAddress, _: [u8; 32]) -> Result<(), String> { Ok(()) } fn revoke_child(&self, _: ActorAddress) -> Result<(), String> { Ok(()) } fn is_routable(&self, actor: ActorAddress) -> bool { self.routable .lock() .as_ref() .is_none_or(|routable| routable.contains(&actor)) } fn watch_route( &self, actor: ActorAddress, changed: Arc, ) -> Option { self.callbacks .lock() .insert(actor, Arc::downgrade(&changed)); Some(HostRouteWatch::new(changed)) } } fn settle(backend: &SteppingBackend) { for _ in 0..32 { backend.step(); } } #[test] fn retirement_route_wakes_are_targeted_and_end_on_acknowledgement() { use data_plane::source::BlobSourceIn; let state = TempState::new("retire-route-wake"); let parts = RuntimeParts::new(RuntimeConfig { worker_count: 1, ..RuntimeConfig::default() }); let runtime = parts.runtime().clone(); let backend = SteppingBackend::new(); let engine = Engine::new(parts, backend.clone()).unwrap(); let routes = Arc::new(Routes::default()); let period = Duration::from_millis(250); let directory = runtime .spawn( DataDirectoryActor::recover( state.store(), Some(RetirementRetry::new( engine.handle(), runtime.create_sender(), period, Some(routes.clone()), )), |record, _| match record { SourceRecovery::Actor { actor, node, .. } => Ok((*actor, *node)), SourceRecovery::File { .. } => unreachable!(), }, ) .unwrap(), ) .unwrap(); let replies = runtime.new_inbox::().unwrap(); let sources = [ runtime.new_inbox::().unwrap(), runtime.new_inbox::().unwrap(), ]; for (index, source) in sources.iter().enumerate() { let logical = path(&format!("/retire/{index}")); runtime .send_to( directory, DataDirectoryIn::Register { request_id: DirectoryRequestId(index as u64), path: logical.clone(), source: *source.addr(), source_node: [9; 32], length: 1, recovery: recovery(*source.addr()), operation_id: OperationId::from_u128(index as u128 * 2 + 1), reservation: None, reply_to: *replies.addr(), }, ) .unwrap(); runtime .send_to( directory, DataDirectoryIn::Unregister { request_id: DirectoryRequestId(index as u64 + 2), path: logical, operation_id: OperationId::from_u128(index as u128 * 2 + 2), reply_to: *replies.addr(), }, ) .unwrap(); } settle(&backend); for source in &sources { assert!(matches!( source.try_recv(), Some(BlobSourceIn::Retire { .. }) )); assert!(source.try_recv().is_none()); } // A ready route re-drives its obligation without moving the virtual clock, // and must not fan out the other pending retirement. let wake = routes.callbacks.lock()[sources[0].addr()] .upgrade() .unwrap(); wake(); drop(wake); settle(&backend); assert!(matches!( sources[0].try_recv(), Some(BlobSourceIn::Retire { .. }) )); assert!(sources[1].try_recv().is_none()); runtime .send_to( directory, DataDirectoryIn::SourceRetired { source: *sources[0].addr(), }, ) .unwrap(); settle(&backend); assert!( routes.callbacks.lock()[sources[0].addr()] .upgrade() .is_none() ); backend.advance_time(period); settle(&backend); assert!(sources[0].try_recv().is_none()); assert!(matches!( sources[1].try_recv(), Some(BlobSourceIn::Retire { .. }) )); assert!(sources[1].try_recv().is_none()); } #[test] fn retirement_ticks_skip_unroutable_sources_until_route_wake() { use data_plane::source::BlobSourceIn; let state = TempState::new("retire-unroutable"); let parts = RuntimeParts::new(RuntimeConfig { worker_count: 1, ..RuntimeConfig::default() }); let runtime = parts.runtime().clone(); let backend = SteppingBackend::new(); let engine = Engine::new(parts, backend.clone()).unwrap(); let routes = Arc::new(Routes::default()); *routes.routable.lock() = Some(HashSet::new()); let period = Duration::from_millis(250); let directory = runtime .spawn( DataDirectoryActor::recover( state.store(), Some(RetirementRetry::new( engine.handle(), runtime.create_sender(), period, Some(routes.clone()), )), |record, _| match record { SourceRecovery::Actor { actor, node, .. } => Ok((*actor, *node)), SourceRecovery::File { .. } => unreachable!(), }, ) .unwrap(), ) .unwrap(); let replies = runtime.new_inbox::().unwrap(); let source = runtime.new_inbox::().unwrap(); let logical = path("/retire/unroutable"); runtime .send_to( directory, DataDirectoryIn::Register { request_id: DirectoryRequestId(1), path: logical.clone(), source: *source.addr(), source_node: [9; 32], length: 1, recovery: recovery(*source.addr()), operation_id: OperationId::from_u128(1), reservation: None, reply_to: *replies.addr(), }, ) .unwrap(); runtime .send_to( directory, DataDirectoryIn::Unregister { request_id: DirectoryRequestId(2), path: logical, operation_id: OperationId::from_u128(2), reply_to: *replies.addr(), }, ) .unwrap(); settle(&backend); assert!(matches!( source.try_recv(), Some(BlobSourceIn::Retire { .. }) )); assert!(source.try_recv().is_none()); for _ in 0..4 { backend.advance_time(period); settle(&backend); } assert!( source.try_recv().is_none(), "periodic retries must remain dormant while the route is absent" ); routes .routable .lock() .as_mut() .unwrap() .insert(*source.addr()); let wake = routes.callbacks.lock()[source.addr()].upgrade().unwrap(); wake(); drop(wake); settle(&backend); assert!(matches!( source.try_recv(), Some(BlobSourceIn::Retire { .. }) )); } #[test] fn retirement_retry_ticks_are_bounded_and_rotate_fairly() { use data_plane::source::BlobSourceIn; let state = TempState::new("retire-batch"); let parts = RuntimeParts::new(RuntimeConfig { worker_count: 1, ..RuntimeConfig::default() }); let runtime = parts.runtime().clone(); let backend = SteppingBackend::new(); let engine = Engine::new(parts, backend.clone()).unwrap(); let routes = Arc::new(Routes::default()); let period = Duration::from_millis(250); let directory = runtime .spawn( DataDirectoryActor::recover( state.store(), Some(RetirementRetry::new( engine.handle(), runtime.create_sender(), period, Some(routes), )), |record, _| match record { SourceRecovery::Actor { actor, node, .. } => Ok((*actor, *node)), SourceRecovery::File { .. } => unreachable!(), }, ) .unwrap(), ) .unwrap(); let replies = runtime.new_inbox::().unwrap(); let sources = (0..40) .map(|index| { let source = runtime.new_inbox::().unwrap(); let logical = path(&format!("/retire/batch/{index}")); runtime .send_to( directory, DataDirectoryIn::Register { request_id: DirectoryRequestId(index * 2), path: logical.clone(), source: *source.addr(), source_node: [9; 32], length: 1, recovery: recovery(*source.addr()), operation_id: OperationId::from_u128(index as u128 * 2 + 1), reservation: None, reply_to: *replies.addr(), }, ) .unwrap(); runtime .send_to( directory, DataDirectoryIn::Unregister { request_id: DirectoryRequestId(index * 2 + 1), path: logical, operation_id: OperationId::from_u128(index as u128 * 2 + 2), reply_to: *replies.addr(), }, ) .unwrap(); source }) .collect::>(); settle(&backend); for source in &sources { assert!(matches!( source.try_recv(), Some(BlobSourceIn::Retire { .. }) )); assert!(source.try_recv().is_none()); } let mut seen = vec![false; sources.len()]; backend.advance_time(period); settle(&backend); let mut first_tick = 0; for (index, source) in sources.iter().enumerate() { if source.try_recv().is_some() { seen[index] = true; first_tick += 1; } } assert_eq!( first_tick, 32, "one retry tick must have a fixed fanout bound" ); backend.advance_time(period); settle(&backend); let mut second_tick = 0; for (index, source) in sources.iter().enumerate() { if source.try_recv().is_some() { seen[index] = true; second_tick += 1; } } assert_eq!( second_tick, 32, "the next retry tick retains the same bound" ); assert!( seen.into_iter().all(|received| received), "the retry cursor must reach every pending source" ); assert!( sources.iter().all(|source| source.try_recv().is_none()), "one retry frame per selected source is expected" ); } #[test] fn displacement_acknowledgements_retry_durability_after_endpoints_stop() { use data_plane::namespace::StreamIncarnation; use data_plane::namespace_store::NamespaceStore; use data_plane::protocol::HostStreamIn; use data_plane::source::BlobSourceIn; use swactor::actor::{ActorInterface, Ctx}; struct OneShotEndpoint { incarnation: StreamIncarnation, stopped: Arc, } impl ActorInterface for OneShotEndpoint { type Incoming = HostStreamIn; type Response = (); fn handle(&mut self, ctx: &Ctx<'_>, message: Self::Incoming) { if let HostStreamIn::Displaced { incarnation, reply_to: Some(reply_to), } = message { assert_eq!(incarnation, self.incarnation); ctx.send( reply_to, DataDirectoryIn::StreamDisplaced { endpoint: ctx.self_addr(), incarnation, }, ) .unwrap(); ctx.stop_self(); } } fn on_stop(&mut self, _: &Ctx<'_>) { self.stopped.fetch_add(1, Ordering::SeqCst); } } let state = TempState::new("displacement-durable-ack"); let parts = RuntimeParts::new(RuntimeConfig { worker_count: 1, ..RuntimeConfig::default() }); let runtime = parts.runtime().clone(); let backend = SteppingBackend::new(); let engine = Engine::new(parts, backend.clone()).unwrap(); let routes = Arc::new(Routes::default()); let period = Duration::from_millis(250); let stopped = Arc::new(AtomicU64::new(0)); let incarnation = StreamIncarnation { authority_epoch: 1, revision: 1, }; let endpoints = [(); 2].map(|()| { runtime .spawn(OneShotEndpoint { incarnation, stopped: stopped.clone(), }) .unwrap() }); let unrelated_source = runtime.new_inbox::().unwrap(); let replies = runtime.new_inbox::().unwrap(); let logical = path("/unrelated/stream"); // Recover real persisted obligations, including an unrelated unacknowledged // retirement that must keep progressing while stream completion is blocked. let mut store = NamespaceStore::open(state.store()).unwrap(); let mut snapshot = store.snapshot().clone(); snapshot.authority_epoch = incarnation.authority_epoch; snapshot.next_revision = incarnation.revision + 1; snapshot .stream_nodes .insert(logical.clone(), incarnation.revision); snapshot.stream_retirements = endpoints .iter() .map(|endpoint| (*endpoint, incarnation)) .collect(); snapshot.retirements.push(*unrelated_source.addr()); store.commit(snapshot).unwrap(); drop(store); let actor = DataDirectoryActor::recover( state.store(), Some(RetirementRetry::new( engine.handle(), runtime.create_sender(), period, Some(routes.clone()), )), |_, _| unreachable!("fixture has no blob bindings"), ) .unwrap(); // Filesystem failpoint: a directory cannot be replaced by the store file. // Arm after recovery, before the endpoints receive their displacement, so // every ACK completion commit fails without a process-global test hook. let saved_store = state.root.join("last-durable-namespace.json"); std::fs::rename(state.store(), &saved_store).unwrap(); std::fs::create_dir(state.store()).unwrap(); let directory = runtime.spawn(actor).unwrap(); settle(&backend); assert_eq!(stopped.load(Ordering::SeqCst), 2); for endpoint in endpoints { assert!(routes.callbacks.lock()[&endpoint].upgrade().is_some()); } assert!(matches!( unrelated_source.try_recv(), Some(BlobSourceIn::Retire { .. }) )); runtime .send_to( directory, DataDirectoryIn::Lookup { request_id: DirectoryRequestId(1), path: logical, reply_to: *replies.addr(), }, ) .unwrap(); backend.advance_time(period); settle(&backend); assert!(matches!( replies.try_recv(), Some(NamespaceClientIn::DirectoryReply(DataDirectoryOut::LookedUp { request_id: DirectoryRequestId(1), result: Ok(node), .. })) if node.kind == EntryKind::Stream && node.revision == incarnation.revision )); assert!(matches!( unrelated_source.try_recv(), Some(BlobSourceIn::Retire { .. }) )); for endpoint in endpoints { assert!(routes.callbacks.lock()[&endpoint].upgrade().is_some()); } std::fs::remove_dir(state.store()).unwrap(); std::fs::rename(&saved_store, state.store()).unwrap(); // A targeted route wake completes only its received ACK, locally. Both // endpoints have stopped and cannot supply another acknowledgement. let source_wake = routes.callbacks.lock()[&endpoints[0]].clone(); source_wake.upgrade().unwrap()(); settle(&backend); assert!(source_wake.upgrade().is_none()); assert!(routes.callbacks.lock()[&endpoints[1]].upgrade().is_some()); assert_eq!( NamespaceStore::open(state.store()) .unwrap() .snapshot() .stream_retirements, vec![(endpoints[1], incarnation)] ); assert!(unrelated_source.try_recv().is_none()); // The unchanged periodic retry also retries durability, not a dead peer. backend.advance_time(period); settle(&backend); let completed = NamespaceStore::open(state.store()).unwrap(); assert!(completed.snapshot().stream_retirements.is_empty()); assert_eq!( completed.snapshot().retirements, vec![*unrelated_source.addr()] ); assert!(routes.callbacks.lock()[&endpoints[1]].upgrade().is_none()); assert!(matches!( unrelated_source.try_recv(), Some(BlobSourceIn::Retire { .. }) )); }