laminar_core/cluster/control/
catalog_manifest.rs1use std::fmt::Write;
4use std::sync::Arc;
5
6use ahash::AHashSet;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::checkpoint::LeaderProof;
11
12use super::{LeaderLeaseStore, LeaseError};
13
14pub use crate::catalog::CatalogObjectKind;
15
16pub(super) const CATALOG_MANIFEST_FORMAT_VERSION: u16 = 1;
17pub(super) const MAX_CATALOG_MANIFEST_ENTRIES: usize = 4_096;
18pub(super) const MAX_CATALOG_MANIFEST_BYTES: usize = 8 * 1024 * 1024;
19
20const CATALOG_MANIFEST_PREFIX: &str = "control/catalog-manifest/v1/";
21
22fn validate_entries(entries: &[CatalogManifestEntry]) -> Result<(), CatalogManifestError> {
23 if entries.len() > MAX_CATALOG_MANIFEST_ENTRIES {
24 return Err(CatalogManifestError::Invalid(format!(
25 "catalog manifest has {} entries; maximum is {MAX_CATALOG_MANIFEST_ENTRIES}",
26 entries.len()
27 )));
28 }
29 let mut names = AHashSet::with_capacity(entries.len());
30 for entry in entries {
31 if entry.canonical_name.is_empty() || entry.canonical_name.trim() != entry.canonical_name {
32 return Err(CatalogManifestError::Invalid(format!(
33 "catalog manifest has a non-canonical name {:?}",
34 entry.canonical_name
35 )));
36 }
37 if entry.ddl.trim().is_empty() {
38 return Err(CatalogManifestError::Invalid(format!(
39 "catalog manifest entry '{}' has empty DDL",
40 entry.canonical_name
41 )));
42 }
43 if !names.insert(entry.canonical_name.as_str()) {
44 return Err(CatalogManifestError::Invalid(format!(
45 "catalog manifest repeats canonical name '{}'",
46 entry.canonical_name
47 )));
48 }
49 }
50 Ok(())
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct CatalogManifestEntry {
57 pub canonical_name: String,
59 pub kind: CatalogObjectKind,
61 pub ddl: String,
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct CatalogManifest {
69 pub entries: Vec<CatalogManifestEntry>,
71}
72
73impl CatalogManifest {
74 pub fn new(entries: Vec<CatalogManifestEntry>) -> Result<Self, CatalogManifestError> {
80 let manifest = Self { entries };
81 manifest.encode_and_reference()?;
82 Ok(manifest)
83 }
84
85 pub(super) fn validate(&self) -> Result<(), CatalogManifestError> {
86 validate_entries(&self.entries)?;
87 Ok(())
88 }
89
90 pub(super) fn encode_and_reference(
91 &self,
92 ) -> Result<(Vec<u8>, CatalogManifestRef), CatalogManifestError> {
93 self.validate()?;
94 let encoded = serde_json::to_vec(self)?;
95 if encoded.len() > MAX_CATALOG_MANIFEST_BYTES {
96 return Err(CatalogManifestError::Invalid(format!(
97 "encoded catalog manifest is {} bytes; maximum is {MAX_CATALOG_MANIFEST_BYTES}",
98 encoded.len()
99 )));
100 }
101 let encoded_len = u64::try_from(encoded.len()).map_err(|_| {
102 CatalogManifestError::Invalid("encoded catalog manifest length overflow".into())
103 })?;
104 let entry_count = u32::try_from(self.entries.len()).map_err(|_| {
105 CatalogManifestError::Invalid("catalog manifest entry count overflow".into())
106 })?;
107 let digest = Sha256::digest(&encoded);
108 let mut sha256 = String::with_capacity(64);
109 for byte in digest {
110 write!(&mut sha256, "{byte:02x}").expect("writing to a String cannot fail");
111 }
112 let reference = CatalogManifestRef {
113 version: CATALOG_MANIFEST_FORMAT_VERSION,
114 sha256,
115 encoded_len,
116 entry_count,
117 };
118 reference.validate()?;
119 Ok((encoded, reference))
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct CatalogManifestRef {
127 pub version: u16,
129 pub sha256: String,
131 pub encoded_len: u64,
133 pub entry_count: u32,
135}
136
137impl CatalogManifestRef {
138 pub(super) fn validate(&self) -> Result<(), CatalogManifestError> {
139 if self.version != CATALOG_MANIFEST_FORMAT_VERSION {
140 return Err(CatalogManifestError::Invalid(format!(
141 "unsupported catalog manifest version {}",
142 self.version
143 )));
144 }
145 if self.sha256.len() != 64
146 || !self
147 .sha256
148 .bytes()
149 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
150 {
151 return Err(CatalogManifestError::Invalid(
152 "catalog manifest SHA-256 must be 64 lowercase hexadecimal characters".into(),
153 ));
154 }
155 if self.encoded_len == 0
156 || self.encoded_len
157 > u64::try_from(MAX_CATALOG_MANIFEST_BYTES)
158 .expect("catalog manifest byte limit fits u64")
159 {
160 return Err(CatalogManifestError::Invalid(format!(
161 "catalog manifest encoded length {} is outside 1..={MAX_CATALOG_MANIFEST_BYTES}",
162 self.encoded_len
163 )));
164 }
165 if !matches!(
166 usize::try_from(self.entry_count),
167 Ok(count) if count <= MAX_CATALOG_MANIFEST_ENTRIES
168 ) {
169 return Err(CatalogManifestError::Invalid(format!(
170 "catalog manifest entry count {} exceeds {MAX_CATALOG_MANIFEST_ENTRIES}",
171 self.entry_count
172 )));
173 }
174 Ok(())
175 }
176
177 pub(super) fn object_path(&self) -> object_store::path::Path {
178 object_store::path::Path::from(format!("{CATALOG_MANIFEST_PREFIX}{}.json", self.sha256))
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum CatalogSealOutcome {
185 Created,
187 ExistingIdentical,
189}
190
191pub struct CatalogManifestStore {
193 authority: Arc<LeaderLeaseStore>,
194}
195
196impl std::fmt::Debug for CatalogManifestStore {
197 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 formatter
199 .debug_struct("CatalogManifestStore")
200 .finish_non_exhaustive()
201 }
202}
203
204#[derive(Debug, thiserror::Error)]
206pub enum CatalogManifestError {
207 #[error("leader lease authority: {0}")]
209 Authority(#[from] LeaseError),
210 #[error("JSON: {0}")]
212 Json(#[from] serde_json::Error),
213 #[error("invalid sealed catalog manifest: {0}")]
215 Invalid(String),
216 #[error("cluster catalog is already sealed with a different inventory")]
218 Conflict,
219 #[error("catalog seal was fenced by a different durable leader term")]
221 Fenced,
222}
223
224impl CatalogManifestStore {
225 #[must_use]
227 pub fn new(authority: Arc<LeaderLeaseStore>) -> Self {
228 Self { authority }
229 }
230
231 pub async fn load(&self) -> Result<Option<CatalogManifest>, CatalogManifestError> {
236 let Some(reference) = self
237 .authority
238 .load()
239 .await?
240 .and_then(|lease| lease.catalog_manifest)
241 else {
242 return Ok(None);
243 };
244 self.authority
245 .load_catalog_manifest(&reference)
246 .await
247 .map(Some)
248 }
249
250 pub async fn seal(
257 &self,
258 manifest: &CatalogManifest,
259 proof: &LeaderProof,
260 ) -> Result<CatalogSealOutcome, CatalogManifestError> {
261 Box::pin(self.authority.seal_catalog(proof, manifest)).await
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use bytes::Bytes;
269 use object_store::{memory::InMemory, ObjectStoreExt, PutPayload};
270 use uuid::Uuid;
271
272 async fn store() -> (CatalogManifestStore, Arc<InMemory>, LeaderProof) {
273 let backing = Arc::new(InMemory::new());
274 let authority = Arc::new(LeaderLeaseStore::new(backing.clone(), 1_000));
275 let owner = super::super::LeaderLeaseOwner {
276 node: crate::cluster::discovery::NodeId(1),
277 boot: Uuid::from_u128(1),
278 process_term: 1,
279 };
280 let super::super::LeaseOutcome::Acquired(lease) =
281 authority.begin_new_term(&owner, 0).await.unwrap()
282 else {
283 unreachable!()
284 };
285 (CatalogManifestStore::new(authority), backing, lease.proof())
286 }
287
288 fn entry(name: &str) -> CatalogManifestEntry {
289 CatalogManifestEntry {
290 canonical_name: name.to_string(),
291 kind: CatalogObjectKind::Source,
292 ddl: format!("CREATE SOURCE {name} (k BIGINT)"),
293 }
294 }
295
296 #[tokio::test]
297 async fn empty_inventory_is_a_real_sealed_manifest() {
298 let (store, _, proof) = store().await;
299 assert!(store.load().await.unwrap().is_none());
300 assert_eq!(
301 store
302 .seal(&CatalogManifest::default(), &proof)
303 .await
304 .unwrap(),
305 CatalogSealOutcome::Created
306 );
307 assert_eq!(
308 store.load().await.unwrap(),
309 Some(CatalogManifest::default())
310 );
311 }
312
313 #[tokio::test]
314 async fn concurrent_identical_seal_is_idempotent() {
315 let (store, _, proof) = store().await;
316 let store = Arc::new(store);
317 let manifest = CatalogManifest::new(vec![entry("events")]).unwrap();
318 let (left, right) =
319 tokio::join!(store.seal(&manifest, &proof), store.seal(&manifest, &proof));
320 let outcomes = [left.unwrap(), right.unwrap()];
321 assert!(outcomes.contains(&CatalogSealOutcome::Created));
322 assert!(outcomes.contains(&CatalogSealOutcome::ExistingIdentical));
323 assert_eq!(store.load().await.unwrap(), Some(manifest));
324 }
325
326 #[tokio::test]
327 async fn divergent_second_inventory_fails_closed() {
328 let (store, _, proof) = store().await;
329 let winner = CatalogManifest::new(vec![entry("winner")]).unwrap();
330 let loser = CatalogManifest::new(vec![entry("loser")]).unwrap();
331 store.seal(&winner, &proof).await.unwrap();
332 assert!(matches!(
333 store.seal(&loser, &proof).await,
334 Err(CatalogManifestError::Conflict)
335 ));
336 assert_eq!(store.load().await.unwrap(), Some(winner));
337 }
338
339 #[test]
340 fn duplicate_or_noncanonical_entries_are_rejected() {
341 let duplicate = vec![entry("events"), entry("events")];
342 assert!(matches!(
343 CatalogManifest::new(duplicate),
344 Err(CatalogManifestError::Invalid(_))
345 ));
346 assert!(matches!(
347 CatalogManifest::new(vec![entry(" events")]),
348 Err(CatalogManifestError::Invalid(_))
349 ));
350 }
351
352 #[test]
353 fn manifest_bounds_are_enforced_before_durable_writes() {
354 let too_many = (0..=MAX_CATALOG_MANIFEST_ENTRIES)
355 .map(|index| entry(&format!("source_{index}")))
356 .collect();
357 assert!(matches!(
358 CatalogManifest::new(too_many),
359 Err(CatalogManifestError::Invalid(_))
360 ));
361
362 let oversized = CatalogManifest::new(vec![CatalogManifestEntry {
363 canonical_name: "events".into(),
364 kind: CatalogObjectKind::Source,
365 ddl: "x".repeat(MAX_CATALOG_MANIFEST_BYTES),
366 }]);
367 assert!(matches!(oversized, Err(CatalogManifestError::Invalid(_))));
368 }
369
370 #[tokio::test]
371 async fn missing_sealed_manifest_blob_fails_closed() {
372 let (store, backing, proof) = store().await;
373 let manifest = CatalogManifest::new(vec![entry("events")]).unwrap();
374 let (_, reference) = manifest.encode_and_reference().unwrap();
375 store.seal(&manifest, &proof).await.unwrap();
376 backing.delete(&reference.object_path()).await.unwrap();
377
378 let error = store.load().await.unwrap_err();
379 assert!(matches!(&error, CatalogManifestError::Invalid(_)));
380 assert!(error.to_string().contains("is missing"));
381 }
382
383 #[tokio::test]
384 async fn tampered_sealed_manifest_blob_fails_closed() {
385 let (store, backing, proof) = store().await;
386 let manifest = CatalogManifest::new(vec![entry("events")]).unwrap();
387 let (mut encoded, reference) = manifest.encode_and_reference().unwrap();
388 store.seal(&manifest, &proof).await.unwrap();
389 let offset = encoded
390 .windows(b"events".len())
391 .position(|window| window == b"events")
392 .unwrap();
393 encoded[offset] = b'x';
394 backing
395 .put(
396 &reference.object_path(),
397 PutPayload::from(Bytes::from(encoded)),
398 )
399 .await
400 .unwrap();
401
402 let error = store.load().await.unwrap_err();
403 assert!(matches!(&error, CatalogManifestError::Invalid(_)));
404 assert!(error
405 .to_string()
406 .contains("does not match its sealed reference"));
407 }
408}