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}