mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 15:41:15 +02:00
rust volume: checkpoint the redb index durably every 1000 writes (#11182)
* rust volume: checkpoint the redb index durably every 1000 writes
Every put and delete on a redb-backed volume committed with
Durability::None and nothing ever committed durably, on the theory that
the .idx file is the source of truth. redb, however, keeps an entry in
its transaction tracker for every non-durable commit and cannot recycle
pages that were on disk at the last durable commit until a durable one
happens. With no durable commit for the life of the process, both grew
with every write, and .rdb files could bloat toward double size after a
restart (#11179, the hash-table rehash stacks in the memleak output).
The needle map now counts non-durable commits and reports when a
checkpoint is due; the volume takes it, data first: flush the .dat, then
the map fsyncs the .idx and commits redb durably, recording in the same
transaction how much of the .idx the table reflects. A checkpoint makes
the index durable, so the bytes it points at must be down before it, or
after a power loss the index would reference past the end of the .dat
and the volume would load read-only. A failed .dat flush skips the
checkpoint; it is retried on the next write.
Volume::close() now closes the needle map instead of only syncing it,
and the redb map's close() takes the same checkpoint. Before, a clean
shutdown left the table durable (redb flushes on drop) but the recorded
.idx size stale at its load-time value, so the next load replayed every
entry written since load on top of the counters.
On load, the redb map's counters now come from the whole .idx history,
the way Go's LevelDB map rebuilds them (newest entry first, with a bloom
filter of seen keys), instead of from the table's final state. Both the
reuse and the full-rebuild path use it, so overwritten and deleted bytes
keep counting as garbage across restarts, and the incremental replay of
the .idx tail only touches the table, which makes it idempotent whether
or not the table is ahead of the recorded .idx size.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019x36FiSeyePh77YXao15kK
* rust volume: skip redundant .idx fsync on checkpoint after flush_idx
On the fsync=true write path, flush_idx() already fsyncs the .idx before
maybe_checkpoint_index() runs, so the checkpoint's own sync() fsyncs the
same file a second time for nothing. Thread an idx_already_synced flag
from the volume through maybe_checkpoint_index into checkpoint(sync_idx):
when it is true the checkpoint skips its .idx fsync and only does the
durable redb commit. The delete path and close() still sync (they have
not flushed the .idx beforehand).
Co-Authored-By: Chris Lu <chris.lu@gmail.com>
* rust volume: saturate writes_since_checkpoint to prevent u32 overflow
If checkpoints keep failing (e.g. a persistent .dat flush failure whose
error is not EIO and so does not mark the volume read-only), the counter
increments on every write with no upper bound and wraps at ~4.3 billion.
Use saturating_add so it pins at u32::MAX instead, which keeps
checkpoint_due() true and retries on every subsequent write.
Co-Authored-By: Chris Lu <chris.lu@gmail.com>
* rust volume: only update max_file_key on live entries in idx metric rebuild
metrics_from_idx called maybe_set_max_file_key on every entry including
tombstones, but the live on_put path only calls it for puts and on_delete
never does. A tombstone always has a preceding put for the same key that
already set max_file_key, so the result is the same today; restricting it
to live entries makes the parity with the live path exact and self-evident.
Co-Authored-By: Chris Lu <chris.lu@gmail.com>
* rust volume: advance idx_file_offset only after redb commit succeeds
put() and delete() appended to the .idx file and advanced idx_file_offset
before committing to redb. If the redb commit failed, the offset included
the orphan row that redb doesn't reflect. A later checkpoint would record
that offset as "the table reflects up to here," and the reload would skip
the orphan row entirely — the entry becomes permanently unindexed.
Move the idx_file_offset increment to after the successful redb commit.
The .idx file still has the orphan row (append-only), but idx_file_offset
stays behind it, so the next checkpoint records the smaller offset and
the reload replays the orphan row back into redb.
Co-Authored-By: Chris Lu <chris.lu@gmail.com>
* rust volume: skip index checkpoint on close when .dat sync fails
Volume::close() discarded the .dat sync_all() result and always
checkpointed the redb index. If the .dat sync failed, the checkpoint
made the index durable with entries that may point past the unflushed
.dat tail, and a power loss would leave the volume read-only on reload
(the max_needle_end check fires).
Check the .dat sync result: on success, checkpoint as before; on
failure, call close_without_checkpoint() — sync the .idx and drop the
writer without a durable redb commit. META_IDX_SIZE stays at the last
successful checkpoint, so the reload replays the uncheckpointed tail
(redb still flushes on drop, but without recording idx_size).
Co-Authored-By: Chris Lu <chris.lu@gmail.com>
* rust volume: schedule checkpoints on every index mutation path
maybe_checkpoint_index was only called from do_write_request and
do_delete_request. put_needle_index and write_needle_blob_and_index
also call NeedleMap::put, which increments writes_since_checkpoint,
but neither triggered the checkpoint. Through those paths the counter
could grow past the interval without ever being satisfied, leaving
non-durable redb transaction state until close().
Add maybe_checkpoint_index(false) after the successful nm.put in both
methods. The .dat flush inside maybe_checkpoint_index covers the blob
write in write_needle_blob_and_index; put_needle_index pairs with a
prior write_needle_blob, so the flush covers that too.
Co-Authored-By: Chris Lu <chris.lu@gmail.com>
* rust volume: truncate orphan .idx row on failed redb commit
Commit 947ee28 moved the idx_file_offset increment after the redb commit
so a failed commit doesn't advance the watermark. But the .idx file is
append-only: the orphan row stays in the file, and the next successful
write appends after it. That write's idx_file_offset += entry_size
advances past the orphan, so a later checkpoint records an offset that
makes the reload skip the orphan row — hiding a persisted put or
restoring a deleted needle.
On a failed redb commit, truncate the .idx file back to idx_file_offset
before returning the error. This removes the orphan row, so the next
write appends at the correct position and idx_file_offset stays a
contiguous replay watermark. Add a truncate_to method to IdxFileWriter
(set_len for std::fs::File) and a truncate_idx_to_offset helper.
Co-Authored-By: Chris Lu <chris.lu@gmail.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: chrislusf <chris@chrislusf.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
This commit is contained in:
co-authored by
Chris Lu
Claude Fable 5.1
chrislusf
parent
d8a40ef750
commit
b82cb05d71
@@ -15,8 +15,10 @@ use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
|
||||
|
||||
mod compact_map;
|
||||
pub mod file_pool;
|
||||
mod idx_metric;
|
||||
pub mod sorted_file;
|
||||
use compact_map::CompactMap;
|
||||
use idx_metric::metrics_from_idx;
|
||||
use sorted_file::SortedFileNeedleMap;
|
||||
|
||||
use redb::{Database, Durability, ReadableDatabase, ReadableTable, TableDefinition};
|
||||
@@ -182,12 +184,19 @@ impl NeedleMapKind {
|
||||
/// Trait for appending to an index file.
|
||||
pub trait IdxFileWriter: Write + Send + Sync {
|
||||
fn sync_all(&self) -> io::Result<()>;
|
||||
/// Truncate the file to `len` bytes. Used to remove an orphan .idx row
|
||||
/// left by a failed redb commit so `idx_file_offset` stays a contiguous
|
||||
/// replay watermark.
|
||||
fn truncate_to(&mut self, len: u64) -> io::Result<()>;
|
||||
}
|
||||
|
||||
impl IdxFileWriter for std::fs::File {
|
||||
fn sync_all(&self) -> io::Result<()> {
|
||||
std::fs::File::sync_all(self)
|
||||
}
|
||||
fn truncate_to(&mut self, len: u64) -> io::Result<()> {
|
||||
self.set_len(len)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -410,6 +419,14 @@ const NEEDLE_TABLE: TableDefinition<u64, &[u8]> = TableDefinition::new("needles"
|
||||
const META_TABLE: TableDefinition<&str, u64> = TableDefinition::new("meta");
|
||||
const META_IDX_SIZE: &str = "idx_size";
|
||||
|
||||
/// Writes between two durable redb checkpoints. Every non-durable commit
|
||||
/// leaves an entry in redb's transaction tracker and pins the pages that
|
||||
/// were on disk at the last durable commit; only a durable commit clears
|
||||
/// both. Without a cadence they grow for the life of the process (#11179).
|
||||
/// The map only counts; the volume takes the checkpoint (see
|
||||
/// `RedbNeedleMap::checkpoint_due`) because the .dat must be flushed first.
|
||||
const REDB_CHECKPOINT_INTERVAL: u32 = 1000;
|
||||
|
||||
/// Disk-backed needle map using redb.
|
||||
/// Low memory usage — data lives on disk behind a small, bounded redb page
|
||||
/// cache sized by `NeedleMapKind::redb_cache_bytes`.
|
||||
@@ -418,12 +435,15 @@ pub struct RedbNeedleMap {
|
||||
metric: NeedleMapMetric,
|
||||
idx_file: Option<Box<dyn IdxFileWriter>>,
|
||||
idx_file_offset: u64,
|
||||
/// Puts/deletes since the last durable checkpoint.
|
||||
writes_since_checkpoint: u32,
|
||||
}
|
||||
|
||||
impl RedbNeedleMap {
|
||||
/// Begin a write transaction with `Durability::None` (no fsync).
|
||||
/// The .idx file is the source of truth for crash recovery, so redb
|
||||
/// is always rebuilt from .idx on startup — fsync is unnecessary.
|
||||
/// The .idx file is the source of truth for crash recovery: a crash
|
||||
/// loses at most the writes since the last checkpoint from redb, and
|
||||
/// the next load replays them from .idx.
|
||||
fn begin_write_no_fsync(db: &Database) -> io::Result<redb::WriteTransaction> {
|
||||
let mut txn = db.begin_write().map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::Other, format!("redb begin_write: {}", e))
|
||||
@@ -432,6 +452,52 @@ impl RedbNeedleMap {
|
||||
Ok(txn)
|
||||
}
|
||||
|
||||
/// True once `REDB_CHECKPOINT_INTERVAL` puts/deletes have been committed
|
||||
/// non-durably. The caller (the volume) must then flush the .dat and
|
||||
/// call [`checkpoint`](Self::checkpoint): a checkpoint makes the index
|
||||
/// durable, so the bytes it points at have to be on disk before it.
|
||||
pub fn checkpoint_due(&self) -> bool {
|
||||
self.writes_since_checkpoint >= REDB_CHECKPOINT_INTERVAL
|
||||
}
|
||||
|
||||
/// Make the table durable and record how much of the .idx it reflects.
|
||||
/// Precondition: the .dat the index points into has been flushed.
|
||||
///
|
||||
/// When `sync_idx` is true the .idx file is fsynced first — the recorded
|
||||
/// size must never exceed what is on disk, or the reload would have to
|
||||
/// rebuild from scratch. A caller that has already fsynced the .idx (e.g.
|
||||
/// the volume's `flush_idx` on the fsync=true write path) may pass false
|
||||
/// to avoid a redundant fsync.
|
||||
pub fn checkpoint(&mut self, sync_idx: bool) -> io::Result<()> {
|
||||
let txn = self.begin_checkpoint(sync_idx)?;
|
||||
txn.commit()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("redb commit: {}", e)))?;
|
||||
self.writes_since_checkpoint = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Begin a durable transaction (fsync on commit) that also records how
|
||||
/// much of the .idx the table reflects, so a reload replays only the
|
||||
/// tail appended after it. When `sync_idx` is true the .idx is fsynced
|
||||
/// first (see [`checkpoint`](Self::checkpoint)).
|
||||
fn begin_checkpoint(&self, sync_idx: bool) -> io::Result<redb::WriteTransaction> {
|
||||
if sync_idx {
|
||||
self.sync()?;
|
||||
}
|
||||
let txn = self.db.begin_write().map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::Other, format!("redb begin_write: {}", e))
|
||||
})?;
|
||||
if self.idx_file.is_some() {
|
||||
let mut meta = txn.open_table(META_TABLE).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::Other, format!("redb open meta: {}", e))
|
||||
})?;
|
||||
meta.insert(META_IDX_SIZE, self.idx_file_offset).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::Other, format!("redb insert meta: {}", e))
|
||||
})?;
|
||||
}
|
||||
Ok(txn)
|
||||
}
|
||||
|
||||
/// Create a new redb-backed needle map at the given path.
|
||||
/// The database file will be created if it does not exist.
|
||||
/// `cache_bytes` bounds redb's page cache for this one database.
|
||||
@@ -461,6 +527,7 @@ impl RedbNeedleMap {
|
||||
metric: NeedleMapMetric::default(),
|
||||
idx_file: None,
|
||||
idx_file_offset: 0,
|
||||
writes_since_checkpoint: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -501,49 +568,6 @@ impl RedbNeedleMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild metrics by scanning all entries in the redb table.
|
||||
/// Called when reusing an existing .rdb without a full rebuild.
|
||||
fn rebuild_metrics_from_db(&self, version: Version) -> io::Result<()> {
|
||||
let txn = self
|
||||
.db
|
||||
.begin_read()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("redb begin_read: {}", e)))?;
|
||||
let table = txn
|
||||
.open_table(NEEDLE_TABLE)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("redb open_table: {}", e)))?;
|
||||
let iter = table
|
||||
.iter()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("redb iter: {}", e)))?;
|
||||
for entry in iter {
|
||||
let (key_guard, val_guard) = entry.map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::Other, format!("redb iter next: {}", e))
|
||||
})?;
|
||||
let key = NeedleId(key_guard.value());
|
||||
let bytes: &[u8] = val_guard.value();
|
||||
if bytes.len() == PACKED_NEEDLE_VALUE_SIZE {
|
||||
let mut arr = [0u8; PACKED_NEEDLE_VALUE_SIZE];
|
||||
arr.copy_from_slice(bytes);
|
||||
let nv = unpack_needle_value(&arr);
|
||||
self.metric.maybe_set_max_file_key(key);
|
||||
self.metric
|
||||
.maybe_set_max_needle_end(nv.offset, nv.size, version);
|
||||
if nv.size.is_valid() {
|
||||
self.metric.file_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.metric
|
||||
.file_byte_count
|
||||
.fetch_add(nv.size.0 as u64, Ordering::Relaxed);
|
||||
} else {
|
||||
// Deleted entry (negative size)
|
||||
self.metric.deletion_count.fetch_add(1, Ordering::Relaxed);
|
||||
self.metric
|
||||
.deletion_byte_count
|
||||
.fetch_add((-nv.size.0) as u64, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load from an .idx file, reusing an existing .rdb if it is consistent.
|
||||
///
|
||||
/// Strategy:
|
||||
@@ -586,11 +610,12 @@ impl RedbNeedleMap {
|
||||
.open(db_path)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("redb open: {}", e)))?;
|
||||
|
||||
let nm = RedbNeedleMap {
|
||||
let mut nm = RedbNeedleMap {
|
||||
db,
|
||||
metric: NeedleMapMetric::default(),
|
||||
idx_file: None,
|
||||
idx_file_offset: 0,
|
||||
writes_since_checkpoint: 0,
|
||||
};
|
||||
|
||||
let stored_idx_size = nm
|
||||
@@ -605,8 +630,10 @@ impl RedbNeedleMap {
|
||||
));
|
||||
}
|
||||
|
||||
// Rebuild metrics from existing data
|
||||
nm.rebuild_metrics_from_db(version)?;
|
||||
// Counters come from the whole .idx history, never from the table,
|
||||
// so the replay below is free to re-apply rows the table already
|
||||
// holds (redb flushes on drop even without a checkpoint).
|
||||
nm.metric = metrics_from_idx(reader, version)?;
|
||||
|
||||
if stored_idx_size < idx_size {
|
||||
// .idx grew — replay new entries incrementally
|
||||
@@ -617,14 +644,12 @@ impl RedbNeedleMap {
|
||||
io::Error::new(io::ErrorKind::Other, format!("redb open_table: {}", e))
|
||||
})?;
|
||||
idx::walk_index_file(reader, start_entry, |key, offset, size| {
|
||||
nm.metric.maybe_set_max_needle_end(offset, size, version);
|
||||
let key_u64: u64 = key.into();
|
||||
if offset.is_zero() || size.is_deleted() {
|
||||
// Delete: look up old value for metric update, then
|
||||
// store tombstone (negative size with original offset)
|
||||
// Delete: store a tombstone (negative size, original
|
||||
// offset) over a live value; already deleted is a no-op.
|
||||
if let Ok(Some(old)) = nm.get_via_table(&table, key_u64) {
|
||||
if old.size.is_valid() {
|
||||
nm.metric.on_delete(&old);
|
||||
let deleted_nv = NeedleValue {
|
||||
offset: old.offset,
|
||||
size: Size(-(old.size.0)),
|
||||
@@ -639,14 +664,10 @@ impl RedbNeedleMap {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Put: look up old value for metric update
|
||||
let old = nm.get_via_table(&table, key_u64).ok().flatten();
|
||||
let nv = NeedleValue { offset, size };
|
||||
let packed = pack_needle_value(&nv);
|
||||
let packed = pack_needle_value(&NeedleValue { offset, size });
|
||||
table.insert(key_u64, packed.as_slice()).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::Other, format!("redb insert: {}", e))
|
||||
})?;
|
||||
nm.metric.on_put(key, old.as_ref(), size);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -695,12 +716,11 @@ impl RedbNeedleMap {
|
||||
cache_bytes: usize,
|
||||
) -> io::Result<Self> {
|
||||
let _ = std::fs::remove_file(db_path);
|
||||
let nm = RedbNeedleMap::new(db_path, cache_bytes)?;
|
||||
let mut nm = RedbNeedleMap::new(db_path, cache_bytes)?;
|
||||
|
||||
// Collect entries from idx file, resolving duplicates/deletions
|
||||
let mut entries: HashMap<NeedleId, Option<NeedleValue>> = HashMap::new();
|
||||
idx::walk_index_file(reader, 0, |key, offset, size| {
|
||||
nm.metric.maybe_set_max_needle_end(offset, size, version);
|
||||
if offset.is_zero() || size.is_deleted() {
|
||||
entries.insert(key, None);
|
||||
} else {
|
||||
@@ -723,7 +743,6 @@ impl RedbNeedleMap {
|
||||
table.insert(key_u64, packed.as_slice()).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::Other, format!("redb insert: {}", e))
|
||||
})?;
|
||||
nm.metric.on_put(*key, None, nv.size);
|
||||
} else {
|
||||
// Entry was deleted — remove from redb if present
|
||||
table.remove(key_u64).map_err(|e| {
|
||||
@@ -736,6 +755,7 @@ impl RedbNeedleMap {
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("redb commit: {}", e)))?;
|
||||
|
||||
nm.save_idx_size_meta(idx_size)?;
|
||||
nm.metric = metrics_from_idx(reader, version)?;
|
||||
|
||||
Ok(nm)
|
||||
}
|
||||
@@ -755,10 +775,13 @@ impl RedbNeedleMap {
|
||||
|
||||
/// Insert or update an entry. Writes to idx file first, then redb.
|
||||
pub fn put(&mut self, key: NeedleId, offset: Offset, size: Size) -> io::Result<()> {
|
||||
// Persist to idx file BEFORE mutating redb state for crash consistency
|
||||
// Persist to idx file BEFORE mutating redb state for crash consistency.
|
||||
// The offset is advanced only after the redb commit succeeds: a failed
|
||||
// commit leaves an orphan row in .idx that redb doesn't reflect, and
|
||||
// advancing the offset here would let a later checkpoint record it as
|
||||
// reflected, making the reload skip it permanently.
|
||||
if let Some(ref mut idx_file) = self.idx_file {
|
||||
idx::write_index_entry(idx_file, key, offset, size)?;
|
||||
self.idx_file_offset += NEEDLE_MAP_ENTRY_SIZE as u64;
|
||||
}
|
||||
|
||||
let key_u64: u64 = key.into();
|
||||
@@ -777,8 +800,14 @@ impl RedbNeedleMap {
|
||||
.insert(key_u64, packed.as_slice())
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("redb insert: {}", e)))?;
|
||||
}
|
||||
txn.commit()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("redb commit: {}", e)))?;
|
||||
if let Err(e) = txn.commit() {
|
||||
self.truncate_idx_to_offset();
|
||||
return Err(io::Error::new(io::ErrorKind::Other, format!("redb commit: {}", e)));
|
||||
}
|
||||
if self.idx_file.is_some() {
|
||||
self.idx_file_offset += NEEDLE_MAP_ENTRY_SIZE as u64;
|
||||
}
|
||||
self.writes_since_checkpoint = self.writes_since_checkpoint.saturating_add(1);
|
||||
|
||||
self.metric.on_put(key, old.as_ref(), size);
|
||||
Ok(())
|
||||
@@ -826,13 +855,13 @@ impl RedbNeedleMap {
|
||||
|
||||
if let Some(old) = self.get_internal(key_u64)? {
|
||||
if old.size.is_valid() {
|
||||
// Persist tombstone to idx file BEFORE mutating redb
|
||||
// Persist tombstone to idx file BEFORE mutating redb. The
|
||||
// offset is advanced only after the redb commit succeeds
|
||||
// (see put).
|
||||
if let Some(ref mut idx_file) = self.idx_file {
|
||||
idx::write_index_entry(idx_file, key, offset, TOMBSTONE_FILE_SIZE)?;
|
||||
self.idx_file_offset += NEEDLE_MAP_ENTRY_SIZE as u64;
|
||||
}
|
||||
|
||||
self.metric.on_delete(&old);
|
||||
let deleted_size = Size(-(old.size.0));
|
||||
// Keep original offset so readDeleted can find original data (matching Go behavior)
|
||||
let deleted_nv = NeedleValue {
|
||||
@@ -850,10 +879,17 @@ impl RedbNeedleMap {
|
||||
io::Error::new(io::ErrorKind::Other, format!("redb insert: {}", e))
|
||||
})?;
|
||||
}
|
||||
txn.commit().map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::Other, format!("redb commit: {}", e))
|
||||
})?;
|
||||
if let Err(e) = txn.commit() {
|
||||
self.truncate_idx_to_offset();
|
||||
return Err(io::Error::new(io::ErrorKind::Other, format!("redb commit: {}", e)));
|
||||
}
|
||||
if self.idx_file.is_some() {
|
||||
self.idx_file_offset += NEEDLE_MAP_ENTRY_SIZE as u64;
|
||||
}
|
||||
self.writes_since_checkpoint = self.writes_since_checkpoint.saturating_add(1);
|
||||
|
||||
// Only now is the tombstone in the table the metrics describe.
|
||||
self.metric.on_delete(&old);
|
||||
return Ok(Some(old.size));
|
||||
}
|
||||
}
|
||||
@@ -900,9 +936,38 @@ impl RedbNeedleMap {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close index file.
|
||||
/// Remove any .idx bytes past `idx_file_offset` — the orphan row left by
|
||||
/// a failed redb commit. Without this the next successful write appends
|
||||
/// after the orphan, `idx_file_offset` advances past it, and a later
|
||||
/// checkpoint records an offset that makes the reload skip the orphan.
|
||||
fn truncate_idx_to_offset(&mut self) {
|
||||
if let Some(ref mut idx_file) = self.idx_file {
|
||||
if let Err(e) = idx_file.truncate_to(self.idx_file_offset) {
|
||||
tracing::warn!("failed to truncate orphan .idx row: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the index file, checkpointing first so a reload starts from
|
||||
/// the recorded .idx size instead of replaying entries the table
|
||||
/// already holds.
|
||||
pub fn close(&mut self) {
|
||||
let _ = self.sync();
|
||||
if let Err(e) = self.checkpoint(true) {
|
||||
tracing::warn!("redb checkpoint on close failed: {}", e);
|
||||
}
|
||||
self.idx_file = None;
|
||||
}
|
||||
|
||||
/// Sync the .idx and drop the writer without taking a durable checkpoint.
|
||||
/// Used when the .dat flush failed: a checkpoint would make the index
|
||||
/// durable with entries that may point past the unflushed .dat tail, so
|
||||
/// the reload's max_needle_end check would mark the volume read-only.
|
||||
/// Without the checkpoint, META_IDX_SIZE stays at the last successful one
|
||||
/// and the reload replays the uncheckpointed tail (redb flushes on drop).
|
||||
pub fn close_without_checkpoint(&mut self) {
|
||||
if let Err(e) = self.sync() {
|
||||
tracing::warn!("redb idx sync on close failed: {}", e);
|
||||
}
|
||||
self.idx_file = None;
|
||||
}
|
||||
|
||||
@@ -1068,6 +1133,27 @@ impl NeedleMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the backend wants a durable checkpoint. Only the redb map
|
||||
/// commits non-durably between checkpoints; see
|
||||
/// `RedbNeedleMap::checkpoint_due`.
|
||||
pub fn checkpoint_due(&self) -> bool {
|
||||
match self {
|
||||
NeedleMap::Redb(nm) => nm.checkpoint_due(),
|
||||
NeedleMap::InMemory(_) | NeedleMap::SortedFile(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the checkpoint `checkpoint_due` asked for. The caller must have
|
||||
/// flushed the .dat first. Pass `sync_idx = false` when the .idx has
|
||||
/// already been fsynced (e.g. by `flush_idx` on the fsync=true path) to
|
||||
/// avoid a redundant fsync.
|
||||
pub fn checkpoint(&mut self, sync_idx: bool) -> io::Result<()> {
|
||||
match self {
|
||||
NeedleMap::Redb(nm) => nm.checkpoint(sync_idx),
|
||||
NeedleMap::InMemory(_) | NeedleMap::SortedFile(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Content byte count.
|
||||
pub fn content_size(&self) -> u64 {
|
||||
match self {
|
||||
@@ -1151,6 +1237,16 @@ impl NeedleMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// Close without checkpointing — sync the .idx and drop the writer only.
|
||||
/// See [`RedbNeedleMap::close_without_checkpoint`].
|
||||
pub fn close_without_checkpoint(&mut self) {
|
||||
match self {
|
||||
NeedleMap::InMemory(nm) => nm.close(),
|
||||
NeedleMap::Redb(nm) => nm.close_without_checkpoint(),
|
||||
NeedleMap::SortedFile(nm) => nm.close(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save to an index file.
|
||||
pub fn save_to_idx(&self, path: &str) -> io::Result<()> {
|
||||
match self {
|
||||
@@ -1198,6 +1294,29 @@ impl NeedleMap {
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
use super::*;
|
||||
|
||||
/// The `.idx` size recorded in the durable state of the `.rdb` at
|
||||
/// `rdb_path`, read from a copy taken while the map may still be open:
|
||||
/// exactly what a crash would leave behind. `None` when nothing durable
|
||||
/// has been recorded yet.
|
||||
pub(crate) fn durable_idx_size(rdb_path: &Path) -> Option<u64> {
|
||||
let copy = rdb_path.with_extension("crash-copy.rdb");
|
||||
std::fs::copy(rdb_path, ©).unwrap();
|
||||
let db = Database::open(©).unwrap();
|
||||
let txn = db.begin_read().unwrap();
|
||||
let meta = txn.open_table(META_TABLE).ok()?;
|
||||
let size = meta.get(META_IDX_SIZE).unwrap().map(|g| g.value());
|
||||
drop(meta);
|
||||
drop(txn);
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(©);
|
||||
size
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1338,6 +1457,36 @@ mod tests {
|
||||
NeedleMapKind::Redb.redb_cache_bytes()
|
||||
}
|
||||
|
||||
/// Open a redb map on an empty .idx with an append writer attached, the
|
||||
/// way `Volume::load_index_redb` opens a writable volume.
|
||||
fn open_writable_redb(
|
||||
dir: &std::path::Path,
|
||||
) -> (RedbNeedleMap, std::path::PathBuf, std::path::PathBuf) {
|
||||
let db_path = dir.join("v.rdb");
|
||||
let idx_path = dir.join("v.idx");
|
||||
let idx_file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(&idx_path)
|
||||
.unwrap();
|
||||
let idx_size = idx_file.metadata().unwrap().len();
|
||||
let mut reader = std::io::BufReader::new(&idx_file);
|
||||
let mut nm = RedbNeedleMap::load_from_idx(
|
||||
db_path.to_str().unwrap(),
|
||||
&mut reader,
|
||||
Version::current(),
|
||||
redb_test_cache(),
|
||||
)
|
||||
.unwrap();
|
||||
let writer = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&idx_path)
|
||||
.unwrap();
|
||||
nm.set_idx_file(Box::new(writer), idx_size);
|
||||
(nm, db_path, idx_path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redb_needle_map_put_get() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -1462,7 +1611,11 @@ mod tests {
|
||||
assert!(nm.get(NeedleId(1)).unwrap().is_some());
|
||||
assert!(nm.get(NeedleId(2)).unwrap().is_none()); // deleted and removed
|
||||
assert!(nm.get(NeedleId(3)).unwrap().is_some());
|
||||
assert_eq!(nm.file_count(), 2);
|
||||
// Same history as test_needle_map_load_from_idx: the counters must
|
||||
// match what the in-memory map (and a live volume) accumulates.
|
||||
assert_eq!(nm.file_count(), 3);
|
||||
assert_eq!(nm.deleted_count(), 1);
|
||||
assert_eq!(nm.deleted_size(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1628,4 +1781,161 @@ mod tests {
|
||||
}
|
||||
assert_eq!(nm.file_count(), n as i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redb_checkpoint_is_explicit_and_due_every_interval() {
|
||||
use test_support::durable_idx_size;
|
||||
|
||||
// Every non-durable redb commit leaves bookkeeping behind until a
|
||||
// durable one clears it, so a writable map asks for a checkpoint on
|
||||
// a fixed cadence. The map never takes it by itself: the volume has
|
||||
// to flush the .dat first, then call checkpoint().
|
||||
const EXPECTED_INTERVAL: u64 = 1000;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut nm, db_path, _idx_path) = open_writable_redb(dir.path());
|
||||
for i in 1..EXPECTED_INTERVAL {
|
||||
nm.put(NeedleId(i), Offset::from_actual_offset((i * 8) as i64), Size(1))
|
||||
.unwrap();
|
||||
assert!(!nm.checkpoint_due(), "due after only {i} writes");
|
||||
}
|
||||
nm.put(
|
||||
NeedleId(EXPECTED_INTERVAL),
|
||||
Offset::from_actual_offset((EXPECTED_INTERVAL * 8) as i64),
|
||||
Size(1),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(nm.checkpoint_due());
|
||||
assert_eq!(durable_idx_size(&db_path), None, "put() must not commit durably");
|
||||
|
||||
nm.checkpoint(true).unwrap();
|
||||
assert!(!nm.checkpoint_due());
|
||||
assert_eq!(
|
||||
durable_idx_size(&db_path),
|
||||
Some(EXPECTED_INTERVAL * NEEDLE_MAP_ENTRY_SIZE as u64),
|
||||
"checkpoint records how much of the .idx the table reflects"
|
||||
);
|
||||
|
||||
// Snapshot the .rdb while the map is still open: what a crash leaves.
|
||||
let crash_copy = dir.path().join("crash.rdb");
|
||||
std::fs::copy(&db_path, &crash_copy).unwrap();
|
||||
drop(nm);
|
||||
let db = Database::open(&crash_copy).unwrap();
|
||||
let txn = db.begin_read().unwrap();
|
||||
let table = txn.open_table(NEEDLE_TABLE).unwrap();
|
||||
assert!(
|
||||
table.get(EXPECTED_INTERVAL).unwrap().is_some(),
|
||||
"entries up to the checkpoint are durable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redb_close_records_idx_size_so_reload_does_not_double_count() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut nm, db_path, idx_path) = open_writable_redb(dir.path());
|
||||
for i in 1..=5u64 {
|
||||
nm.put(NeedleId(i), Offset::from_actual_offset((i * 8) as i64), Size(1))
|
||||
.unwrap();
|
||||
}
|
||||
nm.close();
|
||||
drop(nm);
|
||||
|
||||
// A clean close leaves the table durable; the recorded .idx size
|
||||
// must match it, or the reload replays the same 5 entries on top.
|
||||
let mut idx = std::fs::File::open(&idx_path).unwrap();
|
||||
let reloaded = RedbNeedleMap::load_from_idx(
|
||||
db_path.to_str().unwrap(),
|
||||
&mut idx,
|
||||
Version::current(),
|
||||
redb_test_cache(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reloaded.file_count(), 5);
|
||||
assert_eq!(reloaded.deleted_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redb_reload_with_stale_idx_size_does_not_double_count() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut nm, db_path, idx_path) = open_writable_redb(dir.path());
|
||||
for i in 1..=5u64 {
|
||||
nm.put(NeedleId(i), Offset::from_actual_offset((i * 8) as i64), Size(1))
|
||||
.unwrap();
|
||||
}
|
||||
// Drop without close(): redb makes the table durable on drop, but the
|
||||
// recorded .idx size stays at its load-time value (0), so the reload
|
||||
// replays all 5 entries over rows the table already holds.
|
||||
drop(nm);
|
||||
|
||||
let mut idx = std::fs::File::open(&idx_path).unwrap();
|
||||
let reloaded = RedbNeedleMap::load_from_idx(
|
||||
db_path.to_str().unwrap(),
|
||||
&mut idx,
|
||||
Version::current(),
|
||||
redb_test_cache(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reloaded.file_count(), 5);
|
||||
assert_eq!(reloaded.deleted_count(), 0);
|
||||
assert_eq!(
|
||||
reloaded.get(NeedleId(5)).unwrap().unwrap().offset,
|
||||
Offset::from_actual_offset(40)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redb_reload_metrics_keep_overwrite_and_delete_history() {
|
||||
// garbage_level() is deleted_size / content_size. Every load path
|
||||
// must rebuild both from the whole .idx history, the way the live
|
||||
// counters accumulate, not from the table's final state, or the
|
||||
// bytes of overwritten and deleted needles stop counting as garbage
|
||||
// after a restart.
|
||||
for (close_first, rebuild) in [(true, false), (false, false), (true, true)] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut nm, db_path, idx_path) = open_writable_redb(dir.path());
|
||||
nm.put(NeedleId(1), Offset::from_actual_offset(8), Size(100))
|
||||
.unwrap();
|
||||
// Overwrite: the first 100 bytes become garbage.
|
||||
nm.put(NeedleId(1), Offset::from_actual_offset(200), Size(200))
|
||||
.unwrap();
|
||||
nm.put(NeedleId(2), Offset::from_actual_offset(500), Size(50))
|
||||
.unwrap();
|
||||
nm.delete(NeedleId(2), Offset::from_actual_offset(600))
|
||||
.unwrap();
|
||||
let live = (
|
||||
nm.file_count(),
|
||||
nm.content_size(),
|
||||
nm.deleted_count(),
|
||||
nm.deleted_size(),
|
||||
);
|
||||
assert_eq!(live, (3, 350, 2, 150));
|
||||
if close_first {
|
||||
nm.close();
|
||||
}
|
||||
drop(nm);
|
||||
if rebuild {
|
||||
std::fs::remove_file(&db_path).unwrap();
|
||||
}
|
||||
|
||||
let mut idx = std::fs::File::open(&idx_path).unwrap();
|
||||
let reloaded = RedbNeedleMap::load_from_idx(
|
||||
db_path.to_str().unwrap(),
|
||||
&mut idx,
|
||||
Version::current(),
|
||||
redb_test_cache(),
|
||||
)
|
||||
.unwrap();
|
||||
let after = (
|
||||
reloaded.file_count(),
|
||||
reloaded.content_size(),
|
||||
reloaded.deleted_count(),
|
||||
reloaded.deleted_size(),
|
||||
);
|
||||
assert_eq!(
|
||||
after, live,
|
||||
"close_first={close_first} rebuild={rebuild}"
|
||||
);
|
||||
assert_eq!(reloaded.get(NeedleId(1)).unwrap().unwrap().size, Size(200));
|
||||
assert!(reloaded.get(NeedleId(2)).unwrap().map_or(true, |v| v.size.is_deleted()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Rebuild a needle map's counters from the whole `.idx` history.
|
||||
//!
|
||||
//! The volume's garbage ratio is `deleted_size / content_size`, and both are
|
||||
//! additive over the life of the volume: an overwritten needle keeps its
|
||||
//! bytes in `content_size` and adds them to `deleted_size`. A backend whose
|
||||
//! table only holds the final value per key (redb) cannot recover that from
|
||||
//! the table, so on load the counters come from the `.idx` file instead.
|
||||
//!
|
||||
//! This mirrors Go's `needleMapMetricFromIndexFile`: walk the index newest
|
||||
//! entry first with a bloom filter of the keys already seen, so the memory
|
||||
//! cost is a few bits per entry instead of a map of every key. The counting
|
||||
//! rule reproduces what the live `on_put`/`on_delete` path accumulates:
|
||||
//!
|
||||
//! - every live entry is one put: `file_count`, `file_byte_count`;
|
||||
//! - a live entry with a newer entry for the same key was overwritten or
|
||||
//! deleted later, so it is also one deletion: `deletion_count`,
|
||||
//! `deletion_byte_count`;
|
||||
//! - a tombstone only marks its key as seen.
|
||||
//!
|
||||
//! A bloom false positive (0.1%) can only add a spurious deletion, which
|
||||
//! over-reports garbage slightly; it never hides any.
|
||||
|
||||
use std::io::{self, Read, Seek, SeekFrom};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use xxhash_rust::xxh64::xxh64;
|
||||
|
||||
use super::NeedleMapMetric;
|
||||
use crate::storage::types::*;
|
||||
|
||||
/// Entries read per batch while walking backwards (64 KiB of index).
|
||||
const BATCH_ENTRIES: usize = 4096;
|
||||
/// Same target false-positive rate as the Go server's filter.
|
||||
const FALSE_POSITIVE_RATE: f64 = 0.001;
|
||||
|
||||
/// Minimal bloom filter over needle ids, double hashing with xxh64.
|
||||
struct SeenKeys {
|
||||
bits: Vec<u64>,
|
||||
bit_count: u64,
|
||||
hashes: u64,
|
||||
}
|
||||
|
||||
impl SeenKeys {
|
||||
fn new(expected: u64, false_positive_rate: f64) -> Self {
|
||||
let n = expected.max(1) as f64;
|
||||
let ln2 = std::f64::consts::LN_2;
|
||||
let bit_count = (-(n * false_positive_rate.ln()) / (ln2 * ln2))
|
||||
.ceil()
|
||||
.max(64.0) as u64;
|
||||
let hashes = ((bit_count as f64 / n) * ln2).round().clamp(1.0, 16.0) as u64;
|
||||
SeenKeys {
|
||||
bits: vec![0u64; bit_count.div_ceil(64) as usize],
|
||||
bit_count,
|
||||
hashes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `key` was (probably) seen before; marks it seen either way.
|
||||
fn test_and_add(&mut self, key: u64) -> bool {
|
||||
let bytes = key.to_le_bytes();
|
||||
let h1 = xxh64(&bytes, 0);
|
||||
let h2 = xxh64(&bytes, 0x9E37_79B9_7F4A_7C15) | 1;
|
||||
let mut seen = true;
|
||||
for i in 0..self.hashes {
|
||||
let bit = h1.wrapping_add(i.wrapping_mul(h2)) % self.bit_count;
|
||||
let word = (bit / 64) as usize;
|
||||
let mask = 1u64 << (bit % 64);
|
||||
if self.bits[word] & mask == 0 {
|
||||
seen = false;
|
||||
self.bits[word] |= mask;
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `reader` (an `.idx` file) newest entry first and return the counters
|
||||
/// a live volume would hold after applying the same history. A torn partial
|
||||
/// entry at the tail is ignored, as `walk_index_file` does. The reader is
|
||||
/// left positioned at the start of the file.
|
||||
pub(super) fn metrics_from_idx<R: Read + Seek>(
|
||||
reader: &mut R,
|
||||
version: Version,
|
||||
) -> io::Result<NeedleMapMetric> {
|
||||
let metric = NeedleMapMetric::default();
|
||||
let file_size = reader.seek(SeekFrom::End(0))?;
|
||||
let entry_count = file_size / NEEDLE_MAP_ENTRY_SIZE as u64;
|
||||
let mut seen = SeenKeys::new(entry_count, FALSE_POSITIVE_RATE);
|
||||
let mut buf = vec![0u8; NEEDLE_MAP_ENTRY_SIZE * BATCH_ENTRIES];
|
||||
|
||||
let mut remaining = entry_count;
|
||||
while remaining > 0 {
|
||||
let batch = remaining.min(BATCH_ENTRIES as u64) as usize;
|
||||
let first_entry = remaining - batch as u64;
|
||||
let len = batch * NEEDLE_MAP_ENTRY_SIZE;
|
||||
reader.seek(SeekFrom::Start(first_entry * NEEDLE_MAP_ENTRY_SIZE as u64))?;
|
||||
reader.read_exact(&mut buf[..len])?;
|
||||
for i in (0..batch).rev() {
|
||||
let entry = &buf[i * NEEDLE_MAP_ENTRY_SIZE..(i + 1) * NEEDLE_MAP_ENTRY_SIZE];
|
||||
let (key, offset, size) = idx_entry_from_bytes(entry);
|
||||
metric.maybe_set_max_needle_end(offset, size, version);
|
||||
let superseded = seen.test_and_add(key.into());
|
||||
if offset.is_zero() || size.is_deleted() {
|
||||
// Tombstone: reserves no bytes, only marks the key as seen.
|
||||
continue;
|
||||
}
|
||||
metric.maybe_set_max_file_key(key);
|
||||
metric.file_count.fetch_add(1, Ordering::Relaxed);
|
||||
metric
|
||||
.file_byte_count
|
||||
.fetch_add(size.0 as u64, Ordering::Relaxed);
|
||||
if superseded && size.0 > 0 {
|
||||
metric.deletion_count.fetch_add(1, Ordering::Relaxed);
|
||||
metric
|
||||
.deletion_byte_count
|
||||
.fetch_add(size.0 as u64, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
remaining = first_entry;
|
||||
}
|
||||
|
||||
reader.seek(SeekFrom::Start(0))?;
|
||||
Ok(metric)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_seen_keys_reports_repeats_and_not_fresh_keys() {
|
||||
let mut seen = SeenKeys::new(10_000, FALSE_POSITIVE_RATE);
|
||||
// Fresh keys may occasionally collide (that is the false-positive
|
||||
// rate), but only rarely.
|
||||
let fresh_reported_seen = (0..10_000u64)
|
||||
.filter(|&key| seen.test_and_add(key))
|
||||
.count();
|
||||
assert!(
|
||||
fresh_reported_seen < 50,
|
||||
"fresh keys reported seen: {fresh_reported_seen}"
|
||||
);
|
||||
// A repeated key is never reported fresh: no false negatives.
|
||||
for key in 0..10_000u64 {
|
||||
assert!(seen.test_and_add(key), "repeated key {key} reported fresh");
|
||||
}
|
||||
// Over 100k never-inserted keys the false-positive rate stays near
|
||||
// the 0.1% target; allow a generous margin.
|
||||
let false_positives = (1_000_000..1_100_000u64)
|
||||
.filter(|k| {
|
||||
let bytes = k.to_le_bytes();
|
||||
let h1 = xxh64(&bytes, 0);
|
||||
let h2 = xxh64(&bytes, 0x9E37_79B9_7F4A_7C15) | 1;
|
||||
(0..seen.hashes).all(|i| {
|
||||
let bit = h1.wrapping_add(i.wrapping_mul(h2)) % seen.bit_count;
|
||||
seen.bits[(bit / 64) as usize] & (1u64 << (bit % 64)) != 0
|
||||
})
|
||||
})
|
||||
.count();
|
||||
assert!(false_positives < 500, "false positives: {false_positives}");
|
||||
}
|
||||
}
|
||||
@@ -1874,10 +1874,45 @@ impl Volume {
|
||||
self.last_modified_ts_seconds = n.last_modified;
|
||||
}
|
||||
|
||||
self.maybe_checkpoint_index(fsync);
|
||||
|
||||
// Return Size(n.DataSize) as the logical size, matching Go's doWriteRequest
|
||||
Ok((offset, Size(n.data_size as i32), false))
|
||||
}
|
||||
|
||||
/// Take the index checkpoint the needle map asked for, data first: the
|
||||
/// checkpoint makes the index durable, and an index row that outlives
|
||||
/// the bytes it points at loads read-only (see load()). A failed .dat
|
||||
/// flush therefore skips the checkpoint; the map keeps asking, so it is
|
||||
/// retried on the next write.
|
||||
///
|
||||
/// When `idx_already_synced` is true the .idx has already been fsynced by
|
||||
/// `flush_idx` on the fsync=true write path, so the checkpoint skips its
|
||||
/// own .idx fsync to avoid a redundant one.
|
||||
fn maybe_checkpoint_index(&mut self, idx_already_synced: bool) {
|
||||
let due = self.nm.as_ref().is_some_and(|nm| nm.checkpoint_due());
|
||||
if !due {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.flush_dat() {
|
||||
self.check_read_write_error(Some(&e));
|
||||
tracing::warn!(
|
||||
"volume {}: skipping index checkpoint, .dat flush failed: {}",
|
||||
self.id.0,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
let checkpointed = match self.nm.as_mut() {
|
||||
Some(nm) => nm.checkpoint(!idx_already_synced),
|
||||
None => Ok(()),
|
||||
};
|
||||
if let Err(e) = checkpointed {
|
||||
self.check_read_write_error(Some(&e));
|
||||
tracing::warn!("volume {}: index checkpoint failed: {}", self.id.0, e);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_needle_header_unlocked(&self, n: &mut Needle, offset: i64) -> Result<(), VolumeError> {
|
||||
let mut header = [0u8; NEEDLE_HEADER_SIZE];
|
||||
self.read_exact_at_backend(&mut header, offset as u64)?;
|
||||
@@ -2014,6 +2049,7 @@ impl Volume {
|
||||
if let Some(nm) = &mut self.nm {
|
||||
nm.delete(n.id, Offset::from_actual_offset(offset as i64))?;
|
||||
}
|
||||
self.maybe_checkpoint_index(false);
|
||||
|
||||
Ok(size)
|
||||
}
|
||||
@@ -2726,6 +2762,7 @@ impl Volume {
|
||||
if let Some(ref mut nm) = self.nm {
|
||||
nm.put(key, offset, size).map_err(VolumeError::Io)?;
|
||||
}
|
||||
self.maybe_checkpoint_index(false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3360,6 +3397,7 @@ impl Volume {
|
||||
if let Some(ref mut nm) = self.nm {
|
||||
nm.put(needle_id, offset, size)?;
|
||||
}
|
||||
self.maybe_checkpoint_index(false);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -3967,13 +4005,29 @@ impl Volume {
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
if let Some(ref dat_file) = self.dat_file {
|
||||
let _ = dat_file.sync_all();
|
||||
}
|
||||
let dat_synced = if let Some(ref dat_file) = self.dat_file {
|
||||
dat_file.sync_all().is_ok()
|
||||
} else {
|
||||
true
|
||||
};
|
||||
self.dat_file = None;
|
||||
self.remote_dat_file = None;
|
||||
if let Some(ref nm) = self.nm {
|
||||
let _ = nm.sync();
|
||||
// When the .dat flushed, checkpoint the index so the next load starts
|
||||
// from the recorded .idx size. When it did not, skip the checkpoint:
|
||||
// a durable index pointing past an unflushed .dat tail would make the
|
||||
// reload's max_needle_end check mark the volume read-only. Without the
|
||||
// checkpoint, META_IDX_SIZE stays at the last successful one and the
|
||||
// reload replays the uncheckpointed tail (redb flushes on drop).
|
||||
if let Some(ref mut nm) = self.nm {
|
||||
if dat_synced {
|
||||
nm.close();
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"volume {}: .dat sync failed on close, skipping index checkpoint",
|
||||
self.id.0
|
||||
);
|
||||
nm.close_without_checkpoint();
|
||||
}
|
||||
}
|
||||
self.nm = None;
|
||||
}
|
||||
@@ -5507,6 +5561,106 @@ mod tests {
|
||||
assert_eq!(std::str::from_utf8(&n.data).unwrap(), "data 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redb_volume_close_then_reload_keeps_counters_exact() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().to_str().unwrap();
|
||||
let open = || {
|
||||
Volume::new(
|
||||
dir,
|
||||
dir,
|
||||
"",
|
||||
VolumeId(1),
|
||||
NeedleMapKind::Redb,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
Version::current(),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
{
|
||||
let mut v = open();
|
||||
for i in 1..=3 {
|
||||
let data = format!("data {}", i);
|
||||
let mut n = Needle {
|
||||
id: NeedleId(i),
|
||||
cookie: Cookie(i as u32),
|
||||
data: data.as_bytes().to_vec(),
|
||||
data_size: data.len() as u32,
|
||||
..Needle::default()
|
||||
};
|
||||
v.write_needle(&mut n, true, false).unwrap();
|
||||
}
|
||||
// Volume::close is the shutdown path (store -> disk location ->
|
||||
// volume). It must checkpoint the redb index, or the reload
|
||||
// replays rows the table already holds and inflates the counters.
|
||||
v.close();
|
||||
}
|
||||
|
||||
let v = open();
|
||||
assert_eq!(v.file_count(), 3);
|
||||
assert_eq!(v.deleted_count(), 0);
|
||||
let mut n = Needle {
|
||||
id: NeedleId(2),
|
||||
..Needle::default()
|
||||
};
|
||||
v.read_needle(&mut n).unwrap();
|
||||
assert_eq!(std::str::from_utf8(&n.data).unwrap(), "data 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redb_volume_checkpoint_flushes_dat_before_index() {
|
||||
use crate::storage::needle_map::test_support::durable_idx_size;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().to_str().unwrap();
|
||||
let mut v = Volume::new(
|
||||
dir,
|
||||
dir,
|
||||
"",
|
||||
VolumeId(1),
|
||||
NeedleMapKind::Redb,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
Version::current(),
|
||||
)
|
||||
.unwrap();
|
||||
let rdb_path = std::path::PathBuf::from(v.file_name(".rdb"));
|
||||
let write = |v: &mut Volume, i: u64| {
|
||||
let mut n = Needle {
|
||||
id: NeedleId(i),
|
||||
cookie: Cookie(1),
|
||||
data: b"x".to_vec(),
|
||||
data_size: 1,
|
||||
..Needle::default()
|
||||
};
|
||||
// fsync=false: the .dat append is not flushed by the write itself.
|
||||
v.write_needle(&mut n, true, false).unwrap();
|
||||
};
|
||||
for i in 1..1000 {
|
||||
write(&mut v, i);
|
||||
}
|
||||
assert_eq!(durable_idx_size(&rdb_path), None);
|
||||
|
||||
// The 1000th write makes an index checkpoint due. A checkpoint makes
|
||||
// the index durable, so the volume has to flush the .dat first, and
|
||||
// when that flush fails nothing may be checkpointed: a durable index
|
||||
// row pointing past the end of an unflushed .dat loads read-only.
|
||||
v.fail_next_fsync_for_test(true);
|
||||
write(&mut v, 1000);
|
||||
assert_eq!(durable_idx_size(&rdb_path), None);
|
||||
|
||||
v.fail_next_fsync_for_test(false);
|
||||
write(&mut v, 1001);
|
||||
assert_eq!(
|
||||
durable_idx_size(&rdb_path),
|
||||
Some(1001 * NEEDLE_MAP_ENTRY_SIZE as u64)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relocate_index_to_moves_index_and_serves_reads() {
|
||||
let root = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user