Skip to main content

goosefs_sdk/io/
reader.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//! gRPC streaming block reader with flow-control ACK.
16//!
17//! Implements the Goosefs bidirectional streaming read protocol:
18//!
19//! ```text
20//! Client                                    Worker
21//!   │  1. ReadRequest(block_id, offset,        │
22//!   │     length, chunk_size)                  │
23//!   ├─────────────────────────────────────────→│
24//!   │  2. ReadResponse(chunk.data)             │
25//!   │←─────────────────────────────────────────┤
26//!   │  3. ReadRequest(offset_received=N)       │  ← flow-control ACK
27//!   ├─────────────────────────────────────────→│
28//!   │  4. ReadResponse(chunk.data) ...         │
29//!   │←─────────────────────────────────────────┤
30//!   │  5. stream ends                          │
31//! ```
32//!
33//! # Positioned read
34//!
35//! [`GrpcBlockReader::positioned_read`] opens a fresh stream with
36//! `position_short = true`, which tells the worker to skip prefetch and serve
37//! the exact requested byte range directly.  This path is used by
38//! `GoosefsFileInStream` for random seeks that cross a
39//! `TRANSFER_POSITIONED_READ_THRESHOLD` (8 KiB) boundary.
40
41use bytes::{Bytes, BytesMut};
42use tokio::sync::mpsc;
43use tokio::sync::mpsc::error::TrySendError;
44use tokio::task::JoinHandle;
45use tonic::Streaming;
46use tracing::{debug, trace, warn};
47
48use crate::client::WorkerClient;
49use crate::config::GoosefsConfig;
50use crate::error::{Error, Result};
51use crate::metrics::name;
52use crate::proto::grpc::block::{ReadRequest, ReadResponse};
53use crate::proto::proto::dataserver::OpenUfsBlockOptions;
54
55/// Per-stream tuning knobs for the sequential read path().
56///
57/// Carries the prefetch window, receive-buffer depth, and flow-control ACK
58/// coalescing thresholds resolved from [`GoosefsConfig`].
59#[derive(Debug, Clone, Copy)]
60pub struct ReadTuning {
61    /// Prefetch window in chunks sent on the initial `ReadRequest`().
62    pub prefetch_window: i32,
63    /// Receive-buffer depth between the background drain task and the
64    /// consumer, in messages().
65    pub buffer_messages: usize,
66    /// ACK coalescing threshold in bytes().
67    pub ack_interval_bytes: i64,
68    /// ACK coalescing threshold in chunks().
69    pub ack_interval_chunks: u32,
70}
71
72impl ReadTuning {
73    /// Resolve tuning knobs from the SDK config.
74    pub fn from_config(config: &GoosefsConfig) -> Self {
75        Self {
76            prefetch_window: config.prefetch_window,
77            buffer_messages: config.read_buffer_messages.max(1),
78            ack_interval_bytes: config.ack_interval_bytes.max(0),
79            ack_interval_chunks: config.ack_interval_chunks.max(1),
80        }
81    }
82}
83
84/// An item forwarded by the background stream-drain task to the consumer.
85///
86/// The explicit `End` sentinel is the linchpin of the C2 ("never silently
87/// short-read") invariant: a clean server half-close arrives as `End`,
88/// whereas the receiver channel closing *without* an `End` (drain task
89/// panicked / was aborted) is treated as an error, never as EOF.
90enum StreamItem {
91    /// A response frame from the worker.
92    Data(ReadResponse),
93    /// Clean end-of-stream (server half-closed via `message() == Ok(None)`).
94    End,
95    /// The drain task observed a transport error.
96    Error(Error),
97}
98
99/// Where a [`GrpcBlockReader`] pulls response frames from.
100enum ChunkSource {
101    /// Direct streaming — used by positioned (random) reads. ACKs are sent
102    /// per chunk; no background task is spawned (one-shot, low overhead).
103    Direct(Streaming<ReadResponse>),
104    /// Buffered drain — used by the sequential read path(). A
105    /// background task drains the tonic stream into a bounded channel so the
106    /// network pull is decoupled from application consumption.
107    Buffered {
108        rx: mpsc::Receiver<StreamItem>,
109        task: JoinHandle<()>,
110    },
111}
112
113/// A streaming reader for a single Goosefs block.
114///
115/// Wraps a bidirectional gRPC `ReadBlock` stream and implements
116/// flow-control via `offset_received` ACK messages.
117pub struct GrpcBlockReader {
118    /// Block being read.
119    block_id: i64,
120    /// Starting offset within the block.
121    offset: i64,
122    /// Total bytes expected.
123    length: i64,
124    /// Total bytes received so far.
125    bytes_received: i64,
126    /// Sender for client → server requests (ACK messages).
127    ///
128    /// Wrapped in `Option` so [`Self::half_close`] / [`Drop`] can close the
129    /// client→server half *before* the response `Streaming` is dropped.
130    /// Dropping the response stream first CANCELS the RPC; the Worker then
131    /// keeps the block lock, and the next `ReadBlock` of the same path hangs.
132    request_tx: Option<mpsc::Sender<ReadRequest>>,
133    /// Source of server → client responses (data chunks).
134    source: ChunkSource,
135    /// Bytes received since the last flow-control ACK was emitted.
136    bytes_since_last_ack: i64,
137    /// Chunks received since the last flow-control ACK was emitted.
138    chunks_since_last_ack: u32,
139    /// ACK coalescing threshold in bytes (`0` ⇒ ACK every chunk, used by the
140    /// Direct positioned-read path to preserve the original behaviour).
141    ack_interval_bytes: i64,
142    /// ACK coalescing threshold in chunks.
143    ack_interval_chunks: u32,
144}
145
146/// Decision of how the reader should treat a single `ReadResponse` (or its
147/// absence).
148///
149/// Extracted as a pure function so the empty-frame / EOF / data-deliver
150/// branches can be unit-tested without spinning up a real gRPC stream.
151#[derive(Debug, PartialEq, Eq)]
152enum ChunkAction {
153    /// The server has half-closed the stream — no more data is coming.
154    Eof,
155    /// The frame carries no data (keep-alive / header-only) but the stream
156    /// is still open. Caller must wait for the next frame; emitting `None`
157    /// here would silently truncate the read.
158    KeepReading,
159    /// A data frame: deliver these bytes to the caller.
160    Deliver(Bytes),
161}
162
163/// Classify a single response from the worker stream into one of three
164/// outcomes. Pure function — no I/O, no state, fully unit-testable.
165fn classify_response(resp: Option<ReadResponse>) -> ChunkAction {
166    match resp {
167        None => ChunkAction::Eof,
168        Some(r) => {
169            let data = r.chunk.and_then(|c| c.data).unwrap_or_default();
170            if data.is_empty() {
171                ChunkAction::KeepReading
172            } else {
173                ChunkAction::Deliver(Bytes::from(data))
174            }
175        }
176    }
177}
178
179/// Verify that a positioned-read stream delivered every requested byte.
180///
181/// Pure helper so the H2 short-read guard can be unit-tested without
182/// spinning up a real gRPC stream. Returns `Err(Error::Internal{..})`
183/// iff the server half-closed before `bytes_received == length`.
184fn check_positioned_read_complete(block_id: i64, bytes_received: i64, length: i64) -> Result<()> {
185    if bytes_received < length {
186        return Err(Error::Internal {
187            message: format!(
188                "short positioned read on block {}: received {} of {} bytes \
189                 (server half-closed early)",
190                block_id, bytes_received, length
191            ),
192            source: None,
193        });
194    }
195    Ok(())
196}
197
198/// Decide whether a coalesced flow-control ACK should be emitted now
199///(). Pure function so the policy is unit-testable without a
200/// live stream.
201///
202/// An ACK fires when *either* coalescing threshold is reached, *or* the full
203/// requested range has been received (forced final ACK). With
204/// `ack_interval_bytes == 0` this degenerates to "ACK every chunk", which is
205/// what the Direct positioned-read path uses to preserve original behaviour.
206fn should_send_ack(
207    bytes_since_last_ack: i64,
208    chunks_since_last_ack: u32,
209    ack_interval_bytes: i64,
210    ack_interval_chunks: u32,
211    bytes_received: i64,
212    length: i64,
213) -> bool {
214    bytes_since_last_ack >= ack_interval_bytes
215        || chunks_since_last_ack >= ack_interval_chunks
216        || bytes_received >= length
217}
218
219impl GrpcBlockReader {
220    /// Open a new streaming reader for the specified block range.
221    ///
222    /// This sends the initial `ReadRequest` and returns a reader
223    /// that yields data chunks via `read_chunk()`.
224    ///
225    /// When reading a block that only exists in UFS (e.g. written with
226    /// `THROUGH` mode), pass `Some(OpenUfsBlockOptions { .. })` so the
227    /// Worker can locate the data in the underlying storage.
228    pub async fn open(
229        worker: &WorkerClient,
230        block_id: i64,
231        offset: i64,
232        length: i64,
233        chunk_size: i64,
234        open_ufs_block_options: Option<OpenUfsBlockOptions>,
235    ) -> Result<Self> {
236        let (request_tx, response_rx) = worker
237            .read_block(
238                block_id,
239                offset,
240                length,
241                chunk_size,
242                None,
243                open_ufs_block_options,
244            )
245            .await?;
246
247        // Instrument: track concurrent block reads
248        crate::metrics::gauge(name::CLIENT_BLOCKS_READ_IN_PROGRESS)
249            .set(crate::metrics::gauge(name::CLIENT_BLOCKS_READ_IN_PROGRESS).get() + 1);
250
251        debug!(
252            block_id = block_id,
253            offset = offset,
254            length = length,
255            "opened GrpcBlockReader"
256        );
257
258        Ok(Self {
259            block_id,
260            offset,
261            length,
262            bytes_received: 0,
263            request_tx: Some(request_tx),
264            source: ChunkSource::Direct(response_rx),
265            bytes_since_last_ack: 0,
266            chunks_since_last_ack: 0,
267            // Direct mode keeps the original ACK-per-chunk behaviour.
268            ack_interval_bytes: 0,
269            ack_interval_chunks: 1,
270        })
271    }
272
273    /// Open a sequential block reader with prefetch + buffered drain + ACK
274    /// coalescing().
275    ///
276    /// Unlike [`Self::open`], this:
277    /// - sends `prefetch_window` on the initial request so the worker keeps
278    ///   `(1 + prefetch_window)` chunks in flight();
279    /// - spawns a background task that drains the tonic stream into a bounded
280    ///   channel (`buffer_messages` deep), decoupling network pull from
281    ///   application consumption();
282    /// - coalesces flow-control ACKs to one per `ack_interval_bytes` /
283    ///   `ack_interval_chunks` (plus a forced ACK at EOF), cutting round-trips
284    ///. **Default is one ACK per chunk** (`ack_interval_*` ⇒ every
285    ///   chunk) which is deadlock-safe regardless of the worker's flow-control
286    ///   window; the `try_send` path still removes the blocking ACK cost.
287    ///   Coalescing (>1 chunk) is opt-in via `GoosefsConfig` for workers
288    ///   confirmed to honour `prefetch_window`.
289    pub async fn open_sequential(
290        worker: &WorkerClient,
291        block_id: i64,
292        offset: i64,
293        length: i64,
294        chunk_size: i64,
295        open_ufs_block_options: Option<OpenUfsBlockOptions>,
296        tuning: ReadTuning,
297    ) -> Result<Self> {
298        let (request_tx, response_rx) = worker
299            .read_block(
300                block_id,
301                offset,
302                length,
303                chunk_size,
304                Some(tuning.prefetch_window),
305                open_ufs_block_options,
306            )
307            .await?;
308
309        crate::metrics::gauge(name::CLIENT_BLOCKS_READ_IN_PROGRESS)
310            .set(crate::metrics::gauge(name::CLIENT_BLOCKS_READ_IN_PROGRESS).get() + 1);
311
312        // Spawn the background drain task. It forwards each frame as a
313        // `StreamItem`, emits an explicit `End` sentinel on clean half-close,
314        // and forwards transport errors as `Error`. The consumer
315        // (`read_chunk`) distinguishes "channel closed with End" (clean EOF)
316        // from "channel closed without End" (task aborted/panicked → error),
317        // upholding the C2 no-silent-short-read invariant.
318        let (chunk_tx, chunk_rx) = mpsc::channel::<StreamItem>(tuning.buffer_messages);
319        let mut stream = response_rx;
320        let task = tokio::spawn(async move {
321            loop {
322                match stream.message().await {
323                    Ok(Some(resp)) => {
324                        if chunk_tx.send(StreamItem::Data(resp)).await.is_err() {
325                            // Consumer dropped — stop draining.
326                            break;
327                        }
328                    }
329                    Ok(None) => {
330                        let _ = chunk_tx.send(StreamItem::End).await;
331                        break;
332                    }
333                    Err(status) => {
334                        let _ = chunk_tx.send(StreamItem::Error(Error::from(status))).await;
335                        break;
336                    }
337                }
338            }
339        });
340
341        debug!(
342            block_id = block_id,
343            offset = offset,
344            length = length,
345            prefetch_window = tuning.prefetch_window,
346            buffer_messages = tuning.buffer_messages,
347            "opened GrpcBlockReader (sequential, buffered)"
348        );
349
350        Ok(Self {
351            block_id,
352            offset,
353            length,
354            bytes_received: 0,
355            request_tx: Some(request_tx),
356            source: ChunkSource::Buffered { rx: chunk_rx, task },
357            bytes_since_last_ack: 0,
358            chunks_since_last_ack: 0,
359            ack_interval_bytes: tuning.ack_interval_bytes,
360            ack_interval_chunks: tuning.ack_interval_chunks,
361        })
362    }
363
364    /// Read the next data chunk from the stream.
365    ///
366    /// Returns `None` when all expected data has been received.
367    /// Sends a flow-control `offset_received` ACK after each chunk (Direct)
368    /// or once per coalescing window (Buffered).
369    pub async fn read_chunk(&mut self) -> Result<Option<Bytes>> {
370        if self.bytes_received >= self.length {
371            self.half_close();
372            return Ok(None);
373        }
374
375        // Loop instead of recursing: the server may emit several empty
376        // keep-alive / header-only frames in a row, and `Box::pin`-ing the
377        // recursive call would otherwise heap-allocate per empty frame.
378        loop {
379            let resp = match &mut self.source {
380                ChunkSource::Direct(stream) => match stream.message().await? {
381                    None => {
382                        debug!(
383                            block_id = self.block_id,
384                            bytes_received = self.bytes_received,
385                            "stream ended before all expected data received"
386                        );
387                        return Ok(None);
388                    }
389                    Some(r) => r,
390                },
391                ChunkSource::Buffered { rx, .. } => match rx.recv().await {
392                    Some(StreamItem::Data(r)) => r,
393                    Some(StreamItem::End) => {
394                        debug!(
395                            block_id = self.block_id,
396                            bytes_received = self.bytes_received,
397                            "buffered stream reached clean EOF"
398                        );
399                        return Ok(None);
400                    }
401                    Some(StreamItem::Error(e)) => return Err(e),
402                    None => {
403                        // C2: the drain task closed the channel WITHOUT an
404                        // `End` sentinel. This means it panicked or was
405                        // aborted mid-stream — NOT a clean EOF. Surface an
406                        // error rather than silently truncating user data.
407                        return Err(Error::Internal {
408                            message: format!(
409                                "read stream drain task ended unexpectedly on block {} \
410                                 ({} of {} bytes received)",
411                                self.block_id, self.bytes_received, self.length
412                            ),
413                            source: None,
414                        });
415                    }
416                },
417            };
418
419            match classify_response(Some(resp)) {
420                // `classify_response(Some(_))` never yields `Eof` — that is
421                // reserved for `None`, which is handled in the source match
422                // above.
423                ChunkAction::Eof => unreachable!("Some(_) cannot classify as Eof"),
424                ChunkAction::KeepReading => {
425                    trace!(
426                        block_id = self.block_id,
427                        bytes_received = self.bytes_received,
428                        expected = self.length,
429                        "received empty data frame, awaiting next chunk"
430                    );
431                    continue;
432                }
433                ChunkAction::Deliver(data) => {
434                    let len = data.len() as i64;
435                    self.bytes_received += len;
436                    trace!(
437                        block_id = self.block_id,
438                        chunk_len = data.len(),
439                        total_received = self.bytes_received,
440                        "received chunk"
441                    );
442
443                    // Instrument: increment read bytes counter.
444                    crate::metrics::counter(name::CLIENT_BYTES_READ_LOCAL).inc(len);
445
446                    self.maybe_send_ack(len);
447                    if self.bytes_received >= self.length {
448                        self.half_close();
449                    }
450
451                    return Ok(Some(data));
452                }
453            }
454        }
455    }
456
457    /// Decide whether to emit a coalesced flow-control ACK and, if so, send it.
458    ///
459    /// Sends one `offset_received` ACK per `ack_interval_bytes` /
460    /// `ack_interval_chunks` window, plus a forced ACK once the full range has
461    /// been received. Uses `try_send`:
462    /// - `Full` ⇒ **keep the counters** and retry on the next chunk. Dropping
463    ///   an ACK here is a *liveness* concern only, never a correctness one:
464    ///   `offset_received` is always `offset + bytes_received`, which is
465    ///   monotonic regardless of how many ACKs are skipped().
466    /// - `Closed` ⇒ the stream is finishing; log and move on.
467    fn maybe_send_ack(&mut self, delta: i64) {
468        self.bytes_since_last_ack += delta;
469        self.chunks_since_last_ack += 1;
470
471        let need_ack = should_send_ack(
472            self.bytes_since_last_ack,
473            self.chunks_since_last_ack,
474            self.ack_interval_bytes,
475            self.ack_interval_chunks,
476            self.bytes_received,
477            self.length,
478        );
479        if !need_ack {
480            return;
481        }
482
483        let ack = ReadRequest {
484            offset_received: Some(self.offset + self.bytes_received),
485            ..Default::default()
486        };
487        let Some(tx) = self.request_tx.as_ref() else {
488            return;
489        };
490        match tx.try_send(ack) {
491            Ok(()) => {
492                self.bytes_since_last_ack = 0;
493                self.chunks_since_last_ack = 0;
494            }
495            Err(TrySendError::Full(_)) => {
496                // Keep counters; retry next chunk (liveness, not correctness).
497            }
498            Err(TrySendError::Closed(_)) => {
499                warn!(
500                    block_id = self.block_id,
501                    "ACK channel closed (read may be complete)"
502                );
503            }
504        }
505    }
506
507    /// Close the client→server half of the bidi `ReadBlock` stream.
508    ///
509    /// The Worker holds the block lock until it observes `onCompleted`.
510    /// Dropping the response `Streaming` first CANCELS the RPC instead,
511    /// so the lock is never released and the next read of the same path
512    /// hangs. Idempotent: subsequent calls are a no-op.
513    fn half_close(&mut self) {
514        if self.request_tx.take().is_some() {
515            debug!(
516                block_id = self.block_id,
517                bytes_received = self.bytes_received,
518                "half-closed ReadBlock request stream (Worker will unlock)"
519            );
520        }
521    }
522
523    /// Read all remaining data from this block and return it as a single `Bytes`.
524    ///
525    /// # H2 short-read guarantee
526    ///
527    /// `read_all` is the *positioned-read* tail (used by
528    /// [`Self::positioned_read`]). The caller has constrained `length` to a
529    /// range it knows is in-file, so a server-side half-close before
530    /// `bytes_received == length` indicates either a truncated stream or a
531    /// worker bug — surfacing it as `Error::Internal` lets the upper layer
532    /// (`GoosefsFileInStream::read_at`) decide whether to retry or propagate,
533    /// instead of returning misaligned data via a silent short read.
534    ///
535    /// The streaming sequential path uses [`Self::read_chunk`] directly and
536    /// is unaffected by this check.
537    pub async fn read_all(&mut self) -> Result<Bytes> {
538        let mut buf = BytesMut::with_capacity(self.length as usize);
539
540        while let Some(chunk) = self.read_chunk().await? {
541            buf.extend_from_slice(&chunk);
542        }
543
544        // Instrument: block read completed
545        crate::metrics::counter(name::CLIENT_BLOCKS_READ_TOTAL).inc(1);
546        crate::metrics::gauge(name::CLIENT_BLOCKS_READ_IN_PROGRESS)
547            .set((crate::metrics::gauge(name::CLIENT_BLOCKS_READ_IN_PROGRESS).get() - 1).max(0));
548
549        // H2: short-read guard. `read_chunk()` returns Ok(None) on either
550        // `bytes_received >= length` (the legitimate completion path) or
551        // server-half-close (`ChunkAction::Eof` before all bytes arrived).
552        // The latter must NOT be presented as a successful read.
553        check_positioned_read_complete(self.block_id, self.bytes_received, self.length)?;
554
555        Ok(buf.freeze())
556    }
557
558    /// The block ID being read.
559    pub fn block_id(&self) -> i64 {
560        self.block_id
561    }
562
563    /// Total bytes received so far.
564    pub fn bytes_received(&self) -> i64 {
565        self.bytes_received
566    }
567
568    /// Whether all expected data has been received.
569    pub fn is_complete(&self) -> bool {
570        self.bytes_received >= self.length
571    }
572
573    // ── Positioned read ──────────────────────────────────────────────────────
574
575    /// Perform a one-shot positioned read from `offset` for `length` bytes.
576    ///
577    /// Opens a **new** gRPC stream with `position_short = true`, reads all
578    /// data, and returns it as a single `Bytes`.  The new stream is discarded
579    /// after this call.
580    ///
581    /// # Design
582    ///
583    /// `position_short = true` instructs the worker to:
584    /// 1. Skip prefetch / eviction — serve the range directly from cache or UFS.
585    /// 2. Complete the stream after delivering exactly `length` bytes.
586    ///
587    /// This path is chosen by `GoosefsFileInStream` when the caller uses
588    /// `read_at()` (random access) or when the seek distance exceeds the
589    /// `TRANSFER_POSITIONED_READ_THRESHOLD` (8 KiB).
590    ///
591    /// # Arguments
592    ///
593    /// - `worker`    — connected `WorkerClient`.
594    /// - `block_id`  — block to read from.
595    /// - `offset`    — byte offset within the block.
596    /// - `length`    — number of bytes to read.
597    /// - `chunk_size` — preferred gRPC chunk size.
598    /// - `open_ufs_block_options` — required for THROUGH-mode blocks.
599    pub async fn positioned_read(
600        worker: &WorkerClient,
601        block_id: i64,
602        offset: i64,
603        length: i64,
604        chunk_size: i64,
605        open_ufs_block_options: Option<OpenUfsBlockOptions>,
606    ) -> Result<Bytes> {
607        let (request_tx, response_rx) = worker
608            .read_block_positioned(block_id, offset, length, chunk_size, open_ufs_block_options)
609            .await?;
610
611        debug!(
612            block_id = block_id,
613            offset = offset,
614            length = length,
615            "positioned_read: opened position_short stream"
616        );
617
618        let mut reader = Self {
619            block_id,
620            offset,
621            length,
622            bytes_received: 0,
623            request_tx: Some(request_tx),
624            source: ChunkSource::Direct(response_rx),
625            bytes_since_last_ack: 0,
626            chunks_since_last_ack: 0,
627            // Positioned reads ACK every chunk (one-shot, low overhead).
628            ack_interval_bytes: 0,
629            ack_interval_chunks: 1,
630        };
631
632        reader.read_all().await
633    }
634}
635
636impl Drop for GrpcBlockReader {
637    fn drop(&mut self) {
638        // Half-close the client→server half FIRST. The Worker holds the
639        // block lock until it observes `onCompleted`. Dropping `source`
640        // (the response `Streaming`) first would CANCEL the RPC instead,
641        // leaving the lock held — the next ReadBlock of the same path then
642        // waits forever.
643        self.half_close();
644        // Abort the background drain task (if any) so it does not keep
645        // draining the stream after the consumer goes away.
646        if let ChunkSource::Buffered { task, .. } = &self.source {
647            task.abort();
648        }
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use crate::proto::grpc::block::Chunk;
656
657    /// Verify that metrics instrumentation in read_chunk is sound.
658    /// (Full read path testing is integration-level; here we just verify
659    /// that the metrics counter is accessible and callable.)
660    #[test]
661    fn metrics_counter_accessible() {
662        let _counter = crate::metrics::counter(name::CLIENT_BYTES_READ_LOCAL);
663        // Just verifying no panics during counter access.
664    }
665
666    /// `None` from `Streaming::message()` means the server half-closed.
667    #[test]
668    fn classify_response_none_is_eof() {
669        assert_eq!(classify_response(None), ChunkAction::Eof);
670    }
671
672    /// **Regression**: a frame whose `chunk.data` is `None` or an empty
673    /// `Vec` MUST be treated as a keep-alive, NOT as EOF — the previous
674    /// implementation returned `None` here, which silently short-read user
675    /// data when the server emitted any header-only frame.
676    #[test]
677    fn classify_response_no_chunk_is_keep_reading() {
678        let resp = ReadResponse {
679            chunk: None,
680            ..Default::default()
681        };
682        assert_eq!(classify_response(Some(resp)), ChunkAction::KeepReading);
683    }
684
685    #[test]
686    fn classify_response_empty_chunk_is_keep_reading() {
687        let resp = ReadResponse {
688            chunk: Some(Chunk {
689                data: Some(Vec::new()),
690                ..Default::default()
691            }),
692            ..Default::default()
693        };
694        assert_eq!(classify_response(Some(resp)), ChunkAction::KeepReading);
695    }
696
697    #[test]
698    fn classify_response_chunk_with_none_data_is_keep_reading() {
699        let resp = ReadResponse {
700            chunk: Some(Chunk {
701                data: None,
702                ..Default::default()
703            }),
704            ..Default::default()
705        };
706        assert_eq!(classify_response(Some(resp)), ChunkAction::KeepReading);
707    }
708
709    /// Real data frames must be delivered byte-for-byte unchanged.
710    #[test]
711    fn classify_response_data_is_delivered() {
712        let payload = b"hello world".to_vec();
713        let resp = ReadResponse {
714            chunk: Some(Chunk {
715                data: Some(payload.clone()),
716                ..Default::default()
717            }),
718            ..Default::default()
719        };
720        match classify_response(Some(resp)) {
721            ChunkAction::Deliver(b) => assert_eq!(b.as_ref(), payload.as_slice()),
722            other => panic!("expected Deliver, got {:?}", other),
723        }
724    }
725
726    /// **Regression for H2 (short positioned read)**: when the server
727    /// half-closes before delivering the full requested range,
728    /// `read_all()` MUST surface an `Error::Internal` instead of returning
729    /// a truncated `Bytes`. The pre-fix behaviour returned the partial
730    /// buffer silently, which combined with the buggy `cur += length` in
731    /// `GoosefsFileInStream::read_at` caused mis-aligned data on the
732    /// caller side (random-access read returning wrong bytes).
733    #[test]
734    fn check_positioned_read_complete_short_read_errors() {
735        // Received < expected → Error::Internal with descriptive message.
736        let err = check_positioned_read_complete(
737            /* block_id */ 16777216, /* bytes_received */ 1024, /* length */ 4096,
738        )
739        .unwrap_err();
740        let msg = format!("{}", err);
741        assert!(
742            msg.contains("short positioned read on block 16777216"),
743            "expected short-read message, got: {}",
744            msg
745        );
746        assert!(
747            msg.contains("received 1024 of 4096"),
748            "expected received/length pair in message, got: {}",
749            msg
750        );
751    }
752
753    /// **Regression for H2**: the legitimate completion path
754    /// (`bytes_received == length`) MUST be Ok.
755    #[test]
756    fn check_positioned_read_complete_full_read_ok() {
757        assert!(check_positioned_read_complete(1, 4096, 4096).is_ok());
758    }
759
760    /// **Regression for H2**: any *over-read* (server delivered more than
761    /// asked — defensive check, should not happen in practice) is
762    /// **not** treated as a short read. Strictly `bytes_received < length`
763    /// is the failure condition.
764    #[test]
765    fn check_positioned_read_complete_over_read_ok() {
766        assert!(check_positioned_read_complete(1, 5000, 4096).is_ok());
767    }
768
769    /// : Direct positioned-read mode (`ack_interval_bytes == 0`,
770    /// `ack_interval_chunks == 1`) ACKs on every chunk.
771    #[test]
772    fn should_send_ack_direct_mode_acks_every_chunk() {
773        // 1 chunk, tiny payload, far from completion → still ACKs (chunks>=1).
774        assert!(should_send_ack(64, 1, 0, 1, 64, 1_000_000));
775    }
776
777    /// : in coalescing mode the ACK is suppressed until a threshold or
778    /// completion is hit.
779    #[test]
780    fn should_send_ack_coalesces_until_threshold() {
781        let interval_bytes = 4 * 1024 * 1024;
782        let interval_chunks = 4;
783        // 2 chunks / 2 MiB so far, mid-stream → no ACK yet.
784        assert!(!should_send_ack(
785            2 * 1024 * 1024,
786            2,
787            interval_bytes,
788            interval_chunks,
789            2 * 1024 * 1024,
790            64 * 1024 * 1024
791        ));
792        // Byte threshold reached → ACK.
793        assert!(should_send_ack(
794            interval_bytes,
795            2,
796            interval_bytes,
797            interval_chunks,
798            interval_bytes,
799            64 * 1024 * 1024
800        ));
801        // Chunk threshold reached → ACK.
802        assert!(should_send_ack(
803            1024,
804            interval_chunks,
805            interval_bytes,
806            interval_chunks,
807            4096,
808            64 * 1024 * 1024
809        ));
810    }
811
812    /// : the final chunk that completes the range always forces an ACK,
813    /// even if neither coalescing threshold is met.
814    #[test]
815    fn should_send_ack_forces_final_ack_at_completion() {
816        assert!(should_send_ack(
817            64,
818            1,
819            4 * 1024 * 1024,
820            4,
821            1_000_000,
822            1_000_000
823        ));
824    }
825
826    /// : `ReadTuning::from_config` reflects config defaults and clamps.
827    #[test]
828    fn read_tuning_from_config_defaults_and_clamps() {
829        let mut cfg = crate::config::GoosefsConfig::new("127.0.0.1:9200");
830        let t = ReadTuning::from_config(&cfg);
831        assert_eq!(t.prefetch_window, 8);
832        assert_eq!(t.buffer_messages, 16);
833        assert_eq!(t.ack_interval_bytes, 0); // ACK every chunk (deadlock-safe default)
834        assert_eq!(t.ack_interval_chunks, 1);
835
836        // Degenerate config values are clamped to safe minimums.
837        cfg.read_buffer_messages = 0;
838        cfg.ack_interval_chunks = 0;
839        cfg.ack_interval_bytes = -1;
840        let t = ReadTuning::from_config(&cfg);
841        assert_eq!(t.buffer_messages, 1);
842        assert_eq!(t.ack_interval_chunks, 1);
843        assert_eq!(t.ack_interval_bytes, 0);
844    }
845}