Integrate namespace and source-route lifecycle changes, contextual process cleanup, Python binding updates, and Myelin worker/orchestrator recovery. Keep the shared control contracts, deployment identity fencing, SSH bootstrap adapters, paid admission accounting, and VastAI cleanup implementation together with their consumers. Migrate Iroh dependencies and telemetry transport/collection with dashboard and demo callsites, workspace build configuration, and actor-control-flow policy updates. This is an intermediate development checkpoint, not paid-provider qualification. Review verification: contextual_process_guarantees (4 tests), telemetry_transport (4 tests), and shared control contracts (5 tests) passed. Historical five-node redeployment and campaign execution passed individually; complete ordered qualification remains pending.
3179 lines
108 KiB
Rust
3179 lines
108 KiB
Rust
//! 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<ActorAddress>,
|
|
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<u8>,
|
|
pub sink_descriptor: Vec<u8>,
|
|
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<NamespaceStoreError> 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<OperationId>,
|
|
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<u8>,
|
|
endpoint: ActorAddress,
|
|
replace: bool,
|
|
ensure: bool,
|
|
expected_revision: Option<u64>,
|
|
operation_id: OperationId,
|
|
reply_to: ActorAddress,
|
|
},
|
|
CancelStream {
|
|
request_id: DirectoryRequestId,
|
|
path: DataPath,
|
|
operation_id: OperationId,
|
|
reply_to: Option<ActorAddress>,
|
|
},
|
|
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<MutationReceipt, NamespaceError>,
|
|
},
|
|
Resolved {
|
|
request_id: DirectoryRequestId,
|
|
authority_epoch: u64,
|
|
result: Result<BlobBinding, NamespaceError>,
|
|
},
|
|
LookedUp {
|
|
request_id: DirectoryRequestId,
|
|
authority_epoch: u64,
|
|
result: Result<NamespaceNode, NamespaceError>,
|
|
},
|
|
BlobReserved {
|
|
request_id: DirectoryRequestId,
|
|
authority_epoch: u64,
|
|
result: Result<(), NamespaceError>,
|
|
},
|
|
BlobReservationReleased {
|
|
request_id: DirectoryRequestId,
|
|
authority_epoch: u64,
|
|
result: Result<(), NamespaceError>,
|
|
},
|
|
Unregistered {
|
|
request_id: DirectoryRequestId,
|
|
authority_epoch: u64,
|
|
result: Result<MutationReceipt, NamespaceError>,
|
|
},
|
|
Renamed {
|
|
request_id: DirectoryRequestId,
|
|
authority_epoch: u64,
|
|
result: Result<MutationReceipt, NamespaceError>,
|
|
},
|
|
StreamOpened {
|
|
request_id: DirectoryRequestId,
|
|
authority_epoch: u64,
|
|
result: Result<StreamMatch, NamespaceError>,
|
|
},
|
|
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<OperationId>,
|
|
},
|
|
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<u8>,
|
|
replace: bool,
|
|
ensure: bool,
|
|
expected_revision: Option<u64>,
|
|
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<ActorAddress>,
|
|
},
|
|
Unavailable(String),
|
|
}
|
|
|
|
struct PendingStream {
|
|
role: StreamRole,
|
|
endpoint: ActorAddress,
|
|
operation_id: OperationId,
|
|
request_id: DirectoryRequestId,
|
|
descriptor: Vec<u8>,
|
|
reply_to: ActorAddress,
|
|
revision: u64,
|
|
opened_from_revision: Option<u64>,
|
|
}
|
|
|
|
struct StreamOpenRequest {
|
|
request_id: DirectoryRequestId,
|
|
path: DataPath,
|
|
role: StreamRole,
|
|
endpoint: ActorAddress,
|
|
replace: bool,
|
|
ensure: bool,
|
|
expected_revision: Option<u64>,
|
|
descriptor: Vec<u8>,
|
|
operation_id: OperationId,
|
|
reply_to: ActorAddress,
|
|
}
|
|
|
|
struct StreamReplacementFence {
|
|
incarnation: StreamIncarnation,
|
|
endpoints: HashSet<ActorAddress>,
|
|
old: Option<ActiveStream>,
|
|
requests: Vec<StreamOpenRequest>,
|
|
}
|
|
struct BlobRegistration {
|
|
path: DataPath,
|
|
source: ActorAddress,
|
|
source_node: [u8; 32],
|
|
length: u64,
|
|
recovery: SourceRecovery,
|
|
operation_id: OperationId,
|
|
reservation: Option<OperationId>,
|
|
retired: Option<ActorAddress>,
|
|
}
|
|
|
|
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<DataPath, RuntimeSource>,
|
|
streams: BTreeMap<DataPath, RuntimeStream>,
|
|
blob_reservations: BTreeMap<DataPath, OperationId>,
|
|
pending_retirements: HashSet<ActorAddress>,
|
|
pending_stream_displacements: HashMap<ActorAddress, PendingStreamDisplacement>,
|
|
stream_replacement_fences: BTreeMap<DataPath, StreamReplacementFence>,
|
|
authority_epoch: u64,
|
|
retire_retry: Option<RetirementRetry>,
|
|
retirement_watches: HashMap<ActorAddress, HostRouteWatch>,
|
|
retirement_retry_queue: VecDeque<ActorAddress>,
|
|
retirement_timer: Option<ActorTimer>,
|
|
#[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<Arc<dyn HostRouteRegistrar>>,
|
|
}
|
|
|
|
impl RetirementRetry {
|
|
pub fn new(
|
|
engine: EngineHandle,
|
|
sender: ExternalSender,
|
|
period: Duration,
|
|
routes: Option<Arc<dyn HostRouteRegistrar>>,
|
|
) -> Self {
|
|
Self {
|
|
engine,
|
|
sender,
|
|
period,
|
|
routes,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DataDirectoryActor {
|
|
pub fn recover(
|
|
store_path: impl AsRef<Path>,
|
|
retire_retry: Option<RetirementRetry>,
|
|
mut recover_source: impl FnMut(
|
|
&SourceRecovery,
|
|
u64,
|
|
) -> Result<(ActorAddress, [u8; 32]), NamespaceError>,
|
|
) -> Result<Self, NamespaceError> {
|
|
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<Result<MutationReceipt, NamespaceError>> {
|
|
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<ActorAddress>,
|
|
) -> Result<MutationReceipt, NamespaceError> {
|
|
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<StreamMatch, NamespaceError>,
|
|
) {
|
|
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<MutationReceipt, NamespaceError> {
|
|
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<BlobBinding, NamespaceError> {
|
|
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<NamespaceNode, NamespaceError> {
|
|
if self.blob_reservations.contains_key(path) {
|
|
return Err(NamespaceError::PathExists(path.clone()));
|
|
}
|
|
let snapshot = self.store.snapshot();
|
|
match (snapshot.bindings.get(path), snapshot.stream_nodes.get(path)) {
|
|
(Some(binding), None) => Ok(NamespaceNode {
|
|
kind: EntryKind::Blob,
|
|
revision: binding.revision,
|
|
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<ActorAddress>,
|
|
) -> Result<MutationReceipt, NamespaceError> {
|
|
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<MutationReceipt, NamespaceError> {
|
|
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<ActorAddress>;
|
|
/// 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<dyn NamespaceDiscovery>,
|
|
retry_period: Duration,
|
|
request_deadline: Duration,
|
|
next_request_id: u64,
|
|
pending: HashMap<DirectoryRequestId, PendingRequest>,
|
|
}
|
|
|
|
impl NamespaceClientActor {
|
|
pub fn new(
|
|
engine: EngineHandle,
|
|
sender: ExternalSender,
|
|
discovery: Arc<dyn NamespaceDiscovery>,
|
|
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<dyn NamespaceDiscovery>,
|
|
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<DirectoryRequestId> = 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<T>() -> Result<T, NamespaceError> {
|
|
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<DataDirectoryOut, NamespaceError> {
|
|
let inbox = self
|
|
.runtime
|
|
.new_inbox::<DataDirectoryOut>()
|
|
.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<MutationReceipt, NamespaceError> {
|
|
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<BlobBinding, NamespaceError> {
|
|
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<NamespaceNode, NamespaceError> {
|
|
match self.request(NamespaceRequest::Lookup { path }).await? {
|
|
DataDirectoryOut::LookedUp { result, .. } => result,
|
|
other => Err(NamespaceError::Protocol(format!(
|
|
"expected lookup reply, received {other:?}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
pub async fn unregister(
|
|
&self,
|
|
path: DataPath,
|
|
operation_id: OperationId,
|
|
) -> Result<MutationReceipt, NamespaceError> {
|
|
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<MutationReceipt, NamespaceError> {
|
|
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<StreamMatch, NamespaceError> {
|
|
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<StreamMatch, NamespaceError> {
|
|
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<StreamMatch, NamespaceError> {
|
|
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<AtomicU64>,
|
|
}
|
|
|
|
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<NamespaceClientIn>,
|
|
) -> Result<DataDirectoryOut, NamespaceError> {
|
|
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<MutationReceipt, NamespaceError> {
|
|
let inbox = self
|
|
.runtime
|
|
.new_inbox::<NamespaceClientIn>()
|
|
.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<BlobBinding, NamespaceError> {
|
|
let inbox = self
|
|
.runtime
|
|
.new_inbox::<NamespaceClientIn>()
|
|
.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<NamespaceNode, NamespaceError> {
|
|
let inbox = self
|
|
.runtime
|
|
.new_inbox::<NamespaceClientIn>()
|
|
.map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?;
|
|
self.runtime
|
|
.send_to(
|
|
self.directory,
|
|
DataDirectoryIn::Lookup {
|
|
request_id: self.request_id(),
|
|
path,
|
|
reply_to: *inbox.addr(),
|
|
},
|
|
)
|
|
.map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?;
|
|
match self.receive(&inbox).await? {
|
|
DataDirectoryOut::LookedUp { result, .. } => result,
|
|
other => Err(NamespaceError::Protocol(format!(
|
|
"expected lookup reply, received {other:?}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
pub async fn unregister(
|
|
&self,
|
|
path: DataPath,
|
|
operation_id: OperationId,
|
|
) -> Result<MutationReceipt, NamespaceError> {
|
|
let inbox = self
|
|
.runtime
|
|
.new_inbox::<NamespaceClientIn>()
|
|
.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<MutationReceipt, NamespaceError> {
|
|
let inbox = self
|
|
.runtime
|
|
.new_inbox::<NamespaceClientIn>()
|
|
.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::<NamespaceClientIn>()
|
|
.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::<NamespaceClientIn>()
|
|
.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<StreamMatch, NamespaceError> {
|
|
let inbox = self
|
|
.runtime
|
|
.new_inbox::<NamespaceClientIn>()
|
|
.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<StreamMatch, NamespaceError> {
|
|
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<StreamMatch, NamespaceError> {
|
|
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::<NamespaceClientIn>()
|
|
.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::<DataDirectoryIn, _>(JsonCodec::default())
|
|
.expect("unique codec registration");
|
|
registry
|
|
.register::<NamespaceClientIn, _>(JsonCodec::default())
|
|
.expect("unique codec registration");
|
|
}
|