Skip to main content

goosefs_sdk/client/
worker.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 Worker gRPC client for block data read/write.
16//!
17//! Wraps `BlockWorker` service (Worker:9203) providing:
18//! - `read_block` — bidirectional streaming block read
19//! - `write_block` — bidirectional streaming block write
20//!
21//! ## Write Protocol
22//!
23//! Goosefs Worker's `WriteBlock` is a bidirectional streaming RPC but the server
24//! does **not** send HTTP/2 response headers until the client sends a `flush`
25//! command or closes the stream. This means tonic's
26//! `client.write_block(stream).await` will block until the first server response.
27//!
28//! To work around this, `write_block()` returns a [`WriteBlockHandle`] that
29//! runs the gRPC call in a background tokio task. The caller sends data chunks
30//! through the request sender, then calls `flush()` or `close()` on the handle
31//! to receive server responses.
32
33use std::collections::HashMap;
34use std::sync::atomic::{AtomicU64, Ordering};
35use std::sync::Arc;
36use std::time::Duration;
37
38use dashmap::DashMap;
39use tokio::sync::{mpsc, Mutex as AsyncMutex};
40use tokio_stream::wrappers::ReceiverStream;
41use tokio_stream::StreamExt;
42use tonic::service::interceptor::InterceptedService;
43use tonic::transport::Channel;
44use tonic::Streaming;
45use tracing::{debug, instrument, warn};
46
47use arc_swap::ArcSwap;
48
49use crate::auth::{ChannelAuthenticator, ChannelIdInterceptor, SaslStreamGuard};
50use crate::config::GoosefsConfig;
51use crate::error::{Error, Result};
52use crate::proto::grpc::block::{
53    block_worker_client::BlockWorkerClient, write_request, ReadRequest, ReadResponse, RequestType,
54    WriteRequest, WriteRequestCommand, WriteResponse,
55};
56use crate::proto::proto::dataserver::{CreateUfsFileOptions, OpenUfsBlockOptions};
57
58/// Options for a `write_block` RPC that control *where* the Worker writes data.
59///
60/// - `GoosefsBlock` (default): write to Goosefs cache (MUST_CACHE / CACHE_THROUGH / ASYNC_THROUGH)
61/// - `UfsFile`: write directly to UFS (THROUGH mode), requires `create_ufs_file_options`
62/// - `UfsFallbackBlock`: cache-full fallback to UFS (TRY_CACHE)
63#[derive(Clone, Debug)]
64pub struct WriteBlockOptions {
65    /// The request type sent in the initial `WriteRequestCommand`.
66    pub request_type: RequestType,
67    /// UFS file creation options (required when `request_type == UfsFile`).
68    pub create_ufs_file_options: Option<CreateUfsFileOptions>,
69    /// Whether the write is asynchronous (ASYNC_THROUGH write type).
70    /// When true, the worker may flush data to UFS asynchronously after the
71    /// stream is closed. Defaults to `false`.
72    pub async_write: bool,
73}
74
75impl Default for WriteBlockOptions {
76    fn default() -> Self {
77        Self {
78            request_type: RequestType::GoosefsBlock,
79            create_ufs_file_options: None,
80            async_write: false,
81        }
82    }
83}
84
85/// Handle for an in-progress `WriteBlock` bidirectional streaming RPC.
86///
87/// The gRPC call runs in a background tokio task. The caller sends data through
88/// `request_tx` and receives responses via `recv_response()`. When done, call
89/// `close()` to drop the request channel and wait for the server to finalize.
90pub struct WriteBlockHandle {
91    /// Block being written.
92    block_id: i64,
93    /// Sender for client → server WriteRequest messages (data chunks, flush commands).
94    ///
95    /// Wrapped in `Option` so that `close()` can `take()` the sender (closing
96    /// the client→server half of the stream) without violating the move
97    /// semantics imposed by this type's `Drop` impl. `None` after `close()`
98    /// has run; senders attempting to use it should treat that as
99    /// "stream already closed".
100    pub request_tx: Option<mpsc::Sender<WriteRequest>>,
101    /// Receiver for server → client WriteResponse messages, forwarded from the background task.
102    response_rx: mpsc::Receiver<std::result::Result<WriteResponse, tonic::Status>>,
103    /// Handle to the background gRPC task that drives the bidirectional
104    /// `write_block` stream.
105    ///
106    /// The handle is wrapped in `Option` so that `close()` / `cancel()` can
107    /// take ownership (`take()`) and either await it (close) or abort it
108    /// (cancel). The `Drop` impl below also aborts the task as a safety net
109    /// in case the handle is dropped without going through `close`/`cancel`.
110    task_handle: Option<tokio::task::JoinHandle<()>>,
111}
112
113impl WriteBlockHandle {
114    /// Receive the next `WriteResponse` from the server (e.g., flush ack).
115    ///
116    /// Returns `None` if the server has closed the response stream.
117    pub async fn recv_response(&mut self) -> Result<Option<WriteResponse>> {
118        match self.response_rx.recv().await {
119            Some(Ok(resp)) => Ok(Some(resp)),
120            Some(Err(status)) => Err(Error::GrpcError {
121                message: format!(
122                    "WriteBlock server error for block_id={}: {}",
123                    self.block_id, status
124                ),
125                source: Box::new(status),
126            }),
127            None => Ok(None),
128        }
129    }
130
131    /// Close the write stream by dropping the request sender and wait for
132    /// any final response from the server.
133    pub async fn close(mut self) -> Result<()> {
134        // Drop the request sender to close the client→server half of the stream.
135        // The server will then call onCompleted → commitBlock → replySuccess.
136        // `take()` returns the sender (or `None` if already taken) — dropping
137        // it here closes the stream half.
138        drop(self.request_tx.take());
139        debug!(
140            block_id = self.block_id,
141            "closed write stream, waiting for server finalize"
142        );
143        // Wait for the server's final response (or stream close).
144        // This ensures the background task finishes before we return,
145        // preventing the Channel from being dropped while the task is still running.
146        let mut last_err: Option<Error> = None;
147        while let Some(result) = self.response_rx.recv().await {
148            match result {
149                Ok(_resp) => {
150                    debug!(
151                        block_id = self.block_id,
152                        "received final response from server"
153                    );
154                }
155                Err(status) => {
156                    last_err = Some(Error::GrpcError {
157                        message: format!(
158                            "WriteBlock server error for block_id={}: {}",
159                            self.block_id, status
160                        ),
161                        source: Box::new(status),
162                    });
163                    break;
164                }
165            }
166        }
167        // Join the background task so we surface a panic (rather than silently
168        // detaching it). The task should be finished by now because the
169        // response stream has been drained to `None` (or we broke out on
170        // error). We use a short timeout as a defensive measure so a
171        // hypothetical bug in the task does not hang `close()` forever.
172        if let Some(handle) = self.task_handle.take() {
173            match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {
174                Ok(Ok(())) => {}
175                Ok(Err(join_err)) => {
176                    if join_err.is_panic() {
177                        warn!(
178                            block_id = self.block_id,
179                            "WriteBlock background task panicked"
180                        );
181                    }
182                    // Cancelled or panicked — surface as error only if we
183                    // do not already have one from the stream.
184                    if last_err.is_none() {
185                        last_err = Some(Error::Internal {
186                            message: format!(
187                                "WriteBlock background task ended abnormally for block_id={}: {}",
188                                self.block_id, join_err
189                            ),
190                            source: None,
191                        });
192                    }
193                }
194                Err(_) => {
195                    warn!(
196                        block_id = self.block_id,
197                        "WriteBlock background task did not finish within 5s after stream drain; aborting"
198                    );
199                    // We cannot await again here because the JoinHandle was
200                    // moved into `timeout`; the task will be aborted when the
201                    // tokio runtime drops the handle.
202                }
203            }
204        }
205        if let Some(e) = last_err {
206            return Err(e);
207        }
208        Ok(())
209    }
210
211    /// Cancel the write stream without waiting for server finalization.
212    ///
213    /// Drops the request sender and response receiver immediately and
214    /// aborts the background gRPC task so its resources are released
215    /// promptly (rather than relying on the implicit "task exits because
216    /// channels were dropped" behaviour, which leaves the JoinHandle
217    /// detached on drop).
218    /// Matches Java's `GrpcBlockingStream.cancel()`.
219    pub async fn cancel(mut self) {
220        // Abort the background task *before* dropping the channels so the
221        // task does not race against the channel-closed signal. We then
222        // drop the channels so any pending `Sender::send` / `recv` futures
223        // owned by callers fail immediately.
224        if let Some(handle) = self.task_handle.take() {
225            handle.abort();
226        }
227        // The remaining fields are dropped automatically when `self` goes
228        // out of scope at the end of this function — we cannot explicitly
229        // `drop(self.request_tx)` here because doing so while a `Drop` impl
230        // exists for `WriteBlockHandle` would violate move semantics. The
231        // observable behaviour is identical: when the function returns,
232        // `self` (and therefore the channels) are dropped.
233        debug!(block_id = self.block_id, "cancelled write stream");
234    }
235}
236
237/// Safety net: aborts the background gRPC task if the handle is dropped
238/// without going through `close()` / `cancel()`.
239///
240/// Without this, an early `?` return on the error path leaves a detached
241/// tokio task that can hang indefinitely on `stream.message().await`
242/// (e.g. on a half-open server connection that never sends a final response),
243/// keeping the underlying tonic Channel alive and leaking resources.
244///
245/// `cancel()` and `close()` already `take()` the `task_handle`, so on the
246/// happy path `task_handle` is `None` here and `abort()` is a no-op —
247/// matching the doc-comment on `task_handle` above.
248impl Drop for WriteBlockHandle {
249    fn drop(&mut self) {
250        if let Some(handle) = self.task_handle.take() {
251            debug!(
252                block_id = self.block_id,
253                "WriteBlockHandle dropped without close()/cancel(); aborting background task"
254            );
255            handle.abort();
256        }
257    }
258}
259
260/// Type alias for the authenticated Worker gRPC client.
261type AuthenticatedBlockWorkerClient =
262    BlockWorkerClient<InterceptedService<Channel, ChannelIdInterceptor>>;
263
264/// Client for `BlockWorker` service on a single worker node.
265///
266/// Each `WorkerClient` carries a monotonic `generation` tag assigned by
267/// [`WorkerClientPool`] at construction time.  The generation allows callers
268/// that observed a failure on a specific client to request a **single-flight
269/// reconnect** via [`WorkerClientPool::reconnect_if_stale`]: only the first
270/// observer of generation `N` actually re-establishes the TCP+SASL
271/// connection; all concurrent observers with the same (or older) generation
272/// simply receive the already-replaced client.  This collapses the
273/// "thundering-herd reconnect" that previously produced hundreds of duplicate
274/// `authentication failed` warnings when a SASL session expired.
275#[derive(Clone)]
276pub struct WorkerClient {
277    inner: AuthenticatedBlockWorkerClient,
278    addr: String,
279    /// Monotonic tag identifying this exact connection instance.
280    ///
281    /// Two clients cached for the same address must have different
282    /// generations; a caller that observes a failure on generation `N` can
283    /// ask the pool to reconnect *only if* generation has not advanced yet.
284    generation: u64,
285    /// Keeps the SASL authentication stream alive for the channel's lifetime.
286    _sasl_guard: std::sync::Arc<Option<SaslStreamGuard>>,
287}
288
289impl WorkerClient {
290    /// Connect to a Goosefs Worker at the given address with authentication.
291    ///
292    /// Authentication is performed according to `config.auth_type`.
293    pub async fn connect(addr: &str, config: &GoosefsConfig) -> Result<Self> {
294        let endpoint = Channel::from_shared(format!("http://{}", addr))
295            .map_err(|e| Error::ConfigError {
296                message: format!("invalid worker endpoint: {}", e),
297            })?
298            .connect_timeout(config.connect_timeout)
299            // Set request_timeout: workers are the data plane and most prone
300            // to half-open connections. Without this, a hung gRPC stream
301            // (`read_block` / `write_block`) can stall indefinitely while
302            // the master/metrics/worker_manager paths all already enforce
303            // request_timeout.
304            .timeout(config.request_timeout);
305
306        let channel = endpoint.connect().await?;
307
308        // Perform SASL authentication based on the configured auth type
309        let authenticator =
310            ChannelAuthenticator::new(config.auth_type, config.auth_username.clone(), None)
311                .with_auth_timeout(config.auth_timeout);
312
313        let mut auth_channel = authenticator.authenticate(channel).await?;
314        let sasl_guard = auth_channel.take_sasl_guard();
315        debug!(addr = %addr, auth_type = %config.auth_type, "connected to Goosefs Worker");
316
317        Ok(Self {
318            inner: BlockWorkerClient::new(auth_channel.channel),
319            addr: addr.to_string(),
320            generation: 0,
321            _sasl_guard: std::sync::Arc::new(sasl_guard),
322        })
323    }
324
325    /// Connect to a Goosefs Worker with only connect_timeout (backward compatible, NOSASL).
326    ///
327    /// **Deprecated**: Use `connect(addr, config)` instead for proper authentication.
328    pub async fn connect_simple(addr: &str, connect_timeout: Duration) -> Result<Self> {
329        let endpoint = Channel::from_shared(format!("http://{}", addr))
330            .map_err(|e| Error::ConfigError {
331                message: format!("invalid worker endpoint: {}", e),
332            })?
333            .connect_timeout(connect_timeout);
334
335        let channel = endpoint.connect().await?;
336        let interceptor = ChannelIdInterceptor::new(uuid::Uuid::new_v4().to_string());
337        let intercepted = InterceptedService::new(channel, interceptor);
338        debug!(addr = %addr, "connected to Goosefs Worker (no auth)");
339
340        Ok(Self {
341            inner: BlockWorkerClient::new(intercepted),
342            addr: addr.to_string(),
343            generation: 0,
344            _sasl_guard: std::sync::Arc::new(None),
345        })
346    }
347
348    /// Create from an existing tonic channel (useful for testing / channel sharing).
349    ///
350    /// **Note**: This bypasses authentication.
351    pub fn from_channel(channel: Channel, addr: String) -> Self {
352        let interceptor = ChannelIdInterceptor::new("test-no-auth".to_string());
353        let intercepted = InterceptedService::new(channel, interceptor);
354        Self {
355            inner: BlockWorkerClient::new(intercepted),
356            addr,
357            generation: 0,
358            _sasl_guard: std::sync::Arc::new(None),
359        }
360    }
361
362    /// Start a bidirectional streaming ReadBlock RPC.
363    ///
364    /// Returns: (request_sender, response_stream)
365    ///
366    /// The caller sends an initial `ReadRequest` with block_id/offset/length,
367    /// then sends periodic `offset_received` ACKs. The response stream yields
368    /// `ReadResponse` containing `Chunk` data.
369    ///
370    /// When the block is only stored in UFS (e.g. written with `THROUGH` mode),
371    /// `open_ufs_block_options` must be provided so the Worker knows how to
372    /// locate and read the data from the underlying storage.
373    #[instrument(skip(self, open_ufs_block_options), fields(block_id = %block_id, offset = %offset, length = %length))]
374    pub async fn read_block(
375        &self,
376        block_id: i64,
377        offset: i64,
378        length: i64,
379        chunk_size: i64,
380        prefetch_window: Option<i32>,
381        open_ufs_block_options: Option<OpenUfsBlockOptions>,
382    ) -> Result<(mpsc::Sender<ReadRequest>, Streaming<ReadResponse>)> {
383        let (tx, rx) = mpsc::channel::<ReadRequest>(32);
384
385        // Send the initial read request. `prefetch_window`()
386        // tells the worker how many extra chunks it may keep in flight,
387        // raising the sequential-stream pipeline depth above the default
388        // 4 MiB fallback.
389        let initial_request = ReadRequest {
390            block_id: Some(block_id),
391            offset: Some(offset),
392            length: Some(length),
393            chunk_size: Some(chunk_size),
394            open_ufs_block_options,
395            offset_received: None,
396            position_short: None,
397            request_id: None,
398            capability: None,
399            block_size: None,
400            prefetch_window,
401        };
402        tx.send(initial_request)
403            .await
404            .map_err(|_| Error::BlockIoError {
405                message: "failed to send initial ReadRequest".to_string(),
406            })?;
407
408        let stream = ReceiverStream::new(rx);
409        let response = self.inner.clone().read_block(stream).await?;
410
411        Ok((tx, response.into_inner()))
412    }
413
414    /// Open a positioned (random-access) block read stream.
415    ///
416    /// Identical to [`read_block`](Self::read_block) but sets `position_short = true` in the
417    /// initial `ReadRequest`, instructing the worker to skip prefetch and
418    /// serve the exact requested byte range.
419    ///
420    /// Used by [`crate::io::reader::GrpcBlockReader::positioned_read`].
421    pub async fn read_block_positioned(
422        &self,
423        block_id: i64,
424        offset: i64,
425        length: i64,
426        chunk_size: i64,
427        open_ufs_block_options: Option<OpenUfsBlockOptions>,
428    ) -> Result<(mpsc::Sender<ReadRequest>, Streaming<ReadResponse>)> {
429        let (tx, rx) = mpsc::channel::<ReadRequest>(32);
430
431        let initial_request = ReadRequest {
432            block_id: Some(block_id),
433            offset: Some(offset),
434            length: Some(length),
435            chunk_size: Some(chunk_size),
436            open_ufs_block_options,
437            offset_received: None,
438            position_short: Some(true), // positioned-read hint to worker
439            request_id: None,
440            capability: None,
441            block_size: None,
442            prefetch_window: None,
443        };
444        tx.send(initial_request)
445            .await
446            .map_err(|_| Error::BlockIoError {
447                message: "failed to send initial positioned ReadRequest".to_string(),
448            })?;
449
450        let stream = ReceiverStream::new(rx);
451        let response = self.inner.clone().read_block(stream).await?;
452
453        Ok((tx, response.into_inner()))
454    }
455
456    /// Probe this worker for cached bytes of `block_ids` in the local store.
457    ///
458    /// Mirrors Java 2.0 `BlockWorkerClient.checkBlocks` /
459    /// `CheckBlocksResponse.block_cached_bytes`. Returns `block_id → cached_bytes`.
460    ///
461    /// On GooseFS 2.1.0 workers the same field carries bool-as-0/1; treat
462    /// `cached_bytes > 0` as present.
463    #[instrument(skip(self, block_ids), fields(block_count = block_ids.len()))]
464    pub async fn check_blocks(
465        &self,
466        block_ids: &[i64],
467    ) -> Result<std::collections::HashMap<i64, i64>> {
468        if block_ids.is_empty() {
469            return Ok(std::collections::HashMap::new());
470        }
471        let req = crate::proto::grpc::block::CheckBlocksRequest {
472            block_ids: block_ids.to_vec(),
473        };
474        let resp = self.inner.clone().check_blocks(req).await?;
475        Ok(resp.into_inner().block_cached_bytes)
476    }
477
478    /// Start a bidirectional streaming WriteBlock RPC.
479    ///
480    /// Returns a [`WriteBlockHandle`] that manages the background gRPC task.
481    /// The caller sends data chunks through `handle.request_tx`, then calls
482    /// `handle.recv_response()` to get flush acknowledgements.
483    ///
484    /// ## Why a background task?
485    ///
486    /// Goosefs Worker's `WriteBlock` RPC does **not** send HTTP/2 response
487    /// headers until the client sends a `flush` command or closes the stream.
488    /// tonic's `client.write_block(stream).await` waits for response headers
489    /// before resolving, so calling it inline would deadlock — we'd need the
490    /// returned sender to send flush, but we can't get the sender until the
491    /// call resolves.
492    ///
493    /// By spawning the gRPC call in a background task and forwarding responses
494    /// through an mpsc channel, we decouple request sending from response
495    /// receiving.
496    #[instrument(skip(self, options), fields(block_id = %block_id))]
497    pub async fn write_block(
498        &self,
499        block_id: i64,
500        space_to_reserve: i64,
501        options: WriteBlockOptions,
502    ) -> Result<WriteBlockHandle> {
503        let (tx, rx) = mpsc::channel::<WriteRequest>(32);
504
505        // Build the initial write command
506        let initial_command = WriteRequest {
507            value: Some(write_request::Value::Command(WriteRequestCommand {
508                r#type: Some(options.request_type as i32),
509                id: Some(block_id),
510                offset: Some(0),
511                flush: None,
512                create_ufs_file_options: options.create_ufs_file_options,
513                space_to_reserve: Some(space_to_reserve),
514                capability: None,
515                medium_type: None,
516                async_write: Some(options.async_write),
517            })),
518        };
519
520        // Build a composite stream: initial command first, then channel messages.
521        let initial_stream = tokio_stream::once(initial_command);
522        let subsequent_stream = ReceiverStream::new(rx);
523        let combined_stream = initial_stream.chain(subsequent_stream);
524
525        // Channel for forwarding server responses from the background task.
526        let (resp_tx, resp_rx) =
527            mpsc::channel::<std::result::Result<WriteResponse, tonic::Status>>(8);
528
529        let mut client = self.inner.clone();
530        let addr = self.addr.clone();
531
532        let task_handle = tokio::spawn(async move {
533            debug!(block_id = block_id, addr = %addr, "WriteBlock gRPC task started");
534
535            // This call blocks until the server sends response headers,
536            // which happens on the first flush or stream close.
537            let call_result = client.write_block(combined_stream).await;
538
539            match call_result {
540                Ok(response) => {
541                    let mut stream = response.into_inner();
542                    // Forward all server responses to the caller.
543                    loop {
544                        match stream.message().await {
545                            Ok(Some(msg)) => {
546                                if resp_tx.send(Ok(msg)).await.is_err() {
547                                    debug!(block_id = block_id, "response receiver dropped");
548                                    break;
549                                }
550                            }
551                            Ok(None) => {
552                                debug!(block_id = block_id, "server closed response stream");
553                                break;
554                            }
555                            Err(status) => {
556                                warn!(block_id = block_id, %status, "server response error");
557                                let _ = resp_tx.send(Err(status)).await;
558                                break;
559                            }
560                        }
561                    }
562                }
563                Err(status) => {
564                    warn!(block_id = block_id, %status, "WriteBlock RPC failed");
565                    let _ = resp_tx.send(Err(status)).await;
566                }
567            }
568
569            debug!(block_id = block_id, "WriteBlock gRPC task finished");
570        });
571
572        debug!(block_id = block_id, "WriteBlock handle created");
573
574        Ok(WriteBlockHandle {
575            block_id,
576            request_tx: Some(tx),
577            response_rx: resp_rx,
578            task_handle: Some(task_handle),
579        })
580    }
581
582    /// The worker address this client is connected to.
583    pub fn addr(&self) -> &str {
584        &self.addr
585    }
586
587    /// The monotonic generation tag assigned by the pool.
588    ///
589    /// Callers should save this value alongside the `WorkerClient` when
590    /// starting an RPC; if the RPC fails with an authentication error they
591    /// pass the saved generation back to
592    /// [`WorkerClientPool::reconnect_if_stale`] to trigger a single-flight
593    /// reconnect (de-duplicating concurrent observers of the same failure).
594    pub fn generation(&self) -> u64 {
595        self.generation
596    }
597}
598
599/// Connection pool for `WorkerClient` instances.
600///
601/// Caches authenticated gRPC channels by worker address, avoiding the overhead
602/// of re-establishing connections and re-authenticating for every block I/O.
603/// Matches Java's `FileSystemContext.acquireBlockWorkerClient()` pattern.
604///
605/// The pool is thread-safe and can be shared across concurrent workers.
606///
607/// ## Single-Flight Reconnect
608///
609/// When a SASL stream silently expires server-side, many concurrent RPCs on
610/// the same cached channel will fail simultaneously with UNAUTHENTICATED.
611/// Without coordination each observer would independently invoke `reconnect`,
612/// producing a "thundering herd" that serialises through the pool's write
613/// lock and wastes CPU/RTT on duplicate TCP+SASL handshakes.
614///
615/// To collapse this herd, each [`WorkerClient`] carries a monotonic
616/// `generation` tag.  Callers pass the observed generation back into
617/// [`reconnect_if_stale`](Self::reconnect_if_stale) after an auth failure;
618/// only the **first** observer of a given generation actually performs the
619/// reconnect, all other concurrent observers receive the already-replaced
620/// client.  This reduces N concurrent reconnects to exactly 1.
621pub struct WorkerClientPool {
622    /// Cached worker clients keyed by a **channel key**.
623    ///
624    /// With `pool_size == 1` the key is just the `"host:port"` address (legacy
625    /// behaviour). With `pool_size > 1` the key is `"host:port#slot"`, so each
626    /// worker address owns `pool_size` independent channels (each with its own
627    /// SASL session + generation). The stored client carries its own
628    /// `generation` in-band; readers clone it and inspect `client.generation()`.
629    ///
630    /// **Wait-free read path (P1)**: stored as an [`ArcSwap`] so the hot
631    /// `acquire` path is a single atomic load + map lookup + cheap `WorkerClient`
632    /// clone — no `tokio::sync::RwLock` round-trip. The client set changes only
633    /// on connect-miss / reconnect / invalidate (all rare), so writers do a
634    /// copy-on-write via [`ArcSwap::rcu`]; concurrent writers on different keys
635    /// are reconciled by `rcu`'s retry loop, and same-key connects are
636    /// single-flighted by the per-key reconnect mutex (see `acquire_by_key`).
637    /// Mirrors the `ArcSwap<AuthedState>` model already used by `MasterClient`.
638    clients: ArcSwap<HashMap<String, WorkerClient>>,
639    /// Per-address async mutex guarding the reconnect critical section.
640    ///
641    /// Keyed by the same channel key as `clients`. Separated from `clients` so
642    /// the reconnect handshake (which performs network I/O) does not hold the
643    /// clients-map write lock. Acquiring this mutex for one channel does not
644    /// block other channels' reconnects.
645    ///
646    ///:
647    /// changed from `tokio::sync::RwLock<HashMap<…>>` to `DashMap<…>` so the
648    /// `reconnect_lock_for` read path is lock-free (shard-level striped lock
649    /// inside DashMap, no async `.read().await` round-trip) — the old pattern
650    /// contended under 32+ concurrent readers hitting the same address.
651    reconnect_locks: DashMap<String, Arc<AsyncMutex<()>>>,
652    /// Per-address round-robin counter used to pick the next channel slot when
653    /// `pool_size > 1`. Lazily created on first `acquire` for an address.
654    ///
655    ///: same `RwLock<HashMap> → DashMap` swap as `reconnect_locks`
656    /// above; `next_slot`'s hot path no longer takes an async read lock.
657    addr_rr: DashMap<String, Arc<AtomicU64>>,
658    /// Number of channels to pool per worker address (≥ 1).
659    pool_size: usize,
660    /// Monotonic counter used to hand out a unique `generation` for every
661    /// freshly-created `WorkerClient` (global across all addresses + slots).
662    next_generation: AtomicU64,
663    /// Config used to create new connections.
664    config: GoosefsConfig,
665}
666
667impl WorkerClientPool {
668    /// Create a new empty connection pool.
669    pub fn new(config: GoosefsConfig) -> Self {
670        let pool_size = config.worker_connection_pool_size.max(1);
671        Self {
672            clients: ArcSwap::from_pointee(HashMap::new()),
673            reconnect_locks: DashMap::new(),
674            addr_rr: DashMap::new(),
675            pool_size,
676            // Start generations at 1 so `0` (the default on constructed-but-
677            // never-pooled clients) is always "stale" relative to any pooled
678            // client — this makes `reconnect_if_stale(addr, 0)` always force
679            // a fresh connection when needed.
680            next_generation: AtomicU64::new(1),
681            config,
682        }
683    }
684
685    /// Number of channels pooled per worker address.
686    pub fn pool_size(&self) -> usize {
687        self.pool_size
688    }
689
690    /// Channel-map key for `(addr, slot)`. For a single-channel pool this is
691    /// just `addr` (byte-identical to the legacy behaviour).
692    ///
693    ///:
694    /// replaced `format!("{addr}#{slot}")` with a pre-sized `String` +
695    /// `itoa::Buffer` to avoid the `core::fmt` machinery on every `acquire`
696    /// when `pool_size > 1`.
697    fn slot_key(&self, addr: &str, slot: usize) -> String {
698        if self.pool_size <= 1 {
699            return addr.to_string();
700        }
701        // `addr` + '#' + up-to-20-digit usize.
702        let mut s = String::with_capacity(addr.len() + 21);
703        s.push_str(addr);
704        s.push('#');
705        let mut buf = itoa::Buffer::new();
706        s.push_str(buf.format(slot));
707        s
708    }
709
710    /// Pick the next round-robin slot for `addr` (always `0` when `pool_size == 1`).
711    ///
712    ///: the `DashMap` read path is lock-free (shard-level striped
713    /// lock inside DashMap, no async `.read().await` round-trip). The old
714    /// `tokio::sync::RwLock<HashMap>` pattern took an async read lock on
715    /// every `acquire`, which contended under 32+ concurrent readers.
716    async fn next_slot(&self, addr: &str) -> usize {
717        if self.pool_size <= 1 {
718            return 0;
719        }
720        // Fast path: existing counter for this address.
721        if let Some(c) = self.addr_rr.get(addr) {
722            return (c.fetch_add(1, Ordering::Relaxed) % self.pool_size as u64) as usize;
723        }
724        // Miss: lazily create the counter. `entry()` is atomic.
725        let c = self
726            .addr_rr
727            .entry(addr.to_string())
728            .or_insert_with(|| Arc::new(AtomicU64::new(0)));
729        (c.fetch_add(1, Ordering::Relaxed) % self.pool_size as u64) as usize
730    }
731
732    /// Acquire a `WorkerClient` for the given address.
733    ///
734    /// Returns a cached client if one exists, otherwise creates a new connection.
735    /// With `pool_size > 1` this round-robins across the per-worker channels so
736    /// concurrent block reads spread over multiple HTTP/2 connections. The
737    /// tonic `Channel` itself also multiplexes, so each channel handles many
738    /// concurrent RPCs.
739    pub async fn acquire(&self, addr: &str) -> Result<WorkerClient> {
740        let slot = self.next_slot(addr).await;
741        let key = self.slot_key(addr, slot);
742        self.acquire_by_key(&key, addr).await
743    }
744
745    /// Get-or-create the cached client for a specific channel `key`, connecting
746    /// to `connect_addr` on a miss.
747    async fn acquire_by_key(&self, key: &str, connect_addr: &str) -> Result<WorkerClient> {
748        // Fast path (P1): wait-free atomic load + map lookup + cheap clone.
749        // Hit on every steady-state block read — no lock, no await.
750        if let Some(client) = self.clients.load().get(key).cloned() {
751            debug!(key = %key, generation = client.generation, "reusing cached WorkerClient");
752            return Ok(client);
753        }
754
755        // Miss: serialise the connect for this channel key via the per-key
756        // reconnect mutex so concurrent callers for the *same* key share a
757        // single TCP+SASL handshake. Callers for *different* keys still connect
758        // concurrently (no global lock — unlike the previous coarse write lock).
759        let lock = self.reconnect_lock_for(key).await;
760        let _guard = lock.lock().await;
761        // Double-check after acquiring the mutex (another task may have inserted
762        // while we queued).
763        if let Some(client) = self.clients.load().get(key).cloned() {
764            return Ok(client);
765        }
766
767        debug!(key = %key, addr = %connect_addr, "creating new WorkerClient for pool");
768        let mut client = WorkerClient::connect(connect_addr, &self.config).await?;
769        client.generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
770        self.insert_client(key, client.clone());
771        Ok(client)
772    }
773
774    /// Copy-on-write insert into the `clients` map (wait-free readers).
775    ///
776    /// Uses [`ArcSwap::rcu`] so concurrent inserts on *different* keys do not
777    /// clobber each other (the closure re-runs on contention).
778    fn insert_client(&self, key: &str, client: WorkerClient) {
779        self.clients.rcu(|cur| {
780            let mut next = (**cur).clone();
781            next.insert(key.to_string(), client.clone());
782            next
783        });
784    }
785
786    /// Copy-on-write remove from the `clients` map (wait-free readers).
787    fn remove_client(&self, key: &str) {
788        self.clients.rcu(|cur| {
789            let mut next = (**cur).clone();
790            next.remove(key);
791            next
792        });
793    }
794
795    /// Remove a worker from the pool (e.g., after a connection failure).
796    ///
797    /// The next `acquire()` call for this address will create a fresh connection.
798    ///
799    /// Also drops the per-address reconnect mutex from `reconnect_locks` so
800    /// the map does not grow unbounded when many distinct worker addresses
801    /// come and go (e.g. worker scale-up / scale-down). It is safe to drop
802    /// the mutex here because:
803    /// - any caller that already holds an `Arc<AsyncMutex<()>>` clone keeps
804    ///   it alive for the duration of its critical section;
805    /// - the *next* `reconnect_lock_for()` call for the same address will
806    ///   lazily install a fresh mutex.
807    /// In the worst case two callers racing across an `invalidate()` may
808    /// hold *different* mutex instances briefly, but the cache double-check
809    /// inside `reconnect_if_stale` still serialises the actual handshake
810    /// via the generation comparison, so correctness is preserved.
811    pub async fn invalidate(&self, addr: &str) {
812        // Remove every channel slot for this address.
813        let keys: Vec<String> = (0..self.pool_size)
814            .map(|s| self.slot_key(addr, s))
815            .collect();
816        // Single copy-on-write pass removing all slots for this address.
817        self.clients.rcu(|cur| {
818            let mut next = (**cur).clone();
819            for k in &keys {
820                if next.remove(k).is_some() {
821                    debug!(key = %k, "invalidated WorkerClient from pool");
822                }
823            }
824            next
825        });
826        // Best-effort cleanup of the per-channel reconnect locks + the
827        // round-robin counter to prevent unbounded growth of the maps over
828        // the lifetime of a long-running process.
829        //
830        // H2: `DashMap::remove` is a per-shard striped-lock operation — no
831        // async write lock round-trip, no blocking other addresses.
832        for k in &keys {
833            if self.reconnect_locks.remove(k).is_some() {
834                debug!(key = %k, "removed reconnect lock for invalidated worker");
835            }
836        }
837        if self.pool_size > 1 {
838            self.addr_rr.remove(addr);
839        }
840    }
841
842    /// Get (or lazily create) the per-address reconnect mutex.
843    ///
844    ///: `DashMap::entry` is atomic (shard-level striped lock), so the
845    /// double-checked-locking pattern is no longer needed — a single
846    /// `entry().or_insert_with()` call replaces the read-then-write pattern.
847    async fn reconnect_lock_for(&self, addr: &str) -> Arc<AsyncMutex<()>> {
848        self.reconnect_locks
849            .entry(addr.to_string())
850            .or_insert_with(|| Arc::new(AsyncMutex::new(())))
851            .clone()
852    }
853
854    /// **Single-flight reconnect**: invalidate + reconnect only if the
855    /// currently cached client's generation still matches `stale_generation`.
856    ///
857    /// This is the preferred recovery path on authentication failure.  The
858    /// caller passes the `generation()` of the client that just failed;
859    /// because every `WorkerClient` carries a unique monotonic generation
860    /// allocated by this pool:
861    ///
862    /// - If another concurrent task has **already** reconnected in response
863    ///   to the same underlying SASL expiry, the cached generation will have
864    ///   advanced past `stale_generation` and this call returns the
865    ///   already-replaced client **without** performing another
866    ///   TCP+SASL handshake.
867    /// - Otherwise, this call performs exactly one reconnect under the
868    ///   per-address mutex.
869    ///
870    /// Net effect: N concurrent `AuthenticationFailed` observers on the
871    /// same channel trigger exactly **one** reconnect instead of N.
872    pub async fn reconnect_if_stale(
873        &self,
874        addr: &str,
875        stale_generation: u64,
876    ) -> Result<WorkerClient> {
877        // Single-channel pool: the channel key is just `addr` (legacy path).
878        if self.pool_size <= 1 {
879            return self.reconnect_by_key(addr, addr, stale_generation).await;
880        }
881
882        // Multi-channel pool: the caller knows only `(addr, generation)`, not
883        // which slot failed. Generations are globally unique, so locate the
884        // slot whose cached client still carries `stale_generation` and
885        // reconnect exactly that channel. If no slot matches, the channel was
886        // already replaced by a concurrent task — just hand back a fresh
887        // round-robin client (the read will retry on it).
888        let mut target_key: Option<String> = None;
889        {
890            let cache = self.clients.load();
891            for s in 0..self.pool_size {
892                let k = self.slot_key(addr, s);
893                if let Some(c) = cache.get(&k) {
894                    if c.generation == stale_generation {
895                        target_key = Some(k);
896                        break;
897                    }
898                }
899            }
900        }
901        match target_key {
902            Some(key) => self.reconnect_by_key(&key, addr, stale_generation).await,
903            None => {
904                debug!(
905                    addr = %addr,
906                    observed = stale_generation,
907                    "reconnect coalesced — no slot matches stale generation (already refreshed)"
908                );
909                crate::metrics::counter(crate::metrics::name::CLIENT_WORKER_RECONNECTS_COALESCED)
910                    .inc(1);
911                self.acquire(addr).await
912            }
913        }
914    }
915
916    /// Single-flight reconnect for a specific channel `map_key`, connecting to
917    /// `connect_addr` on the actual handshake. Coalesces concurrent callers on
918    /// the same key via the per-key reconnect mutex + generation re-check.
919    async fn reconnect_by_key(
920        &self,
921        map_key: &str,
922        connect_addr: &str,
923        stale_generation: u64,
924    ) -> Result<WorkerClient> {
925        // Take the per-channel reconnect mutex.  Concurrent callers for the
926        // same channel serialise here; callers for *different* channels do
927        // not block each other.
928        let lock = self.reconnect_lock_for(map_key).await;
929        let _guard = lock.lock().await;
930
931        // Under the mutex, re-check the cache.  If another task already
932        // replaced the stale client while we were queuing, skip the
933        // reconnect entirely.
934        {
935            let cache = self.clients.load();
936            if let Some(client) = cache.get(map_key) {
937                if client.generation > stale_generation {
938                    debug!(
939                        key = %map_key,
940                        observed = stale_generation,
941                        current = client.generation,
942                        "reconnect coalesced — another task already refreshed this channel"
943                    );
944                    crate::metrics::counter(
945                        crate::metrics::name::CLIENT_WORKER_RECONNECTS_COALESCED,
946                    )
947                    .inc(1);
948                    return Ok(client.clone());
949                }
950            }
951        }
952
953        // We are the designated reconnect-er: drop the stale entry, then
954        // build and install a new one.
955        debug!(
956            key = %map_key,
957            stale_generation = stale_generation,
958            "performing single-flight reconnect"
959        );
960        crate::metrics::counter(crate::metrics::name::CLIENT_WORKER_RECONNECTS_TOTAL).inc(1);
961        self.remove_client(map_key);
962        let mut fresh = WorkerClient::connect(connect_addr, &self.config).await?;
963        fresh.generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
964        self.insert_client(map_key, fresh.clone());
965        debug!(
966            key = %map_key,
967            new_generation = fresh.generation,
968            "single-flight reconnect installed fresh WorkerClient"
969        );
970        Ok(fresh)
971    }
972
973    /// Invalidate a cached worker connection and immediately reconnect.
974    ///
975    /// **Prefer [`reconnect_if_stale`](Self::reconnect_if_stale) whenever the
976    /// caller holds a reference to the failing `WorkerClient`** — it
977    /// deduplicates concurrent reconnects triggered by the same underlying
978    /// SASL expiry.
979    ///
980    /// This unconditional variant is kept for paths where the caller does
981    /// not know the generation of the failing client (e.g. a stand-alone
982    /// `connect()` failure that never produced a `WorkerClient`).  It
983    /// acquires the same per-address reconnect mutex so it still coalesces
984    /// against any in-flight `reconnect_if_stale`.
985    pub async fn reconnect(&self, addr: &str) -> Result<WorkerClient> {
986        // Use `u64::MAX` as "stale" so the handshake always proceeds (current
987        // generation can never exceed MAX). This still passes through the
988        // per-channel mutex so concurrent callers on the same channel share a
989        // single handshake. For a multi-channel pool, reconnect one
990        // round-robin slot (the caller has no generation to target).
991        let slot = self.next_slot(addr).await;
992        let key = self.slot_key(addr, slot);
993        self.reconnect_by_key(&key, addr, u64::MAX).await
994    }
995
996    /// Create a new pool wrapped in `Arc` for shared ownership.
997    pub fn new_shared(config: GoosefsConfig) -> Arc<Self> {
998        Arc::new(Self::new(config))
999    }
1000
1001    // ── Test-only helpers ────────────────────────────────────────────
1002    //
1003    // These helpers are gated on `cfg(test)` so downstream code cannot
1004    // accidentally inject bypass-auth clients into the pool.  They exist
1005    // purely to let the unit tests in this module drive the single-flight
1006    // reconnect logic without needing a live Worker process to handshake
1007    // against.
1008
1009    /// Manually insert a client with a specific `generation` into the
1010    /// pool for testing.  Returns the previously-cached client, if any.
1011    #[cfg(test)]
1012    async fn test_install(&self, addr: &str, mut client: WorkerClient) -> Option<WorkerClient> {
1013        client.generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
1014        let prev = self.clients.load().get(addr).cloned();
1015        self.insert_client(addr, client);
1016        prev
1017    }
1018
1019    /// Snapshot the current cached generation for `addr` (if any).
1020    #[cfg(test)]
1021    async fn test_current_generation(&self, addr: &str) -> Option<u64> {
1022        self.clients.load().get(addr).map(|c| c.generation)
1023    }
1024
1025    /// Snapshot the number of entries in the `reconnect_locks` map.
1026    #[cfg(test)]
1027    async fn test_reconnect_locks_len(&self) -> usize {
1028        self.reconnect_locks.len()
1029    }
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034    use super::*;
1035    use tonic::transport::Channel;
1036
1037    /// Fabricate a `WorkerClient` from a *never-connected* channel.  The
1038    /// client is fully usable for anything that only touches the in-memory
1039    /// struct (addr/generation lookups, clone, drop), which is all the
1040    /// coalesce tests need.
1041    fn fake_client(addr: &str) -> WorkerClient {
1042        // `Channel::from_static` is synchronous and does not open a TCP
1043        // connection; any actual RPC on this channel would fail but the
1044        // tests below never issue one.
1045        let channel = Channel::from_static("http://127.0.0.1:1").connect_lazy();
1046        WorkerClient::from_channel(channel, addr.to_string())
1047    }
1048
1049    /// Worker-side multi-channel pool: `next_slot` round-robins per address
1050    /// and `slot_key` composes the channel key ( worker-side pool).
1051    #[tokio::test]
1052    async fn worker_pool_round_robins_slots() {
1053        let config = GoosefsConfig::new("127.0.0.1:9200").with_worker_connection_pool_size(4);
1054        let pool = WorkerClientPool::new(config);
1055        assert_eq!(pool.pool_size(), 4);
1056        assert_eq!(pool.slot_key("h:1", 0), "h:1#0");
1057        assert_eq!(pool.slot_key("h:1", 3), "h:1#3");
1058
1059        let mut seen = Vec::new();
1060        for _ in 0..8 {
1061            seen.push(pool.next_slot("h:1").await);
1062        }
1063        assert_eq!(seen, vec![0, 1, 2, 3, 0, 1, 2, 3]);
1064        // A different address has its own independent counter.
1065        assert_eq!(pool.next_slot("h:2").await, 0);
1066    }
1067
1068    /// Single-channel pool (default) keeps the legacy `addr`-keyed behaviour
1069    /// byte-for-byte, so existing single-flight tests are unaffected.
1070    ///
1071    /// Explicitly forces `worker_connection_pool_size = 1` so this test
1072    /// exercises the intended `slot_key(addr, 0) == addr` branch regardless
1073    /// of the SDK-level default (which is `min(cores, 4)` since B3).
1074    #[tokio::test]
1075    async fn worker_pool_single_channel_keys_by_addr() {
1076        let pool = WorkerClientPool::new(
1077            GoosefsConfig::new("127.0.0.1:9200").with_worker_connection_pool_size(1),
1078        );
1079        assert_eq!(pool.pool_size(), 1);
1080        assert_eq!(pool.slot_key("h:1", 0), "h:1");
1081        assert_eq!(pool.next_slot("h:1").await, 0);
1082        assert_eq!(pool.next_slot("h:1").await, 0);
1083    }
1084
1085    #[tokio::test]
1086    async fn test_reconnect_if_stale_coalesces_when_generation_advanced() {
1087        // Scenario: generation 5 is cached.  Caller A "observes" a failure
1088        // on gen 5 and calls reconnect_if_stale(5).  Before it enters the
1089        // critical section, caller B has already replaced gen 5 with gen 6
1090        // (simulated by manually bumping via test_install).  Caller A must
1091        // NOT trigger a second reconnect — it should return gen 6 as-is.
1092        //
1093        // Pool is pinned to `pool_size = 1` so `test_install(addr, …)` and
1094        // the pool's own key derivation share the same key `addr`. This
1095        // isolates the test from the default (`min(cores, 4)`) which uses
1096        // `addr#slot` keys.
1097        let pool = WorkerClientPool::new(
1098            GoosefsConfig::new("127.0.0.1:9200").with_worker_connection_pool_size(1),
1099        );
1100        let addr = "test-worker:9203";
1101
1102        // Install a gen-1 client, then another gen-2 client (simulating
1103        // "someone else already reconnected").
1104        pool.test_install(addr, fake_client(addr)).await;
1105        let gen_before = pool.test_current_generation(addr).await.unwrap();
1106        pool.test_install(addr, fake_client(addr)).await;
1107        let gen_after = pool.test_current_generation(addr).await.unwrap();
1108        assert!(gen_after > gen_before);
1109
1110        // Caller passes the *old* generation — pool must short-circuit and
1111        // NOT call WorkerClient::connect (which would fail against a
1112        // non-existent host and fail the test).
1113        let result = pool.reconnect_if_stale(addr, gen_before).await;
1114        assert!(
1115            result.is_ok(),
1116            "coalesced reconnect must short-circuit without network I/O, got {:?}",
1117            result.err()
1118        );
1119        let returned = result.unwrap();
1120        assert_eq!(
1121            returned.generation(),
1122            gen_after,
1123            "caller must receive the already-replaced generation"
1124        );
1125        assert_eq!(
1126            pool.test_current_generation(addr).await,
1127            Some(gen_after),
1128            "cached generation must not advance for a coalesced caller"
1129        );
1130    }
1131
1132    #[tokio::test]
1133    async fn test_reconnect_locks_are_per_address() {
1134        // Acquiring the reconnect lock for addr-A must not block acquiring
1135        // the lock for addr-B.  Without per-address locks, unrelated worker
1136        // reconnects would serialise through one global mutex.
1137        let pool = WorkerClientPool::new(GoosefsConfig::new("127.0.0.1:9200"));
1138        let lock_a = pool.reconnect_lock_for("worker-a:9203").await;
1139        let lock_b = pool.reconnect_lock_for("worker-b:9203").await;
1140
1141        // Hold A, must still be able to grab B immediately.
1142        let guard_a = lock_a.lock().await;
1143        let guard_b = tokio::time::timeout(std::time::Duration::from_millis(50), lock_b.lock())
1144            .await
1145            .expect("lock for different address must not be blocked");
1146        drop(guard_b);
1147        drop(guard_a);
1148    }
1149
1150    /// `invalidate()` must drop the per-address reconnect lock so the
1151    /// `reconnect_locks` map does not grow unbounded for long-running
1152    /// processes that connect to many distinct worker addresses (worker
1153    /// scale-up / scale-down).
1154    #[tokio::test]
1155    async fn test_invalidate_clears_reconnect_lock_to_prevent_leak() {
1156        // Pinned to single-channel so `test_install(addr, …)` uses the same
1157        // key as `reconnect_lock_for(addr)` (which the multi-channel default
1158        // would key by `addr#slot`).
1159        let pool = WorkerClientPool::new(
1160            GoosefsConfig::new("127.0.0.1:9200").with_worker_connection_pool_size(1),
1161        );
1162
1163        // Touch the reconnect-lock map for several addresses (simulates
1164        // reconnect activity over time).
1165        for i in 0..10 {
1166            let addr = format!("ephemeral-worker-{}:9203", i);
1167            pool.test_install(&addr, fake_client(&addr)).await;
1168            let _lock = pool.reconnect_lock_for(&addr).await;
1169        }
1170        assert_eq!(
1171            pool.test_reconnect_locks_len().await,
1172            10,
1173            "reconnect_locks must be populated by reconnect_lock_for()"
1174        );
1175
1176        // Now invalidate them (workers scaled down).
1177        for i in 0..10 {
1178            pool.invalidate(&format!("ephemeral-worker-{}:9203", i))
1179                .await;
1180        }
1181
1182        assert_eq!(
1183            pool.test_reconnect_locks_len().await,
1184            0,
1185            "invalidate() must remove the per-address reconnect lock so the \
1186             map does not leak across worker churn"
1187        );
1188    }
1189
1190    #[tokio::test]
1191    async fn test_generation_is_monotonic_across_installs() {
1192        let pool = WorkerClientPool::new(GoosefsConfig::new("127.0.0.1:9200"));
1193        let addr = "w:9203";
1194
1195        pool.test_install(addr, fake_client(addr)).await;
1196        let g1 = pool.test_current_generation(addr).await.unwrap();
1197
1198        pool.test_install(addr, fake_client(addr)).await;
1199        let g2 = pool.test_current_generation(addr).await.unwrap();
1200
1201        pool.test_install(addr, fake_client(addr)).await;
1202        let g3 = pool.test_current_generation(addr).await.unwrap();
1203
1204        assert!(g1 < g2, "gen {} not less than {}", g1, g2);
1205        assert!(g2 < g3, "gen {} not less than {}", g2, g3);
1206    }
1207
1208    // ── Auth-retry regression tests ──────────────────────────────────────
1209    //
1210    // These tests verify the core sequence of the auth-retry path:
1211    //   1. acquire() returns a cached (stale) WorkerClient
1212    //   2. RPC on that client fails with AuthenticationFailed
1213    //   3. Caller invokes reconnect_if_stale() or reconnect()
1214    //   4. Pool returns a fresh client (either already installed by another
1215    //      task, or via a new TCP+SASL handshake)
1216    //   5. Caller retries the RPC on the fresh client
1217    //
1218    // Steps 1–4 are testable at the pool level without a real server (using
1219    // test_install to simulate reconnect outcomes).  Step 5 requires a real
1220    // Goosefs cluster and is covered by `tests/auth_retry.rs` integration
1221    // tests.
1222
1223    /// **Auth-retry recovery point 1** (RPC failure → single-flight reconnect):
1224    ///
1225    /// Simulate the full auth-retry sequence at the pool level:
1226    /// 1. `acquire()` returns a cached client with generation N
1227    /// 2. An RPC on that client fails with `AuthenticationFailed`
1228    /// 3. Another concurrent reader has already reconnected (installed gen N+1)
1229    /// 4. `reconnect_if_stale(addr, N)` returns the already-installed fresh
1230    ///    client without a redundant TCP+SASL handshake
1231    ///
1232    /// This mirrors the code path in `GoosefsFileReader::read_next_block()`
1233    /// and `GoosefsFileInStream::read()` / `read_at()`:
1234    /// ```ignore
1235    /// Err(e) if e.is_authentication_failed() => {
1236    ///     let fresh = self.reconnect_worker(&addr, Some(worker_generation)).await?;
1237    ///     // retry RPC with fresh client
1238    /// }
1239    /// ```
1240    #[tokio::test]
1241    async fn test_auth_retry_reconnect_if_stale_returns_fresh_after_rpc_failure() {
1242        // Pinned to single-channel so `test_install`/`acquire` share keys.
1243        let pool = WorkerClientPool::new(
1244            GoosefsConfig::new("127.0.0.1:9200").with_worker_connection_pool_size(1),
1245        );
1246        let addr = "test-worker:9203";
1247
1248        // Step 1: Install and acquire a cached client (SASL-stale, but pool
1249        // doesn't know that yet — the client is valid from the pool's POV).
1250        pool.test_install(addr, fake_client(addr)).await;
1251        let stale_client = pool.acquire(addr).await.unwrap();
1252        let stale_gen = stale_client.generation();
1253
1254        // Step 2: Simulate that another concurrent reader already detected the
1255        // auth failure and triggered a reconnect — a fresh client with a higher
1256        // generation is now cached.
1257        pool.test_install(addr, fake_client(addr)).await;
1258        let expected_fresh_gen = pool.test_current_generation(addr).await.unwrap();
1259        assert!(
1260            expected_fresh_gen > stale_gen,
1261            "fresh gen must exceed stale gen"
1262        );
1263
1264        // Step 3: This caller's RPC failed with AuthenticationFailed; it calls
1265        // reconnect_if_stale(addr, stale_gen) for single-flight reconnect.
1266        // The pool sees generation has already advanced and returns the
1267        // existing client — no redundant TCP+SASL handshake.
1268        let fresh_client = pool
1269            .reconnect_if_stale(addr, stale_gen)
1270            .await
1271            .expect("reconnect_if_stale must return Ok when generation advanced");
1272
1273        assert_eq!(
1274            fresh_client.generation(),
1275            expected_fresh_gen,
1276            "must return the already-installed fresh client (coalesced reconnect)"
1277        );
1278        assert!(
1279            fresh_client.generation() > stale_gen,
1280            "fresh client generation ({}) must be > stale generation ({})",
1281            fresh_client.generation(),
1282            stale_gen
1283        );
1284
1285        // Verify pool generation didn't advance further (no duplicate reconnect)
1286        assert_eq!(
1287            pool.test_current_generation(addr).await,
1288            Some(expected_fresh_gen),
1289            "pool generation must not advance for a coalesced reconnect"
1290        );
1291    }
1292
1293    /// **Auth-retry recovery point 2** (acquire failure → unconditional reconnect):
1294    ///
1295    /// When `acquire()` itself fails with `AuthenticationFailed` (e.g. the
1296    /// connect+auth step returned UNAUTHENTICATED), no `WorkerClient` was
1297    /// produced and there is no generation to coalesce against.  The caller
1298    /// falls back to the unconditional `reconnect()` path.
1299    ///
1300    /// `reconnect()` is implemented as `reconnect_if_stale(addr, u64::MAX)`.
1301    /// Since no generation can ever exceed `u64::MAX`, this ALWAYS falls
1302    /// through to a real `WorkerClient::connect()` — it cannot coalesce.
1303    /// This is by design: the caller has no WorkerClient to compare
1304    /// generations against, so a fresh connection is always required.
1305    ///
1306    /// This test verifies the `u64::MAX` semantics — that `reconnect_if_stale`
1307    /// with `u64::MAX` does NOT short-circuit even when a client with a
1308    /// valid generation exists in the pool.
1309    ///
1310    /// Note: Testing the actual reconnect requires a real Goosefs server.
1311    /// See `tests/auth_retry.rs` for integration test stubs.
1312    #[tokio::test]
1313    async fn test_auth_retry_unconditional_reconnect_never_short_circuits() {
1314        let pool = WorkerClientPool::new(GoosefsConfig::new("127.0.0.1:9200"));
1315        let addr = "test-worker:9203";
1316
1317        // Install a client with some generation
1318        pool.test_install(addr, fake_client(addr)).await;
1319        let current_gen = pool.test_current_generation(addr).await.unwrap();
1320
1321        // reconnect_if_stale(addr, u64::MAX) must NOT short-circuit,
1322        // because current_gen can never exceed u64::MAX.
1323        // It will try to connect to the real server (which doesn't exist),
1324        // so we expect a transport error, NOT a successful return of the
1325        // existing client.
1326        let result = pool.reconnect_if_stale(addr, u64::MAX).await;
1327        assert!(
1328            result.is_err(),
1329            "reconnect_if_stale(addr, u64::MAX) must NOT short-circuit \
1330             when generation ({}) < u64::MAX — expected real connect attempt",
1331            current_gen
1332        );
1333    }
1334
1335    /// **Auth-retry thundering-herd collapse**:
1336    ///
1337    /// When a SASL stream expires server-side, N concurrent readers on the
1338    /// same cached channel will all observe `AuthenticationFailed`
1339    /// simultaneously.  Without single-flight reconnect, each would
1340    /// independently invoke `reconnect`, producing N TCP+SASL handshakes.
1341    ///
1342    /// With single-flight (`reconnect_if_stale`), only the first observer
1343    /// of generation N triggers a real reconnect; all other observers with
1344    /// the same (or older) generation receive the already-replaced client.
1345    ///
1346    /// This test simulates: stale gen N → first observer reconnects
1347    /// (installs N+1) → second observer with stale gen N gets N+1.
1348    #[tokio::test]
1349    async fn test_auth_retry_multiple_observers_collapse_to_one_reconnect() {
1350        // Pinned to single-channel so `test_install`/`acquire` share keys.
1351        let pool = WorkerClientPool::new(
1352            GoosefsConfig::new("127.0.0.1:9200").with_worker_connection_pool_size(1),
1353        );
1354        let addr = "test-worker:9203";
1355
1356        // Install initial client (SASL-stale)
1357        pool.test_install(addr, fake_client(addr)).await;
1358        let stale_gen = pool.test_current_generation(addr).await.unwrap();
1359
1360        // First observer detects auth failure, triggers reconnect.
1361        // (In reality this would call reconnect_if_stale which does
1362        // WorkerClient::connect; we simulate the outcome with test_install.)
1363        pool.test_install(addr, fake_client(addr)).await;
1364        let fresh_gen = pool.test_current_generation(addr).await.unwrap();
1365        assert!(fresh_gen > stale_gen);
1366
1367        // Second observer with the same stale generation must get the
1368        // already-installed fresh client — NO duplicate reconnect.
1369        let client = pool
1370            .reconnect_if_stale(addr, stale_gen)
1371            .await
1372            .expect("coalesced reconnect must succeed");
1373        assert_eq!(
1374            client.generation(),
1375            fresh_gen,
1376            "second observer must get the already-installed fresh client"
1377        );
1378
1379        // Pool generation must not advance further (no duplicate reconnect)
1380        assert_eq!(
1381            pool.test_current_generation(addr).await,
1382            Some(fresh_gen),
1383            "generation must not advance for a coalesced observer"
1384        );
1385
1386        // Third observer with an even older generation (0 = never-pooled)
1387        // must also get the fresh client
1388        let client_old = pool
1389            .reconnect_if_stale(addr, 0)
1390            .await
1391            .expect("observer with gen=0 must also get coalesced client");
1392        assert_eq!(
1393            client_old.generation(),
1394            fresh_gen,
1395            "observer with stale gen=0 must get the same fresh client"
1396        );
1397    }
1398
1399    /// **Regression for C3**: dropping a `WriteBlockHandle` without going
1400    /// through `close()` / `cancel()` MUST abort the background gRPC task.
1401    ///
1402    /// Pre-fix behaviour: the comment claimed there was a `Drop` safety net
1403    /// but the impl was missing. An early `?` on the error path therefore
1404    /// left a detached task forever stuck on `stream.message().await`,
1405    /// pinning the channel and leaking resources.
1406    #[tokio::test]
1407    async fn write_block_handle_drop_aborts_background_task() {
1408        // Channels must look real but never carry traffic.
1409        let (tx, _rx) = mpsc::channel::<WriteRequest>(8);
1410        let (_resp_tx, resp_rx) = mpsc::channel(8);
1411
1412        // A background task that would otherwise hang forever — Drop must
1413        // abort it via task_handle.abort().
1414        let task = tokio::spawn(async {
1415            std::future::pending::<()>().await;
1416        });
1417        let abort_handle = task.abort_handle();
1418
1419        let handle = WriteBlockHandle {
1420            block_id: 42,
1421            request_tx: Some(tx),
1422            response_rx: resp_rx,
1423            task_handle: Some(task),
1424        };
1425
1426        assert!(
1427            !abort_handle.is_finished(),
1428            "task should still be running before Drop"
1429        );
1430
1431        // Drop the handle on the error path (simulating an early `?` return).
1432        drop(handle);
1433
1434        // Wait briefly for tokio to process the abort.
1435        for _ in 0..50 {
1436            if abort_handle.is_finished() {
1437                break;
1438            }
1439            tokio::time::sleep(Duration::from_millis(10)).await;
1440        }
1441        assert!(
1442            abort_handle.is_finished(),
1443            "Drop did not abort background task — pre-fix regression"
1444        );
1445    }
1446
1447    /// `close()` already takes the task_handle and awaits it; the subsequent
1448    /// `Drop` must therefore see `task_handle = None` and be a complete
1449    /// no-op (no double-abort).
1450    #[tokio::test]
1451    async fn write_block_handle_drop_after_close_is_noop() {
1452        let (tx, _rx) = mpsc::channel::<WriteRequest>(8);
1453        // Drop the response sender immediately: the channel is closed, so
1454        // close()'s `response_rx.recv()` loop terminates straight away
1455        // (matches the real-world "server done" signal).
1456        let (_, resp_rx) = mpsc::channel(8);
1457
1458        // Spawn a task that finishes immediately so close()'s join completes.
1459        let task = tokio::spawn(async {});
1460
1461        let handle = WriteBlockHandle {
1462            block_id: 7,
1463            request_tx: Some(tx),
1464            response_rx: resp_rx,
1465            task_handle: Some(task),
1466        };
1467
1468        // close() takes ownership of the handle, drains the (already-closed)
1469        // response stream, joins the task, and returns. After it returns,
1470        // `self` is dropped — the Drop impl must be a no-op because
1471        // `task_handle.take()` already happened inside close().
1472        let close_fut = handle.close();
1473        let res = tokio::time::timeout(Duration::from_millis(500), close_fut).await;
1474        assert!(
1475            res.is_ok(),
1476            "close() must complete promptly when response stream is closed"
1477        );
1478        assert!(
1479            res.unwrap().is_ok(),
1480            "close() should succeed on a graceful task"
1481        );
1482    }
1483}