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}