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}