mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-09 21:38:19 +01:00
5668 lines
203 KiB
Rust
5668 lines
203 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::{info, warn};
|
|
|
|
#[cfg(test)]
|
|
use crate::storage::idx;
|
|
use crate::storage::needle::needle::{self, get_actual_size, Needle, NeedleError};
|
|
use crate::storage::needle_map::{CompactNeedleMap, NeedleMap, NeedleMapKind, RedbNeedleMap};
|
|
use crate::storage::super_block::{ReplicaPlacement, SuperBlock, SUPER_BLOCK_SIZE};
|
|
use crate::storage::types::*;
|
|
|
|
// ============================================================================
|
|
// 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,
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
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,
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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>,
|
|
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,
|
|
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,
|
|
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 {
|
|
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 OpenOptions::new().read(true).write(true).open(&dat_path) {
|
|
Ok(file) => {
|
|
self.dat_file = Some(file);
|
|
}
|
|
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
|
|
self.dat_file = Some(File::open(&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 = OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.open(&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 {
|
|
// 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"
|
|
);
|
|
}
|
|
|
|
// 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)?;
|
|
}
|
|
|
|
if use_redb {
|
|
self.load_index_redb(&idx_path)?;
|
|
} else {
|
|
self.load_index_inmemory(&idx_path)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 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 = File::open(&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 = OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.open(&idx_path)?;
|
|
|
|
let idx_size = idx_file.metadata()?.len();
|
|
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");
|
|
|
|
if self.no_write_or_delete {
|
|
// Open read-only
|
|
if Path::new(&idx_path).exists() {
|
|
let mut idx_file = File::open(&idx_path)?;
|
|
let nm = RedbNeedleMap::load_from_idx(&rdb_path, &mut idx_file, self.version())?;
|
|
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)?));
|
|
}
|
|
} else {
|
|
// Open read-write (create if missing)
|
|
let idx_file = OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.open(&idx_path)?;
|
|
|
|
let idx_size = idx_file.metadata()?.len();
|
|
let mut idx_reader = io::BufReader::new(&idx_file);
|
|
let mut nm = RedbNeedleMap::load_from_idx(&rdb_path, &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::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).
|
|
pub fn write_needle(
|
|
&mut self,
|
|
n: &mut Needle,
|
|
check_cookie: 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)
|
|
}
|
|
|
|
fn do_write_request(
|
|
&mut self,
|
|
n: &mut Needle,
|
|
check_cookie: 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;
|
|
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)?;
|
|
self.last_append_at_ns = n.append_at_ns;
|
|
|
|
// Update needle map (uses n.size = full body size, matching Go's nm.Put)
|
|
let should_update = if let Some(nm) = &self.nm {
|
|
match nm.get(n.id) {
|
|
Some(nv) => (nv.offset.to_actual_offset() as u64) < offset,
|
|
None => true,
|
|
}
|
|
} else {
|
|
true
|
|
};
|
|
|
|
if should_update {
|
|
if let Some(nm) = &mut self.nm {
|
|
nm.put(n.id, Offset::from_actual_offset(offset as i64), n.size)?;
|
|
}
|
|
}
|
|
|
|
if self.last_modified_ts_seconds < n.last_modified {
|
|
self.last_modified_ts_seconds = n.last_modified;
|
|
}
|
|
|
|
// Return Size(n.DataSize) as the logical size, matching Go's doWriteRequest
|
|
Ok((offset, Size(n.data_size as i32), false))
|
|
}
|
|
|
|
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 {
|
|
if let Some(nv) = nm.get(n.id) {
|
|
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 {
|
|
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))?;
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// .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, including its
|
|
/// v3-only tail check.
|
|
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_offset = checked_offset as u64 + NEEDLE_HEADER_SIZE as u64 + size.0 as u64 + 4; // skip checksum
|
|
let mut ts_buf = [0u8; 8];
|
|
self.read_exact_at_backend(&mut ts_buf, ts_offset)?;
|
|
let ts = u64::from_be_bytes(ts_buf);
|
|
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)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Mark this volume as read-only (no writes or deletes).
|
|
/// If `persist` is true, the readonly state is saved to the .vif file.
|
|
pub fn set_read_only(&mut self) -> Result<(), VolumeError> {
|
|
self.no_write_or_delete = true;
|
|
self.save_vif()
|
|
}
|
|
|
|
/// Mark this volume as read-only, optionally persisting to .vif.
|
|
pub fn set_read_only_persist(&mut self, persist: bool) -> Result<(), VolumeError> {
|
|
self.no_write_or_delete = true;
|
|
if persist {
|
|
self.save_vif()?;
|
|
}
|
|
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> {
|
|
// Attach the writer (if missing) before flipping the flag — otherwise
|
|
// a transient open/metadata failure would leave the volume marked
|
|
// writable with no .idx writer, and subsequent puts would silently
|
|
// skip the on-disk append and vanish on the next restart.
|
|
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 = write_file.metadata()?.len();
|
|
if let Some(ref mut nm) = self.nm {
|
|
nm.set_idx_file(Box::new(write_file), idx_size);
|
|
}
|
|
}
|
|
self.no_write_or_delete = false;
|
|
self.save_vif()
|
|
}
|
|
|
|
/// Recompute the Go-style write/delete mode from the current remote tier state.
|
|
pub fn refresh_remote_write_mode(&mut self) {
|
|
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 {
|
|
self.no_write_can_delete = false;
|
|
}
|
|
}
|
|
|
|
/// 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 = OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.open(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 {
|
|
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(&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),
|
|
)));
|
|
}
|
|
}
|
|
|
|
let mut vif = VifVolumeInfo::from_pb(&self.volume_info);
|
|
vif.read_only = self.no_write_or_delete;
|
|
|
|
// Match Go's SaveVolumeInfo: compute ExpireAtSec from TTL
|
|
let ttl_seconds = self.super_block.ttl.to_seconds();
|
|
if ttl_seconds > 0 {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
vif.expire_at_sec = now + ttl_seconds;
|
|
}
|
|
|
|
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> {
|
|
self.volume_info.read_only = self.no_write_or_delete;
|
|
|
|
// Compute ExpireAtSec from TTL (matches Go's SaveVolumeInfo)
|
|
let ttl_seconds = self.super_block.ttl.to_seconds();
|
|
if ttl_seconds > 0 {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
self.volume_info.expire_at_sec = now + ttl_seconds;
|
|
}
|
|
|
|
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)?;
|
|
}
|
|
|
|
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() {
|
|
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) {
|
|
if let Some(ref dat_file) = self.dat_file {
|
|
let _ = dat_file.sync_all();
|
|
}
|
|
self.dat_file = None;
|
|
self.remote_dat_file = None;
|
|
if let Some(ref nm) = self.nm {
|
|
let _ = nm.sync();
|
|
}
|
|
self.nm = None;
|
|
}
|
|
|
|
/// 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 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.
|
|
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
|
|
),
|
|
))
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
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).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));
|
|
}
|
|
|
|
#[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).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).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).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).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"
|
|
);
|
|
}
|
|
|
|
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).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.
|
|
#[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).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).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).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).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).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).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).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).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_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).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).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).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).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).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).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).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).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).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).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).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).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).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,
|
|
)
|
|
.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");
|
|
}
|
|
|
|
#[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();
|
|
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).unwrap();
|
|
v.set_read_only_persist(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!(
|
|
!v.nm.as_ref().unwrap().has_idx_writer(),
|
|
"read-only load should not attach an .idx writer"
|
|
);
|
|
|
|
v.set_writable().unwrap();
|
|
assert!(!v.is_read_only());
|
|
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).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");
|
|
}
|
|
|
|
#[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).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).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).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).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).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).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).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"
|
|
);
|
|
}
|
|
}
|