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}