hyperfile 0.11.4

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
#![cfg(not(feature = "blocking"))]
//! Compaction that is not mistaken for a change of content.
//!
//! A publish that only moved blocks — every dirty block came from
//! `HyperFile::relocate`, nothing else changed since the last publish, and its
//! number directly follows that publish — sets `InodeRaw::FLAG_COMPACTED`, and
//! `content_cno()` then names the checkpoint before it. Any publish that changes
//! content clears it. On memory staging.
//!
//! ```bash
//! cargo test --test functional_compaction
//! cargo test --features wal --test functional_compaction
//! ```

#![cfg_attr(feature = "wal", allow(dead_code, unused_imports))]

use std::io::{Error, ErrorKind, Result};
use std::sync::{Arc, Mutex};

use btree_ondisk::{MemoryBlockLoader, NullNodeCache};
use hyperfile::block_source::BlockSource;
use hyperfile::config::{HyperFileConfigBuilder, HyperFileMetaConfig, HyperFileRuntimeConfig};
use hyperfile::file::block::BlockState;
use hyperfile::file::file::HyperFile;
use hyperfile::file::flags::{FileFlags, HyperFileFlags};
use hyperfile::file::mode::{FileMode, HyperFileMode};
use hyperfile::file::HyperTrait;
use hyperfile::inode::FlushInodeFlag;
use hyperfile::meta_format::BlockPtrFormat;
use hyperfile::ondisk::InodeRaw;
use hyperfile::staging::config::StagingConfig;
use hyperfile::staging::memory::MemoryStaging;
use hyperfile::staging::{Staging, StagingIntercept};
use hyperfile::SegmentId;

type MemFile<'a> = HyperFile<'a, MemoryStaging, MemoryBlockLoader<hyperfile::BlockPtr>, NullNodeCache>;

const BS: usize = 4096;
const FMT: BlockPtrFormat = BlockPtrFormat::PartedSegment;

fn runtime() -> HyperFileRuntimeConfig {
    let mut rt = HyperFileRuntimeConfig::default();
    rt.data_cache_dirty_max_bytes_threshold = 1 << 40;
    rt.data_cache_dirty_max_blocks_threshold = (1 << 40) / BS;
    rt.data_cache_dirty_max_flush_interval = u64::MAX / 2;
    rt.segment_buffer_size = if cfg!(feature = "wal") { 64 << 20 } else { 1 << 40 };
    rt
}

fn config(staging: &MemoryStaging, rt: &HyperFileRuntimeConfig) -> hyperfile::config::HyperFileConfig {
    let mut meta = HyperFileMetaConfig::default();
    meta.block_ptr_format = FMT;
    HyperFileConfigBuilder::new().with_staging_config(staging.config()).with_meta_config(&meta)
        .with_runtime_config(rt).build()
}

/// A file of 40 blocks: 0..16 and 20..40 written, 16..20 a hole, 30 an explicit
/// zero block. Flushed.
async fn prepared(name: &str, rt: HyperFileRuntimeConfig) -> (MemoryStaging, MemFile<'static>) {
    prepared_as(name, rt, FMT).await
}

async fn prepared_as(name: &str, rt: HyperFileRuntimeConfig, fmt: BlockPtrFormat) -> (MemoryStaging, MemFile<'static>) {
    let staging = MemoryStaging::new(StagingConfig::new_memory(name), rt.clone()).with_block_ptr_format(fmt);
    let mut meta = HyperFileMetaConfig::default();
    meta.block_ptr_format = fmt;
    let cfg = HyperFileConfigBuilder::new().with_staging_config(staging.config()).with_meta_config(&meta)
        .with_runtime_config(&rt).build();
    let mut f = MemFile::new(staging.clone(), staging.to_block_loader(), NullNodeCache, cfg,
        HyperFileFlags::from_flags(FileFlags::rdwr()), HyperFileMode::from_mode(FileMode::default_file()))
        .await.expect("create");
    f.write(0, &pattern(1, 16 * BS)).await.unwrap();
    f.write(20 * BS, &pattern(2, 20 * BS)).await.unwrap();
    f.write_zero(30 * BS, BS).await.unwrap();
    f.flush().await.expect("flush");
    (staging, f)
}

async fn reopen(staging: &MemoryStaging) -> MemFile<'static> {
    MemFile::open(staging.clone(), staging.to_block_loader(), NullNodeCache, config(staging, &runtime()),
        HyperFileFlags::from_flags(FileFlags::rdwr())).await.expect("open")
}

fn pattern(seed: u32, len: usize) -> Vec<u8> {
    let mut x = seed | 1;
    (0..len).map(|_| { x ^= x << 13; x ^= x >> 17; x ^= x << 5; x as u8 }).collect()
}

async fn read_all(f: &mut MemFile<'static>) -> Vec<u8> {
    let mut v = vec![0u8; 40 * BS];
    let n = f.read(0, &mut v).await.expect("read");
    v.truncate(n);
    v
}

/// The stored inode, as a reader that does not open the file sees it.
fn stored(staging: &MemoryStaging) -> InodeRaw {
    InodeRaw::from_u8_slice(&staging.inode_bytes().expect("an inode is stored"))
}

/// Refuses part-0 segment writes while armed.
#[derive(Clone, Default)]
struct RefuseCommit(Arc<Mutex<bool>>);

impl StagingIntercept<MemoryStaging> for RefuseCommit {
    fn after_flush_inode(&self, _: &MemoryStaging, _: &[u8], _: FlushInodeFlag)
        -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + '_ + Send>> {
        Box::pin(async { Ok(()) })
    }
    fn after_remove_inode(&self, _: &MemoryStaging)
        -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + '_ + Send>> {
        Box::pin(async { Ok(()) })
    }
    fn before_segment_done(&self, _: &MemoryStaging, segid: SegmentId, _: &[u8], _: usize)
        -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + '_ + Send>> {
        let refuse = *self.0.lock().unwrap() && segid.part_id() == Some(0);
        Box::pin(async move {
            if refuse { Err(Error::new(ErrorKind::ConnectionReset, "part 0 refused")) } else { Ok(()) }
        })
    }
}

/// A flush of relocated blocks only sets the flag: `content_cno` stays where it
/// was while `last_cno` moves on. The blocks read back the same, cold; holes and
/// zero blocks are skipped and stay as they were; mtime and ctime do not move.
#[cfg(not(feature = "wal"))]
#[tokio::test]
async fn a_publish_of_relocated_blocks_only_keeps_content_cno() {
    let (s, mut f) = prepared("cmp-only", runtime()).await;
    let before = read_all(&mut f).await;
    let (last0, content0) = (f.last_cno(), f.content_cno().unwrap());
    assert_eq!(content0, last0, "a content flush names itself");
    let st0 = f.stat();
    let states0: Vec<BlockState> = { let mut v = Vec::new(); for b in 0..40 { v.push(f.block_state(b).await.unwrap()); } v };

    let n = f.relocate(&[(0, 40)]).await.expect("relocate");
    assert_eq!(n, 35, "16 + 20 written blocks, less the zero block; the hole skipped");
    f.flush().await.expect("flush");

    assert!(f.last_cno() > last0, "a relocation publishes a new checkpoint");
    assert_eq!(f.content_cno().unwrap(), content0, "content_cno moved on a relocation");
    assert!(stored(&s).is_compacted(), "the stored inode is not marked");
    assert_eq!(stored(&s).content_cno().unwrap(), content0);
    let st1 = f.stat();
    assert_eq!((st1.st_mtime, st1.st_mtime_nsec, st1.st_ctime, st1.st_ctime_nsec),
        (st0.st_mtime, st0.st_mtime_nsec, st0.st_ctime, st0.st_ctime_nsec), "relocate moved a timestamp");
    for b in 0..40 {
        assert_eq!(f.block_state(b).await.unwrap(), states0[b as usize], "block {} changed state", b);
    }
    let mut cold = reopen(&s).await;
    assert!(read_all(&mut cold).await == before, "relocated blocks read back differently");
    assert_eq!(cold.content_cno().unwrap(), content0, "a reopen sees another content_cno");
}

/// Blocks already dirty are not relocated, and their checkpoint is a content one.
/// So is one where any content changed: a write, a write_zero of a whole block,
/// a truncate, a size set through the attributes — whether before or after the
/// relocation.
#[cfg(not(feature = "wal"))]
#[tokio::test]
async fn any_content_change_in_the_checkpoint_clears_the_flag() {
    let changes: Vec<(&str, usize)> = vec![("write", 0), ("write_zero", 1), ("truncate", 2), ("size", 3), ("write first", 4)];
    for (what, k) in changes {
        let (s, mut f) = prepared(&format!("cmp-mixed-{}", k), runtime()).await;
        if k == 4 {
            f.write(5 * BS, &[9u8; 100]).await.unwrap();
            let n = f.relocate(&[(0, 16)]).await.unwrap();
            assert_eq!(n, 15, "{}: the dirty block was relocated", what);
        } else {
            f.relocate(&[(0, 16)]).await.unwrap();
        }
        match k {
            0 => { f.write(25 * BS, &[7u8; 10]).await.unwrap(); },
            1 => { f.write_zero(21 * BS, BS).await.unwrap(); },
            2 => { f.truncate(39 * BS + 1).await.unwrap(); },
            3 => { let mut st = f.stat(); st.st_size += 1; f.update_stat(&st).await.unwrap(); },
            _ => {},
        }
        f.flush().await.expect("flush");
        assert!(!stored(&s).is_compacted(), "{}: a content change was marked as moving blocks only", what);
        assert_eq!(f.content_cno().unwrap(), f.last_cno(), "{}: content_cno did not move", what);
    }
}

/// The flag describes the newest checkpoint: the next content flush clears it,
/// and an attribute-only write keeps it — unless that write publishes a change
/// of content, as a truncate that only extends the file does.
#[cfg(not(feature = "wal"))]
#[tokio::test]
async fn the_flag_follows_the_newest_checkpoint() {
    let (s, mut f) = prepared("cmp-follow", runtime()).await;
    let c0 = f.content_cno().unwrap();
    f.relocate(&[(0, 40)]).await.unwrap();
    f.flush().await.unwrap();
    assert!(stored(&s).is_compacted());

    // attributes only: kept
    let mut st = f.stat();
    st.st_mode = (st.st_mode & !0o777) | 0o600;
    f.update_stat(&st).await.unwrap();
    assert!(stored(&s).is_compacted(), "a chmod cleared the flag");
    assert_eq!(f.content_cno().unwrap(), c0);

    // a truncate that only extends, published without a new checkpoint: cleared
    let last = f.last_cno();
    f.truncate(50 * BS).await.unwrap();
    assert_eq!(f.last_cno(), last, "an extend published a checkpoint after all");
    assert!(!stored(&s).is_compacted(), "an extend kept the flag");
    assert!(f.content_cno().unwrap() > c0, "content_cno did not move on an extend");

    // a relocation again, then a content flush: cleared
    f.relocate(&[(0, 16)]).await.unwrap();
    f.flush().await.unwrap();
    assert!(stored(&s).is_compacted());
    f.write(0, &[1u8; 10]).await.unwrap();
    f.flush().await.unwrap();
    assert!(!stored(&s).is_compacted(), "a content flush kept the flag");
    assert_eq!(f.content_cno().unwrap(), f.last_cno());
}

/// A content write in an earlier partial makes the whole checkpoint a content
/// one, even when what is dirty at the final flush was all relocated.
#[cfg(not(feature = "wal"))]
#[tokio::test]
async fn a_content_write_in_an_earlier_partial_clears_the_flag() {
    let mut rt = runtime();
    rt.parted_segment_enabled = true;
    rt.data_cache_dirty_min_bytes_to_part = 4 * BS;
    rt.data_cache_dirty_max_blocks_threshold = 6;
    rt.data_cache_dirty_max_bytes_threshold = 6 * BS;
    let (s, mut f) = prepared("cmp-partial", rt).await;
    let parts_before = s.segment_count();
    for b in 0..8 {
        f.write(b * BS, &[0x55u8; BS]).await.unwrap();
    }
    f.relocate(&[(20, 4)]).await.unwrap();
    f.flush().await.unwrap();
    assert!(s.segment_count() > parts_before + 1, "no partial was written");
    assert!(!stored(&s).is_compacted(), "content in an earlier partial was missed");
    assert_eq!(f.content_cno().unwrap(), f.last_cno());
}

/// A flush that fails after a content write, retried with relocated blocks in
/// the same dirty set: still a content checkpoint. The bookkeeping lives until a
/// publish, not until a number is taken — here the checkpoint keeps its number
/// across the attempts, since it has partials, so only the bookkeeping stands
/// between it and the flag.
#[cfg(not(feature = "wal"))]
#[tokio::test]
async fn a_failed_content_flush_retried_is_still_a_content_checkpoint() {
    let mut rt = runtime();
    rt.parted_segment_enabled = true;
    rt.data_cache_dirty_min_bytes_to_part = 4 * BS;
    rt.data_cache_dirty_max_blocks_threshold = 6;
    rt.data_cache_dirty_max_bytes_threshold = 6 * BS;
    let (s, mut f) = prepared("cmp-retry", rt).await;
    let refuse = RefuseCommit::default();
    f.staging_interceptor(refuse.clone());
    for b in 0..8 {
        f.write(b * BS, &[0x66u8; BS]).await.unwrap();
    }
    f.relocate(&[(20, 4)]).await.unwrap();
    *refuse.0.lock().unwrap() = true;
    assert!(f.flush().await.is_err(), "the flush was meant to fail");
    *refuse.0.lock().unwrap() = false;
    f.flush().await.expect("the retry");
    assert!(!stored(&s).is_compacted(), "a retried content flush was marked as moving blocks only");
}

/// A relocation published at a number that does not directly follow the last
/// publish — a failed attempt used one — is not marked: the checkpoint before it
/// is not the one `last_cno - 1` names. Reported as a change, once.
#[cfg(not(feature = "wal"))]
#[tokio::test]
async fn a_relocation_after_a_skipped_number_is_not_marked() {
    let (s, mut f) = prepared("cmp-gap", runtime()).await;
    let refuse = RefuseCommit::default();
    f.staging_interceptor(refuse.clone());
    let last0 = f.last_cno();
    f.relocate(&[(0, 16)]).await.unwrap();
    *refuse.0.lock().unwrap() = true;
    assert!(f.flush().await.is_err());
    *refuse.0.lock().unwrap() = false;
    f.flush().await.expect("the retry");
    assert!(f.last_cno() > last0 + 1, "no number was skipped");
    assert!(!stored(&s).is_compacted(), "marked across a skipped number");
    assert_eq!(f.content_cno().unwrap(), f.last_cno());
}

/// A rollback brings back the persisted flag and forgets what it discarded: a
/// content write rolled back does not keep the next relocation from being marked.
#[cfg(not(feature = "wal"))]
#[tokio::test]
async fn a_rollback_forgets_the_content_it_discarded() {
    let (s, mut f) = prepared("cmp-rollback", runtime()).await;
    f.relocate(&[(0, 16)]).await.unwrap();
    f.flush().await.unwrap();
    assert!(stored(&s).is_compacted());
    let c = f.content_cno().unwrap();
    let refuse = RefuseCommit::default();
    f.staging_interceptor(refuse.clone());
    f.write(0, &[3u8; 100]).await.unwrap();
    *refuse.0.lock().unwrap() = true;
    assert!(f.flush_with_rollback().await.is_err());
    *refuse.0.lock().unwrap() = false;
    assert_eq!(f.content_cno().unwrap(), c, "the rollback changed content_cno");
    // the abandoned number is behind us now; the next number follows the
    // persisted counter, which the rollback moved past it
    let _ = f.relocate(&[(20, 4)]).await.unwrap();
    f.flush().await.unwrap();
    let raw = stored(&s);
    assert_eq!(raw.content_cno().unwrap(), f.content_cno().unwrap());
    // Whether this one is marked depends on the skipped number; that it is not a
    // content checkpoint for the discarded write is what matters: the file reads
    // as it did before that write.
    let mut cold = reopen(&s).await;
    let mut b = vec![0u8; 100];
    cold.read(0, &mut b).await.unwrap();
    assert!(b == pattern(1, 16 * BS)[..100], "the rolled back write is in the file");
}

/// Blocks written by reference are not relocated, and keep the flag off.
#[cfg(not(feature = "wal"))]
#[tokio::test]
async fn references_are_not_relocated_and_are_content() {
    struct Src(Vec<u8>);
    impl BlockSource for Src {
        fn read_at(&self, off: u64, buf: &mut [u8]) -> Result<()> {
            buf.copy_from_slice(&self.0[off as usize..off as usize + buf.len()]);
            Ok(())
        }
    }
    let (s, mut f) = prepared("cmp-refs", runtime()).await;
    f.set_block_source(Some(Arc::new(Src(pattern(4, 40 * BS))))).unwrap();
    f.write_ref(20 * BS, 4 * BS).await.unwrap();
    let n = f.relocate(&[(20, 8)]).await.unwrap();
    assert_eq!(n, 4, "referenced blocks were relocated");
    f.flush().await.unwrap();
    assert!(!stored(&s).is_compacted());
}

/// `content_cno` is refused for what has no content checkpoint.
#[test]
fn content_cno_is_refused_for_a_device_node_and_an_inline_file() {
    let mut raw: InodeRaw = unsafe { std::mem::MaybeUninit::zeroed().assume_init() };
    raw.i_last_cno = 9;
    raw.i_mode = libc::S_IFREG as u32 | 0o644;
    assert_eq!(raw.content_cno().unwrap(), 9);
    raw.set_compacted(true);
    assert_eq!(raw.content_cno().unwrap(), 8);
    raw.i_mode = libc::S_IFCHR as u32 | 0o644;
    assert_eq!(raw.content_cno().unwrap_err().kind(), ErrorKind::InvalidInput);
    raw.i_mode = libc::S_IFREG as u32 | 0o644;
    raw.set_inline(b"tiny");
    assert_eq!(raw.content_cno().unwrap_err().kind(), ErrorKind::InvalidInput);
}

#[cfg(feature = "wal")]
#[tokio::test]
async fn relocate_is_refused_with_the_wal() {
    let (_s, mut f) = prepared("cmp-wal", runtime()).await;
    assert_eq!(f.relocate(&[(0, 4)]).await.unwrap_err().kind(), ErrorKind::Unsupported);
}

/// A pass larger than the dirty-data limits is written out as partials while it
/// is marked, so it holds no more than the limits in memory, and is still one
/// checkpoint: one publish, marked as moving blocks only, content where it was.
#[cfg(not(feature = "wal"))]
#[tokio::test]
async fn a_pass_past_the_limits_is_written_as_partials_and_published_once() {
    let mut rt = runtime();
    rt.parted_segment_enabled = true;
    rt.data_cache_dirty_min_bytes_to_part = 4 * BS;
    rt.data_cache_dirty_max_blocks_threshold = 6;
    rt.data_cache_dirty_max_bytes_threshold = 6 * BS;
    let (s, mut f) = prepared("cmp-pass-parts", rt).await;
    let before = read_all(&mut f).await;
    let (cno0, content0, objects0) = (f.last_cno(), f.content_cno().unwrap(), s.segment_count());

    let mut marked = 0;
    let mut most_dirty = 0;
    for first in [0u64, 4, 8, 12, 20, 24, 28, 32, 36] {
        marked += f.relocate(&[(first, 4)]).await.expect("relocate");
        most_dirty = most_dirty.max(f.get_data_blocks_dirty().len());
    }
    let partials = s.segment_count() - objects0;
    assert_eq!(f.last_cno(), cno0, "a relocate published");
    let cno = f.flush().await.expect("flush").as_cno();

    assert_eq!(marked, 35, "every written block but the zero block");
    assert!(partials >= 2, "no partials were written while marking: {}", partials);
    assert!(most_dirty <= 6 + 4, "{} blocks were dirty at once, past the limit and one call", most_dirty);
    assert_eq!(cno, cno0 + 1, "the pass is one checkpoint");
    assert!(stored(&s).is_compacted(), "a pass written as partials lost the flag");
    assert_eq!(f.content_cno().unwrap(), content0);
    assert_eq!(read_all(&mut f).await, before);
    let mut g = reopen(&s).await;
    assert_eq!(read_all(&mut g).await, before, "cold");
    assert_eq!(g.content_cno().unwrap(), content0);
}

/// Where there is nowhere to put a partial — a format without parts, or parts
/// turned off — relocate writes nothing and publishes nothing past the limits:
/// the caller's flush is the only publish, as before.
#[cfg(not(feature = "wal"))]
#[tokio::test]
async fn without_partials_relocate_writes_and_publishes_nothing() {
    for (name, fmt, parted) in [("cmp-no-parts-mg", BlockPtrFormat::MicroGroup, true),
                                ("cmp-no-parts-off", FMT, false)] {
        let mut rt = runtime();
        rt.parted_segment_enabled = parted;
        rt.data_cache_dirty_min_bytes_to_part = 4 * BS;
        rt.data_cache_dirty_max_blocks_threshold = 6;
        rt.data_cache_dirty_max_bytes_threshold = 6 * BS;
        let (s, mut f) = prepared_as(name, rt, fmt).await;
        let before = read_all(&mut f).await;
        let (cno0, objects0) = (f.last_cno(), s.segment_count());
        for first in [0u64, 4, 8, 12, 20, 24] {
            f.relocate(&[(first, 4)]).await.expect("relocate");
        }
        assert_eq!(s.segment_count(), objects0, "{}: relocate wrote an object", name);
        assert_eq!(f.last_cno(), cno0, "{}: relocate published", name);
        assert_eq!(f.flush().await.expect("flush").as_cno(), cno0 + 1, "{}", name);
        assert!(stored(&s).is_compacted(), "{}", name);
        assert_eq!(read_all(&mut f).await, before, "{}", name);
    }
}