//! Authoritative virtual blob namespace actor and restart-tolerant client proxy. use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::fmt; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use serde::{Deserialize, Serialize}; use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use swactor::runtime::{ExternalSender, Runtime}; use swactor_engine::{ActorTimer, EngineHandle, EngineInstant}; use swactor_transport::{CodecRegistry, JsonCodec, NetworkMessage}; use crate::blob_transfer::{BlobTransferEvent, BlobTransferId}; use crate::host::{HostRouteRegistrar, HostRouteWatch}; use crate::namespace_store::{ MutationReceipt, MutationRejection, MutationRequest, NamespaceStore, NamespaceStoreError, PersistedBinding, PersistedMutationResult, PersistedOperation, }; use crate::path::DataPath; use crate::protocol::HostStreamIn; use crate::source::BlobSourceIn; const RETIREMENT_RETRY_BATCH: usize = 32; pub use crate::namespace_store::{OperationId, SourceRecovery}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct DirectoryRequestId(pub u64); #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BlobBinding { pub source: ActorAddress, pub source_node: [u8; 32], pub owner: Option, pub length: u64, pub revision: u64, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum EntryKind { Blob, Stream, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NamespaceNode { pub kind: EntryKind, pub revision: u64, pub active: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum StreamRole { Source, Sink, } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct StreamIncarnation { pub authority_epoch: u64, pub revision: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct StreamMatch { pub incarnation: StreamIncarnation, pub source: ActorAddress, pub source_descriptor: Vec, pub sink_descriptor: Vec, pub sink: ActorAddress, pub revision: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum NamespaceError { PathNotFound(DataPath), PathExists(DataPath), WrongEntryType { path: DataPath, expected: EntryKind, found: EntryKind, }, PathReplaced(DataPath), DuplicateStreamRole { path: DataPath, role: StreamRole, }, StaleIncarnation { path: DataPath, incarnation: StreamIncarnation, }, OperationConflict(OperationId), StreamActive(DataPath), Storage(String), SourceRecovery(String), DirectoryUnavailable(String), Protocol(String), } impl fmt::Display for NamespaceError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::PathNotFound(path) => write!(f, "data path not found: {path}"), Self::PathExists(path) => write!(f, "data path already exists: {path}"), Self::WrongEntryType { path, expected, found, } => write!( f, "data path {path} has entry kind {found:?}, expected {expected:?}" ), Self::PathReplaced(path) => { write!(f, "pending data path was replaced: {path}") } Self::DuplicateStreamRole { path, role } => { write!(f, "stream path {path} already has a {role:?}") } Self::StaleIncarnation { path, incarnation } => write!( f, "stream path {path} no longer names incarnation {}:{}", incarnation.authority_epoch, incarnation.revision ), Self::OperationConflict(operation) => write!( f, "namespace operation ID {:02x?} was reused for a different request", operation.bytes() ), Self::StreamActive(path) => { write!(f, "stream path {path} has active or pending endpoints") } Self::Storage(reason) => write!(f, "namespace persistence failed: {reason}"), Self::SourceRecovery(reason) => write!(f, "namespace source recovery failed: {reason}"), Self::DirectoryUnavailable(reason) => { write!(f, "namespace directory is unavailable: {reason}") } Self::Protocol(reason) => write!(f, "namespace protocol failed: {reason}"), } } } impl std::error::Error for NamespaceError {} impl From for NamespaceError { fn from(error: NamespaceStoreError) -> Self { Self::Storage(error.to_string()) } } #[derive(Clone, Debug, Serialize, Deserialize)] pub enum DataDirectoryIn { Register { request_id: DirectoryRequestId, path: DataPath, source: ActorAddress, source_node: [u8; 32], length: u64, recovery: SourceRecovery, operation_id: OperationId, reservation: Option, reply_to: ActorAddress, }, Resolve { request_id: DirectoryRequestId, path: DataPath, 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 { request_id: DirectoryRequestId, path: DataPath, operation_id: OperationId, reply_to: ActorAddress, }, Rename { request_id: DirectoryRequestId, source: DataPath, destination: DataPath, replace: bool, operation_id: OperationId, reply_to: ActorAddress, }, OpenStream { request_id: DirectoryRequestId, path: DataPath, role: StreamRole, descriptor: Vec, endpoint: ActorAddress, replace: bool, ensure: bool, expected_revision: Option, operation_id: OperationId, reply_to: ActorAddress, }, CancelStream { request_id: DirectoryRequestId, path: DataPath, operation_id: OperationId, reply_to: Option, }, CloseStream { request_id: DirectoryRequestId, path: DataPath, incarnation: StreamIncarnation, reply_to: ActorAddress, }, SourceRetired { source: ActorAddress, }, StreamDisplaced { endpoint: ActorAddress, incarnation: StreamIncarnation, }, /// Periodic self-tick that re-drives pending retirements. Retire is a /// lifecycle one-shot over an at-most-once transport; without traffic /// that re-runs [`DataDirectoryActor`]'s retry pass, a single lost frame /// strands the retired source and its published binding forever. RetryRetirements, /// Local observation: re-drive only this still-pending obligation. RetirementRouteChanged { target: ActorAddress, }, } impl NetworkMessage for DataDirectoryIn { fn type_tag() -> &'static str { "data-plane.data-directory.in.v1" } } #[derive(Clone, Debug, Serialize, Deserialize)] pub enum DataDirectoryOut { Registered { request_id: DirectoryRequestId, authority_epoch: u64, result: Result, }, Resolved { request_id: DirectoryRequestId, authority_epoch: u64, result: Result, }, LookedUp { request_id: DirectoryRequestId, authority_epoch: u64, result: Result, }, BlobReserved { request_id: DirectoryRequestId, authority_epoch: u64, result: Result<(), NamespaceError>, }, BlobReservationReleased { request_id: DirectoryRequestId, authority_epoch: u64, result: Result<(), NamespaceError>, }, Unregistered { request_id: DirectoryRequestId, authority_epoch: u64, result: Result, }, Renamed { request_id: DirectoryRequestId, authority_epoch: u64, result: Result, }, StreamOpened { request_id: DirectoryRequestId, authority_epoch: u64, result: Result, }, StreamClosed { request_id: DirectoryRequestId, authority_epoch: u64, result: Result<(), NamespaceError>, }, StreamCancelled { request_id: DirectoryRequestId, authority_epoch: u64, result: Result<(), NamespaceError>, }, } impl DataDirectoryOut { pub fn request_id(&self) -> DirectoryRequestId { match self { Self::Registered { request_id, .. } | Self::Resolved { request_id, .. } | Self::LookedUp { request_id, .. } | Self::BlobReserved { request_id, .. } | Self::BlobReservationReleased { request_id, .. } | Self::Unregistered { request_id, .. } | Self::Renamed { request_id, .. } | Self::StreamOpened { request_id, .. } | Self::StreamClosed { request_id, .. } | Self::StreamCancelled { request_id, .. } => *request_id, } } fn authority_epoch(&self) -> u64 { match self { Self::Registered { authority_epoch, .. } | Self::Resolved { authority_epoch, .. } | Self::LookedUp { authority_epoch, .. } | Self::BlobReserved { authority_epoch, .. } | Self::BlobReservationReleased { authority_epoch, .. } | Self::Unregistered { authority_epoch, .. } | Self::Renamed { authority_epoch, .. } | Self::StreamOpened { authority_epoch, .. } | Self::StreamClosed { authority_epoch, .. } | Self::StreamCancelled { authority_epoch, .. } => *authority_epoch, } } } #[derive(Clone, Debug, Serialize, Deserialize)] pub enum NamespaceRequest { Register { path: DataPath, source: ActorAddress, source_node: [u8; 32], length: u64, recovery: SourceRecovery, operation_id: OperationId, reservation: Option, }, Resolve { path: DataPath, }, Lookup { path: DataPath, }, ReserveBlob { path: DataPath, operation_id: OperationId, }, ReleaseBlobReservation { path: DataPath, operation_id: OperationId, }, Unregister { path: DataPath, operation_id: OperationId, }, Rename { source: DataPath, destination: DataPath, replace: bool, operation_id: OperationId, }, OpenStream { path: DataPath, role: StreamRole, endpoint: ActorAddress, descriptor: Vec, replace: bool, ensure: bool, expected_revision: Option, operation_id: OperationId, }, CancelStream { path: DataPath, operation_id: OperationId, }, CloseStream { path: DataPath, incarnation: StreamIncarnation, }, } #[derive(Clone, Debug, Serialize, Deserialize)] pub enum NamespaceClientIn { Request { request: NamespaceRequest, reply_to: ActorAddress, }, Cancel { reply_to: ActorAddress, }, CancelBlobReservation { path: DataPath, operation_id: OperationId, reply_to: ActorAddress, }, DirectoryReply(DataDirectoryOut), TransferFailed { destination: ActorAddress, transfer_id: BlobTransferId, reason: String, }, Retry, } impl NetworkMessage for NamespaceClientIn { fn type_tag() -> &'static str { "data-plane.namespace-client.in.v1" } } #[derive(Clone, Debug)] enum RuntimeSource { Available { actor: ActorAddress, node: [u8; 32], owner: Option, }, Unavailable(String), } struct PendingStream { role: StreamRole, endpoint: ActorAddress, operation_id: OperationId, request_id: DirectoryRequestId, descriptor: Vec, reply_to: ActorAddress, revision: u64, opened_from_revision: Option, } struct StreamOpenRequest { request_id: DirectoryRequestId, path: DataPath, role: StreamRole, endpoint: ActorAddress, replace: bool, ensure: bool, expected_revision: Option, descriptor: Vec, operation_id: OperationId, reply_to: ActorAddress, } struct StreamReplacementFence { incarnation: StreamIncarnation, endpoints: HashSet, old: Option, requests: Vec, } struct BlobRegistration { path: DataPath, source: ActorAddress, source_node: [u8; 32], length: u64, recovery: SourceRecovery, operation_id: OperationId, reservation: Option, retired: Option, } struct ActiveStream { binding: StreamMatch, source_operation: OperationId, sink_operation: OperationId, } enum RuntimeStream { Pending(PendingStream), Active(ActiveStream), } struct PendingStreamDisplacement { incarnation: StreamIncarnation, // Once the endpoint acknowledges it may stop, so only durability remains // retryable. Keep the acknowledgement until that commit succeeds. acknowledged: bool, } impl PendingStreamDisplacement { /// Returns true only when the acknowledged obligation is durably complete. fn retry(&self, ctx: &Ctx<'_>, endpoint: ActorAddress, store: &mut NamespaceStore) -> bool { if !self.acknowledged { let _ = ctx.send( endpoint, HostStreamIn::Displaced { incarnation: self.incarnation, reply_to: Some(ctx.self_addr()), }, ); return false; } let mut next = store.snapshot().clone(); next.stream_retirements .retain(|(known, _)| *known != endpoint); if let Err(error) = store.commit(next) { eprintln!("data-directory: retiring stream displacement failed: {error}"); return false; } true } } pub struct DataDirectoryActor { store: NamespaceStore, sources: BTreeMap, streams: BTreeMap, blob_reservations: BTreeMap, pending_retirements: HashSet, pending_stream_displacements: HashMap, stream_replacement_fences: BTreeMap, authority_epoch: u64, retire_retry: Option, retirement_watches: HashMap, retirement_retry_queue: VecDeque, retirement_timer: Option, #[cfg(feature = "directory-trace")] trace: DirectoryTrace, } /// Load-diagnostic counters for the directory actor (compiled in only with /// the `directory-trace` feature). Production builds carry no cost. #[cfg(feature = "directory-trace")] pub struct DirectoryTrace { messages: u64, retire_fans: u64, last_report: std::time::Instant, last_commits: u64, } #[cfg(feature = "directory-trace")] impl DirectoryTrace { fn emit(line: String) { use std::io::Write; eprintln!("{line}"); if let Ok(mut file) = std::fs::OpenOptions::new() .create(true) .append(true) .open("target/dir-trace.log") { let _ = writeln!(file, "{line}"); } } fn new() -> Self { Self { messages: 0, retire_fans: 0, last_report: std::time::Instant::now(), last_commits: crate::namespace_store::trace_commit_count(), } } /// Aggregate one message-handling step; print a summary every 2 seconds. fn observed_step(&mut self, pending_retirements: usize, store: &NamespaceStore) { self.messages += 1; if self.last_report.elapsed() >= Duration::from_secs(2) { let snapshot = store.snapshot(); let commits = crate::namespace_store::trace_commit_count(); let elapsed = self.last_report.elapsed().as_secs_f64().max(0.001); Self::emit(format!( "[dir-trace] msgs={} ({:.0}/s) retire_fans={} ({:.0}/s) pending_retire={} \ persisted_retire={} ops={} bindings={} stream_nodes={} commits={} ({:.0}/s) \ avg_commit={:.1}ms worst_commit={:.1}ms", self.messages, self.messages as f64 / elapsed, self.retire_fans, self.retire_fans as f64 / elapsed, pending_retirements, snapshot.retirements.len(), snapshot.operations.len(), snapshot.bindings.len(), snapshot.stream_nodes.len(), commits, (commits - self.last_commits) as f64 / elapsed, crate::namespace_store::trace_commit_avg_ms(), crate::namespace_store::trace_commit_worst_ms(), )); self.last_commits = commits; self.last_report = std::time::Instant::now(); } } } /// Engine-hosted periodic tick that re-sends pending `Retire` messages. /// /// Retire frames are fire-and-forget: a write onto a connection that dies /// mid-flight is dropped without feedback. The tick is the only periodic /// re-drive of pending retirements: re-fanning on every inbound message /// would couple the outbound Retire rate to the cluster's request rate, and /// a loaded directory multiplied each unacknowledged retirement into a /// self-sustaining frame storm. The tick keeps the retry pass running at a /// fixed cadence until each retirement is acknowledged; a fresh retirement /// is also sent immediately at enqueue time. pub struct RetirementRetry { engine: EngineHandle, sender: ExternalSender, period: Duration, routes: Option>, } impl RetirementRetry { pub fn new( engine: EngineHandle, sender: ExternalSender, period: Duration, routes: Option>, ) -> Self { Self { engine, sender, period, routes, } } } impl DataDirectoryActor { pub fn recover( store_path: impl AsRef, retire_retry: Option, mut recover_source: impl FnMut( &SourceRecovery, u64, ) -> Result<(ActorAddress, [u8; 32]), NamespaceError>, ) -> Result { let mut store = NamespaceStore::open(store_path)?; let bindings = store.snapshot().bindings.clone(); let mut sources = BTreeMap::new(); for (path, binding) in bindings { let owner = match &binding.recovery { SourceRecovery::Actor { owner, .. } => *owner, SourceRecovery::File { .. } => None, }; let source = match recover_source(&binding.recovery, binding.length) { Ok((actor, node)) => RuntimeSource::Available { actor, node, owner }, Err(error) => RuntimeSource::Unavailable(error.to_string()), }; sources.insert(path, source); } let pending_retirements: HashSet<_> = store.snapshot().retirements.iter().copied().collect(); let retirement_retry_queue = pending_retirements.iter().copied().collect(); let pending_stream_displacements = store .snapshot() .stream_retirements .iter() .map(|(endpoint, incarnation)| { ( *endpoint, PendingStreamDisplacement { incarnation: *incarnation, acknowledged: false, }, ) }) .collect(); let mut stream_replacement_fences = BTreeMap::new(); for (endpoint, incarnation) in &store.snapshot().stream_retirements { let Some((path, _)) = store .snapshot() .stream_nodes .iter() .find(|(_, revision)| **revision == incarnation.revision) else { continue; }; let fence = stream_replacement_fences .entry(path.clone()) .or_insert_with(|| StreamReplacementFence { incarnation: *incarnation, endpoints: HashSet::new(), old: None, requests: Vec::new(), }); if fence.incarnation == *incarnation { fence.endpoints.insert(*endpoint); } } let authority_epoch = store.advance_authority_epoch()?; Ok(Self { store, sources, streams: BTreeMap::new(), blob_reservations: BTreeMap::new(), pending_retirements, pending_stream_displacements, stream_replacement_fences, authority_epoch, retire_retry, retirement_watches: HashMap::new(), retirement_retry_queue, retirement_timer: None, #[cfg(feature = "directory-trace")] trace: DirectoryTrace::new(), }) } fn watch_retirement(&mut self, ctx: &Ctx<'_>, target: ActorAddress) { if self.retirement_watches.contains_key(&target) { return; } let Some(retry) = &self.retire_retry else { return; }; let Some(routes) = &retry.routes else { return; }; let sender = retry.sender.clone(); let directory = ctx.self_addr(); if let Some(watch) = routes.watch_route( target, Arc::new(move || { let _ = sender.send_to( directory, DataDirectoryIn::RetirementRouteChanged { target }, ); }), ) { self.retirement_watches.insert(target, watch); } } fn retry_retirement(&mut self, ctx: &Ctx<'_>, target: ActorAddress) { if self .pending_stream_displacements .get(&target) .is_some_and(|pending| pending.retry(ctx, target, &mut self.store)) { self.pending_stream_displacements.remove(&target); self.retirement_watches.remove(&target); } self.resume_stream_replacement_fences(ctx); let routable = self .retire_retry .as_ref() .and_then(|retry| retry.routes.as_deref()) .is_none_or(|routes| routes.is_routable(target)); if routable && self.pending_retirements.contains(&target) { let _ = ctx.send( target, BlobSourceIn::Retire { reply_to: Some(ctx.self_addr()), }, ); } } fn queue_retirement(&mut self, ctx: &Ctx<'_>, source: ActorAddress) { if self.pending_retirements.insert(source) { self.retirement_retry_queue.push_back(source); } self.watch_retirement(ctx, source); let _ = ctx.send( source, BlobSourceIn::Retire { reply_to: Some(ctx.self_addr()), }, ); } fn retry_retirements(&mut self, ctx: &Ctx<'_>) { let store = &mut self.store; let watches = &mut self.retirement_watches; self.pending_stream_displacements .retain(|endpoint, pending| { if !pending.retry(ctx, *endpoint, store) { return true; } watches.remove(endpoint); false }); let routes = self .retire_retry .as_ref() .and_then(|retry| retry.routes.clone()); let mut examined = 0; let mut sent = 0_u64; let pending = self.retirement_retry_queue.len(); while examined < pending && (sent as usize) < RETIREMENT_RETRY_BATCH { let source = self .retirement_retry_queue .pop_front() .expect("pending retry count matches queue"); examined += 1; if !self.pending_retirements.contains(&source) { continue; } self.retirement_retry_queue.push_back(source); // Preserve the durable retirement tombstone while a route is // absent, but do not flood the actor runtime with frames that // cannot be delivered. The route watch re-drives this source as // soon as it becomes routable again. if routes .as_deref() .is_some_and(|routes| !routes.is_routable(source)) { continue; } let _ = ctx.send( source, BlobSourceIn::Retire { reply_to: Some(ctx.self_addr()), }, ); sent += 1; } #[cfg(feature = "directory-trace")] { self.trace.retire_fans += sent; } self.resume_stream_replacement_fences(ctx); } fn confirm_retirement(&mut self, source: ActorAddress) -> Result<(), NamespaceError> { if !self.pending_retirements.contains(&source) { return Ok(()); } let mut next = self.store.snapshot().clone(); next.retirements.retain(|retired| *retired != source); self.store.commit(next)?; self.pending_retirements.remove(&source); self.retirement_watches.remove(&source); Ok(()) } pub fn authority_epoch(&self) -> u64 { self.authority_epoch } fn replay( &self, operation_id: OperationId, request: &MutationRequest, ) -> Option> { self.store .snapshot() .operations .get(&operation_id) .map(|operation| { if &operation.request != request { return Err(NamespaceError::OperationConflict(operation_id)); } match &operation.result { PersistedMutationResult::Committed(receipt) => Ok(*receipt), PersistedMutationResult::Rejected(MutationRejection::PathNotFound(path)) => { Err(NamespaceError::PathNotFound(path.clone())) } PersistedMutationResult::Rejected(MutationRejection::PathExists(path)) => { Err(NamespaceError::PathExists(path.clone())) } PersistedMutationResult::Rejected(MutationRejection::StreamActive(path)) => { Err(NamespaceError::StreamActive(path.clone())) } PersistedMutationResult::Rejected(MutationRejection::PathReplaced(path)) => { Err(NamespaceError::PathReplaced(path.clone())) } } }) } /// Persist a rejected mutation under its operation ID so later retries of /// the same operation replay the rejection instead of re-evaluating /// against state that has since changed. Returns the matching error. fn reject( &mut self, operation_id: OperationId, request: MutationRequest, rejection: MutationRejection, ) -> NamespaceError { let mut next = self.store.snapshot().clone(); next.operations.insert( operation_id, PersistedOperation { request, result: PersistedMutationResult::Rejected(rejection.clone()), }, ); if let Err(error) = self.store.commit(next) { return error.into(); } match rejection { MutationRejection::PathNotFound(path) => NamespaceError::PathNotFound(path), MutationRejection::PathExists(path) => NamespaceError::PathExists(path), MutationRejection::StreamActive(path) => NamespaceError::StreamActive(path), MutationRejection::PathReplaced(path) => NamespaceError::PathReplaced(path), } } fn record_stream_participant( &mut self, path: &DataPath, operation_id: OperationId, revision: u64, ) -> Result<(), NamespaceError> { let request = MutationRequest::BindStream { path: path.clone() }; if let Some(existing) = self.store.snapshot().operations.get(&operation_id) { return if existing.request == request { Ok(()) } else { Err(NamespaceError::OperationConflict(operation_id)) }; } let mut next = self.store.snapshot().clone(); next.operations.insert( operation_id, PersistedOperation { request, result: PersistedMutationResult::Committed(MutationReceipt { revision }), }, ); self.store.commit(next)?; Ok(()) } fn bind_stream( &mut self, path: DataPath, operation_id: OperationId, retired: Option, ) -> Result { let request = MutationRequest::BindStream { path: path.clone() }; if let Some(replayed) = self.replay(operation_id, &request) { return replayed; } if self.blob_reservations.contains_key(&path) { return Err(self.reject(operation_id, request, MutationRejection::PathExists(path))); } let revision = self.store.snapshot().next_revision; let next_revision = revision .checked_add(1) .filter(|revision| *revision != 0) .ok_or(NamespaceStoreError::RevisionExhausted)?; let receipt = MutationReceipt { revision }; let mut next = self.store.snapshot().clone(); next.next_revision = next_revision; next.bindings.remove(&path); next.stream_nodes.insert(path.clone(), revision); if let Some(retired) = retired && !next.retirements.contains(&retired) { next.retirements.push(retired); } next.operations.insert( operation_id, PersistedOperation { request, result: PersistedMutationResult::Committed(receipt), }, ); self.store.commit(next)?; self.sources.remove(&path); Ok(receipt) } fn send_stream_result( &self, ctx: &Ctx<'_>, request_id: DirectoryRequestId, reply_to: ActorAddress, result: Result, ) { let _ = ctx.send( reply_to, NamespaceClientIn::DirectoryReply(DataDirectoryOut::StreamOpened { request_id, authority_epoch: self.authority_epoch, result, }), ); } fn displace_stream(&mut self, ctx: &Ctx<'_>, path: &DataPath) { match self.streams.remove(path) { Some(RuntimeStream::Pending(pending)) => { self.send_stream_result( ctx, pending.request_id, pending.reply_to, Err(NamespaceError::PathReplaced(path.clone())), ); } Some(RuntimeStream::Active(active)) => { // A live generation must be fenced: its endpoints keep a // working transport, so removing the runtime entry alone // would let displaced writers and readers continue. Fan a // displacement notice to both endpoints and keep re-fanning // until each acknowledges. let incarnation = active.binding.incarnation; for endpoint in [active.binding.source, active.binding.sink] { if let Err(error) = self.queue_stream_displacement(ctx, endpoint, incarnation) { eprintln!("data-directory: persisting stream displacement failed: {error}"); } } } None => {} } } fn queue_stream_displacement( &mut self, ctx: &Ctx<'_>, endpoint: ActorAddress, incarnation: StreamIncarnation, ) -> Result<(), NamespaceError> { if self .pending_stream_displacements .get(&endpoint) .is_some_and(|known| known.incarnation == incarnation) { return Ok(()); } let mut next = self.store.snapshot().clone(); next.stream_retirements .retain(|(known, _)| *known != endpoint); next.stream_retirements.push((endpoint, incarnation)); self.store.commit(next)?; self.pending_stream_displacements.insert( endpoint, PendingStreamDisplacement { incarnation, acknowledged: false, }, ); self.watch_retirement(ctx, endpoint); let _ = ctx.send( endpoint, HostStreamIn::Displaced { incarnation, reply_to: Some(ctx.self_addr()), }, ); Ok(()) } fn begin_stream_replacement( &mut self, ctx: &Ctx<'_>, request: StreamOpenRequest, active: ActiveStream, ) { let path = request.path.clone(); let incarnation = active.binding.incarnation; let endpoints = HashSet::from([active.binding.source, active.binding.sink]); let mut next = self.store.snapshot().clone(); for endpoint in &endpoints { next.stream_retirements .retain(|(known, _)| known != endpoint); next.stream_retirements.push((*endpoint, incarnation)); } if let Err(error) = self.store.commit(next) { self.streams.insert(path, RuntimeStream::Active(active)); self.send_stream_result(ctx, request.request_id, request.reply_to, Err(error.into())); return; } for endpoint in &endpoints { self.pending_stream_displacements.insert( *endpoint, PendingStreamDisplacement { incarnation, acknowledged: false, }, ); self.watch_retirement(ctx, *endpoint); } self.stream_replacement_fences.insert( path, StreamReplacementFence { incarnation, endpoints: endpoints.clone(), old: Some(active), requests: vec![request], }, ); for endpoint in endpoints { let _ = ctx.send( endpoint, HostStreamIn::Displaced { incarnation, reply_to: Some(ctx.self_addr()), }, ); } } fn resume_stream_replacement_fences(&mut self, ctx: &Ctx<'_>) { let ready: Vec<_> = self .stream_replacement_fences .iter() .filter(|(_, fence)| { fence.endpoints.iter().all(|endpoint| { self.pending_stream_displacements .get(endpoint) .is_none_or(|pending| pending.incarnation != fence.incarnation) }) }) .map(|(path, _)| path.clone()) .collect(); for path in ready { if let Some(fence) = self.stream_replacement_fences.remove(&path) { for request in fence.requests { self.open_stream(ctx, request); } } } } fn hold_stream_open_for_fence(&mut self, ctx: &Ctx<'_>, request: StreamOpenRequest) { let old_reply = self .stream_replacement_fences .get(&request.path) .and_then(|fence| fence.old.as_ref()) .and_then(|old| { let belongs_to_old = request.operation_id == old.source_operation || request.operation_id == old.sink_operation; belongs_to_old.then(|| { let expected = match request.role { StreamRole::Source => (old.source_operation, old.binding.source), StreamRole::Sink => (old.sink_operation, old.binding.sink), }; if expected == (request.operation_id, request.endpoint) { Ok(old.binding.clone()) } else { Err(NamespaceError::OperationConflict(request.operation_id)) } }) }); if let Some(result) = old_reply { self.send_stream_result(ctx, request.request_id, request.reply_to, result); return; } let fence = self .stream_replacement_fences .get_mut(&request.path) .expect("replacement fence checked"); if let Some(known) = fence.requests.iter_mut().find(|known| { known.operation_id == request.operation_id && known.role == request.role && known.endpoint == request.endpoint }) { *known = request; } else { fence.requests.push(request); } } fn confirm_stream_displacement( &mut self, ctx: &Ctx<'_>, endpoint: ActorAddress, incarnation: StreamIncarnation, ) { let Some(pending) = self.pending_stream_displacements.get_mut(&endpoint) else { return; }; if pending.incarnation != incarnation { return; } pending.acknowledged = true; self.retry_retirement(ctx, endpoint); } fn open_stream(&mut self, ctx: &Ctx<'_>, request: StreamOpenRequest) { if self.stream_replacement_fences.contains_key(&request.path) { self.hold_stream_open_for_fence(ctx, request); return; } let StreamOpenRequest { request_id, path, role, endpoint, replace, ensure, expected_revision, descriptor, operation_id, reply_to, } = request; if let Some(replayed) = self.replay( operation_id, &MutationRequest::BindStream { path: path.clone() }, ) { let receipt = match replayed { // A cancelled open replays its persisted rejection: the // cancel tombstone must win over a reordered late retry. Err(error) => { self.send_stream_result(ctx, request_id, reply_to, Err(error)); return; } Ok(receipt) => receipt, }; let is_current = matches!( self.streams.get(&path), Some(RuntimeStream::Pending(pending)) if pending.operation_id == operation_id ) || matches!( self.streams.get(&path), Some(RuntimeStream::Active(active)) if active.source_operation == operation_id || active.sink_operation == operation_id ); if !is_current { self.send_stream_result( ctx, request_id, reply_to, Err(NamespaceError::StaleIncarnation { path, incarnation: StreamIncarnation { authority_epoch: self.authority_epoch, revision: receipt.revision, }, }), ); return; } } 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) && pending.operation_id == operation_id { if pending.role != role || pending.endpoint != endpoint { self.send_stream_result( ctx, request_id, reply_to, Err(NamespaceError::OperationConflict(operation_id)), ); } else { pending.request_id = request_id; pending.reply_to = reply_to; } return; } if let Some(RuntimeStream::Active(active)) = self.streams.get(&path) && (active.source_operation == operation_id || active.sink_operation == operation_id) { self.send_stream_result(ctx, request_id, reply_to, Ok(active.binding.clone())); return; } if replace && matches!(self.streams.get(&path), Some(RuntimeStream::Active(_))) { let Some(RuntimeStream::Active(active)) = self.streams.remove(&path) else { unreachable!("active stream checked"); }; self.begin_stream_replacement( ctx, StreamOpenRequest { request_id, path, role, endpoint, replace, ensure, expected_revision, descriptor, operation_id, reply_to, }, active, ); return; } let compatible_pending = matches!( self.streams.get(&path), Some(RuntimeStream::Pending(pending)) if pending.role != role ); if replace && self.streams.contains_key(&path) && !compatible_pending { self.displace_stream(ctx, &path); } if matches!(self.streams.get(&path), Some(RuntimeStream::Pending(_))) { let Some(RuntimeStream::Pending(pending)) = self.streams.remove(&path) else { unreachable!("pending stream checked"); }; if pending.role == role { self.streams .insert(path.clone(), RuntimeStream::Pending(pending)); self.send_stream_result( ctx, request_id, reply_to, Err(NamespaceError::DuplicateStreamRole { path, role }), ); return; } if let Err(error) = self.record_stream_participant(&path, operation_id, pending.revision) { self.streams .insert(path.clone(), RuntimeStream::Pending(pending)); self.send_stream_result(ctx, request_id, reply_to, Err(error)); return; } let ( source, sink, source_descriptor, sink_descriptor, source_operation, sink_operation, ) = match role { StreamRole::Source => ( endpoint, pending.endpoint, descriptor, pending.descriptor, operation_id, pending.operation_id, ), StreamRole::Sink => ( pending.endpoint, endpoint, pending.descriptor, descriptor, pending.operation_id, operation_id, ), }; let binding = StreamMatch { incarnation: StreamIncarnation { authority_epoch: self.authority_epoch, revision: pending.revision, }, source, source_descriptor, sink_descriptor, sink, revision: pending.revision, }; self.send_stream_result( ctx, pending.request_id, pending.reply_to, Ok(binding.clone()), ); self.send_stream_result(ctx, request_id, reply_to, Ok(binding.clone())); self.streams.insert( path, RuntimeStream::Active(ActiveStream { binding, source_operation, sink_operation, }), ); return; } if self.streams.contains_key(&path) { self.send_stream_result( ctx, request_id, reply_to, Err(NamespaceError::DuplicateStreamRole { path, role }), ); return; } if self.store.snapshot().bindings.contains_key(&path) && !replace { self.send_stream_result( ctx, request_id, reply_to, Err(NamespaceError::WrongEntryType { path, expected: EntryKind::Stream, found: EntryKind::Blob, }), ); return; } let retired = self.sources.get(&path).and_then(|source| match source { RuntimeSource::Available { actor, .. } => Some(*actor), RuntimeSource::Unavailable(_) => None, }); match self.bind_stream(path.clone(), operation_id, retired) { Ok(receipt) => { if let Some(retired) = retired { self.queue_retirement(ctx, retired); } self.streams.insert( path, RuntimeStream::Pending(PendingStream { role, descriptor, endpoint, operation_id, request_id, reply_to, revision: receipt.revision, opened_from_revision: (!ensure).then_some(expected_revision).flatten(), }), ); } Err(error) => self.send_stream_result(ctx, request_id, reply_to, Err(error)), } } /// Retract a waiting stream open. Cancels ride the same at-most-once /// transport as every other directory frame, so callers re-send them /// until acknowledged, and the cancel must also survive reordering /// against the open itself: when the open frame was lost and lands only /// after this cancel, the persisted rejection below turns its replay /// into a typed failure instead of resurrecting a pending endpoint that /// no live actor will ever close. fn cancel_stream( &mut self, path: &DataPath, operation_id: OperationId, ) -> Result<(), NamespaceError> { if !self.store.snapshot().operations.contains_key(&operation_id) { let request = MutationRequest::BindStream { path: path.clone() }; let mut next = self.store.snapshot().clone(); next.operations.insert( operation_id, PersistedOperation { request, result: PersistedMutationResult::Rejected(MutationRejection::PathReplaced( path.clone(), )), }, ); self.store.commit(next)?; } if matches!( self.streams.get(path), Some(RuntimeStream::Pending(pending)) if pending.operation_id == operation_id ) { self.streams.remove(path); } if let Some(fence) = self.stream_replacement_fences.get_mut(path) { fence .requests .retain(|request| request.operation_id != operation_id); } Ok(()) } fn close_stream( &mut self, path: &DataPath, incarnation: StreamIncarnation, ) -> Result<(), NamespaceError> { let active_matches = matches!( self.streams.get(path), Some(RuntimeStream::Active(active)) if active.binding.incarnation == incarnation ); if active_matches { self.streams.remove(path); return Ok(()); } let already_closed = !self.streams.contains_key(path) && incarnation.authority_epoch == self.authority_epoch && self.store.snapshot().stream_nodes.get(path).copied() == Some(incarnation.revision); if already_closed { return Ok(()); } Err(NamespaceError::StaleIncarnation { path: path.clone(), incarnation, }) } 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( &mut self, registration: BlobRegistration, ) -> Result { let BlobRegistration { path, source, source_node, length, recovery, operation_id, reservation, retired, } = registration; let owner = match &recovery { SourceRecovery::Actor { owner, .. } => *owner, SourceRecovery::File { .. } => None, }; let request = MutationRequest::Register { path: path.clone(), length, recovery: recovery.clone(), }; if let Some(replayed) = self.replay(operation_id, &request) { return replayed; } match self.blob_reservations.get(&path) { Some(existing) if Some(*existing) == reservation => {} Some(_) => { return Err(self.reject( operation_id, request, MutationRejection::PathExists(path), )); } None if reservation.is_some() => { return Err(self.reject( operation_id, request, MutationRejection::PathReplaced(path), )); } None => {} } let revision = self.store.snapshot().next_revision; let next_revision = revision .checked_add(1) .filter(|revision| *revision != 0) .ok_or(NamespaceStoreError::RevisionExhausted)?; let receipt = MutationReceipt { revision }; let mut next = self.store.snapshot().clone(); next.next_revision = next_revision; next.stream_nodes.remove(&path); next.bindings.insert( path.clone(), PersistedBinding { length, revision, recovery, }, ); if let Some(retired) = retired && !next.retirements.contains(&retired) { next.retirements.push(retired); } next.operations.insert( operation_id, PersistedOperation { request, result: PersistedMutationResult::Committed(receipt), }, ); self.store.commit(next)?; if reservation.is_some() { self.blob_reservations.remove(&path); } self.sources.insert( path, RuntimeSource::Available { actor: source, node: source_node, owner, }, ); Ok(receipt) } fn resolve(&self, path: &DataPath) -> Result { if self.store.snapshot().stream_nodes.contains_key(path) { return Err(NamespaceError::WrongEntryType { path: path.clone(), expected: EntryKind::Blob, found: EntryKind::Stream, }); } let persisted = self .store .snapshot() .bindings .get(path) .ok_or_else(|| NamespaceError::PathNotFound(path.clone()))?; match self.sources.get(path) { Some(RuntimeSource::Available { actor, node, owner }) => Ok(BlobBinding { source: *actor, source_node: *node, owner: *owner, length: persisted.length, revision: persisted.revision, }), Some(RuntimeSource::Unavailable(reason)) => { Err(NamespaceError::SourceRecovery(reason.clone())) } None => Err(NamespaceError::SourceRecovery(format!( "binding {path} has no recovered runtime source" ))), } } fn lookup(&self, path: &DataPath) -> Result { 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, active: false, }), (None, Some(revision)) => Ok(NamespaceNode { kind: EntryKind::Stream, revision: *revision, active: self.streams.contains_key(path), }), (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( &mut self, path: DataPath, operation_id: OperationId, retired: Option, ) -> Result { let request = MutationRequest::Unregister { path: path.clone() }; if let Some(replayed) = self.replay(operation_id, &request) { return replayed; } if self.blob_reservations.contains_key(&path) { return Err(self.reject(operation_id, request, MutationRejection::PathExists(path))); } if !self.store.snapshot().bindings.contains_key(&path) && !self.store.snapshot().stream_nodes.contains_key(&path) { return Err(self.reject(operation_id, request, MutationRejection::PathNotFound(path))); } let revision = self.store.snapshot().next_revision; let next_revision = revision .checked_add(1) .filter(|revision| *revision != 0) .ok_or(NamespaceStoreError::RevisionExhausted)?; let receipt = MutationReceipt { revision }; let mut next = self.store.snapshot().clone(); next.next_revision = next_revision; next.bindings.remove(&path); next.stream_nodes.remove(&path); if let Some(retired) = retired && !next.retirements.contains(&retired) { next.retirements.push(retired); } next.operations.insert( operation_id, PersistedOperation { request, result: PersistedMutationResult::Committed(receipt), }, ); self.store.commit(next)?; self.sources.remove(&path); Ok(receipt) } fn rename( &mut self, source: DataPath, destination: DataPath, replace: bool, operation_id: OperationId, ) -> Result { let request = MutationRequest::Rename { source: source.clone(), destination: destination.clone(), replace, }; if let Some(replayed) = self.replay(operation_id, &request) { return replayed; } let snapshot = self.store.snapshot(); let source_exists = snapshot.bindings.contains_key(&source) || snapshot.stream_nodes.contains_key(&source); if !source_exists { return Err(self.reject( operation_id, request, MutationRejection::PathNotFound(source), )); } if self.blob_reservations.contains_key(&source) || self.blob_reservations.contains_key(&destination) { let reserved = if self.blob_reservations.contains_key(&source) { source } else { destination }; return Err(self.reject( operation_id, request, MutationRejection::PathExists(reserved), )); } let active_path = self .streams .contains_key(&source) .then_some(source.clone()) .or_else(|| { self.streams .contains_key(&destination) .then_some(destination.clone()) }); if let Some(path) = active_path { return Err(self.reject(operation_id, request, MutationRejection::StreamActive(path))); } let destination_exists = snapshot.bindings.contains_key(&destination) || snapshot.stream_nodes.contains_key(&destination); if source != destination && destination_exists && !replace { return Err(self.reject( operation_id, request, MutationRejection::PathExists(destination), )); } let revision = snapshot.next_revision; let next_revision = revision .checked_add(1) .filter(|revision| *revision != 0) .ok_or(NamespaceStoreError::RevisionExhausted)?; let receipt = MutationReceipt { revision }; let destination_retired = (source != destination) .then(|| self.sources.get(&destination)) .flatten() .and_then(|source| match source { RuntimeSource::Available { actor, .. } => Some(*actor), RuntimeSource::Unavailable(_) => None, }); let mut next = snapshot.clone(); next.next_revision = next_revision; let source_binding = next.bindings.remove(&source); let source_stream = next.stream_nodes.remove(&source); next.bindings.remove(&destination); next.stream_nodes.remove(&destination); if let Some(mut binding) = source_binding { binding.revision = revision; next.bindings.insert(destination.clone(), binding); } else if source_stream.is_some() { next.stream_nodes.insert(destination.clone(), revision); } if let Some(retired) = destination_retired && !next.retirements.contains(&retired) { next.retirements.push(retired); } next.operations.insert( operation_id, PersistedOperation { request, result: PersistedMutationResult::Committed(receipt), }, ); self.store.commit(next)?; let source_runtime = self.sources.remove(&source); if source != destination { self.sources.remove(&destination); } if let Some(source_runtime) = source_runtime { self.sources.insert(destination, source_runtime); } Ok(receipt) } } impl ActorInterface for DataDirectoryActor { type Incoming = DataDirectoryIn; type Response = (); fn on_start(&mut self, ctx: &Ctx<'_>) { if let Some(retire_retry) = &self.retire_retry { self.retirement_timer = Some(retire_retry.engine.send_every( retire_retry.period, retire_retry.sender.clone(), ctx.self_addr(), DataDirectoryIn::RetryRetirements, )); } let targets: Vec<_> = self .pending_retirements .iter() .chain(self.pending_stream_displacements.keys()) .copied() .collect(); for target in targets { self.watch_retirement(ctx, target); } self.retry_retirements(ctx); } fn handle(&mut self, ctx: &Ctx<'_>, message: DataDirectoryIn) { #[cfg(feature = "directory-trace")] self.trace .observed_step(self.pending_retirements.len(), &self.store); match message { DataDirectoryIn::Register { request_id, path, source, source_node, length, recovery, operation_id, reservation, reply_to, } => { let logical = path.clone(); let replayed = self.store.snapshot().operations.contains_key(&operation_id); let retired = (!replayed) .then(|| self.sources.get(&path)) .flatten() .and_then(|runtime_source| match runtime_source { RuntimeSource::Available { actor, .. } if *actor != source => Some(*actor), RuntimeSource::Available { .. } | RuntimeSource::Unavailable(_) => None, }); let result = self.register(BlobRegistration { path, source, source_node, length, recovery, operation_id, reservation, retired, }); if result.is_ok() && let Some(retired) = retired { self.queue_retirement(ctx, retired); } if result.is_ok() { self.displace_stream(ctx, &logical); } let _ = ctx.send( reply_to, NamespaceClientIn::DirectoryReply(DataDirectoryOut::Registered { request_id, authority_epoch: self.authority_epoch, result, }), ); } DataDirectoryIn::Resolve { request_id, path, reply_to, } => { let result = self.resolve(&path); let _ = ctx.send( reply_to, NamespaceClientIn::DirectoryReply(DataDirectoryOut::Resolved { request_id, authority_epoch: self.authority_epoch, result, }), ); } 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 { request_id, path, operation_id, reply_to, } => { if self.streams.contains_key(&path) { let _ = ctx.send( reply_to, NamespaceClientIn::DirectoryReply(DataDirectoryOut::Unregistered { request_id, authority_epoch: self.authority_epoch, result: Err(NamespaceError::WrongEntryType { path, expected: EntryKind::Blob, found: EntryKind::Stream, }), }), ); return; } let replayed = self.store.snapshot().operations.contains_key(&operation_id); let retired = (!replayed) .then(|| self.sources.get(&path)) .flatten() .and_then(|source| match source { RuntimeSource::Available { actor, .. } => Some(*actor), RuntimeSource::Unavailable(_) => None, }); let result = self.unregister(path, operation_id, retired); if result.is_ok() && let Some(retired) = retired { self.queue_retirement(ctx, retired); } let _ = ctx.send( reply_to, NamespaceClientIn::DirectoryReply(DataDirectoryOut::Unregistered { request_id, authority_epoch: self.authority_epoch, result, }), ); } DataDirectoryIn::Rename { request_id, source, destination, replace, operation_id, reply_to, } => { let replayed = self.store.snapshot().operations.contains_key(&operation_id); let retired = (!replayed && source != destination) .then(|| self.sources.get(&destination)) .flatten() .and_then(|source| match source { RuntimeSource::Available { actor, .. } => Some(*actor), RuntimeSource::Unavailable(_) => None, }); let result = self.rename(source, destination, replace, operation_id); if result.is_ok() && let Some(retired) = retired { self.queue_retirement(ctx, retired); } let _ = ctx.send( reply_to, NamespaceClientIn::DirectoryReply(DataDirectoryOut::Renamed { request_id, authority_epoch: self.authority_epoch, result, }), ); } DataDirectoryIn::OpenStream { request_id, path, role, endpoint, descriptor, replace, ensure, expected_revision, operation_id, reply_to, } => self.open_stream( ctx, StreamOpenRequest { request_id, path, role, endpoint, replace, ensure, expected_revision, descriptor, operation_id, reply_to, }, ), DataDirectoryIn::CancelStream { request_id, path, operation_id, reply_to, } => { let result = self.cancel_stream(&path, operation_id); if let Some(reply_to) = reply_to { let _ = ctx.send( reply_to, NamespaceClientIn::DirectoryReply(DataDirectoryOut::StreamCancelled { request_id, authority_epoch: self.authority_epoch, result, }), ); } } DataDirectoryIn::CloseStream { request_id, path, incarnation, reply_to, } => { let result = self.close_stream(&path, incarnation); let _ = ctx.send( reply_to, NamespaceClientIn::DirectoryReply(DataDirectoryOut::StreamClosed { request_id, authority_epoch: self.authority_epoch, result, }), ); } DataDirectoryIn::SourceRetired { source } => { let _ = self.confirm_retirement(source); } // The retry tick is the only periodic re-drive of pending // retirements: re-fanning on *every* inbound message couples the // outbound Retire rate to the cluster's request rate, so a loaded // directory (many pending namespace requests) multiplied each // unacknowledged retirement into a self-sustaining frame storm // that starved namespace replies behind bulk traffic. DataDirectoryIn::RetryRetirements => self.retry_retirements(ctx), DataDirectoryIn::RetirementRouteChanged { target } => { self.retry_retirement(ctx, target); } DataDirectoryIn::StreamDisplaced { endpoint, incarnation, } => { self.confirm_stream_displacement(ctx, endpoint, incarnation); } } } } impl Drop for DataDirectoryActor { fn drop(&mut self) { if let Some(timer) = self.retirement_timer.take() { timer.cancel(); } } } pub trait NamespaceDiscovery: Send + Sync + 'static { fn current_directory(&self) -> Option; /// Discoveries carrying a durable authority epoch must validate it exactly /// and reject replies while the current authority is undiscovered. A /// transport wake alone never authenticates an old authority. fn accepts_authority_epoch(&self, _epoch: u64) -> bool { true } } struct PendingRequest { request: NamespaceRequest, reply_to: ActorAddress, enqueued: EngineInstant, } /// How long a namespace request may stay unanswered while no directory is /// discoverable. Authority loss must surface as a bounded typed failure to /// callers (e.g. a contextual process blocked on `lookup` after the /// orchestrator died), never as a hang. const NAMESPACE_REQUEST_DEADLINE: Duration = Duration::from_secs(80); pub struct NamespaceClientActor { engine: EngineHandle, sender: ExternalSender, discovery: Arc, retry_period: Duration, request_deadline: Duration, next_request_id: u64, pending: HashMap, } impl NamespaceClientActor { pub fn new( engine: EngineHandle, sender: ExternalSender, discovery: Arc, retry_period: Duration, ) -> Self { Self::new_with_deadline( engine, sender, discovery, retry_period, NAMESPACE_REQUEST_DEADLINE, ) } pub fn new_with_deadline( engine: EngineHandle, sender: ExternalSender, discovery: Arc, retry_period: Duration, request_deadline: Duration, ) -> Self { Self { engine, sender, discovery, retry_period, request_deadline, next_request_id: 1, pending: HashMap::new(), } } fn dispatch(&self, ctx: &Ctx<'_>, request_id: DirectoryRequestId, request: &NamespaceRequest) { let Some(directory) = self.discovery.current_directory() else { return; }; let message = match request { NamespaceRequest::Register { path, source, source_node, length, recovery, operation_id, reservation, } => DataDirectoryIn::Register { request_id, path: path.clone(), source: *source, source_node: *source_node, length: *length, recovery: recovery.clone(), operation_id: *operation_id, reservation: *reservation, reply_to: ctx.self_addr(), }, NamespaceRequest::Resolve { path } => DataDirectoryIn::Resolve { request_id, path: path.clone(), 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 { request_id, path: path.clone(), operation_id: *operation_id, reply_to: ctx.self_addr(), }, NamespaceRequest::Rename { source, destination, replace, operation_id, } => DataDirectoryIn::Rename { request_id, source: source.clone(), destination: destination.clone(), replace: *replace, operation_id: *operation_id, reply_to: ctx.self_addr(), }, NamespaceRequest::OpenStream { path, role, endpoint, descriptor, replace, ensure, expected_revision, operation_id, } => DataDirectoryIn::OpenStream { request_id, path: path.clone(), role: *role, endpoint: *endpoint, descriptor: descriptor.clone(), replace: *replace, ensure: *ensure, expected_revision: *expected_revision, operation_id: *operation_id, reply_to: ctx.self_addr(), }, NamespaceRequest::CancelStream { path, operation_id } => { DataDirectoryIn::CancelStream { request_id, path: path.clone(), operation_id: *operation_id, reply_to: Some(ctx.self_addr()), } } NamespaceRequest::CloseStream { path, incarnation } => DataDirectoryIn::CloseStream { request_id, path: path.clone(), incarnation: *incarnation, reply_to: ctx.self_addr(), }, }; let _ = ctx.send(directory, message); } /// Enqueue a tracked CancelStream retraction for an abandoned stream /// open. The retraction is a pending request the retry tick re-drives /// until the directory acknowledges it: a dropped frame would otherwise /// strand the parked endpoint in the directory forever, and unlink of /// its path would then fail with ENXIO indefinitely. fn retract_stream_open(&mut self, ctx: &Ctx<'_>, path: DataPath, operation_id: OperationId) { let Some(next) = self .next_request_id .checked_add(1) .filter(|next| *next != 0) else { return; }; let request_id = DirectoryRequestId(self.next_request_id); self.next_request_id = next; let request = NamespaceRequest::CancelStream { path, operation_id }; self.dispatch(ctx, request_id, &request); self.pending.insert( request_id, PendingRequest { request, reply_to: ctx.self_addr(), enqueued: self.engine.now(), }, ); } } impl ActorInterface for NamespaceClientActor { type Incoming = NamespaceClientIn; type Response = (); fn on_start(&mut self, ctx: &Ctx<'_>) { self.engine.send_every( self.retry_period, self.sender.clone(), ctx.self_addr(), NamespaceClientIn::Retry, ); } fn handle(&mut self, ctx: &Ctx<'_>, message: NamespaceClientIn) { match message { NamespaceClientIn::Request { request, reply_to } => { let request_id = DirectoryRequestId(self.next_request_id); let Some(next) = self .next_request_id .checked_add(1) .filter(|next| *next != 0) else { let _ = ctx.send( reply_to, DataDirectoryOut::Resolved { request_id, authority_epoch: 0, result: Err(NamespaceError::Protocol( "namespace client request IDs exhausted".to_owned(), )), }, ); return; }; self.next_request_id = next; self.dispatch(ctx, request_id, &request); self.pending.insert( request_id, PendingRequest { request, reply_to, enqueued: self.engine.now(), }, ); } NamespaceClientIn::Cancel { reply_to } => { let cancelled: Vec<(DataPath, OperationId)> = self .pending .values() .filter(|pending| pending.reply_to == reply_to) .filter_map(|pending| match &pending.request { NamespaceRequest::OpenStream { path, operation_id, .. } => Some((path.clone(), *operation_id)), _ => None, }) .collect(); self.pending.retain(|_, pending| { if pending.reply_to != reply_to { return true; } if matches!( pending.request, NamespaceRequest::CloseStream { .. } | NamespaceRequest::CancelStream { .. } ) { // Caller cancellation transfers cleanup ownership to // the proxy; it cannot revoke an unacknowledged retract. pending.reply_to = ctx.self_addr(); true } else { false } }); // A dropped cancel frame would strand the pending endpoint // in the directory forever (nothing else retracts it), so // the retraction is tracked as a pending request the retry // tick re-drives until the directory acknowledges it. for (path, operation_id) in cancelled { self.retract_stream_open(ctx, path, operation_id); } } 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) => { if !self .discovery .accepts_authority_epoch(reply.authority_epoch()) { return; } if let Some(pending) = self.pending.remove(&reply.request_id()) { let _ = ctx.send(pending.reply_to, reply); } } NamespaceClientIn::TransferFailed { destination, transfer_id, reason, } => { let _ = ctx.send( destination, BlobTransferEvent::Failed { transfer_id, reason, }, ); } NamespaceClientIn::Retry => { // A request no directory has answered within the bound fails // with a typed error instead of hanging the caller forever // (e.g. authority loss — the registry may keep serving the // dead directory's address, so age is the only sound bound). // Stream retractions are exempt: a CloseStream or // CancelStream that expires while the directory is // unreachable would strand the endpoint in the directory // forever — unlink of that path then fails with ENXIO // indefinitely — while every later request flows again. // Retractions have no caller left to disappoint; they retry // until the directory acknowledges them. let now = self.engine.now(); let expired: Vec = self .pending .iter() .filter(|(_, pending)| { !matches!( pending.request, NamespaceRequest::CloseStream { .. } | NamespaceRequest::CancelStream { .. } ) && now .to_instant() .duration_since(pending.enqueued.to_instant()) > self.request_deadline }) .map(|(request_id, _)| *request_id) .collect(); for request_id in expired { if let Some(pending) = self.pending.remove(&request_id) { // An expired stream open may already be registered // in the directory: first-role opens park without a // reply until their peer arrives, so the deadline // fires while the endpoint is committed. Its caller // is gone with the failure reply — nothing else // retracts the open — so the expiry itself must // enqueue the same deadline-exempt retraction the // cancel path uses. Without it the parked endpoint // survives forever and unlink of the path fails // with ENXIO indefinitely. if let NamespaceRequest::OpenStream { path, operation_id, .. } = &pending.request { self.retract_stream_open(ctx, path.clone(), *operation_id); } let _ = ctx.send( pending.reply_to, expired_reply(request_id, &pending.request), ); } } for (request_id, pending) in &self.pending { self.dispatch(ctx, *request_id, &pending.request); } } } } } /// Build the typed failure a caller receives when its namespace request fn expired_reply(request_id: DirectoryRequestId, request: &NamespaceRequest) -> DataDirectoryOut { fn failure() -> Result { Err(NamespaceError::DirectoryUnavailable( "no namespace authority was discoverable within the request deadline".to_owned(), )) } match request { NamespaceRequest::Register { .. } => DataDirectoryOut::Registered { request_id, authority_epoch: 0, result: failure(), }, NamespaceRequest::Resolve { .. } => DataDirectoryOut::Resolved { request_id, authority_epoch: 0, result: failure(), }, NamespaceRequest::Lookup { .. } => DataDirectoryOut::LookedUp { request_id, authority_epoch: 0, result: failure(), }, NamespaceRequest::ReserveBlob { .. } => DataDirectoryOut::BlobReserved { request_id, authority_epoch: 0, result: failure(), }, NamespaceRequest::ReleaseBlobReservation { .. } => { DataDirectoryOut::BlobReservationReleased { request_id, authority_epoch: 0, result: failure(), } } NamespaceRequest::Unregister { .. } => DataDirectoryOut::Unregistered { request_id, authority_epoch: 0, result: failure(), }, NamespaceRequest::Rename { .. } => DataDirectoryOut::Renamed { request_id, authority_epoch: 0, result: failure(), }, NamespaceRequest::OpenStream { .. } => DataDirectoryOut::StreamOpened { request_id, authority_epoch: 0, result: failure(), }, NamespaceRequest::CancelStream { .. } => DataDirectoryOut::StreamCancelled { request_id, authority_epoch: 0, result: failure(), }, NamespaceRequest::CloseStream { .. } => DataDirectoryOut::StreamClosed { request_id, authority_epoch: 0, result: failure(), }, } } struct NamespaceRequestCancellation { runtime: Runtime, proxy: ActorAddress, reply_to: ActorAddress, armed: bool, } impl Drop for NamespaceRequestCancellation { fn drop(&mut self) { if self.armed { let _ = self.runtime.send_to( self.proxy, NamespaceClientIn::Cancel { reply_to: self.reply_to, }, ); } } } #[derive(Clone)] pub struct NamespaceClient { runtime: Runtime, proxy: ActorAddress, } impl NamespaceClient { pub fn new(runtime: Runtime, proxy: ActorAddress) -> Self { Self { runtime, proxy } } pub fn proxy(&self) -> ActorAddress { self.proxy } async fn request(&self, request: NamespaceRequest) -> Result { let inbox = self .runtime .new_inbox::() .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; let reply_to = *inbox.addr(); self.runtime .send_to(self.proxy, NamespaceClientIn::Request { request, reply_to }) .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; let mut cancellation = NamespaceRequestCancellation { runtime: self.runtime.clone(), proxy: self.proxy, reply_to, armed: true, }; let reply = inbox.recv().await; cancellation.armed = false; Ok(reply) } pub async fn register( &self, path: DataPath, source: ActorAddress, source_node: [u8; 32], length: u64, recovery: SourceRecovery, operation_id: OperationId, ) -> Result { match self .request(NamespaceRequest::Register { path, source, source_node, length, recovery, operation_id, reservation: None, }) .await? { DataDirectoryOut::Registered { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected register reply, received {other:?}" ))), } } pub async fn resolve(&self, path: DataPath) -> Result { match self.request(NamespaceRequest::Resolve { path }).await? { DataDirectoryOut::Resolved { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected resolve reply, received {other:?}" ))), } } pub async fn lookup(&self, path: DataPath) -> Result { 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( &self, path: DataPath, operation_id: OperationId, ) -> Result { match self .request(NamespaceRequest::Unregister { path, operation_id }) .await? { DataDirectoryOut::Unregistered { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected unregister reply, received {other:?}" ))), } } pub async fn rename( &self, source: DataPath, destination: DataPath, replace: bool, operation_id: OperationId, ) -> Result { match self .request(NamespaceRequest::Rename { source, destination, replace, operation_id, }) .await? { DataDirectoryOut::Renamed { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected rename reply, received {other:?}" ))), } } async fn open_stream_inner( &self, path: DataPath, role: StreamRole, endpoint: ActorAddress, replace: bool, operation_id: OperationId, ) -> Result { match self .request(NamespaceRequest::OpenStream { path, role, endpoint, replace, ensure: true, expected_revision: None, descriptor: Vec::new(), operation_id, }) .await? { DataDirectoryOut::StreamOpened { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected stream-open reply, received {other:?}" ))), } } pub async fn open_stream( &self, path: DataPath, role: StreamRole, endpoint: ActorAddress, operation_id: OperationId, ) -> Result { self.open_stream_inner(path, role, endpoint, false, operation_id) .await } pub async fn replace_with_stream( &self, path: DataPath, role: StreamRole, endpoint: ActorAddress, operation_id: OperationId, ) -> Result { self.open_stream_inner(path, role, endpoint, true, operation_id) .await } pub async fn close_stream( &self, path: DataPath, incarnation: StreamIncarnation, ) -> Result<(), NamespaceError> { match self .request(NamespaceRequest::CloseStream { path, incarnation }) .await? { DataDirectoryOut::StreamClosed { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected stream-close reply, received {other:?}" ))), } } } struct DirectoryStreamCancellation { runtime: Runtime, directory: ActorAddress, path: DataPath, operation_id: OperationId, armed: bool, } impl Drop for DirectoryStreamCancellation { fn drop(&mut self) { if self.armed { // Fire-and-forget: this path is unused in production (stream // opens route through the retrying namespace proxy); the // durable cancel tombstone still makes a lost frame safe // against a later replay of the same open. let _ = self.runtime.send_to( self.directory, DataDirectoryIn::CancelStream { request_id: DirectoryRequestId(0), path: self.path.clone(), operation_id: self.operation_id, reply_to: None, }, ); } } } #[derive(Clone)] pub struct DirectoryClient { runtime: Runtime, directory: ActorAddress, next_request_id: Arc, } impl DirectoryClient { pub fn new(runtime: Runtime, directory: ActorAddress) -> Self { Self { runtime, directory, next_request_id: Arc::new(AtomicU64::new(1)), } } pub fn directory(&self) -> ActorAddress { self.directory } fn request_id(&self) -> DirectoryRequestId { DirectoryRequestId(self.next_request_id.fetch_add(1, Ordering::Relaxed)) } async fn receive( &self, inbox: &swactor::runtime::Inbox, ) -> Result { match inbox.recv().await { NamespaceClientIn::DirectoryReply(reply) => Ok(reply), other => Err(NamespaceError::Protocol(format!( "expected directory reply, received {other:?}" ))), } } pub async fn register( &self, path: DataPath, source: ActorAddress, source_node: [u8; 32], length: u64, recovery: SourceRecovery, operation_id: OperationId, ) -> Result { let inbox = self .runtime .new_inbox::() .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; self.runtime .send_to( self.directory, DataDirectoryIn::Register { request_id: self.request_id(), path, source, source_node, length, recovery, operation_id, reservation: None, reply_to: *inbox.addr(), }, ) .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; match self.receive(&inbox).await? { DataDirectoryOut::Registered { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected register reply, received {other:?}" ))), } } pub async fn resolve(&self, path: DataPath) -> Result { let inbox = self .runtime .new_inbox::() .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; self.runtime .send_to( self.directory, DataDirectoryIn::Resolve { request_id: self.request_id(), path, reply_to: *inbox.addr(), }, ) .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; match self.receive(&inbox).await? { DataDirectoryOut::Resolved { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected resolve reply, received {other:?}" ))), } } pub async fn lookup(&self, path: DataPath) -> Result { let inbox = self .runtime .new_inbox::() .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( &self, path: DataPath, operation_id: OperationId, ) -> Result { let inbox = self .runtime .new_inbox::() .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; self.runtime .send_to( self.directory, DataDirectoryIn::Unregister { request_id: self.request_id(), path, operation_id, reply_to: *inbox.addr(), }, ) .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; match self.receive(&inbox).await? { DataDirectoryOut::Unregistered { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected unregister reply, received {other:?}" ))), } } pub async fn rename( &self, source: DataPath, destination: DataPath, replace: bool, operation_id: OperationId, ) -> Result { let inbox = self .runtime .new_inbox::() .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; self.runtime .send_to( self.directory, DataDirectoryIn::Rename { request_id: self.request_id(), source, destination, replace, operation_id, reply_to: *inbox.addr(), }, ) .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; match self.receive(&inbox).await? { DataDirectoryOut::Renamed { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected rename reply, received {other:?}" ))), } } pub async fn reserve_blob( &self, path: DataPath, operation_id: OperationId, ) -> Result<(), NamespaceError> { let inbox = self .runtime .new_inbox::() .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; self.runtime .send_to( self.directory, DataDirectoryIn::ReserveBlob { request_id: self.request_id(), path, operation_id, reply_to: *inbox.addr(), }, ) .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; match self.receive(&inbox).await? { DataDirectoryOut::BlobReserved { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected reserve reply, received {other:?}" ))), } } pub async fn release_blob_reservation( &self, path: DataPath, operation_id: OperationId, ) -> Result<(), NamespaceError> { let inbox = self .runtime .new_inbox::() .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; self.runtime .send_to( self.directory, DataDirectoryIn::ReleaseBlobReservation { request_id: self.request_id(), path, operation_id, reply_to: *inbox.addr(), }, ) .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; match self.receive(&inbox).await? { DataDirectoryOut::BlobReservationReleased { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected release reply, received {other:?}" ))), } } async fn open_stream_inner( &self, path: DataPath, role: StreamRole, endpoint: ActorAddress, replace: bool, operation_id: OperationId, ) -> Result { let inbox = self .runtime .new_inbox::() .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; let mut cancellation = DirectoryStreamCancellation { runtime: self.runtime.clone(), directory: self.directory, path: path.clone(), operation_id, armed: true, }; self.runtime .send_to( self.directory, DataDirectoryIn::OpenStream { request_id: self.request_id(), path, role, endpoint, replace, ensure: true, expected_revision: None, operation_id, descriptor: Vec::new(), reply_to: *inbox.addr(), }, ) .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; let result = match self.receive(&inbox).await? { DataDirectoryOut::StreamOpened { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected stream-open reply, received {other:?}" ))), }; cancellation.armed = false; result } pub async fn open_stream( &self, path: DataPath, role: StreamRole, endpoint: ActorAddress, operation_id: OperationId, ) -> Result { self.open_stream_inner(path, role, endpoint, false, operation_id) .await } pub async fn replace_with_stream( &self, path: DataPath, role: StreamRole, endpoint: ActorAddress, operation_id: OperationId, ) -> Result { self.open_stream_inner(path, role, endpoint, true, operation_id) .await } pub async fn close_stream( &self, path: DataPath, incarnation: StreamIncarnation, ) -> Result<(), NamespaceError> { let inbox = self .runtime .new_inbox::() .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; self.runtime .send_to( self.directory, DataDirectoryIn::CloseStream { request_id: self.request_id(), path, incarnation, reply_to: *inbox.addr(), }, ) .map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?; match self.receive(&inbox).await? { DataDirectoryOut::StreamClosed { result, .. } => result, other => Err(NamespaceError::Protocol(format!( "expected stream-close reply, received {other:?}" ))), } } } pub fn register_namespace_codecs(registry: &mut CodecRegistry) { registry .register::(JsonCodec::default()) .expect("unique codec registration"); registry .register::(JsonCodec::default()) .expect("unique codec registration"); }