use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use crate::sync::topology::{SyncNodeId, SyncTopology};
use crate::tree::TreePolicy;
use super::{CONFIG_FILE, DatabaseError};
pub const ON_DISK_FORMAT_VERSION: u32 = 2;
const V1_FORMAT_VERSION: u32 = 1;
const CHUNKING_V2_ID: &str = "bytes-v2";
pub const DEFAULT_LEAF_TARGET_BYTES: u64 = 64 * 1024;
pub const DEFAULT_INTERNAL_TARGET_BYTES: u64 = 48 * 1024;
const MIGRATE_RESUME_COMMAND: &str = "haem migrate-chunking";
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct ChunkingStamp {
chunking: String,
leaf_target_bytes: u64,
internal_target_bytes: u64,
}
impl ChunkingStamp {
fn default_v2() -> Self {
Self {
chunking: CHUNKING_V2_ID.to_owned(),
leaf_target_bytes: DEFAULT_LEAF_TARGET_BYTES,
internal_target_bytes: DEFAULT_INTERNAL_TARGET_BYTES,
}
}
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct MigrationFence {
source_policy: String,
target_policy: String,
phase: String,
}
pub(super) struct OpenedConfig {
pub config: DatabaseConfig,
pub policy: TreePolicy,
}
pub(super) const fn default_create_policy() -> TreePolicy {
TreePolicy::v2(DEFAULT_LEAF_TARGET_BYTES, DEFAULT_INTERNAL_TARGET_BYTES)
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct DatabaseConfig {
pub data_dir: PathBuf,
pub shard_count: usize,
pub distributed: Option<DistributedDatabaseConfig>,
#[serde(default)]
pub executor_threads: Option<usize>,
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct DistributedDatabaseConfig {
pub local_node: SyncNodeId,
pub nodes: Vec<SyncNodeId>,
pub topology: Option<SyncTopology>,
pub sync_interval: u64,
}
#[derive(serde::Serialize)]
struct StampedConfig<'config> {
format_version: u32,
chunking_policy: ChunkingStamp,
#[serde(flatten)]
config: &'config DatabaseConfig,
}
#[derive(serde::Deserialize)]
struct StoredConfig {
format_version: Option<u32>,
chunking_policy: Option<ChunkingStamp>,
migration_fence: Option<MigrationFence>,
#[serde(flatten)]
config: DatabaseConfig,
}
pub(super) fn write_config(config: &DatabaseConfig) -> Result<(), DatabaseError> {
let stamped = StampedConfig {
format_version: ON_DISK_FORMAT_VERSION,
chunking_policy: ChunkingStamp::default_v2(),
config,
};
let bytes = serde_json::to_vec_pretty(&stamped).map_err(|error| {
DatabaseError::ConfigWrite(io::Error::new(io::ErrorKind::InvalidData, error))
})?;
install_config_atomic(&config.data_dir.join(CONFIG_FILE), &bytes)
}
pub(super) fn ensure_uninitialised(data_dir: &Path) -> Result<(), DatabaseError> {
let config_path = data_dir.join(CONFIG_FILE);
if config_path.exists() {
read_config(data_dir)?;
return Err(DatabaseError::DataDirAlreadyInitialised { config_path });
}
Ok(())
}
pub(super) fn read_config(path: &Path) -> Result<OpenedConfig, DatabaseError> {
let bytes = fs::read(path.join(CONFIG_FILE)).map_err(DatabaseError::ConfigRead)?;
let value: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|error| DatabaseError::ConfigParse(error.to_string()))?;
validate_legacy_ttl_cadence(&value)?;
let stored: StoredConfig = serde_json::from_value(value)
.map_err(|error| DatabaseError::ConfigParse(error.to_string()))?;
if let Some(fence) = &stored.migration_fence {
log::warn!(
"refusing open of a fenced directory: chunking migration {} -> {} (phase {}); \
resume with `{MIGRATE_RESUME_COMMAND}`",
fence.source_policy,
fence.target_policy,
fence.phase,
);
return Err(DatabaseError::MigrationInProgress {
resume_command: MIGRATE_RESUME_COMMAND.to_owned(),
});
}
validate_format_version(stored.format_version)?;
let policy = derive_policy(stored.format_version, stored.chunking_policy.as_ref())?;
Ok(OpenedConfig {
config: stored.config,
policy,
})
}
fn derive_policy(
format_version: Option<u32>,
stamp: Option<&ChunkingStamp>,
) -> Result<TreePolicy, DatabaseError> {
match format_version {
None | Some(V1_FORMAT_VERSION) => Ok(TreePolicy::V1_DEFAULT),
Some(ON_DISK_FORMAT_VERSION) => {
let stamp = stamp.ok_or_else(|| {
DatabaseError::InvalidChunkingStamp(format!(
"format_version {ON_DISK_FORMAT_VERSION} requires a chunking_policy block"
))
})?;
if stamp.chunking != CHUNKING_V2_ID {
return Err(DatabaseError::InvalidChunkingStamp(format!(
"unknown chunking policy id {:?} (expected {CHUNKING_V2_ID:?})",
stamp.chunking
)));
}
if stamp.leaf_target_bytes == 0 || stamp.internal_target_bytes == 0 {
return Err(DatabaseError::InvalidChunkingStamp(format!(
"leaf/internal target bytes must be nonzero (got {}/{})",
stamp.leaf_target_bytes, stamp.internal_target_bytes
)));
}
Ok(TreePolicy::v2(
stamp.leaf_target_bytes,
stamp.internal_target_bytes,
))
}
Some(other) => Err(DatabaseError::ConfigParse(format!(
"format_version {other} has no chunking-policy mapping"
))),
}
}
fn validate_legacy_ttl_cadence(value: &serde_json::Value) -> Result<(), DatabaseError> {
const LEGACY_KEY: &str = "sweep_interval";
let Some(value) = value.as_object().and_then(|object| object.get(LEGACY_KEY)) else {
return Ok(());
};
match value {
serde_json::Value::Null => Ok(()),
serde_json::Value::Number(number) if number.as_u64() == Some(0) => {
Err(DatabaseError::InvalidSweepInterval)
}
serde_json::Value::Number(number) if number.as_u64().is_some() => Ok(()),
_ => Err(DatabaseError::ConfigParse(
"legacy TTL cadence must be null or an unsigned integer".to_owned(),
)),
}
}
fn validate_format_version(stamp: Option<u32>) -> Result<(), DatabaseError> {
match stamp {
None | Some(V1_FORMAT_VERSION | ON_DISK_FORMAT_VERSION) => Ok(()),
Some(found) if found > ON_DISK_FORMAT_VERSION => Err(DatabaseError::FormatVersionTooNew {
found,
supported: ON_DISK_FORMAT_VERSION,
}),
Some(found) => Err(DatabaseError::ConfigParse(format!(
"on-disk format_version {found} has no read arm in this binary (reads \
{V1_FORMAT_VERSION}..={ON_DISK_FORMAT_VERSION}; unstamped legacy reads as \
{V1_FORMAT_VERSION}; 0 was never issued — stamping begins at {V1_FORMAT_VERSION})"
))),
}
}
fn install_config_atomic(path: &Path, bytes: &[u8]) -> Result<(), DatabaseError> {
let parent = path.parent().ok_or_else(|| {
DatabaseError::ConfigWrite(io::Error::new(
io::ErrorKind::InvalidInput,
"config path has no parent directory",
))
})?;
let mut temp_file = tempfile::Builder::new()
.prefix(".config-")
.suffix(".tmp")
.tempfile_in(parent)
.map_err(DatabaseError::ConfigWrite)?;
temp_file
.write_all(bytes)
.map_err(DatabaseError::ConfigWrite)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
temp_file
.as_file()
.set_permissions(fs::Permissions::from_mode(0o644))
.map_err(DatabaseError::ConfigWrite)?;
}
temp_file
.as_file_mut()
.sync_all()
.map_err(DatabaseError::ConfigWrite)?;
temp_file
.persist(path)
.map(drop)
.map_err(|error| DatabaseError::ConfigWrite(error.error))?;
sync_parent_dir(parent)
}
#[cfg(unix)]
fn sync_parent_dir(parent: &Path) -> Result<(), DatabaseError> {
fs::File::open(parent)
.and_then(|dir| dir.sync_all())
.map_err(DatabaseError::ConfigWrite)
}
#[cfg(not(unix))]
fn sync_parent_dir(_parent: &Path) -> Result<(), DatabaseError> {
Ok(())
}
pub(super) fn validate_database_config(config: &DatabaseConfig) -> Result<(), DatabaseError> {
validate_shard_count(config.shard_count)?;
validate_executor_threads(config.executor_threads)?;
validate_distributed_config(config)?;
Ok(())
}
fn validate_executor_threads(executor_threads: Option<usize>) -> Result<(), DatabaseError> {
if executor_threads == Some(0) {
return Err(DatabaseError::ExecutorThreadsInvalid(
"executor_threads = 0 is not a valid worker count; set a positive integer or omit \
the field for the signed default min(shard_count, available_parallelism)"
.to_owned(),
));
}
Ok(())
}
const fn validate_shard_count(shard_count: usize) -> Result<(), DatabaseError> {
if shard_count == 0 {
Err(DatabaseError::InvalidShardCount)
} else {
Ok(())
}
}
fn validate_distributed_config(config: &DatabaseConfig) -> Result<(), DatabaseError> {
let Some(distributed) = &config.distributed else {
return Ok(());
};
let Some(topology) = &distributed.topology else {
return Err(DatabaseError::MissingSyncTopology);
};
if distributed.sync_interval == 0 {
return Err(DatabaseError::InvalidSyncInterval);
}
topology
.partners_for(&distributed.local_node, &distributed.nodes)
.map_err(|error| DatabaseError::SyncSchedulerError(error.to_string()))?;
Ok(())
}
mod migration_io;
pub(in crate::db) use migration_io::{
FenceView, MigrationState, finalize_v1, finalize_v2, flip_to_abort, install_forward_fence,
read_state,
};
#[cfg(test)]
#[path = "config_stamp_tests.rs"]
mod stamp_tests;