Skip to main content

rudb_io/
pool.rs

1//! The I/O threads, which are not the execution threads.
2//!
3//! `spec/engine/05-scan.md` section 5.3. A thread blocked on a read is not a core lost to
4//! execution, because the thread that blocked was never an execution thread. That is the whole
5//! idea and it is the one DuckDB v2.0 arrived at, with the practical effect that on a machine where
6//! the data does not fit in the page cache the engine keeps the CPU busy while the disk works.
7//!
8//! # Why the two pools are sized apart
9//!
10//! The execution pool wants one thread per core, because more than that is context switches
11//! between threads that all want the same ALUs. The I/O pool wants however many concurrent reads
12//! it takes to keep the device busy, which for a local NVMe is a small number and for object
13//! storage is a large one: an S3 GET is a hundred milliseconds of waiting and nothing else, so the
14//! only way to fill a link is to have a lot of them outstanding at once. Those two numbers differ
15//! by an order of magnitude, which is why this pool is sized on its own rather than being told the
16//! core count and left to it. [`Config::local_disk`] and [`Config::object_store`] are the two
17//! answers.
18//!
19//! # Coalescing
20//!
21//! A batch handed to [`Pool::submit`] is sorted and adjacent ranges are merged into one physical
22//! read, then scattered back into the per request buffers. Section 5.3 says coalescing is worth
23//! more than concurrency on spinning media and on object storage, where the per request cost
24//! dominates. It is not free: merging two ranges means reading into a scratch buffer and copying
25//! out of it, plus reading the bytes in the gap and throwing them away. Which way that comes out is
26//! a fact about a machine and about an access pattern, so it is a knob with a measurement behind it
27//! rather than a thing that is always on. `cargo xtask io` is the measurement, and the defaults in
28//! [`Config::local_disk`] say which run of it produced them.
29//!
30//! The short version of that run: cold, coalescing over a small gap is worth nearly two to one on
31//! scattered pages and worth nothing on a sequential scan, and coalescing over a large gap is worth
32//! two to one against you on a column projection. So the gap is small.
33//!
34//! # What this is not
35//!
36//! It is not io_uring and it is not asynchronous in the sense of a runtime. There are threads and
37//! they block, which is what the standard library gives us and what the zero dependency rule in
38//! `spec/18-package-layout.md` leaves us with. Section 5.3 records io_uring as a possible later
39//! change under the same [`File::submit`] interface, with the measurement that would justify it,
40//! which is this pool showing up as a bottleneck on `server1` where there are four cores to spare.
41
42use std::collections::VecDeque;
43use std::sync::atomic::{AtomicU64, Ordering};
44use std::sync::{Arc, Condvar, Mutex};
45use std::thread::JoinHandle;
46
47use rudb_common::Result;
48
49use crate::File;
50use crate::submit::{Completion, Filler, Request, Response};
51
52/// How an [`Pool`] is sized and how hard it coalesces.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct Config {
55    /// How many I/O threads. One means every batch is served in submission order by one thread,
56    /// which is a useful thing for a test to be able to ask for.
57    pub threads: usize,
58    /// Two ranges no further apart than this are read as one, and the bytes in the gap are read
59    /// and thrown away. Zero merges only ranges that touch.
60    pub coalesce_gap: u64,
61    /// A merged read never spans more than this, however small the gaps are. Without it a
62    /// projection of two columns from opposite ends of a row group turns into a read of the row
63    /// group.
64    pub coalesce_span: u64,
65    /// Whether to merge at all.
66    pub coalesce: bool,
67}
68
69impl Config {
70    /// The sizing for a local disk.
71    ///
72    /// Every number here came out of `cargo xtask io --cold` on `server3`, which is what that task
73    /// exists for. The warm table is the opposite of the cold one on almost every row, which is the
74    /// reason the cold one is the one that decided this.
75    ///
76    /// Threads equal to the core count, floored at two and capped at eight. Cold, on eight cores, a
77    /// batch of scattered reads goes from 2.7 seconds through the loop to 392 milliseconds at eight
78    /// threads, and sixteen threads is 413, inside the spread. Sequential is 204 at eight and 211 at
79    /// sixteen, the same. Eight is where the device saturates and past it the extra threads are
80    /// contending for the memory bandwidth the execution threads want.
81    ///
82    /// Coalescing on, over gaps of sixteen kilobytes. The gap is the whole decision and it is a
83    /// narrow one. Sixteen kilobytes takes a batch of scattered eight kilobyte pages from 413
84    /// milliseconds to 223, nearly twice as fast, and it does it while reading only 1.22 times the
85    /// bytes asked for. Widening it to half a megabyte buys nothing on that pattern that is outside
86    /// the spread, reads 7.03 times the bytes, and costs a column projection dearly: 64 kilobyte
87    /// ranges 448 kilobytes apart go from 60 milliseconds unmerged to 115 merged, because the gaps
88    /// between columns are real and reading them is work. Sixteen kilobytes is small enough to leave
89    /// that pattern alone entirely, which is why it is the number.
90    ///
91    /// The span cap means a sequential scan in one megabyte ranges merges nothing at all, which the
92    /// table confirms: its read count does not move at any gap. That is the intended answer. A one
93    /// megabyte read is already large enough that saving the syscall next to it is not measurable.
94    #[must_use]
95    pub fn local_disk() -> Self {
96        Self {
97            threads: cores().clamp(2, 8),
98            coalesce_gap: 16 << 10,
99            coalesce_span: 1 << 20,
100            coalesce: true,
101        }
102    }
103
104    /// The sizing for object storage.
105    ///
106    /// An order of magnitude more threads, because the thing being hidden is a round trip rather
107    /// than a device, and coalescing on with a generous gap, because a request that costs a
108    /// hundred milliseconds however many bytes it asks for makes reading a gap and throwing it
109    /// away obviously right.
110    ///
111    /// Named rather than measured. There is no object store in this workspace yet and this is the
112    /// default it will be measured against when there is, not a number that came out of a run.
113    #[must_use]
114    pub fn object_store() -> Self {
115        Self {
116            threads: (cores() * 8).clamp(32, 128),
117            coalesce_gap: 512 << 10,
118            coalesce_span: 8 << 20,
119            coalesce: true,
120        }
121    }
122
123    /// This configuration with `threads` threads.
124    #[must_use]
125    pub fn with_threads(mut self, threads: usize) -> Self {
126        self.threads = threads.max(1);
127        self
128    }
129
130    /// This configuration merging ranges no more than `gap` bytes apart.
131    #[must_use]
132    pub fn coalescing(mut self, gap: u64) -> Self {
133        self.coalesce = true;
134        self.coalesce_gap = gap;
135        self
136    }
137
138    /// This configuration issuing every range as its own read.
139    #[must_use]
140    pub fn not_coalescing(mut self) -> Self {
141        self.coalesce = false;
142        self
143    }
144}
145
146impl Default for Config {
147    fn default() -> Self {
148        Self::local_disk()
149    }
150}
151
152fn cores() -> usize {
153    std::thread::available_parallelism().map_or(4, std::num::NonZero::get)
154}
155
156/// What the pool has done, for the byte counting `spec/engine/13-measurement.md` section 13.5
157/// asks for.
158///
159/// The distinction between [`Self::wanted`] and [`Self::read`] is the one that matters and it is
160/// the reason coalescing is counted rather than assumed harmless. A merged read that spans a gap
161/// reads bytes nobody asked for, and a reader whose pruning is wrong reads bytes it should have
162/// skipped. Both show up here as a ratio and neither shows up in the answer.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
164pub struct Stats {
165    /// How many requests were submitted.
166    pub requests: u64,
167    /// How many physical reads those requests turned into.
168    pub reads: u64,
169    /// How many bytes the requests asked for.
170    pub wanted: u64,
171    /// How many bytes were actually read off the device.
172    pub read: u64,
173}
174
175#[derive(Debug, Default)]
176struct Counters {
177    requests: AtomicU64,
178    reads: AtomicU64,
179    wanted: AtomicU64,
180    read: AtomicU64,
181}
182
183impl Counters {
184    fn snapshot(&self) -> Stats {
185        Stats {
186            requests: self.requests.load(Ordering::Relaxed),
187            reads: self.reads.load(Ordering::Relaxed),
188            wanted: self.wanted.load(Ordering::Relaxed),
189            read: self.read.load(Ordering::Relaxed),
190        }
191    }
192}
193
194/// One request inside a physical read.
195#[derive(Debug)]
196struct Part {
197    index: usize,
198    offset: u64,
199    buf: Vec<u8>,
200}
201
202/// One physical read, serving the requests it was merged out of.
203#[derive(Debug)]
204struct Job {
205    file: Arc<dyn File>,
206    filler: Filler,
207    offset: u64,
208    span: usize,
209    parts: Vec<Part>,
210}
211
212impl Job {
213    /// Performs the read and fills the completion slots it covers.
214    fn run(self, counters: &Counters) {
215        let Self { file, filler, offset, span, mut parts } = self;
216        counters.reads.fetch_add(1, Ordering::Relaxed);
217
218        // One part is the common case and it reads straight into the caller's buffer, which is the
219        // whole reason a request owns one. Only a merge needs the scratch and the copies.
220        if parts.len() == 1 {
221            let part = parts.pop().unwrap_or_else(|| unreachable!("checked one part"));
222            let Part { index, offset, mut buf } = part;
223            let outcome = file.read_at(offset, &mut buf).map(|read| {
224                counters.read.fetch_add(read as u64, Ordering::Relaxed);
225                Response::new(index, offset, read, buf)
226            });
227            filler.finish(index, outcome);
228            return;
229        }
230
231        let mut scratch = vec![0u8; span];
232        match file.read_at(offset, &mut scratch) {
233            Ok(read) => {
234                counters.read.fetch_add(read as u64, Ordering::Relaxed);
235                for part in parts {
236                    let Part { index, offset: at, mut buf } = part;
237                    let start = (at - offset) as usize;
238                    // A short read on a merged read is a short read on every part past where it
239                    // stopped, which is the same thing it would have been unmerged. Saying so here
240                    // rather than erroring is what keeps merging invisible to the caller.
241                    let got = read.saturating_sub(start).min(buf.len());
242                    buf[..got].copy_from_slice(&scratch[start..start + got]);
243                    filler.finish(index, Ok(Response::new(index, at, got, buf)));
244                }
245            }
246            Err(error) => {
247                for part in parts {
248                    filler.finish(part.index, Err(error.clone()));
249                }
250            }
251        }
252    }
253}
254
255#[derive(Debug)]
256struct Queue {
257    jobs: VecDeque<Job>,
258    closed: bool,
259}
260
261#[derive(Debug)]
262struct Shared {
263    queue: Mutex<Queue>,
264    work: Condvar,
265    counters: Counters,
266}
267
268impl Shared {
269    fn lock(&self) -> std::sync::MutexGuard<'_, Queue> {
270        self.queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
271    }
272}
273
274/// A pool of threads that do nothing but wait for disks.
275///
276/// Cloning a handle is cheap and gives another handle on the same pool. The threads stop when the
277/// last handle goes away.
278#[derive(Debug, Clone)]
279pub struct Pool {
280    shared: Arc<Shared>,
281    /// Held and never read. The field is the shutdown mechanism rather than a value: the last
282    /// handle to go away drops the last `Arc`, and that is what closes the queue and joins.
283    #[allow(dead_code, reason = "holding this is the point of it, reading it is not")]
284    threads: Arc<Threads>,
285    config: Config,
286}
287
288/// The join handles, in their own allocation so that dropping the last [`Pool`] handle is what
289/// stops the threads rather than dropping any of them.
290#[derive(Debug)]
291struct Threads {
292    shared: Arc<Shared>,
293    handles: Mutex<Vec<JoinHandle<()>>>,
294}
295
296impl Drop for Threads {
297    fn drop(&mut self) {
298        self.shared.lock().closed = true;
299        self.shared.work.notify_all();
300        let handles = std::mem::take(
301            &mut *self.handles.lock().unwrap_or_else(std::sync::PoisonError::into_inner),
302        );
303        for handle in handles {
304            let _ = handle.join();
305        }
306    }
307}
308
309impl Pool {
310    /// A pool sized and configured by `config`.
311    ///
312    /// # Panics
313    ///
314    /// If a thread cannot be spawned, which is not a condition a database can carry on from and is
315    /// not one a caller can do anything about.
316    #[must_use]
317    pub fn new(config: Config) -> Self {
318        let shared = Arc::new(Shared {
319            queue: Mutex::new(Queue { jobs: VecDeque::new(), closed: false }),
320            work: Condvar::new(),
321            counters: Counters::default(),
322        });
323        let mut handles = Vec::with_capacity(config.threads);
324        for n in 0..config.threads.max(1) {
325            let shared = Arc::clone(&shared);
326            handles.push(
327                std::thread::Builder::new()
328                    .name(format!("rudb-io-{n}"))
329                    .spawn(move || worker(&shared))
330                    .expect("could not spawn an I/O thread"),
331            );
332        }
333        let threads =
334            Arc::new(Threads { shared: Arc::clone(&shared), handles: Mutex::new(handles) });
335        Self { shared, threads, config }
336    }
337
338    /// How this pool is sized.
339    #[must_use]
340    pub fn config(&self) -> Config {
341        self.config
342    }
343
344    /// What it has read since it was made.
345    #[must_use]
346    pub fn stats(&self) -> Stats {
347        self.shared.counters.snapshot()
348    }
349
350    /// Queues every request against `file` and hands back something to wait on or poll.
351    ///
352    /// The requests are merged where the configuration says to, then queued. This call does not
353    /// block on the disk, which is the point: the caller goes back to decoding the row group it
354    /// already has.
355    #[must_use]
356    pub fn submit(&self, file: &Arc<dyn File>, requests: Vec<Request>) -> Completion {
357        let count = requests.len();
358        self.shared.counters.requests.fetch_add(count as u64, Ordering::Relaxed);
359        let wanted: u64 = requests.iter().map(|r| r.len() as u64).sum();
360        self.shared.counters.wanted.fetch_add(wanted, Ordering::Relaxed);
361
362        let (completion, filler) = Completion::pending(count);
363        let mut wanted_nothing = Vec::new();
364        let mut real = Vec::with_capacity(count);
365        for (index, request) in requests.into_iter().enumerate() {
366            if request.is_empty() {
367                wanted_nothing.push((index, request));
368            } else {
369                real.push((index, request));
370            }
371        }
372        // A request for no bytes is answered here rather than queued, because nobody reads nothing
373        // and because a slot left empty is a caller left waiting for a read that will never be
374        // issued. It gets a response of length zero, not no response at all.
375        for (index, request) in wanted_nothing {
376            let offset = request.offset();
377            filler.finish(index, Ok(Response::new(index, offset, 0, request.into_buffer())));
378        }
379        let jobs = plan(real, self.config, file, &filler);
380        let mut queue = self.shared.lock();
381        if queue.closed {
382            // The pool is stopping. Dropping the filler wakes the waiter with an error, which is
383            // better than queueing work nobody will run.
384            drop(queue);
385            drop(filler);
386            return completion;
387        }
388        queue.jobs.extend(jobs);
389        drop(queue);
390        self.shared.work.notify_all();
391        completion
392    }
393
394    /// How many jobs are queued and not yet picked up.
395    #[must_use]
396    pub fn queued(&self) -> usize {
397        self.shared.lock().jobs.len()
398    }
399}
400
401/// Turns a batch of requests into the physical reads that will serve them.
402///
403/// Sorted by offset, because the merge is a scan over neighbours and because issuing a row group's
404/// ranges in file order is what a device wants whether or not they get merged.
405fn plan(
406    requests: Vec<(usize, Request)>,
407    config: Config,
408    file: &Arc<dyn File>,
409    filler: &Filler,
410) -> Vec<Job> {
411    let mut parts: Vec<Part> = requests
412        .into_iter()
413        .map(|(index, request)| {
414            let offset = request.offset();
415            Part { index, offset, buf: request.into_buffer() }
416        })
417        .collect();
418    parts.sort_by_key(|part| part.offset);
419
420    let mut jobs: Vec<Job> = Vec::with_capacity(parts.len());
421    for part in parts {
422        let end = part.offset + part.buf.len() as u64;
423        if config.coalesce {
424            if let Some(last) = jobs.last_mut() {
425                let last_end = last.offset + last.span as u64;
426                let gap = part.offset.saturating_sub(last_end);
427                let span = end.saturating_sub(last.offset);
428                // `part.offset < last_end` means the ranges overlap, which merges for free.
429                if gap <= config.coalesce_gap && span <= config.coalesce_span {
430                    last.span = span as usize;
431                    last.parts.push(part);
432                    continue;
433                }
434            }
435        }
436        jobs.push(Job {
437            file: Arc::clone(file),
438            filler: filler.clone(),
439            offset: part.offset,
440            span: part.buf.len(),
441            parts: vec![part],
442        });
443    }
444    jobs
445}
446
447fn worker(shared: &Arc<Shared>) {
448    loop {
449        let mut queue = shared.lock();
450        let job = loop {
451            if let Some(job) = queue.jobs.pop_front() {
452                break job;
453            }
454            if queue.closed {
455                return;
456            }
457            queue = shared.work.wait(queue).unwrap_or_else(std::sync::PoisonError::into_inner);
458        };
459        drop(queue);
460        job.run(&shared.counters);
461    }
462}
463
464/// A file whose batched reads go through a [`Pool`].
465///
466/// Everything else is the file underneath, unchanged. `read_at` in particular stays synchronous on
467/// the calling thread, because handing one read to another thread and then blocking on it is two
468/// context switches to do what the caller was going to do anyway.
469#[derive(Debug)]
470pub struct Pooled {
471    file: Arc<dyn File>,
472    pool: Pool,
473}
474
475impl Pooled {
476    /// Puts `pool` underneath `file`.
477    #[must_use]
478    pub fn new(file: Box<dyn File>, pool: Pool) -> Self {
479        Self { file: Arc::from(file), pool }
480    }
481
482    /// The pool this file reads through.
483    #[must_use]
484    pub fn pool(&self) -> &Pool {
485        &self.pool
486    }
487}
488
489impl File for Pooled {
490    fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<usize> {
491        self.file.read_at(offset, buf)
492    }
493
494    fn submit(&self, requests: Vec<Request>) -> Completion {
495        self.pool.submit(&self.file, requests)
496    }
497
498    fn write_at(&self, offset: u64, data: &[u8]) -> Result<()> {
499        self.file.write_at(offset, data)
500    }
501
502    fn sync(&self) -> Result<()> {
503        self.file.sync()
504    }
505
506    fn truncate(&self, len: u64) -> Result<()> {
507        self.file.truncate(len)
508    }
509
510    fn len(&self) -> Result<u64> {
511        self.file.len()
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use std::path::Path;
518
519    use super::{Config, Pool, Pooled};
520    use crate::submit::{Request, Response};
521    use crate::{File, Filesystem, OpenMode, SimFilesystem};
522
523    /// Two hundred and fifty six bytes, each one its own offset, so an assertion on the bytes is
524    /// an assertion on where they came from.
525    fn ramp(pool: Pool) -> Pooled {
526        let fs = SimFilesystem::new();
527        let file = fs.open(Path::new("/data"), OpenMode::Create).unwrap();
528        let bytes: Vec<u8> = (0..=255u8).collect();
529        file.write_at(0, &bytes).unwrap();
530        file.sync().unwrap();
531        Pooled::new(file, pool)
532    }
533
534    #[test]
535    fn a_batch_comes_back_answering_the_requests_it_was_given() {
536        let file = ramp(Pool::new(Config::local_disk()));
537        let responses = file
538            .submit(vec![Request::new(100, 4), Request::new(0, 4), Request::new(200, 4)])
539            .wait()
540            .unwrap();
541        assert_eq!(responses[0].bytes(), &[100, 101, 102, 103]);
542        assert_eq!(responses[1].bytes(), &[0, 1, 2, 3]);
543        assert_eq!(responses[2].bytes(), &[200, 201, 202, 203]);
544    }
545
546    #[test]
547    fn one_thread_answers_everything_just_as_well_as_eight() {
548        // The pool being a pool is not supposed to be visible in the answers, and a test that
549        // passes at eight threads and not at one is a test that found a race at eight.
550        for threads in [1, 2, 8] {
551            let file = ramp(Pool::new(Config::local_disk().with_threads(threads)));
552            let requests = (0..32).map(|i| Request::new(i * 8, 8)).collect::<Vec<_>>();
553            let responses = file.submit(requests).wait().unwrap();
554            assert_eq!(responses.len(), 32, "at {threads} threads");
555            for (i, response) in responses.iter().enumerate() {
556                assert_eq!(response.bytes()[0], (i * 8) as u8, "at {threads} threads");
557            }
558        }
559    }
560
561    #[test]
562    fn adjacent_ranges_become_one_read_and_the_bytes_do_not_change() {
563        let pool = Pool::new(Config::local_disk().with_threads(1).coalescing(0));
564        let file = ramp(pool.clone());
565        let responses = file
566            .submit(vec![Request::new(0, 8), Request::new(8, 8), Request::new(16, 8)])
567            .wait()
568            .unwrap();
569        assert_eq!(pool.stats().reads, 1, "three adjacent ranges are one read");
570        assert_eq!(pool.stats().requests, 3);
571        assert_eq!(pool.stats().wanted, 24);
572        assert_eq!(pool.stats().read, 24, "and no byte was read that nobody asked for");
573        for (i, response) in responses.iter().enumerate() {
574            assert_eq!(response.bytes()[0], (i * 8) as u8);
575        }
576    }
577
578    #[test]
579    fn a_gap_is_read_and_thrown_away_and_the_byte_count_says_so() {
580        // This is why the byte count is two numbers. Coalescing over a gap is a decision to read
581        // bytes nobody wanted, and a coalescing policy that is too generous looks exactly like a
582        // pruning bug from the outside: a right answer and too much disk.
583        let pool = Pool::new(Config::local_disk().with_threads(1).coalescing(16));
584        let file = ramp(pool.clone());
585        let responses = file.submit(vec![Request::new(0, 4), Request::new(20, 4)]).wait().unwrap();
586        assert_eq!(pool.stats().reads, 1);
587        assert_eq!(pool.stats().wanted, 8);
588        assert_eq!(pool.stats().read, 24, "the sixteen byte gap was read too");
589        assert_eq!(responses[0].bytes(), &[0, 1, 2, 3]);
590        assert_eq!(responses[1].bytes(), &[20, 21, 22, 23]);
591    }
592
593    #[test]
594    fn a_gap_wider_than_the_policy_stays_two_reads() {
595        let pool = Pool::new(Config::local_disk().with_threads(1).coalescing(4));
596        let file = ramp(pool.clone());
597        file.submit(vec![Request::new(0, 4), Request::new(20, 4)]).wait().unwrap();
598        assert_eq!(pool.stats().reads, 2);
599        assert_eq!(pool.stats().read, 8);
600    }
601
602    #[test]
603    fn the_span_limit_stops_a_chain_of_small_gaps_becoming_one_huge_read() {
604        // Without it, a hundred ranges four bytes apart merge pairwise all the way across the file
605        // and the projection that was supposed to read 200 MB reads 20 GB.
606        let mut config = Config::local_disk().with_threads(1).coalescing(12);
607        config.coalesce_span = 32;
608        let pool = Pool::new(config);
609        let file = ramp(pool.clone());
610        let requests = (0..8).map(|i| Request::new(i * 16, 4)).collect::<Vec<_>>();
611        file.submit(requests).wait().unwrap();
612        assert_eq!(pool.stats().reads, 4, "eight ranges over 128 bytes, capped at 32 a read");
613    }
614
615    #[test]
616    fn both_defaults_coalesce_and_the_local_one_does_it_far_more_narrowly() {
617        // Both merge, because `cargo xtask io --cold` says merging a small gap is worth nearly two
618        // to one on scattered reads even on a local device. The gap is what separates them: a local
619        // disk merges over kilobytes, an object store over hundreds of them, because an object
620        // store request costs a round trip whatever it asks for.
621        assert!(Config::local_disk().coalesce);
622        assert!(Config::object_store().coalesce);
623        assert!(Config::object_store().coalesce_gap >= Config::local_disk().coalesce_gap * 8);
624        // The sizing difference is the point of there being two, per section 5.3.
625        assert!(Config::object_store().threads >= Config::local_disk().threads * 4);
626    }
627
628    #[test]
629    fn the_local_gap_is_too_small_to_swallow_the_space_between_two_columns() {
630        // The row the default was chosen on. 64KiB ranges 448KiB apart is a projection of one
631        // column out of eight, and merging those cold costs two to one, so the default must leave
632        // that pattern alone. This is that claim as an assertion rather than as a paragraph.
633        let pool = Pool::new(Config::local_disk().with_threads(1));
634        let fs = SimFilesystem::new();
635        let handle = fs.open(Path::new("/columns"), OpenMode::Create).unwrap();
636        handle.write_at(0, &vec![7u8; 2 << 20]).unwrap();
637        handle.sync().unwrap();
638        let file = Pooled::new(handle, pool.clone());
639        let requests = (0..4).map(|i| Request::new(i * (512 << 10), 64 << 10)).collect::<Vec<_>>();
640        file.submit(requests).wait().unwrap();
641        assert_eq!(pool.stats().reads, 4, "four columns 448KiB apart are four reads and not one");
642        assert_eq!(pool.stats().read, pool.stats().wanted, "and nothing else was read");
643    }
644
645    #[test]
646    fn a_short_read_stays_short_through_a_merge() {
647        let pool = Pool::new(Config::local_disk().with_threads(1).coalescing(0));
648        let file = ramp(pool.clone());
649        // 248 through 256 is there, 256 through 264 is past the end.
650        let responses =
651            file.submit(vec![Request::new(248, 8), Request::new(256, 8)]).wait().unwrap();
652        assert_eq!(pool.stats().reads, 1);
653        assert!(!responses[0].is_short());
654        assert!(responses[1].is_short());
655        assert_eq!(responses[1].read(), 0);
656    }
657
658    #[test]
659    fn a_failed_read_fails_every_request_it_was_merged_with_and_no_others() {
660        let fs = SimFilesystem::new();
661        let file = fs.open(Path::new("/data"), OpenMode::Create).unwrap();
662        file.write_at(0, &(0..=255u8).collect::<Vec<u8>>()).unwrap();
663        file.sync().unwrap();
664        let pool = Pool::new(Config::local_disk().with_threads(1).coalescing(0));
665        let pooled = Pooled::new(file, pool);
666        // Reads are served in queue order at one thread, so the first physical read is the merged
667        // pair at the front of the file.
668        fs.fail_read_at(0);
669        let mut completion =
670            pooled.submit(vec![Request::new(0, 8), Request::new(8, 8), Request::new(128, 8)]);
671        let mut failed = 0;
672        let mut answered = 0;
673        while let Some(outcome) = completion.take() {
674            match outcome {
675                Ok(_) => answered += 1,
676                Err(_) => failed += 1,
677            }
678        }
679        assert_eq!((answered, failed), (1, 2));
680    }
681
682    #[test]
683    fn an_empty_request_is_answered_rather_than_queued() {
684        let pool = Pool::new(Config::local_disk().with_threads(1));
685        let file = ramp(pool.clone());
686        let responses = file.submit(vec![Request::new(0, 0), Request::new(4, 4)]).wait().unwrap();
687        assert_eq!(pool.stats().reads, 1, "nobody reads nothing");
688        // A response of length zero and not no response at all. A slot left empty is a caller left
689        // waiting for a read that is never going to be issued.
690        assert_eq!(responses.len(), 2);
691        assert_eq!(responses[0].read(), 0);
692        assert_eq!(responses[1].bytes(), &[4, 5, 6, 7]);
693    }
694
695    #[test]
696    fn an_empty_batch_is_done_before_it_is_submitted() {
697        let pool = Pool::new(Config::local_disk());
698        let file = ramp(pool.clone());
699        let completion = file.submit(Vec::new());
700        assert!(completion.is_done());
701        assert!(completion.wait().unwrap().is_empty());
702    }
703
704    #[test]
705    fn a_submission_returns_before_the_reads_do() {
706        // The reason the pool exists. If `submit` blocked until the bytes arrived it would be
707        // `read_at` with extra steps, and the scan could not decode row group n while row group n
708        // plus one is in flight.
709        let pool = Pool::new(Config::local_disk().with_threads(1));
710        let file = ramp(pool.clone());
711        let requests = (0..64).map(|i| Request::new(i * 4, 4)).collect::<Vec<_>>();
712        let completion = file.submit(requests);
713        // Not an assertion on how much is left, because a fast machine may well have drained it.
714        // The assertion is that submitting did not wait for all of it.
715        let responses = completion.wait().unwrap();
716        assert_eq!(responses.len(), 64);
717        assert_eq!(pool.stats().requests, 64);
718    }
719
720    #[test]
721    fn everything_submitted_is_answered_once_the_pool_is_stopping() {
722        let pool = Pool::new(Config::local_disk().with_threads(2));
723        let file = ramp(pool.clone());
724        let completion = file.submit(vec![Request::new(0, 4)]);
725        // Dropping the last pool handle stops the threads, and the outstanding batch has to come
726        // back one way or the other. A hang here is the failure the test gate names.
727        drop(pool);
728        let responses = completion.wait();
729        assert!(responses.is_ok() || responses.is_err());
730    }
731
732    #[test]
733    fn read_at_on_a_pooled_file_is_the_read_underneath_it() {
734        let file = ramp(Pool::new(Config::local_disk()));
735        let mut buf = [0u8; 4];
736        file.read_exact_at(64, &mut buf).unwrap();
737        assert_eq!(buf, [64, 65, 66, 67]);
738        assert_eq!(file.len().unwrap(), 256);
739    }
740
741    #[test]
742    fn responses_are_in_submission_order_whatever_order_the_threads_finished_in() {
743        let file = ramp(Pool::new(Config::local_disk().with_threads(8)));
744        // Descending offsets, so submission order and file order disagree and the sort inside the
745        // planner has something to get wrong.
746        let requests = (0..32).rev().map(|i| Request::new(i * 8, 8)).collect::<Vec<_>>();
747        let responses = file.submit(requests).wait().unwrap();
748        let indices: Vec<usize> = responses.iter().map(Response::index).collect();
749        assert_eq!(indices, (0..32).collect::<Vec<_>>());
750        for (i, response) in responses.iter().enumerate() {
751            assert_eq!(response.bytes()[0], ((31 - i) * 8) as u8);
752        }
753    }
754}