Skip to main content

chronix_engine/objstore/
tiering.rs

1//! Cold-storage archival: uploading an encoded archive object and verifying
2//! it landed.
3//!
4//! This engine moves bytes. It does not read segments and it does not encode —
5//! an archive is only correct if it is built from what the database would
6//! *answer*, and the read path lives above this crate, so the caller encodes
7//! ([`ParquetArchiveWriter`](crate::objstore::ParquetArchiveWriter)).
8//!
9//! It does not touch local files either. The catalog entry must be dropped
10//! before the file is deleted, or a crash between the two leaves the database
11//! pointing at a segment that is gone — and only the caller holding the
12//! catalog can order those two steps.
13
14use std::time::Duration;
15
16use serde::{Deserialize, Serialize};
17use tracing::{debug, info};
18
19use chronix_core::ShardId;
20
21use crate::objstore::backend::ObjectStoreBackend;
22use crate::objstore::error::{ObjStoreError, Result};
23
24/// Configuration for the cold-storage tiering policy.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TieringConfig {
27    /// How old a segment's max timestamp must be (relative to now) before
28    /// it is eligible for archival. Only segments whose `max_timestamp`
29    /// is older than `now - cold_after` are candidates.
30    pub cold_after: Duration,
31    /// URL of the remote object store (e.g. `s3://bucket/prefix`).
32    pub remote_url: String,
33}
34
35/// Where one archive object lives in the bucket.
36///
37/// The layout is `measurement=<m>/shard=<n>/<name>.parquet`: Hive-style
38/// partition directories, which DuckDB, Spark, Polars and DataFusion all
39/// expose as **columns** and prune whole directories on.
40///
41/// # Why `measurement=` and not `namespace=`
42///
43/// The old layout partitioned by namespace. It could not be populated: a
44/// namespace is a *tag on a series*, so one segment holds rows of many
45/// namespaces and there is no namespace to name the directory after — the
46/// caller passed `NamespaceId::default_namespace()` unconditionally, and every
47/// tenant's archive landed in one `namespace=default` directory that said
48/// nothing true. The namespace tag travels in the data instead, where it is
49/// queryable and correct.
50///
51/// Measurement *is* a segment-level property, and it is the partition an
52/// archive actually needs: one Parquet listing table requires one schema, and
53/// two measurements do not share one. Partitioning by measurement is what lets
54/// each measurement's archive be registered as its own table with the hot
55/// tier's schema.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ArchiveObject {
58    measurement: String,
59    shard_id: ShardId,
60    object_name: String,
61}
62
63impl ArchiveObject {
64    /// Name an archive object.
65    ///
66    /// # Errors
67    ///
68    /// Returns `InvalidConfig` if `measurement` or `object_name` is empty or
69    /// carries a path separator or a `..` component. A measurement name comes
70    /// from user data and is about to become a directory name, so it is
71    /// validated here rather than trusted.
72    pub fn new(
73        measurement: impl Into<String>,
74        shard_id: ShardId,
75        object_name: impl Into<String>,
76    ) -> Result<Self> {
77        let measurement = measurement.into();
78        let object_name = object_name.into();
79        for (what, value) in [("measurement", &measurement), ("object name", &object_name)] {
80            if value.is_empty()
81                || value.contains('/')
82                || value.contains('\\')
83                || value.contains("..")
84                || value == "."
85                || value.contains('=')
86            {
87                return Err(ObjStoreError::InvalidConfig {
88                    detail: format!(
89                        "invalid archive {what} {value:?}: must not be empty or contain \
90                         path separators, '..' components or '='"
91                    ),
92                });
93            }
94        }
95        Ok(Self {
96            measurement,
97            shard_id,
98            object_name,
99        })
100    }
101
102    /// The measurement this object holds.
103    #[must_use]
104    pub fn measurement(&self) -> &str {
105        &self.measurement
106    }
107
108    /// The shard this object holds.
109    #[must_use]
110    pub const fn shard_id(&self) -> ShardId {
111        self.shard_id
112    }
113
114    /// The object key, relative to the archive prefix.
115    #[must_use]
116    pub fn key(&self) -> String {
117        format!(
118            "measurement={}/shard={}/{}",
119            self.measurement, self.shard_id.0, self.object_name
120        )
121    }
122
123    /// The prefix under which every object of `measurement` lives.
124    ///
125    /// This is what `register_cold_tier` points a listing table at, so that
126    /// one table is one measurement and therefore one schema.
127    #[must_use]
128    pub fn measurement_prefix(measurement: &str) -> String {
129        format!("measurement={measurement}/")
130    }
131}
132
133impl std::fmt::Display for ArchiveObject {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.write_str(&self.key())
136    }
137}
138
139/// Cold-storage archiver: uploads encoded archive objects and verifies them.
140///
141/// Candidate selection, encoding, catalog removal and local deletion belong to
142/// the database, which is the only thing that can order them safely.
143pub struct TieringEngine {
144    /// Configuration.
145    config: TieringConfig,
146    /// Remote object store backend (destination).
147    remote: ObjectStoreBackend,
148}
149
150impl TieringEngine {
151    /// Create a new archiver over `remote`.
152    pub const fn new(config: TieringConfig, remote: ObjectStoreBackend) -> Self {
153        Self { config, remote }
154    }
155
156    /// Returns the tiering configuration.
157    #[must_use]
158    pub const fn config(&self) -> &TieringConfig {
159        &self.config
160    }
161
162    /// Check if a segment is eligible for cold tiering.
163    ///
164    /// A segment is eligible if its `max_timestamp` is older than
165    /// `now - cold_after`, where both values are in nanoseconds since
166    /// epoch (matching segment header timestamp units).
167    #[must_use]
168    pub fn is_eligible(&self, max_timestamp_ns: i64, now_ns: i64) -> bool {
169        let cold_after_ns = i64::try_from(self.config.cold_after.as_nanos()).unwrap_or(i64::MAX);
170        let threshold = now_ns.saturating_sub(cold_after_ns);
171        max_timestamp_ns < threshold
172    }
173
174    /// Upload one encoded archive object and verify it landed intact.
175    ///
176    /// Returns the object key. The caller's next step is to drop the source
177    /// segments, so an upload that cannot be *verified* is an error rather
178    /// than a warning: the old engine logged and returned success, and a
179    /// caller that removed the catalog entry on a successful return then
180    /// dropped data that was never archived.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if the upload fails, or if the object is absent or of
185    /// the wrong size afterwards.
186    pub async fn upload_archive(&self, object: &ArchiveObject, data: &[u8]) -> Result<String> {
187        let key = object.key();
188
189        info!(
190            measurement = %object.measurement,
191            shard = object.shard_id.0,
192            object = %key,
193            size = data.len(),
194            "uploading cold archive object"
195        );
196
197        self.remote
198            .put(&key, bytes::Bytes::copy_from_slice(data))
199            .await?;
200
201        metrics::counter!("chronix_objstore_tiering_objects_total").increment(1);
202        metrics::counter!("chronix_objstore_tiering_bytes_total").increment(data.len() as u64);
203
204        // A HEAD is enough: it confirms the object exists and has the expected
205        // size, which is what a truncated or interrupted upload fails.
206        match self.remote.object_size(&key).await? {
207            Some(remote_size) if remote_size == data.len() => {}
208            found => {
209                return Err(ObjStoreError::IntegrityCheckFailed {
210                    object: key,
211                    expected: data.len(),
212                    found,
213                });
214            }
215        }
216
217        debug!(object = %key, "cold archive object verified");
218        Ok(key)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    #![allow(clippy::unwrap_used, clippy::expect_used)]
225
226    use super::*;
227    use std::sync::Arc;
228
229    use crate::objstore::backend::ObjectStoreBackend;
230
231    fn engine() -> TieringEngine {
232        let remote =
233            ObjectStoreBackend::from_store(Arc::new(object_store::memory::InMemory::new()), None);
234        TieringEngine::new(
235            TieringConfig {
236                cold_after: Duration::from_secs(3600),
237                remote_url: "memory://test".to_string(),
238            },
239            remote,
240        )
241    }
242
243    #[test]
244    fn is_eligible_works() {
245        let engine = engine();
246
247        // cold_after = 1 hour = 3_600_000_000_000 ns
248        // now_ns = 10_000_000_000_000, threshold = 6_400_000_000_000
249        let now_ns: i64 = 10_000_000_000_000;
250
251        assert!(engine.is_eligible(5_000_000_000_000, now_ns));
252        assert!(!engine.is_eligible(7_000_000_000_000, now_ns));
253        // Exactly at the threshold is not yet cold.
254        assert!(!engine.is_eligible(6_400_000_000_000, now_ns));
255    }
256
257    /// The key is the Hive-style layout every external reader prunes on.
258    #[test]
259    fn an_archive_object_names_its_measurement_and_shard() {
260        let object = ArchiveObject::new("power", ShardId(7), "part-a.parquet").unwrap();
261        assert_eq!(object.key(), "measurement=power/shard=7/part-a.parquet");
262        assert_eq!(object.measurement(), "power");
263        assert_eq!(object.shard_id(), ShardId(7));
264        assert_eq!(
265            ArchiveObject::measurement_prefix("power"),
266            "measurement=power/"
267        );
268    }
269
270    /// A measurement name is user data about to become a directory name.
271    #[test]
272    fn an_archive_object_refuses_a_traversing_name() {
273        for bad in ["", "..", "a/b", "a\\b", "../etc", "shard=1"] {
274            assert!(
275                ArchiveObject::new(bad, ShardId(0), "part.parquet").is_err(),
276                "measurement {bad:?} must be refused"
277            );
278            assert!(
279                ArchiveObject::new("power", ShardId(0), bad).is_err(),
280                "object name {bad:?} must be refused"
281            );
282        }
283    }
284
285    /// An upload is only reported when the object is verifiably there.
286    #[tokio::test]
287    async fn upload_archive_verifies_the_object_landed() {
288        let engine = engine();
289        let object = ArchiveObject::new("power", ShardId(1), "part-a.parquet").unwrap();
290
291        let key = engine
292            .upload_archive(&object, b"parquet-bytes")
293            .await
294            .unwrap();
295        assert_eq!(key, "measurement=power/shard=1/part-a.parquet");
296
297        let back = engine.remote.get(&key).await.unwrap();
298        assert_eq!(&back[..], b"parquet-bytes");
299        assert_eq!(engine.remote.object_size(&key).await.unwrap(), Some(13));
300    }
301
302    /// An object that is not there afterwards is an error, not a warning: the
303    /// caller's next step is to delete the source data.
304    #[tokio::test]
305    async fn a_missing_object_fails_verification() {
306        let engine = engine();
307        assert_eq!(
308            engine
309                .remote
310                .object_size("measurement=power/shard=1/absent.parquet")
311                .await
312                .unwrap(),
313            None,
314            "nothing was uploaded, so the HEAD must report absence rather than erroring"
315        );
316    }
317
318    #[test]
319    fn tiering_config_serde_roundtrip() {
320        let config = TieringConfig {
321            cold_after: Duration::from_secs(7200),
322            remote_url: "s3://my-bucket/cold".to_string(),
323        };
324
325        let json = serde_json::to_string(&config).unwrap();
326        let deserialized: TieringConfig = serde_json::from_str(&json).unwrap();
327
328        assert_eq!(deserialized.cold_after, config.cold_after);
329        assert_eq!(deserialized.remote_url, config.remote_url);
330    }
331}