hyperfile 0.7.2

A file-like interface abstraction layer provides random read and write capabilities for S3
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
//! Read-side counters (`read_timing`).
//!
//! The read path's cost is dominated by object-store round trips.
//! Wall-clock timing cannot distinguish one request for a coalesced
//! range from many separate ones, nor a cache hit from a fetch, so
//! these counters exist to make those observable — and these tests
//! exist to make sure they count what they claim to.
//!
//! ```bash
//! HYPERFILE_TEST_BUCKET=<your-bucket> HYPERFILE_TEST_REGION=<your-region> \
//!     cargo test --test integration_s3_read_timing -- --ignored --test-threads=1
//! ```

#[allow(dead_code)]
mod common;

use common::*;

use hyperfile::file::hyper::Hyper;
use hyperfile::file::flags::FileFlags;
use hyperfile::file::mode::FileMode;

const BLK: usize = 4096;

async fn seed(client: &aws_sdk_s3::Client, uri: &str, blocks: usize) {
    let mut h = Hyper::fs_open_or_create_with_default_opt(
        client, uri, FileFlags::rdwr(), FileMode::default_file(),
    ).await.expect("create");
    let _ = h.fs_write(0, &vec![0x5Au8; blocks * BLK]).await.expect("write");
    let _ = h.fs_flush().await.expect("flush");
    let _ = h.fs_release().await.expect("release");
}

/// A read spanning blocks that are contiguous inside one segment must
/// cost **one** request, not one per block. This is the property the
/// counters exist to make checkable: it is invisible to wall-clock
/// timing, and it is what separates a coalesced read from a loop.
#[tokio::test]
#[ignore]
async fn contiguous_read_costs_one_request() {
    let _ = env_logger::try_init();
    let client = make_client().await;
    let tf = TestFile::new(&client).await;

    const N: usize = 64;
    seed(&client, tf.uri(), N).await;

    let mut h = Hyper::fs_open(&client, tf.uri(), FileFlags::rdonly()).await.expect("reopen");
    // Reset after open so the inode and index reads that opening
    // performs do not count against the read being measured.
    h.read_timing_reset();

    let mut buf = vec![0u8; N * BLK];
    let n = h.fs_read(0, &mut buf).await.expect("read");
    assert_eq!(n, N * BLK);
    assert!(buf.iter().all(|b| *b == 0x5A));

    let t = h.read_timing().snapshot();
    assert_eq!(t.data_gets, 1,
        "{N} contiguous blocks in one segment should coalesce into 1 request, got {}",
        t.data_gets);
    assert_eq!(t.data_bytes, (N * BLK) as u64,
        "fetched bytes should match the range read");
    assert_eq!(t.cache_hits, 0, "nothing should have been cached on a fresh open");

    let _ = h.fs_release().await.expect("release");
    tf.cleanup(&client).await;
}

/// Reading the same block twice through the byte API costs two
/// requests: the byte read path deliberately does not populate the
/// data cache, so there is nothing for the second read to hit.
///
/// Asserting this pins down an asymmetry that is easy to trip over —
/// see `block_borrow_populates_the_cache` below for the other half.
#[tokio::test]
#[ignore]
async fn byte_read_does_not_populate_the_cache() {
    let _ = env_logger::try_init();
    let client = make_client().await;
    let tf = TestFile::new(&client).await;

    seed(&client, tf.uri(), 8).await;

    let mut h = Hyper::fs_open(&client, tf.uri(), FileFlags::rdonly()).await.expect("reopen");
    h.read_timing_reset();

    let mut buf = vec![0u8; BLK];
    let _ = h.fs_read(3 * BLK, &mut buf).await.expect("first read");
    let first = h.read_timing().snapshot();
    assert_eq!(first.data_gets, 1);
    assert_eq!(first.cache_hits, 0);

    let _ = h.fs_read(3 * BLK, &mut buf).await.expect("second read");
    let second = h.read_timing().snapshot();
    assert_eq!(second.data_gets, 2,
        "a byte read does not cache, so the second read must fetch again");
    assert_eq!(second.cache_hits, 0,
        "a byte read leaves nothing in the data cache to hit");

    let _ = h.fs_release().await.expect("release");
    tf.cleanup(&client).await;
}

/// A block borrow *does* populate the cache: the second borrow is a
/// hit and costs no request. A byte read of the same block afterwards
/// is also a hit, so the benefit is one-directional but real.
#[tokio::test]
#[ignore]
async fn block_borrow_populates_the_cache() {
    let _ = env_logger::try_init();
    let client = make_client().await;
    let tf = TestFile::new(&client).await;

    seed(&client, tf.uri(), 8).await;

    let mut h = Hyper::fs_open(&client, tf.uri(), FileFlags::rdonly()).await.expect("reopen");
    h.read_timing_reset();

    {
        let blk = h.fs_block(3).await.expect("borrow").expect("mapped");
        assert_eq!(blk.as_slice()[0], 0x5A);
    }
    let first = h.read_timing().snapshot();
    assert_eq!(first.data_gets, 1, "a cold borrow fetches once");
    assert_eq!(first.cache_hits, 0);

    {
        let blk = h.fs_block(3).await.expect("borrow again").expect("mapped");
        assert_eq!(blk.as_slice()[0], 0x5A);
    }
    let second = h.read_timing().snapshot();
    assert_eq!(second.data_gets, 1, "a second borrow must not fetch");
    assert_eq!(second.cache_hits, 1, "a second borrow is a cache hit");

    // The byte path sees the cached block too.
    let mut buf = vec![0u8; BLK];
    let _ = h.fs_read(3 * BLK, &mut buf).await.expect("read");
    let third = h.read_timing().snapshot();
    assert_eq!(third.data_gets, 1, "a byte read of a cached block must not fetch");
    assert_eq!(third.cache_hits, 2);

    let _ = h.fs_release().await.expect("release");
    tf.cleanup(&client).await;
}

/// Opening a file whose block map has spilled out of the inode costs
/// index reads, and they are counted separately from data reads so
/// that the two can be attributed independently.
#[tokio::test]
#[ignore]
async fn index_reads_are_counted_separately() {
    let _ = env_logger::try_init();
    let client = make_client().await;
    let tf = TestFile::new(&client).await;

    // Enough blocks that the map cannot live in the inode's inline
    // root, so a lookup has to fetch a node.
    seed(&client, tf.uri(), 64).await;

    let mut h = Hyper::fs_open(&client, tf.uri(), FileFlags::rdonly()).await.expect("reopen");
    let after_open = h.read_timing().snapshot();
    assert!(after_open.inode_gets >= 1, "opening must read the inode");

    h.read_timing_reset();
    // Read one block far into the file, forcing a descent.
    let mut buf = vec![0u8; BLK];
    let _ = h.fs_read(60 * BLK, &mut buf).await.expect("read");

    let t = h.read_timing().snapshot();
    assert_eq!(t.data_gets, 1, "one block, one data request");
    assert!(t.meta_gets >= 1,
        "a spilled map must be descended, so at least one index request; got {}",
        t.meta_gets);
    assert!(t.meta_bytes > 0, "index requests should report bytes");
    assert_eq!(t.total_gets(), t.data_gets + t.meta_gets + t.inode_gets);
    assert!(t.staging_ns > 0, "time spent awaiting staging should be recorded");

    let _ = h.fs_release().await.expect("release");
    tf.cleanup(&client).await;
}

/// The batched index loader fetches a segment's meta blocks with a
/// header request followed by a blocks request. Both must be counted:
/// recording one request per call to that helper would understate
/// round trips by half, which is the number these counters exist to
/// report honestly.
#[tokio::test]
#[ignore]
async fn index_fetch_counts_every_request_it_makes() {
    let _ = env_logger::try_init();
    let client = make_client().await;
    let tf = TestFile::new(&client).await;

    seed(&client, tf.uri(), 64).await;

    let mut h = Hyper::fs_open(&client, tf.uri(), FileFlags::rdonly()).await.expect("reopen");
    h.read_timing_reset();

    let mut buf = vec![0u8; BLK];
    let _ = h.fs_read(60 * BLK, &mut buf).await.expect("read");

    let t = h.read_timing().snapshot();
    // With the batched loader a descent fetches a header and then the
    // block chunk, so at least two. The per-block loader issues one
    // request per node, which is also at least one; assert the floor
    // that holds either way, and that bytes were attributed.
    assert!(t.meta_gets >= 1, "index requests should be counted, got {}", t.meta_gets);
    assert!(t.meta_bytes >= t.meta_gets,
        "each counted index request should have contributed bytes: {} requests, {} bytes",
        t.meta_gets, t.meta_bytes);

    let _ = h.fs_release().await.expect("release");
    tf.cleanup(&client).await;
}

/// A hole costs no request at all, and neither does a read served
/// entirely from the write cache before a flush.
#[tokio::test]
#[ignore]
async fn holes_and_unflushed_writes_cost_nothing() {
    let _ = env_logger::try_init();
    let client = make_client().await;
    let tf = TestFile::new(&client).await;

    let mut h = Hyper::fs_open_or_create_with_default_opt(
        &client, tf.uri(), FileFlags::rdwr(), FileMode::default_file(),
    ).await.expect("create");

    // Write block 0, leave 1 as a hole, write block 2.
    let _ = h.fs_write(0, &vec![0x11u8; BLK]).await.expect("write 0");
    let _ = h.fs_write(2 * BLK, &vec![0x22u8; BLK]).await.expect("write 2");
    h.read_timing_reset();

    // Still dirty in cache: reads must not reach staging.
    let mut buf = vec![0u8; BLK];
    let _ = h.fs_read(0, &mut buf).await.expect("read dirty");
    assert!(buf.iter().all(|b| *b == 0x11));
    let t = h.read_timing().snapshot();
    assert_eq!(t.data_gets, 0, "a dirty block must be served from cache");
    assert_eq!(t.cache_hits, 1);

    // The hole.
    h.read_timing_reset();
    let mut buf = vec![0xFFu8; BLK];
    let _ = h.fs_read(BLK, &mut buf).await.expect("read hole");
    assert!(buf.iter().all(|b| *b == 0), "hole reads as zeros");
    let t = h.read_timing().snapshot();
    assert_eq!(t.data_gets, 0, "a hole costs no request");
    assert_eq!(t.cache_hits, 0, "a hole is not a cache hit either");

    let _ = h.fs_flush().await.expect("flush");
    let _ = h.fs_release().await.expect("release");
    tf.cleanup(&client).await;
}

/// `read_timing_reset` zeroes every counter, so a measurement can be
/// bracketed.
#[tokio::test]
#[ignore]
async fn reset_zeroes_every_counter() {
    let _ = env_logger::try_init();
    let client = make_client().await;
    let tf = TestFile::new(&client).await;

    seed(&client, tf.uri(), 8).await;

    let mut h = Hyper::fs_open(&client, tf.uri(), FileFlags::rdonly()).await.expect("reopen");
    let mut buf = vec![0u8; BLK];
    let _ = h.fs_read(0, &mut buf).await.expect("read");
    let _ = h.fs_block(1).await.expect("borrow");
    let _ = h.fs_block(1).await.expect("borrow again");

    let before = h.read_timing().snapshot();
    assert!(before.total_gets() > 0 && before.cache_hits > 0,
        "the measurement should have recorded something to reset");

    h.read_timing_reset();
    let after = h.read_timing().snapshot();
    assert_eq!(after.total_gets(), 0);
    assert_eq!(after.total_bytes(), 0);
    assert_eq!(after.cache_hits, 0);
    assert_eq!(after.staging_ns, 0);
    assert_eq!(after.data_gets, 0);
    assert_eq!(after.meta_gets, 0);
    assert_eq!(after.inode_gets, 0);

    let _ = h.fs_release().await.expect("release");
    tf.cleanup(&client).await;
}

/// `fs_read_ahead` is the byte path's way into the data cache: a read of
/// a warmed range must cost nothing, where a read of the same range
/// warmed through `fs_read` would cost the same again.
///
/// This is the property the entry point exists for, and it is only
/// visible in the counters — the reporter measured read-ahead through the
/// byte path doubling requests with cache hits unchanged, because the
/// bytes had nowhere to live.
#[tokio::test]
#[ignore]
async fn read_ahead_warms_the_cache_and_reads_cost_nothing() {
    let _ = env_logger::try_init();
    let client = make_client().await;
    let tf = TestFile::new(&client).await;

    const N: usize = 64;
    seed(&client, tf.uri(), N).await;

    let mut h = Hyper::fs_open(&client, tf.uri(), FileFlags::rdonly()).await.expect("reopen");
    h.read_timing_reset();

    let cached = h.fs_read_ahead(0, N * BLK).await.expect("read_ahead");
    assert_eq!(cached, N, "every block in the range should have been installed");
    let warm = h.read_timing().snapshot();
    assert_eq!(warm.data_gets, 1,
        "{N} contiguous blocks should warm in 1 request, got {}", warm.data_gets);

    // Now read it. Nothing should reach staging.
    let mut buf = vec![0u8; N * BLK];
    let n = h.fs_read(0, &mut buf).await.expect("read");
    assert_eq!(n, N * BLK);
    assert!(buf.iter().all(|b| *b == 0x5A), "warmed data came back wrong");

    let after = h.read_timing().snapshot();
    assert_eq!(after.data_gets, warm.data_gets,
        "a read over a warmed range must not fetch, but data_gets went {} -> {}",
        warm.data_gets, after.data_gets);
    assert!(after.cache_hits > warm.cache_hits,
        "and the read should be counted as cache hits");

    let _ = h.fs_release().await;
    tf.cleanup(&client).await;
}

/// A read-ahead must not report cache hits.
///
/// It plans the same way a read does, and planning counts every block it
/// finds resident. Counting a read-ahead's planning made `cache_hits`
/// useless for the thing it exists for: warming a wide range that is
/// already warm reported hits by the thousand and drowned out the reads.
/// Measured over 16 MiB it inflated the number about ninefold.
///
/// A hit is a read this layer served. A read-ahead is not serving anybody.
#[tokio::test]
#[ignore]
async fn read_ahead_does_not_report_cache_hits() {
    let _ = env_logger::try_init();
    let client = make_client().await;
    let tf = TestFile::new(&client).await;

    const N: usize = 64;
    seed(&client, tf.uri(), N).await;

    let mut h = Hyper::fs_open(&client, tf.uri(), FileFlags::rdonly()).await.expect("reopen");
    h.read_timing_reset();

    // First warm: nothing is resident, so nothing could be a hit anyway.
    let _ = h.fs_read_ahead(0, N * BLK).await.expect("warm");
    let after_first = h.read_timing().snapshot();
    assert_eq!(after_first.cache_hits, 0,
        "a read-ahead should report no hits, got {}", after_first.cache_hits);

    // Second warm over the now-resident range: every block is resident, so
    // this is where the inflation used to come from.
    let again = h.fs_read_ahead(0, N * BLK).await.expect("warm again");
    assert_eq!(again, 0, "a warmed range installs nothing the second time");
    let after_second = h.read_timing().snapshot();
    assert_eq!(after_second.cache_hits, 0,
        "warming an already-warm range should still report no hits, got {}",
        after_second.cache_hits);

    // A read over the same range does report them, which is the point of
    // the counter.
    let mut buf = vec![0u8; N * BLK];
    let _ = h.fs_read(0, &mut buf).await.expect("read");
    let after_read = h.read_timing().snapshot();
    assert!(after_read.cache_hits > 0,
        "a read served from the cache has to report hits");
    assert!(after_read.cache_hits <= N as u64,
        "{} blocks read once cannot be more than {} hits, got {}",
        N, N, after_read.cache_hits);

    let _ = h.fs_release().await;
    tf.cleanup(&client).await;
}

/// The direct API answers the placement queries too, and the plan it gives
/// is what the direct read then costs.
///
/// The reactor surface has its own suite for these
/// (`integration_reactor_s3_placement`); this is here because the queries
/// are only worth anything while they agree with the read path, and the two
/// surfaces reach it by different routes.
#[tokio::test]
#[ignore]
async fn the_plan_agrees_with_a_direct_read() {
    let _ = env_logger::try_init();
    let client = make_client().await;
    let tf = TestFile::new(&client).await;
    const NB: usize = 48;

    // Even blocks in one flush, odd in another, so consecutive blocks land in
    // different segments and the read cannot merge across them.
    {
        let mut h = Hyper::fs_open_or_create_with_default_opt(
            &client, tf.uri(), FileFlags::rdwr(), FileMode::default_file(),
        ).await.expect("create");
        for b in (0..NB).filter(|b| b % 2 == 0) {
            let _ = h.fs_write(b * BLK, &vec![(b % 251) as u8; BLK]).await.expect("write");
        }
        let _ = h.fs_flush().await.expect("flush");
        for b in (0..NB).filter(|b| b % 2 == 1) {
            let _ = h.fs_write(b * BLK, &vec![(b % 251) as u8; BLK]).await.expect("write");
        }
        let _ = h.fs_flush().await.expect("flush");
        let _ = h.fs_release().await.expect("release");
    }

    let mut h = Hyper::fs_open(&client, tf.uri(), FileFlags::rdonly()).await.expect("reopen");
    h.read_timing_reset();

    let plan = h.fs_read_plan(0, (NB * BLK) as u64).await.expect("fs_read_plan");
    let bytes: u64 = plan.iter().map(|e| e.range().1).sum();
    let gets = plan.iter().filter(|e| e.is_get()).count();
    assert_eq!(bytes, (NB * BLK) as u64, "entries must account for the whole range");
    assert!(gets > 1, "alternating segments cannot merge into one request, got {}", gets);
    assert_eq!(h.read_timing().snapshot().data_gets, 0, "asking must issue no data request");

    let mut buf = vec![0u8; NB * BLK];
    let n = h.fs_read(0, &mut buf).await.expect("read");
    assert_eq!(n, NB * BLK);
    let t = h.read_timing().snapshot();
    assert_eq!(t.data_gets as usize, gets,
        "the plan predicted {} requests and the read issued {}", gets, t.data_gets);

    // Placement explains that count: two flushes, two segments, alternating.
    let places = h.fs_block_placement(0, NB).await.expect("fs_block_placement");
    assert_eq!(places.len(), NB);
    let segs: std::collections::BTreeSet<_> = places.iter()
        .filter_map(|p| p.map(|(s, _)| s)).collect();
    assert_eq!(segs.len(), 2, "two flushes, two segments, got {:?}", segs);

    // And the batch forms agree with the single-range ones.
    let ranges = [(0u64, (4 * BLK) as u64), ((10 * BLK) as u64, (3 * BLK) as u64)];
    let batch = h.fs_read_plan_many(&ranges).await.expect("fs_read_plan_many");
    assert_eq!(batch.len(), ranges.len());
    for (i, (off, len)) in ranges.iter().copied().enumerate() {
        assert_eq!(batch[i], h.fs_read_plan(off, len).await.expect("fs_read_plan"),
            "range {} answered differently in a batch", i);
    }
    let b_batch = h.fs_block_placement_many(&[(0, 4), (10, 3)]).await.expect("fs_block_placement_many");
    assert_eq!(b_batch.len(), 2);
    assert_eq!(b_batch[0], h.fs_block_placement(0, 4).await.expect("fs_block_placement"));

    let _ = h.fs_release().await.expect("release");
    tf.cleanup(&client).await;
}

/// Read-ahead's requests are attributable, so a read's own cost can be had
/// by subtraction.
///
/// Without this the two are indistinguishable — the counter is incremented
/// where the request is made, and staging cannot see who asked. A consumer
/// comparing two identically-laid-out files found one costing twice the
/// requests of the other and could not tell whether they were measuring the
/// layout or how much read-ahead each file attracted.
#[tokio::test]
#[ignore]
async fn read_ahead_requests_are_attributable() {
    let _ = env_logger::try_init();
    let client = make_client().await;
    let tf = TestFile::new(&client).await;
    const NB: usize = 64;

    seed(&client, tf.uri(), NB).await;

    // A plain cold read attributes nothing to read-ahead.
    {
        let mut h = Hyper::fs_open(&client, tf.uri(), FileFlags::rdonly()).await.expect("reopen");
        h.read_timing_reset();
        let mut buf = vec![0u8; NB * BLK];
        let _ = h.fs_read(0, &mut buf).await.expect("read");
        let t = h.read_timing().snapshot();
        assert!(t.data_gets >= 1, "a cold read must fetch");
        assert_eq!(t.read_ahead_gets, 0, "no read-ahead was asked for");
        assert_eq!(t.read_ahead_bytes, 0);
        let _ = h.fs_release().await.expect("release");
    }

    // Warming attributes everything to read-ahead, and the read that follows
    // adds nothing because it is served from the cache.
    {
        let mut h = Hyper::fs_open(&client, tf.uri(), FileFlags::rdonly()).await.expect("reopen");
        h.read_timing_reset();

        let _ = h.fs_read_ahead(0, NB * BLK).await.expect("read ahead");
        let warm = h.read_timing().snapshot();
        assert!(warm.data_gets >= 1, "warming must fetch");
        assert_eq!(warm.read_ahead_gets, warm.data_gets,
            "every request so far was read-ahead's: {} of {}", warm.read_ahead_gets, warm.data_gets);
        assert_eq!(warm.read_ahead_bytes, warm.data_bytes);

        let mut buf = vec![0u8; NB * BLK];
        let _ = h.fs_read(0, &mut buf).await.expect("read");
        let after = h.read_timing().snapshot();
        assert_eq!(after.data_gets, warm.data_gets, "the read was served from the cache");
        assert_eq!(
            after.data_gets - after.read_ahead_gets, 0,
            "so the read's own requests subtract to nothing",
        );
        let _ = h.fs_release().await.expect("release");
    }

    // Mixed: warm one half, read the other. Subtraction separates them.
    {
        let mut h = Hyper::fs_open(&client, tf.uri(), FileFlags::rdonly()).await.expect("reopen");
        h.read_timing_reset();

        let half = NB / 2 * BLK;
        let _ = h.fs_read_ahead(0, half).await.expect("read ahead");
        let ra = h.read_timing().snapshot().read_ahead_gets;
        assert!(ra >= 1);

        let mut buf = vec![0u8; half];
        let _ = h.fs_read(half, &mut buf).await.expect("read the cold half");

        let t = h.read_timing().snapshot();
        assert_eq!(t.read_ahead_gets, ra, "reading must not add to read-ahead's count");
        assert!(t.data_gets > ra, "the cold half had to be fetched");
        assert_eq!(t.data_gets - t.read_ahead_gets, t.data_gets - ra,
            "the read's own requests are what is left over");
        assert!(t.read_ahead_bytes < t.data_bytes, "and the same holds for bytes");

        // Both subsets, never larger than the total they are part of.
        assert!(t.read_ahead_gets <= t.data_gets);
        assert!(t.read_ahead_bytes <= t.data_bytes);

        let _ = h.fs_release().await.expect("release");
    }

    tf.cleanup(&client).await;
}