Files
seaweedfs/seaweed-volume/src/storage/volume.rs
T
b82cb05d71 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>
2026-09-05 21:46:34 -07:00

7808 lines
285 KiB
Rust

//! Volume: the core storage unit — a .dat file + .idx index.
//!
//! Each volume contains many needles (files). It manages:
//! - Reading/writing/deleting needles from the .dat file
//! - Maintaining the in-memory NeedleMap (NeedleId → Offset+Size)
//! - SuperBlock at offset 0 of the .dat file
//! - Metrics (file count, content size, deleted count)
//!
//! Matches Go's storage/volume.go, volume_loading.go, volume_read.go,
//! volume_write.go, volume_super_block.go.
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::{Condvar, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{error, info, warn};
use crate::storage::idx;
use crate::storage::needle::needle::{self, get_actual_size, Needle, NeedleError};
use crate::storage::needle_map::sorted_file::SortedFileNeedleMap;
use crate::storage::needle_map::{CompactNeedleMap, NeedleMap, NeedleMapKind, RedbNeedleMap};
use crate::storage::super_block::{ReplicaPlacement, SuperBlock, SUPER_BLOCK_SIZE};
use crate::storage::types::*;
use crate::storage::volume_open::open_volume_file;
// ============================================================================
// Errors
// ============================================================================
#[derive(Debug, thiserror::Error)]
pub enum VolumeError {
#[error("not found")]
NotFound,
#[error("already deleted")]
Deleted,
#[error("needle size mismatch")]
SizeMismatch,
#[error("unsupported version: {0}")]
UnsupportedVersion(u8),
#[error("cookie mismatch: {0:#x}")]
CookieMismatch(u32),
#[error("volume not empty")]
NotEmpty,
#[error("volume already exists")]
AlreadyExists,
#[error("volume is read-only")]
ReadOnly,
#[error("volume size limit exceeded: current {current}, limit {limit}")]
SizeLimitExceeded { current: u64, limit: u64 },
#[error("volume not initialized")]
NotInitialized,
#[error("needle error: {0}")]
Needle(#[from] NeedleError),
#[error("super block error: {0}")]
SuperBlock(#[from] crate::storage::super_block::SuperBlockError),
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("streaming from remote-backed volume requires buffered fallback")]
StreamingUnsupported,
}
/// Returns true when a needle read failed because the on-disk bytes are
/// unreadable in a permanent way (offset past EOF, header corruption, CRC
/// mismatch, malformed v2/v3/v4 fields). Vacuum can safely drop such entries
/// during compaction. Anything else (real disk EIO, ERROR_CRC on Windows,
/// network timeouts, EROFS, etc.) is transient or environmental and must
/// abort the compaction so an operator notices.
fn is_skippable_needle_read_error(e: &VolumeError) -> bool {
match e {
VolumeError::Io(io_err) => io_err.kind() == io::ErrorKind::UnexpectedEof,
VolumeError::Needle(NeedleError::SizeMismatch { .. }) => true,
VolumeError::Needle(NeedleError::CrcMismatch { .. }) => true,
VolumeError::Needle(NeedleError::IndexOutOfRange(_)) => true,
VolumeError::Needle(NeedleError::TailTooShort) => true,
VolumeError::SizeMismatch => true,
_ => false,
}
}
// ============================================================================
// VolumeInfo (.vif persistence)
// ============================================================================
/// Legacy simple VolumeInfo for backward compat with old .vif files.
#[derive(serde::Serialize, serde::Deserialize)]
struct VolumeInfo {
read_only: bool,
}
pub use crate::pb::volume_server_pb::RemoteFile as PbRemoteFile;
/// Protobuf VolumeInfo type alias.
pub use crate::pb::volume_server_pb::VolumeInfo as PbVolumeInfo;
/// Helper module for deserializing protojson uint64 fields that may be strings or numbers.
mod string_or_u64 {
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(value: &u64, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
// Emit as string to match Go's protojson format for uint64
serializer.serialize_str(&value.to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrNum {
Str(String),
Num(u64),
}
match StringOrNum::deserialize(deserializer)? {
StringOrNum::Str(s) => s.parse::<u64>().map_err(serde::de::Error::custom),
StringOrNum::Num(n) => Ok(n),
}
}
}
mod string_or_i64 {
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(value: &i64, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&value.to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<i64, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrNum {
Str(String),
Num(i64),
}
match StringOrNum::deserialize(deserializer)? {
StringOrNum::Str(s) => s.parse::<i64>().map_err(serde::de::Error::custom),
StringOrNum::Num(n) => Ok(n),
}
}
}
/// Serde-compatible representation of RemoteFile for .vif JSON serialization.
/// Field names use snake_case to match Go's protobuf JSON output (jsonpb).
#[derive(serde::Serialize, serde::Deserialize, Default, Clone)]
pub struct VifRemoteFile {
#[serde(default, rename = "backendType")]
pub backend_type: String,
#[serde(default, rename = "backendId")]
pub backend_id: String,
#[serde(default)]
pub key: String,
#[serde(default, with = "string_or_u64")]
pub offset: u64,
#[serde(default, rename = "fileSize", with = "string_or_u64")]
pub file_size: u64,
#[serde(default, rename = "modifiedTime", with = "string_or_u64")]
pub modified_time: u64,
#[serde(default)]
pub extension: String,
}
#[derive(serde::Serialize, serde::Deserialize, Default, Clone)]
pub struct VifEcShardConfig {
#[serde(default, rename = "dataShards")]
pub data_shards: u32,
#[serde(default, rename = "parityShards")]
pub parity_shards: u32,
/// Encode time (unix nanos). Shards and .ecx of one encode run share it,
/// so a read served from a different run's shard is rejected.
#[serde(default, rename = "encodeTsNs", with = "string_or_i64")]
pub encode_ts_ns: i64,
/// Uniform block layout: each shard is a single contiguous block of this
/// many bytes. 0 = legacy 1GiB/1MiB two-tier layout.
#[serde(default, rename = "blockSize", with = "string_or_i64")]
pub block_size: i64,
}
/// Serde-compatible representation of OldVersionVolumeInfo for legacy .vif JSON deserialization.
/// Matches Go's protobuf OldVersionVolumeInfo where `DestroyTime` maps to `expire_at_sec`.
#[derive(serde::Deserialize, Default)]
struct OldVersionVifVolumeInfo {
#[serde(default)]
pub files: Vec<VifRemoteFile>,
#[serde(default)]
pub version: u32,
#[serde(default)]
pub replication: String,
#[serde(default, alias = "bytesOffset", alias = "BytesOffset")]
pub bytes_offset: u32,
#[serde(default, alias = "datFileSize", alias = "dat_file_size", with = "string_or_i64")]
pub dat_file_size: i64,
#[serde(default, alias = "destroyTime", alias = "DestroyTime", with = "string_or_u64")]
pub destroy_time: u64,
#[serde(default, alias = "readOnly", alias = "read_only")]
pub read_only: bool,
}
impl OldVersionVifVolumeInfo {
/// Convert to the standard VifVolumeInfo, mapping destroy_time -> expire_at_sec.
fn to_vif(self) -> VifVolumeInfo {
VifVolumeInfo {
files: self.files,
version: self.version,
replication: self.replication,
bytes_offset: self.bytes_offset,
dat_file_size: self.dat_file_size,
expire_at_sec: self.destroy_time,
read_only: self.read_only,
ec_shard_config: None,
read_only_can_delete: false,
}
}
}
/// Serde-compatible representation of VolumeInfo for .vif JSON serialization.
/// Matches Go's protobuf JSON format (jsonpb with EmitUnpopulated=true).
#[derive(serde::Serialize, serde::Deserialize, Default, Clone)]
pub struct VifVolumeInfo {
#[serde(default)]
pub files: Vec<VifRemoteFile>,
#[serde(default)]
pub version: u32,
#[serde(default)]
pub replication: String,
#[serde(default, rename = "bytesOffset")]
pub bytes_offset: u32,
#[serde(default, rename = "datFileSize", with = "string_or_i64")]
pub dat_file_size: i64,
#[serde(default, rename = "expireAtSec", with = "string_or_u64")]
pub expire_at_sec: u64,
#[serde(default, rename = "readOnly")]
pub read_only: bool,
#[serde(
default,
rename = "ecShardConfig",
skip_serializing_if = "Option::is_none"
)]
pub ec_shard_config: Option<VifEcShardConfig>,
#[serde(default, rename = "readOnlyCanDelete")]
pub read_only_can_delete: bool,
}
impl VifVolumeInfo {
/// Convert from protobuf VolumeInfo to the serde-compatible struct.
pub fn from_pb(pb: &PbVolumeInfo) -> Self {
Self {
files: pb
.files
.iter()
.map(|f| VifRemoteFile {
backend_type: f.backend_type.clone(),
backend_id: f.backend_id.clone(),
key: f.key.clone(),
offset: f.offset,
file_size: f.file_size,
modified_time: f.modified_time,
extension: f.extension.clone(),
})
.collect(),
version: pb.version,
replication: pb.replication.clone(),
bytes_offset: pb.bytes_offset,
dat_file_size: pb.dat_file_size,
expire_at_sec: pb.expire_at_sec,
read_only: pb.read_only,
ec_shard_config: pb.ec_shard_config.as_ref().map(|c| VifEcShardConfig {
data_shards: c.data_shards,
parity_shards: c.parity_shards,
encode_ts_ns: c.encode_ts_ns,
block_size: c.block_size,
}),
read_only_can_delete: pb.read_only_can_delete,
}
}
/// Convert to protobuf VolumeInfo.
pub fn to_pb(&self) -> PbVolumeInfo {
PbVolumeInfo {
files: self
.files
.iter()
.map(|f| PbRemoteFile {
backend_type: f.backend_type.clone(),
backend_id: f.backend_id.clone(),
key: f.key.clone(),
offset: f.offset,
file_size: f.file_size,
modified_time: f.modified_time,
extension: f.extension.clone(),
})
.collect(),
version: self.version,
replication: self.replication.clone(),
bytes_offset: self.bytes_offset,
dat_file_size: self.dat_file_size,
expire_at_sec: self.expire_at_sec,
read_only: self.read_only,
ec_shard_config: self.ec_shard_config.as_ref().map(|c| {
crate::pb::volume_server_pb::EcShardConfig {
data_shards: c.data_shards,
parity_shards: c.parity_shards,
encode_ts_ns: c.encode_ts_ns,
block_size: c.block_size,
}
}),
read_only_can_delete: self.read_only_can_delete,
}
}
}
// ============================================================================
// Streaming read support
// ============================================================================
#[derive(Default)]
struct DataFileAccessState {
readers: usize,
writer_active: bool,
}
#[derive(Default)]
pub struct DataFileAccessControl {
state: Mutex<DataFileAccessState>,
condvar: Condvar,
}
pub struct DataFileReadLease {
control: Arc<DataFileAccessControl>,
}
pub struct DataFileWriteLease {
control: Arc<DataFileAccessControl>,
}
impl DataFileAccessControl {
pub fn read_lock(self: &Arc<Self>) -> DataFileReadLease {
let mut state = self.state.lock().unwrap();
while state.writer_active {
state = self.condvar.wait(state).unwrap();
}
state.readers += 1;
drop(state);
DataFileReadLease {
control: self.clone(),
}
}
pub fn write_lock(self: &Arc<Self>) -> DataFileWriteLease {
let mut state = self.state.lock().unwrap();
while state.writer_active || state.readers > 0 {
state = self.condvar.wait(state).unwrap();
}
state.writer_active = true;
drop(state);
DataFileWriteLease {
control: self.clone(),
}
}
}
impl Drop for DataFileReadLease {
fn drop(&mut self) {
let mut state = self.control.state.lock().unwrap();
state.readers -= 1;
if state.readers == 0 {
self.control.condvar.notify_all();
}
}
}
impl Drop for DataFileWriteLease {
fn drop(&mut self) {
let mut state = self.control.state.lock().unwrap();
state.writer_active = false;
self.control.condvar.notify_all();
}
}
/// Information needed to stream needle data directly from the dat file
/// without loading the entire payload into memory.
pub(crate) enum NeedleStreamSource {
Local(File),
Remote(RemoteDatFile),
}
impl NeedleStreamSource {
pub(crate) fn clone_for_read(&self) -> io::Result<Self> {
match self {
NeedleStreamSource::Local(file) => Ok(NeedleStreamSource::Local(file.try_clone()?)),
NeedleStreamSource::Remote(remote) => Ok(NeedleStreamSource::Remote(remote.clone())),
}
}
pub(crate) fn read_exact_at(&self, buf: &mut [u8], offset: u64) -> io::Result<()> {
match self {
NeedleStreamSource::Local(file) => {
#[cfg(unix)]
{
use std::os::unix::fs::FileExt;
file.read_exact_at(buf, offset)?;
}
#[cfg(windows)]
{
read_exact_at(file, buf, offset)?;
}
#[cfg(not(any(unix, windows)))]
{
compile_error!("Platform not supported: only unix and windows are supported");
}
Ok(())
}
NeedleStreamSource::Remote(remote) => remote.read_exact_at(buf, offset),
}
}
}
pub struct NeedleStreamInfo {
/// Stream source for the dat file, local or remote.
pub(crate) source: NeedleStreamSource,
/// Absolute byte offset within the dat file where needle data starts.
pub data_file_offset: u64,
/// Size of the data payload in bytes.
pub data_size: u32,
/// Per-volume file access lock used to match Go's slow-read behavior.
pub data_file_access_control: Arc<DataFileAccessControl>,
/// Volume ID — used to re-lookup needle offset if compaction occurs during streaming.
pub volume_id: VolumeId,
/// Needle ID — used to re-lookup needle offset if compaction occurs during streaming.
pub needle_id: NeedleId,
/// Compaction revision at the time of the initial read. If this changes during
/// streaming, the needle's disk offset must be re-read from the needle map because
/// compaction may have moved the needle to a different location.
pub compaction_revision: u16,
}
#[derive(Clone)]
pub(crate) struct RemoteDatFile {
backend: Arc<crate::remote_storage::s3_tier::S3TierBackend>,
key: String,
file_size: u64,
modified_time: u64,
}
impl RemoteDatFile {
/// Read up to `size` bytes at `offset`, bounded by the remote file size.
/// Mirrors `Volume::read_dat_slice` for a remote-only backend, but holds no
/// volume/store lock so callers can stream off the store lock entirely.
pub(crate) fn read_slice(&self, offset: u64, size: usize) -> io::Result<Vec<u8>> {
if size == 0 || offset >= self.file_size {
return Ok(Vec::new());
}
let read_len = std::cmp::min(size as u64, self.file_size - offset) as usize;
let mut buf = vec![0u8; read_len];
self.read_exact_at(&mut buf, offset)?;
Ok(buf)
}
pub(crate) fn read_exact_at(&self, buf: &mut [u8], offset: u64) -> io::Result<()> {
let data = self
.backend
.read_range_blocking(&self.key, offset, buf.len())
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
if data.len() != buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!(
"remote read short read at offset {}: got {}, expected {}",
offset,
data.len(),
buf.len()
),
));
}
buf.copy_from_slice(&data);
Ok(())
}
}
// ============================================================================
// Volume
// ============================================================================
pub struct Volume {
pub id: VolumeId,
dir: String,
dir_idx: String,
pub collection: String,
dat_file: Option<File>,
remote_dat_file: Option<RemoteDatFile>,
nm: Option<NeedleMap>,
/// Make the next .dat or .idx flush fail, so tests can exercise the durable
/// write's failure paths without a real disk fault.
#[cfg(test)]
fail_fsync_for_test: bool,
#[cfg(test)]
fail_idx_sync_for_test: bool,
needle_map_kind: NeedleMapKind,
data_file_access_control: Arc<DataFileAccessControl>,
pub super_block: SuperBlock,
no_write_or_delete: bool,
no_write_can_delete: bool,
/// Shared flag from the parent DiskLocation indicating low disk space.
/// Matches Go's `v.location.isDiskSpaceLow` checked in `IsReadOnly()`.
pub location_disk_space_low: Arc<AtomicBool>,
last_modified_ts_seconds: u64,
last_append_at_ns: u64,
pub last_disk_check_ns: Arc<std::sync::atomic::AtomicI64>, // for phantom volume detection cache
last_compact_index_offset: u64,
last_compact_revision: u16,
is_compacting: bool,
/// Compaction speed limit in bytes per second (0 = unlimited).
pub compaction_byte_per_second: i64,
/// Tracks the last I/O error (EIO) for volume health monitoring.
/// Uses Mutex for interior mutability so reads (&self) can clear/set it.
last_io_error: Mutex<Option<String>>,
/// Protobuf VolumeInfo for tiered storage (.vif file).
pub volume_info: PbVolumeInfo,
/// Whether this volume has a remote file reference.
pub has_remote_file: bool,
}
/// Windows helper: loop seek_read until buffer is fully filled.
#[cfg(windows)]
fn read_exact_at(file: &File, buf: &mut [u8], mut offset: u64) -> io::Result<()> {
use std::os::windows::fs::FileExt;
let mut filled = 0;
while filled < buf.len() {
let n = file.seek_read(&mut buf[filled..], offset)?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"unexpected EOF in seek_read",
));
}
filled += n;
offset += n as u64;
}
Ok(())
}
impl Volume {
/// Create and load a volume from disk.
pub fn new(
dirname: &str,
dir_idx: &str,
collection: &str,
id: VolumeId,
needle_map_kind: NeedleMapKind,
replica_placement: Option<ReplicaPlacement>,
ttl: Option<crate::storage::needle::ttl::TTL>,
preallocate: u64,
version: Version,
) -> Result<Self, VolumeError> {
let mut v = Volume {
id,
dir: dirname.to_string(),
dir_idx: dir_idx.to_string(),
collection: collection.to_string(),
dat_file: None,
remote_dat_file: None,
#[cfg(test)]
fail_fsync_for_test: false,
#[cfg(test)]
fail_idx_sync_for_test: false,
nm: None,
needle_map_kind,
data_file_access_control: Arc::new(DataFileAccessControl::default()),
super_block: SuperBlock {
replica_placement: replica_placement.unwrap_or_default(),
ttl: ttl.unwrap_or(crate::storage::needle::ttl::TTL::EMPTY),
..SuperBlock::default()
},
no_write_or_delete: false,
no_write_can_delete: false,
location_disk_space_low: Arc::new(AtomicBool::new(false)),
last_modified_ts_seconds: 0,
last_append_at_ns: 0,
last_disk_check_ns: Arc::new(std::sync::atomic::AtomicI64::new(0)),
last_compact_index_offset: 0,
last_compact_revision: 0,
is_compacting: false,
compaction_byte_per_second: 0,
last_io_error: Mutex::new(None),
volume_info: PbVolumeInfo::default(),
has_remote_file: false,
};
v.load(true, true, preallocate, version)?;
Ok(v)
}
/// Build a minimal, unloaded Volume holding only the identity/path fields
/// reconcile_compact_state needs. Used by the disk-location load pre-pass to
/// recover an interrupted commit before the volume itself is loaded.
pub fn new_unloaded(dirname: &str, dir_idx: &str, collection: &str, id: VolumeId) -> Self {
Volume {
id,
dir: dirname.to_string(),
dir_idx: dir_idx.to_string(),
collection: collection.to_string(),
dat_file: None,
remote_dat_file: None,
#[cfg(test)]
fail_fsync_for_test: false,
#[cfg(test)]
fail_idx_sync_for_test: false,
nm: None,
needle_map_kind: NeedleMapKind::InMemory,
data_file_access_control: Arc::new(DataFileAccessControl::default()),
super_block: SuperBlock::default(),
no_write_or_delete: false,
no_write_can_delete: false,
location_disk_space_low: Arc::new(AtomicBool::new(false)),
last_modified_ts_seconds: 0,
last_append_at_ns: 0,
last_disk_check_ns: Arc::new(std::sync::atomic::AtomicI64::new(0)),
last_compact_index_offset: 0,
last_compact_revision: 0,
is_compacting: false,
compaction_byte_per_second: 0,
last_io_error: Mutex::new(None),
volume_info: PbVolumeInfo::default(),
has_remote_file: false,
}
}
/// Returns true if the volume is currently being compacted.
pub fn is_compacting(&self) -> bool {
self.is_compacting
}
// ---- File naming (matching Go) ----
/// Base filename: dir/collection_id or dir/id
pub fn data_file_name(&self) -> String {
volume_file_name(&self.dir, &self.collection, self.id)
}
pub fn index_file_name(&self) -> String {
volume_file_name(&self.dir_idx, &self.collection, self.id)
}
pub fn file_name(&self, ext: &str) -> String {
match ext {
".idx" | ".cpx" | ".ldb" | ".cpldb" | ".rdb" => {
format!("{}{}", self.index_file_name(), ext)
}
_ => {
format!("{}{}", self.data_file_name(), ext)
}
}
}
pub fn version(&self) -> Version {
if self.volume_info.version != 0 {
Version(self.volume_info.version as u8)
} else {
self.super_block.version
}
}
// ---- Loading ----
fn load(
&mut self,
also_load_index: bool,
create_dat_if_missing: bool,
preallocate: u64,
version: Version,
) -> Result<(), VolumeError> {
let dat_path = self.file_name(".dat");
let mut already_has_super_block = false;
let has_volume_info_file = self.load_vif()?;
if self.volume_info.read_only && !self.has_remote_file {
if self.volume_info.read_only_can_delete {
self.no_write_can_delete = true;
} else {
self.no_write_or_delete = true;
}
}
if self.has_remote_file {
self.load_remote_dat_file()?;
if let Some(remote_file) = self.volume_info.files.first() {
if remote_file.modified_time > 0 {
self.last_modified_ts_seconds = remote_file.modified_time;
} else if let Ok(metadata) = fs::metadata(self.vif_path()) {
self.last_modified_ts_seconds = metadata
.modified()
.unwrap_or(SystemTime::UNIX_EPOCH)
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
}
}
already_has_super_block = true;
} else if Path::new(&dat_path).exists() {
let metadata = fs::metadata(&dat_path)?;
// Try to open read-write; fall back to read-only
match open_volume_file(OpenOptions::new().read(true).write(true), &dat_path) {
Ok(file) => {
self.dat_file = Some(file);
}
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
self.dat_file = Some(open_volume_file(OpenOptions::new().read(true), &dat_path)?);
self.no_write_or_delete = true;
}
Err(e) => return Err(e.into()),
}
self.last_modified_ts_seconds = metadata
.modified()
.unwrap_or(SystemTime::UNIX_EPOCH)
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if metadata.len() >= SUPER_BLOCK_SIZE as u64 {
already_has_super_block = true;
}
} else if create_dat_if_missing {
// Create directory if needed
if let Some(parent) = Path::new(&dat_path).parent() {
fs::create_dir_all(parent)?;
}
let file = open_volume_file(
OpenOptions::new().read(true).write(true).create(true),
&dat_path,
)?;
if preallocate > 0 {
preallocate_file(&file, preallocate);
}
self.dat_file = Some(file);
} else {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::NotFound,
format!("volume data file {} does not exist", dat_path),
)));
}
if already_has_super_block {
match self.read_super_block() {
Ok(()) => {
if !self.super_block.version.is_supported() {
return Err(VolumeError::UnsupportedVersion(self.super_block.version.0));
}
// Match Go: v.volumeInfo.Version = uint32(v.SuperBlock.Version)
self.volume_info.version = self.super_block.version.0 as u32;
}
Err(e) if self.has_remote_file => {
warn!(
volume_id = self.id.0,
error = %e,
"failed to read remote super block during load"
);
}
Err(e) => return Err(e),
}
} else {
self.maybe_write_super_block(version)?;
}
if also_load_index {
// Adjust for existing volumes with .idx together with .dat files:
// an index already beside the data keeps serving after --dir.idx
// named a different directory.
if self.dir_idx != self.dir
&& Path::new(&format!("{}.idx", self.data_file_name())).exists()
{
self.dir_idx = self.dir.clone();
}
// A changed --dir.idx leaves the new directory without an index.
// The .dat still holds every row, so rebuild rather than mount the
// volume with every needle invisible.
if !self.has_remote_file
&& !Path::new(&self.file_name(".idx")).exists()
&& self.current_dat_file_size()? > SUPER_BLOCK_SIZE as u64
{
self.rebuild_idx_file()?;
info!(
volume_id = self.id.0,
idx = %self.file_name(".idx"),
"rebuilt the index from the data file"
);
}
// Recover rows that deletes on a tiered read-only volume overwrote
// at the front of .idx. Best effort: a volume that cannot be
// repaired is still servable for everything the surviving rows
// index.
match self.repair_idx_head_tombstones() {
Ok(0) => {}
Ok(restored) => info!(
volume_id = self.id.0,
restored, "recovered overwritten .idx rows from .dat"
),
Err(e) => warn!(
volume_id = self.id.0,
error = %e,
"recover overwritten .idx rows"
),
}
self.load_index()?;
// Match Go: CheckVolumeDataIntegrity after loading index (volume_loading.go L154-159)
// Only for non-remote volumes (remote storage may not have local .dat)
if !self.has_remote_file {
if let Err(e) = self.check_volume_data_integrity() {
self.no_write_or_delete = true;
warn!(
volume_id = self.id.0,
error = %e,
"volumeDataIntegrityChecking failed"
);
}
self.recover_last_modified_ts();
// Structural check: no .idx entry may reference bytes past the
// end of .dat. The needle map's load walk above already
// populated max_needle_end, so this is a numeric comparison
// — no extra disk I/O. A violation marks the volume read-only
// so vacuum doesn't silently drop reachable data based on a
// corrupt .idx left over from a crashed batched write.
// See issue #8928.
if let Some(ref nm) = self.nm {
if let Ok(dat_size) = self.current_dat_file_size() {
let max_end = nm.max_needle_end();
if dat_size > 0 && max_end > dat_size as i64 {
self.no_write_or_delete = true;
warn!(
volume_id = self.id.0,
max_needle_end = max_end,
dat_size,
"idx references bytes past end of .dat; marking volume read-only"
);
}
}
}
}
}
// Match Go: if no .vif file existed, create one with version and bytes_offset
if !has_volume_info_file {
self.volume_info.version = self.super_block.version.0 as u32;
self.volume_info.bytes_offset = OFFSET_SIZE as u32;
if let Err(e) = self.save_volume_info() {
warn!(
volume_id = self.id.0,
error = %e,
"failed to save volume info"
);
}
}
Ok(())
}
fn nm_or_not_found(&self) -> Result<&NeedleMap, VolumeError> {
self.nm.as_ref().ok_or_else(|| {
tracing::warn!(volume_id = self.id.0, "needle map not loaded");
VolumeError::NotFound
})
}
fn load_index(&mut self) -> Result<(), VolumeError> {
let use_redb = matches!(
self.needle_map_kind,
NeedleMapKind::Redb | NeedleMapKind::RedbMedium | NeedleMapKind::RedbLarge
);
let idx_path = self.file_name(".idx");
// Ensure idx directory exists
if let Some(parent) = Path::new(&idx_path).parent() {
fs::create_dir_all(parent)?;
}
// Go picks the sorted-file map for both read-only modes, which is every
// cloud-tiered volume: the index stays on disk in .sdx instead of in
// RAM, and nothing is held open between lookups.
if self.use_sorted_index() {
self.load_index_sorted_file(&idx_path)?;
} else if use_redb {
self.load_index_redb(&idx_path)?;
} else {
self.load_index_inmemory(&idx_path)?;
}
Ok(())
}
/// Whether this volume's index belongs in a `.sdx`. Mirrors Go's
/// `v.noWriteOrDelete || v.noWriteCanDelete` branch in volume_loading.go —
/// a tiered volume is always the second of the two.
fn use_sorted_index(&self) -> bool {
self.no_write_or_delete || self.no_write_can_delete
}
/// Load the on-disk sorted index for a read-only or cloud-tiered volume.
fn load_index_sorted_file(&mut self, idx_path: &str) -> Result<(), VolumeError> {
if !Path::new(idx_path).exists() {
// Missing .idx with existing .dat could orphan needles
let dat_path = self.file_name(".dat");
if Path::new(&dat_path).exists() {
let dat_size = fs::metadata(&dat_path).map(|m| m.len()).unwrap_or(0);
if dat_size > SUPER_BLOCK_SIZE as u64 {
warn!(
volume_id = self.id.0,
".idx file missing but .dat exists with data; needles may be orphaned"
);
}
}
// Go opens .idx with O_CREATE only where deletes are allowed; an
// empty index yields an empty .sdx and a volume that still mounts.
// A volume that takes neither writes nor deletes must not write
// here, since its index directory can sit on a read-only mount.
if !self.no_write_or_delete {
File::create(idx_path)?;
}
}
// Building .sdx writes to the index directory. Where that cannot be
// done — a read-only mount, a full disk — keep this volume's index in
// memory rather than failing the load and taking its data offline.
match SortedFileNeedleMap::open(&self.index_file_name(), self.version()) {
Ok(nm) => {
self.nm = Some(NeedleMap::SortedFile(nm));
Ok(())
}
Err(e) => {
warn!(
volume_id = self.id.0,
error = %e,
"cannot build the sorted index; keeping this volume's index in memory"
);
match self.load_index_inmemory(idx_path) {
Ok(()) => Ok(()),
// Where deletes are allowed the in-memory loader opens .idx
// read-write, which fails on the same directory that just
// refused the .sdx. Give up the deletes rather than the
// volume: without a writer no tombstone could be recorded
// anyway, so refusing them is the honest answer, and a
// remount on a writable directory restores them.
Err(retry_err) if !self.no_write_or_delete => {
warn!(
volume_id = self.id.0,
error = %retry_err,
"cannot open the index for write; serving this volume without deletes"
);
self.no_write_or_delete = true;
// Deletes are gone with the writer, so stop advertising
// them: leaving no_write_can_delete set would report
// this volume as delete-capable in metrics and mode
// checks while every delete is refused.
self.no_write_can_delete = false;
self.load_index_inmemory(idx_path)
}
Err(retry_err) => Err(retry_err),
}
}
}
}
/// Load index using in-memory CompactNeedleMap.
fn load_index_inmemory(&mut self, idx_path: &str) -> Result<(), VolumeError> {
if self.no_write_or_delete {
// Open read-only
if Path::new(&idx_path).exists() {
let mut idx_file = open_volume_file(OpenOptions::new().read(true), idx_path)?;
let nm = CompactNeedleMap::load_from_idx(&mut idx_file, self.version())?;
self.nm = Some(NeedleMap::InMemory(nm));
} else {
// Missing .idx with existing .dat could orphan needles
let dat_path = self.file_name(".dat");
if Path::new(&dat_path).exists() {
let dat_size = fs::metadata(&dat_path).map(|m| m.len()).unwrap_or(0);
if dat_size > SUPER_BLOCK_SIZE as u64 {
warn!(
volume_id = self.id.0,
".idx file missing but .dat exists with data; needles may be orphaned"
);
}
}
self.nm = Some(NeedleMap::InMemory(CompactNeedleMap::new()));
}
} else {
// Open read-write (create if missing)
let idx_file = open_volume_file(
OpenOptions::new().read(true).write(true).create(true),
idx_path,
)?;
let idx_size = trim_torn_idx_tail(&idx_file, idx_path)?;
let mut idx_reader = io::BufReader::new(&idx_file);
let mut nm = CompactNeedleMap::load_from_idx(&mut idx_reader, self.version())?;
// Re-open for append-only writes
let write_file = OpenOptions::new()
.write(true)
.append(true)
.open(&idx_path)?;
nm.set_idx_file(Box::new(write_file), idx_size);
self.nm = Some(NeedleMap::InMemory(nm));
}
Ok(())
}
/// Load index using disk-backed RedbNeedleMap.
fn load_index_redb(&mut self, idx_path: &str) -> Result<(), VolumeError> {
// The redb database file is stored alongside the volume files
let rdb_path = self.file_name(".rdb");
// One redb database per volume: keep its page cache small, or the
// process grows by up to redb's 1 GiB default per volume (#11179).
let cache_bytes = self.needle_map_kind.redb_cache_bytes();
if self.no_write_or_delete {
// Open read-only
if Path::new(&idx_path).exists() {
let mut idx_file = open_volume_file(OpenOptions::new().read(true), idx_path)?;
let nm = RedbNeedleMap::load_from_idx(
&rdb_path,
&mut idx_file,
self.version(),
cache_bytes,
)?;
self.nm = Some(NeedleMap::Redb(nm));
} else {
// Missing .idx with existing .dat could orphan needles
let dat_path = self.file_name(".dat");
if Path::new(&dat_path).exists() {
let dat_size = fs::metadata(&dat_path).map(|m| m.len()).unwrap_or(0);
if dat_size > SUPER_BLOCK_SIZE as u64 {
warn!(
volume_id = self.id.0,
".idx file missing but .dat exists with data; needles may be orphaned"
);
}
}
self.nm = Some(NeedleMap::Redb(RedbNeedleMap::new(&rdb_path, cache_bytes)?));
}
} else {
// Open read-write (create if missing)
let idx_file = open_volume_file(
OpenOptions::new().read(true).write(true).create(true),
idx_path,
)?;
let idx_size = trim_torn_idx_tail(&idx_file, idx_path)?;
let mut idx_reader = io::BufReader::new(&idx_file);
let mut nm = RedbNeedleMap::load_from_idx(
&rdb_path,
&mut idx_reader,
self.version(),
cache_bytes,
)?;
// Re-open for append-only writes
let write_file = OpenOptions::new()
.write(true)
.append(true)
.open(&idx_path)?;
nm.set_idx_file(Box::new(write_file), idx_size);
self.nm = Some(NeedleMap::Redb(nm));
}
Ok(())
}
pub(crate) fn load_remote_dat_file(&mut self) -> Result<(), VolumeError> {
let (storage_name, storage_key) = self.remote_storage_name_key();
let backend = crate::remote_storage::s3_tier::global_s3_tier_registry()
.read()
.unwrap()
.get(&storage_name)
.ok_or_else(|| {
VolumeError::Io(io::Error::new(
io::ErrorKind::NotFound,
format!("remote tier backend {} not found", storage_name),
))
})?;
let remote_file = self.volume_info.files.first().ok_or_else(|| {
VolumeError::Io(io::Error::new(
io::ErrorKind::NotFound,
"remote volume has no remote file entries",
))
})?;
let file_size = if remote_file.file_size > 0 {
remote_file.file_size
} else if self.volume_info.dat_file_size > 0 {
self.volume_info.dat_file_size as u64
} else {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!("remote volume {} is missing file size metadata", self.id.0),
)));
};
self.dat_file = None;
self.remote_dat_file = Some(RemoteDatFile {
backend,
key: storage_key,
file_size,
modified_time: remote_file.modified_time,
});
Ok(())
}
pub(crate) fn read_exact_at_backend(
&self,
buf: &mut [u8],
offset: u64,
) -> Result<(), VolumeError> {
if let Some(dat_file) = self.dat_file.as_ref() {
#[cfg(unix)]
{
use std::os::unix::fs::FileExt;
dat_file.read_exact_at(buf, offset)?;
}
#[cfg(windows)]
{
read_exact_at(dat_file, buf, offset)?;
}
#[cfg(not(any(unix, windows)))]
{
compile_error!("Platform not supported: only unix and windows are supported");
}
Ok(())
} else if let Some(remote_dat_file) = self.remote_dat_file.as_ref() {
remote_dat_file.read_exact_at(buf, offset)?;
Ok(())
} else {
Err(VolumeError::Io(io::Error::new(
io::ErrorKind::Other,
"dat file not open",
)))
}
}
/// Returns true when the volume has a data backend (local .dat file or
/// remote tiered storage). Mirrors Go's `v.DataBackend != nil` check.
pub fn has_data_backend(&self) -> bool {
self.dat_file.is_some() || self.remote_dat_file.is_some()
}
fn current_dat_file_size(&self) -> io::Result<u64> {
if let Some(ref f) = self.dat_file {
Ok(f.metadata()?.len())
} else if let Some(ref remote_dat_file) = self.remote_dat_file {
Ok(remote_dat_file.file_size)
} else {
Ok(0)
}
}
/// Read a raw byte range from the current .dat backend.
///
/// This matches Go paths that stream directly from `DataBackend`, including
/// remote-only tiered volumes whose `.dat` is no longer present locally.
pub fn read_dat_slice(&self, offset: u64, size: usize) -> Result<Vec<u8>, VolumeError> {
let _guard = self.data_file_access_control.read_lock();
let dat_size = self.current_dat_file_size()?;
if size == 0 || offset >= dat_size {
return Ok(Vec::new());
}
let read_len = std::cmp::min(size as u64, dat_size - offset) as usize;
let mut buf = vec![0u8; read_len];
self.read_exact_at_backend(&mut buf, offset)?;
Ok(buf)
}
/// Clone the remote backend handle (cheap: an `Arc` plus key/size) so a
/// caller can stream a tiered `.dat` from S3 after dropping the store lock.
/// Returns `None` for local volumes. `has_remote_file` implies `dat_file`
/// is `None`, so this selects the same backend `read_dat_slice` would.
pub(crate) fn remote_dat_file(&self) -> Option<RemoteDatFile> {
self.remote_dat_file.clone()
}
// ---- SuperBlock I/O ----
fn read_super_block(&mut self) -> Result<(), VolumeError> {
let mut header = [0u8; SUPER_BLOCK_SIZE];
self.read_exact_at_backend(&mut header, 0)?;
let extra_size = u16::from_be_bytes([header[6], header[7]]);
let total_size = SUPER_BLOCK_SIZE + extra_size as usize;
let mut full_buf = vec![0u8; total_size];
full_buf[..SUPER_BLOCK_SIZE].copy_from_slice(&header);
if extra_size > 0 {
self.read_exact_at_backend(&mut full_buf[SUPER_BLOCK_SIZE..], SUPER_BLOCK_SIZE as u64)?;
}
self.super_block = SuperBlock::from_bytes(&full_buf)?;
// Match Go: if volumeInfo.Replication is set, override super block's ReplicaPlacement
if !self.volume_info.replication.is_empty() {
let rp = ReplicaPlacement::from_string(&self.volume_info.replication)?;
self.super_block.replica_placement = rp;
}
Ok(())
}
fn maybe_write_super_block(&mut self, version: Version) -> Result<(), VolumeError> {
let dat_file = self.dat_file.as_mut().ok_or_else(|| {
VolumeError::Io(io::Error::new(io::ErrorKind::Other, "dat file not open"))
})?;
let dat_size = dat_file.metadata()?.len();
if dat_size == 0 {
if !version.is_supported() {
return Err(VolumeError::UnsupportedVersion(version.0));
}
self.super_block.version = version;
let bytes = self.super_block.to_bytes();
dat_file.seek(SeekFrom::Start(0))?;
dat_file.write_all(&bytes)?;
dat_file.sync_all()?;
}
Ok(())
}
// ---- Read ----
/// Read a needle by its ID from the volume.
pub fn read_needle(&self, n: &mut Needle) -> Result<i32, VolumeError> {
let mut read_option = ReadOption::default();
self.read_needle_with_option(n, &mut read_option)
}
pub fn read_needle_opt(&self, n: &mut Needle, read_deleted: bool) -> Result<i32, VolumeError> {
let mut read_option = ReadOption {
read_deleted,
..ReadOption::default()
};
self.read_needle_with_option(n, &mut read_option)
}
pub fn read_needle_with_option(
&self,
n: &mut Needle,
read_option: &mut ReadOption,
) -> Result<i32, VolumeError> {
let _guard = self.data_file_access_control.read_lock();
let nm = self.nm_or_not_found()?;
let nv = nm.get(n.id)?.ok_or(VolumeError::NotFound)?;
if nv.offset.is_zero() {
return Err(VolumeError::NotFound);
}
let mut read_size = nv.size;
if read_size.is_deleted() {
if read_option.read_deleted && !read_size.is_tombstone() {
// Negate to get original size
read_size = Size(-read_size.0);
} else {
return Err(VolumeError::Deleted);
}
}
if read_size.0 == 0 {
return Ok(0);
}
match self.read_needle_data_at_unlocked(n, nv.offset.to_actual_offset(), read_size, read_option) {
Ok(()) => self.check_read_write_error(None),
Err(VolumeError::Io(ref e)) => {
self.check_read_write_error(Some(e));
return Err(VolumeError::Io(io::Error::new(e.kind(), e.to_string())));
}
Err(e) => return Err(e),
}
// TTL expiry check
if n.has_ttl() {
if let Some(ref ttl) = n.ttl {
let ttl_minutes = ttl.minutes();
if ttl_minutes > 0 && n.has_last_modified_date() {
let expire_at_ns = n.append_at_ns + (ttl_minutes as u64) * 60 * 1_000_000_000;
let now_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
if now_ns >= expire_at_ns {
return Err(VolumeError::NotFound);
}
}
}
}
Ok(n.data_size as i32)
}
/// Read needle data from .dat file at given offset.
pub fn read_needle_data_at(
&self,
n: &mut Needle,
offset: i64,
size: Size,
) -> Result<(), VolumeError> {
let _guard = self.data_file_access_control.read_lock();
let mut read_option = ReadOption::default();
self.read_needle_data_at_unlocked(n, offset, size, &mut read_option)
}
fn read_needle_data_at_unlocked(
&self,
n: &mut Needle,
offset: i64,
size: Size,
_read_option: &mut ReadOption,
) -> Result<(), VolumeError> {
match self.read_needle_blob_and_parse(n, offset, size) {
Ok(()) => Ok(()),
#[cfg(not(feature = "5bytes"))]
Err(VolumeError::Needle(NeedleError::SizeMismatch { offset: o, .. }))
if o < MAX_POSSIBLE_VOLUME_SIZE as i64 =>
{
// Double-read: in 4-byte offset mode, the actual data may be
// beyond 32GB due to offset wrapping. Retry at offset + 32GB.
self.read_needle_blob_and_parse(n, offset + MAX_POSSIBLE_VOLUME_SIZE as i64, size)
}
Err(e) => Err(e),
}
}
fn read_needle_blob_and_parse(
&self,
n: &mut Needle,
offset: i64,
size: Size,
) -> Result<(), VolumeError> {
let version = self.version();
let actual_size = get_actual_size(size, version);
let mut buf = vec![0u8; actual_size as usize];
self.read_exact_at_backend(&mut buf, offset as u64)?;
n.read_bytes(&mut buf, offset, size, version)?;
Ok(())
}
/// Read raw needle blob at a specific offset.
pub fn read_needle_blob(&self, offset: i64, size: Size) -> Result<Vec<u8>, VolumeError> {
let _guard = self.data_file_access_control.read_lock();
self.read_needle_blob_unlocked(offset, size)
}
fn read_needle_blob_unlocked(&self, offset: i64, size: Size) -> Result<Vec<u8>, VolumeError> {
let version = self.version();
let actual_size = get_actual_size(size, version);
let mut buf = vec![0u8; actual_size as usize];
self.read_exact_at_backend(&mut buf, offset as u64)?;
Ok(buf)
}
/// Read needle metadata at a specific offset without loading the data payload.
///
/// Matches Go's `readNeedleMetaAt`, including the tombstone path where a
/// deleted idx entry passes a negative size and the tombstone record itself
/// is read as size 0 metadata.
pub fn read_needle_meta_at(
&self,
n: &mut Needle,
offset: i64,
size: Size,
) -> Result<(), VolumeError> {
let _guard = self.data_file_access_control.read_lock();
self.read_needle_meta_at_unlocked(n, offset, size)
}
fn read_needle_meta_at_unlocked(
&self,
n: &mut Needle,
offset: i64,
size: Size,
) -> Result<(), VolumeError> {
let normalized_size = if size.is_deleted() { Size(0) } else { size };
match self.read_needle_meta_blob_and_parse(n, offset, normalized_size) {
Ok(()) => Ok(()),
#[cfg(not(feature = "5bytes"))]
Err(VolumeError::Needle(NeedleError::SizeMismatch { offset: o, .. }))
if o < MAX_POSSIBLE_VOLUME_SIZE as i64 =>
{
self.read_needle_meta_blob_and_parse(
n,
offset + MAX_POSSIBLE_VOLUME_SIZE as i64,
normalized_size,
)
}
Err(e) => Err(e),
}
}
fn read_needle_meta_blob_and_parse(
&self,
n: &mut Needle,
offset: i64,
size: Size,
) -> Result<(), VolumeError> {
let version = self.version();
// Step 1: Read only the first 20 bytes (header + DataSize).
// Matches Go's ReadNeedleMeta which reads NeedleHeaderSize+DataSizeSize first.
const HEADER_PREFIX: usize = NEEDLE_HEADER_SIZE + DATA_SIZE_SIZE; // 20
let mut header_buf = [0u8; HEADER_PREFIX];
self.read_exact_at_backend(&mut header_buf, offset as u64)?;
// Parse header to get the needle's Size field for validation
let (_, _, found_size) = Needle::parse_header(&header_buf);
if found_size != size {
return Err(VolumeError::Needle(NeedleError::SizeMismatch {
offset,
id: n.id,
found: found_size,
expected: size,
}));
}
// Step 2: Calculate how much meta tail to read (skip the data payload)
let actual_size = get_actual_size(size, version);
if size.0 == 0 || version == VERSION_1 {
// Tombstone or V1: no body data section, tail starts right after header
let meta_size = actual_size - NEEDLE_HEADER_SIZE as i64;
if meta_size < 0 || meta_size > 128 * 1024 {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"invalid needle meta size {}: DataSize=0, size={}, offset={}",
meta_size, size.0, offset
),
)));
}
let mut meta_buf = vec![0u8; meta_size as usize];
self.read_exact_at_backend(
&mut meta_buf,
(offset + NEEDLE_HEADER_SIZE as i64) as u64,
)?;
n.read_paged_meta(&header_buf, &meta_buf, offset, size, version)?;
} else {
// V2/V3: extract DataSize from bytes 16..20
let data_size = u32::from_be_bytes([
header_buf[NEEDLE_HEADER_SIZE],
header_buf[NEEDLE_HEADER_SIZE + 1],
header_buf[NEEDLE_HEADER_SIZE + 2],
header_buf[NEEDLE_HEADER_SIZE + 3],
]);
// Skip past: header(16) + DataSize(4) + data(data_size)
let start_offset =
offset + NEEDLE_HEADER_SIZE as i64 + DATA_SIZE_SIZE as i64 + data_size as i64;
let stop_offset = offset + actual_size;
let meta_size = stop_offset - start_offset;
// Sanity check: reject metadata sizes > 128KB (matching Go's ReadNeedleMeta guard)
if meta_size < 0 || meta_size > 128 * 1024 {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"invalid needle meta size {}: DataSize={}, size={}, offset={}",
meta_size, data_size, size.0, offset
),
)));
}
// Step 3: Read only the meta tail (skip the data payload entirely)
let mut meta_buf = vec![0u8; meta_size as usize];
self.read_exact_at_backend(&mut meta_buf, start_offset as u64)?;
n.read_paged_meta(&header_buf, &meta_buf, offset, size, version)?;
}
Ok(())
}
/// Read needle metadata (header + flags/name/mime/etc) without loading the data payload,
/// and return a `NeedleStreamInfo` that can be used to stream data directly from the dat file.
///
/// This is used for large needles to avoid loading the entire payload into memory.
pub fn read_needle_stream_info(
&self,
n: &mut Needle,
read_deleted: bool,
) -> Result<NeedleStreamInfo, VolumeError> {
let _guard = self.data_file_access_control.read_lock();
let nm = self.nm_or_not_found()?;
let nv = nm.get(n.id)?.ok_or(VolumeError::NotFound)?;
if nv.offset.is_zero() {
return Err(VolumeError::NotFound);
}
let mut read_size = nv.size;
if read_size.is_deleted() {
if read_deleted && !read_size.is_tombstone() {
read_size = Size(-read_size.0);
} else {
return Err(VolumeError::Deleted);
}
}
if read_size.0 == 0 {
return Err(VolumeError::NotFound);
}
#[cfg_attr(feature = "5bytes", allow(unused_mut))]
let mut offset = nv.offset.to_actual_offset();
let version = self.version();
let actual_size = get_actual_size(read_size, version);
// Read the full needle bytes (including data) for metadata parsing.
// We use read_bytes_meta_only which skips copying the data payload.
#[cfg_attr(feature = "5bytes", allow(unused_mut))]
let mut read_and_parse = |off: i64| -> Result<(), VolumeError> {
let mut buf = vec![0u8; actual_size as usize];
self.read_exact_at_backend(&mut buf, off as u64)?;
n.read_bytes_meta_only(&mut buf, off, read_size, version)?;
Ok(())
};
match read_and_parse(offset) {
Ok(()) => {}
#[cfg(not(feature = "5bytes"))]
Err(VolumeError::Needle(NeedleError::SizeMismatch { offset: o, .. }))
if o < MAX_POSSIBLE_VOLUME_SIZE as i64 =>
{
offset += MAX_POSSIBLE_VOLUME_SIZE as i64;
read_and_parse(offset)?;
}
Err(e) => return Err(e),
}
// TTL expiry check
if n.has_ttl() {
if let Some(ref ttl) = n.ttl {
let ttl_minutes = ttl.minutes();
if ttl_minutes > 0 && n.has_last_modified_date() {
let expire_at_ns = n.append_at_ns + (ttl_minutes as u64) * 60 * 1_000_000_000;
let now_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
if now_ns >= expire_at_ns {
return Err(VolumeError::NotFound);
}
}
}
}
// For V1, data starts right after the header
// For V2/V3, data starts at header + 4 (DataSize field)
let data_file_offset = if version == VERSION_1 {
offset as u64 + NEEDLE_HEADER_SIZE as u64
} else {
offset as u64 + NEEDLE_HEADER_SIZE as u64 + 4 // skip DataSize (4 bytes)
};
let source = match (self.dat_file.as_ref(), self.remote_dat_file.as_ref()) {
(Some(dat_file), _) => NeedleStreamSource::Local(
dat_file.try_clone().map_err(VolumeError::Io)?,
),
(None, Some(remote_dat_file)) => NeedleStreamSource::Remote(remote_dat_file.clone()),
(None, None) => return Err(VolumeError::StreamingUnsupported),
};
Ok(NeedleStreamInfo {
source,
data_file_offset,
data_size: n.data_size,
data_file_access_control: self.data_file_access_control.clone(),
volume_id: self.id,
needle_id: n.id,
compaction_revision: self.super_block.compaction_revision,
})
}
/// Re-lookup a needle's data-file offset after compaction may have moved it.
///
/// Returns `(new_data_file_offset, current_compaction_revision)` or an error
/// if the needle is no longer present / has been deleted.
///
/// This matches Go's `readNeedleDataInto` behaviour: when the volume's
/// `CompactionRevision` changes between streaming chunks, the needle offset
/// is re-read from the needle map because compaction may have relocated it.
pub fn re_lookup_needle_data_offset(
&self,
needle_id: NeedleId,
) -> Result<(u64, u16), VolumeError> {
let nm = self.nm_or_not_found()?;
let nv = nm.get(needle_id)?.ok_or(VolumeError::NotFound)?;
if nv.offset.is_zero() {
return Err(VolumeError::NotFound);
}
if nv.size.is_deleted() {
return Err(VolumeError::Deleted);
}
let offset = nv.offset.to_actual_offset();
let version = self.version();
let data_file_offset = if version == VERSION_1 {
offset as u64 + NEEDLE_HEADER_SIZE as u64
} else {
offset as u64 + NEEDLE_HEADER_SIZE as u64 + 4 // skip DataSize (4 bytes)
};
Ok((data_file_offset, self.super_block.compaction_revision))
}
// ---- Write ----
/// Write a needle to the volume (synchronous path).
///
/// With `fsync` the write is only reported successful once both the .dat
/// record and the .idx row that indexes it are on disk, so an ack survives a
/// crash rather than sitting in the page cache. The two flushes fail
/// differently: a failed .dat flush happens before anything is published, so
/// it takes its own append back off the end and nobody else's, while a
/// failed .idx flush cannot be undone and stops the volume taking writes.
pub fn write_needle(
&mut self,
n: &mut Needle,
check_cookie: bool,
fsync: bool,
) -> Result<(u64, Size, bool), VolumeError> {
let _guard = self.data_file_access_control.write_lock();
if self.is_read_only() {
return Err(VolumeError::ReadOnly);
}
self.do_write_request(n, check_cookie, fsync)
}
/// Flush the .dat, the first half of a durable write. The .idx is flushed
/// separately by flush_idx once the row is published; the two are split so
/// nothing is indexed before the bytes it points at are down.
fn flush_dat(&self) -> io::Result<()> {
#[cfg(test)]
if self.fail_fsync_for_test {
return Err(io::Error::new(
io::ErrorKind::Other,
"injected fsync failure",
));
}
match self.dat_file.as_ref() {
Some(dat_file) => dat_file.sync_all(),
None => Err(io::Error::new(io::ErrorKind::Other, "dat file not open")),
}
}
/// Flush the .idx, the second half of a durable write. load() rebuilds the
/// map from .idx, not from .dat, so the row has to be down before the write
/// is acked: lose it and the volume comes back with a .dat tail the
/// integrity check cannot account for, and loads read only.
///
/// By the time this runs the row is published and cannot be taken back -
/// undoing it would mean writing to a disk that is already failing, and
/// would leave an .idx row pointing past a truncated .dat. So a failure
/// stops the volume taking writes instead: nothing more gets appended past a
/// record whose index may not survive, and the master routes writes
/// elsewhere once the volume heartbeats read only.
fn flush_idx(&mut self) -> Result<(), VolumeError> {
#[cfg(test)]
if self.fail_idx_sync_for_test {
let e = io::Error::new(io::ErrorKind::Other, "injected idx sync failure");
self.no_write_or_delete = true;
return Err(VolumeError::Io(e));
}
let synced = match self.nm.as_ref() {
Some(nm) => nm.sync(),
None => Ok(()),
};
if let Err(e) = synced {
self.check_read_write_error(Some(&e));
self.no_write_or_delete = true;
tracing::error!(
"volume {}: failed to flush the index after a durable write, \
marking read only: {}",
self.id.0,
e
);
return Err(VolumeError::Io(e));
}
Ok(())
}
fn do_write_request(
&mut self,
n: &mut Needle,
check_cookie: bool,
fsync: bool,
) -> Result<(u64, Size, bool), VolumeError> {
// TTL inheritance from volume (matching Go's writeNeedle2)
{
use crate::storage::needle::ttl::TTL;
let needle_ttl = n.ttl.unwrap_or(TTL::EMPTY);
if needle_ttl == TTL::EMPTY && self.super_block.ttl != TTL::EMPTY {
n.set_has_ttl();
n.ttl = Some(self.super_block.ttl);
}
}
// Ensure checksum is computed before dedup check
if n.checksum == crate::storage::needle::crc::CRC(0) && !n.data.is_empty() {
n.checksum = crate::storage::needle::crc::CRC::new(&n.data);
}
// Dedup check (matches Go: n.DataSize = oldNeedle.DataSize on dedup)
if let Some(old_data_size) = self.is_file_unchanged(n) {
n.data_size = old_data_size;
// Nothing to append, but the write this matched may have been
// non-durable, and the caller is asking for the content to be on
// disk. Its .idx row can be sitting in the page cache too, so both
// files get flushed exactly as they would for a fresh append.
if fsync {
self.flush_dat().map_err(|e| {
self.check_read_write_error(Some(&e));
VolumeError::Io(e)
})?;
self.flush_idx()?;
}
return Ok((0, Size(n.data_size as i32), true));
}
// Cookie validation for existing needle (matches Go: check whenever nm.Get returns ok)
if let Some(nm) = &self.nm {
if let Some(nv) = nm.get(n.id)? {
let mut existing = Needle::default();
// Read only the header to check cookie
self.read_needle_header_unlocked(&mut existing, nv.offset.to_actual_offset())?;
if n.cookie.0 == 0 && !check_cookie {
n.cookie = existing.cookie;
}
if existing.cookie != n.cookie {
return Err(VolumeError::CookieMismatch(n.cookie.0));
}
}
}
// Update append timestamp
n.append_at_ns = get_append_at_ns(self.last_append_at_ns);
// Append to .dat file
let (offset, _body_size, _actual_size) = self.append_needle(n)?;
// Nothing is published until the bytes are down: an index entry for an
// unflushed append would resolve past the end of the file after a crash,
// and undoing it afterwards would double-count the volume's metrics.
if fsync {
if let Err(e) = self.flush_dat() {
self.check_read_write_error(Some(&e));
let truncated = match self.dat_file.as_ref() {
Some(dat_file) => dat_file.set_len(offset),
None => Ok(()),
};
if let Err(te) = truncated {
// The rejected record is still on the end. A later append
// would bury it mid-file, where the .dat tail check cannot
// see it, so stop taking writes instead.
self.no_write_or_delete = true;
tracing::error!(
"volume {}: failed to truncate back to {} after a failed fsync, \
marking read only: {}",
self.id.0,
offset,
te
);
}
return Err(VolumeError::Io(e));
}
}
self.last_append_at_ns = n.append_at_ns;
// Update needle map (uses n.size = full body size, matching Go's nm.Put)
let prior = match self.nm.as_ref() {
Some(nm) => nm.get(n.id),
None => Ok(None),
};
let should_update = match prior {
Ok(Some(nv)) => (nv.offset.to_actual_offset() as u64) < offset,
Ok(None) => true,
Err(e) => {
if fsync {
// Leaves the same durable-but-unindexed record a failed put
// below does, so it gets the same treatment.
self.no_write_or_delete = true;
tracing::error!(
"volume {}: failed to read the prior mapping for a durable write at {}, \
marking read only: {}",
self.id.0,
offset,
e
);
}
return Err(VolumeError::Io(e));
}
};
if should_update {
let indexed = match self.nm.as_mut() {
Some(nm) => nm.put(n.id, Offset::from_actual_offset(offset as i64), n.size),
None => Ok(()),
};
if let Err(e) = indexed {
if fsync {
// The record is already down but nothing indexes it, the
// same state a failed .idx flush leaves behind, so it gets
// the same treatment rather than another write on top.
self.no_write_or_delete = true;
tracing::error!(
"volume {}: failed to index a durable write at {}, \
marking read only: {}",
self.id.0,
offset,
e
);
}
return Err(VolumeError::Io(e));
}
}
if fsync {
self.flush_idx()?;
}
if self.last_modified_ts_seconds < n.last_modified {
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)?;
n.read_header(&header);
Ok(())
}
/// Check if the needle is unchanged from the existing one on disk.
/// Returns `Some(old_data_size)` if unchanged, `None` otherwise.
/// Matches Go's isFileUnchanged which also sets n.DataSize = oldNeedle.DataSize.
fn is_file_unchanged(&self, n: &Needle) -> Option<u32> {
// Don't dedup for volumes with TTL
if self.super_block.ttl != crate::storage::needle::ttl::TTL::EMPTY {
return None;
}
if let Some(nm) = &self.nm {
let existing = match nm.get(n.id) {
Ok(existing) => existing,
Err(e) => {
// Not a lookup miss: the index could not be read. Report
// "unknown" so the caller writes the needle again rather
// than treating an unreadable index as proof of a change.
warn!(volume_id = self.id.0, error = %e, "needle map lookup failed");
return None;
}
};
if let Some(nv) = existing {
if !nv.offset.is_zero() && nv.size.is_valid() {
let mut old = Needle::default();
let mut ro = ReadOption::default();
if self
.read_needle_data_at_unlocked(
&mut old,
nv.offset.to_actual_offset(),
nv.size,
&mut ro,
)
.is_ok()
{
if old.cookie == n.cookie
&& old.checksum == n.checksum
&& old.data == n.data
{
return Some(old.data_size);
}
}
}
}
}
None
}
/// Append a needle to the .dat file. Returns (offset, size, actual_size).
fn append_needle(&mut self, n: &mut Needle) -> Result<(u64, Size, i64), VolumeError> {
let version = self.version();
let bytes = n.write_bytes(version);
let actual_size = bytes.len() as i64;
let dat_file = self.dat_file.as_mut().ok_or_else(|| {
VolumeError::Io(io::Error::new(io::ErrorKind::Other, "dat file not open"))
})?;
let offset = dat_file.seek(SeekFrom::End(0))?;
// Check volume size limit before writing (matching Go's Append)
if offset >= MAX_POSSIBLE_VOLUME_SIZE && !n.data.is_empty() {
return Err(VolumeError::SizeLimitExceeded {
current: offset,
limit: MAX_POSSIBLE_VOLUME_SIZE,
});
}
if let Err(e) = dat_file.write_all(&bytes) {
// Truncate back to pre-write position on error (matching Go)
let _ = dat_file.set_len(offset);
self.check_read_write_error(Some(&e));
return Err(VolumeError::Io(e));
}
self.check_read_write_error(None);
Ok((offset, n.size, actual_size))
}
// ---- Delete ----
/// Delete a needle from the volume.
pub fn delete_needle(&mut self, n: &mut Needle) -> Result<Size, VolumeError> {
let _guard = self.data_file_access_control.write_lock();
if self.no_write_or_delete {
return Err(VolumeError::ReadOnly);
}
self.do_delete_request(n)
}
fn do_delete_request(&mut self, n: &mut Needle) -> Result<Size, VolumeError> {
let (found, size, _stored_offset) = if let Some(nm) = &self.nm {
// Propagate: acknowledging the delete as a no-op because the index
// could not be read would drop the tombstone on the floor while the
// caller believes the needle is gone.
if let Some(nv) = nm.get(n.id)? {
if !nv.size.is_deleted() {
(true, nv.size, nv.offset)
} else {
(false, Size(0), Offset::default())
}
} else {
(false, Size(0), Offset::default())
}
} else {
return Ok(Size(0));
};
if !found {
return Ok(Size(0));
}
// Write tombstone: append needle with empty data
n.data = vec![];
n.append_at_ns = get_append_at_ns(self.last_append_at_ns);
let offset = if !self.has_remote_file {
// Normal volume: append tombstone to .dat file
let (offset, _, _) = self.append_needle(n)?;
offset
} else {
// Remote-tiered volume: skip .dat append, use offset 0
0
};
self.last_append_at_ns = n.append_at_ns;
// Update index
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)
}
// ---- Metrics ----
pub fn content_size(&self) -> u64 {
self.nm.as_ref().map_or(0, |nm| nm.content_size())
}
pub fn deleted_size(&self) -> u64 {
self.nm.as_ref().map_or(0, |nm| nm.deleted_size())
}
pub fn file_count(&self) -> i64 {
self.nm.as_ref().map_or(0, |nm| nm.file_count())
}
pub fn deleted_count(&self) -> i64 {
self.nm.as_ref().map_or(0, |nm| nm.deleted_count())
}
pub fn max_file_key(&self) -> NeedleId {
self.nm.as_ref().map_or(NeedleId(0), |nm| nm.max_file_key())
}
pub fn is_read_only(&self) -> bool {
self.no_write_or_delete
|| self.no_write_can_delete
|| self.location_disk_space_low.load(Ordering::Relaxed)
}
pub fn is_no_write_or_delete(&self) -> bool {
self.no_write_or_delete
}
pub fn is_no_write_can_delete(&self) -> bool {
self.no_write_can_delete
}
pub fn last_compact_revision(&self) -> u16 {
self.last_compact_revision
}
pub fn last_modified_ts(&self) -> u64 {
self.last_modified_ts_seconds
}
pub fn is_expired(&self, volume_size: u64, volume_size_limit: u64) -> bool {
if volume_size_limit == 0 {
return false;
}
if volume_size <= SUPER_BLOCK_SIZE as u64 {
return false;
}
let ttl_minutes = self.super_block.ttl.minutes();
if ttl_minutes == 0 {
return false;
}
let lived_minutes = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.saturating_sub(self.last_modified_ts_seconds)
/ 60;
(ttl_minutes as u64) < lived_minutes
}
/// When this volume's data becomes garbage, counted from its last write.
/// Counting from the current time instead let every .vif rewrite — a
/// read-only mark, a tier upload, an EC encode — hand an already expiring
/// volume another full TTL. Zero when the volume has no TTL. Mirrors Go's
/// ExpireAtSec.
pub fn expire_at_sec(&self) -> u64 {
let ttl_seconds = self.super_block.ttl.to_seconds();
if ttl_seconds == 0 {
return 0;
}
let mut last_write_sec = self.last_modified_ts_seconds;
if last_write_sec == 0 {
last_write_sec = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
}
last_write_sec + ttl_seconds
}
pub fn is_expired_long_enough(&self, max_delay_minutes: u32) -> bool {
let ttl_minutes = self.super_block.ttl.minutes();
if ttl_minutes == 0 {
return false;
}
let removal_delay = std::cmp::min(ttl_minutes / 10, max_delay_minutes);
((ttl_minutes + removal_delay) as u64) * 60 + self.last_modified_ts_seconds
< SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Read all live needles from the volume (for ReadAllNeedles streaming RPC).
pub fn read_all_needles(&self) -> Result<Vec<Needle>, VolumeError> {
let _guard = self.data_file_access_control.read_lock();
let nm = self.nm_or_not_found()?;
let version = self.version();
let dat_size = self.current_dat_file_size()? as i64;
let mut needles = Vec::new();
let mut offset = self.super_block.block_size() as i64;
while offset < dat_size {
let mut header = [0u8; NEEDLE_HEADER_SIZE];
match self.read_exact_at_backend(&mut header, offset as u64) {
Ok(()) => {}
Err(VolumeError::Io(e)) if e.kind() == io::ErrorKind::UnexpectedEof => break,
Err(e) => return Err(e),
}
let (_cookie, key, size) = Needle::parse_header(&header);
if size.0 == 0 && key.is_empty() {
break;
}
let body_length = needle::needle_body_length(size, version);
let total_size = NEEDLE_HEADER_SIZE as i64 + body_length as i64;
if size.is_deleted() || size.0 <= 0 {
offset += total_size;
continue;
}
let Some(nv) = nm.get(key)? else {
offset += total_size;
continue;
};
if nv.offset.to_actual_offset() != offset {
offset += total_size;
continue;
}
let mut n = Needle {
id: key,
..Needle::default()
};
let mut read_option = ReadOption::default();
self.read_needle_data_at_unlocked(&mut n, offset, size, &mut read_option)?;
needles.push(n);
offset += total_size;
}
Ok(needles)
}
/// Check volume data integrity by verifying the last index entries against the .dat file.
/// Matches Go's CheckVolumeDataIntegrity (volume_checking.go L117-141).
/// Reads the last few index entries, verifies each needle header is readable and
/// consistent. On failure, marks the volume read-only.
fn check_volume_data_integrity(&mut self) -> Result<(), VolumeError> {
let idx_path = self.file_name(".idx");
if !Path::new(&idx_path).exists() {
return Ok(());
}
let idx_size = fs::metadata(&idx_path).map(|m| m.len()).unwrap_or(0) as i64;
if idx_size == 0 {
return Ok(());
}
if idx_size % NEEDLE_MAP_ENTRY_SIZE as i64 != 0 {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"index file's size is {} bytes, maybe corrupted",
idx_size
),
)));
}
let version = self.version();
// The deeper-than-tail structural check (every (offset + actual size)
// fits inside .dat — issue #8928) is now handled in load() via the
// needle map's max_needle_end accumulator, so we don't pay for a
// second linear scan of the .idx here.
// Verify the needle physically last in the .dat (the highest offset)
// and that the .dat ends exactly at it. The .idx is not always in .dat
// append order: weed fix and other rebuilds could rewrite it sorted by
// key, which puts the highest-key needle last instead of the .dat-tail
// needle. Picking the last file-position entry there compares a
// mid-file needle's tail against the full .dat size and falsely flips
// the volume read-only on every load. Mirrors Go's
// findDatTailEntryOffset + doCheckAndFixVolumeData.
let mut idx_file = File::open(&idx_path)?;
let tail_entry_pos = self.find_dat_tail_entry_offset(&mut idx_file, idx_size, version)?;
if tail_entry_pos < 0 {
// pre-allocated / all-zero index, nothing to verify against the .dat
return Ok(());
}
let mut buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
idx_file.seek(SeekFrom::Start(tail_entry_pos as u64))?;
idx_file.read_exact(&mut buf)?;
let (key, offset, size) = idx_entry_from_bytes(&buf);
if offset.is_zero() {
return Ok(());
}
if size.is_deleted() {
// Deletion tombstone: the .idx carries TombstoneFileSize (-1) but
// the appended needle in .dat carries Size=0, so verify with
// Size=0 — comparing against -1 would mismatch the on-disk header
// and send every volume with a trailing deletion read-only.
self.verify_deleted_needle_integrity(offset.to_actual_offset(), key, version)
} else {
self.verify_needle_integrity(offset.to_actual_offset(), key, size, version)
}
}
/// Bounds the work a vacuumed volume's recovery does, where key order makes
/// every write a candidate for the newest one. A volume with more live
/// needles than this keeps the .dat mtime: reading a subset could recover a
/// timestamp older than the newest write and expire data still inside its
/// TTL, so a scan that will not fit declines instead of guessing.
const VACUUMED_LAST_WRITE_SCAN_ENTRIES: usize = 1 << 16;
/// Point the TTL clock at the newest write recorded in the volume, replacing
/// the .dat mtime the loader starts from. A delete appends a tombstone and
/// vacuum rewrites the .dat wholesale, so the mtime moves without any write
/// ever landing: every restart of a volume taking delete traffic re-armed
/// is_expired() for another full TTL and the volume was never reclaimed.
/// Mirrors Go's recoverLastModifiedTs.
fn recover_last_modified_ts(&mut self) {
if self.super_block.ttl.minutes() == 0 {
return;
}
match self.find_last_write_append_at_ns() {
Ok(0) => {}
Ok(append_at_ns) => self.last_modified_ts_seconds = append_at_ns / 1_000_000_000,
Err(e) => warn!(
volume_id = self.id.0,
error = %e,
"recover the last write from the index"
),
}
}
/// Scan the .idx backwards for the newest write — an entry that is not a
/// deletion tombstone — and return that needle's append timestamp. The .idx
/// and the .dat share an order, so an append-ordered volume answers with the
/// first write the scan reaches. Vacuum rewrites both in key order, which
/// tracks write order only because the master issues keys increasing: an
/// overwrite keeps its original, lower key, so a vacuumed volume has to take
/// the maximum over every write it indexes. Returns 0 when the .idx holds
/// nothing but tombstones, when a vacuumed volume holds more needles than
/// the scan budget, or for a volume older than version 3, whose needles
/// carry no append timestamp. Mirrors Go's findLastWriteAppendAtNs.
fn find_last_write_append_at_ns(&self) -> Result<u64, VolumeError> {
let version = self.version();
if version != VERSION_3 {
return Ok(0);
}
let idx_path = self.file_name(".idx");
let idx_size = fs::metadata(&idx_path).map(|m| m.len()).unwrap_or(0) as i64;
if idx_size == 0 || idx_size % NEEDLE_MAP_ENTRY_SIZE as i64 != 0 {
return Ok(0);
}
let scan_every_write = self.super_block.compaction_revision > 0;
let mut entry_budget = Self::VACUUMED_LAST_WRITE_SCAN_ENTRIES;
let mut last_write_append_at_ns = 0u64;
let mut idx_file = File::open(&idx_path)?;
let mut block = vec![0u8; NEEDLE_MAP_ENTRY_SIZE * idx::ROWS_TO_READ];
let mut end = idx_size;
while end > 0 {
let start = (end - block.len() as i64).max(0);
let entries = &mut block[..(end - start) as usize];
idx_file.seek(SeekFrom::Start(start as u64))?;
idx_file.read_exact(entries)?;
for entry in entries.chunks_exact(NEEDLE_MAP_ENTRY_SIZE).rev() {
let (key, offset, size) = idx_entry_from_bytes(entry);
if offset.is_zero() || size.is_deleted() {
continue;
}
let Some(needle_offset) =
self.find_needle_offset(offset.to_actual_offset(), key, size)
else {
continue;
};
last_write_append_at_ns = last_write_append_at_ns
.max(self.read_needle_append_at_ns(needle_offset, size)?);
if !scan_every_write {
return Ok(last_write_append_at_ns);
}
entry_budget -= 1;
if entry_budget == 0 {
warn!(
volume_id = self.id.0,
budget = Self::VACUUMED_LAST_WRITE_SCAN_ENTRIES,
"too many needles to scan for the last write, keeping the .dat mtime"
);
return Ok(0);
}
}
end = start;
}
Ok(last_write_append_at_ns)
}
/// The .dat offset holding the needle an .idx entry describes, or None when
/// no needle there matches it. A .dat past MAX_POSSIBLE_VOLUME_SIZE wraps the
/// offsets in its .idx, so the needle can sit one volume size further in;
/// verify_needle_integrity retries the same way. Mirrors Go's
/// findNeedleOffset.
fn find_needle_offset(&self, actual_offset: i64, key: NeedleId, size: Size) -> Option<i64> {
for at in [
actual_offset,
actual_offset + MAX_POSSIBLE_VOLUME_SIZE as i64,
] {
let mut header = [0u8; NEEDLE_HEADER_SIZE];
if self.read_exact_at_backend(&mut header, at as u64).is_err() {
continue;
}
let (_, needle_id, needle_size) = Needle::parse_header(&header);
if needle_id == key && needle_size == size {
return Some(at);
}
}
None
}
/// Read the append timestamp a version 3 needle carries past its checksum.
fn read_needle_append_at_ns(&self, actual_offset: i64, size: Size) -> Result<u64, VolumeError> {
let ts_offset = actual_offset as u64
+ NEEDLE_HEADER_SIZE as u64
+ size.0 as u64
+ NEEDLE_CHECKSUM_SIZE as u64;
let mut ts = [0u8; TIMESTAMP_SIZE];
self.read_exact_at_backend(&mut ts, ts_offset)?;
Ok(u64::from_be_bytes(ts))
}
/// .idx file position of the entry whose needle is physically last in the
/// .dat (highest offset). The common case — an append-ordered .idx — is
/// resolved in O(1): the last entry's on-disk end equals the .dat size. A
/// key-sorted .idx (e.g. legacy weed fix output) falls back to a single
/// linear scan for the maximum offset. Returns -1 when no entry has a
/// non-zero offset (a pre-allocated / all-zero index).
fn find_dat_tail_entry_offset(
&self,
idx_file: &mut File,
idx_size: i64,
version: Version,
) -> Result<i64, VolumeError> {
if let Ok(dat_size) = self.dat_file_size() {
if dat_size > 0 {
let last_pos = idx_size - NEEDLE_MAP_ENTRY_SIZE as i64;
let mut buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
idx_file.seek(SeekFrom::Start(last_pos as u64))?;
idx_file.read_exact(&mut buf)?;
let (_, offset, size) = idx_entry_from_bytes(&buf);
if !offset.is_zero() && needle_disk_end(offset, size, version) == dat_size as i64 {
return Ok(last_pos);
}
}
}
// Slow path: scan the whole .idx for the entry at the maximum offset.
// Tombstone entries are not skipped: a trailing deletion is a real
// on-disk record and can be the needle physically last in the .dat.
let mut max_entry_pos: i64 = -1;
let mut max_actual_offset: i64 = -1;
let mut entry_pos: i64 = 0;
let mut buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
idx_file.seek(SeekFrom::Start(0))?;
let mut reader = io::BufReader::new(idx_file);
while entry_pos < idx_size {
reader.read_exact(&mut buf)?;
let (_, offset, _) = idx_entry_from_bytes(&buf);
if !offset.is_zero() {
let ao = offset.to_actual_offset();
if ao > max_actual_offset {
max_actual_offset = ao;
max_entry_pos = entry_pos;
}
}
entry_pos += NEEDLE_MAP_ENTRY_SIZE as i64;
}
Ok(max_entry_pos)
}
/// Verify the live needle at the .dat tail: its header must match the
/// .idx entry and, on v3, the .dat must end exactly at its on-disk end.
/// Extra bytes mean an unindexed trailing record (a torn append);
/// appending after one would place the next needle at an offset the
/// 8-byte .idx encoding may not represent, so the volume is quarantined
/// read-only instead. Mirrors Go's verifyNeedleIntegrity, whose tail
/// check is v3-only -- a v1/v2 volume Go serves read-write must not go
/// read-only here.
fn verify_needle_integrity(
&mut self,
actual_offset: i64,
key: NeedleId,
size: Size,
version: Version,
) -> Result<(), VolumeError> {
let mut header = [0u8; NEEDLE_HEADER_SIZE];
self.read_exact_at_backend(&mut header, actual_offset as u64)?;
let (_cookie, needle_id, needle_size) = Needle::parse_header(&header);
let mut checked_offset = actual_offset;
let mut checked_id = needle_id;
if needle_size != size {
// Go parity: on a size mismatch, retry past the 32GB offset
// wrap-around before giving up.
let alt_offset = actual_offset + MAX_POSSIBLE_VOLUME_SIZE as i64;
let mut alt_header = [0u8; NEEDLE_HEADER_SIZE];
self.read_exact_at_backend(&mut alt_header, alt_offset as u64)
.map_err(|_| size_mismatch_error(actual_offset, needle_id, needle_size, size))?;
let (_, alt_id, alt_size) = Needle::parse_header(&alt_header);
if alt_size != size {
return Err(size_mismatch_error(actual_offset, needle_id, needle_size, size));
}
checked_offset = alt_offset;
checked_id = alt_id;
}
if !key.is_empty() && checked_id != key {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"index key {:?} does not match needle Id {:?} at offset {}",
key, checked_id, checked_offset
),
)));
}
if version != VERSION_3 {
return Ok(());
}
let ts = self.read_needle_append_at_ns(checked_offset, size)?;
if ts > 0 {
self.last_append_at_ns = ts;
}
self.verify_dat_ends_at(checked_offset + get_actual_size(size, version))
}
/// Verify the deletion tombstone at the .dat tail. Tombstones are appended
/// with DataSize=0, so the on-disk header carries Size=0; verify with that
/// and then require the .dat to end exactly at the tombstone. Mirrors Go's
/// verifyDeletedNeedleIntegrity.
fn verify_deleted_needle_integrity(
&mut self,
actual_offset: i64,
key: NeedleId,
version: Version,
) -> Result<(), VolumeError> {
let record_size = get_actual_size(Size(0), version) as usize;
let mut record = vec![0u8; record_size];
self.read_exact_at_backend(&mut record, actual_offset as u64)?;
let mut n = Needle::default();
n.read_bytes(&record, actual_offset, Size(0), version)
.map_err(|e| {
VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!("read tombstone at offset {}: {}", actual_offset, e),
))
})?;
if !key.is_empty() && n.id != key {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"index key {:?} does not match needle Id {:?} at offset {}",
key, n.id, actual_offset
),
)));
}
if n.append_at_ns > 0 {
self.last_append_at_ns = n.append_at_ns;
}
self.verify_dat_ends_at(actual_offset + get_actual_size(Size(0), version))
}
/// The checked needle is the one physically last in the .dat, so the file
/// must end exactly at it; extra bytes mean an unindexed trailing record.
fn verify_dat_ends_at(&self, file_tail_offset: i64) -> Result<(), VolumeError> {
let file_size = self.dat_file_size().map_err(VolumeError::Io)? as i64;
if file_size == file_tail_offset {
return Ok(());
}
let dat_path = self.file_name(".dat");
if file_size > file_tail_offset {
warn!(
"data file {} actual {} bytes expected {} bytes!",
dat_path, file_size, file_tail_offset
);
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"data file {} actual {} bytes expected {} bytes",
dat_path, file_size, file_tail_offset
),
)));
}
warn!(
"data file {} has {} bytes, less than expected {} bytes!",
dat_path, file_size, file_tail_offset
);
Err(VolumeError::Io(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!(
"data file {} has {} bytes, less than expected {} bytes",
dat_path, file_size, file_tail_offset
),
)))
}
/// Open the on-disk .idx for scrubbing and apply Go openIndex's zero-size
/// guard: a zero-size index is only legal for a pre-allocated volume whose
/// .dat is superblock-only; an empty index over a populated .dat is
/// corruption. Returns the open file + size, or the messages to report.
fn open_index_for_scrub(&self) -> Result<(File, i64), Vec<String>> {
let idx_path = self.file_name(".idx");
let idx_file =
File::open(&idx_path).map_err(|e| vec![format!("open index file {}: {}", idx_path, e)])?;
let idx_file_size = idx_file
.metadata()
.map_err(|e| vec![format!("stat index file {}: {}", idx_path, e)])?
.len() as i64;
if idx_file_size == 0 {
match self.dat_file_size() {
Ok(dat_size) if dat_size > SUPER_BLOCK_SIZE as u64 => {
return Err(vec![format!(
"zero-size IDX file for volume {} with store size {}",
self.id.0, dat_size
)]);
}
Ok(_) => {}
Err(e) => {
return Err(vec![format!(
"stat data file for volume {}: {}",
self.id.0, e
)])
}
}
}
Ok((idx_file, idx_file_size))
}
/// Scrub the volume index: verify the on-disk .idx for overlapping needles
/// and a size that is a whole number of entries. Mirrors Go's Volume.ScrubIndex
/// → idx.CheckIndexFile. Index-only; does not read the .dat.
pub fn scrub_index(&self) -> Result<(u64, Vec<String>), VolumeError> {
let _guard = self.data_file_access_control.read_lock();
let (mut idx_file, idx_file_size) = match self.open_index_for_scrub() {
Ok(v) => v,
Err(msgs) => return Ok((0, msgs)),
};
Ok(crate::storage::idx::check_index_file(
&mut idx_file,
idx_file_size,
self.version(),
))
}
/// Scrub the volume: walk the on-disk .idx (every physical row, including
/// superseded and deleted entries), CRC-verify each live needle, and reconcile
/// the .dat size against the needles it indexes. Mirrors Go's Volume.Scrub →
/// scrubVolumeData. Returns (rows_walked, broken_needles).
pub fn scrub(&self) -> Result<(u64, Vec<String>), VolumeError> {
// Matches Go's scrubVolumeData: a volume with no data backend can't be
// scrubbed (and a zero dat_size would flag every needle out of range).
if !self.has_data_backend() {
return Err(VolumeError::NotFound);
}
let _guard = self.data_file_access_control.read_lock();
let (mut idx_file, idx_file_size) = match self.open_index_for_scrub() {
Ok(v) => v,
Err(msgs) => return Ok((0, msgs)),
};
let version = self.version();
let dat_size = self.dat_file_size().map_err(VolumeError::Io)?;
// Full scrub also runs the index structural check.
let (_, mut broken) =
crate::storage::idx::check_index_file(&mut idx_file, idx_file_size, version);
let mut count: u64 = 0;
let mut total_read: i64 = 0;
let walk = crate::storage::idx::walk_index_file(&mut idx_file, 0, |needle_id, offset, size| {
count += 1;
// A remote-tier delete records an offset-0 tombstone with no physical
// .dat bytes, so it must not contribute to total_read.
if offset.is_zero() && size.is_deleted() {
return Ok(());
}
// Deleted needles still occupy .dat space: count their size, don't read.
total_read += get_actual_size(size, version);
if size.is_deleted() {
return Ok(());
}
let actual_offset = offset.to_actual_offset();
if actual_offset < 0 || actual_offset as u64 >= dat_size {
broken.push(format!(
"needle {} offset {} out of range (dat_size={})",
needle_id.0, actual_offset, dat_size
));
return Ok(());
}
// Lock held above; read directly via the unlocked path (matches Go).
let mut n = Needle {
id: needle_id,
..Needle::default()
};
let mut read_option = ReadOption::default();
if let Err(e) =
self.read_needle_data_at_unlocked(&mut n, actual_offset, size, &mut read_option)
{
broken.push(format!(
"failed to read needle {} on volume {}: {}",
needle_id.0, self.id.0, e
));
}
Ok(())
});
if let Err(e) = walk {
broken.push(format!("walk index file: {}", e));
}
// Reconcile the physical .dat size against the needles read.
let want_size = total_read + SUPER_BLOCK_SIZE as i64;
if (dat_size as i64) < want_size {
broken.push(format!(
"data file for volume {} is smaller ({}) than the {} needles it contains ({})",
self.id.0, dat_size, count, want_size
));
} else if dat_size as i64 != want_size {
broken.push(format!(
"data file size for volume {} ({}) doesn't match the size for {} needles read ({})",
self.id.0, dat_size, count, want_size
));
}
Ok((count, broken))
}
/// Scan raw needle entries from the .dat file starting at `from_offset`.
/// Returns (needle_header_bytes, needle_body_bytes, append_at_ns) for each needle.
/// Used by VolumeTailSender to stream raw bytes.
pub fn scan_raw_needles_from(
&self,
from_offset: u64,
) -> Result<Vec<(Vec<u8>, Vec<u8>, u64)>, VolumeError> {
let version = self.version();
let dat_size = self.current_dat_file_size()?;
let mut entries = Vec::new();
let mut offset = from_offset;
while offset < dat_size {
// Read needle header (16 bytes)
let mut header = [0u8; NEEDLE_HEADER_SIZE];
match self.read_exact_at_backend(&mut header, offset) {
Ok(()) => {}
Err(VolumeError::Io(e)) if e.kind() == io::ErrorKind::UnexpectedEof => break,
Err(e) => return Err(e.into()),
}
let (_cookie, _id, size) = Needle::parse_header(&header);
if size.0 == 0 && _id.is_empty() {
break;
}
let body_length = needle::needle_body_length(size, version);
let total_size = NEEDLE_HEADER_SIZE as u64 + body_length as u64;
// Match Go's ScanVolumeFileFrom: visit ALL needles including deleted ones.
// This is critical for incremental copy where tombstones must be propagated.
// Read body bytes
let mut body = vec![0u8; body_length as usize];
match self.read_exact_at_backend(&mut body, offset + NEEDLE_HEADER_SIZE as u64) {
Ok(()) => {}
Err(VolumeError::Io(e)) if e.kind() == io::ErrorKind::UnexpectedEof => break,
Err(e) => return Err(e.into()),
}
// Parse the needle to get append_at_ns
let mut full = vec![0u8; total_size as usize];
full[..NEEDLE_HEADER_SIZE].copy_from_slice(&header);
full[NEEDLE_HEADER_SIZE..].copy_from_slice(&body);
let mut n = Needle::default();
let _ = n.read_bytes(&full, offset as i64, size, version);
entries.push((header.to_vec(), body, n.append_at_ns));
offset += total_size;
}
Ok(entries)
}
/// Insert or update a needle index entry (for low-level blob writes).
pub fn put_needle_index(
&mut self,
key: NeedleId,
offset: Offset,
size: Size,
) -> Result<(), VolumeError> {
if let Some(ref mut nm) = self.nm {
nm.put(key, offset, size).map_err(VolumeError::Io)?;
}
self.maybe_checkpoint_index(false);
Ok(())
}
/// Mark this volume as read-only (no writes or deletes) and persist.
pub fn set_read_only(&mut self) -> Result<(), VolumeError> {
self.set_read_only_persist(false, true)
}
/// Mark this volume as read-only, optionally persisting to .vif.
/// With `can_delete`, deletes still land so expiring data drains the volume.
pub fn set_read_only_persist(
&mut self,
can_delete: bool,
persist: bool,
) -> Result<(), VolumeError> {
if can_delete && !self.has_remote_file {
// deletes append tombstones to .idx; a read-only boot attached no writer
self.attach_idx_writer_if_missing()?;
}
self.no_write_or_delete = !can_delete;
if can_delete {
self.no_write_can_delete = true;
} else if !self.has_remote_file {
// downgrading a canDelete mark; remote volumes keep their derived flag
self.no_write_can_delete = false;
}
if persist {
self.save_vif()?;
}
Ok(())
}
/// Attach an append-only .idx writer when missing (read-only boot). Runs
/// before any flag flips so a failure cannot leave puts silently skipping
/// the on-disk append.
fn attach_idx_writer_if_missing(&mut self) -> Result<(), VolumeError> {
let needs_idx_writer = self
.nm
.as_ref()
.map(|nm| !nm.has_idx_writer())
.unwrap_or(false);
if needs_idx_writer {
let idx_path = self.file_name(".idx");
let write_file = OpenOptions::new()
.write(true)
.append(true)
.create(true)
.open(&idx_path)?;
let idx_size = trim_torn_idx_tail(&write_file, &idx_path)?;
if let Some(ref mut nm) = self.nm {
nm.set_idx_file(Box::new(write_file), idx_size);
}
}
Ok(())
}
/// Mark this volume as writable (allow writes and deletes).
///
/// If the volume booted with .vif ReadOnly=true, `load_index` built the
/// needle map without an .idx writer attached, so subsequent puts would
/// silently skip the on-disk append and only mutate in-memory state —
/// surviving until the next restart, then vanishing. Re-attach a writer
/// here so writes persist again.
pub fn set_writable(&mut self) -> Result<(), VolumeError> {
let was_no_write_or_delete = self.no_write_or_delete;
let was_no_write_can_delete = self.no_write_can_delete;
self.no_write_or_delete = false;
// Remote-tiered volumes must stay no_write_can_delete regardless of marks.
if !self.has_remote_file {
self.no_write_can_delete = false;
}
// Everything below keys off the flags above, so it runs with the volume
// already marked writable. Any failure has to put them back: a volume
// advertising writable whose needle map cannot reach .idx acknowledges
// writes that are gone after a restart.
if let Err(e) = self.publish_writable() {
self.no_write_or_delete = was_no_write_or_delete;
self.no_write_can_delete = was_no_write_can_delete;
return Err(e);
}
Ok(())
}
/// The steps that must all succeed before a volume can be left marked
/// writable: swap out the read-only map, attach the .idx writer, persist the
/// mark. Split out so set_writable has one rollback point rather than three.
fn publish_writable(&mut self) -> Result<(), VolumeError> {
self.reconcile_index_mode()?;
self.attach_idx_writer_if_missing()?;
self.save_vif()
}
/// Swap the read-only sorted map for the configured writable one once the
/// current mode says the volume is no longer read-only. Mirrors Go's
/// reopenIdxForWrite, and is the reason a volume that stops being read-only
/// can accept writes at all: SortedFileNeedleMap::put always fails, so
/// publishing a writable volume without this leaves every write appending to
/// .dat and then failing to index it. A no-op unless the map is the sorted
/// one and the flags have already moved.
fn reconcile_index_mode(&mut self) -> Result<(), VolumeError> {
let wants_sorted = self.use_sorted_index();
let has_sorted = matches!(self.nm, Some(NeedleMap::SortedFile(_)));
if wants_sorted == has_sorted {
return Ok(());
}
// Both directions matter. Tiering DOWN has to trade the read-only
// sorted map for a writable one before the volume is published as
// writable. Tiering UP has to install the sorted map, or a volume
// tiered while the server runs keeps its whole index in RAM and its
// .idx descriptor pinned until the next restart — the cost this map
// exists to avoid.
//
// load_index only publishes self.nm once the new map is built, so a
// failure leaves the previous map in place and the caller can recover.
self.load_index()
}
/// Recompute the Go-style write/delete mode from the current remote tier
/// state, and bring the needle map in line with it — a volume that stops
/// being remote also stops using the read-only sorted index.
///
/// If the map cannot be rebuilt the volume is pinned read-only rather than
/// published as writable with an index that rejects every put; a restart
/// recovers it.
pub fn refresh_remote_write_mode(&mut self) -> Result<(), VolumeError> {
self.has_remote_file = !self.volume_info.files.is_empty();
if self.has_remote_file {
self.no_write_can_delete = true;
self.no_write_or_delete = false;
} else if !self.volume_info.read_only_can_delete {
// only clear the remoteness-derived flag, not an operator mark
self.no_write_can_delete = false;
}
if let Err(e) = self.reconcile_index_mode() {
self.no_write_or_delete = true;
return Err(e);
}
Ok(())
}
/// Open the local .dat as the data backend, dropping any remote backend, so reads
/// are served from local disk. Mirrors Go's swapToLocalDatBackend after a tier-down.
pub(crate) fn open_local_dat_backend(&mut self) -> Result<(), VolumeError> {
let file = open_volume_file(OpenOptions::new().read(true).write(true), self.dat_path())?;
self.remote_dat_file = None;
self.dat_file = Some(file);
Ok(())
}
/// Path to .vif file.
fn vif_path(&self) -> String {
format!("{}.vif", self.data_file_name())
}
/// Load volume info from .vif file.
/// Supports both the protobuf-JSON format (Go-compatible) and legacy JSON.
/// Returns true if a .vif file was found and successfully loaded.
fn load_vif(&mut self) -> Result<bool, VolumeError> {
let path = self.vif_path();
if let Ok(content) = fs::read_to_string(&path) {
if content.trim().is_empty() {
return Ok(false);
}
// Try protobuf-JSON (Go-compatible VolumeInfo via VifVolumeInfo)
if let Ok(vif_info) = serde_json::from_str::<VifVolumeInfo>(&content) {
let pb_info = vif_info.to_pb();
if pb_info.read_only {
if pb_info.read_only_can_delete {
self.no_write_can_delete = true;
} else {
self.no_write_or_delete = true;
}
}
self.volume_info = pb_info;
self.refresh_remote_write_mode()?;
if self.volume_info.version == 0 {
self.volume_info.version = Version::current().0 as u32;
}
if !self.has_remote_file && self.volume_info.bytes_offset == 0 {
self.volume_info.bytes_offset = OFFSET_SIZE as u32;
}
if self.volume_info.bytes_offset != 0
&& self.volume_info.bytes_offset != OFFSET_SIZE as u32
{
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"bytes_offset mismatch in {}: found {}, expected {}",
path, self.volume_info.bytes_offset, OFFSET_SIZE
),
)));
}
return Ok(true);
}
// Fall back to OldVersionVolumeInfo (Go's tryOldVersionVolumeInfo):
// maps DestroyTime -> expire_at_sec
if let Ok(old_info) = serde_json::from_str::<OldVersionVifVolumeInfo>(&content) {
let vif_info = old_info.to_vif();
let pb_info = vif_info.to_pb();
if pb_info.read_only {
self.no_write_or_delete = true;
}
self.volume_info = pb_info;
self.refresh_remote_write_mode()?;
if self.volume_info.version == 0 {
self.volume_info.version = Version::current().0 as u32;
}
if !self.has_remote_file && self.volume_info.bytes_offset == 0 {
self.volume_info.bytes_offset = OFFSET_SIZE as u32;
}
if self.volume_info.bytes_offset != 0
&& self.volume_info.bytes_offset != OFFSET_SIZE as u32
{
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"bytes_offset mismatch in {}: found {}, expected {}",
path, self.volume_info.bytes_offset, OFFSET_SIZE
),
)));
}
return Ok(true);
}
// Fall back to legacy format
if let Ok(info) = serde_json::from_str::<VolumeInfo>(&content) {
if info.read_only {
self.no_write_or_delete = true;
}
return Ok(true);
}
}
Ok(false)
}
/// Save volume info to .vif file in protobuf-JSON format (Go-compatible).
/// Matches Go's SaveVolumeInfo: checks writability before writing and propagates errors.
fn save_vif(&mut self) -> Result<(), VolumeError> {
let vif_path = self.vif_path();
// Match Go: if file exists but is not writable, return an error
let path = std::path::Path::new(&vif_path);
if path.exists() {
let metadata = fs::metadata(path)?;
if metadata.permissions().readonly() {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("failed to check {} not writable", vif_path),
)));
}
}
// remoteness-derived no_write_can_delete is not an operator mark; sync
// volume_info so refresh_remote_write_mode sees a real mark
let marked_can_delete =
self.no_write_can_delete && !self.has_remote_file && !self.no_write_or_delete;
self.volume_info.read_only = self.no_write_or_delete || marked_can_delete;
self.volume_info.read_only_can_delete = marked_can_delete;
let mut vif = VifVolumeInfo::from_pb(&self.volume_info);
// Match Go's SaveVolumeInfo: compute ExpireAtSec from TTL
let expire_at_sec = self.expire_at_sec();
if expire_at_sec > 0 {
vif.expire_at_sec = expire_at_sec;
}
let content = serde_json::to_string_pretty(&vif)
.map_err(|e| VolumeError::Io(io::Error::new(io::ErrorKind::Other, e.to_string())))?;
fs::write(&vif_path, content)?;
Ok(())
}
/// Save full VolumeInfo to .vif file (for tiered storage).
/// Matches Go's SaveVolumeInfo which computes ExpireAtSec from TTL.
pub fn save_volume_info(&mut self) -> Result<(), VolumeError> {
let marked_can_delete =
self.no_write_can_delete && !self.has_remote_file && !self.no_write_or_delete;
self.volume_info.read_only = self.no_write_or_delete || marked_can_delete;
self.volume_info.read_only_can_delete = marked_can_delete;
// Compute ExpireAtSec from TTL (matches Go's SaveVolumeInfo)
let expire_at_sec = self.expire_at_sec();
if expire_at_sec > 0 {
self.volume_info.expire_at_sec = expire_at_sec;
}
let vif = VifVolumeInfo::from_pb(&self.volume_info);
let content = serde_json::to_string_pretty(&vif)
.map_err(|e| VolumeError::Io(io::Error::new(io::ErrorKind::Other, e.to_string())))?;
// fsync the .vif so a tiered volume's remote reference is durable before the
// caller acts on it, e.g. deletes the remote object (matches Go util.WriteFile).
let mut f = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(self.vif_path())?;
f.write_all(content.as_bytes())?;
f.sync_all()?;
Ok(())
}
/// Get the remote storage backend name and key from this volume .vif.
pub fn remote_storage_name_key(&self) -> (String, String) {
if self.volume_info.files.is_empty() {
return (String::new(), String::new());
}
let rf = &self.volume_info.files[0];
let backend_name = if rf.backend_id.is_empty() {
rf.backend_type.clone()
} else {
format!("{}.{}", rf.backend_type, rf.backend_id)
};
(backend_name, rf.key.clone())
}
/// Get the dat file path for this volume.
pub fn dat_path(&self) -> String {
self.file_name(".dat")
}
/// Get the directory this volume is stored in.
pub fn dir(&self) -> &str {
&self.dir
}
/// Throttle IO during compaction to avoid saturating disk.
pub fn maybe_throttle_compaction(&self, bytes_written: u64) {
if self.compaction_byte_per_second <= 0 || !self.is_compacting {
return;
}
// Simple throttle: sleep based on bytes written vs allowed rate
let sleep_us =
(bytes_written as f64 / self.compaction_byte_per_second as f64 * 1_000_000.0) as u64;
if sleep_us > 0 {
std::thread::sleep(std::time::Duration::from_micros(sleep_us));
}
}
/// Change the replication placement and rewrite the super block.
pub fn set_replica_placement(&mut self, rp: ReplicaPlacement) -> Result<(), VolumeError> {
self.super_block.replica_placement = rp;
let bytes = self.super_block.to_bytes();
let dat_file = self.dat_file.as_mut().ok_or_else(|| {
VolumeError::Io(io::Error::new(io::ErrorKind::Other, "dat file not open"))
})?;
dat_file.seek(SeekFrom::Start(0))?;
dat_file.write_all(&bytes)?;
dat_file.sync_all()?;
Ok(())
}
// ---- Binary search for incremental copy ----
/// Read a single index entry's offset from the .idx file by entry index.
fn read_offset_from_index(&self, m: i64) -> Result<Offset, VolumeError> {
let idx_path = self.file_name(".idx");
let idx_file = File::open(&idx_path)?;
let mut buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
let file_offset = m as u64 * NEEDLE_MAP_ENTRY_SIZE as u64;
#[cfg(unix)]
{
use std::os::unix::fs::FileExt;
idx_file.read_exact_at(&mut buf, file_offset)?;
}
#[cfg(not(unix))]
{
let mut f = idx_file;
f.seek(SeekFrom::Start(file_offset))?;
std::io::Read::read_exact(&mut f, &mut buf)?;
}
let (_key, offset, _size) = idx_entry_from_bytes(&buf);
Ok(offset)
}
/// Read the append_at_ns timestamp from a needle at the given offset in the .dat file.
/// Go reads the full needle body for ALL entries including tombstones to get the
/// actual AppendAtNs timestamp, which is needed for correct binary search during
/// incremental copy.
fn read_append_at_ns(&self, offset: Offset) -> Result<u64, VolumeError> {
let actual_offset = offset.to_actual_offset() as u64;
let version = self.version();
let mut header_buf = [0u8; NEEDLE_HEADER_SIZE];
self.read_exact_at_backend(&mut header_buf, actual_offset)?;
let (_cookie, _id, size) = Needle::parse_header(&header_buf);
let actual_size = get_actual_size(size, version);
if actual_size <= 0 {
return Ok(0);
}
let mut buf = vec![0u8; actual_size as usize];
self.read_exact_at_backend(&mut buf, actual_offset)?;
let mut n = Needle::default();
n.read_bytes_meta_only(&mut buf, offset.to_actual_offset(), size, version)?;
Ok(n.append_at_ns)
}
/// Search right from position m to find the first non-deleted entry.
fn read_right_ns(&self, m: i64, max: i64) -> Result<(i64, Offset, u64), VolumeError> {
let mut index = m;
loop {
index += 1;
if index >= max {
return Ok((index, Offset::default(), 0));
}
let offset = self.read_offset_from_index(index)?;
if !offset.is_zero() {
let ts = self.read_append_at_ns(offset)?;
return Ok((index, offset, ts));
}
}
}
/// Search left from position m to find the first non-deleted entry.
fn read_left_ns(&self, m: i64) -> Result<(i64, Offset, u64), VolumeError> {
let mut index = m;
loop {
index -= 1;
if index < 0 {
return Ok((index, Offset::default(), 0));
}
let offset = self.read_offset_from_index(index)?;
if !offset.is_zero() {
let ts = self.read_append_at_ns(offset)?;
return Ok((index, offset, ts));
}
}
}
/// Binary search through the .idx file to find the first needle
/// with append_at_ns > since_ns. Returns (offset, is_last).
/// Matches Go's BinarySearchByAppendAtNs in volume_backup.go.
pub fn binary_search_by_append_at_ns(
&self,
since_ns: u64,
) -> Result<(Offset, bool), VolumeError> {
let file_size = self.idx_file_size() as i64;
if file_size % NEEDLE_MAP_ENTRY_SIZE as i64 != 0 {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected idx file size: {}", file_size),
)));
}
let entry_count = file_size / NEEDLE_MAP_ENTRY_SIZE as i64;
let mut l: i64 = 0;
let mut h: i64 = entry_count;
while l < h {
let m = (l + h) / 2;
if m == entry_count {
return Ok((Offset::default(), true));
}
let offset = self.read_offset_from_index(m)?;
if offset.is_zero() {
let (left_index, _left_offset, left_ns) = self.read_left_ns(m)?;
let (right_index, right_offset, right_ns) = self.read_right_ns(m, entry_count)?;
if right_ns <= since_ns {
l = right_index;
if l == entry_count {
return Ok((Offset::default(), true));
} else {
continue;
}
}
if since_ns < left_ns {
h = left_index + 1;
continue;
}
return Ok((right_offset, false));
}
let m_ns = self.read_append_at_ns(offset)?;
if m_ns <= since_ns {
l = m + 1;
} else {
h = m;
}
}
if l == entry_count {
return Ok((Offset::default(), true));
}
let offset = self.read_offset_from_index(l)?;
Ok((offset, false))
}
/// Write a raw needle blob at a specific offset in the .dat file.
pub fn write_needle_blob(
&mut self,
offset: i64,
needle_blob: &[u8],
) -> Result<(), VolumeError> {
if self.is_read_only() {
return Err(VolumeError::ReadOnly);
}
let dat_file = self.dat_file.as_mut().ok_or_else(|| {
VolumeError::Io(io::Error::new(io::ErrorKind::Other, "dat file not open"))
})?;
dat_file.seek(SeekFrom::Start(offset as u64))?;
dat_file.write_all(needle_blob)?;
Ok(())
}
/// Write a needle blob and update the needle map index.
/// Matches Go's Volume.WriteNeedleBlob which appends to dat and calls nm.Put.
pub fn write_needle_blob_and_index(
&mut self,
needle_id: NeedleId,
needle_blob: &[u8],
size: Size,
) -> Result<(), VolumeError> {
// nm.put on a read-only volume fails only after the blob is appended to .dat.
if self.is_read_only() {
return Err(VolumeError::ReadOnly);
}
// size indexes the needle and places the v3 append timestamp, so a caller using
// the payload-only DataSize corrupts both, silently until the needle is read back.
if needle_blob.len() < NEEDLE_HEADER_SIZE {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"needle {} blob of {} bytes is shorter than a needle header",
needle_id.0,
needle_blob.len()
),
)));
}
let (_, _, header_size) = Needle::parse_header(needle_blob);
if header_size != size {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"needle {} size {} does not match its blob header size {}",
needle_id.0, size.0, header_size.0
),
)));
}
// Dedup check: if the same needle already exists with matching content, skip the write.
// Matches Go's WriteNeedleBlob which reads existing needle and compares cookie+checksum+data.
if let Some(nm) = &self.nm {
if let Some(nv) = nm.get(needle_id)? {
if nv.size == size {
let version = self.version();
// Read existing needle from disk
let mut old_needle = Needle::default();
let mut ro = ReadOption::default();
if self
.read_needle_data_at_unlocked(
&mut old_needle,
nv.offset.to_actual_offset(),
nv.size,
&mut ro,
)
.is_ok()
{
// Parse the incoming blob into a needle
let mut new_needle = Needle::default();
if new_needle
.read_bytes(needle_blob, nv.offset.to_actual_offset(), size, version)
.is_ok()
{
if old_needle.cookie == new_needle.cookie
&& old_needle.checksum == new_needle.checksum
&& old_needle.data == new_needle.data
{
return Ok(());
}
}
}
}
}
}
// Check volume size limit
let content_size = self.content_size();
if MAX_POSSIBLE_VOLUME_SIZE < content_size + needle_blob.len() as u64 {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::Other,
format!(
"volume size limit {} exceeded! current size is {}",
MAX_POSSIBLE_VOLUME_SIZE, content_size
),
)));
}
// Compute monotonic appendAtNs (matches Go: needle.GetAppendAtNs(v.lastAppendAtNs))
let append_at_ns = get_append_at_ns(self.last_append_at_ns);
// Patch appendAtNs timestamp into V3 blobs (matches Go WriteNeedleBlob L64-77)
let mut blob_buf;
let blob_to_write = if self.version() == VERSION_3 {
let ts_offset =
NEEDLE_HEADER_SIZE + size.0 as usize + NEEDLE_CHECKSUM_SIZE;
if ts_offset + TIMESTAMP_SIZE > needle_blob.len() {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"needle blob buffer too small: need {} bytes, have {}",
ts_offset + TIMESTAMP_SIZE,
needle_blob.len()
),
)));
}
blob_buf = needle_blob.to_vec();
blob_buf[ts_offset..ts_offset + TIMESTAMP_SIZE]
.copy_from_slice(&append_at_ns.to_be_bytes());
&blob_buf[..]
} else {
needle_blob
};
// Append blob at end of dat file
let dat_size = self.dat_file_size()? as i64;
self.write_needle_blob(dat_size, blob_to_write)?;
// Update lastAppendAtNs (matches Go L352: v.lastAppendAtNs = appendAtNs)
self.last_append_at_ns = append_at_ns;
// Update needle map index
let offset = Offset::from_actual_offset(dat_size);
if let Some(ref mut nm) = self.nm {
nm.put(needle_id, offset, size)?;
}
self.maybe_checkpoint_index(false);
Ok(())
}
pub fn needs_replication(&self) -> bool {
self.super_block.replica_placement.get_copy_count() > 1
}
/// Garbage ratio: deleted_size / content_size (matching Go's garbageLevel).
/// content_size is the additive-only FileByteCounter.
///
/// When DeletedCount > 0 but DeletedSize == 0 (e.g. .sdx converted back to
/// normal .idx where deleted entry sizes are missing), falls back to
/// computing deleted bytes as (datFileSize - contentSize - SuperBlockSize)
/// and uses datFileSize as the denominator.
pub fn garbage_level(&self) -> f64 {
let content = self.content_size();
if content == 0 {
return 0.0;
}
let mut deleted = self.deleted_size();
let mut file_size = content;
if self.deleted_count() > 0 && deleted == 0 {
// This happens for .sdx converted back to normal .idx
// where deleted entry size is missing
let dat_file_size = self.dat_file_size().unwrap_or(0);
deleted = dat_file_size.saturating_sub(content).saturating_sub(SUPER_BLOCK_SIZE as u64);
file_size = dat_file_size;
}
if file_size == 0 {
return 0.0;
}
deleted as f64 / file_size as f64
}
pub fn dat_file_size(&self) -> io::Result<u64> {
self.current_dat_file_size()
}
/// Get the modification time of the .dat file as Unix seconds.
pub fn dat_file_mod_time(&self) -> u64 {
if let Some(dat_file) = self.dat_file.as_ref() {
dat_file
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0)
} else {
self.remote_dat_file
.as_ref()
.map(|remote_dat_file| remote_dat_file.modified_time)
.unwrap_or(0)
}
}
pub fn idx_file_size(&self) -> u64 {
self.nm.as_ref().map_or(0, |nm| nm.index_file_size())
}
// ---- Compaction / Vacuum ----
/// Compact the volume by copying only live needles to new .cpd/.cpx files.
/// This reads from the current .dat/.idx and writes to .cpd/.cpx.
/// Call `commit_compact()` after to swap the files.
pub fn compact_by_index<F>(
&mut self,
_preallocate: u64,
_max_bytes_per_second: i64,
progress_fn: F,
) -> Result<(), VolumeError>
where
F: Fn(i64) -> bool,
{
if self.is_compacting {
return Ok(()); // already compacting
}
self.is_compacting = true;
let result = self.do_compact_by_index(progress_fn);
self.is_compacting = false;
result
}
fn do_compact_by_index<F>(&mut self, progress_fn: F) -> Result<(), VolumeError>
where
F: Fn(i64) -> bool,
{
// Guard against nil needle map (matches Go's nil check before compaction sync)
if self.nm.is_none() {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::Other,
format!("volume {} needle map is nil", self.id),
)));
}
// Record state before compaction for makeupDiff
self.last_compact_index_offset = self.nm.as_ref().map_or(0, |nm| nm.index_file_size());
self.last_compact_revision = self.super_block.compaction_revision;
// Sync current data
self.sync_to_disk()?;
let cpd_path = self.file_name(".cpd");
let cpx_path = self.file_name(".cpx");
let version = self.version();
// Write new super block with incremented compaction revision
let mut new_sb = self.super_block.clone();
new_sb.compaction_revision += 1;
let sb_bytes = new_sb.to_bytes();
let mut dst = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&cpd_path)?;
dst.write_all(&sb_bytes)?;
let mut new_offset = sb_bytes.len() as i64;
// Build new index in memory
let mut new_nm = CompactNeedleMap::new();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Collect live entries from needle map (sorted ascending)
let nm = self.nm.as_ref().ok_or(VolumeError::NotInitialized)?;
let mut entries: Vec<(NeedleId, Offset, Size)> = Vec::new();
for (id, nv) in nm.iter_entries().map_err(VolumeError::Io)? {
if nv.offset.is_zero() || nv.size.is_deleted() {
continue;
}
entries.push((id, nv.offset, nv.size));
}
entries.sort_by_key(|(_, offset, _)| *offset);
let mut skipped_needles: u64 = 0;
let mut skipped_data_bytes: u64 = 0;
for (id, offset, size) in entries {
// Progress callback
if !progress_fn(offset.to_actual_offset()) {
// Interrupted
let _ = fs::remove_file(&cpd_path);
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::Interrupted,
"compaction interrupted",
)));
}
// Read needle from source
let mut n = Needle {
id,
..Needle::default()
};
match self.read_needle_data_at(&mut n, offset.to_actual_offset(), size) {
Ok(()) => {}
Err(e) => {
// Record EIO for health monitoring (parity with Go's checkReadWriteError).
if let VolumeError::Io(ref io_err) = e {
self.check_read_write_error(Some(io_err));
}
// Only drop the entry when the failure is one of the well-
// known permanent-corruption shapes. A transient disk fault,
// a tiered-read timeout, or a Windows hardware error (which
// surfaces as a generic Io rather than UnexpectedEof) must
// abort so an operator notices, rather than silently
// compacting away data that might come back on retry.
// See issue #8928.
if !is_skippable_needle_read_error(&e) {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::Other,
format!("cannot hydrate needle from file: {}", e),
)));
}
skipped_needles += 1;
if size.is_valid() {
skipped_data_bytes += size.0 as u64;
}
warn!(
volume_id = self.id.0,
key = id.0,
offset = offset.to_actual_offset(),
size = size.0,
error = %e,
"vacuum: dropping unreadable needle"
);
continue;
}
}
// Skip TTL-expired needles using the volume's TTL (matches Go's volume_vacuum.go)
if n.has_ttl() {
let ttl_minutes = self.super_block.ttl.minutes();
if ttl_minutes > 0 && n.last_modified > 0 {
let expire_at = n.last_modified + (ttl_minutes as u64) * 60;
if now >= expire_at {
continue;
}
}
}
// Write needle to destination
let bytes = n.write_bytes(version);
dst.write_all(&bytes)?;
// Update new index
new_nm.put(id, Offset::from_actual_offset(new_offset), n.size)?;
new_offset += bytes.len() as i64;
}
if skipped_needles > 0 {
warn!(
volume_id = self.id.0,
skipped_needles,
skipped_data_bytes,
"vacuum: dropped unreadable index entries during compaction"
);
}
dst.sync_all()?;
// Save new index
new_nm.save_to_idx(&cpx_path)?;
Ok(())
}
/// Commit a previously completed compaction: swap .cpd/.cpx to .dat/.idx and reload.
/// Matches Go's isCompactionInProgress CompareAndSwap guard.
pub fn commit_compact(&mut self) -> Result<(), VolumeError> {
if self.is_compacting {
return Ok(()); // already compacting, silently skip (matches Go)
}
self.is_compacting = true;
let result = self.do_commit_compact();
self.is_compacting = false;
result
}
fn do_commit_compact(&mut self) -> Result<(), VolumeError> {
if let Err(e) = self.makeup_diff() {
warn!("makeup_diff failed: {}", e);
// Match Go: clean up .cpd/.cpx on makeup_diff failure
let cpd = self.file_name(".cpd");
let cpx = self.file_name(".cpx");
let _ = fs::remove_file(&cpd);
let _ = fs::remove_file(&cpx);
return Err(e);
}
// Close current files
if let Some(ref mut nm) = self.nm {
nm.close();
}
self.nm = None;
if let Some(ref dat_file) = self.dat_file {
let _ = dat_file.sync_all();
}
self.dat_file = None;
self.remote_dat_file = None;
// makeup_diff has fsynced the .cpd/.cpx contents. Persist a durable .cpc
// commit marker BEFORE the renames so the two-rename swap is atomic
// across a crash: a marker on disk means the swap is decided and
// reconcile rolls forward; no marker means roll back. Without it, a
// crash between the two renames leaves a stale .idx that a later vacuum
// compacts to empty.
self.write_compact_commit_marker()?;
self.apply_compact_swap()?;
// Reload
self.load(true, false, 0, self.version())?;
Ok(())
}
/// Write and fsync the .cpc marker, then fsync the directory so the marker
/// survives a crash before apply_compact_swap.
fn write_compact_commit_marker(&self) -> Result<(), VolumeError> {
let marker_path = self.file_name(".cpc");
let f = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&marker_path)?;
f.sync_all()?;
drop(f);
fsync_dir(&marker_path)?;
Ok(())
}
/// Perform the durable two-rename compaction commit. Idempotent: run both at
/// the tail of commit and by reconcile rolling forward after a crash. It
/// requires BOTH .cpd/.cpx to be present, so a stale or duplicate commit
/// returns an error without deleting the live .dat/.idx.
fn apply_compact_swap(&self) -> Result<(), VolumeError> {
// Normal commit: both temp files must be present, or a stale/duplicate
// commit could clobber the live .dat/.idx.
if !Path::new(&self.file_name(".cpd")).exists()
|| !Path::new(&self.file_name(".cpx")).exists()
{
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::NotFound,
"compact files (.cpd/.cpx) not found",
)));
}
self.finish_compact_swap()
}
/// Renames whichever compaction temp file is still present (.cpd->.dat,
/// .cpx->.idx), fsyncs, drops the stale .ldb/.rdb, then clears the .cpc
/// marker. Tolerates a partial state: a crash after the .dat rename but
/// before the .idx rename leaves only .cpx, which must still be applied --
/// not abandoned, which would pair a fresh .dat with a stale .idx. Existence
/// is checked robustly so a transient error never silently skips the swap.
fn finish_compact_swap(&self) -> Result<(), VolumeError> {
let exists = |p: String| match fs::metadata(&p) {
Ok(_) => true,
Err(e) if e.kind() == io::ErrorKind::NotFound => false,
Err(_) => true,
};
let dat_path = self.file_name(".dat");
let idx_path = self.file_name(".idx");
let cpd_exists = exists(self.file_name(".cpd"));
let cpx_exists = exists(self.file_name(".cpx"));
if cpd_exists {
#[cfg(windows)]
{
let _ = fs::remove_file(&dat_path);
}
fs::rename(self.file_name(".cpd"), &dat_path)?;
}
if cpx_exists {
#[cfg(windows)]
{
let _ = fs::remove_file(&idx_path);
}
fs::rename(self.file_name(".cpx"), &idx_path)?;
}
if cpd_exists || cpx_exists {
fsync_dir(&dat_path)?;
if self.dir != self.dir_idx {
fsync_dir(&idx_path)?;
}
let _ = fs::remove_dir_all(self.file_name(".ldb"));
let _ = fs::remove_file(self.file_name(".rdb"));
}
// Clear the marker last and fsync the dir so a restart does not re-run a
// completed swap.
let marker_path = self.file_name(".cpc");
match fs::remove_file(&marker_path) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
fsync_dir(&marker_path)?;
Ok(())
}
/// Recover an interrupted compaction commit on load. When the .cpc marker is
/// present the swap was decided, so roll FORWARD by finishing the renames;
/// when it is absent any leftover .cpd/.cpx are an abandoned generation, so
/// roll BACK by deleting them (and any stale .ldb beside a healthy .idx).
pub fn reconcile_compact_state(&self) -> Result<(), VolumeError> {
let cpc_path = self.file_name(".cpc");
if Path::new(&cpc_path).exists() {
// Marker present: the swap was decided. Finish whichever rename is
// still pending -- a crash may have completed only the .dat rename,
// so the lone remaining .cpx must still be applied, not abandoned.
// If neither temp file remains, finish_compact_swap clears the marker.
tracing::info!(
volume_id = self.id.0,
"rolling forward interrupted compaction commit"
);
return self.finish_compact_swap();
}
// No marker: roll back any orphan compaction temp files.
let mut rolled_back = false;
for ext in &[".cpd", ".cpx"] {
let p = self.file_name(ext);
if Path::new(&p).exists() {
tracing::info!(
volume_id = self.id.0,
"rolling back orphan compaction file {}",
ext
);
match fs::remove_file(&p) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
rolled_back = true;
}
}
if rolled_back {
let _ = fs::remove_dir_all(self.file_name(".ldb"));
let _ = fs::remove_file(self.file_name(".rdb"));
}
Ok(())
}
/// Clean up leftover compaction files (.cpd, .cpx).
pub fn cleanup_compact(&self) -> Result<(), VolumeError> {
// Refuse to unlink .cpd/.cpx while a .cpc marker exists: those temp files
// are the only inputs reconcile can roll forward to, so removing them
// mid-commit would strand a decided swap.
if Path::new(&self.file_name(".cpc")).exists() {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::Other,
format!(
"volume {}: refusing cleanup while commit marker present",
self.id
),
)));
}
let cpd_path = self.file_name(".cpd");
let cpx_path = self.file_name(".cpx");
let cpldb_path = self.file_name(".cpldb");
let cpc_path = self.file_name(".cpc");
let e1 = fs::remove_file(&cpd_path);
let e2 = fs::remove_file(&cpx_path);
let e3 = fs::remove_dir_all(&cpldb_path);
let e4 = fs::remove_file(&cpc_path);
// Ignore NotFound errors
for e in [e1, e2, e3, e4] {
if let Err(e) = e {
if e.kind() != io::ErrorKind::NotFound {
return Err(e.into());
}
}
}
Ok(())
}
/// Read any new needles appended during compaction and append them to .cpd/.cpx
fn makeup_diff(&mut self) -> Result<(), VolumeError> {
let old_idx_size = self.nm.as_ref().map_or(0, |nm| nm.index_file_size());
if old_idx_size == 0 || old_idx_size <= self.last_compact_index_offset {
return Ok(());
}
let old_super_block = &self.super_block;
if old_super_block.compaction_revision != self.last_compact_revision {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::Other,
format!(
"current old dat file's compact revision {} is not the expected one {}",
old_super_block.compaction_revision, self.last_compact_revision
),
)));
}
// Read the new .cpd file's super block and verify its compaction revision is old + 1
let cpd_path_check = self.file_name(".cpd");
let mut cpd_file_check = File::open(&cpd_path_check)?;
let mut sb_buf = [0u8; SUPER_BLOCK_SIZE];
cpd_file_check.read_exact(&mut sb_buf)?;
let new_super_block = SuperBlock::from_bytes(&sb_buf)?;
let old_compact_revision = old_super_block.compaction_revision;
let new_compact_revision = new_super_block.compaction_revision;
if old_compact_revision + 1 != new_compact_revision {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::Other,
format!(
"old dat file's compact revision {} + 1 does not equal new dat file's compact revision {}",
old_compact_revision, new_compact_revision
),
)));
}
let old_idx_path = self.file_name(".idx");
let mut old_idx_file = File::open(&old_idx_path)?;
// Read new entries from .idx
let mut incremented_entries = std::collections::HashMap::new();
let offset = self.last_compact_index_offset;
old_idx_file.seek(SeekFrom::Start(offset))?;
let entry_count = (old_idx_size - offset) / NEEDLE_MAP_ENTRY_SIZE as u64;
for _ in 0..entry_count {
let mut buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
old_idx_file.read_exact(&mut buf)?;
let (key, needle_offset, size) = crate::storage::types::idx_entry_from_bytes(&buf);
incremented_entries.insert(key, (needle_offset, size));
}
if incremented_entries.is_empty() {
return Ok(());
}
let cpd_path = self.file_name(".cpd");
let cpx_path = self.file_name(".cpx");
let mut dst_dat = OpenOptions::new().read(true).write(true).open(&cpd_path)?;
let mut dst_idx = OpenOptions::new()
.write(true)
.append(true)
.open(&cpx_path)?;
let mut dat_offset = dst_dat.seek(SeekFrom::End(0))?;
let padding_rem = dat_offset % NEEDLE_PADDING_SIZE as u64;
if padding_rem != 0 {
dat_offset += NEEDLE_PADDING_SIZE as u64 - padding_rem;
dst_dat.seek(SeekFrom::Start(dat_offset))?;
}
let version = self.version();
let old_dat_path = self.file_name(".dat");
let old_dat_file = File::open(&old_dat_path)?;
for (key, (needle_offset, size)) in incremented_entries {
if !needle_offset.is_zero() && !size.is_deleted() && size.0 > 0 {
let actual_size = crate::storage::needle::needle::get_actual_size(size, version);
let mut blob = vec![0u8; actual_size as usize];
#[cfg(unix)]
{
use std::os::unix::fs::FileExt;
old_dat_file
.read_exact_at(&mut blob, needle_offset.to_actual_offset() as u64)?;
}
#[cfg(windows)]
{
crate::storage::volume::read_exact_at(
&old_dat_file,
&mut blob,
needle_offset.to_actual_offset() as u64,
)?;
}
dst_dat.write_all(&blob)?;
let mut idx_entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
crate::storage::types::idx_entry_to_bytes(
&mut idx_entry_buf,
key,
Offset::from_actual_offset(dat_offset as i64),
size,
);
dst_idx.write_all(&idx_entry_buf)?;
dat_offset += actual_size as u64;
} else {
let mut fake_del_needle = Needle {
id: key,
cookie: Cookie(0x12345678),
append_at_ns: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos() as u64,
..Needle::default()
};
let bytes = fake_del_needle.write_bytes(version);
dst_dat.write_all(&bytes)?;
// Record the tombstone's real .dat offset, like the normal delete path,
// so a deletion left at the .dat tail stays visible to the integrity
// check on reload. Offset 0 hid the trailing tombstone and falsely
// flipped the volume read-only.
let mut idx_entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
crate::storage::types::idx_entry_to_bytes(
&mut idx_entry_buf,
key,
Offset::from_actual_offset(dat_offset as i64),
Size(crate::storage::types::TOMBSTONE_FILE_SIZE.into()),
);
dst_idx.write_all(&idx_entry_buf)?;
dat_offset += bytes.len() as u64;
}
}
dst_dat.sync_all()?;
dst_idx.sync_all()?;
Ok(())
}
// ---- Sync / Close ----
pub fn sync_to_disk(&mut self) -> io::Result<()> {
if let Some(ref dat_file) = self.dat_file {
dat_file.sync_all()?;
}
if let Some(ref nm) = self.nm {
nm.sync()?;
}
Ok(())
}
pub fn close(&mut self) {
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;
// 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;
}
/// Move this volume's `.idx` (and `.sdx` when present) from its current
/// index directory into `new_idx_dir`, then reload in place. Mirrors Go's
/// `Volume::RelocateIndexTo`: it is a no-op when the index is already there
/// or nothing is co-located to move, and is used to pull an index a decode
/// or reconstruct left beside the data back into the configured `-dir.idx`.
pub fn relocate_index_to(&mut self, new_idx_dir: &str) -> Result<(), VolumeError> {
let _guard = self.data_file_access_control.write_lock();
if self.dir_idx == new_idx_dir {
return Ok(());
}
let old_base = volume_file_name(&self.dir_idx, &self.collection, self.id);
let old_idx = format!("{old_base}.idx");
if !Path::new(&old_idx).exists() {
return Ok(()); // nothing co-located to move
}
let new_base = volume_file_name(new_idx_dir, &self.collection, self.id);
let new_idx = format!("{new_base}.idx");
// Close the index and data handles before moving the index file.
let version = self.version();
self.close();
if let Err(e) = rename_or_copy(&old_idx, &new_idx) {
// Reopen against the old dir so the volume is not left down; surface
// a failed reopen since it leaves the volume unusable until reload.
if let Err(reopen) = self.load(true, false, 0, version) {
error!(
"relocate volume {}: reopen after failed .idx move: {}",
self.id, reopen
);
}
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::Other,
format!("relocate index for volume {}: move .idx: {e}", self.id),
)));
}
// The .sdx is a derived sorted index; move it when present, but a
// failure is not fatal — drop the stale copy so a reload rebuilds it.
let old_sdx = format!("{old_base}.sdx");
if Path::new(&old_sdx).exists() {
let new_sdx = format!("{new_base}.sdx");
if let Err(e) = rename_or_copy(&old_sdx, &new_sdx) {
warn!(
"relocate volume {}: move .sdx: {} (will rebuild)",
self.id, e
);
let _ = fs::remove_file(&old_sdx);
}
}
self.dir_idx = new_idx_dir.to_string();
self.load(true, false, 0, version)
}
/// Remove all volume files from disk.
/// Destroy removes everything related to this volume. When keep_remote_data
/// is true the cloud-tier object backing the volume is left intact — used
/// by moves where another server is taking over the same .vif.
pub fn destroy(&mut self, only_empty: bool, keep_remote_data: bool) -> Result<(), VolumeError> {
if only_empty && self.file_count() > 0 {
return Err(VolumeError::NotEmpty);
}
if self.is_compacting {
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::Other,
format!("volume {} is compacting", self.id),
)));
}
let (storage_name, storage_key) = self.remote_storage_name_key();
if !keep_remote_data
&& self.has_remote_file
&& !storage_name.is_empty()
&& !storage_key.is_empty()
{
let backend = crate::remote_storage::s3_tier::global_s3_tier_registry()
.read()
.unwrap()
.get(&storage_name);
if let Some(backend) = backend {
if let Err(e) = backend.delete_file_blocking(&storage_key) {
warn!(
volume_id = self.id.0,
storage_name,
storage_key,
error = %e,
"failed to delete remote tier file during destroy"
);
}
} else {
warn!(
volume_id = self.id.0,
storage_name, storage_key, "remote tier backend not found during destroy"
);
}
}
// A regular volume and an EC volume for the same id share <base>.vif.
// When EC artefacts coexist on this disk (e.g. shards distributed onto
// a source replica before it is deleted), keep the .vif so removing the
// regular volume does not strip the EC volume's info file.
let keep_vif = self.shares_vif_with_ec_volume();
self.close();
remove_volume_files(&self.data_file_name(), keep_vif);
remove_volume_files(&self.index_file_name(), keep_vif);
Ok(())
}
/// Reports whether an EC volume for this id has a sealed .ecx on the same
/// disk, in which case its .vif is the same file as the regular volume's
/// and must outlive the regular volume's deletion. Mirrors the on-disk
/// portion of Go's Volume.sharesVifWithEcVolume / HasEcxFileOnDisk.
fn shares_vif_with_ec_volume(&self) -> bool {
let has_ecx = |base: &str| -> bool {
fs::metadata(format!("{}.ecx", base))
.map(|m| !m.is_dir() && m.len() > 0)
.unwrap_or(false)
};
if has_ecx(&volume_file_name(&self.dir_idx, &self.collection, self.id)) {
return true;
}
self.dir != self.dir_idx
&& has_ecx(&volume_file_name(&self.dir, &self.collection, self.id))
}
/// Check if an I/O error is EIO (errno 5) and record it for health monitoring.
/// On success (None), clears any previously recorded EIO error.
/// Matches Go's `checkReadWriteError` in volume_write.go.
fn check_read_write_error(&self, err: Option<&io::Error>) {
if let Some(e) = err {
if e.raw_os_error() == Some(5) {
// EIO — record it
if let Ok(mut guard) = self.last_io_error.lock() {
*guard = Some(e.to_string());
}
}
} else {
// Success — clear any previous EIO
if let Ok(mut guard) = self.last_io_error.lock() {
if guard.is_some() {
*guard = None;
}
}
}
}
/// Returns the last recorded I/O error string, if any.
#[allow(dead_code)]
pub fn last_io_error(&self) -> Option<String> {
self.last_io_error.lock().ok()?.clone()
}
#[cfg(test)]
pub(crate) fn set_last_io_error_for_test(&self, err: Option<&str>) {
if let Ok(mut guard) = self.last_io_error.lock() {
*guard = err.map(|value| value.to_string());
}
}
#[cfg(test)]
pub(crate) fn fail_next_fsync_for_test(&mut self, fail: bool) {
self.fail_fsync_for_test = fail;
}
#[cfg(test)]
pub(crate) fn fail_next_idx_sync_for_test(&mut self, fail: bool) {
self.fail_idx_sync_for_test = fail;
}
#[cfg(test)]
pub(crate) fn set_last_modified_ts_for_test(&mut self, ts_seconds: u64) {
self.last_modified_ts_seconds = ts_seconds;
}
}
// ============================================================================
// Helpers
// ============================================================================
/// Generate volume file base name: dir/collection_id or dir/id
/// Byte offset just past the needle's on-disk record. Deletion tombstones
/// carry TombstoneFileSize (-1) in the .idx but are written with DataSize=0,
/// so their on-disk record is sized as 0. Mirrors Go's needleDiskEnd.
pub(crate) fn needle_disk_end(offset: Offset, size: Size, version: Version) -> i64 {
let on_disk_size = if size.is_deleted() { Size(0) } else { size };
offset.to_actual_offset() + get_actual_size(on_disk_size, version)
}
fn size_mismatch_error(offset: i64, id: NeedleId, found: Size, expected: Size) -> VolumeError {
VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"entry not found: offset {} found id {:?} size {}, expected size {}",
offset, id, found.0, expected.0
),
))
}
/// Drop a partial row from the end of `.idx` and return the remaining length.
///
/// `.idx` is a run of fixed-size rows, and writers append at EOF: a short write
/// leaves a partial row behind, and every row appended after it lands off
/// alignment, so the next load parses the rest of the file as garbage. The
/// partial row is unrecoverable either way — every loader already skips it — so
/// removing it before anything can append is what keeps the file parseable.
/// Go instead refuses to load a volume whose `.idx` is not a whole number of
/// rows; trimming keeps it mountable, and the rows before the tear are intact.
fn trim_torn_idx_tail(idx_file: &File, idx_path: &str) -> Result<u64, VolumeError> {
let size = idx_file.metadata()?.len();
let aligned = size - size % NEEDLE_MAP_ENTRY_SIZE as u64;
if aligned != size {
warn!(
idx = %idx_path,
size, aligned, "dropping a partial row from the end of the index"
);
idx_file.set_len(aligned)?;
idx_file.sync_all()?;
}
Ok(aligned)
}
pub fn volume_file_name(dir: &str, collection: &str, id: VolumeId) -> String {
if collection.is_empty() {
format!("{}/{}", dir, id.0)
} else {
format!("{}/{}_{}", dir, collection, id.0)
}
}
/// Move `src` to `dst`, falling back to copy+remove when the two live on
/// different filesystems (a real `-dir.idx` split). Mirrors Go's
/// `RenameOrCopyFile`: any rename error other than cross-device is fatal, and a
/// failed copy or a source that cannot be removed rolls the copy back so a
/// failure never leaves two divergent files.
fn rename_or_copy(src: &str, dst: &str) -> io::Result<()> {
match fs::rename(src, dst) {
Ok(()) => return Ok(()),
Err(e) if e.raw_os_error() != Some(libc::EXDEV) => return Err(e),
Err(_) => {}
}
fs::copy(src, dst)?;
// fsync the destination before dropping the source, matching Go's out.Sync().
if let Err(e) = OpenOptions::new()
.write(true)
.open(dst)
.and_then(|f| f.sync_all())
{
let _ = fs::remove_file(dst);
return Err(e);
}
if let Err(e) = fs::remove_file(src) {
let _ = fs::remove_file(dst);
return Err(e);
}
Ok(())
}
/// Generate a monotonically increasing append timestamp.
fn get_append_at_ns(last: u64) -> u64 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
if now <= last {
last + 1
} else {
now
}
}
/// Remove all files associated with a volume.
/// .dat/.idx removals log at info level so destructive calls are traceable.
/// fsync the parent directory of `path` so a rename/create/unlink inside it is
/// durable, propagating a sync failure so the commit path can abort rather than
/// proceed with an undurable rename or marker. A path with no openable parent is
/// tolerated; directory fsync is unsupported on Windows, so it is a no-op there
/// (matching the Go fsyncDir helper, which skips it too).
pub(crate) fn fsync_dir(path: &str) -> io::Result<()> {
#[cfg(windows)]
{
let _ = path;
Ok(())
}
#[cfg(not(windows))]
{
if let Some(parent) = Path::new(path).parent() {
if let Ok(d) = File::open(parent) {
return d.sync_all();
}
}
Ok(())
}
}
pub(crate) fn remove_volume_files(base: &str, keep_vif: bool) {
for ext in &[
".dat", ".idx", ".vif", ".sdx", ".cpd", ".cpx", ".cpc", ".note", ".rdb",
] {
if *ext == ".vif" && keep_vif {
continue;
}
let path = format!("{}{}", base, ext);
let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
let existed = fs::remove_file(&path).is_ok();
if existed && (*ext == ".dat" || *ext == ".idx") {
tracing::info!("removed volume file {} (size={})", path, size);
}
}
// leveldb uses a directory
let _ = fs::remove_dir_all(format!("{}.ldb", base));
}
// ============================================================================
// ScanVolumeFile — iterate all needles in a .dat file
// ============================================================================
/// Callback for scanning needles in a volume file.
pub trait VolumeFileVisitor {
fn visit_super_block(&mut self, sb: &SuperBlock) -> Result<(), VolumeError>;
fn read_needle_body(&self) -> bool;
fn visit_needle(&mut self, n: &Needle, offset: i64) -> Result<(), VolumeError>;
}
/// Scan all needles in a volume's .dat file.
pub fn scan_volume_file(
dat_path: &str,
visitor: &mut dyn VolumeFileVisitor,
) -> Result<(), VolumeError> {
let mut file = File::open(dat_path)?;
// Read super block
let mut sb_buf = [0u8; SUPER_BLOCK_SIZE];
file.read_exact(&mut sb_buf)?;
let sb = SuperBlock::from_bytes(&sb_buf)?;
visitor.visit_super_block(&sb)?;
let version = sb.version;
let mut offset = sb.block_size() as i64;
loop {
// Read needle header
let mut header = [0u8; NEEDLE_HEADER_SIZE];
file.seek(SeekFrom::Start(offset as u64))?;
match file.read_exact(&mut header) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
Err(e) => return Err(e.into()),
}
let (_cookie, _id, size) = Needle::parse_header(&header);
if size.0 == 0 && _id.is_empty() {
break; // end of valid data
}
// A negative size is a corrupt header, and body_length would advance the
// walk backwards from it. Go's scanners stop here by returning io.EOF.
if size.0 < 0 {
break;
}
let body_length = needle::needle_body_length(size, version);
let total_size = NEEDLE_HEADER_SIZE as i64 + body_length;
// Skip full body parsing for deleted needles (tombstone or negative size)
if size.is_deleted() || size.0 <= 0 {
let mut n = Needle::default();
n.read_header(&header);
visitor.visit_needle(&n, offset)?;
} else if visitor.read_needle_body() {
let mut buf = vec![0u8; total_size as usize];
file.seek(SeekFrom::Start(offset as u64))?;
file.read_exact(&mut buf)?;
let mut n = Needle::default();
n.read_bytes(&buf, offset, size, version)?;
visitor.visit_needle(&n, offset)?;
} else {
let mut n = Needle::default();
n.read_header(&header);
visitor.visit_needle(&n, offset)?;
}
offset += total_size;
}
Ok(())
}
/// Reserve disk blocks for a file without changing its visible size.
/// On Linux, uses `fallocate(FALLOC_FL_KEEP_SIZE)` to actually reserve blocks.
/// On other platforms, this is a no-op.
fn preallocate_file(file: &File, size: u64) {
#[cfg(target_os = "linux")]
{
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
// FALLOC_FL_KEEP_SIZE = 1: allocate blocks without changing file size
let ret = unsafe { libc::fallocate(fd, 1, 0, size as libc::off_t) };
if ret == 0 {
tracing::info!(bytes = size, "preallocated disk space");
} else {
tracing::warn!(
bytes = size,
error = %io::Error::last_os_error(),
"fallocate failed"
);
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = (file, size);
tracing::debug!(bytes = size, "preallocation not supported on this platform");
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::needle::crc::CRC;
use tempfile::TempDir;
fn spawn_fake_s3_server(body: Vec<u8>) -> (String, tokio::sync::oneshot::Sender<()>) {
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::routing::any;
use axum::Router;
let body = Arc::new(body);
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
listener.set_nonblocking(true).unwrap();
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>();
std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async move {
let app = Router::new().fallback(any(move |headers: HeaderMap| {
let body = body.clone();
async move {
let bytes = body.as_ref();
if let Some(range) = headers
.get(header::RANGE)
.and_then(|value| value.to_str().ok())
{
if let Some(spec) = range.strip_prefix("bytes=") {
let (start, end) = spec.split_once('-').unwrap();
let start = start.parse::<usize>().unwrap();
let end = if end.is_empty() {
bytes.len().saturating_sub(1)
} else {
end.parse::<usize>().unwrap()
}
.min(bytes.len().saturating_sub(1));
let chunk = bytes[start..=end].to_vec();
let mut response_headers = HeaderMap::new();
response_headers.insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&chunk.len().to_string()).unwrap(),
);
response_headers.insert(
header::CONTENT_RANGE,
HeaderValue::from_str(&format!(
"bytes {}-{}/{}",
start,
end,
bytes.len()
))
.unwrap(),
);
return (StatusCode::PARTIAL_CONTENT, response_headers, chunk);
}
}
let mut response_headers = HeaderMap::new();
response_headers.insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&bytes.len().to_string()).unwrap(),
);
(StatusCode::OK, response_headers, bytes.to_vec())
}
}));
let listener = tokio::net::TcpListener::from_std(listener).unwrap();
let _ = ready_tx.send(());
axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await
.unwrap();
});
});
// Wait for the server thread to be ready before returning.
ready_rx.recv().unwrap();
(format!("http://{}", addr), shutdown_tx)
}
fn make_test_volume(dir: &str) -> Volume {
Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap()
}
#[test]
fn test_data_file_access_control_blocks_writer_until_reader_releases() {
let control = Arc::new(DataFileAccessControl::default());
let read_lease = control.read_lock();
let writer_control = control.clone();
let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let acquired_clone = acquired.clone();
let writer = std::thread::spawn(move || {
let _write_lease = writer_control.write_lock();
acquired_clone.store(true, std::sync::atomic::Ordering::Relaxed);
});
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(!acquired.load(std::sync::atomic::Ordering::Relaxed));
drop(read_lease);
writer.join().unwrap();
assert!(acquired.load(std::sync::atomic::Ordering::Relaxed));
}
#[test]
fn test_volume_file_name() {
assert_eq!(volume_file_name("/data", "", VolumeId(1)), "/data/1");
assert_eq!(
volume_file_name("/data", "pics", VolumeId(42)),
"/data/pics_42"
);
}
#[test]
fn test_volume_create_and_load() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let v = make_test_volume(dir);
assert_eq!(v.version(), VERSION_3);
assert_eq!(v.file_count(), 0);
assert_eq!(v.content_size(), 0);
// .dat and .idx files should exist
assert!(Path::new(&v.file_name(".dat")).exists());
assert!(Path::new(&v.file_name(".idx")).exists());
}
#[test]
fn test_volume_write_read() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
// Write a needle
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0x12345678),
data: b"hello world".to_vec(),
data_size: 11,
flags: 0,
..Needle::default()
};
let (offset, size, unchanged) = v.write_needle(&mut n, true, false).unwrap();
assert!(!unchanged);
assert!(offset > 0); // after superblock
assert!(size.0 > 0);
assert_eq!(v.file_count(), 1);
// Read it back
let mut read_n = Needle {
id: NeedleId(1),
..Needle::default()
};
let count = v.read_needle(&mut read_n).unwrap();
assert_eq!(count, 11);
assert_eq!(read_n.data, b"hello world");
assert_eq!(read_n.cookie, Cookie(0x12345678));
}
/// A durable write goes down the same path and lands the same data; the
/// flush is extra work, not different work.
#[test]
fn test_volume_write_needle_fsync() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0x12345678),
data: b"durable payload".to_vec(),
data_size: 15,
flags: 0,
..Needle::default()
};
let (offset, _, unchanged) = v.write_needle(&mut n, true, true).unwrap();
assert!(!unchanged);
assert!(offset > 0);
let mut read_n = Needle {
id: NeedleId(1),
..Needle::default()
};
assert_eq!(v.read_needle(&mut read_n).unwrap(), 15);
assert_eq!(read_n.data, b"durable payload");
// rewriting the same content still dedups, so there is nothing to flush
let mut same = Needle {
id: NeedleId(1),
cookie: Cookie(0x12345678),
data: b"durable payload".to_vec(),
data_size: 15,
flags: 0,
..Needle::default()
};
let (_, _, unchanged) = v.write_needle(&mut same, true, true).unwrap();
assert!(unchanged);
}
/// A durable write whose flush fails must leave nothing behind: the append
/// comes off the .dat and the mapping it would have replaced still stands,
/// with the volume's own accounting untouched.
#[test]
fn test_write_needle_failed_fsync_keeps_prior_mapping() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let mut kept = Needle {
id: NeedleId(1),
cookie: Cookie(0xaa),
data: b"first-copy".to_vec(),
data_size: 10,
..Needle::default()
};
v.write_needle(&mut kept, true, true).unwrap();
let prior = v.nm.as_ref().unwrap().get(NeedleId(1)).unwrap().unwrap();
let dat_len_before = std::fs::metadata(v.file_name(".dat")).unwrap().len();
let file_count_before = v.file_count();
let content_size_before = v.content_size();
v.fail_next_fsync_for_test(true);
let mut replacement = Needle {
id: NeedleId(1),
cookie: Cookie(0xaa),
data: b"second-copy".to_vec(),
data_size: 11,
..Needle::default()
};
v.write_needle(&mut replacement, true, true).unwrap_err();
v.fail_next_fsync_for_test(false);
assert_eq!(
std::fs::metadata(v.file_name(".dat")).unwrap().len(),
dat_len_before,
"the unflushed append should be off the .dat"
);
let now = v.nm.as_ref().unwrap().get(NeedleId(1)).unwrap().unwrap();
assert_eq!(
now.offset, prior.offset,
"the mapping should never have moved"
);
assert_eq!(now.size, prior.size);
assert_eq!(
v.file_count(),
file_count_before,
"a rejected write must not count towards the volume"
);
assert_eq!(v.content_size(), content_size_before);
let mut read_n = Needle {
id: NeedleId(1),
..Needle::default()
};
assert_eq!(v.read_needle(&mut read_n).unwrap(), 10);
assert_eq!(read_n.data, b"first-copy");
}
/// A durable write that dedups against an earlier non-durable one still has
/// to flush the index. The row that earlier write left behind can be sitting
/// in the page cache, so acking without flushing is the same false promise
/// as skipping the flush on a fresh append.
#[test]
fn test_dedup_durable_write_flushes_the_index() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let mut first = Needle {
id: NeedleId(1),
cookie: Cookie(0xaa),
data: b"same-content".to_vec(),
data_size: 12,
..Needle::default()
};
v.write_needle(&mut first, true, false).unwrap();
v.fail_next_idx_sync_for_test(true);
let mut same = Needle {
id: NeedleId(1),
cookie: Cookie(0xaa),
data: b"same-content".to_vec(),
data_size: 12,
..Needle::default()
};
let result = v.write_needle(&mut same, true, true);
v.fail_next_idx_sync_for_test(false);
assert!(
result.is_err(),
"a dedup hit must not be acked without flushing the index"
);
assert!(
v.is_read_only(),
"and the failed index flush quarantines the volume, dedup or not"
);
}
/// A durable write whose index flush fails leaves the .dat record down and
/// its row published but not guaranteed to come back. The volume stops
/// taking writes rather than appending more past a record whose index may
/// not survive a restart.
#[test]
fn test_write_needle_failed_idx_sync_quarantines_volume() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
v.fail_next_idx_sync_for_test(true);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0xaa),
data: b"index-never-flushed".to_vec(),
data_size: 19,
..Needle::default()
};
v.write_needle(&mut n, true, true).unwrap_err();
v.fail_next_idx_sync_for_test(false);
assert!(
v.is_read_only(),
"a volume whose index flush failed must stop taking writes"
);
let mut later = Needle {
id: NeedleId(2),
cookie: Cookie(0xbb),
data: b"should-be-refused".to_vec(),
data_size: 17,
..Needle::default()
};
assert!(
matches!(
v.write_needle(&mut later, true, false),
Err(VolumeError::ReadOnly)
),
"later writes must not append past the record whose index is in doubt"
);
}
/// Same for a needle the failed write introduced: it never becomes visible,
/// rather than being published and then tombstoned back out.
#[test]
fn test_write_needle_failed_fsync_publishes_nothing() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let dat_len_before = std::fs::metadata(v.file_name(".dat")).unwrap().len();
let file_count_before = v.file_count();
v.fail_next_fsync_for_test(true);
let mut fresh = Needle {
id: NeedleId(7),
cookie: Cookie(0x77),
data: b"never-landed".to_vec(),
data_size: 12,
..Needle::default()
};
v.write_needle(&mut fresh, true, true).unwrap_err();
v.fail_next_fsync_for_test(false);
assert_eq!(
std::fs::metadata(v.file_name(".dat")).unwrap().len(),
dat_len_before
);
assert!(
v.nm.as_ref().unwrap().get(NeedleId(7)).unwrap().is_none(),
"a needle that never reached the disk must not be indexed at all"
);
assert_eq!(v.file_count(), file_count_before);
assert_eq!(
v.deleted_count(),
0,
"nothing was written, so nothing was deleted"
);
let mut read_n = Needle {
id: NeedleId(7),
..Needle::default()
};
assert!(v.read_needle(&mut read_n).is_err());
}
#[test]
fn test_volume_write_dedup() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0xaa),
data: b"same data".to_vec(),
data_size: 9,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
// Write same needle again — should be unchanged
let mut n2 = Needle {
id: NeedleId(1),
cookie: Cookie(0xaa),
data: b"same data".to_vec(),
data_size: 9,
..Needle::default()
};
n2.checksum = CRC::new(&n2.data);
let (_, _, unchanged) = v.write_needle(&mut n2, true, false).unwrap();
assert!(unchanged);
}
#[test]
fn test_volume_delete() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0xbb),
data: b"delete me".to_vec(),
data_size: 9,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
assert_eq!(v.file_count(), 1);
let deleted_size = v
.delete_needle(&mut Needle {
id: NeedleId(1),
cookie: Cookie(0xbb),
..Needle::default()
})
.unwrap();
assert!(deleted_size.0 > 0);
// Additive-only: file_count stays at 1 after delete
assert_eq!(v.file_count(), 1);
assert_eq!(v.deleted_count(), 1);
// Read should fail with Deleted
let mut read_n = Needle {
id: NeedleId(1),
..Needle::default()
};
let err = v.read_needle(&mut read_n).unwrap_err();
assert!(matches!(err, VolumeError::Deleted));
}
// Guard the Rust integrity-check tombstone path against the Go regression
// where verifyDeletedNeedleIntegrity forwarded TombstoneFileSize into the
// needle-size check, mismatched against the on-disk Size=0 header, and
// sent every volume with a trailing deletion read-only on load. The Rust
// check guards its size comparison with !size.is_deleted(); this test
// keeps that guarantee from silently regressing.
#[test]
fn test_check_volume_data_integrity_with_deletion_tombstone() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
{
let mut v = make_test_volume(dir);
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();
}
v.delete_needle(&mut Needle {
id: NeedleId(2),
cookie: Cookie(2),
..Needle::default()
})
.unwrap();
v.sync_to_disk().unwrap();
}
let v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert!(
!v.is_no_write_or_delete(),
"volume should not be read-only after reload with trailing deletion tombstone"
);
}
// Reproduce the delete-traffic TTL bug: deletes append a tombstone to the
// .dat, which moves the file's mtime, and the loader read the TTL clock
// back from that mtime. A volume taking delete traffic therefore had
// is_expired() re-armed for another full TTL on every restart and was
// never reclaimed.
#[test]
fn test_ttl_clock_survives_deletes() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let ttl = crate::storage::needle::ttl::TTL::read("5m").unwrap();
let last_write_ns = (SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
- 2 * 60 * 60)
* 1_000_000_000;
let content_size = {
let mut v = make_ttl_volume(dir, ttl);
let mut written = Vec::new();
for i in 1..=3u64 {
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()
};
let (offset, _, _) = v.write_needle(&mut n, true, false).unwrap();
written.push((offset, n.size));
}
// More than one tombstone: the scan has to walk back over the whole
// run of them to reach a write.
for i in [2u64, 3] {
v.delete_needle(&mut Needle {
id: NeedleId(i),
cookie: Cookie(i as u32),
..Needle::default()
})
.unwrap();
}
v.sync_to_disk().unwrap();
// Backdate the writes on disk so the last one sits well outside the
// TTL, while the tombstones leave the .dat mtime at now.
for (offset, size) in written {
backdate_append_at_ns(&v.dat_path(), offset, size, last_write_ns);
}
v.content_size()
};
let v = make_ttl_volume(dir, ttl);
assert_eq!(v.last_modified_ts(), last_write_ns / 1_000_000_000);
assert!(
v.is_expired(content_size, 1024 * 1024),
"a TTL volume whose last write is 2h old must be expired after a reload"
);
}
// The one layout where a .dat's order does not track its write order:
// vacuum rewrites it by key, and an overwrite keeps its original, lower key.
// Reading the position rather than the timestamps would recover the
// highest-key needle's older write time and expire the volume before the
// overwrite has lived out its TTL.
#[test]
fn test_ttl_clock_after_vacuum_takes_newest_write() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let ttl = crate::storage::needle::ttl::TTL::read("5m").unwrap();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let old_write_ns = (now - 2 * 60 * 60) * 1_000_000_000;
let new_write_ns = (now - 60) * 1_000_000_000;
let content_size = {
let mut v = make_ttl_volume(dir, ttl);
// Needle 1 is overwritten last but sorts first, so vacuum leaves it
// at the head of the .dat with the newest timestamp of the three.
for (id, append_at_ns) in [(2u64, old_write_ns), (3, old_write_ns), (1, new_write_ns)] {
let data = format!("data {}", id);
let mut n = Needle {
id: NeedleId(id),
cookie: Cookie(id as u32),
data: data.as_bytes().to_vec(),
data_size: data.len() as u32,
..Needle::default()
};
let (offset, _, _) = v.write_needle(&mut n, true, false).unwrap();
v.sync_to_disk().unwrap();
backdate_append_at_ns(&v.dat_path(), offset, n.size, append_at_ns);
}
v.compact_by_index(0, 0, |_| true).unwrap();
v.commit_compact().unwrap();
v.content_size()
};
let v = make_ttl_volume(dir, ttl);
assert_eq!(v.last_modified_ts(), new_write_ns / 1_000_000_000);
assert!(
!v.is_expired(content_size, 1024 * 1024),
"a volume overwritten a minute ago must not be expired after a vacuum and reload"
);
}
// Guard the destroy time an EC volume is reclaimed on: it was recomputed as
// now+TTL every time the .vif was written, so a read-only mark, a tier
// upload or an EC encode handed an already expiring volume another full TTL.
#[test]
fn test_expire_at_sec_counts_from_last_write() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let ttl = crate::storage::needle::ttl::TTL::read("5m").unwrap();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let mut v = make_ttl_volume(dir, ttl);
// A volume with nothing written yet has no last write to count from, and
// must not land in 1970 with its data due for destruction on sight.
assert!(v.expire_at_sec() >= now);
v.set_last_modified_ts_for_test(now - 3600);
let want = now - 3600 + ttl.to_seconds();
for pass in 0..2 {
v.save_volume_info().unwrap();
assert_eq!(
v.volume_info.expire_at_sec, want,
".vif save {} moved the destroy time off the last write",
pass
);
}
}
fn make_ttl_volume(dir: &str, ttl: crate::storage::needle::ttl::TTL) -> Volume {
Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
Some(ttl),
0,
Version::current(),
)
.unwrap()
}
fn backdate_append_at_ns(dat_path: &str, offset: u64, size: Size, append_at_ns: u64) {
let ts_offset =
offset + NEEDLE_HEADER_SIZE as u64 + size.0 as u64 + NEEDLE_CHECKSUM_SIZE as u64;
let mut f = OpenOptions::new().write(true).open(dat_path).unwrap();
f.seek(SeekFrom::Start(ts_offset)).unwrap();
f.write_all(&append_at_ns.to_be_bytes()).unwrap();
}
fn write_three_needles(dir: &str) {
let mut v = make_test_volume(dir);
for i in 1..=3u64 {
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();
}
v.sync_to_disk().unwrap();
}
fn reload_volume(dir: &str) -> Volume {
Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap()
}
// A torn shutdown can leave bytes in the .dat past the last indexed
// needle. Appending after such a tail would place the next needle at an
// offset the 8-byte-unit .idx encoding may not represent, so the load
// check must quarantine the volume read-only, matching Go's
// verifyNeedleIntegrity "actual N bytes expected M bytes" behavior.
#[test]
fn test_integrity_detects_unindexed_dat_tail() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
write_three_needles(dir);
let dat_path = volume_file_name(dir, "", VolumeId(1)) + ".dat";
let mut f = OpenOptions::new().append(true).open(&dat_path).unwrap();
f.write_all(&[0xFFu8; 100]).unwrap();
f.sync_all().unwrap();
drop(f);
let v = reload_volume(dir);
assert!(
v.is_no_write_or_delete(),
"volume with unindexed .dat tail must load read-only"
);
}
// Go's tail check is v3-only, so it serves a v1/v2 volume with an
// unindexed tail read-write. Checking it here flipped whole legacy disks
// read-only on their first boot under this server.
#[test]
fn test_integrity_skips_dat_tail_check_before_v3() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
{
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
VERSION_2,
)
.unwrap();
for i in 1..=3u64 {
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();
}
v.sync_to_disk().unwrap();
}
let dat_path = volume_file_name(dir, "", VolumeId(1)) + ".dat";
let mut f = OpenOptions::new().append(true).open(&dat_path).unwrap();
f.write_all(&[0xFFu8; 96]).unwrap();
f.sync_all().unwrap();
drop(f);
let v = reload_volume(dir);
assert_eq!(v.version(), VERSION_2, "volume should reload as v2");
assert!(
!v.is_no_write_or_delete(),
"v2 volume with unindexed .dat tail must stay writable, as in Go"
);
}
// Same torn tail, but with a deletion tombstone as the last indexed
// record — the tombstone path must also verify the .dat ends at it.
#[test]
fn test_integrity_detects_tail_after_tombstone() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
{
let mut v = make_test_volume(dir);
let data = b"data 1".to_vec();
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(1),
data: data.clone(),
data_size: data.len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.delete_needle(&mut Needle {
id: NeedleId(1),
cookie: Cookie(1),
..Needle::default()
})
.unwrap();
v.sync_to_disk().unwrap();
}
let dat_path = volume_file_name(dir, "", VolumeId(1)) + ".dat";
let mut f = OpenOptions::new().append(true).open(&dat_path).unwrap();
f.write_all(&[0xFFu8; 64]).unwrap();
f.sync_all().unwrap();
drop(f);
let v = reload_volume(dir);
assert!(
v.is_no_write_or_delete(),
"volume with unindexed tail after tombstone must load read-only"
);
}
// A key-sorted .idx (legacy weed fix output) puts the highest-key needle
// last by file position, not the .dat-tail needle. The check must find
// the true tail entry via the max-offset scan instead of falsely flipping
// a healthy volume read-only.
#[test]
fn test_integrity_key_sorted_idx_no_false_readonly() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
write_three_needles(dir);
// Swap the last two .idx entries so the file-position-last entry
// references a mid-file needle.
let idx_path = volume_file_name(dir, "", VolumeId(1)) + ".idx";
let content = fs::read(&idx_path).unwrap();
assert_eq!(content.len(), 3 * NEEDLE_MAP_ENTRY_SIZE);
let mut swapped = content.clone();
swapped[NEEDLE_MAP_ENTRY_SIZE..2 * NEEDLE_MAP_ENTRY_SIZE]
.copy_from_slice(&content[2 * NEEDLE_MAP_ENTRY_SIZE..]);
swapped[2 * NEEDLE_MAP_ENTRY_SIZE..]
.copy_from_slice(&content[NEEDLE_MAP_ENTRY_SIZE..2 * NEEDLE_MAP_ENTRY_SIZE]);
fs::write(&idx_path, swapped).unwrap();
let v = reload_volume(dir);
assert!(
!v.is_no_write_or_delete(),
"healthy volume with key-sorted .idx must not load read-only"
);
}
// A .dat truncated mid-needle (crash during append) must quarantine.
#[test]
fn test_integrity_detects_truncated_dat_tail() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
write_three_needles(dir);
let dat_path = volume_file_name(dir, "", VolumeId(1)) + ".dat";
let len = fs::metadata(&dat_path).unwrap().len();
let f = OpenOptions::new().write(true).open(&dat_path).unwrap();
f.set_len(len - 5).unwrap();
f.sync_all().unwrap();
drop(f);
let v = reload_volume(dir);
assert!(
v.is_no_write_or_delete(),
"volume with truncated .dat tail must load read-only"
);
}
#[test]
fn test_scrub_empty_volume() {
// Mirror of Go's TestScrubVolumeData "zero-size volume without index"
// case (weed/storage/volume_checking_test.go): a freshly created /
// pre-allocated volume has a superblock-only .dat and a zero-size .idx,
// and must scrub clean instead of being flagged as corrupt.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let v = make_test_volume(dir);
// .dat holds only the superblock; .idx is empty.
assert_eq!(v.dat_file_size().unwrap(), SUPER_BLOCK_SIZE as u64);
let (files_checked, broken) = v.scrub().unwrap();
assert_eq!(files_checked, 0);
assert!(
broken.is_empty(),
"empty volume should scrub clean, got {:?}",
broken
);
// The index-only mode must agree.
let (idx_checked, idx_broken) = v.scrub_index().unwrap();
assert_eq!(idx_checked, 0);
assert!(
idx_broken.is_empty(),
"empty volume should scrub_index clean, got {:?}",
idx_broken
);
}
#[test]
fn test_scrub_overwritten_volume_scrubs_clean() {
// An overwrite/delete appends a second .dat record + .idx row. Walking
// the on-disk .idx (not the deduped map) makes total_read match the
// physical .dat, so a healthy overwritten volume is NOT flagged broken.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
for (id, body) in [(1u64, "first version"), (1, "second version"), (2, "other")] {
let mut n = Needle {
id: NeedleId(id),
cookie: Cookie(id as u32),
data: body.as_bytes().to_vec(),
data_size: body.len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
}
v.sync_to_disk().unwrap();
let (count, broken) = v.scrub().unwrap();
assert!(
broken.is_empty(),
"overwritten volume should scrub clean, got {:?}",
broken
);
assert_eq!(count, 3, "scrub must count every .idx row");
}
#[test]
fn test_scrub_ignores_offset0_tombstone() {
// A remote-tier delete appends an offset-0 tombstone to the .idx with no
// physical .dat bytes; it must not flag the volume (neither the size
// reconcile nor the structural overlap check).
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let data = b"needle data".to_vec();
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(1),
data: data.clone(),
data_size: data.len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.sync_to_disk().unwrap();
// Append an offset-0 logical tombstone to the on-disk .idx.
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(v.file_name(".idx"))
.unwrap();
crate::storage::idx::write_index_entry(&mut f, NeedleId(2), Offset::from_actual_offset(0), Size(-1))
.unwrap();
f.sync_all().unwrap();
drop(f);
let (count, broken) = v.scrub().unwrap();
assert!(
broken.is_empty(),
"offset-0 tombstone must not flag the volume, got {:?}",
broken
);
assert_eq!(count, 2, "both .idx rows are walked");
}
#[test]
fn test_scrub_index_flags_zero_size_idx_with_data() {
// A populated .dat with an empty .idx is corruption — the pre-allocated
// exception only covers a superblock-only .dat.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let data = b"needle data".to_vec();
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(1),
data: data.clone(),
data_size: data.len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.sync_to_disk().unwrap();
assert!(v.dat_file_size().unwrap() > SUPER_BLOCK_SIZE as u64);
// Truncate the .idx to zero while the .dat keeps its needle.
std::fs::File::create(v.file_name(".idx")).unwrap();
let (count, broken) = v.scrub_index().unwrap();
assert_eq!(count, 0);
assert!(
broken.iter().any(|e| e.contains("zero-size IDX file")),
"expected zero-size IDX error, got {:?}",
broken
);
}
#[test]
fn test_scrub_healthy_volume() {
// Mirror of Go's TestScrubVolumeData "healthy volume" case: a volume
// with live needles scrubs clean and the .dat size accounting matches.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
for i in 1..=5 {
let data = format!("needle 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();
}
v.sync_to_disk().unwrap();
let (files_checked, broken) = v.scrub().unwrap();
assert_eq!(files_checked, 5);
assert!(
broken.is_empty(),
"healthy volume should scrub clean, got {:?}",
broken
);
}
#[test]
fn test_volume_multiple_needles() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
for i in 1..=10 {
let data = format!("needle 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();
}
assert_eq!(v.file_count(), 10);
assert_eq!(v.max_file_key(), NeedleId(10));
// Read back needle 5
let mut n = Needle {
id: NeedleId(5),
..Needle::default()
};
v.read_needle(&mut n).unwrap();
assert_eq!(n.data, b"needle data 5");
}
#[test]
fn test_volume_reload_from_disk() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
// Write some needles
{
let mut v = make_test_volume(dir);
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();
}
v.sync_to_disk().unwrap();
}
// Reload and verify
let v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert_eq!(v.file_count(), 3);
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_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();
let data_dir = root.path().join("data");
let idx_dir = root.path().join("idx");
fs::create_dir_all(&data_dir).unwrap();
fs::create_dir_all(&idx_dir).unwrap();
let data = data_dir.to_str().unwrap();
let idx = idx_dir.to_str().unwrap();
// Create the volume with its index co-located in the data dir (the state
// an EC reconstruct leaves), write a needle, and flush.
let mut v = Volume::new(
data,
data,
"",
VolumeId(7),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
let payload = b"payload-across-relocate".to_vec();
let mut n = Needle {
id: NeedleId(42),
cookie: Cookie(0x55),
data: payload.clone(),
data_size: payload.len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.sync_to_disk().unwrap();
let data_idx = format!("{data}/7.idx");
let idx_dir_idx = format!("{idx}/7.idx");
assert!(
Path::new(&data_idx).exists(),
"precondition: index co-located with the data"
);
v.relocate_index_to(idx).unwrap();
assert!(Path::new(&idx_dir_idx).exists(), "index moved to the idx dir");
assert!(
!Path::new(&data_idx).exists(),
"index gone from the data dir"
);
assert_eq!(v.file_count(), 1);
// The volume reloaded in place, so the needle still reads back.
let mut got = Needle {
id: NeedleId(42),
..Needle::default()
};
v.read_needle(&mut got).unwrap();
assert_eq!(got.data, payload, "needle content survives the relocate");
// Relocating again is a no-op: the index is already where asked.
v.relocate_index_to(idx).unwrap();
assert!(Path::new(&idx_dir_idx).exists());
}
#[test]
fn test_load_keeps_index_co_located_with_the_data() {
let root = TempDir::new().unwrap();
let data_dir = root.path().join("data");
let idx_dir = root.path().join("idx");
fs::create_dir_all(&data_dir).unwrap();
fs::create_dir_all(&idx_dir).unwrap();
let data = data_dir.to_str().unwrap();
let idx = idx_dir.to_str().unwrap();
let mut v = Volume::new(
data,
data,
"",
VolumeId(7),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
let payload = b"payload-beside-the-data".to_vec();
let mut n = Needle {
id: NeedleId(42),
cookie: Cookie(0x55),
data: payload.clone(),
data_size: payload.len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.sync_to_disk().unwrap();
drop(v);
// --dir.idx now names an empty directory: the index already beside the
// data keeps serving, and nothing lands in the new directory.
let reopened = Volume::new(
data,
idx,
"",
VolumeId(7),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert!(
Path::new(&format!("{data}/7.idx")).exists(),
"index stays with the data"
);
assert!(
!Path::new(&format!("{idx}/7.idx")).exists(),
"nothing written to the new idx dir"
);
let mut got = Needle {
id: NeedleId(42),
..Needle::default()
};
reopened.read_needle(&mut got).unwrap();
assert_eq!(got.data, payload);
}
#[test]
fn test_relocate_index_to_noop_when_already_in_place() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(1),
data: b"x".to_vec(),
data_size: 1,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.sync_to_disk().unwrap();
// Data and index share a directory, so there is nothing to move.
v.relocate_index_to(dir).unwrap();
assert!(Path::new(&format!("{dir}/1.idx")).exists());
let mut got = Needle {
id: NeedleId(1),
..Needle::default()
};
v.read_needle(&mut got).unwrap();
assert_eq!(got.data, b"x");
}
#[test]
fn test_volume_cookie_mismatch() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0xaa),
data: b"original".to_vec(),
data_size: 8,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
// Write with wrong cookie
let mut n2 = Needle {
id: NeedleId(1),
cookie: Cookie(0xbb),
data: b"overwrite".to_vec(),
data_size: 9,
..Needle::default()
};
let err = v.write_needle(&mut n2, true, false).unwrap_err();
assert!(matches!(err, VolumeError::CookieMismatch(_)));
}
// A size disagreeing with the blob's own header indexes the needle at the wrong
// length and, on v3, stamps the append timestamp into the middle of the needle.
#[test]
fn test_write_needle_blob_rejects_size_mismatch() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0x12345678),
data: b"the merged payload".to_vec(),
data_size: 18,
..Needle::default()
};
n.checksum = CRC::new(&n.data);
let (offset, _, _) = v.write_needle(&mut n, true, false).unwrap();
let blob = v.read_needle_blob(offset as i64, n.size).unwrap();
let dat_size_before = v.dat_file_size().unwrap();
// Size(n.data_size) is what append reports, and what a caller following the
// payload-size convention would send.
let err = v
.write_needle_blob_and_index(NeedleId(2), &blob, Size(n.data_size as i32))
.unwrap_err();
assert!(matches!(err, VolumeError::Io(_)), "got {err:?}");
assert_eq!(v.dat_file_size().unwrap(), dat_size_before);
v.write_needle_blob_and_index(NeedleId(2), &blob, n.size)
.unwrap();
}
#[test]
fn test_volume_destroy() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let dat_path;
let idx_path;
{
let mut v = make_test_volume(dir);
dat_path = v.file_name(".dat");
idx_path = v.file_name(".idx");
assert!(Path::new(&dat_path).exists());
v.destroy(false, false).unwrap();
}
assert!(!Path::new(&dat_path).exists());
assert!(!Path::new(&idx_path).exists());
}
#[test]
fn test_read_all_needles_uses_dat_order_for_live_offsets() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let mut first = Needle {
id: NeedleId(10),
cookie: Cookie(0x11223344),
data: b"first".to_vec(),
data_size: 5,
..Needle::default()
};
v.write_needle(&mut first, true, false).unwrap();
let mut second = Needle {
id: NeedleId(20),
cookie: Cookie(0x55667788),
data: b"second".to_vec(),
data_size: 6,
..Needle::default()
};
v.write_needle(&mut second, true, false).unwrap();
let mut first_overwrite = Needle {
id: NeedleId(10),
cookie: Cookie(0x11223344),
data: b"first-overwrite".to_vec(),
data_size: 15,
..Needle::default()
};
v.write_needle(&mut first_overwrite, true, false).unwrap();
let needles = v.read_all_needles().unwrap();
let ids: Vec<u64> = needles.iter().map(|n| u64::from(n.id)).collect();
let bodies: Vec<&[u8]> = needles.iter().map(|n| n.data.as_slice()).collect();
assert_eq!(ids, vec![20, 10]);
assert_eq!(bodies, vec![b"second".as_slice(), b"first-overwrite".as_slice()]);
}
#[test]
fn test_get_append_at_ns() {
let t1 = get_append_at_ns(0);
assert!(t1 > 0);
let t2 = get_append_at_ns(t1);
assert!(t2 > t1);
// If we pass a future timestamp, should return last+1
let future = u64::MAX - 1;
let t3 = get_append_at_ns(future);
assert_eq!(t3, future + 1);
}
#[test]
fn test_volume_compact() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
// Write 3 needles
for i in 1..=3u64 {
let mut n = Needle {
id: NeedleId(i),
cookie: Cookie(i as u32),
data: format!("data-{}", i).into_bytes(),
data_size: format!("data-{}", i).len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
}
assert_eq!(v.file_count(), 3);
// Delete needle 2
let mut del = Needle {
id: NeedleId(2),
cookie: Cookie(2),
..Needle::default()
};
v.delete_needle(&mut del).unwrap();
// Additive-only: file_count stays at 3 after delete
assert_eq!(v.file_count(), 3);
assert_eq!(v.deleted_count(), 1);
let dat_size_before = v.dat_file_size().unwrap();
// Compact
v.compact_by_index(0, 0, |_| true).unwrap();
// Verify compact files exist
assert!(Path::new(&v.file_name(".cpd")).exists());
assert!(Path::new(&v.file_name(".cpx")).exists());
// Commit: swap files and reload
v.commit_compact().unwrap();
// After compaction: 2 live needles, 0 deleted
assert_eq!(v.file_count(), 2);
assert_eq!(v.deleted_count(), 0);
// Dat should be smaller (deleted needle removed)
let dat_size_after = v.dat_file_size().unwrap();
assert!(
dat_size_after < dat_size_before,
"dat should shrink after compact"
);
// Read back live needles
let mut n1 = Needle {
id: NeedleId(1),
..Needle::default()
};
v.read_needle(&mut n1).unwrap();
assert_eq!(n1.data, b"data-1");
let mut n3 = Needle {
id: NeedleId(3),
..Needle::default()
};
v.read_needle(&mut n3).unwrap();
assert_eq!(n3.data, b"data-3");
// Needle 2 should not exist
let mut n2 = Needle {
id: NeedleId(2),
..Needle::default()
};
assert!(v.read_needle(&mut n2).is_err());
// Compact files should not exist after commit
assert!(!Path::new(&v.file_name(".cpd")).exists());
assert!(!Path::new(&v.file_name(".cpx")).exists());
// Cleanup should be a no-op
v.cleanup_compact().unwrap();
}
/// Vacuum compaction must tolerate an .idx entry whose offset points past
/// the end of the .dat file (the failure mode in issue #8928). The bad
/// entry is silently dropped from the resulting .cpx; healthy needles
/// survive untouched.
#[test]
fn test_compact_by_index_drops_dangling_needle() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
// Write a handful of healthy needles to establish a baseline .dat/.idx.
for i in 1..=5u64 {
let mut n = Needle {
id: NeedleId(i),
cookie: Cookie(i as u32),
data: format!("payload-{}", i).into_bytes(),
data_size: format!("payload-{}", i).len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
}
v.sync_to_disk().unwrap();
let dat_size = v.dat_file_size().unwrap();
let bad_key = NeedleId(9999);
let bad_offset = Offset::from_actual_offset((dat_size + 1024 * 1024) as i64);
let bad_size = Size(2048);
v.nm
.as_mut()
.expect("needle map present")
.put(bad_key, bad_offset, bad_size)
.unwrap();
v.nm.as_ref().unwrap().sync().unwrap();
// Vacuum must succeed in spite of the dangling entry.
v.compact_by_index(0, 0, |_| true)
.expect("compact_by_index should tolerate dangling entries");
// Walk the resulting .cpx and confirm the bad key was dropped while
// every healthy key made it through.
let cpx_path = v.file_name(".cpx");
let mut cpx = File::open(&cpx_path).unwrap();
let mut kept: Vec<u64> = Vec::new();
idx::walk_index_file(&mut cpx, 0, |key, _, size| {
if !size.is_deleted() {
kept.push(key.0);
}
Ok(())
})
.unwrap();
assert!(
!kept.contains(&bad_key.0),
"dangling key {} should have been dropped from .cpx, got {:?}",
bad_key.0,
kept
);
for i in 1..=5u64 {
assert!(
kept.contains(&i),
"healthy key {} missing from compacted .cpx, got {:?}",
i,
kept
);
}
}
/// The needle map's max_needle_end accumulator must let volume.load
/// detect an .idx whose entries point past the end of the .dat — the
/// deeper-than-tail corruption shape from issue #8928 that the existing
/// last-10-entries scan cannot see. The check is populated by the load
/// walk and read in volume.load() to flip the volume read-only.
#[test]
fn test_max_needle_end_detects_dangling_entry() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
for i in 1..=4u64 {
let mut n = Needle {
id: NeedleId(i),
cookie: Cookie(i as u32),
data: format!("data-{}", i).into_bytes(),
data_size: format!("data-{}", i).len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
}
v.sync_to_disk().unwrap();
let dat_size = v.dat_file_size().unwrap() as i64;
let idx_path = v.file_name(".idx");
let version = v.version();
// Sanity: a fresh load walk over the healthy .idx puts max_needle_end
// somewhere inside the .dat.
let mut idx_reader = File::open(&idx_path).unwrap();
let healthy_nm =
CompactNeedleMap::load_from_idx(&mut idx_reader, version).unwrap();
let healthy_end = healthy_nm.max_needle_end();
assert!(
healthy_end > 0 && healthy_end <= dat_size,
"healthy volume should have max_needle_end ({}) in [1, dat_size={}]",
healthy_end,
dat_size
);
// Inject a dangling entry by appending a bogus 16/17-byte record
// directly to the .idx, then reload. The load walk should observe
// max_needle_end past dat_size — which is exactly the signal
// volume.load uses to mark the volume read-only.
let bad_offset = Offset::from_actual_offset(dat_size + 4 * 1024 * 1024);
let mut idx_append = OpenOptions::new()
.write(true)
.append(true)
.open(&idx_path)
.unwrap();
idx::write_index_entry(&mut idx_append, NeedleId(9999), bad_offset, Size(1024))
.unwrap();
idx_append.sync_all().unwrap();
let mut idx_reread = File::open(&idx_path).unwrap();
let bad_nm = CompactNeedleMap::load_from_idx(&mut idx_reread, version).unwrap();
let bad_end = bad_nm.max_needle_end();
assert!(
bad_end > dat_size,
"after dangling-entry inject max_needle_end ({}) should exceed dat_size ({})",
bad_end,
dat_size
);
}
#[test]
fn test_compaction_revision_relookup() {
// Verifies that re_lookup_needle_data_offset returns the correct data offset
// and compaction revision, and that after compaction the offset changes.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
// Write two needles
let mut n1 = Needle {
id: NeedleId(1),
cookie: Cookie(0xAABBCCDD),
data: b"first-needle-data".to_vec(),
data_size: 17,
..Needle::default()
};
v.write_needle(&mut n1, true, false).unwrap();
let mut n2 = Needle {
id: NeedleId(2),
cookie: Cookie(0x11223344),
data: b"second-needle-data".to_vec(),
data_size: 18,
..Needle::default()
};
v.write_needle(&mut n2, true, false).unwrap();
// Get initial revision and offset for needle 1
let initial_rev = v.super_block.compaction_revision;
let (initial_offset, rev) = v.re_lookup_needle_data_offset(NeedleId(1)).unwrap();
assert_eq!(rev, initial_rev);
assert!(initial_offset > 0, "data offset should be positive");
// Delete needle 2 so compaction removes it
let mut del_n2 = Needle {
id: NeedleId(2),
cookie: Cookie(0x11223344),
..Needle::default()
};
v.delete_needle(&mut del_n2).unwrap();
// Compact the volume — this increments compaction_revision and may move needles
v.compact_by_index(0, 0, |_| true).unwrap();
v.commit_compact().unwrap();
// After compaction, the revision should have changed
let new_rev = v.super_block.compaction_revision;
assert_eq!(
new_rev,
initial_rev + 1,
"compaction should increment revision"
);
// Re-lookup needle 1 — should still be found with the new revision
let (new_offset, relookup_rev) = v.re_lookup_needle_data_offset(NeedleId(1)).unwrap();
assert_eq!(relookup_rev, new_rev);
assert!(new_offset > 0, "data offset should still be positive");
// The data should still be readable correctly after compaction
let mut read_n1 = Needle {
id: NeedleId(1),
..Needle::default()
};
v.read_needle(&mut read_n1).unwrap();
assert_eq!(read_n1.data, b"first-needle-data");
// Deleted needle should not be found
let result = v.re_lookup_needle_data_offset(NeedleId(2));
assert!(
result.is_err(),
"deleted needle should not be found after compaction"
);
}
#[test]
fn test_stream_info_includes_compaction_revision() {
// Verifies that NeedleStreamInfo carries the volume's compaction revision
// so that StreamingBody can detect when compaction has occurred.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
// Write a needle large enough to have meaningful data
let data = vec![0xABu8; 2048];
let mut n = Needle {
id: NeedleId(42),
cookie: Cookie(0xDEADBEEF),
data: data.clone(),
data_size: data.len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
// Read stream info
let mut read_n = Needle {
id: NeedleId(42),
cookie: Cookie(0xDEADBEEF),
..Needle::default()
};
let info = v.read_needle_stream_info(&mut read_n, false).unwrap();
assert_eq!(info.volume_id, VolumeId(1));
assert_eq!(info.needle_id, NeedleId(42));
assert_eq!(info.compaction_revision, v.super_block.compaction_revision);
assert_eq!(info.data_size, data.len() as u32);
assert!(info.data_file_offset > 0);
}
#[test]
fn test_remote_vif_load_blocks_writes_but_allows_delete() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let dat_size_before_reload = {
let mut v = make_test_volume(dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0x1234),
data: b"remote".to_vec(),
data_size: 6,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
let vif = VifVolumeInfo {
files: vec![VifRemoteFile {
backend_type: "s3".to_string(),
backend_id: "vif_rw_test".to_string(),
key: "remote-key".to_string(),
offset: 0,
file_size: v.dat_file_size().unwrap(),
modified_time: 123,
extension: ".dat".to_string(),
}],
version: v.version().0 as u32,
..VifVolumeInfo::default()
};
std::fs::write(
format!("{}/1.vif", dir),
serde_json::to_string_pretty(&vif).unwrap(),
)
.unwrap();
// Register a dummy S3 backend so load_remote_dat_file can look it
// up. This test never reads remote data, so a dead endpoint is fine.
// Use a test-specific backend_id to avoid racing with other tests
// that share the global registry.
let tier_config = crate::remote_storage::s3_tier::S3TierConfig {
access_key: "access".to_string(),
secret_key: "secret".to_string(),
region: "us-east-1".to_string(),
bucket: "bucket-a".to_string(),
endpoint: "http://127.0.0.1:1".to_string(),
storage_class: "STANDARD".to_string(),
force_path_style: true,
};
crate::remote_storage::s3_tier::global_s3_tier_registry()
.write()
.unwrap()
.register(
"s3.vif_rw_test".to_string(),
crate::remote_storage::s3_tier::S3TierBackend::new(&tier_config),
);
v.dat_file_size().unwrap()
};
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert!(v.is_read_only());
assert!(!v.no_write_or_delete);
assert!(v.no_write_can_delete);
let err = v
.write_needle(
&mut Needle {
id: NeedleId(2),
cookie: Cookie(0x5678),
data: b"blocked".to_vec(),
data_size: 7,
..Needle::default()
},
true,
false,
)
.unwrap_err();
assert!(matches!(err, VolumeError::ReadOnly));
let deleted_size = v
.delete_needle(&mut Needle {
id: NeedleId(1),
cookie: Cookie(0x1234),
..Needle::default()
})
.unwrap();
assert!(deleted_size.0 > 0);
assert_eq!(v.dat_file_size().unwrap(), dat_size_before_reload);
crate::remote_storage::s3_tier::global_s3_tier_registry()
.write()
.unwrap()
.remove("s3.vif_rw_test");
}
/// Build a volume with `needles` needles, tier it to a fake remote backend,
/// and reload it the way a restart would — so it comes back read-only with
/// its index on disk. The local .dat is left in place, matching the state a
/// completed tier-down download leaves behind.
fn reload_as_tiered(dir: &str, backend_id: &str, needles: u64) -> Volume {
{
let mut v = make_test_volume(dir);
for i in 1..=needles {
let mut n = Needle {
id: NeedleId(i),
cookie: Cookie(0x1234),
data: format!("needle-{i}").into_bytes(),
data_size: format!("needle-{i}").len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
}
let vif = VifVolumeInfo {
files: vec![VifRemoteFile {
backend_type: "s3".to_string(),
backend_id: backend_id.to_string(),
key: "remote-key".to_string(),
offset: 0,
file_size: v.dat_file_size().unwrap(),
modified_time: 123,
extension: ".dat".to_string(),
}],
version: v.version().0 as u32,
..VifVolumeInfo::default()
};
std::fs::write(
format!("{}/1.vif", dir),
serde_json::to_string_pretty(&vif).unwrap(),
)
.unwrap();
let tier_config = crate::remote_storage::s3_tier::S3TierConfig {
access_key: "access".to_string(),
secret_key: "secret".to_string(),
region: "us-east-1".to_string(),
bucket: "bucket-a".to_string(),
endpoint: "http://127.0.0.1:1".to_string(),
storage_class: "STANDARD".to_string(),
force_path_style: true,
};
crate::remote_storage::s3_tier::global_s3_tier_registry()
.write()
.unwrap()
.register(
format!("s3.{backend_id}"),
crate::remote_storage::s3_tier::S3TierBackend::new(&tier_config),
);
}
Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap()
}
// Tier-down clears the remote mode and publishes the volume as writable. The
// map it booted with is the read-only sorted one, whose put always fails, so
// without a rebuild the first write appends to .dat and then cannot be
// indexed — leaving bytes nothing references.
/// A volume tiered while the server runs must install the sorted map right
/// then. Reconciling only on the way down left it holding the whole index
/// in RAM and its .idx descriptor pinned until the next restart — the cost
/// the sorted map exists to avoid.
#[test]
fn test_tier_up_installs_the_sorted_needle_map() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
for i in 1..=2u64 {
let mut n = Needle {
id: NeedleId(i),
cookie: Cookie(0x1234),
data: format!("needle-{i}").into_bytes(),
data_size: format!("needle-{i}").len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
}
v.sync_to_disk().unwrap();
assert!(
!matches!(v.nm, Some(NeedleMap::SortedFile(_))),
"a local volume starts with a writable in-memory map"
);
// What the tier-up handler does once the .dat is uploaded: record the
// remote reference and reconcile the mode.
v.volume_info.files.push(PbRemoteFile {
backend_type: "s3".to_string(),
backend_id: "vif_tierup_test".to_string(),
key: "remote-key".to_string(),
offset: 0,
file_size: v.dat_file_size().unwrap(),
modified_time: 123,
extension: ".dat".to_string(),
});
v.refresh_remote_write_mode().unwrap();
assert!(v.has_remote_file);
assert!(
matches!(v.nm, Some(NeedleMap::SortedFile(_))),
"tier-up must install the sorted map without waiting for a restart"
);
// The index still answers, now off .sdx.
let nv = v
.nm
.as_ref()
.unwrap()
.get(NeedleId(1))
.unwrap()
.expect("needle 1 still resolves through the sorted index");
assert!(!nv.size.is_deleted());
}
#[test]
fn test_tier_down_swaps_in_a_writable_needle_map() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = reload_as_tiered(dir, "vif_tierdown_test", 2);
assert!(matches!(v.nm, Some(NeedleMap::SortedFile(_))));
// The tail of the tier-down handler, once the .dat is back on disk.
v.volume_info.files.remove(0);
v.refresh_remote_write_mode().unwrap();
v.save_volume_info().unwrap();
v.open_local_dat_backend().unwrap();
assert!(!v.has_remote_file);
assert!(!v.is_read_only(), "tier-down should publish a writable volume");
assert!(
!matches!(v.nm, Some(NeedleMap::SortedFile(_))),
"tier-down must swap the read-only map out before writes are allowed"
);
let mut n = Needle {
id: NeedleId(7),
cookie: Cookie(0x5678),
data: b"after-tier-down".to_vec(),
data_size: 15,
..Needle::default()
};
v.write_needle(&mut n, true, true).unwrap();
v.sync_to_disk().unwrap();
drop(v);
// Reload: the needle must come back from .idx, not just have landed in
// .dat as unreferenced bytes.
let v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
let mut probe = Needle {
id: NeedleId(7),
..Needle::default()
};
v.read_needle(&mut probe).unwrap();
assert_eq!(probe.data, b"after-tier-down");
crate::remote_storage::s3_tier::global_s3_tier_registry()
.write()
.unwrap()
.remove("s3.vif_tierdown_test");
}
// A .sdx that cannot be read end to end must abort compaction. Treating the
// short scan as the complete live set would commit a volume missing every
// needle past the truncation.
#[test]
fn test_compaction_aborts_on_unreadable_sorted_index() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = reload_as_tiered(dir, "vif_compact_test", 4);
let Some(NeedleMap::SortedFile(ref nm)) = v.nm else {
panic!("tiered volume should search the on-disk .sdx");
};
let sdx_path = nm.db_file_name().to_string();
let sdx = OpenOptions::new().write(true).open(&sdx_path).unwrap();
sdx.set_len(NEEDLE_MAP_ENTRY_SIZE as u64).unwrap();
drop(sdx);
crate::storage::needle_map::file_pool::pooled_index_files().discard(&sdx_path);
let err = v
.compact_by_index(0, 0, |_| true)
.expect_err("compaction must not commit a partially scanned index");
assert!(
matches!(err, VolumeError::Io(ref e) if e.kind() == io::ErrorKind::UnexpectedEof),
"unexpected error: {err:?}"
);
crate::remote_storage::s3_tier::global_s3_tier_registry()
.write()
.unwrap()
.remove("s3.vif_compact_test");
}
// Building .sdx writes to the index directory, which a read-only volume's
// may not allow. Before the sorted map that volume mounted and served reads
// off an in-memory index; it must still do so rather than going offline.
#[test]
#[cfg(unix)]
fn test_read_only_volume_mounts_when_the_index_dir_is_not_writable() {
use std::os::unix::fs::PermissionsExt;
// root ignores the directory mode, so there is nothing to simulate.
if unsafe { libc::geteuid() } == 0 {
return;
}
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap().to_string();
{
let mut v = make_test_volume(&dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0x1234),
data: b"still-readable".to_vec(),
data_size: 14,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.set_read_only_persist(false, true).unwrap();
v.sync_to_disk().unwrap();
}
let set_mode = |mode: u32| {
let mut perms = std::fs::metadata(tmp.path()).unwrap().permissions();
perms.set_mode(mode);
std::fs::set_permissions(tmp.path(), perms).unwrap();
};
set_mode(0o555);
let loaded = Volume::new(
&dir,
&dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
);
set_mode(0o755); // restore before any assertion, so cleanup works
let v = loaded.expect("a read-only volume must mount without writing its index dir");
assert!(
!matches!(v.nm, Some(NeedleMap::SortedFile(_))),
"the .sdx could not be built, so the index should have fallen back to memory"
);
let mut probe = Needle {
id: NeedleId(1),
..Needle::default()
};
v.read_needle(&mut probe).unwrap();
assert_eq!(probe.data, b"still-readable");
}
// set_writable clears the read-only flags before it can know the .idx writer
// will attach. If attaching fails the flags have to go back: a volume that
// advertises writable while its map has no writer takes puts into memory and
// loses them at the next restart.
#[test]
#[cfg(unix)]
fn test_set_writable_rolls_back_when_the_idx_writer_cannot_attach() {
use std::os::unix::fs::PermissionsExt;
// root ignores the directory mode, so there is nothing to simulate.
if unsafe { libc::geteuid() } == 0 {
return;
}
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap().to_string();
{
let mut v = make_test_volume(&dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0x1234),
data: b"before".to_vec(),
data_size: 6,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.set_read_only_persist(false, true).unwrap();
v.sync_to_disk().unwrap();
}
// A read-only mount, both halves of it: nothing new can be created in the
// directory, so .sdx generation fails and the index falls back to memory,
// and the .idx itself cannot be opened for write, so no writer attaches.
let set_mode = |path: &std::path::Path, mode: u32| {
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(mode);
std::fs::set_permissions(path, perms).unwrap();
};
let idx_path = tmp.path().join("1.idx");
set_mode(&idx_path, 0o444);
set_mode(tmp.path(), 0o555);
let loaded = Volume::new(
&dir,
&dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
);
let marked = loaded.map(|mut v| {
let r = v.set_writable();
(v.no_write_or_delete, v.is_read_only(), v.nm.as_ref().unwrap().has_idx_writer(), r)
});
// Restore before any assertion, so cleanup works.
set_mode(tmp.path(), 0o755);
set_mode(&idx_path, 0o644);
let (no_write_or_delete, read_only, has_writer, result) = marked.unwrap();
assert!(
result.is_err(),
"set_writable cannot succeed without an .idx writer"
);
assert!(!has_writer, "the writer is what failed to attach");
assert!(
no_write_or_delete && read_only,
"a failed mark must leave the volume read-only, not writable with an index it cannot append to"
);
}
// .idx is a run of fixed-size rows and writers append at EOF, so a partial
// row left by a short write puts every row appended after it off alignment
// and the next load parses the rest as garbage. The tail has to go before
// anything writable attaches.
#[test]
fn test_writable_load_trims_a_torn_idx_tail() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap().to_string();
{
let mut v = make_test_volume(&dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0x1234),
data: b"first".to_vec(),
data_size: 5,
..Needle::default()
};
v.write_needle(&mut n, true, true).unwrap();
v.sync_to_disk().unwrap();
}
let idx_path = tmp.path().join("1.idx");
let whole = std::fs::metadata(&idx_path).unwrap().len();
{
use std::io::Write as _;
let mut f = OpenOptions::new().append(true).open(&idx_path).unwrap();
f.write_all(&[0xcd; 5]).unwrap();
}
let mut v = Volume::new(
&dir,
&dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert_eq!(
std::fs::metadata(&idx_path).unwrap().len(),
whole,
"the partial row should be gone before any writer attaches"
);
let mut n = Needle {
id: NeedleId(2),
cookie: Cookie(0x5678),
data: b"second".to_vec(),
data_size: 6,
..Needle::default()
};
v.write_needle(&mut n, true, true).unwrap();
v.sync_to_disk().unwrap();
drop(v);
// Both rows have to survive the round trip; a misaligned append loses
// the second one and garbles whatever follows it.
let v = Volume::new(
&dir,
&dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
for (id, want) in [(1u64, &b"first"[..]), (2u64, &b"second"[..])] {
let mut probe = Needle {
id: NeedleId(id),
..Needle::default()
};
v.read_needle(&mut probe).unwrap();
assert_eq!(probe.data, want);
}
}
// A delete-capable read-only volume — every tiered one — takes the in-memory
// loader's read-write branch, which fails on the same unwritable directory
// that refused the .sdx. It has to end up read-only rather than offline.
#[test]
#[cfg(unix)]
fn test_delete_capable_volume_falls_all_the_way_back_to_read_only() {
use std::os::unix::fs::PermissionsExt;
// root ignores the directory mode, so there is nothing to simulate.
if unsafe { libc::geteuid() } == 0 {
return;
}
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap().to_string();
{
let mut v = make_test_volume(&dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0x1234),
data: b"still-readable".to_vec(),
data_size: 14,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.set_read_only_persist(true, true).unwrap();
v.sync_to_disk().unwrap();
}
let set_mode = |path: &std::path::Path, mode: u32| {
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(mode);
std::fs::set_permissions(path, perms).unwrap();
};
let idx_path = tmp.path().join("1.idx");
set_mode(&idx_path, 0o444);
set_mode(tmp.path(), 0o555);
let loaded = Volume::new(
&dir,
&dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
);
set_mode(tmp.path(), 0o755);
set_mode(&idx_path, 0o644);
let v = loaded.expect("a delete-capable volume must mount without a writable index dir");
assert!(
v.no_write_or_delete,
"with no writer for tombstones, deletes have to be refused outright"
);
let mut probe = Needle {
id: NeedleId(1),
..Needle::default()
};
v.read_needle(&mut probe).unwrap();
assert_eq!(probe.data, b"still-readable");
}
// Issue #10937: a volume server holding hundreds of thousands of cloud-tiered
// volumes ran out of descriptors because every one of them pinned its index
// for the life of the process. A tiered volume must search the on-disk .sdx
// and hold nothing open while it sits idle.
#[test]
fn test_remote_volume_index_is_on_disk_and_holds_no_descriptors() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
{
let mut v = make_test_volume(dir);
for i in 1..=4u64 {
let mut n = Needle {
id: NeedleId(i),
cookie: Cookie(0x1234),
data: format!("needle-{i}").into_bytes(),
data_size: format!("needle-{i}").len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
}
// A writable volume does pin its .idx, which is also what proves the
// descriptor probe below is looking at something real.
if let Some(count) = crate::storage::needle_map::file_pool::open_index_fds(tmp.path()) {
assert!(count > 0, "a writable volume should hold its .idx open");
}
let vif = VifVolumeInfo {
files: vec![VifRemoteFile {
backend_type: "s3".to_string(),
backend_id: "vif_fd_test".to_string(),
key: "remote-key".to_string(),
offset: 0,
file_size: v.dat_file_size().unwrap(),
modified_time: 123,
extension: ".dat".to_string(),
}],
version: v.version().0 as u32,
..VifVolumeInfo::default()
};
std::fs::write(
format!("{}/1.vif", dir),
serde_json::to_string_pretty(&vif).unwrap(),
)
.unwrap();
let tier_config = crate::remote_storage::s3_tier::S3TierConfig {
access_key: "access".to_string(),
secret_key: "secret".to_string(),
region: "us-east-1".to_string(),
bucket: "bucket-a".to_string(),
endpoint: "http://127.0.0.1:1".to_string(),
storage_class: "STANDARD".to_string(),
force_path_style: true,
};
crate::remote_storage::s3_tier::global_s3_tier_registry()
.write()
.unwrap()
.register(
"s3.vif_fd_test".to_string(),
crate::remote_storage::s3_tier::S3TierBackend::new(&tier_config),
);
}
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert!(v.has_remote_file);
let Some(NeedleMap::SortedFile(ref nm)) = v.nm else {
panic!("tiered volume should search the on-disk .sdx, got {:?}", v.nm.is_some());
};
let idx_path = nm.index_file_name().to_string();
let sdx_path = nm.db_file_name().to_string();
assert!(Path::new(&sdx_path).exists(), "load should build the .sdx");
let drop_pooled = || {
crate::storage::needle_map::file_pool::pooled_index_files().discard(&idx_path);
crate::storage::needle_map::file_pool::pooled_index_files().discard(&sdx_path);
};
if crate::storage::needle_map::file_pool::open_index_fds(tmp.path()).is_some() {
drop_pooled();
assert_eq!(
crate::storage::needle_map::file_pool::open_index_fds(tmp.path()),
Some(0),
"a loaded tiered volume must hold no .idx/.sdx descriptor"
);
}
// Lookups still resolve, straight off .sdx.
assert!(!v.nm.as_ref().unwrap().get(NeedleId(3)).unwrap().unwrap().size.is_deleted());
assert!(v.nm.as_ref().unwrap().get(NeedleId(9)).unwrap().is_none());
// Deletes are still allowed on a tiered volume and land in .idx.
let idx_before = std::fs::metadata(&idx_path).unwrap().len();
let deleted = v
.delete_needle(&mut Needle {
id: NeedleId(3),
cookie: Cookie(0x1234),
..Needle::default()
})
.unwrap();
assert!(deleted.0 > 0);
assert_eq!(
std::fs::metadata(&idx_path).unwrap().len(),
idx_before + NEEDLE_MAP_ENTRY_SIZE as u64,
"the tombstone must be appended to the .idx tail"
);
assert!(v.nm.as_ref().unwrap().get(NeedleId(3)).unwrap().unwrap().size.is_deleted());
if crate::storage::needle_map::file_pool::open_index_fds(tmp.path()).is_some() {
drop_pooled();
assert_eq!(
crate::storage::needle_map::file_pool::open_index_fds(tmp.path()),
Some(0),
"reads and deletes must give their borrowed handles back"
);
}
crate::remote_storage::s3_tier::global_s3_tier_registry()
.write()
.unwrap()
.remove("s3.vif_fd_test");
}
#[test]
fn test_set_writable_keeps_remote_delete_only_mode() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
v.volume_info.files.push(PbRemoteFile {
backend_type: "s3".to_string(),
backend_id: "default".to_string(),
key: "remote-key".to_string(),
offset: 0,
file_size: v.dat_file_size().unwrap(),
modified_time: 123,
extension: ".dat".to_string(),
});
v.refresh_remote_write_mode().unwrap();
v.set_writable().unwrap();
assert!(v.is_read_only());
assert!(!v.no_write_or_delete);
assert!(v.no_write_can_delete);
}
// A volume booted with .vif ReadOnly=true used to come back stuck —
// load_index_inmemory built the CompactNeedleMap without an .idx writer
// attached, and set_writable only flipped the flag and rewrote .vif.
// The next put silently skipped the .idx append, so the write landed in
// memory only and was lost on the next restart.
#[test]
fn test_set_writable_reattaches_idx_writer_after_persisted_readonly() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
{
let mut v = make_test_volume(dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(1),
data: b"initial".to_vec(),
data_size: 7,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.set_read_only_persist(false, true).unwrap();
v.sync_to_disk().unwrap();
}
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert!(
v.no_write_or_delete,
"reloaded volume should be read-only from .vif"
);
assert!(
matches!(v.nm, Some(NeedleMap::SortedFile(_))),
"read-only load should search the on-disk .sdx"
);
v.set_writable().unwrap();
assert!(!v.is_read_only());
assert!(
!matches!(v.nm, Some(NeedleMap::SortedFile(_))),
"set_writable must swap out the read-only map, whose put always fails"
);
assert!(
v.nm.as_ref().unwrap().has_idx_writer(),
"set_writable must reattach the .idx writer or post-restart writes vanish"
);
let mut n = Needle {
id: NeedleId(2),
cookie: Cookie(2),
data: b"after-mark-writable".to_vec(),
data_size: 19,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.sync_to_disk().unwrap();
// Reload one more time — the .idx must contain the post-mark-writable
// entry, not just have it in memory.
drop(v);
let v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
let mut probe = Needle {
id: NeedleId(2),
..Needle::default()
};
v.read_needle(&mut probe).unwrap();
assert_eq!(std::str::from_utf8(&probe.data).unwrap(), "after-mark-writable");
}
// readOnlyCanDelete rejects writes, keeps accepting deletes, and survives
// a restart.
#[test]
fn test_read_only_can_delete_persists_and_keeps_deletes_working() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
{
let mut v = make_test_volume(dir);
for id in 1..=2u64 {
let mut n = Needle {
id: NeedleId(id),
cookie: Cookie(id as u32),
data: b"payload".to_vec(),
data_size: 7,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
}
v.set_read_only_persist(true, true).unwrap();
assert!(v.no_write_can_delete);
assert!(!v.no_write_or_delete);
assert!(v.is_read_only());
let err = v
.write_needle(
&mut Needle {
id: NeedleId(3),
cookie: Cookie(3),
data: b"blocked".to_vec(),
data_size: 7,
..Needle::default()
},
true,
false,
)
.unwrap_err();
assert!(matches!(err, VolumeError::ReadOnly));
let deleted = v
.delete_needle(&mut Needle {
id: NeedleId(1),
cookie: Cookie(1),
..Needle::default()
})
.unwrap();
assert!(deleted.0 > 0, "deletes must still land");
v.sync_to_disk().unwrap();
}
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert!(v.no_write_can_delete, "state must survive a restart");
assert!(!v.no_write_or_delete);
assert!(
v.nm.as_ref().unwrap().has_idx_writer(),
"canDelete boot must attach an .idx writer for delete tombstones"
);
let err = v
.write_needle(
&mut Needle {
id: NeedleId(4),
cookie: Cookie(4),
data: b"blocked".to_vec(),
data_size: 7,
..Needle::default()
},
true,
false,
)
.unwrap_err();
assert!(matches!(err, VolumeError::ReadOnly));
let deleted = v
.delete_needle(&mut Needle {
id: NeedleId(2),
cookie: Cookie(2),
..Needle::default()
})
.unwrap();
assert!(deleted.0 > 0, "deletes must still land after restart");
// downgrading to plain readonly clears the canDelete flag
v.set_read_only_persist(false, true).unwrap();
assert!(v.no_write_or_delete);
assert!(!v.no_write_can_delete);
// -writable clears the state again.
v.set_writable().unwrap();
assert!(!v.is_read_only());
let mut n = Needle {
id: NeedleId(5),
cookie: Cookie(5),
data: b"writable-again".to_vec(),
data_size: 14,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
}
// Upgrading a volume that booted plain persisted-readonly to canDelete
// must attach the missing .idx writer, or delete tombstones cannot append.
#[test]
fn test_read_only_can_delete_upgrade_after_readonly_boot() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
{
let mut v = make_test_volume(dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(1),
data: b"payload".to_vec(),
data_size: 7,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.set_read_only_persist(false, true).unwrap();
v.sync_to_disk().unwrap();
}
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert!(v.no_write_or_delete);
let err = v
.delete_needle(&mut Needle {
id: NeedleId(1),
cookie: Cookie(1),
..Needle::default()
})
.unwrap_err();
assert!(
matches!(err, VolumeError::ReadOnly),
"plain readonly must reject deletes"
);
v.set_read_only_persist(true, true).unwrap();
assert!(!v.no_write_or_delete);
assert!(v.no_write_can_delete);
assert!(
v.nm.as_ref().unwrap().has_idx_writer(),
"upgrade to canDelete must attach the .idx writer"
);
let deleted = v
.delete_needle(&mut Needle {
id: NeedleId(1),
cookie: Cookie(1),
..Needle::default()
})
.unwrap();
assert!(deleted.0 > 0, "deletes must land once canDelete is set");
}
#[test]
fn test_load_vif_defaults_local_version_and_bytes_offset() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
{
let _v = make_test_volume(dir);
let vif = VifVolumeInfo::default();
std::fs::write(
format!("{}/1.vif", dir),
serde_json::to_string_pretty(&vif).unwrap(),
)
.unwrap();
}
let v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert_eq!(v.volume_info.version, Version::current().0 as u32);
assert_eq!(v.volume_info.bytes_offset, OFFSET_SIZE as u32);
assert_eq!(v.version(), Version::current());
}
#[test]
fn test_version_superblock_overrides_vif_version() {
// Go behavior: after reading the superblock, volumeInfo.Version is set
// to SuperBlock.Version, overriding whatever was in the .vif file.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
{
let _v = make_test_volume(dir);
// Write a .vif with version=2, but the .dat superblock is version=3
let vif = VifVolumeInfo {
version: VERSION_2.0 as u32,
bytes_offset: OFFSET_SIZE as u32,
..VifVolumeInfo::default()
};
std::fs::write(
format!("{}/1.vif", dir),
serde_json::to_string_pretty(&vif).unwrap(),
)
.unwrap();
}
let v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
// Superblock version (3) overrides the .vif version (2)
assert_eq!(v.volume_info.version, VERSION_3.0 as u32);
assert_eq!(v.version(), VERSION_3);
}
#[test]
fn test_load_vif_rejects_bytes_offset_mismatch() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
{
let _v = make_test_volume(dir);
let vif = VifVolumeInfo {
version: Version::current().0 as u32,
bytes_offset: (OFFSET_SIZE as u32) + 1,
..VifVolumeInfo::default()
};
std::fs::write(
format!("{}/1.vif", dir),
serde_json::to_string_pretty(&vif).unwrap(),
)
.unwrap();
}
let result = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
);
match result {
Ok(_) => panic!("expected bytes_offset mismatch to fail"),
Err(VolumeError::Io(io_err)) => {
assert_eq!(io_err.kind(), io::ErrorKind::InvalidData);
assert!(io_err.to_string().contains("bytes_offset mismatch"));
}
Err(other) => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn test_remote_only_volume_load_reads_from_tier_backend() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let dat_bytes = {
let mut v = make_test_volume(dir);
let mut n = Needle {
id: NeedleId(7),
cookie: Cookie(0x7788),
data: b"remote-only".to_vec(),
data_size: 11,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
v.sync_to_disk().unwrap();
std::fs::read(v.file_name(".dat")).unwrap()
};
let dat_path = format!("{}/1.dat", dir);
std::fs::remove_file(&dat_path).unwrap();
let (endpoint, shutdown_tx) = spawn_fake_s3_server(dat_bytes.clone());
// Use a test-specific backend_id to avoid racing with other tests
// that share the global registry. Never call clear() — only
// register/remove our own entries.
let tier_config = crate::remote_storage::s3_tier::S3TierConfig {
access_key: "access".to_string(),
secret_key: "secret".to_string(),
region: "us-east-1".to_string(),
bucket: "bucket-a".to_string(),
endpoint,
storage_class: "STANDARD".to_string(),
force_path_style: true,
};
{
let mut registry = crate::remote_storage::s3_tier::global_s3_tier_registry()
.write()
.unwrap();
registry.register(
"s3.remote_only_rw".to_string(),
crate::remote_storage::s3_tier::S3TierBackend::new(&tier_config),
);
}
let vif = VifVolumeInfo {
files: vec![VifRemoteFile {
backend_type: "s3".to_string(),
backend_id: "remote_only_rw".to_string(),
key: "remote-key".to_string(),
offset: 0,
file_size: dat_bytes.len() as u64,
modified_time: 123,
extension: ".dat".to_string(),
}],
version: Version::current().0 as u32,
bytes_offset: OFFSET_SIZE as u32,
dat_file_size: dat_bytes.len() as i64,
..VifVolumeInfo::default()
};
std::fs::write(
format!("{}/1.vif", dir),
serde_json::to_string_pretty(&vif).unwrap(),
)
.unwrap();
let v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert!(v.has_remote_file);
assert!(v.dat_file.is_none());
assert!(v.remote_dat_file.is_some());
let mut n = Needle {
id: NeedleId(7),
..Needle::default()
};
let size = v.read_needle(&mut n).unwrap();
assert_eq!(size, 11);
assert_eq!(n.data, b"remote-only");
let mut meta = Needle {
id: NeedleId(7),
..Needle::default()
};
let info = v.read_needle_stream_info(&mut meta, false).unwrap();
assert!(matches!(info.source, NeedleStreamSource::Remote(_)));
let mut streamed = vec![0u8; info.data_size as usize];
info.source
.read_exact_at(&mut streamed, info.data_file_offset)
.unwrap();
assert_eq!(streamed, b"remote-only");
assert_eq!(meta.data_size, 11);
let _ = shutdown_tx.send(());
crate::remote_storage::s3_tier::global_s3_tier_registry()
.write()
.unwrap()
.remove("s3.remote_only_rw");
}
/// Volume destroy removes .vif alongside the primary data files.
#[test]
fn test_destroy_removes_vif() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(1),
data: b"test".to_vec(),
data_size: 4,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
// Write a .vif file (as EC encode would)
let vif_path = format!("{}/1.vif", dir);
std::fs::write(&vif_path, r#"{"version":3}"#).unwrap();
assert!(std::path::Path::new(&vif_path).exists());
// .dat and .idx should exist
let dat_path = format!("{}/1.dat", dir);
let idx_path = format!("{}/1.idx", dir);
assert!(std::path::Path::new(&dat_path).exists());
assert!(std::path::Path::new(&idx_path).exists());
// Destroy the volume
v.destroy(false, false).unwrap();
// .dat and .idx should be gone
assert!(
!std::path::Path::new(&dat_path).exists(),
".dat should be removed"
);
assert!(
!std::path::Path::new(&idx_path).exists(),
".idx should be removed"
);
assert!(
!std::path::Path::new(&vif_path).exists(),
".vif should be removed"
);
}
/// Volume destroy with separate idx directory must clean up both dirs and .vif.
#[test]
fn test_destroy_with_separate_idx_dir() {
let dat_tmp = TempDir::new().unwrap();
let idx_tmp = TempDir::new().unwrap();
let dat_dir = dat_tmp.path().to_str().unwrap();
let idx_dir = idx_tmp.path().to_str().unwrap();
let mut v = Volume::new(
dat_dir,
idx_dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(1),
data: b"hello".to_vec(),
data_size: 5,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
// Write .vif in data dir (as EC encode would)
let vif_path = format!("{}/1.vif", dat_dir);
std::fs::write(&vif_path, r#"{"version":3}"#).unwrap();
let dat_path = format!("{}/1.dat", dat_dir);
let idx_path = format!("{}/1.idx", idx_dir);
assert!(std::path::Path::new(&dat_path).exists());
assert!(std::path::Path::new(&idx_path).exists());
v.destroy(false, false).unwrap();
assert!(
!std::path::Path::new(&dat_path).exists(),
".dat removed from data dir"
);
assert!(
!std::path::Path::new(&idx_path).exists(),
".idx removed from idx dir"
);
assert!(
!std::path::Path::new(&vif_path).exists(),
".vif removed from data dir"
);
}
/// When an EC volume for the same id has a sealed .ecx on the same disk, the
/// .vif is shared with it and must survive the regular volume's deletion.
#[test]
fn test_destroy_keeps_vif_when_ec_coexists() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(1),
data: b"test".to_vec(),
data_size: 4,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
let vif_path = format!("{}/1.vif", dir);
std::fs::write(&vif_path, r#"{"version":3}"#).unwrap();
// A sealed .ecx marks a coexisting EC volume for the same id.
let ecx_path = format!("{}/1.ecx", dir);
std::fs::write(&ecx_path, b"ec-index").unwrap();
v.destroy(false, false).unwrap();
let dat_path = format!("{}/1.dat", dir);
let idx_path = format!("{}/1.idx", dir);
assert!(!std::path::Path::new(&dat_path).exists(), ".dat removed");
assert!(!std::path::Path::new(&idx_path).exists(), ".idx removed");
assert!(
std::path::Path::new(&vif_path).exists(),
".vif kept: shared with the coexisting EC volume"
);
assert!(
std::path::Path::new(&ecx_path).exists(),
".ecx is an EC sidecar, never touched here"
);
}
/// A crash after the .idx/.dat were consumed but before the .cpx->.idx
/// rename committed leaves only .cpd + .cpx + .cpc. reconcile_compact_state
/// must roll the swap FORWARD and produce a loadable, writable volume
/// holding the compacted needle count.
#[test]
fn test_reconcile_roll_forward_marker_only() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
for i in 1..=6u64 {
let mut n = Needle {
id: NeedleId(i),
cookie: Cookie(i as u32),
data: format!("data-{}", i).into_bytes(),
data_size: format!("data-{}", i).len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
}
for id in [2u64, 5u64] {
let mut del = Needle {
id: NeedleId(id),
cookie: Cookie(id as u32),
..Needle::default()
};
v.delete_needle(&mut del).unwrap();
}
let expected_live = v.file_count() - v.deleted_count();
v.compact_by_index(0, 0, |_| true).unwrap();
v.close();
let cpd = v.file_name(".cpd");
let cpx = v.file_name(".cpx");
let cpc = v.file_name(".cpc");
let dat = v.file_name(".dat");
let idx = v.file_name(".idx");
assert!(Path::new(&cpd).exists());
assert!(Path::new(&cpx).exists());
// Simulate the mid-commit crash: original .dat/.idx are gone and only
// the compacted temp files plus the commit marker survive.
fs::remove_file(&dat).unwrap();
fs::remove_file(&idx).unwrap();
let _ = fs::remove_dir_all(v.file_name(".ldb"));
fs::write(&cpc, b"").unwrap();
v.reconcile_compact_state().unwrap();
assert!(!Path::new(&cpd).exists(), ".cpd consumed by roll-forward");
assert!(!Path::new(&cpx).exists(), ".cpx consumed by roll-forward");
assert!(!Path::new(&cpc).exists(), ".cpc cleared after swap");
assert!(Path::new(&dat).exists(), ".dat restored by roll-forward");
assert!(Path::new(&idx).exists(), ".idx restored by roll-forward");
// The rolled-forward files must load as a writable volume with the
// compacted needle count.
let reloaded = make_test_volume(dir);
assert!(!reloaded.is_read_only(), "rolled-forward volume read-only");
assert_eq!(
reloaded.file_count(),
expected_live,
"rolled-forward volume needle count"
);
}
/// With no .cpc marker, leftover .cpd/.cpx are an abandoned generation;
/// reconcile must roll BACK by deleting them and leave the live .dat/.idx.
#[test]
fn test_reconcile_roll_back_no_marker() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
for i in 1..=4u64 {
let mut n = Needle {
id: NeedleId(i),
cookie: Cookie(i as u32),
data: format!("data-{}", i).into_bytes(),
data_size: format!("data-{}", i).len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
}
v.compact_by_index(0, 0, |_| true).unwrap();
let cpd = v.file_name(".cpd");
let cpx = v.file_name(".cpx");
let dat = v.file_name(".dat");
let idx = v.file_name(".idx");
assert!(Path::new(&cpd).exists());
assert!(Path::new(&cpx).exists());
// No .cpc marker exists.
v.reconcile_compact_state().unwrap();
assert!(!Path::new(&cpd).exists(), ".cpd rolled back");
assert!(!Path::new(&cpx).exists(), ".cpx rolled back");
assert!(Path::new(&dat).exists(), "live .dat kept");
assert!(Path::new(&idx).exists(), "live .idx kept");
}
/// apply_compact_swap must abort without touching the live .dat/.idx when
/// the .cpd/.cpx temp files are missing (a stale or duplicate commit).
#[test]
fn test_apply_compact_swap_missing_temp_files_preserves_live() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
for i in 1..=3u64 {
let mut n = Needle {
id: NeedleId(i),
cookie: Cookie(i as u32),
data: format!("data-{}", i).into_bytes(),
data_size: format!("data-{}", i).len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true, false).unwrap();
}
v.sync_to_disk().unwrap();
let dat = v.file_name(".dat");
let idx = v.file_name(".idx");
let dat_len_before = fs::metadata(&dat).unwrap().len();
// No .cpd/.cpx present: the swap must refuse.
assert!(
v.apply_compact_swap().is_err(),
"swap should error when .cpd/.cpx are missing"
);
assert!(Path::new(&dat).exists(), "live .dat preserved");
assert!(Path::new(&idx).exists(), "live .idx preserved");
assert_eq!(
fs::metadata(&dat).unwrap().len(),
dat_len_before,
"live .dat unchanged"
);
}
}