use std::borrow::Cow;
use std::collections::HashSet;
use std::time::Duration;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct HumanDuration(pub Duration);
impl From<Duration> for HumanDuration {
fn from(d: Duration) -> Self {
Self(d)
}
}
impl Serialize for HumanDuration {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(&humantime::format_duration(self.0))
}
}
impl<'de> Deserialize<'de> for HumanDuration {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
humantime::parse_duration(&s)
.map(HumanDuration)
.map_err(serde::de::Error::custom)
}
}
impl JsonSchema for HumanDuration {
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("HumanDuration")
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"type": "string",
"description": "A duration in humantime form, e.g. \"10s\", \"5m\", \"1h 30m\".",
"pattern": "^([0-9]+ *[a-zµ]+ *)+$"
})
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(title = "sleet fleet config")]
pub struct SleetConfig {
#[serde(default)]
pub node: NodeConfig,
#[serde(default)]
pub database: DatabaseConfig,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NodeConfig {
#[serde(default = "default_heartbeat_interval")]
pub heartbeat_interval: HumanDuration,
#[serde(default = "default_heartbeat_timeout")]
pub heartbeat_timeout: HumanDuration,
#[serde(default = "default_config_poll")]
pub config_poll: HumanDuration,
}
impl Default for NodeConfig {
fn default() -> Self {
Self {
heartbeat_interval: default_heartbeat_interval(),
heartbeat_timeout: default_heartbeat_timeout(),
config_poll: default_config_poll(),
}
}
}
fn default_heartbeat_interval() -> HumanDuration {
Duration::from_secs(10).into()
}
fn default_heartbeat_timeout() -> HumanDuration {
Duration::from_secs(30).into()
}
fn default_config_poll() -> HumanDuration {
Duration::from_secs(60).into()
}
#[derive(
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
#[serde(rename_all = "kebab-case")]
pub enum Service {
Gc,
CompactorCoordinator,
CompactionWorkers,
Mirror,
}
impl Service {
pub fn as_str(self) -> &'static str {
match self {
Service::Gc => "gc",
Service::CompactorCoordinator => "compactor-coordinator",
Service::CompactionWorkers => "compaction-workers",
Service::Mirror => "mirror",
}
}
pub fn letter(self) -> char {
match self {
Service::Gc => 'g',
Service::CompactorCoordinator => 'c',
Service::CompactionWorkers => 'w',
Service::Mirror => 'm',
}
}
pub fn from_letter(letter: char) -> Option<Self> {
match letter {
'g' => Some(Service::Gc),
'c' => Some(Service::CompactorCoordinator),
'w' => Some(Service::CompactionWorkers),
'm' => Some(Service::Mirror),
_ => None,
}
}
pub const ALL: [Service; 4] = [
Service::Gc,
Service::CompactorCoordinator,
Service::CompactionWorkers,
Service::Mirror,
];
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(title = "sleet database config")]
pub struct DatabaseConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub services: Option<Vec<Service>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gc: Option<GcOverrides>,
#[serde(
rename = "compactor-coordinator",
skip_serializing_if = "Option::is_none"
)]
pub compactor_coordinator: Option<CoordinatorOverrides>,
#[serde(rename = "compaction-workers", skip_serializing_if = "Option::is_none")]
pub compaction_workers: Option<WorkersOverrides>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mirror: Option<MirrorOverrides>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GcOverrides {
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest: Option<GcDirectoryOverrides>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wal: Option<GcDirectoryOverrides>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wal_fence: Option<GcDirectoryOverrides>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compacted: Option<GcDirectoryOverrides>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compactions: Option<GcDirectoryOverrides>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detach: Option<GcDetachOverrides>,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GcDirectoryOverrides {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interval: Option<HumanDuration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_age: Option<HumanDuration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dry_run: Option<bool>,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GcDetachOverrides {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interval: Option<HumanDuration>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CoordinatorOverrides {
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_interval: Option<HumanDuration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest_update_timeout: Option<HumanDuration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_concurrent_compactions: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit_compacted_interval: Option<HumanDuration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub worker_heartbeat_timeout: Option<HumanDuration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scheduler: Option<SchedulerOverrides>,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SchedulerOverrides {
#[serde(skip_serializing_if = "Option::is_none")]
pub min_compaction_sources: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_compaction_sources: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_size_threshold: Option<f32>,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkersOverrides {
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_concurrent_compactions: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compactions_poll_interval: Option<HumanDuration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub heartbeat_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub heartbeat_min_interval: Option<HumanDuration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_sst_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_fetch_tasks: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes_to_fetch: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_subcompactions: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_filter_keys: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compression_codec: Option<CompressionCodec>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum CompressionCodec {
Snappy,
Zlib,
Lz4,
Zstd,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MirrorOverrides {
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub targets: std::collections::BTreeMap<String, MirrorTargetOverrides>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MirrorTargetOverrides {
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<MirrorMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub copier: Option<CopierKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub poll: Option<HumanDuration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interval: Option<HumanDuration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_age: Option<HumanDuration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub checkpoint_lifetime: Option<HumanDuration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub copy_parallelism: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retention: Option<RetentionOverrides>,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RetentionOverrides {
#[serde(skip_serializing_if = "Option::is_none")]
pub keep: Option<HumanDuration>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum MirrorMode {
Continuous,
Periodic,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum CopierKind {
Builtin,
Rclone,
External,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedServices {
pub services: Vec<Service>,
pub gc: ResolvedGc,
pub coordinator: ResolvedCoordinator,
pub workers: ResolvedWorkers,
pub mirror: ResolvedMirror,
}
impl Default for ResolvedServices {
fn default() -> Self {
Self {
services: Service::ALL.to_vec(),
gc: ResolvedGc::default(),
coordinator: ResolvedCoordinator::default(),
workers: ResolvedWorkers::default(),
mirror: ResolvedMirror::default(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ResolvedMirror {
pub targets: std::collections::BTreeMap<String, ResolvedMirrorTarget>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedMirrorTarget {
pub url: Option<String>,
pub source_prefix: Option<String>,
pub disabled: bool,
pub mode: MirrorMode,
pub copier: CopierKind,
pub poll: Duration,
pub interval: Duration,
pub min_age: Duration,
pub checkpoint_lifetime: Duration,
pub copy_parallelism: u32,
pub keep: Option<Duration>,
}
impl Default for ResolvedMirrorTarget {
fn default() -> Self {
Self {
url: None,
source_prefix: None,
disabled: false,
mode: MirrorMode::Continuous,
copier: CopierKind::Builtin,
poll: Duration::from_secs(10),
interval: Duration::from_secs(24 * 60 * 60),
min_age: Duration::from_secs(300),
checkpoint_lifetime: Duration::from_secs(15 * 60),
copy_parallelism: 8,
keep: None,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedGc {
pub manifest: ResolvedGcDirectory,
pub wal: ResolvedGcDirectory,
pub wal_fence: ResolvedGcDirectory,
pub compacted: ResolvedGcDirectory,
pub compactions: ResolvedGcDirectory,
pub detach: ResolvedGcDetach,
}
impl Default for ResolvedGc {
fn default() -> Self {
let dir = ResolvedGcDirectory::default();
Self {
manifest: dir,
wal: dir,
wal_fence: ResolvedGcDirectory {
dry_run: true,
..dir
},
compacted: dir,
compactions: dir,
detach: ResolvedGcDetach::default(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ResolvedGcDirectory {
pub enabled: bool,
pub interval: Duration,
pub min_age: Duration,
pub dry_run: bool,
}
impl Default for ResolvedGcDirectory {
fn default() -> Self {
Self {
enabled: true,
interval: Duration::from_secs(60),
min_age: Duration::from_secs(300),
dry_run: false,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ResolvedGcDetach {
pub enabled: bool,
pub interval: Duration,
}
impl Default for ResolvedGcDetach {
fn default() -> Self {
Self {
enabled: true,
interval: Duration::from_secs(60),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ResolvedCoordinator {
pub poll_interval: Duration,
pub manifest_update_timeout: Duration,
pub max_concurrent_compactions: u32,
pub commit_compacted_interval: Duration,
pub worker_heartbeat_timeout: Duration,
pub scheduler: ResolvedScheduler,
}
impl Default for ResolvedCoordinator {
fn default() -> Self {
Self {
poll_interval: Duration::from_secs(5),
manifest_update_timeout: Duration::from_secs(300),
max_concurrent_compactions: 4,
commit_compacted_interval: Duration::from_secs(1),
worker_heartbeat_timeout: Duration::from_secs(30),
scheduler: ResolvedScheduler::default(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ResolvedScheduler {
pub min_compaction_sources: u32,
pub max_compaction_sources: u32,
pub include_size_threshold: f32,
}
impl Default for ResolvedScheduler {
fn default() -> Self {
Self {
min_compaction_sources: 4,
max_compaction_sources: 8,
include_size_threshold: 4.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ResolvedWorkers {
pub count: u32,
pub max_concurrent_compactions: u32,
pub compactions_poll_interval: Duration,
pub heartbeat_bytes: u64,
pub heartbeat_min_interval: Duration,
pub max_sst_size: u64,
pub max_fetch_tasks: u32,
pub bytes_to_fetch: u64,
pub max_subcompactions: u32,
pub min_filter_keys: u32,
pub compression_codec: Option<CompressionCodec>,
}
impl Default for ResolvedWorkers {
fn default() -> Self {
Self {
count: 1,
max_concurrent_compactions: 4,
compactions_poll_interval: Duration::from_secs(5),
heartbeat_bytes: 5 * 1024 * 1024,
heartbeat_min_interval: Duration::from_secs(5),
max_sst_size: 256 * 1024 * 1024,
max_fetch_tasks: 4,
bytes_to_fetch: 2 * 1024 * 1024,
max_subcompactions: 4,
min_filter_keys: 1000,
compression_codec: None,
}
}
}
impl DatabaseConfig {
fn apply(&self, r: &mut ResolvedServices) {
if let Some(services) = &self.services {
r.services = services.clone();
}
if let Some(gc) = &self.gc {
gc.apply(&mut r.gc);
}
if let Some(coordinator) = &self.compactor_coordinator {
coordinator.apply(&mut r.coordinator);
}
if let Some(workers) = &self.compaction_workers {
workers.apply(&mut r.workers);
}
if let Some(mirror) = &self.mirror {
mirror.apply(&mut r.mirror);
}
}
}
impl MirrorOverrides {
fn apply(&self, r: &mut ResolvedMirror) {
for (name, target) in &self.targets {
target.apply(r.targets.entry(name.clone()).or_default());
}
}
}
impl MirrorTargetOverrides {
fn apply(&self, r: &mut ResolvedMirrorTarget) {
if self.url.is_some() || self.source_prefix.is_some() {
r.url = self.url.clone();
r.source_prefix = self.source_prefix.clone();
}
if let Some(v) = self.disabled {
r.disabled = v;
}
if let Some(v) = self.mode {
r.mode = v;
}
if let Some(v) = self.copier {
r.copier = v;
}
if let Some(v) = self.poll {
r.poll = v.0;
}
if let Some(v) = self.interval {
r.interval = v.0;
}
if let Some(v) = self.min_age {
r.min_age = v.0;
}
if let Some(v) = self.checkpoint_lifetime {
r.checkpoint_lifetime = v.0;
}
if let Some(v) = self.copy_parallelism {
r.copy_parallelism = v;
}
if let Some(retention) = &self.retention
&& let Some(keep) = retention.keep
{
r.keep = Some(keep.0);
}
}
}
impl GcOverrides {
fn apply(&self, r: &mut ResolvedGc) {
for (o, t) in [
(&self.manifest, &mut r.manifest),
(&self.wal, &mut r.wal),
(&self.wal_fence, &mut r.wal_fence),
(&self.compacted, &mut r.compacted),
(&self.compactions, &mut r.compactions),
] {
if let Some(o) = o {
o.apply(t);
}
}
if let Some(detach) = &self.detach {
detach.apply(&mut r.detach);
}
}
}
impl GcDirectoryOverrides {
fn apply(&self, r: &mut ResolvedGcDirectory) {
if let Some(v) = self.enabled {
r.enabled = v;
}
if let Some(v) = self.interval {
r.interval = v.0;
}
if let Some(v) = self.min_age {
r.min_age = v.0;
}
if let Some(v) = self.dry_run {
r.dry_run = v;
}
}
}
impl GcDetachOverrides {
fn apply(&self, r: &mut ResolvedGcDetach) {
if let Some(v) = self.enabled {
r.enabled = v;
}
if let Some(v) = self.interval {
r.interval = v.0;
}
}
}
impl CoordinatorOverrides {
fn apply(&self, r: &mut ResolvedCoordinator) {
if let Some(v) = self.poll_interval {
r.poll_interval = v.0;
}
if let Some(v) = self.manifest_update_timeout {
r.manifest_update_timeout = v.0;
}
if let Some(v) = self.max_concurrent_compactions {
r.max_concurrent_compactions = v;
}
if let Some(v) = self.commit_compacted_interval {
r.commit_compacted_interval = v.0;
}
if let Some(v) = self.worker_heartbeat_timeout {
r.worker_heartbeat_timeout = v.0;
}
if let Some(s) = &self.scheduler {
s.apply(&mut r.scheduler);
}
}
}
impl SchedulerOverrides {
fn apply(&self, r: &mut ResolvedScheduler) {
if let Some(v) = self.min_compaction_sources {
r.min_compaction_sources = v;
}
if let Some(v) = self.max_compaction_sources {
r.max_compaction_sources = v;
}
if let Some(v) = self.include_size_threshold {
r.include_size_threshold = v;
}
}
}
impl WorkersOverrides {
fn apply(&self, r: &mut ResolvedWorkers) {
if let Some(v) = self.count {
r.count = v;
}
if let Some(v) = self.max_concurrent_compactions {
r.max_concurrent_compactions = v;
}
if let Some(v) = self.compactions_poll_interval {
r.compactions_poll_interval = v.0;
}
if let Some(v) = self.heartbeat_bytes {
r.heartbeat_bytes = v;
}
if let Some(v) = self.heartbeat_min_interval {
r.heartbeat_min_interval = v.0;
}
if let Some(v) = self.max_sst_size {
r.max_sst_size = v;
}
if let Some(v) = self.max_fetch_tasks {
r.max_fetch_tasks = v;
}
if let Some(v) = self.bytes_to_fetch {
r.bytes_to_fetch = v;
}
if let Some(v) = self.max_subcompactions {
r.max_subcompactions = v;
}
if let Some(v) = self.min_filter_keys {
r.min_filter_keys = v;
}
if let Some(v) = self.compression_codec {
r.compression_codec = Some(v);
}
}
}
impl SleetConfig {
pub fn resolve(&self, db: Option<&DatabaseConfig>) -> ResolvedServices {
let mut r = ResolvedServices::default();
self.database.apply(&mut r);
if let Some(db) = db {
db.apply(&mut r);
}
r
}
}
#[derive(Debug, thiserror::Error)]
#[error("invalid config:\n {}", .0.join("\n "))]
pub struct ConfigError(pub Vec<String>);
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("failed to parse config: {0}")]
Toml(#[from] toml::de::Error),
#[error(transparent)]
Invalid(#[from] ConfigError),
}
pub fn parse_config(toml: &str) -> Result<SleetConfig, ParseError> {
let config: SleetConfig = toml::from_str(toml)?;
config.validate()?;
Ok(config)
}
pub fn parse_database(fleet: &SleetConfig, toml: &str) -> Result<DatabaseConfig, ParseError> {
let db: DatabaseConfig = toml::from_str(toml)?;
fleet.validate_database(&db)?;
Ok(db)
}
pub fn schema_json() -> String {
crate::schema_pretty::<SleetConfig>()
}
impl SleetConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
let mut errs = Vec::new();
if self.node.heartbeat_interval.0.is_zero() {
errs.push("node.heartbeat_interval must be > 0".into());
}
if self.node.heartbeat_interval >= self.node.heartbeat_timeout {
errs.push(format!(
"node.heartbeat_interval ({}) must be < node.heartbeat_timeout ({})",
humantime::format_duration(self.node.heartbeat_interval.0),
humantime::format_duration(self.node.heartbeat_timeout.0),
));
}
if self.node.config_poll.0.is_zero() {
errs.push("node.config_poll must be > 0".into());
}
check_database(&self.database, "database", &mut errs);
check_resolved(&self.resolve(None), "database", &mut errs);
if errs.is_empty() {
Ok(())
} else {
Err(ConfigError(errs))
}
}
pub fn validate_database(&self, db: &DatabaseConfig) -> Result<(), ConfigError> {
let mut errs = Vec::new();
check_database(db, "", &mut errs);
check_resolved(&self.resolve(Some(db)), "resolved", &mut errs);
if errs.is_empty() {
Ok(())
} else {
Err(ConfigError(errs))
}
}
}
fn loc(at: &str, field: &str) -> String {
if at.is_empty() {
field.to_string()
} else {
format!("{at}.{field}")
}
}
fn check_resolved(r: &ResolvedServices, at: &str, errs: &mut Vec<String>) {
let s = r.coordinator.scheduler;
if s.min_compaction_sources > s.max_compaction_sources {
errs.push(format!(
"{}: min_compaction_sources ({}) exceeds max_compaction_sources ({})",
loc(at, "compactor-coordinator.scheduler"),
s.min_compaction_sources,
s.max_compaction_sources
));
}
for (name, target) in &r.mirror.targets {
if target.disabled {
continue;
}
let table = format!("mirror.targets.{name}");
match &target.url {
None => errs.push(format!(
"{}: url is required unless the target is disabled",
loc(at, &table)
)),
Some(url) => {
if let Err(e) = crate::registry::canonicalize_url(url) {
errs.push(format!("{}.url: {e}", loc(at, &table)));
}
}
}
if let Some(prefix) = &target.source_prefix
&& let Err(e) = crate::registry::canonicalize_url(prefix)
{
errs.push(format!("{}.source_prefix: {e}", loc(at, &table)));
}
}
}
pub fn validate_target_name(name: &str) -> Result<(), String> {
let ok = !name.is_empty()
&& name.len() <= 128
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'));
if ok {
Ok(())
} else {
Err("mirror target names are 1-128 chars of [A-Za-z0-9_-]".to_string())
}
}
fn check_database(o: &DatabaseConfig, at: &str, errs: &mut Vec<String>) {
if let Some(services) = &o.services {
let mut seen = HashSet::new();
for s in services {
if !seen.insert(*s) {
errs.push(format!(
"{} lists {:?} more than once",
loc(at, "services"),
s.as_str()
));
}
}
}
if let Some(gc) = &o.gc {
for (name, dir) in [
("manifest", &gc.manifest),
("wal", &gc.wal),
("wal_fence", &gc.wal_fence),
("compacted", &gc.compacted),
("compactions", &gc.compactions),
] {
if let Some(dir) = dir
&& dir.interval.is_some_and(|d| d.0.is_zero())
{
errs.push(format!(
"{} must be > 0",
loc(at, &format!("gc.{name}.interval"))
));
}
}
if let Some(detach) = &gc.detach
&& detach.interval.is_some_and(|d| d.0.is_zero())
{
errs.push(format!("{} must be > 0", loc(at, "gc.detach.interval")));
}
}
if let Some(c) = &o.compactor_coordinator {
let cc = "compactor-coordinator";
for (name, d) in [
("poll_interval", c.poll_interval),
("manifest_update_timeout", c.manifest_update_timeout),
("commit_compacted_interval", c.commit_compacted_interval),
("worker_heartbeat_timeout", c.worker_heartbeat_timeout),
] {
if d.is_some_and(|d| d.0.is_zero()) {
errs.push(format!("{} must be > 0", loc(at, &format!("{cc}.{name}"))));
}
}
if c.max_concurrent_compactions == Some(0) {
errs.push(format!(
"{} must be >= 1",
loc(at, &format!("{cc}.max_concurrent_compactions"))
));
}
if let Some(s) = &c.scheduler {
if s.min_compaction_sources == Some(0) {
errs.push(format!(
"{} must be >= 1",
loc(at, &format!("{cc}.scheduler.min_compaction_sources"))
));
}
if s.max_compaction_sources == Some(0) {
errs.push(format!(
"{} must be >= 1",
loc(at, &format!("{cc}.scheduler.max_compaction_sources"))
));
}
if s.include_size_threshold
.is_some_and(|t| !(t.is_finite() && t > 0.0))
{
errs.push(format!(
"{} must be a positive number",
loc(at, &format!("{cc}.scheduler.include_size_threshold"))
));
}
}
}
if let Some(m) = &o.mirror {
for (name, t) in &m.targets {
let table = format!("mirror.targets.{name}");
if let Err(e) = validate_target_name(name) {
errs.push(format!("{}: {e}", loc(at, &table)));
}
for (field, d) in [
("poll", t.poll),
("interval", t.interval),
("min_age", t.min_age),
("checkpoint_lifetime", t.checkpoint_lifetime),
] {
if d.is_some_and(|d| d.0.is_zero()) {
errs.push(format!(
"{} must be > 0",
loc(at, &format!("{table}.{field}"))
));
}
}
if t.copy_parallelism == Some(0) {
errs.push(format!(
"{} must be >= 1",
loc(at, &format!("{table}.copy_parallelism"))
));
}
if let Some(retention) = &t.retention
&& retention.keep.is_some_and(|d| d.0.is_zero())
{
errs.push(format!(
"{} must be > 0",
loc(at, &format!("{table}.retention.keep"))
));
}
}
}
if let Some(w) = &o.compaction_workers {
let cw = "compaction-workers";
if w.count == Some(0) {
errs.push(format!(
"{} must be >= 1 (drop \"compaction-workers\" from services to run none)",
loc(at, &format!("{cw}.count"))
));
}
if w.max_concurrent_compactions == Some(0) {
errs.push(format!(
"{} must be >= 1",
loc(at, &format!("{cw}.max_concurrent_compactions"))
));
}
if w.compactions_poll_interval.is_some_and(|d| d.0.is_zero()) {
errs.push(format!(
"{} must be > 0",
loc(at, &format!("{cw}.compactions_poll_interval"))
));
}
if w.max_fetch_tasks == Some(0) {
errs.push(format!(
"{} must be >= 1",
loc(at, &format!("{cw}.max_fetch_tasks"))
));
}
for (name, v) in [
("max_sst_size", w.max_sst_size),
("bytes_to_fetch", w.bytes_to_fetch),
] {
if v == Some(0) {
errs.push(format!("{} must be > 0", loc(at, &format!("{cw}.{name}"))));
}
}
}
}