use std::collections::{HashSet, VecDeque};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use arc_swap::ArcSwap;
use tonic::service::interceptor::InterceptedService;
use tonic::transport::Channel;
use tracing::{debug, instrument, warn};
use crate::auth::{ChannelAuthenticator, ChannelIdInterceptor, SaslStreamGuard};
use crate::client::master_inquire::{create_master_inquire_client, MasterInquireClient};
use crate::config::GoosefsConfig;
use crate::error::{Error, Result};
use crate::fs::options::DeleteOptions;
use crate::metrics::registry::Counter;
use crate::proto::grpc::file::{
file_system_master_client_service_client::FileSystemMasterClientServiceClient,
CommitLocationPOptions, CommitLocationPRequest, CompleteFilePOptions, CompleteFilePRequest,
CreateDirectoryPOptions, CreateDirectoryPRequest, CreateFilePOptions, CreateFilePRequest,
DeletePOptions, DeletePRequest, FileInfo, FileSystemMasterCommonPOptions, FsOpPId,
GetStatusPOptions, GetStatusPRequest, ListStatusPOptions, ListStatusPRequest,
LoadMetadataPType, RemoveBlocksPRequest, RenamePOptions, RenamePRequest,
ScheduleAsyncPersistencePOptions, ScheduleAsyncPersistencePRequest,
};
use crate::proto::grpc::{Bits, PMode};
use crate::proto::proto::shared::FileLocation;
const MAX_RPC_RETRIES: u32 = 2;
type AuthenticatedFsClient =
FileSystemMasterClientServiceClient<InterceptedService<Channel, ChannelIdInterceptor>>;
struct AuthedState {
client: AuthenticatedFsClient,
_sasl_guard: Option<SaslStreamGuard>,
}
pub fn default_dir_mode() -> PMode {
PMode {
owner_bits: Bits::All as i32, group_bits: Bits::ReadExecute as i32, other_bits: Bits::ReadExecute as i32, }
}
pub fn default_file_mode() -> PMode {
PMode {
owner_bits: Bits::ReadWrite as i32, group_bits: Bits::Read as i32, other_bits: Bits::Read as i32, }
}
#[derive(Debug, Default, Clone)]
pub struct CompleteFileOptions {
pub ufs_length: Option<i64>,
pub operation_id: Option<FsOpPId>,
pub locations: Vec<FileLocation>,
pub async_persist_options: Option<ScheduleAsyncPersistencePOptions>,
pub force_persisted: Option<bool>,
}
fn list_status_path_key(path: &str) -> &str {
if path.len() > 1 {
path.strip_suffix('/').unwrap_or(path)
} else {
path
}
}
fn list_status_bfs_child_dir<'a>(
cur: &str,
child_path: Option<&'a str>,
is_folder: bool,
) -> Option<&'a str> {
if !is_folder {
return None;
}
let child = child_path.filter(|p| !p.is_empty())?;
if list_status_path_key(child) == list_status_path_key(cur) {
return None;
}
Some(child)
}
pub(crate) fn new_fs_op_id() -> FsOpPId {
let (high, low) = uuid::Uuid::new_v4().as_u64_pair();
FsOpPId {
most_significant_bits: Some(high as i64),
least_significant_bits: Some(low as i64),
}
}
pub(crate) fn common_p_options(
sync_interval_ms: i64,
operation_id: Option<FsOpPId>,
) -> FileSystemMasterCommonPOptions {
FileSystemMasterCommonPOptions {
sync_interval_ms: Some(sync_interval_ms),
operation_id,
}
}
fn write_common_p_options(sync_interval_ms: i64) -> FileSystemMasterCommonPOptions {
common_p_options(sync_interval_ms, Some(new_fs_op_id()))
}
pub(crate) fn rename_p_options(sync_interval_ms: i64, persist: bool) -> RenamePOptions {
RenamePOptions {
common_options: Some(write_common_p_options(sync_interval_ms)),
persist: Some(persist),
}
}
fn fill_write_common_options(
slot: &mut Option<FileSystemMasterCommonPOptions>,
sync_interval_ms: i64,
) {
match slot {
None => *slot = Some(write_common_p_options(sync_interval_ms)),
Some(existing) => {
if existing.sync_interval_ms.is_none() {
existing.sync_interval_ms = Some(sync_interval_ms);
}
if existing.operation_id.is_none() {
existing.operation_id = Some(new_fs_op_id());
}
}
}
}
pub(crate) fn get_status_p_options(
load_metadata_type: Option<LoadMetadataPType>,
sync_interval_ms: Option<i64>,
) -> GetStatusPOptions {
GetStatusPOptions {
load_metadata_type: load_metadata_type.map(|t| t as i32),
common_options: sync_interval_ms.map(|ms| common_p_options(ms, None)),
..Default::default()
}
}
pub(crate) fn list_status_p_options(
load_metadata_type: Option<LoadMetadataPType>,
sync_interval_ms: Option<i64>,
) -> ListStatusPOptions {
ListStatusPOptions {
load_metadata_type: load_metadata_type.map(|t| t as i32),
common_options: sync_interval_ms.map(|ms| common_p_options(ms, None)),
..Default::default()
}
}
#[derive(Clone)]
pub struct MasterClient {
state: Arc<ArcSwap<AuthedState>>,
config: GoosefsConfig,
inquire_client: Arc<dyn MasterInquireClient>,
inflight: Arc<AtomicUsize>,
counter_get_status_ops: Arc<Counter>,
counter_get_status_latency_us: Arc<Counter>,
counter_list_status_ops: Arc<Counter>,
counter_list_status_latency_us: Arc<Counter>,
counter_create_file_ops: Arc<Counter>,
counter_create_dir_ops: Arc<Counter>,
counter_delete_ops: Arc<Counter>,
counter_rename_ops: Arc<Counter>,
counter_rpc_errors_total: Arc<Counter>,
counter_rpc_auth_errors: Arc<Counter>,
counter_rpc_unavailable_errors: Arc<Counter>,
}
impl MasterClient {
pub async fn connect(config: &GoosefsConfig) -> Result<Self> {
let inquire_client = create_master_inquire_client(config);
Self::connect_with_inquire(config, inquire_client).await
}
pub async fn connect_with_inquire(
config: &GoosefsConfig,
inquire_client: Arc<dyn MasterInquireClient>,
) -> Result<Self> {
let primary_addr = inquire_client.get_primary_rpc_address().await?;
let (client, sasl_guard) = Self::build_authenticated_client(config, &primary_addr).await?;
debug!(addr = %primary_addr, auth_type = %config.auth_type, "connected to Goosefs Master");
Ok(Self::from_parts(
AuthedState {
client,
_sasl_guard: sasl_guard,
},
config.clone(),
inquire_client,
))
}
fn from_parts(
state: AuthedState,
config: GoosefsConfig,
inquire_client: Arc<dyn MasterInquireClient>,
) -> Self {
Self {
state: Arc::new(ArcSwap::from_pointee(state)),
config,
inquire_client,
inflight: Arc::new(AtomicUsize::new(0)),
counter_get_status_ops: crate::metrics::counter(
crate::metrics::name::CLIENT_GET_STATUS_OPS,
),
counter_get_status_latency_us: crate::metrics::counter(
crate::metrics::name::CLIENT_GET_STATUS_LATENCY_US,
),
counter_list_status_ops: crate::metrics::counter(
crate::metrics::name::CLIENT_LIST_STATUS_OPS,
),
counter_list_status_latency_us: crate::metrics::counter(
crate::metrics::name::CLIENT_LIST_STATUS_LATENCY_US,
),
counter_create_file_ops: crate::metrics::counter(
crate::metrics::name::CLIENT_CREATE_FILE_OPS,
),
counter_create_dir_ops: crate::metrics::counter(
crate::metrics::name::CLIENT_CREATE_DIR_OPS,
),
counter_delete_ops: crate::metrics::counter(crate::metrics::name::CLIENT_DELETE_OPS),
counter_rename_ops: crate::metrics::counter(crate::metrics::name::CLIENT_RENAME_OPS),
counter_rpc_errors_total: crate::metrics::counter(
crate::metrics::name::CLIENT_RPC_ERRORS_TOTAL,
),
counter_rpc_auth_errors: crate::metrics::counter(
crate::metrics::name::CLIENT_RPC_AUTH_ERRORS,
),
counter_rpc_unavailable_errors: crate::metrics::counter(
crate::metrics::name::CLIENT_RPC_UNAVAILABLE_ERRORS,
),
}
}
pub fn from_channel(channel: Channel, config: GoosefsConfig) -> Self {
let inquire_client = create_master_inquire_client(&config);
let interceptor = ChannelIdInterceptor::new("test-no-auth".to_string());
let intercepted = InterceptedService::new(channel, interceptor);
Self::from_parts(
AuthedState {
client: FileSystemMasterClientServiceClient::new(intercepted),
_sasl_guard: None,
},
config,
inquire_client,
)
}
async fn build_authenticated_client(
config: &GoosefsConfig,
addr: &str,
) -> Result<(AuthenticatedFsClient, Option<SaslStreamGuard>)> {
let channel = Self::build_raw_channel(config, addr).await?;
let authenticator = ChannelAuthenticator::new(
config.auth_type,
config.auth_username.clone(),
None, )
.with_auth_timeout(config.auth_timeout);
let mut auth_channel = authenticator.authenticate(channel).await?;
let sasl_guard = auth_channel.take_sasl_guard();
Ok((
FileSystemMasterClientServiceClient::new(auth_channel.channel),
sasl_guard,
))
}
async fn build_raw_channel(config: &GoosefsConfig, addr: &str) -> Result<Channel> {
let endpoint_uri = format!("http://{}", addr);
let endpoint = Channel::from_shared(endpoint_uri)
.map_err(|e| Error::ConfigError {
message: format!("invalid master endpoint: {}", e),
})?
.connect_timeout(config.connect_timeout)
.timeout(config.request_timeout);
let channel = endpoint.connect().await?;
Ok(channel)
}
async fn reconnect(&self) -> Result<()> {
self.inquire_client.reset_cached_primary().await;
let primary_addr = self.inquire_client.get_primary_rpc_address().await?;
let (client, sasl_guard) =
Self::build_authenticated_client(&self.config, &primary_addr).await?;
self.state.store(Arc::new(AuthedState {
client,
_sasl_guard: sasl_guard,
}));
debug!(addr = %primary_addr, "reconnected to Goosefs Master after failover");
Ok(())
}
async fn with_retry<F, Fut, T>(&self, op_name: &str, mut f: F) -> Result<T>
where
F: FnMut(AuthenticatedFsClient) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let mut last_err: Option<Error> = None;
for attempt in 0..=MAX_RPC_RETRIES {
if attempt > 0 {
if let Err(reconnect_err) = self.reconnect().await {
warn!(
op = op_name,
attempt = attempt + 1,
error = %reconnect_err,
"reconnect failed; will retry reconnect on next attempt"
);
last_err = Some(Error::Internal {
message: format!("master reconnect failed: {}", reconnect_err),
source: None,
});
continue;
}
}
let client: AuthenticatedFsClient = self.state.load().client.clone();
self.inflight.fetch_add(1, Ordering::Relaxed);
let _inflight_guard = InflightGuard(&self.inflight);
match f(client).await {
Ok(result) => return Ok(result),
Err(err) => {
self.counter_rpc_errors_total.inc(1);
if err.is_authentication_error() {
self.counter_rpc_auth_errors.inc(1);
} else if err.is_unavailable() {
self.counter_rpc_unavailable_errors.inc(1);
}
if err.is_retriable() && attempt < MAX_RPC_RETRIES {
warn!(
op = op_name,
attempt = attempt + 1,
max = MAX_RPC_RETRIES,
error = %err,
"retriable error; will reconnect and retry"
);
last_err = Some(err);
} else {
return Err(err);
}
}
}
}
Err(last_err.unwrap_or_else(|| Error::Internal {
message: format!("{}: exhausted all retries", op_name),
source: None,
}))
}
#[instrument(skip(self), fields(path = %path))]
pub async fn get_status(&self, path: &str) -> Result<FileInfo> {
self.get_status_with_load_type(
path,
Some(self.config.file_metadata_load_type),
Some(self.config.file_metadata_sync_interval),
)
.await
}
#[instrument(skip(self), fields(path = %path, ?load_metadata_type, ?sync_interval_ms))]
pub async fn get_status_with_load_type(
&self,
path: &str,
load_metadata_type: Option<LoadMetadataPType>,
sync_interval_ms: Option<i64>,
) -> Result<FileInfo> {
let start = std::time::Instant::now();
let options = get_status_p_options(load_metadata_type, sync_interval_ms);
let mut path_owned: Option<String> = Some(path.to_string());
let result = self
.with_retry("get_status", |mut client| {
let req_path = path_owned.take().unwrap_or_else(|| path.to_string());
let options = options.clone();
async move {
let req = GetStatusPRequest {
path: Some(req_path),
options: Some(options),
request_id: None,
};
client
.get_status(req)
.await?
.into_inner()
.file_info
.ok_or_else(|| Error::missing_field("file_info"))
}
})
.await;
self.counter_get_status_ops.inc(1);
self.counter_get_status_latency_us
.inc(start.elapsed().as_micros() as i64);
result
}
#[instrument(skip(self), fields(path = %path, recursive))]
pub async fn list_status(&self, path: &str, recursive: bool) -> Result<Vec<FileInfo>> {
self.list_status_with_load_type(path, recursive, None).await
}
#[instrument(skip(self), fields(path = %path, recursive, ?load_metadata_type))]
pub async fn list_status_with_load_type(
&self,
path: &str,
recursive: bool,
load_metadata_type: Option<LoadMetadataPType>,
) -> Result<Vec<FileInfo>> {
self.list_status_with_options(
path,
recursive,
load_metadata_type,
Some(self.config.file_metadata_sync_interval),
)
.await
}
#[instrument(
skip(self),
fields(path = %path, recursive, ?load_metadata_type, ?sync_interval_ms)
)]
pub async fn list_status_with_options(
&self,
path: &str,
recursive: bool,
load_metadata_type: Option<LoadMetadataPType>,
sync_interval_ms: Option<i64>,
) -> Result<Vec<FileInfo>> {
let load = load_metadata_type.unwrap_or(self.config.file_metadata_load_type);
let options = list_status_p_options(Some(load), sync_interval_ms);
if !recursive {
return self.list_status_one_level(path, options).await;
}
let mut out: Vec<FileInfo> = Vec::new();
let mut queue: VecDeque<String> = VecDeque::new();
let mut visited: HashSet<String> = HashSet::new();
let start = list_status_path_key(path).to_string();
visited.insert(start.clone());
queue.push_back(start);
while let Some(cur) = queue.pop_front() {
let items = self.list_status_one_level(&cur, options.clone()).await?;
for fi in items {
if let Some(child) =
list_status_bfs_child_dir(&cur, fi.path.as_deref(), fi.folder.unwrap_or(false))
{
let key = list_status_path_key(child).to_string();
if visited.insert(key.clone()) {
queue.push_back(key);
}
}
out.push(fi);
}
}
Ok(out)
}
async fn list_status_one_level(
&self,
path: &str,
options: ListStatusPOptions,
) -> Result<Vec<FileInfo>> {
let start = std::time::Instant::now();
let path = path.to_string();
let result = self
.with_retry("list_status", |mut client| {
let path = path.clone();
let options = options.clone();
async move {
let req = ListStatusPRequest {
path: Some(path),
options: Some(options),
request_id: None,
};
let mut stream = client.list_status(req).await?.into_inner();
let mut result = Vec::new();
while let Some(resp) = stream.message().await? {
result.extend(resp.file_infos);
}
Ok(result)
}
})
.await;
self.counter_list_status_ops.inc(1);
self.counter_list_status_latency_us
.inc(start.elapsed().as_micros() as i64);
result
}
#[instrument(skip(self, options), fields(path = %path))]
pub async fn create_file(
&self,
path: &str,
mut options: CreateFilePOptions,
) -> Result<FileInfo> {
fill_write_common_options(
&mut options.common_options,
self.config.file_metadata_sync_interval,
);
let path = path.to_string();
let result = self
.with_retry("create_file", |mut client| {
let path = path.clone();
let options = options.clone();
async move {
let req = CreateFilePRequest {
path: Some(path),
options: Some(options),
};
let resp = client.create_file(req).await?;
resp.into_inner()
.file_info
.ok_or_else(|| Error::missing_field("file_info"))
}
})
.await;
self.counter_create_file_ops.inc(1);
result
}
#[instrument(skip(self), fields(path = %path))]
pub async fn complete_file(
&self,
path: &str,
ufs_length: Option<i64>,
operation_id: Option<FsOpPId>,
) -> Result<()> {
self.complete_file_with_options(
path,
CompleteFileOptions {
ufs_length,
operation_id,
..Default::default()
},
)
.await
}
#[instrument(
skip(self, opts),
fields(path = %path, location_count = opts.locations.len())
)]
pub async fn complete_file_with_options(
&self,
path: &str,
opts: CompleteFileOptions,
) -> Result<()> {
let path = path.to_string();
let sync_interval_ms = self.config.file_metadata_sync_interval;
self.with_retry("complete_file", |mut client| {
let path = path.clone();
let opts = opts.clone();
async move {
let common_options = Some(common_p_options(sync_interval_ms, opts.operation_id));
let req = CompleteFilePRequest {
path: Some(path),
options: Some(CompleteFilePOptions {
ufs_length: opts.ufs_length,
common_options,
locations: opts.locations,
async_persist_options: opts.async_persist_options,
force_persisted: opts.force_persisted,
..Default::default()
}),
inode_id: None,
};
client.complete_file(req).await?;
Ok(())
}
})
.await
}
#[instrument(skip(self, locations), fields(path = %path, block_id = block_id, location_count = locations.len()))]
pub async fn commit_location(
&self,
path: &str,
inode_id: Option<i64>,
block_id: i64,
locations: Vec<FileLocation>,
) -> Result<()> {
if locations.is_empty() {
return Ok(());
}
let path = path.to_string();
self.with_retry("commit_location", |mut client| {
let path = path.clone();
let locations = locations.clone();
async move {
let req = CommitLocationPRequest {
path: Some(path),
inode_id,
block_id: Some(block_id),
options: Some(CommitLocationPOptions { locations }),
};
client.commit_location(req).await?;
Ok(())
}
})
.await
}
#[instrument(skip(self, block_ids), fields(block_count = block_ids.len()))]
pub async fn remove_blocks(&self, block_ids: Vec<i64>) -> Result<()> {
if block_ids.is_empty() {
return Ok(());
}
let block_ids_clone = block_ids.clone();
self.with_retry("remove_blocks", |mut client| {
let block_ids = block_ids_clone.clone();
async move {
let req = RemoveBlocksPRequest { block_ids };
client.remove_blocks(req).await?;
Ok(())
}
})
.await
}
#[instrument(skip(self, opts), fields(path = %path))]
pub async fn delete_with_options(&self, path: &str, opts: DeleteOptions) -> Result<()> {
let path = path.to_string();
let common_options = Some(write_common_p_options(
self.config.file_metadata_sync_interval,
));
self.with_retry("delete_with_options", |mut client| {
let path = path.clone();
let opts = opts.clone();
async move {
let req = DeletePRequest {
path: Some(path),
options: Some(DeletePOptions {
recursive: Some(opts.recursive),
unchecked: Some(opts.unchecked),
goosefs_only: Some(opts.goosefs_only),
common_options,
..Default::default()
}),
};
client.remove(req).await?;
Ok(())
}
})
.await
}
#[instrument(skip(self), fields(path = %path, recursive = %recursive))]
pub async fn delete(&self, path: &str, recursive: bool) -> Result<()> {
let result = self
.delete_with_options(
path,
DeleteOptions {
recursive,
..Default::default()
},
)
.await;
self.counter_delete_ops.inc(1);
result
}
#[instrument(skip(self), fields(src = %src, dst = %dst))]
pub async fn rename(&self, src: &str, dst: &str) -> Result<()> {
let src = src.to_string();
let dst = dst.to_string();
let options = rename_p_options(
self.config.file_metadata_sync_interval,
self.config.file_persist_on_rename,
);
let result = self
.with_retry("rename", |mut client| {
let src = src.clone();
let dst = dst.clone();
async move {
let req = RenamePRequest {
path: Some(src),
dst_path: Some(dst),
options: Some(options),
};
client.rename(req).await?;
Ok(())
}
})
.await;
self.counter_rename_ops.inc(1);
result
}
#[instrument(skip(self), fields(path = %path))]
pub async fn create_directory(&self, path: &str, recursive: bool) -> Result<()> {
let path = path.to_string();
let common_options = Some(write_common_p_options(
self.config.file_metadata_sync_interval,
));
let result = self
.with_retry("create_directory", |mut client| {
let path = path.clone();
async move {
let req = CreateDirectoryPRequest {
path: Some(path),
options: Some(CreateDirectoryPOptions {
recursive: Some(recursive),
allow_exists: Some(true),
mode: Some(default_dir_mode()),
common_options,
..Default::default()
}),
};
client.create_directory(req).await?;
Ok(())
}
})
.await;
self.counter_create_dir_ops.inc(1);
result
}
#[instrument(skip(self), fields(path = %path))]
pub async fn schedule_async_persistence(
&self,
path: &str,
persistence_wait_time: Option<i64>,
) -> Result<()> {
let path = path.to_string();
let common_options = Some(common_p_options(
self.config.file_metadata_sync_interval,
None,
));
self.with_retry("schedule_async_persistence", |mut client| {
let path = path.clone();
async move {
let req = ScheduleAsyncPersistencePRequest {
path: Some(path),
options: Some(ScheduleAsyncPersistencePOptions {
common_options,
persistence_wait_time,
}),
};
client.schedule_async_persistence(req).await?;
Ok(())
}
})
.await
}
pub fn config(&self) -> &GoosefsConfig {
&self.config
}
pub fn inquire_client(&self) -> &Arc<dyn MasterInquireClient> {
&self.inquire_client
}
}
struct InflightGuard<'a>(&'a AtomicUsize);
impl Drop for InflightGuard<'_> {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
pub struct MasterClientPool {
clients: Vec<Arc<MasterClient>>,
schedule: crate::config::MasterPoolSchedule,
rr: AtomicUsize,
}
impl MasterClientPool {
pub async fn connect_with_inquire(
config: &GoosefsConfig,
inquire_client: Arc<dyn MasterInquireClient>,
) -> Result<Self> {
let size = config.master_connection_pool_size.max(1);
let clients: Vec<Arc<MasterClient>> = futures::future::try_join_all((0..size).map(|_| {
let config = config.clone();
let inquire = inquire_client.clone();
async move {
MasterClient::connect_with_inquire(&config, inquire)
.await
.map(Arc::new)
}
}))
.await?;
debug!(pool_size = size, "MasterClientPool connected");
Ok(Self {
clients,
schedule: config.master_connection_pool_schedule,
rr: AtomicUsize::new(0),
})
}
pub fn pick(&self) -> Arc<MasterClient> {
let n = self.clients.len();
if n == 1 {
return self.clients[0].clone();
}
match self.schedule {
crate::config::MasterPoolSchedule::RoundRobin => {
let idx = self.rr.fetch_add(1, Ordering::Relaxed) % n;
self.clients[idx].clone()
}
crate::config::MasterPoolSchedule::P2C => {
let a = fastrand::usize(0..n);
let b = loop {
let b = fastrand::usize(0..n);
if b != a {
break b;
}
};
let la = self.clients[a].inflight.load(Ordering::Relaxed);
let lb = self.clients[b].inflight.load(Ordering::Relaxed);
let idx = if la <= lb { a } else { b };
debug!(
a_idx = a,
b_idx = b,
a_inflight = la,
b_inflight = lb,
picked = idx,
"P2C pick"
);
self.clients[idx].clone()
}
}
}
pub fn size(&self) -> usize {
self.clients.len()
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::{Duration, Instant};
use arc_swap::ArcSwap;
#[test]
fn list_status_bfs_skips_non_folders_and_empty_paths() {
assert_eq!(
super::list_status_bfs_child_dir("/a", Some("/a/b.bin"), false),
None
);
assert_eq!(super::list_status_bfs_child_dir("/a", Some(""), true), None);
assert_eq!(super::list_status_bfs_child_dir("/a", None, true), None);
}
#[test]
fn list_status_bfs_skips_self_entry_to_avoid_infinite_loop() {
assert_eq!(
super::list_status_bfs_child_dir("/data", Some("/data"), true),
None,
"re-queueing the listed directory would loop forever"
);
assert_eq!(
super::list_status_bfs_child_dir("/data", Some("/data/nested"), true),
Some("/data/nested")
);
}
#[test]
fn list_status_path_key_strips_trailing_slash_except_root() {
assert_eq!(super::list_status_path_key("/"), "/");
assert_eq!(super::list_status_path_key(""), "");
assert_eq!(super::list_status_path_key("/data"), "/data");
assert_eq!(super::list_status_path_key("/data/"), "/data");
assert_eq!(super::list_status_path_key("/data/nested/"), "/data/nested");
}
#[test]
fn list_status_bfs_treats_trailing_slash_as_same_node() {
assert_eq!(
super::list_status_bfs_child_dir("/data", Some("/data/"), true),
None
);
assert_eq!(
super::list_status_bfs_child_dir("/data/", Some("/data"), true),
None
);
assert_eq!(
super::list_status_bfs_child_dir("/data/", Some("/data/nested/"), true),
Some("/data/nested/")
);
}
struct AuthedStateLike {
epoch: u64,
guard_epoch: Arc<u64>,
drop_counter: Arc<AtomicUsize>,
}
impl Drop for AuthedStateLike {
fn drop(&mut self) {
self.drop_counter.fetch_add(1, Ordering::SeqCst);
}
}
fn new_state(epoch: u64, drop_counter: Arc<AtomicUsize>) -> Arc<AuthedStateLike> {
Arc::new(AuthedStateLike {
epoch,
guard_epoch: Arc::new(epoch),
drop_counter,
})
}
#[test]
fn arcswap_publication_is_atomic_under_concurrent_readers() {
const READERS: usize = 32;
const RECONNECT_ROUNDS: usize = 200;
let drop_counter = Arc::new(AtomicUsize::new(0));
let state = Arc::new(ArcSwap::from(new_state(0, drop_counter.clone())));
let stop = Arc::new(AtomicBool::new(false));
let ready = Arc::new(Barrier::new(READERS + 1));
let mut readers = Vec::with_capacity(READERS);
for _ in 0..READERS {
let state = state.clone();
let stop = stop.clone();
let ready = ready.clone();
readers.push(thread::spawn(move || {
ready.wait();
let mut observed_epochs: Vec<u64> = Vec::new();
while !stop.load(Ordering::Relaxed) {
let snap = state.load();
assert_eq!(
snap.epoch, *snap.guard_epoch,
"torn read: ArcSwap published a half-swapped snapshot",
);
observed_epochs.push(snap.epoch);
}
let snap = state.load();
assert_eq!(snap.epoch, *snap.guard_epoch);
observed_epochs.push(snap.epoch);
observed_epochs
}));
}
ready.wait();
for round in 1..=RECONNECT_ROUNDS {
state.store(new_state(round as u64, drop_counter.clone()));
thread::sleep(Duration::from_micros(50));
}
stop.store(true, Ordering::Relaxed);
for r in readers {
let observed = r.join().expect("reader thread panicked");
assert!(!observed.is_empty(), "reader observed nothing");
let max = observed.iter().copied().max().unwrap();
assert!(
max >= 1,
"reader never saw a reconnect-published epoch (max={})",
max,
);
}
}
#[test]
fn old_snapshot_outlives_concurrent_swap_until_reader_releases() {
let drop_counter = Arc::new(AtomicUsize::new(0));
let state = Arc::new(ArcSwap::from(new_state(1, drop_counter.clone())));
let held = state.load_full();
assert_eq!(held.epoch, 1);
for round in 2..=50 {
state.store(new_state(round, drop_counter.clone()));
}
let observed_drops = drop_counter.load(Ordering::SeqCst);
assert!(
observed_drops <= 48,
"old snapshot was dropped while a reader still held it: \
drops = {} (expected <= 48)",
observed_drops,
);
assert_eq!(held.epoch, 1, "held snapshot was mutated in place");
drop(held);
state.store(new_state(999, drop_counter.clone()));
let deadline = Instant::now() + Duration::from_secs(2);
loop {
if drop_counter.load(Ordering::SeqCst) >= 50 {
break;
}
if Instant::now() > deadline {
panic!(
"expected >= 50 drops after releasing the held snapshot, \
observed {}",
drop_counter.load(Ordering::SeqCst),
);
}
thread::sleep(Duration::from_millis(5));
}
}
use super::{InflightGuard, MasterClient, MasterClientPool};
use crate::config::GoosefsConfig;
use std::panic::AssertUnwindSafe;
fn make_test_master_client() -> MasterClient {
let endpoint = tonic::transport::Endpoint::from_static("http://localhost:0").connect_lazy();
MasterClient::from_channel(endpoint, GoosefsConfig::new("localhost:0"))
}
fn make_test_pool(n: usize) -> MasterClientPool {
let clients: Vec<Arc<MasterClient>> = (0..n)
.map(|_| Arc::new(make_test_master_client()))
.collect();
MasterClientPool {
clients,
schedule: crate::config::MasterPoolSchedule::P2C,
rr: AtomicUsize::new(0),
}
}
#[tokio::test]
async fn pick_chooses_lighter_candidate() {
let pool = make_test_pool(2);
pool.clients[0].inflight.store(10, Ordering::Relaxed);
for _ in 0..200 {
let picked = pool.pick();
assert!(
Arc::ptr_eq(&picked, &pool.clients[1]),
"pick() selected the heavier channel"
);
}
}
#[tokio::test]
async fn pick_samples_two_distinct_candidates() {
let pool = make_test_pool(2);
let mut saw_0 = false;
let mut saw_1 = false;
for _ in 0..1000 {
let picked = pool.pick();
if Arc::ptr_eq(&picked, &pool.clients[0]) {
saw_0 = true;
} else if Arc::ptr_eq(&picked, &pool.clients[1]) {
saw_1 = true;
} else {
panic!("pick() returned a client not in the pool");
}
}
assert!(
saw_0 && saw_1,
"pick() never sampled one of the two channels"
);
}
#[tokio::test]
async fn pick_balances_equal_loads() {
let n = 4;
let pool = make_test_pool(n);
let mut hits = vec![0usize; n];
for _ in 0..4000 {
let picked = pool.pick();
for (i, c) in pool.clients.iter().enumerate() {
if Arc::ptr_eq(&picked, c) {
hits[i] += 1;
break;
}
}
}
for (i, &h) in hits.iter().enumerate() {
assert!(h > 0, "channel {} was starved by pick()", i);
}
}
#[test]
fn inflight_counter_decrements_on_normal_exit() {
let counter = AtomicUsize::new(0);
{
counter.fetch_add(1, Ordering::Relaxed);
let _guard = InflightGuard(&counter);
assert_eq!(
counter.load(Ordering::Relaxed),
1,
"counter must be 1 while guard alive"
);
}
assert_eq!(
counter.load(Ordering::Relaxed),
0,
"counter must return to 0 after guard drops"
);
}
#[test]
fn inflight_counter_decrements_on_early_drop() {
let counter = AtomicUsize::new(0);
counter.fetch_add(1, Ordering::Relaxed);
let guard = InflightGuard(&counter);
assert_eq!(counter.load(Ordering::Relaxed), 1);
drop(guard); assert_eq!(
counter.load(Ordering::Relaxed),
0,
"counter must be 0 after early drop"
);
}
#[test]
fn inflight_guard_decrements_on_panic() {
let counter = Arc::new(AtomicUsize::new(0));
let counter_for_unwind = counter.clone();
let result = std::panic::catch_unwind(AssertUnwindSafe(move || {
counter_for_unwind.fetch_add(1, Ordering::Relaxed);
let _guard = InflightGuard(&counter_for_unwind);
assert_eq!(
counter_for_unwind.load(Ordering::Relaxed),
1,
"counter must be 1 while guard alive"
);
panic!("simulated panic mid-RPC");
}));
assert!(result.is_err(), "test should have panicked");
assert_eq!(
counter.load(Ordering::Relaxed),
0,
"guard must decrement on panic unwind (cancellation safety)"
);
}
#[tokio::test]
async fn master_client_clone_shares_inflight_counter() {
let original = make_test_master_client();
let cloned = original.clone();
cloned.inflight.store(7, Ordering::Relaxed);
assert_eq!(
original.inflight.load(Ordering::Relaxed),
7,
"clone must share the in-flight counter (Arc<AtomicUsize>)"
);
original.inflight.store(3, Ordering::Relaxed);
assert_eq!(
cloned.inflight.load(Ordering::Relaxed),
3,
"mutations via original must be visible to clone"
);
}
#[tokio::test]
async fn pick_returns_arc_sharing_inflight() {
let pool = make_test_pool(2);
let picked = pool.pick();
let cloned = Arc::clone(&picked);
assert!(
Arc::ptr_eq(&picked, &cloned),
"Arc::clone must share the same MasterClient"
);
}
#[test]
fn get_status_p_options_matches_java_defaults() {
use crate::proto::grpc::file::LoadMetadataPType;
let opts = super::get_status_p_options(Some(LoadMetadataPType::Once), Some(-1));
assert_eq!(
opts.load_metadata_type,
Some(LoadMetadataPType::Once as i32)
);
assert_eq!(
opts.common_options
.as_ref()
.and_then(|c| c.sync_interval_ms),
Some(-1)
);
}
#[test]
fn get_status_p_options_unset_is_never_on_the_wire() {
let opts = super::get_status_p_options(None, None);
assert_eq!(
opts.load_metadata_type, None,
"unset load_metadata_type is proto NEVER (0) on the Master"
);
assert!(opts.common_options.is_none());
}
#[test]
fn get_status_uses_config_load_type_and_sync_interval() {
use crate::proto::grpc::file::LoadMetadataPType;
let cfg = GoosefsConfig::new("localhost:0");
assert_eq!(cfg.file_metadata_load_type, LoadMetadataPType::Once);
assert_eq!(cfg.file_metadata_sync_interval, -1);
let opts = super::get_status_p_options(
Some(cfg.file_metadata_load_type),
Some(cfg.file_metadata_sync_interval),
);
assert_eq!(opts.load_metadata_type, Some(1)); assert_eq!(
opts.common_options
.as_ref()
.and_then(|c| c.sync_interval_ms),
Some(-1)
);
}
#[test]
fn list_status_p_options_matches_java_defaults() {
use crate::proto::grpc::file::LoadMetadataPType;
let opts = super::list_status_p_options(Some(LoadMetadataPType::Once), Some(-1));
assert_eq!(
opts.load_metadata_type,
Some(LoadMetadataPType::Once as i32)
);
assert_eq!(
opts.common_options
.as_ref()
.and_then(|c| c.sync_interval_ms),
Some(-1)
);
}
#[test]
fn list_status_p_options_unset_is_never_on_the_wire() {
let opts = super::list_status_p_options(None, None);
assert_eq!(
opts.load_metadata_type, None,
"unset load_metadata_type is proto NEVER (0) on the Master"
);
assert!(opts.common_options.is_none());
}
#[test]
fn list_status_uses_config_load_type_and_sync_interval() {
use crate::proto::grpc::file::LoadMetadataPType;
let cfg = GoosefsConfig::new("localhost:0");
let opts = super::list_status_p_options(
Some(cfg.file_metadata_load_type),
Some(cfg.file_metadata_sync_interval),
);
assert_eq!(
opts.load_metadata_type,
Some(LoadMetadataPType::Once as i32)
);
assert_eq!(
opts.common_options
.as_ref()
.and_then(|c| c.sync_interval_ms),
Some(-1)
);
}
#[test]
fn write_common_p_options_matches_java_mutating_defaults() {
let opts = super::write_common_p_options(-1);
assert_eq!(opts.sync_interval_ms, Some(-1));
let op = opts
.operation_id
.expect("mutating RPCs must send operationId");
assert!(
op.most_significant_bits.is_some() && op.least_significant_bits.is_some(),
"FsOpPId must carry both UUID halves"
);
}
#[test]
fn common_p_options_read_path_has_no_operation_id() {
let opts = super::common_p_options(-1, None);
assert_eq!(opts.sync_interval_ms, Some(-1));
assert!(opts.operation_id.is_none());
}
#[test]
fn fill_write_common_options_preserves_caller_values() {
let caller_id = super::new_fs_op_id();
let mut slot = Some(super::common_p_options(0, Some(caller_id)));
super::fill_write_common_options(&mut slot, -1);
let filled = slot.expect("slot stays Some");
assert_eq!(
filled.sync_interval_ms,
Some(0),
"caller syncIntervalMs must not be overwritten"
);
assert_eq!(filled.operation_id, Some(caller_id));
}
#[test]
fn fill_write_common_options_fills_empty_slot() {
let mut slot = None;
super::fill_write_common_options(&mut slot, -1);
let filled = slot.expect("empty slot is filled");
assert_eq!(filled.sync_interval_ms, Some(-1));
assert!(filled.operation_id.is_some());
}
#[test]
fn rename_p_options_persist_follows_config() {
let cfg = GoosefsConfig::new("localhost:0");
let opts =
super::rename_p_options(cfg.file_metadata_sync_interval, cfg.file_persist_on_rename);
assert_eq!(opts.persist, Some(false));
assert_eq!(
opts.common_options
.as_ref()
.and_then(|c| c.sync_interval_ms),
Some(-1)
);
assert!(opts
.common_options
.as_ref()
.and_then(|c| c.operation_id)
.is_some());
let opts = super::rename_p_options(-1, true);
assert_eq!(opts.persist, Some(true));
}
}