Skip to main content

goosefs_sdk/client/
master.rs

1// Copyright (C) 2026 Tencent. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Goosefs Master gRPC client for file system metadata operations.
16//!
17//! Wraps `FileSystemMasterClientService` (Master:9200) providing:
18//! - `get_status` — stat / head
19//! - `list_status` — list directory (client-side recursion when requested)
20//! - `create_file` — create a new file
21//! - `complete_file` — mark file write complete (with idempotency operation-ID)
22//! - `remove_blocks` — clean up block metadata for in-flight or failed writes
23//! - `delete` / `delete_with_options` — delete file or directory
24//! - `rename` — rename / move
25//! - `create_directory` — mkdir -p
26//!
27//! ## HA / Multi-Master Support
28//!
29//! When multiple Master addresses are configured, [`MasterClient::connect`]
30//! uses [`MasterInquireClient`] to discover the Primary Master before
31//! establishing the gRPC channel. If an RPC fails with a retriable error
32//! (`Unavailable`, `DeadlineExceeded`), the client will re-discover the
33//! Primary and rebuild the channel automatically.
34
35use std::collections::{HashSet, VecDeque};
36use std::sync::atomic::{AtomicUsize, Ordering};
37use std::sync::Arc;
38
39use arc_swap::ArcSwap;
40use tonic::service::interceptor::InterceptedService;
41use tonic::transport::Channel;
42use tracing::{debug, instrument, warn};
43
44use crate::auth::{ChannelAuthenticator, ChannelIdInterceptor, SaslStreamGuard};
45use crate::client::master_inquire::{create_master_inquire_client, MasterInquireClient};
46use crate::config::GoosefsConfig;
47use crate::error::{Error, Result};
48use crate::fs::options::DeleteOptions;
49use crate::metrics::registry::Counter;
50use crate::proto::grpc::file::{
51    file_system_master_client_service_client::FileSystemMasterClientServiceClient,
52    CommitLocationPOptions, CommitLocationPRequest, CompleteFilePOptions, CompleteFilePRequest,
53    CreateDirectoryPOptions, CreateDirectoryPRequest, CreateFilePOptions, CreateFilePRequest,
54    DeletePOptions, DeletePRequest, FileInfo, FileSystemMasterCommonPOptions, FsOpPId,
55    GetStatusPOptions, GetStatusPRequest, ListStatusPOptions, ListStatusPRequest,
56    LoadMetadataPType, RemoveBlocksPRequest, RenamePOptions, RenamePRequest,
57    ScheduleAsyncPersistencePOptions, ScheduleAsyncPersistencePRequest,
58};
59use crate::proto::grpc::{Bits, PMode};
60use crate::proto::proto::shared::FileLocation;
61
62/// Maximum number of RPC-level retries on retriable errors before giving up.
63const MAX_RPC_RETRIES: u32 = 2;
64
65/// Type alias for the authenticated gRPC client.
66///
67/// Both NOSASL and SIMPLE modes use `InterceptedService` wrapping;
68/// the difference is that NOSASL skips the SASL handshake but still injects a channel-id.
69type AuthenticatedFsClient =
70    FileSystemMasterClientServiceClient<InterceptedService<Channel, ChannelIdInterceptor>>;
71
72/// Immutable snapshot of the authenticated channel state.
73///
74/// `client` (which holds the tonic `Channel` + `channel-id` interceptor) and
75/// `sasl_guard` (which keeps the SASL session alive on the Master side)
76/// **must** travel together as a single unit: the Master uses the
77/// `channel-id` injected by the interceptor to look up the SASL session,
78/// so a stale `sasl_guard` paired with a fresh `client` (or vice versa) would
79/// break authentication.
80///
81/// This struct enforces that pairing in the type system: it is never mutated
82/// in place — instead, `MasterClient::reconnect` builds a brand-new
83/// `AuthedState` and atomically swaps the `Arc` via `ArcSwap::store`. The old
84/// `Arc<AuthedState>` is dropped only after the last in-flight reader
85/// releases it, so the old SASL stream cannot be closed while anyone is still
86/// using the old channel.
87///
88///
89/// full consistency rationale.
90struct AuthedState {
91    client: AuthenticatedFsClient,
92    /// Holds the SASL stream alive for the lifetime of `client`. `Option`
93    /// because the test-only `from_channel` constructor and NOSASL mode do
94    /// not need a SASL session.
95    _sasl_guard: Option<SaslStreamGuard>,
96}
97
98/// Default mode for directories: 0755 (rwxr-xr-x)
99pub fn default_dir_mode() -> PMode {
100    PMode {
101        owner_bits: Bits::All as i32,         // rwx
102        group_bits: Bits::ReadExecute as i32, // r-x
103        other_bits: Bits::ReadExecute as i32, // r-x
104    }
105}
106
107/// Default mode for files: 0644 (rw-r--r--)
108pub fn default_file_mode() -> PMode {
109    PMode {
110        owner_bits: Bits::ReadWrite as i32, // rw-
111        group_bits: Bits::Read as i32,      // r--
112        other_bits: Bits::Read as i32,      // r--
113    }
114}
115
116/// Write-path-driven fields of `CompleteFilePOptions`.
117///
118/// # Java authority
119///
120/// Mirrors the builder calls in `GoosefsFileOutStream.close()`. Grouping them
121/// keeps [`MasterClient::complete_file_with_options`] to two arguments as the
122/// option set grows.
123#[derive(Debug, Default, Clone)]
124pub struct CompleteFileOptions {
125    /// Final file length (`setUfsLength`). Java sends this for every write
126    /// type, not just the persisting ones.
127    pub ufs_length: Option<i64>,
128    /// Idempotency token, carried in `commonOptions.operationId`.
129    pub operation_id: Option<FsOpPId>,
130    /// Last block's replica locations. ASYNC_THROUGH only — sending these for
131    /// other write types makes the Master treat the file as persist-scheduled.
132    pub locations: Vec<FileLocation>,
133    /// Ask the Master to schedule an async-persist job.
134    ///
135    /// Mutually exclusive with [`force_persisted`](Self::force_persisted): a
136    /// file that is already on the UFS must not also be queued for persisting.
137    pub async_persist_options: Option<ScheduleAsyncPersistencePOptions>,
138    /// Mark the file as already persisted.
139    ///
140    /// Set when the client degraded to a UFS-only write and that write
141    /// completed, so the bytes are on the UFS before `CompleteFile` runs. The
142    /// Master then fetches the UFS fingerprint and stamps the inode
143    /// `PERSISTED` instead of queueing a persist job.
144    pub force_persisted: Option<bool>,
145    /// Inode id from `CreateFile` (`CompleteFilePRequest.inode_id`).
146    ///
147    /// Java `GooseFSFileOutStream.close()` always sends `mInodeId` so Master
148    /// can lock the inode if the path has already been renamed. `None` falls
149    /// back to path-only lookup.
150    pub inode_id: Option<i64>,
151    /// `CompleteFilePOptions.crc_type`. Java always sets this from
152    /// `DataChecksum.getChecksumType()` (default CRC32C).
153    pub crc_type: Option<i32>,
154    /// `CompleteFilePOptions.crc_value`. Running checksum of every byte
155    /// accepted by `write()`, matching Java `mOptions.getFileChecksum().getValue()`.
156    pub crc_value: Option<i64>,
157}
158
159/// Wire `CompleteFilePRequest` from [`CompleteFileOptions`].
160///
161/// Extracted so unit tests can assert CRC / inode_id reach the proto without
162/// a live Master.
163pub(crate) fn complete_file_request(
164    path: String,
165    opts: CompleteFileOptions,
166    sync_interval_ms: i64,
167) -> CompleteFilePRequest {
168    let common_options = Some(common_p_options(sync_interval_ms, opts.operation_id));
169    CompleteFilePRequest {
170        path: Some(path),
171        options: Some(CompleteFilePOptions {
172            ufs_length: opts.ufs_length,
173            common_options,
174            locations: opts.locations,
175            async_persist_options: opts.async_persist_options,
176            force_persisted: opts.force_persisted,
177            crc_type: opts.crc_type,
178            crc_value: opts.crc_value,
179        }),
180        inode_id: opts.inode_id,
181    }
182}
183
184/// Strip a trailing slash except for the filesystem root (`"/"`).
185///
186/// GooseFS listings generally omit trailing slashes, but treating `/foo` and
187/// `/foo/` as distinct nodes would re-queue the same directory during BFS.
188fn list_status_path_key(path: &str) -> &str {
189    if path.len() > 1 {
190        path.strip_suffix('/').unwrap_or(path)
191    } else {
192        path
193    }
194}
195
196/// Child directory a recursive `list_status` BFS should visit next.
197///
198/// GooseFS `listStatus` normally returns children only. If a server echoes
199/// the listed directory itself (`child == cur`, including `/foo` vs `/foo/`),
200/// re-queueing it would loop forever. Empty paths are skipped.
201fn list_status_bfs_child_dir<'a>(
202    cur: &str,
203    child_path: Option<&'a str>,
204    is_folder: bool,
205) -> Option<&'a str> {
206    if !is_folder {
207        return None;
208    }
209    let child = child_path.filter(|p| !p.is_empty())?;
210    if list_status_path_key(child) == list_status_path_key(cur) {
211        return None;
212    }
213    Some(child)
214}
215
216/// Split a UUID into the `FsOpPId` wire layout Java uses
217/// (`UUID.getMostSignificantBits` / `getLeastSignificantBits`).
218pub(crate) fn new_fs_op_id() -> FsOpPId {
219    let (high, low) = uuid::Uuid::new_v4().as_u64_pair();
220    FsOpPId {
221        most_significant_bits: Some(high as i64),
222        least_significant_bits: Some(low as i64),
223    }
224}
225
226/// Java `FileSystemOptions.commonDefaults(conf, withOpId)`.
227///
228/// Master `Context.create` does not merge server defaults. Mutating RPCs
229/// (create / delete / rename) pass a fresh `operation_id` generated **once
230/// per call** and reused across `with_retry` attempts so Master exactly-once
231/// semantics work. Read RPCs pass `None`.
232pub(crate) fn common_p_options(
233    sync_interval_ms: i64,
234    operation_id: Option<FsOpPId>,
235) -> FileSystemMasterCommonPOptions {
236    FileSystemMasterCommonPOptions {
237        sync_interval_ms: Some(sync_interval_ms),
238        operation_id,
239    }
240}
241
242/// `commonDefaults(conf, true)` — sync interval plus a fresh operation id.
243fn write_common_p_options(sync_interval_ms: i64) -> FileSystemMasterCommonPOptions {
244    common_p_options(sync_interval_ms, Some(new_fs_op_id()))
245}
246
247/// Java `FileSystemOptions.renameDefaults`: `persist` from
248/// `goosefs.user.file.persist.on.rename` (default `false`) plus write-path
249/// `commonOptions`.
250pub(crate) fn rename_p_options(sync_interval_ms: i64, persist: bool) -> RenamePOptions {
251    RenamePOptions {
252        common_options: Some(write_common_p_options(sync_interval_ms)),
253        persist: Some(persist),
254    }
255}
256
257/// Fill missing `common_options` fields on a mutating RPC, matching Java
258/// `defaults.mergeFrom(caller)`: caller-set values win, unset ones take the
259/// config default / a fresh operation id.
260fn fill_write_common_options(
261    slot: &mut Option<FileSystemMasterCommonPOptions>,
262    sync_interval_ms: i64,
263) {
264    match slot {
265        None => *slot = Some(write_common_p_options(sync_interval_ms)),
266        Some(existing) => {
267            if existing.sync_interval_ms.is_none() {
268                existing.sync_interval_ms = Some(sync_interval_ms);
269            }
270            if existing.operation_id.is_none() {
271                existing.operation_id = Some(new_fs_op_id());
272            }
273        }
274    }
275}
276
277/// Java `FileSystemOptions.getStatusDefaults` wire shape.
278///
279/// Master `GetStatusContext.create` does **not** merge server defaults. An
280/// unset `load_metadata_type` is protobuf enum `0` (`NEVER`), which makes
281/// `checkLoadMetadataOptions` reject UFS-only paths with
282/// `Path "..." does not exist.` OpenDAL `stat` / `get_status` must therefore
283/// always send `ONCE` (config default) unless the caller overrides.
284pub(crate) fn get_status_p_options(
285    load_metadata_type: Option<LoadMetadataPType>,
286    sync_interval_ms: Option<i64>,
287) -> GetStatusPOptions {
288    GetStatusPOptions {
289        load_metadata_type: load_metadata_type.map(|t| t as i32),
290        common_options: sync_interval_ms.map(|ms| common_p_options(ms, None)),
291        ..Default::default()
292    }
293}
294
295/// Build `ListStatusPOptions` matching Java `FileSystemOptions.listStatusDefaults`.
296///
297/// Same Master-side hazard as [`get_status_p_options`]: `ListStatusContext.create`
298/// does not merge server defaults, and `DefaultFileSystemMaster.listStatus` reads
299/// `getLoadMetadataType()` without a `hasLoadMetadataType()` fallback. An unset
300/// value is protobuf enum `0` (`NEVER`), which both forces `loadDescendantType`
301/// to `NONE` and makes `checkLoadMetadataOptions` reject a UFS-only directory
302/// with `Path "..." does not exist.` — so OpenDAL `list` would silently miss
303/// COS objects that are not in the inode tree yet.
304pub(crate) fn list_status_p_options(
305    load_metadata_type: Option<LoadMetadataPType>,
306    sync_interval_ms: Option<i64>,
307) -> ListStatusPOptions {
308    ListStatusPOptions {
309        load_metadata_type: load_metadata_type.map(|t| t as i32),
310        common_options: sync_interval_ms.map(|ms| common_p_options(ms, None)),
311        ..Default::default()
312    }
313}
314
315/// Client for Goosefs `FileSystemMasterClientService` (Master:9200).
316///
317/// In HA mode, the client holds a reference to the [`MasterInquireClient`]
318/// and can automatically re-discover the Primary Master when RPCs fail.
319///
320/// ## Authentication
321///
322/// The client supports NOSASL and SIMPLE authentication modes.
323/// When `config.auth_type` is `Simple`, the client performs a SASL PLAIN
324/// handshake after establishing the gRPC channel, then injects a `channel-id`
325/// metadata header into all subsequent RPCs.
326///
327/// ## Concurrency model
328///
329/// `state` is an [`ArcSwap`] holding the immutable
330/// `(channel + sasl_guard)` pair. The RPC hot path uses
331/// `state.load()` — a wait-free single atomic load — to obtain a snapshot,
332/// then clones the lightweight `AuthenticatedFsClient` (which is itself an
333/// `Arc`-shared tonic `Channel`).  Failover (`reconnect`) atomically
334/// publishes a new snapshot via `state.store(...)`; readers either see the
335/// old snapshot (still valid for in-flight requests) or the new one — never
336/// a torn mix.
337///
338/// The hot-path counters (`counter_*`) are cached as `Arc<Counter>` here
339/// **outside** of `AuthedState` on purpose: they are process-level metric
340/// handles that must outlive any `reconnect` and must not be re-resolved
341/// from the global `DashMap` on every RPC. See
342///
343/// rule.
344#[derive(Clone)]
345pub struct MasterClient {
346    /// Atomically-swappable authenticated state (channel + SASL guard).
347    state: Arc<ArcSwap<AuthedState>>,
348    config: GoosefsConfig,
349    inquire_client: Arc<dyn MasterInquireClient>,
350    /// Per-client in-flight RPC counter, shared across all clones of this
351    /// `MasterClient` (the `Arc` makes `#[derive(Clone)]` produce a shared
352    /// counter rather than independent ones). Incremented in `with_retry`
353    /// on entry and decremented on exit (success, error, or panic via the
354    /// RAII guard). The `MasterClientPool::pick` P2C scheduler reads this
355    /// to pick the least-loaded channel — crucially this count is accurate
356    /// even for `MasterClient`s cloned out of the pool (e.g. by
357    /// `GoosefsFileWriter`), which the previous pool-level counter could
358    /// not track.
359    inflight: Arc<AtomicUsize>,
360    // ── Cached metric handles (lifetime-aligned with the MasterClient, not
361    //    with any single channel/SASL session — see ). Caching avoids
362    //    `crate::metrics::counter(name)` DashMap lookups on every RPC.
363    counter_get_status_ops: Arc<Counter>,
364    counter_get_status_latency_us: Arc<Counter>,
365    counter_list_status_ops: Arc<Counter>,
366    counter_list_status_latency_us: Arc<Counter>,
367    counter_create_file_ops: Arc<Counter>,
368    counter_create_dir_ops: Arc<Counter>,
369    counter_delete_ops: Arc<Counter>,
370    counter_rename_ops: Arc<Counter>,
371    counter_rpc_errors_total: Arc<Counter>,
372    counter_rpc_auth_errors: Arc<Counter>,
373    counter_rpc_unavailable_errors: Arc<Counter>,
374}
375
376impl MasterClient {
377    /// Connect to the Goosefs Master.
378    ///
379    /// In single-master mode, connects directly to `config.master_addr`.
380    /// In HA mode (multiple addresses in `config.master_addrs`), uses
381    /// [`PollingMasterInquireClient`](crate::client::master_inquire::PollingMasterInquireClient)
382    /// to discover the Primary first.
383    ///
384    /// Authentication is performed according to `config.auth_type`.
385    pub async fn connect(config: &GoosefsConfig) -> Result<Self> {
386        let inquire_client = create_master_inquire_client(config);
387        Self::connect_with_inquire(config, inquire_client).await
388    }
389
390    /// Connect using an externally-provided [`MasterInquireClient`].
391    ///
392    /// This is useful when sharing a single inquire client across multiple
393    /// client types (e.g. `MasterClient` + `WorkerManagerClient`).
394    pub async fn connect_with_inquire(
395        config: &GoosefsConfig,
396        inquire_client: Arc<dyn MasterInquireClient>,
397    ) -> Result<Self> {
398        let primary_addr = inquire_client.get_primary_rpc_address().await?;
399        let (client, sasl_guard) = Self::build_authenticated_client(config, &primary_addr).await?;
400        debug!(addr = %primary_addr, auth_type = %config.auth_type, "connected to Goosefs Master");
401
402        Ok(Self::from_parts(
403            AuthedState {
404                client,
405                _sasl_guard: sasl_guard,
406            },
407            config.clone(),
408            inquire_client,
409        ))
410    }
411
412    /// Internal constructor that wires up the `ArcSwap<AuthedState>` and
413    /// caches the hot-path metric handles in one place. Both
414    /// [`Self::connect_with_inquire`] and [`Self::from_channel`] go through
415    /// this so the field-list stays single-sourced.
416    fn from_parts(
417        state: AuthedState,
418        config: GoosefsConfig,
419        inquire_client: Arc<dyn MasterInquireClient>,
420    ) -> Self {
421        Self {
422            state: Arc::new(ArcSwap::from_pointee(state)),
423            config,
424            inquire_client,
425            inflight: Arc::new(AtomicUsize::new(0)),
426            counter_get_status_ops: crate::metrics::counter(
427                crate::metrics::name::CLIENT_GET_STATUS_OPS,
428            ),
429            counter_get_status_latency_us: crate::metrics::counter(
430                crate::metrics::name::CLIENT_GET_STATUS_LATENCY_US,
431            ),
432            counter_list_status_ops: crate::metrics::counter(
433                crate::metrics::name::CLIENT_LIST_STATUS_OPS,
434            ),
435            counter_list_status_latency_us: crate::metrics::counter(
436                crate::metrics::name::CLIENT_LIST_STATUS_LATENCY_US,
437            ),
438            counter_create_file_ops: crate::metrics::counter(
439                crate::metrics::name::CLIENT_CREATE_FILE_OPS,
440            ),
441            counter_create_dir_ops: crate::metrics::counter(
442                crate::metrics::name::CLIENT_CREATE_DIR_OPS,
443            ),
444            counter_delete_ops: crate::metrics::counter(crate::metrics::name::CLIENT_DELETE_OPS),
445            counter_rename_ops: crate::metrics::counter(crate::metrics::name::CLIENT_RENAME_OPS),
446            counter_rpc_errors_total: crate::metrics::counter(
447                crate::metrics::name::CLIENT_RPC_ERRORS_TOTAL,
448            ),
449            counter_rpc_auth_errors: crate::metrics::counter(
450                crate::metrics::name::CLIENT_RPC_AUTH_ERRORS,
451            ),
452            counter_rpc_unavailable_errors: crate::metrics::counter(
453                crate::metrics::name::CLIENT_RPC_UNAVAILABLE_ERRORS,
454            ),
455        }
456    }
457
458    /// Create from an existing tonic channel (useful for testing / channel sharing).
459    ///
460    /// **Note**: This bypasses authentication. The channel is wrapped with a
461    /// no-op channel-id interceptor for API compatibility.
462    pub fn from_channel(channel: Channel, config: GoosefsConfig) -> Self {
463        let inquire_client = create_master_inquire_client(&config);
464        let interceptor = ChannelIdInterceptor::new("test-no-auth".to_string());
465        let intercepted = InterceptedService::new(channel, interceptor);
466        Self::from_parts(
467            AuthedState {
468                client: FileSystemMasterClientServiceClient::new(intercepted),
469                _sasl_guard: None,
470            },
471            config,
472            inquire_client,
473        )
474    }
475
476    /// Build a gRPC channel and perform authentication, returning an authenticated client
477    /// and the SASL stream guard that must be kept alive.
478    async fn build_authenticated_client(
479        config: &GoosefsConfig,
480        addr: &str,
481    ) -> Result<(AuthenticatedFsClient, Option<SaslStreamGuard>)> {
482        let channel = Self::build_raw_channel(config, addr).await?;
483
484        // Perform SASL authentication based on the configured auth type
485        let authenticator = ChannelAuthenticator::new(
486            config.auth_type,
487            config.auth_username.clone(),
488            None, // impersonation_user: not yet supported
489        )
490        .with_auth_timeout(config.auth_timeout);
491
492        let mut auth_channel = authenticator.authenticate(channel).await?;
493        let sasl_guard = auth_channel.take_sasl_guard();
494
495        Ok((
496            FileSystemMasterClientServiceClient::new(auth_channel.channel),
497            sasl_guard,
498        ))
499    }
500
501    /// Build a raw gRPC channel to a specific master address (without authentication).
502    async fn build_raw_channel(config: &GoosefsConfig, addr: &str) -> Result<Channel> {
503        let endpoint_uri = format!("http://{}", addr);
504        let endpoint = Channel::from_shared(endpoint_uri)
505            .map_err(|e| Error::ConfigError {
506                message: format!("invalid master endpoint: {}", e),
507            })?
508            .connect_timeout(config.connect_timeout)
509            .timeout(config.request_timeout);
510
511        let channel = endpoint.connect().await?;
512        Ok(channel)
513    }
514
515    /// Reconnect to the Primary Master after a failover.
516    ///
517    /// Resets the cached Primary in the inquire client, re-discovers the
518    /// new Primary, rebuilds the gRPC channel, and re-authenticates.
519    ///
520    /// The new `(client, sasl_guard)` pair is published as a single
521    /// [`AuthedState`] via [`ArcSwap::store`], so concurrent readers always
522    /// observe a self-consistent snapshot. The old `Arc<AuthedState>` —
523    /// containing the previous `sasl_guard` — is kept alive by any in-flight
524    /// reader holding the old `Guard`, and is only dropped after the last
525    /// such reader releases it. This guarantees that the old SASL stream is
526    /// not closed while old-channel requests are still in flight.
527    async fn reconnect(&self) -> Result<()> {
528        // Reset cached primary so the inquire client re-polls all addresses.
529        self.inquire_client.reset_cached_primary().await;
530
531        let primary_addr = self.inquire_client.get_primary_rpc_address().await?;
532        let (client, sasl_guard) =
533            Self::build_authenticated_client(&self.config, &primary_addr).await?;
534        // Single atomic publish: callers either see the previous AuthedState
535        // in its entirety, or the new one — never a torn `(new client, old
536        // guard)` mix.
537        self.state.store(Arc::new(AuthedState {
538            client,
539            _sasl_guard: sasl_guard,
540        }));
541        debug!(addr = %primary_addr, "reconnected to Goosefs Master after failover");
542        Ok(())
543    }
544
545    /// Execute an RPC with automatic retry on retriable errors.
546    ///
547    /// On retriable failure, the client reconnects to a (potentially new)
548    /// Primary Master and retries up to [`MAX_RPC_RETRIES`] times.
549    ///
550    /// Each RPC attempt is wrapped with an in-flight counter guard so the
551    /// P2C scheduler in [`MasterClientPool::pick`] sees an accurate load
552    /// even for `MasterClient`s cloned out of the pool (e.g. by
553    /// `GoosefsFileWriter`). The counter is shared across clones via the
554    /// `Arc<AtomicUsize>` field, and the RAII guard guarantees decrement
555    /// on success, error, or future cancellation (task drop).
556    async fn with_retry<F, Fut, T>(&self, op_name: &str, mut f: F) -> Result<T>
557    where
558        // `FnMut` (rather than `Fn`) lets callers move owned state (e.g. the
559        // request `path: String`) into the closure on the *first* attempt and
560        // only `clone()` it inside the closure when a retry is actually
561        // needed.
562        F: FnMut(AuthenticatedFsClient) -> Fut,
563        Fut: std::future::Future<Output = Result<T>>,
564    {
565        let mut last_err: Option<Error> = None;
566
567        for attempt in 0..=MAX_RPC_RETRIES {
568            // For retry attempts (attempt > 0) we know the previous call hit
569            // a retriable error, which usually means the channel is dead.
570            // Reconnect *before* re-sending — sending on a stale channel
571            // just burns `request_timeout` for no gain. If the reconnect
572            // itself fails, skip this attempt (the next iteration will try
573            // reconnect again) so we don't consume retries on a known-bad
574            // connection.
575            if attempt > 0 {
576                if let Err(reconnect_err) = self.reconnect().await {
577                    warn!(
578                        op = op_name,
579                        attempt = attempt + 1,
580                        error = %reconnect_err,
581                        "reconnect failed; will retry reconnect on next attempt"
582                    );
583                    last_err = Some(Error::Internal {
584                        message: format!("master reconnect failed: {}", reconnect_err),
585                        source: None,
586                    });
587                    continue;
588                }
589            }
590
591            // Wait-free atomic load: replaces the previous
592            // `RwLock::read().await` round-trip with a single `Acquire` load.
593            // The cloned client shares the underlying `tonic::Channel`
594            // (which itself is `Arc`-internally cloneable and Send+Sync), so
595            // this is cheap.
596            let client: AuthenticatedFsClient = self.state.load().client.clone();
597
598            // Mark this RPC as in-flight for P2C load balancing. The guard
599            // decrements on drop — covering success, error, and future
600            // cancellation (task drop) paths uniformly.
601            self.inflight.fetch_add(1, Ordering::Relaxed);
602            let _inflight_guard = InflightGuard(&self.inflight);
603
604            match f(client).await {
605                Ok(result) => return Ok(result),
606                Err(err) => {
607                    // Instrument: count RPC errors (use the cached Arc<Counter>
608                    // to avoid a DashMap lookup on every error path).
609                    self.counter_rpc_errors_total.inc(1);
610                    // Classify the error
611                    if err.is_authentication_error() {
612                        self.counter_rpc_auth_errors.inc(1);
613                    } else if err.is_unavailable() {
614                        self.counter_rpc_unavailable_errors.inc(1);
615                    }
616
617                    if err.is_retriable() && attempt < MAX_RPC_RETRIES {
618                        warn!(
619                            op = op_name,
620                            attempt = attempt + 1,
621                            max = MAX_RPC_RETRIES,
622                            error = %err,
623                            "retriable error; will reconnect and retry"
624                        );
625                        last_err = Some(err);
626                    } else {
627                        return Err(err);
628                    }
629                }
630            }
631            // `_inflight_guard` drops here on the retry path → fetch_sub.
632        }
633
634        Err(last_err.unwrap_or_else(|| Error::Internal {
635            message: format!("{}: exhausted all retries", op_name),
636            source: None,
637        }))
638    }
639
640    /// Get the file/directory status (equivalent to `stat` / `head`).
641    ///
642    /// Sends Java `FileSystemOptions.getStatusDefaults`:
643    /// `loadMetadataType` from [`GoosefsConfig::file_metadata_load_type`]
644    /// (default `ONCE`) and `syncIntervalMs` from
645    /// [`GoosefsConfig::file_metadata_sync_interval`] (default `-1`).
646    ///
647    /// The Master handler uses client options as-is (`GetStatusContext.create`,
648    /// no server-side merge). An unset `loadMetadataType` is proto enum `0`
649    /// (`NEVER`), so COS/UFS files not yet in the namespace would return
650    /// `NotFound`. Matching Java is required for OpenDAL `stat`.
651    #[instrument(skip(self), fields(path = %path))]
652    pub async fn get_status(&self, path: &str) -> Result<FileInfo> {
653        self.get_status_with_load_type(
654            path,
655            Some(self.config.file_metadata_load_type),
656            Some(self.config.file_metadata_sync_interval),
657        )
658        .await
659    }
660
661    /// `GetStatus` with an explicit `load_metadata_type` / `sync_interval_ms`.
662    ///
663    /// # Java authority
664    ///
665    /// The `close()` recovery path in `GoosefsFileOutStream` re-reads the file
666    /// with `LoadMetadataPType.ALWAYS` and `syncIntervalMs = 0` after a
667    /// `completeFile` failure, forcing the Master to re-import metadata from
668    /// the UFS copy that was already written.
669    ///
670    /// Unlike [`Self::get_status`] this deliberately bypasses no client-side
671    /// cache of its own — callers wanting cache semantics should go through
672    /// `FileSystem::get_status_with_options`.
673    #[instrument(skip(self), fields(path = %path, ?load_metadata_type, ?sync_interval_ms))]
674    pub async fn get_status_with_load_type(
675        &self,
676        path: &str,
677        load_metadata_type: Option<LoadMetadataPType>,
678        sync_interval_ms: Option<i64>,
679    ) -> Result<FileInfo> {
680        let start = std::time::Instant::now();
681        // Allocate the owned path exactly once.
682        //
683        // The closure captures `path_owned: Option<String>` by `&mut`. On
684        // the first attempt we `take()` (move) into the request — zero
685        // additional allocation. On a retry attempt (rare) the `Option` is
686        // empty, so we re-allocate from `path` (`&str`) one more time. Since
687        // `with_retry` accepts `FnMut`, this pattern is sound.
688        let options = get_status_p_options(load_metadata_type, sync_interval_ms);
689        let mut path_owned: Option<String> = Some(path.to_string());
690        let result = self
691            .with_retry("get_status", |mut client| {
692                let req_path = path_owned.take().unwrap_or_else(|| path.to_string());
693                let options = options.clone();
694                async move {
695                    let req = GetStatusPRequest {
696                        path: Some(req_path),
697                        options: Some(options),
698                        request_id: None,
699                    };
700                    client
701                        .get_status(req)
702                        .await?
703                        .into_inner()
704                        .file_info
705                        .ok_or_else(|| Error::missing_field("file_info"))
706                }
707            })
708            .await;
709        self.counter_get_status_ops.inc(1);
710        self.counter_get_status_latency_us
711            .inc(start.elapsed().as_micros() as i64);
712        result
713    }
714
715    /// List the contents of a directory. Returns all FileInfo entries.
716    ///
717    /// GooseFS 2.0 dropped `ListStatusPOptions.recursive`; each Master RPC
718    /// returns a single directory level. When `recursive` is `true`, this
719    /// method walks descendants **client-side** (BFS), matching Java
720    /// `BaseFileSystem.iterateStatusInternal` and the historical SDK contract
721    /// that `list_status(path, true)` returns the full subtree.
722    ///
723    /// Every level — recursive or not — sends Java
724    /// `FileSystemOptions.listStatusDefaults`: `loadMetadataType` from
725    /// [`GoosefsConfig::file_metadata_load_type`] (default `ONCE`) and
726    /// `syncIntervalMs` from [`GoosefsConfig::file_metadata_sync_interval`]
727    /// (default `-1`). Callers that need `Never` / `Always` should use
728    /// [`Self::list_status_with_load_type`]. The BFS skips a child whose path
729    /// is the directory currently being listed (`/foo` and `/foo/` are the
730    /// same node), so a server that echoes `self` cannot loop forever.
731    ///
732    /// Each level wraps a **server-side streaming** RPC — the server sends
733    /// multiple `ListStatusPResponse` messages, each containing a batch
734    /// of `FileInfo`.
735    #[instrument(skip(self), fields(path = %path, recursive))]
736    pub async fn list_status(&self, path: &str, recursive: bool) -> Result<Vec<FileInfo>> {
737        self.list_status_with_load_type(path, recursive, None).await
738    }
739
740    /// List a directory, applying `load_metadata_type` on every Master RPC.
741    ///
742    /// `None` resolves to [`GoosefsConfig::file_metadata_load_type`] (default
743    /// `ONCE`), and `syncIntervalMs` comes from the config — same as
744    /// [`Self::list_status`].
745    #[instrument(skip(self), fields(path = %path, recursive, ?load_metadata_type))]
746    pub async fn list_status_with_load_type(
747        &self,
748        path: &str,
749        recursive: bool,
750        load_metadata_type: Option<LoadMetadataPType>,
751    ) -> Result<Vec<FileInfo>> {
752        self.list_status_with_options(
753            path,
754            recursive,
755            load_metadata_type,
756            Some(self.config.file_metadata_sync_interval),
757        )
758        .await
759    }
760
761    /// `ListStatus` with an explicit `load_metadata_type` / `sync_interval_ms`.
762    ///
763    /// Mirrors [`Self::get_status_with_load_type`] so that a `FileSystem`
764    /// layer which resolved both values from `ListStatusOptions` can pass
765    /// them straight through.
766    #[instrument(
767        skip(self),
768        fields(path = %path, recursive, ?load_metadata_type, ?sync_interval_ms)
769    )]
770    pub async fn list_status_with_options(
771        &self,
772        path: &str,
773        recursive: bool,
774        load_metadata_type: Option<LoadMetadataPType>,
775        sync_interval_ms: Option<i64>,
776    ) -> Result<Vec<FileInfo>> {
777        let load = load_metadata_type.unwrap_or(self.config.file_metadata_load_type);
778        let options = list_status_p_options(Some(load), sync_interval_ms);
779        if !recursive {
780            return self.list_status_one_level(path, options).await;
781        }
782
783        // Client-side BFS: Master no longer accepts a recursive ListStatus option.
784        let mut out: Vec<FileInfo> = Vec::new();
785        let mut queue: VecDeque<String> = VecDeque::new();
786        let mut visited: HashSet<String> = HashSet::new();
787        let start = list_status_path_key(path).to_string();
788        visited.insert(start.clone());
789        queue.push_back(start);
790        while let Some(cur) = queue.pop_front() {
791            let items = self.list_status_one_level(&cur, options.clone()).await?;
792            for fi in items {
793                if let Some(child) =
794                    list_status_bfs_child_dir(&cur, fi.path.as_deref(), fi.folder.unwrap_or(false))
795                {
796                    let key = list_status_path_key(child).to_string();
797                    if visited.insert(key.clone()) {
798                        queue.push_back(key);
799                    }
800                }
801                out.push(fi);
802            }
803        }
804        Ok(out)
805    }
806
807    /// One-level `ListStatus` RPC (no client-side descent).
808    async fn list_status_one_level(
809        &self,
810        path: &str,
811        options: ListStatusPOptions,
812    ) -> Result<Vec<FileInfo>> {
813        let start = std::time::Instant::now();
814        let path = path.to_string();
815        let result = self
816            .with_retry("list_status", |mut client| {
817                let path = path.clone();
818                let options = options.clone();
819                async move {
820                    let req = ListStatusPRequest {
821                        path: Some(path),
822                        options: Some(options),
823                        request_id: None,
824                    };
825                    let mut stream = client.list_status(req).await?.into_inner();
826                    let mut result = Vec::new();
827                    while let Some(resp) = stream.message().await? {
828                        result.extend(resp.file_infos);
829                    }
830                    Ok(result)
831                }
832            })
833            .await;
834        self.counter_list_status_ops.inc(1);
835        self.counter_list_status_latency_us
836            .inc(start.elapsed().as_micros() as i64);
837        result
838    }
839
840    /// Create a new file. Returns the `FileInfo` of the created file.
841    #[instrument(skip(self, options), fields(path = %path))]
842    pub async fn create_file(
843        &self,
844        path: &str,
845        mut options: CreateFilePOptions,
846    ) -> Result<FileInfo> {
847        fill_write_common_options(
848            &mut options.common_options,
849            self.config.file_metadata_sync_interval,
850        );
851        let path = path.to_string();
852        let result = self
853            .with_retry("create_file", |mut client| {
854                let path = path.clone();
855                let options = options.clone();
856                async move {
857                    let req = CreateFilePRequest {
858                        path: Some(path),
859                        options: Some(options),
860                    };
861                    let resp = client.create_file(req).await?;
862                    resp.into_inner()
863                        .file_info
864                        .ok_or_else(|| Error::missing_field("file_info"))
865                }
866            })
867            .await;
868        self.counter_create_file_ops.inc(1);
869        result
870    }
871
872    /// Mark a file as completed (called after all blocks are written).
873    ///
874    /// # Idempotent operation ID
875    ///
876    /// `operation_id` is used by the Master for exactly-once semantics: if the
877    /// RPC is retried after a network hiccup the Master detects the duplicate
878    /// via `FsOpPId` and returns success without applying the operation twice.
879    ///
880    /// The caller (`GoosefsFileWriter`) generates a fresh `uuid::Uuid` at
881    /// construction time and reuses it across all `complete_file` calls for the
882    /// same write session.  The UUID is split into two `i64` halves via
883    /// `Uuid::as_u64_pair()`:
884    ///
885    /// ```text
886    /// (high, low) = uuid.as_u64_pair()
887    /// FsOpPId { most_significant_bits: high as i64,
888    ///           least_significant_bits: low  as i64 }
889    /// ```
890    ///
891    /// This matches Java `UUID.getMostSignificantBits()` / `getLeastSignificantBits()`
892    /// as verified in `DefaultFileSystemMaster.completeFile()`.
893    ///
894    /// # Note on Go SDK bug
895    ///
896    /// The Go SDK `base_filesystem.go:394-400` accepts an `operationID` parameter
897    /// but **never writes it to the proto request**.  The Rust implementation
898    /// fixes this: `operation_id` is always wired into `CompleteFilePOptions`.
899    #[instrument(skip(self), fields(path = %path))]
900    pub async fn complete_file(
901        &self,
902        path: &str,
903        ufs_length: Option<i64>,
904        operation_id: Option<FsOpPId>,
905    ) -> Result<()> {
906        self.complete_file_with_options(
907            path,
908            CompleteFileOptions {
909                ufs_length,
910                operation_id,
911                ..Default::default()
912            },
913        )
914        .await
915    }
916
917    /// `CompleteFile` with ASYNC_THROUGH location metadata and persist options.
918    ///
919    /// # Java authority
920    ///
921    /// Matches `GoosefsFileOutStream.close()` which puts the last block's
922    /// `writeSucceedWorkers` into `CompleteFilePOptions.locations` and, for
923    /// ASYNC_THROUGH, embeds `asyncPersistOptions` rather than issuing a
924    /// separate `ScheduleAsyncPersistence` RPC.
925    #[instrument(
926        skip(self, opts),
927        fields(path = %path, location_count = opts.locations.len())
928    )]
929    pub async fn complete_file_with_options(
930        &self,
931        path: &str,
932        opts: CompleteFileOptions,
933    ) -> Result<()> {
934        let path = path.to_string();
935        let sync_interval_ms = self.config.file_metadata_sync_interval;
936        self.with_retry("complete_file", |mut client| {
937            let path = path.clone();
938            let opts = opts.clone();
939            async move {
940                let req = complete_file_request(path, opts, sync_interval_ms);
941                client.complete_file(req).await?;
942                Ok(())
943            }
944        })
945        .await
946    }
947
948    /// Commit a completed block's replica locations to Master.
949    ///
950    /// # Java authority
951    ///
952    /// Matches `GoosefsFileOutStream.commitCurrentBlock()` →
953    /// `FileSystemMasterClient.commitLocation`. Called after every block
954    /// except the last (the last block's locations travel with `completeFile`).
955    #[instrument(skip(self, locations), fields(path = %path, block_id = block_id, location_count = locations.len()))]
956    pub async fn commit_location(
957        &self,
958        path: &str,
959        inode_id: Option<i64>,
960        block_id: i64,
961        locations: Vec<FileLocation>,
962    ) -> Result<()> {
963        if locations.is_empty() {
964            return Ok(());
965        }
966        let path = path.to_string();
967        self.with_retry("commit_location", |mut client| {
968            let path = path.clone();
969            let locations = locations.clone();
970            async move {
971                let req = CommitLocationPRequest {
972                    path: Some(path),
973                    inode_id,
974                    block_id: Some(block_id),
975                    options: Some(CommitLocationPOptions { locations }),
976                };
977                client.commit_location(req).await?;
978                Ok(())
979            }
980        })
981        .await
982    }
983
984    // -----------------------------------------------------------------------
985    // RemoveBlocks RPC
986    // -----------------------------------------------------------------------
987
988    /// Request the Master to free block metadata for the given block IDs.
989    ///
990    /// This is the preferred cleanup path for `GoosefsFileWriter::cancel()`:
991    /// it removes only the block metadata on the Master without touching the
992    /// file-system namespace entry (the INCOMPLETE inode).
993    ///
994    /// Falls back to `delete_with_options(unchecked=true)` when this RPC fails.
995    ///
996    /// # Java authority
997    ///
998    /// Matches `FileSystemMasterClientServiceHandler.removeBlocks()` →
999    /// `DefaultFileSystemMaster.removeBlocks(blockIds)`.
1000    #[instrument(skip(self, block_ids), fields(block_count = block_ids.len()))]
1001    pub async fn remove_blocks(&self, block_ids: Vec<i64>) -> Result<()> {
1002        if block_ids.is_empty() {
1003            return Ok(());
1004        }
1005        let block_ids_clone = block_ids.clone();
1006        self.with_retry("remove_blocks", |mut client| {
1007            let block_ids = block_ids_clone.clone();
1008            async move {
1009                let req = RemoveBlocksPRequest { block_ids };
1010                client.remove_blocks(req).await?;
1011                Ok(())
1012            }
1013        })
1014        .await
1015    }
1016
1017    // -----------------------------------------------------------------------
1018    // Delete with full DeleteOptions
1019    // -----------------------------------------------------------------------
1020
1021    /// Delete a file or directory with fine-grained options.
1022    ///
1023    /// Prefer this over the legacy [`delete`](Self::delete) wrapper when you need
1024    /// `unchecked` or `goosefs_only` semantics.
1025    ///
1026    /// See [`DeleteOptions`] for field semantics and Java authority notes.
1027    #[instrument(skip(self, opts), fields(path = %path))]
1028    pub async fn delete_with_options(&self, path: &str, opts: DeleteOptions) -> Result<()> {
1029        let path = path.to_string();
1030        let common_options = Some(write_common_p_options(
1031            self.config.file_metadata_sync_interval,
1032        ));
1033        self.with_retry("delete_with_options", |mut client| {
1034            let path = path.clone();
1035            let opts = opts.clone();
1036            async move {
1037                let req = DeletePRequest {
1038                    path: Some(path),
1039                    options: Some(DeletePOptions {
1040                        recursive: Some(opts.recursive),
1041                        unchecked: Some(opts.unchecked),
1042                        goosefs_only: Some(opts.goosefs_only),
1043                        common_options,
1044                        ..Default::default()
1045                    }),
1046                };
1047                client.remove(req).await?;
1048                Ok(())
1049            }
1050        })
1051        .await
1052    }
1053
1054    /// Delete a file or directory (simple recursive wrapper).
1055    ///
1056    /// For `unchecked` or `goosefs_only` deletion use [`delete_with_options`](Self::delete_with_options)
1057    /// directly.
1058    #[instrument(skip(self), fields(path = %path, recursive = %recursive))]
1059    pub async fn delete(&self, path: &str, recursive: bool) -> Result<()> {
1060        let result = self
1061            .delete_with_options(
1062                path,
1063                DeleteOptions {
1064                    recursive,
1065                    ..Default::default()
1066                },
1067            )
1068            .await;
1069        self.counter_delete_ops.inc(1);
1070        result
1071    }
1072
1073    /// Rename (move) a file or directory.
1074    #[instrument(skip(self), fields(src = %src, dst = %dst))]
1075    pub async fn rename(&self, src: &str, dst: &str) -> Result<()> {
1076        let src = src.to_string();
1077        let dst = dst.to_string();
1078        let options = rename_p_options(
1079            self.config.file_metadata_sync_interval,
1080            self.config.file_persist_on_rename,
1081        );
1082        let result = self
1083            .with_retry("rename", |mut client| {
1084                let src = src.clone();
1085                let dst = dst.clone();
1086                async move {
1087                    let req = RenamePRequest {
1088                        path: Some(src),
1089                        dst_path: Some(dst),
1090                        options: Some(options),
1091                    };
1092                    client.rename(req).await?;
1093                    Ok(())
1094                }
1095            })
1096            .await;
1097        self.counter_rename_ops.inc(1);
1098        result
1099    }
1100
1101    /// Create a directory (recursive by default).
1102    ///
1103    /// Sets a default mode of `0755` (rwxr-xr-x) so that the corresponding
1104    /// UFS directory created by Goosefs has usable permissions.
1105    #[instrument(skip(self), fields(path = %path))]
1106    pub async fn create_directory(&self, path: &str, recursive: bool) -> Result<()> {
1107        let path = path.to_string();
1108        // `allow_exists=true` is an intentional OpenDAL mkdir -p divergence
1109        // from Java `createDirectoryDefaults` (`allowExists=false`). commonOptions
1110        // still match Java: syncIntervalMs from config + a fresh operation id.
1111        let common_options = Some(write_common_p_options(
1112            self.config.file_metadata_sync_interval,
1113        ));
1114        let result = self
1115            .with_retry("create_directory", |mut client| {
1116                let path = path.clone();
1117                async move {
1118                    let req = CreateDirectoryPRequest {
1119                        path: Some(path),
1120                        options: Some(CreateDirectoryPOptions {
1121                            recursive: Some(recursive),
1122                            allow_exists: Some(true),
1123                            mode: Some(default_dir_mode()),
1124                            common_options,
1125                            ..Default::default()
1126                        }),
1127                    };
1128                    client.create_directory(req).await?;
1129                    Ok(())
1130                }
1131            })
1132            .await;
1133        self.counter_create_dir_ops.inc(1);
1134        result
1135    }
1136
1137    /// Schedule asynchronous persistence for a file.
1138    /// This will persist the file to the underlying storage system.
1139    #[instrument(skip(self), fields(path = %path))]
1140    pub async fn schedule_async_persistence(
1141        &self,
1142        path: &str,
1143        persistence_wait_time: Option<i64>,
1144    ) -> Result<()> {
1145        let path = path.to_string();
1146        // Java `scheduleAsyncPersistDefaults` uses `commonDefaults(conf)` —
1147        // sync interval, no operation id.
1148        let common_options = Some(common_p_options(
1149            self.config.file_metadata_sync_interval,
1150            None,
1151        ));
1152        self.with_retry("schedule_async_persistence", |mut client| {
1153            let path = path.clone();
1154            async move {
1155                let req = ScheduleAsyncPersistencePRequest {
1156                    path: Some(path),
1157                    options: Some(ScheduleAsyncPersistencePOptions {
1158                        common_options,
1159                        persistence_wait_time,
1160                    }),
1161                };
1162                client.schedule_async_persistence(req).await?;
1163                Ok(())
1164            }
1165        })
1166        .await
1167    }
1168
1169    /// Get a reference to the underlying config.
1170    pub fn config(&self) -> &GoosefsConfig {
1171        &self.config
1172    }
1173
1174    /// Get a reference to the underlying inquire client.
1175    ///
1176    /// Useful for sharing the same inquire client with `WorkerManagerClient`.
1177    pub fn inquire_client(&self) -> &Arc<dyn MasterInquireClient> {
1178        &self.inquire_client
1179    }
1180}
1181
1182// ── In-flight RPC counting (P2C load balancing) ─────────────────────────────
1183
1184/// RAII guard that decrements a `MasterClient`'s in-flight RPC counter on drop.
1185///
1186/// Created in [`MasterClient::with_retry`] around each RPC attempt. The guard
1187/// is panic-safe and cancellation-safe: if the future is dropped before
1188/// completion (e.g. `tokio::task` cancellation), the guard's `Drop` still
1189/// runs, so the counter never leaks.
1190struct InflightGuard<'a>(&'a AtomicUsize);
1191
1192impl Drop for InflightGuard<'_> {
1193    fn drop(&mut self) {
1194        self.0.fetch_sub(1, Ordering::Relaxed);
1195    }
1196}
1197
1198// ── Master connection pool ────────────────────────────────────────────────
1199
1200/// A pool of [`MasterClient`]s over independent HTTP/2 channels.
1201///
1202/// # Why
1203///
1204/// A single tonic [`Channel`] multiplexes all RPCs over one HTTP/2 connection,
1205/// which caps concurrency at `SETTINGS_MAX_CONCURRENT_STREAMS` (default 100).
1206/// Under 256-way concurrency over remote RTT the surplus requests queue in
1207/// `tower::Buffer`, which is the measured root cause of the remote GetFileStatus
1208/// / OpenFile regression vs Java (Java defaults to a channel pool). Spreading
1209/// requests across `master_connection_pool_size` channels removes the queue.
1210///
1211/// # Scheduling
1212///
1213/// Two strategies are available via `GoosefsConfig::master_connection_pool_schedule`:
1214///
1215/// - **RoundRobin** (default): cycles through channels in order. Wait-free,
1216///   zero overhead, no in-flight tracking required.
1217/// - **P2C**: Power of Two Choices — uniformly samples two distinct channels
1218///   with a fast PRNG (`fastrand`) and picks the one with fewer in-flight
1219///   RPCs. Per-channel in-flight counts are tracked inside each
1220///   [`MasterClient`] (incremented in `with_retry`, decremented on RPC
1221///   completion), so the count is accurate even for `MasterClient`s cloned
1222///   out of the pool (e.g. by `GoosefsFileWriter`).
1223///
1224/// # HA consistency
1225///
1226/// Every pooled client is constructed with the **same** `inquire_client`, so a
1227/// failover decision is shared: all channels re-discover and switch to the same
1228/// new Primary, eliminating split-brain. Each channel performs its own SASL
1229/// handshake and carries a unique `channel-id`, fully compatible with the
1230/// `ArcSwap<AuthedState>` model.
1231pub struct MasterClientPool {
1232    clients: Vec<Arc<MasterClient>>,
1233    schedule: crate::config::MasterPoolSchedule,
1234    /// Round-robin cursor for `RoundRobin` schedule (Relaxed, wait-free).
1235    rr: AtomicUsize,
1236}
1237
1238impl MasterClientPool {
1239    /// Connect a pool of `config.master_connection_pool_size` master clients,
1240    /// all sharing the supplied `inquire_client`.
1241    ///
1242    /// The size is clamped to at least 1, so this is a strict superset of the
1243    /// previous single-channel behaviour (`size = 1`).
1244    pub async fn connect_with_inquire(
1245        config: &GoosefsConfig,
1246        inquire_client: Arc<dyn MasterInquireClient>,
1247    ) -> Result<Self> {
1248        let size = config.master_connection_pool_size.max(1);
1249        // Connect all channels concurrently to avoid multiplying cold-start
1250        // latency by `size` (each connect = TCP + SASL handshake). All
1251        // connections share the same inquire_client, which is safe under
1252        // concurrent access (it deduplicates primary discovery internally).
1253        let clients: Vec<Arc<MasterClient>> = futures::future::try_join_all((0..size).map(|_| {
1254            let config = config.clone();
1255            let inquire = inquire_client.clone();
1256            async move {
1257                MasterClient::connect_with_inquire(&config, inquire)
1258                    .await
1259                    .map(Arc::new)
1260            }
1261        }))
1262        .await?;
1263        debug!(pool_size = size, "MasterClientPool connected");
1264        Ok(Self {
1265            clients,
1266            schedule: config.master_connection_pool_schedule,
1267            rr: AtomicUsize::new(0),
1268        })
1269    }
1270
1271    /// Pick the next client according to the configured scheduling strategy.
1272    ///
1273    /// - `RoundRobin` (default): cycle through channels in order. Wait-free,
1274    ///   zero overhead, no in-flight tracking required.
1275    /// - `P2C`: Power of Two Choices — uniformly sample two distinct channels
1276    ///   at random and select the one with fewer in-flight RPCs. The
1277    ///   per-channel in-flight count is maintained inside
1278    ///   [`MasterClient::with_retry`] (not by this method), so it stays
1279    ///   accurate even for clients cloned out of the pool.
1280    ///
1281    /// Returns `Arc<MasterClient>` — callers interact with it exactly as
1282    /// before. The in-flight counter lives inside `MasterClient` itself
1283    /// (shared via `Arc<AtomicUsize>` across clones), so P2C load awareness
1284    /// works regardless of whether the caller holds the `Arc` directly or
1285    /// clones the inner `MasterClient`.
1286    pub fn pick(&self) -> Arc<MasterClient> {
1287        let n = self.clients.len();
1288        if n == 1 {
1289            return self.clients[0].clone();
1290        }
1291
1292        match self.schedule {
1293            crate::config::MasterPoolSchedule::RoundRobin => {
1294                let idx = self.rr.fetch_add(1, Ordering::Relaxed) % n;
1295                self.clients[idx].clone()
1296            }
1297            crate::config::MasterPoolSchedule::P2C => {
1298                // Uniformly sample two distinct indices with a fast PRNG.
1299                let a = fastrand::usize(0..n);
1300                let b = loop {
1301                    let b = fastrand::usize(0..n);
1302                    if b != a {
1303                        break b;
1304                    }
1305                };
1306
1307                // Read per-client in-flight counts (tracked inside each
1308                // MasterClient via with_retry, so clone-out-of-pool RPCs
1309                // are counted too).
1310                let la = self.clients[a].inflight.load(Ordering::Relaxed);
1311                let lb = self.clients[b].inflight.load(Ordering::Relaxed);
1312                let idx = if la <= lb { a } else { b };
1313
1314                // TOCTOU note: the counter is only incremented inside
1315                // with_retry (not at pick time). This leaves a sub-
1316                // microsecond window between pick() and the first RPC
1317                // increment, during which another pick() could observe
1318                // the same stale value. In practice this is negligible —
1319                // callers immediately .await on the returned client —
1320                // and a fetch_add here would leak one increment per
1321                // pick() (unbalanced against with_retry's +1/-1 per RPC).
1322
1323                debug!(
1324                    a_idx = a,
1325                    b_idx = b,
1326                    a_inflight = la,
1327                    b_inflight = lb,
1328                    picked = idx,
1329                    "P2C pick"
1330                );
1331
1332                self.clients[idx].clone()
1333            }
1334        }
1335    }
1336
1337    /// Number of pooled channels.
1338    pub fn size(&self) -> usize {
1339        self.clients.len()
1340    }
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345    //! Concurrency-correctness tests for the `ArcSwap<AuthedState>`-based
1346    //! state model introduced as part of the GetFileStatus performance
1347    //! optimisation work.  See
1348    //!
1349    //! the rationale and the gating-test requirement.
1350    //!
1351    //! These tests intentionally do **not** spin up a real Master server.
1352    //! They exercise the *type-level* invariant that motivates the change:
1353    //!
1354    //! 1. The `(client, sasl_guard)` pair is published as a single
1355    //!    immutable `Arc<AuthedState>`.  A concurrent reader either sees
1356    //!    the previous publication in its entirety, or the new one — never
1357    //!    a torn `(new client, old guard)` mix.
1358    //!
1359    //! 2. The previous publication's resources (in particular the SASL
1360    //!    guard standing in for `SaslStreamGuard` here) are *not* dropped
1361    //!    until the last reader releases its `Arc`.  This is the
1362    //!    "old SASL stream cannot be closed while in-flight requests still
1363    //!    use the old channel" property.
1364    //!
1365    //! Both properties are checked against a stand-in payload struct that
1366    //! mirrors the shape of `AuthedState` (channel + guard).  The real
1367    //! `AuthedState` is private to the module and uses tonic's generated
1368    //! client stub which is hard to instantiate in a unit test, so the
1369    //! stand-in keeps the test focused on the `ArcSwap` semantics that
1370    //! `MasterClient` relies on.
1371
1372    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1373    use std::sync::{Arc, Barrier};
1374    use std::thread;
1375    use std::time::{Duration, Instant};
1376
1377    use arc_swap::ArcSwap;
1378
1379    #[test]
1380    fn list_status_bfs_skips_non_folders_and_empty_paths() {
1381        assert_eq!(
1382            super::list_status_bfs_child_dir("/a", Some("/a/b.bin"), false),
1383            None
1384        );
1385        assert_eq!(super::list_status_bfs_child_dir("/a", Some(""), true), None);
1386        assert_eq!(super::list_status_bfs_child_dir("/a", None, true), None);
1387    }
1388
1389    #[test]
1390    fn list_status_bfs_skips_self_entry_to_avoid_infinite_loop() {
1391        assert_eq!(
1392            super::list_status_bfs_child_dir("/data", Some("/data"), true),
1393            None,
1394            "re-queueing the listed directory would loop forever"
1395        );
1396        assert_eq!(
1397            super::list_status_bfs_child_dir("/data", Some("/data/nested"), true),
1398            Some("/data/nested")
1399        );
1400    }
1401
1402    #[test]
1403    fn list_status_path_key_strips_trailing_slash_except_root() {
1404        assert_eq!(super::list_status_path_key("/"), "/");
1405        assert_eq!(super::list_status_path_key(""), "");
1406        assert_eq!(super::list_status_path_key("/data"), "/data");
1407        assert_eq!(super::list_status_path_key("/data/"), "/data");
1408        assert_eq!(super::list_status_path_key("/data/nested/"), "/data/nested");
1409    }
1410
1411    /// Java `GooseFSFileOutStream.close()` always sets `inodeId` plus
1412    /// `crcType`/`crcValue` on `CompleteFile`. Omitting them is what produced
1413    /// `inode crc missing, skip ufs check` on HybridPersistenceManager.
1414    #[test]
1415    fn complete_file_request_wires_inode_id_and_crc() {
1416        use crate::proto::grpc::ChecksumTypeProto;
1417        let req = super::complete_file_request(
1418            "/tmp/a.bin".to_string(),
1419            super::CompleteFileOptions {
1420                ufs_length: Some(12),
1421                inode_id: Some(42),
1422                crc_type: Some(ChecksumTypeProto::ChecksumCrc32c as i32),
1423                crc_value: Some(0xe3069283),
1424                ..Default::default()
1425            },
1426            -1,
1427        );
1428        assert_eq!(req.path.as_deref(), Some("/tmp/a.bin"));
1429        assert_eq!(req.inode_id, Some(42));
1430        let opts = req.options.expect("CompleteFilePOptions");
1431        assert_eq!(opts.ufs_length, Some(12));
1432        assert_eq!(
1433            opts.crc_type,
1434            Some(ChecksumTypeProto::ChecksumCrc32c as i32)
1435        );
1436        assert_eq!(opts.crc_value, Some(0xe3069283));
1437    }
1438
1439    #[test]
1440    fn list_status_bfs_treats_trailing_slash_as_same_node() {
1441        assert_eq!(
1442            super::list_status_bfs_child_dir("/data", Some("/data/"), true),
1443            None
1444        );
1445        assert_eq!(
1446            super::list_status_bfs_child_dir("/data/", Some("/data"), true),
1447            None
1448        );
1449        assert_eq!(
1450            super::list_status_bfs_child_dir("/data/", Some("/data/nested/"), true),
1451            Some("/data/nested/")
1452        );
1453    }
1454
1455    /// Stand-in for `AuthedState`.  The exact field types do not matter for
1456    /// the property we are testing — what matters is that:
1457    /// - `epoch` is an immutable per-publication tag (analogue of
1458    ///   "this channel's `channel-id`")
1459    /// - `guard` is a resource whose drop must be deferred until no reader
1460    ///   is using this snapshot any more (analogue of `SaslStreamGuard`).
1461    struct AuthedStateLike {
1462        /// Identifies which `store(...)` produced this snapshot.  In the
1463        /// real code, the tonic `Channel`'s `channel-id` plays the same
1464        /// role.
1465        epoch: u64,
1466        /// Same epoch as above, but inside a separately-allocated leaf —
1467        /// in the real code the SASL session id would live separately
1468        /// from the channel-id metadata.  Reading both and asserting they
1469        /// match is what proves there is no torn read.
1470        guard_epoch: Arc<u64>,
1471        /// When the snapshot is dropped, increments the shared counter.
1472        /// Mirrors `SaslStreamGuard`'s `Drop` impl (which would close the
1473        /// SASL stream on the Master side).
1474        drop_counter: Arc<AtomicUsize>,
1475    }
1476
1477    impl Drop for AuthedStateLike {
1478        fn drop(&mut self) {
1479            self.drop_counter.fetch_add(1, Ordering::SeqCst);
1480        }
1481    }
1482
1483    fn new_state(epoch: u64, drop_counter: Arc<AtomicUsize>) -> Arc<AuthedStateLike> {
1484        Arc::new(AuthedStateLike {
1485            epoch,
1486            guard_epoch: Arc::new(epoch),
1487            drop_counter,
1488        })
1489    }
1490
1491    /// Property 1 — atomic publication.
1492    ///
1493    /// N reader threads continuously load the current `AuthedState` and
1494    /// assert that the `(epoch, guard_epoch)` pair is internally
1495    /// consistent.  Meanwhile a writer thread atomically swaps in fresh
1496    /// states.  A torn read would produce a snapshot whose `guard_epoch`
1497    /// disagrees with `epoch` — which can never happen with `ArcSwap`
1498    /// because the *whole* `Arc<AuthedState>` is replaced as one pointer.
1499    #[test]
1500    fn arcswap_publication_is_atomic_under_concurrent_readers() {
1501        const READERS: usize = 32;
1502        const RECONNECT_ROUNDS: usize = 200;
1503
1504        let drop_counter = Arc::new(AtomicUsize::new(0));
1505        let state = Arc::new(ArcSwap::from(new_state(0, drop_counter.clone())));
1506        let stop = Arc::new(AtomicBool::new(false));
1507        // Wait for every reader (and the writer) to be scheduled before the
1508        // reconnect loop starts — otherwise a slow CI runner can finish all
1509        // rounds before some reader threads ever enter their loop, which
1510        // falsely fails with "reader observed nothing".
1511        let ready = Arc::new(Barrier::new(READERS + 1));
1512
1513        let mut readers = Vec::with_capacity(READERS);
1514        for _ in 0..READERS {
1515            let state = state.clone();
1516            let stop = stop.clone();
1517            let ready = ready.clone();
1518            readers.push(thread::spawn(move || {
1519                ready.wait();
1520                let mut observed_epochs: Vec<u64> = Vec::new();
1521                while !stop.load(Ordering::Relaxed) {
1522                    let snap = state.load();
1523                    // The two fields are written by *different* allocations
1524                    // in different orders; only the `Arc<AuthedStateLike>`
1525                    // pointer publication is atomic.  This pair must
1526                    // always agree.
1527                    assert_eq!(
1528                        snap.epoch, *snap.guard_epoch,
1529                        "torn read: ArcSwap published a half-swapped snapshot",
1530                    );
1531                    observed_epochs.push(snap.epoch);
1532                }
1533                // One final load after stop so every reader records at least
1534                // the terminal published epoch even if scheduling was tight.
1535                let snap = state.load();
1536                assert_eq!(snap.epoch, *snap.guard_epoch);
1537                observed_epochs.push(snap.epoch);
1538                observed_epochs
1539            }));
1540        }
1541
1542        ready.wait();
1543
1544        // Writer: simulate `reconnect` events by store()'ing fresh states.
1545        for round in 1..=RECONNECT_ROUNDS {
1546            state.store(new_state(round as u64, drop_counter.clone()));
1547            // Yield so readers have a chance to observe each epoch.
1548            thread::sleep(Duration::from_micros(50));
1549        }
1550
1551        stop.store(true, Ordering::Relaxed);
1552        // Make sure each reader saw at least one swap take effect.
1553        for r in readers {
1554            let observed = r.join().expect("reader thread panicked");
1555            assert!(!observed.is_empty(), "reader observed nothing");
1556            let max = observed.iter().copied().max().unwrap();
1557            assert!(
1558                max >= 1,
1559                "reader never saw a reconnect-published epoch (max={})",
1560                max,
1561            );
1562        }
1563    }
1564
1565    /// Property 2 — no premature drop.
1566    ///
1567    /// A reader that has already `load()`ed a snapshot is then *paused*
1568    /// (e.g. parked between obtaining the channel and finishing the gRPC
1569    /// round-trip).  During the pause we run many more `store(...)`
1570    /// rounds.  The reader's `Guard`/`Arc<AuthedState>` keeps the old
1571    /// snapshot alive, so its `drop_counter` must NOT have ticked for
1572    /// that particular epoch yet.  Once the reader drops its handle, the
1573    /// old snapshot finally gets reclaimed.
1574    ///
1575    /// This is what guarantees, in the real code, that
1576    /// `SaslStreamGuard::drop` (which would close the SASL stream on the
1577    /// Master and unregister the `channel-id`) never fires while there
1578    /// are still in-flight RPCs holding a clone of the old client.
1579    #[test]
1580    fn old_snapshot_outlives_concurrent_swap_until_reader_releases() {
1581        let drop_counter = Arc::new(AtomicUsize::new(0));
1582        let state = Arc::new(ArcSwap::from(new_state(1, drop_counter.clone())));
1583
1584        // Reader grabs a snapshot and *holds it*.
1585        let held = state.load_full();
1586        assert_eq!(held.epoch, 1);
1587
1588        // While the reader is still holding `held`, simulate a flurry of
1589        // reconnect events.
1590        for round in 2..=50 {
1591            state.store(new_state(round, drop_counter.clone()));
1592        }
1593
1594        // The held snapshot must still be alive, hence its drop counter
1595        // contribution has not fired.
1596        // Other (orphaned-on-store) snapshots may have been dropped — but
1597        // *not the one we hold*.  Verify by inspecting the held snapshot
1598        // directly: if it had been dropped we would not be able to read
1599        // its fields without UB; we additionally assert that the absolute
1600        // drop count is < total publications, i.e. at least one snapshot
1601        // (the one we hold) is still alive.
1602        let observed_drops = drop_counter.load(Ordering::SeqCst);
1603        // 50 publications happened (epochs 1..=50). The current one in
1604        // `state` and the one held by `held` must both still be alive →
1605        // at most 48 drops so far.
1606        assert!(
1607            observed_drops <= 48,
1608            "old snapshot was dropped while a reader still held it: \
1609             drops = {} (expected <= 48)",
1610            observed_drops,
1611        );
1612        assert_eq!(held.epoch, 1, "held snapshot was mutated in place");
1613
1614        // Release the reader's hold.
1615        drop(held);
1616
1617        // Replace the still-current snapshot too so that *no* live Arc
1618        // remains, then wait for ArcSwap's lazy reclamation to settle.
1619        state.store(new_state(999, drop_counter.clone()));
1620        let deadline = Instant::now() + Duration::from_secs(2);
1621        loop {
1622            // 50 original epochs + 1 final = 51 publications, but the
1623            // final-stored one is still in `state`, so we expect 50 drops.
1624            if drop_counter.load(Ordering::SeqCst) >= 50 {
1625                break;
1626            }
1627            if Instant::now() > deadline {
1628                panic!(
1629                    "expected >= 50 drops after releasing the held snapshot, \
1630                     observed {}",
1631                    drop_counter.load(Ordering::SeqCst),
1632                );
1633            }
1634            thread::sleep(Duration::from_millis(5));
1635        }
1636    }
1637
1638    // ── P2C scheduler + in-flight counter tests ───────────────────────────
1639    //
1640    // These tests cover the P2C `pick()` selection logic and the
1641    // `InflightGuard` RAII semantics introduced to fix the "counter does
1642    // not track cloned MasterClient RPCs" issue. They do **not** perform any
1643    // network I/O — a lazy tonic channel is used so no connection is
1644    // established.
1645
1646    use super::{InflightGuard, MasterClient, MasterClientPool};
1647    use crate::config::GoosefsConfig;
1648    use std::panic::AssertUnwindSafe;
1649
1650    /// Build a `MasterClient` without any network I/O.
1651    ///
1652    /// `connect_lazy()` creates a `Channel` that only connects on the first
1653    /// RPC — which these tests never send. The resulting client has a valid
1654    /// `inflight: Arc<AtomicUsize>` field (initially 0) that the P2C
1655    /// scheduler reads.
1656    fn make_test_master_client() -> MasterClient {
1657        let endpoint = tonic::transport::Endpoint::from_static("http://localhost:0").connect_lazy();
1658        MasterClient::from_channel(endpoint, GoosefsConfig::new("localhost:0"))
1659    }
1660
1661    /// Build a pool of `n` test clients with P2C scheduling (no network I/O).
1662    fn make_test_pool(n: usize) -> MasterClientPool {
1663        let clients: Vec<Arc<MasterClient>> = (0..n)
1664            .map(|_| Arc::new(make_test_master_client()))
1665            .collect();
1666        MasterClientPool {
1667            clients,
1668            schedule: crate::config::MasterPoolSchedule::P2C,
1669            rr: AtomicUsize::new(0),
1670        }
1671    }
1672
1673    /// Under unequal load, `pick()` must always select the lighter channel.
1674    #[tokio::test]
1675    async fn pick_chooses_lighter_candidate() {
1676        let pool = make_test_pool(2);
1677        // Artificially load client 0 with 10 in-flight RPCs; client 1 stays idle.
1678        pool.clients[0].inflight.store(10, Ordering::Relaxed);
1679        // Regardless of which two candidates are sampled, the lighter one
1680        // (client 1) must win every time.
1681        for _ in 0..200 {
1682            let picked = pool.pick();
1683            assert!(
1684                Arc::ptr_eq(&picked, &pool.clients[1]),
1685                "pick() selected the heavier channel"
1686            );
1687        }
1688    }
1689
1690    /// With `n >= 2`, `pick()` never compares a channel against itself —
1691    /// the two sampled indices are always distinct.
1692    #[tokio::test]
1693    async fn pick_samples_two_distinct_candidates() {
1694        // With n=1 there is no choice; with n=2 the only possible pair is
1695        // (0,1), so every pick observes both. Verify that over many picks
1696        // both channels are returned (proving the loop produces a != b).
1697        let pool = make_test_pool(2);
1698        let mut saw_0 = false;
1699        let mut saw_1 = false;
1700        // Set equal load so selection is driven purely by the PRNG +
1701        // tie-break (la <= lb → a), not by load asymmetry.
1702        for _ in 0..1000 {
1703            let picked = pool.pick();
1704            if Arc::ptr_eq(&picked, &pool.clients[0]) {
1705                saw_0 = true;
1706            } else if Arc::ptr_eq(&picked, &pool.clients[1]) {
1707                saw_1 = true;
1708            } else {
1709                panic!("pick() returned a client not in the pool");
1710            }
1711        }
1712        assert!(
1713            saw_0 && saw_1,
1714            "pick() never sampled one of the two channels"
1715        );
1716    }
1717
1718    /// Under equal load, `pick()` distributes selections across all channels
1719    /// (no channel is starved over a reasonable sample size).
1720    #[tokio::test]
1721    async fn pick_balances_equal_loads() {
1722        let n = 4;
1723        let pool = make_test_pool(n);
1724        let mut hits = vec![0usize; n];
1725        for _ in 0..4000 {
1726            let picked = pool.pick();
1727            for (i, c) in pool.clients.iter().enumerate() {
1728                if Arc::ptr_eq(&picked, c) {
1729                    hits[i] += 1;
1730                    break;
1731                }
1732            }
1733        }
1734        // Every channel must receive at least one pick (P2C with uniform
1735        // sampling visits all channels given enough trials). We don't assert
1736        // a tight distribution — just non-starvation.
1737        for (i, &h) in hits.iter().enumerate() {
1738            assert!(h > 0, "channel {} was starved by pick()", i);
1739        }
1740    }
1741
1742    /// `InflightGuard` decrements the counter on normal scope exit.
1743    #[test]
1744    fn inflight_counter_decrements_on_normal_exit() {
1745        let counter = AtomicUsize::new(0);
1746        {
1747            counter.fetch_add(1, Ordering::Relaxed);
1748            let _guard = InflightGuard(&counter);
1749            assert_eq!(
1750                counter.load(Ordering::Relaxed),
1751                1,
1752                "counter must be 1 while guard alive"
1753            );
1754        }
1755        assert_eq!(
1756            counter.load(Ordering::Relaxed),
1757            0,
1758            "counter must return to 0 after guard drops"
1759        );
1760    }
1761
1762    /// `InflightGuard` decrements the counter on early (explicit) drop.
1763    #[test]
1764    fn inflight_counter_decrements_on_early_drop() {
1765        let counter = AtomicUsize::new(0);
1766        counter.fetch_add(1, Ordering::Relaxed);
1767        let guard = InflightGuard(&counter);
1768        assert_eq!(counter.load(Ordering::Relaxed), 1);
1769        drop(guard); // explicit early drop
1770        assert_eq!(
1771            counter.load(Ordering::Relaxed),
1772            0,
1773            "counter must be 0 after early drop"
1774        );
1775    }
1776
1777    /// `InflightGuard` decrements the counter even when the containing
1778    /// stack unwinds due to a panic (cancellation safety).
1779    #[test]
1780    fn inflight_guard_decrements_on_panic() {
1781        let counter = Arc::new(AtomicUsize::new(0));
1782        let counter_for_unwind = counter.clone();
1783        let result = std::panic::catch_unwind(AssertUnwindSafe(move || {
1784            counter_for_unwind.fetch_add(1, Ordering::Relaxed);
1785            let _guard = InflightGuard(&counter_for_unwind);
1786            assert_eq!(
1787                counter_for_unwind.load(Ordering::Relaxed),
1788                1,
1789                "counter must be 1 while guard alive"
1790            );
1791            panic!("simulated panic mid-RPC");
1792        }));
1793        assert!(result.is_err(), "test should have panicked");
1794        assert_eq!(
1795            counter.load(Ordering::Relaxed),
1796            0,
1797            "guard must decrement on panic unwind (cancellation safety)"
1798        );
1799    }
1800
1801    /// `MasterClient::clone()` shares the in-flight counter via `Arc`, so
1802    /// RPCs issued through a cloned client (e.g. by `GoosefsFileWriter`)
1803    /// are visible to the P2C scheduler. This is the core correctness
1804    /// property that fixes the "counter does not track cloned MasterClient"
1805    /// issue.
1806    #[tokio::test]
1807    async fn master_client_clone_shares_inflight_counter() {
1808        let original = make_test_master_client();
1809        let cloned = original.clone();
1810        // Mutate via the clone; the original must observe the same value.
1811        cloned.inflight.store(7, Ordering::Relaxed);
1812        assert_eq!(
1813            original.inflight.load(Ordering::Relaxed),
1814            7,
1815            "clone must share the in-flight counter (Arc<AtomicUsize>)"
1816        );
1817        // And vice-versa.
1818        original.inflight.store(3, Ordering::Relaxed);
1819        assert_eq!(
1820            cloned.inflight.load(Ordering::Relaxed),
1821            3,
1822            "mutations via original must be visible to clone"
1823        );
1824    }
1825
1826    /// `pick()` returns `Arc<MasterClient>` — cloning the Arc shares the
1827    /// same underlying `MasterClient` (and thus the same in-flight counter).
1828    #[tokio::test]
1829    async fn pick_returns_arc_sharing_inflight() {
1830        let pool = make_test_pool(2);
1831        let picked = pool.pick();
1832        let cloned = Arc::clone(&picked);
1833        assert!(
1834            Arc::ptr_eq(&picked, &cloned),
1835            "Arc::clone must share the same MasterClient"
1836        );
1837    }
1838
1839    /// Java `getStatusDefaults` always sets `loadMetadataType=ONCE` and
1840    /// `syncIntervalMs=-1`. An all-None options struct is proto `NEVER`.
1841    #[test]
1842    fn get_status_p_options_matches_java_defaults() {
1843        use crate::proto::grpc::file::LoadMetadataPType;
1844
1845        let opts = super::get_status_p_options(Some(LoadMetadataPType::Once), Some(-1));
1846        assert_eq!(
1847            opts.load_metadata_type,
1848            Some(LoadMetadataPType::Once as i32)
1849        );
1850        assert_eq!(
1851            opts.common_options
1852                .as_ref()
1853                .and_then(|c| c.sync_interval_ms),
1854            Some(-1)
1855        );
1856    }
1857
1858    #[test]
1859    fn get_status_p_options_unset_is_never_on_the_wire() {
1860        let opts = super::get_status_p_options(None, None);
1861        assert_eq!(
1862            opts.load_metadata_type, None,
1863            "unset load_metadata_type is proto NEVER (0) on the Master"
1864        );
1865        assert!(opts.common_options.is_none());
1866    }
1867
1868    #[test]
1869    fn get_status_uses_config_load_type_and_sync_interval() {
1870        use crate::proto::grpc::file::LoadMetadataPType;
1871
1872        let cfg = GoosefsConfig::new("localhost:0");
1873        assert_eq!(cfg.file_metadata_load_type, LoadMetadataPType::Once);
1874        assert_eq!(cfg.file_metadata_sync_interval, -1);
1875        let opts = super::get_status_p_options(
1876            Some(cfg.file_metadata_load_type),
1877            Some(cfg.file_metadata_sync_interval),
1878        );
1879        assert_eq!(opts.load_metadata_type, Some(1)); // ONCE
1880        assert_eq!(
1881            opts.common_options
1882                .as_ref()
1883                .and_then(|c| c.sync_interval_ms),
1884            Some(-1)
1885        );
1886    }
1887
1888    /// Java `listStatusDefaults` sets the same two fields as
1889    /// `getStatusDefaults`, for recursive and non-recursive listings alike.
1890    #[test]
1891    fn list_status_p_options_matches_java_defaults() {
1892        use crate::proto::grpc::file::LoadMetadataPType;
1893
1894        let opts = super::list_status_p_options(Some(LoadMetadataPType::Once), Some(-1));
1895        assert_eq!(
1896            opts.load_metadata_type,
1897            Some(LoadMetadataPType::Once as i32)
1898        );
1899        assert_eq!(
1900            opts.common_options
1901                .as_ref()
1902                .and_then(|c| c.sync_interval_ms),
1903            Some(-1)
1904        );
1905    }
1906
1907    /// A non-recursive `list_status` used to send no `loadMetadataType` at
1908    /// all, which the Master reads as `NEVER` — OpenDAL `list` then missed
1909    /// UFS-only entries. Pin the resolved default instead.
1910    #[test]
1911    fn list_status_p_options_unset_is_never_on_the_wire() {
1912        let opts = super::list_status_p_options(None, None);
1913        assert_eq!(
1914            opts.load_metadata_type, None,
1915            "unset load_metadata_type is proto NEVER (0) on the Master"
1916        );
1917        assert!(opts.common_options.is_none());
1918    }
1919
1920    #[test]
1921    fn list_status_uses_config_load_type_and_sync_interval() {
1922        use crate::proto::grpc::file::LoadMetadataPType;
1923
1924        let cfg = GoosefsConfig::new("localhost:0");
1925        let opts = super::list_status_p_options(
1926            Some(cfg.file_metadata_load_type),
1927            Some(cfg.file_metadata_sync_interval),
1928        );
1929        assert_eq!(
1930            opts.load_metadata_type,
1931            Some(LoadMetadataPType::Once as i32)
1932        );
1933        assert_eq!(
1934            opts.common_options
1935                .as_ref()
1936                .and_then(|c| c.sync_interval_ms),
1937            Some(-1)
1938        );
1939    }
1940
1941    /// Java `commonDefaults(conf, true)` always sets `syncIntervalMs` and a
1942    /// non-empty `operationId`.
1943    #[test]
1944    fn write_common_p_options_matches_java_mutating_defaults() {
1945        let opts = super::write_common_p_options(-1);
1946        assert_eq!(opts.sync_interval_ms, Some(-1));
1947        let op = opts
1948            .operation_id
1949            .expect("mutating RPCs must send operationId");
1950        assert!(
1951            op.most_significant_bits.is_some() && op.least_significant_bits.is_some(),
1952            "FsOpPId must carry both UUID halves"
1953        );
1954    }
1955
1956    /// Java `commonDefaults(conf)` (getStatus / listStatus / scheduleAsyncPersist)
1957    /// sets only `syncIntervalMs`.
1958    #[test]
1959    fn common_p_options_read_path_has_no_operation_id() {
1960        let opts = super::common_p_options(-1, None);
1961        assert_eq!(opts.sync_interval_ms, Some(-1));
1962        assert!(opts.operation_id.is_none());
1963    }
1964
1965    /// Caller-supplied common_options win; only unset fields are filled.
1966    #[test]
1967    fn fill_write_common_options_preserves_caller_values() {
1968        let caller_id = super::new_fs_op_id();
1969        let mut slot = Some(super::common_p_options(0, Some(caller_id)));
1970        super::fill_write_common_options(&mut slot, -1);
1971        let filled = slot.expect("slot stays Some");
1972        assert_eq!(
1973            filled.sync_interval_ms,
1974            Some(0),
1975            "caller syncIntervalMs must not be overwritten"
1976        );
1977        assert_eq!(filled.operation_id, Some(caller_id));
1978    }
1979
1980    #[test]
1981    fn fill_write_common_options_fills_empty_slot() {
1982        let mut slot = None;
1983        super::fill_write_common_options(&mut slot, -1);
1984        let filled = slot.expect("empty slot is filled");
1985        assert_eq!(filled.sync_interval_ms, Some(-1));
1986        assert!(filled.operation_id.is_some());
1987    }
1988
1989    /// Java `renameDefaults` reads `goosefs.user.file.persist.on.rename`
1990    /// (default false). The config default and an explicit true must both
1991    /// reach `RenamePOptions.persist`.
1992    #[test]
1993    fn rename_p_options_persist_follows_config() {
1994        let cfg = GoosefsConfig::new("localhost:0");
1995        let opts =
1996            super::rename_p_options(cfg.file_metadata_sync_interval, cfg.file_persist_on_rename);
1997        assert_eq!(opts.persist, Some(false));
1998        assert_eq!(
1999            opts.common_options
2000                .as_ref()
2001                .and_then(|c| c.sync_interval_ms),
2002            Some(-1)
2003        );
2004        assert!(opts
2005            .common_options
2006            .as_ref()
2007            .and_then(|c| c.operation_id)
2008            .is_some());
2009
2010        let opts = super::rename_p_options(-1, true);
2011        assert_eq!(opts.persist, Some(true));
2012    }
2013}