use std::collections::BTreeMap;
use std::time::Duration;
use arrow::datatypes::SchemaRef;
use chronix_core::{SegmentState, ShardId};
use chronix_engine::index::SegmentCatalogEntry;
use chronix_engine::objstore::{
ArchiveObject, ObjectStoreBackend, ObjectStoreConfig, ParquetArchiveWriter, TieringConfig,
TieringEngine,
};
use tracing::{info, warn};
use crate::Chronix;
use crate::error::{DbError, Result};
#[derive(Debug, Clone)]
pub struct ArchiveConfig {
pub cold_after: Duration,
pub remote_url: String,
pub max_objects_per_run: usize,
}
impl Default for ArchiveConfig {
fn default() -> Self {
Self {
cold_after: Duration::from_secs(30 * 86_400),
remote_url: String::new(),
max_objects_per_run: 8,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ArchiveOutcome {
pub objects: usize,
pub segments: usize,
pub rows: u64,
pub bytes: u64,
pub failed: usize,
pub more_pending: bool,
}
struct Group {
measurement: String,
shard_id: ShardId,
segments: Vec<SegmentCatalogEntry>,
min_timestamp: i64,
max_timestamp: i64,
}
impl Chronix {
pub async fn archive_cold_segments(&self, config: &ArchiveConfig) -> Result<ArchiveOutcome> {
if config.remote_url.is_empty() {
return Err(DbError::Internal(
"archive_cold_segments requires a remote_url".into(),
));
}
#[cfg(feature = "field-encryption")]
if !self.config.field_encryption.is_empty() {
let declared: Vec<&str> = self
.config
.field_encryption
.columns
.keys()
.map(String::as_str)
.collect();
self.refuse_encrypted_columns(declared, "the cold archive")?;
}
let now_ns = i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
)
.unwrap_or(i64::MAX);
let cutoff =
now_ns.saturating_sub(i64::try_from(config.cold_after.as_nanos()).unwrap_or(i64::MAX));
let (groups, read_lease) = self.cold_groups(cutoff, config.max_objects_per_run);
let more_pending = groups.len() >= config.max_objects_per_run;
if groups.is_empty() {
return Ok(ArchiveOutcome::default());
}
let remote = ObjectStoreBackend::new(ObjectStoreConfig {
url: config.remote_url.clone(),
cache: None,
multipart_threshold_bytes: 8 * 1024 * 1024,
max_concurrent_downloads: 4,
backoff: chronix_engine::objstore::BackoffConfig::default(),
})
.await
.map_err(|e| DbError::Internal(format!("cold archive store: {e}")))?;
let engine = TieringEngine::new(
TieringConfig {
cold_after: config.cold_after,
remote_url: config.remote_url.clone(),
},
remote,
);
let mut outcome = ArchiveOutcome::default();
let mut archived: Vec<Group> = Vec::new();
for group in groups {
let encoded = match self.encode_group(&group) {
Ok(Some(encoded)) => encoded,
Ok(None) => {
info!(
measurement = %group.measurement,
shard = group.shard_id.0,
"cold archive: group is empty after tombstones — dropping without an object"
);
archived.push(group);
continue;
}
Err(e) => {
warn!(
measurement = %group.measurement,
shard = group.shard_id.0,
error = %e,
"cold archive: could not encode group, leaving it hot"
);
outcome.failed += 1;
continue;
}
};
let object = match ArchiveObject::new(
group.measurement.clone(),
group.shard_id,
object_name(&group),
) {
Ok(object) => object,
Err(e) => {
warn!(
measurement = %group.measurement,
error = %e,
"cold archive: cannot name an object for this measurement"
);
outcome.failed += 1;
continue;
}
};
match engine.upload_archive(&object, &encoded.bytes).await {
Ok(key) => {
info!(
measurement = %group.measurement,
shard = group.shard_id.0,
object = %key,
rows = encoded.rows,
"group archived to cold storage"
);
outcome.rows += encoded.rows;
}
Err(e) => {
warn!(
measurement = %group.measurement,
shard = group.shard_id.0,
error = %e,
"cold archive: upload failed, leaving the group hot"
);
outcome.failed += 1;
continue;
}
}
outcome.objects += 1;
archived.push(group);
}
drop(read_lease);
if !archived.is_empty() {
self.drop_archived(&archived, &mut outcome);
}
outcome.more_pending = more_pending;
metrics::counter!("chronix_cold_archive_objects_total").increment(outcome.objects as u64);
metrics::counter!("chronix_cold_archive_rows_total").increment(outcome.rows);
metrics::counter!("chronix_cold_archive_bytes_total").increment(outcome.bytes);
if outcome.failed > 0 {
metrics::counter!("chronix_cold_archive_failures_total")
.increment(outcome.failed as u64);
}
Ok(outcome)
}
fn cold_groups(
&self,
cutoff: i64,
max_groups: usize,
) -> (Vec<Group>, crate::db::leases::SegmentLease<'_>) {
let (all, lease) = {
let catalog = self.catalog.read();
let all: Vec<SegmentCatalogEntry> = catalog
.all_segments()
.into_iter()
.filter(|e| e.state == SegmentState::Active)
.cloned()
.collect();
let lease = self
.segment_leases
.acquire(all.iter().map(|e| e.segment_id));
(all, lease)
};
let mut by_group: BTreeMap<(String, i64), Vec<SegmentCatalogEntry>> = BTreeMap::new();
for entry in all.iter().cloned() {
by_group
.entry((entry.measurement.clone(), entry.shard_id.0))
.or_default()
.push(entry);
}
let mut groups = Vec::new();
for ((measurement, shard), segments) in by_group {
if groups.len() >= max_groups {
break;
}
let Some(min_timestamp) = segments.iter().map(|e| e.min_timestamp).min() else {
continue;
};
let Some(max_timestamp) = segments.iter().map(|e| e.max_timestamp).max() else {
continue;
};
if max_timestamp >= cutoff {
continue;
}
let overlaps_outsider = all.iter().any(|e| {
e.measurement == measurement
&& e.shard_id.0 != shard
&& e.min_timestamp <= max_timestamp
&& e.max_timestamp >= min_timestamp
});
if overlaps_outsider {
warn!(
measurement = %measurement,
shard,
"cold archive: a segment outside this shard overlaps its range — keeping it hot"
);
metrics::counter!("chronix_cold_archive_groups_incomplete_total").increment(1);
continue;
}
if !self
.shards
.scan_measurement(&measurement, min_timestamp, max_timestamp)
.is_empty()
{
warn!(
measurement = %measurement,
shard,
"cold archive: unflushed rows overlap this group — keeping it hot"
);
metrics::counter!("chronix_cold_archive_groups_incomplete_total").increment(1);
continue;
}
if !self.rollups_materialised_past(&measurement, max_timestamp.saturating_add(1)) {
warn!(
measurement = %measurement,
shard,
"cold archive: rollups not yet materialised past this group — keeping it hot"
);
metrics::counter!("chronix_cold_archive_groups_awaiting_rollup_total").increment(1);
continue;
}
groups.push(Group {
measurement,
shard_id: ShardId(shard),
segments,
min_timestamp,
max_timestamp,
});
}
(groups, lease)
}
fn encode_group(&self, group: &Group) -> Result<Option<Encoded>> {
let schema = self.archive_schema(&group.measurement)?;
let plan = self
.query()
.measurement(&group.measurement)
.range(group.min_timestamp, group.max_timestamp)
.build()?;
let mut writer = ParquetArchiveWriter::new(schema.clone())
.map_err(|e| DbError::Internal(format!("cold archive writer: {e}")))?;
for batch in self.execute_iter(&plan)?.without_deadline() {
let batch = batch?;
if batch.num_rows() == 0 {
continue;
}
let batch = crate::sql::to_archive_batch(&batch, &schema)
.map_err(|e| DbError::Internal(format!("cold archive batch: {e}")))?;
writer
.write(&batch)
.map_err(|e| DbError::Internal(format!("cold archive write: {e}")))?;
}
let (bytes, rows) = writer
.finish()
.map_err(|e| DbError::Internal(format!("cold archive finish: {e}")))?;
if rows == 0 {
return Ok(None);
}
Ok(Some(Encoded { bytes, rows }))
}
fn archive_schema(&self, measurement: &str) -> Result<SchemaRef> {
let ms = self.schema(measurement).ok_or_else(|| {
DbError::Internal(format!(
"cold archive: no schema for measurement {measurement}"
))
})?;
Ok(crate::sql::measurement_schema_to_archive_arrow(&ms))
}
fn drop_archived(&self, archived: &[Group], outcome: &mut ArchiveOutcome) {
for group in archived {
let retired = self.retire_segments(&group.segments);
outcome.failed += group.segments.len() - retired.total();
outcome.segments += retired.total();
outcome.bytes += retired.bytes_freed;
}
if outcome.segments > 0 {
self.repair_live_series();
}
}
}
struct Encoded {
bytes: Vec<u8>,
rows: u64,
}
fn object_name(group: &Group) -> String {
format!(
"part-{}-{}.parquet",
group.min_timestamp, group.max_timestamp
)
}