1use std::collections::HashSet;
18use std::time::Duration;
19use std::{fmt, str};
20
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use uuid::Uuid;
24
25use crate::data::export_v2::chunker::generate_chunks;
26use crate::data::export_v2::error::{
27 ChunkTimeWindowRequiresBoundsSnafu, Result as ExportResult, TimeParseEndBeforeStartSnafu,
28 TimeParseInvalidFormatSnafu,
29};
30
31pub const MANIFEST_VERSION: u32 = 1;
33
34pub const MANIFEST_FILE: &str = "manifest.json";
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct TimeRange {
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub start: Option<DateTime<Utc>>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub end: Option<DateTime<Utc>>,
46}
47
48impl TimeRange {
49 pub fn new(start: Option<DateTime<Utc>>, end: Option<DateTime<Utc>>) -> Self {
51 Self { start, end }
52 }
53
54 pub fn unbounded() -> Self {
56 Self {
57 start: None,
58 end: None,
59 }
60 }
61
62 pub fn is_unbounded(&self) -> bool {
64 self.start.is_none() && self.end.is_none()
65 }
66
67 pub fn is_bounded(&self) -> bool {
69 self.start.is_some() && self.end.is_some()
70 }
71
72 pub fn parse(start: Option<&str>, end: Option<&str>) -> ExportResult<Self> {
74 let start = start.map(parse_time).transpose()?;
75 let end = end.map(parse_time).transpose()?;
76
77 if let (Some(start), Some(end)) = (start, end)
78 && end < start
79 {
80 return TimeParseEndBeforeStartSnafu.fail();
81 }
82
83 Ok(Self::new(start, end))
84 }
85}
86
87fn parse_time(input: &str) -> ExportResult<DateTime<Utc>> {
88 DateTime::parse_from_rfc3339(input)
89 .map(|dt| dt.with_timezone(&Utc))
90 .map_err(|_| TimeParseInvalidFormatSnafu { input }.build())
91}
92
93impl Default for TimeRange {
94 fn default() -> Self {
95 Self::unbounded()
96 }
97}
98
99#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
101#[serde(rename_all = "snake_case")]
102pub enum ChunkStatus {
103 #[default]
105 Pending,
106 InProgress,
108 Completed,
110 Skipped,
112 Failed,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ChunkMeta {
119 pub id: u32,
121 pub time_range: TimeRange,
123 pub status: ChunkStatus,
125 #[serde(default)]
127 pub files: Vec<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub checksum: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub error: Option<String>,
134}
135
136impl ChunkMeta {
137 pub fn new(id: u32, time_range: TimeRange) -> Self {
139 Self {
140 id,
141 time_range,
142 status: ChunkStatus::Pending,
143 files: vec![],
144 checksum: None,
145 error: None,
146 }
147 }
148
149 pub fn skipped(id: u32, time_range: TimeRange) -> Self {
151 let mut chunk = Self::new(id, time_range);
152 chunk.mark_skipped();
153 chunk
154 }
155
156 pub fn mark_in_progress(&mut self) {
158 self.status = ChunkStatus::InProgress;
159 self.error = None;
160 }
161
162 pub fn mark_completed(&mut self, files: Vec<String>, checksum: Option<String>) {
164 self.status = ChunkStatus::Completed;
165 self.files = files;
166 self.checksum = checksum;
167 self.error = None;
168 }
169
170 pub fn mark_skipped(&mut self) {
172 self.status = ChunkStatus::Skipped;
173 self.files.clear();
174 self.checksum = None;
175 self.error = None;
176 }
177
178 pub fn mark_failed(&mut self, error: String) {
180 self.status = ChunkStatus::Failed;
181 self.error = Some(error);
182 }
183}
184
185#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, clap::ValueEnum)]
187#[serde(rename_all = "lowercase")]
188#[value(rename_all = "lowercase")]
189pub enum DataFormat {
190 #[default]
192 Parquet,
193 Csv,
195 Json,
197}
198
199impl fmt::Display for DataFormat {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 match self {
202 DataFormat::Parquet => write!(f, "parquet"),
203 DataFormat::Csv => write!(f, "csv"),
204 DataFormat::Json => write!(f, "json"),
205 }
206 }
207}
208
209impl str::FromStr for DataFormat {
210 type Err = String;
211
212 fn from_str(s: &str) -> Result<Self, Self::Err> {
213 match s.to_lowercase().as_str() {
214 "parquet" => Ok(DataFormat::Parquet),
215 "csv" => Ok(DataFormat::Csv),
216 "json" => Ok(DataFormat::Json),
217 _ => Err(format!(
218 "invalid format '{}': expected one of parquet, csv, json",
219 s
220 )),
221 }
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct Manifest {
236 pub version: u32,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub data_layout: Option<String>,
240 pub snapshot_id: Uuid,
242 pub catalog: String,
244 pub schemas: Vec<String>,
246 pub time_range: TimeRange,
248 pub schema_only: bool,
250 pub format: DataFormat,
252 #[serde(default)]
254 pub chunks: Vec<ChunkMeta>,
255 #[serde(skip_serializing_if = "Option::is_none")]
257 pub checksum: Option<String>,
258 pub created_at: DateTime<Utc>,
260 pub updated_at: DateTime<Utc>,
262}
263
264impl Manifest {
265 pub fn from_json(data: &[u8]) -> serde_json::Result<Self> {
267 use serde::de::Error;
268
269 let value: serde_json::Value = serde_json::from_slice(data)?;
270 if value["version"] == 2 {
271 let chunks = value["chunks"].as_array().ok_or_else(|| {
272 serde_json::Error::custom("version-2 manifest requires a chunks array")
273 })?;
274 for chunk in chunks {
275 if !chunk["files"].is_array() {
276 return Err(serde_json::Error::custom(
277 "version-2 chunk requires a files array",
278 ));
279 }
280 }
281 }
282 serde_json::from_slice(data)
283 }
284
285 pub fn validate_layout(&self) -> std::result::Result<(), String> {
287 match (self.version, self.data_layout.as_deref(), self.format) {
288 (1, None, _) => Ok(()),
289 (2, Some(common_datasource::packed_snapshot::PACKED_LAYOUT), DataFormat::Parquet) => {
290 if self.checksum.is_some() || self.chunks.iter().any(|c| c.checksum.is_some()) {
291 return Err("version-2 checksums are unsupported".into());
292 }
293 if self.schema_only != self.chunks.is_empty() {
294 return Err("version-2 schema_only requires empty chunks; data snapshots require chunks".into());
295 }
296 let schemas: HashSet<_> = self.schemas.iter().collect();
297 if schemas.len() != self.schemas.len() {
298 return Err("duplicate version-2 schemas".into());
299 }
300 let mut ids = HashSet::new();
301 for chunk in &self.chunks {
302 if chunk.id == 0 || !ids.insert(chunk.id) {
303 return Err("version-2 chunk IDs must be unique and positive".into());
304 }
305 let files: HashSet<_> = chunk.files.iter().collect();
306 if files.len() != chunk.files.len() {
307 return Err("duplicate version-2 chunk files".into());
308 }
309 if chunk.status == ChunkStatus::Skipped && !chunk.files.is_empty() {
310 return Err("skipped version-2 chunks must have no files".into());
311 }
312 for file in &chunk.files {
313 if file.contains('\\')
314 || file
315 .split('/')
316 .any(|part| part.is_empty() || part == "." || part == "..")
317 || !self.schemas.iter().any(|schema| {
318 file.starts_with(&crate::data::path::data_dir_for_schema_chunk(
319 schema, chunk.id,
320 ))
321 })
322 {
323 return Err(format!("invalid version-2 chunk file: {file}"));
324 }
325 }
326 }
327 Ok(())
328 }
329 _ => Err(format!(
330 "Manifest version mismatch or unsupported layout: version {}, layout {:?}, format {}",
331 self.version, self.data_layout, self.format
332 )),
333 }
334 }
335
336 pub fn is_packed(&self) -> bool {
337 self.version == 2
338 }
339
340 pub fn new_for_export(
341 catalog: String,
342 schemas: Vec<String>,
343 schema_only: bool,
344 time_range: TimeRange,
345 format: DataFormat,
346 chunk_time_window: Option<Duration>,
347 ) -> ExportResult<Self> {
348 if chunk_time_window.is_some() && !time_range.is_bounded() {
349 return ChunkTimeWindowRequiresBoundsSnafu.fail();
350 }
351
352 let mut manifest = if schema_only {
353 Self::new_schema_only(catalog, schemas)
354 } else {
355 Self::new_full(catalog, schemas, time_range, format)
356 };
357
358 if !schema_only {
359 manifest.chunks = match chunk_time_window {
360 Some(window) => generate_chunks(&manifest.time_range, window),
361 None => generate_single_chunk(&manifest.time_range),
362 };
363 manifest.touch();
364 }
365
366 Ok(manifest)
367 }
368
369 pub fn new_schema_only(catalog: String, schemas: Vec<String>) -> Self {
371 let now = Utc::now();
372 Self {
373 version: MANIFEST_VERSION,
374 data_layout: None,
375 snapshot_id: Uuid::new_v4(),
376 catalog,
377 schemas,
378 time_range: TimeRange::unbounded(),
379 schema_only: true,
380 format: DataFormat::Parquet,
381 chunks: vec![],
382 checksum: None,
383 created_at: now,
384 updated_at: now,
385 }
386 }
387
388 pub fn new_full(
390 catalog: String,
391 schemas: Vec<String>,
392 time_range: TimeRange,
393 format: DataFormat,
394 ) -> Self {
395 let now = Utc::now();
396 Self {
397 version: MANIFEST_VERSION,
398 data_layout: None,
399 snapshot_id: Uuid::new_v4(),
400 catalog,
401 schemas,
402 time_range,
403 schema_only: false,
404 format,
405 chunks: vec![],
406 checksum: None,
407 created_at: now,
408 updated_at: now,
409 }
410 }
411
412 pub fn is_complete(&self) -> bool {
414 self.schema_only
415 || (!self.chunks.is_empty()
416 && self
417 .chunks
418 .iter()
419 .all(|c| matches!(c.status, ChunkStatus::Completed | ChunkStatus::Skipped)))
420 }
421
422 pub fn pending_count(&self) -> usize {
424 self.chunks
425 .iter()
426 .filter(|c| c.status == ChunkStatus::Pending)
427 .count()
428 }
429
430 pub fn in_progress_count(&self) -> usize {
432 self.chunks
433 .iter()
434 .filter(|c| c.status == ChunkStatus::InProgress)
435 .count()
436 }
437
438 pub fn completed_count(&self) -> usize {
440 self.chunks
441 .iter()
442 .filter(|c| c.status == ChunkStatus::Completed)
443 .count()
444 }
445
446 pub fn skipped_count(&self) -> usize {
448 self.chunks
449 .iter()
450 .filter(|c| c.status == ChunkStatus::Skipped)
451 .count()
452 }
453
454 pub fn failed_count(&self) -> usize {
456 self.chunks
457 .iter()
458 .filter(|c| c.status == ChunkStatus::Failed)
459 .count()
460 }
461
462 pub fn touch(&mut self) {
464 self.updated_at = Utc::now();
465 }
466
467 pub fn add_chunk(&mut self, chunk: ChunkMeta) {
469 self.chunks.push(chunk);
470 self.touch();
471 }
472
473 pub fn update_chunk(&mut self, id: u32, updater: impl FnOnce(&mut ChunkMeta)) {
475 if let Some(chunk) = self.chunks.iter_mut().find(|c| c.id == id) {
476 updater(chunk);
477 self.touch();
478 }
479 }
480}
481
482fn generate_single_chunk(time_range: &TimeRange) -> Vec<ChunkMeta> {
483 if let (Some(start), Some(end)) = (time_range.start, time_range.end) {
484 if start == end {
485 return vec![ChunkMeta::skipped(1, time_range.clone())];
486 }
487 if start > end {
488 return Vec::new();
489 }
490 }
491 vec![ChunkMeta::new(1, time_range.clone())]
492}
493
494#[cfg(test)]
495mod tests {
496 #[test]
497 fn packed_manifest_read_contract_preserves_legacy_writer() {
498 let mut manifest =
499 super::Manifest::new_schema_only("greptime".into(), vec!["public".into()]);
500 assert!(manifest.validate_layout().is_ok());
501 manifest.version = 2;
502 assert!(manifest.validate_layout().is_err());
503 manifest.data_layout = Some("metric-parquet-packs".into());
504 assert!(manifest.validate_layout().is_ok());
505 manifest.format = super::DataFormat::Csv;
506 assert!(manifest.validate_layout().is_err());
507 manifest.format = super::DataFormat::Parquet;
508 manifest.version = 3;
509 assert!(manifest.validate_layout().is_err());
510 manifest.version = 1;
511 assert!(manifest.validate_layout().is_err());
512 }
513
514 #[test]
515 fn packed_required_fields_and_checksums_preserve_v1_defaults() {
516 let mut manifest =
517 super::Manifest::new_schema_only("greptime".into(), vec!["public".into()]);
518 manifest
519 .chunks
520 .push(super::ChunkMeta::new(1, super::TimeRange::unbounded()));
521 let legacy = serde_json::to_value(manifest).unwrap();
522 for version in [1, 2] {
523 let mut value = legacy.clone();
524 value["version"] = version.into();
525 if version == 2 {
526 value["data_layout"] = "metric-parquet-packs".into();
527 value["schema_only"] = false.into();
528 }
529 let raw = serde_json::to_string(&value).unwrap();
530 for duplicate in [
531 format!(
532 "{{\"checksum\":\"unsupported\",\"checksum\":null,{}",
533 &raw[1..]
534 ),
535 format!("{{\"chunks\":[],{}", &raw[1..]),
536 raw.replacen("\"files\":[]", "\"files\":[],\"files\":[]", 1),
537 raw.replacen(
538 "\"files\":[]",
539 "\"checksum\":\"unsupported\",\"checksum\":null,\"files\":[]",
540 1,
541 ),
542 ] {
543 assert!(super::Manifest::from_json(duplicate.as_bytes()).is_err());
544 }
545 for field in ["chunks", "files"] {
546 let mut missing = value.clone();
547 if field == "chunks" {
548 missing.as_object_mut().unwrap().remove(field);
549 } else {
550 missing["chunks"][0].as_object_mut().unwrap().remove(field);
551 }
552 assert_eq!(
553 super::Manifest::from_json(&serde_json::to_vec(&missing).unwrap()).is_ok(),
554 version == 1
555 );
556 }
557 for checksum in [
558 None,
559 Some(serde_json::Value::Null),
560 Some("".into()),
561 Some("sha256:abc".into()),
562 ] {
563 for chunk_level in [false, true] {
564 let mut candidate = value.clone();
565 if let Some(checksum) = &checksum {
566 if chunk_level {
567 candidate["chunks"][0]["checksum"] = checksum.clone();
568 } else {
569 candidate["checksum"] = checksum.clone();
570 }
571 }
572 let decoded =
573 super::Manifest::from_json(&serde_json::to_vec(&candidate).unwrap())
574 .unwrap();
575 assert_eq!(
576 decoded.validate_layout().is_ok(),
577 version == 1 || checksum.as_ref().is_none_or(serde_json::Value::is_null)
578 );
579 }
580 }
581 }
582 }
583
584 use std::time::Duration;
585
586 use chrono::{TimeZone, Utc};
587
588 use super::*;
589
590 #[test]
591 fn test_time_range_serialization() {
592 let range = TimeRange::unbounded();
593 let json = serde_json::to_string(&range).unwrap();
594 assert_eq!(json, "{}");
595
596 let range: TimeRange = serde_json::from_str("{}").unwrap();
597 assert!(range.is_unbounded());
598 }
599
600 #[test]
601 fn test_manifest_schema_only() {
602 let manifest =
603 Manifest::new_schema_only("greptime".to_string(), vec!["public".to_string()]);
604
605 assert_eq!(manifest.version, MANIFEST_VERSION);
606 assert!(manifest.schema_only);
607 assert!(manifest.chunks.is_empty());
608 assert!(manifest.is_complete());
609 }
610
611 #[test]
612 fn test_generate_single_chunk_zero_width_range_is_skipped() {
613 let ts = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
614 let chunks = generate_single_chunk(&TimeRange::new(Some(ts), Some(ts)));
615
616 assert_eq!(chunks.len(), 1);
617 assert_eq!(chunks[0].status, ChunkStatus::Skipped);
618 assert_eq!(chunks[0].time_range.start, Some(ts));
619 assert_eq!(chunks[0].time_range.end, Some(ts));
620 }
621
622 #[test]
623 fn test_generate_single_chunk_invalid_range_is_empty() {
624 let start = Utc.with_ymd_and_hms(2025, 1, 1, 1, 0, 0).unwrap();
625 let end = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
626 let chunks = generate_single_chunk(&TimeRange::new(Some(start), Some(end)));
627
628 assert!(chunks.is_empty());
629 }
630
631 #[test]
632 fn test_manifest_full() {
633 let manifest = Manifest::new_full(
634 "greptime".to_string(),
635 vec!["public".to_string()],
636 TimeRange::unbounded(),
637 DataFormat::Parquet,
638 );
639
640 assert!(!manifest.schema_only);
641 assert!(manifest.chunks.is_empty());
642 assert!(!manifest.is_complete());
643 }
644
645 #[test]
646 fn test_data_format_parsing() {
647 assert_eq!(
648 "parquet".parse::<DataFormat>().unwrap(),
649 DataFormat::Parquet
650 );
651 assert_eq!("CSV".parse::<DataFormat>().unwrap(), DataFormat::Csv);
652 assert_eq!("JSON".parse::<DataFormat>().unwrap(), DataFormat::Json);
653 assert!("invalid".parse::<DataFormat>().is_err());
654 }
655
656 #[test]
657 fn test_chunk_status_transitions() {
658 let mut chunk = ChunkMeta::new(1, TimeRange::unbounded());
659 assert_eq!(chunk.status, ChunkStatus::Pending);
660
661 chunk.mark_in_progress();
662 assert_eq!(chunk.status, ChunkStatus::InProgress);
663
664 chunk.mark_completed(
665 vec!["file1.parquet".to_string()],
666 Some("abc123".to_string()),
667 );
668 assert_eq!(chunk.status, ChunkStatus::Completed);
669 assert_eq!(chunk.files.len(), 1);
670
671 chunk.mark_skipped();
672 assert_eq!(chunk.status, ChunkStatus::Skipped);
673 assert!(chunk.files.is_empty());
674 }
675
676 #[test]
677 fn test_manifest_is_complete_when_chunks_are_completed_or_skipped() {
678 let mut manifest = Manifest::new_full(
679 "greptime".to_string(),
680 vec!["public".to_string()],
681 TimeRange::unbounded(),
682 DataFormat::Parquet,
683 );
684 manifest.add_chunk(ChunkMeta::new(1, TimeRange::unbounded()));
685 manifest.add_chunk(ChunkMeta::new(2, TimeRange::unbounded()));
686
687 manifest.update_chunk(1, |chunk| {
688 chunk.mark_completed(vec!["a.parquet".to_string()], None)
689 });
690 manifest.update_chunk(2, |chunk| chunk.mark_skipped());
691
692 assert!(manifest.is_complete());
693 assert_eq!(manifest.completed_count(), 1);
694 assert_eq!(manifest.skipped_count(), 1);
695 }
696
697 #[test]
698 fn test_manifest_chunk_time_window_none_single_chunk() {
699 let start = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
700 let end = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
701 let range = TimeRange::new(Some(start), Some(end));
702 let manifest = Manifest::new_for_export(
703 "greptime".to_string(),
704 vec!["public".to_string()],
705 false,
706 range.clone(),
707 DataFormat::Parquet,
708 None,
709 )
710 .unwrap();
711
712 assert_eq!(manifest.chunks.len(), 1);
713 assert_eq!(manifest.chunks[0].time_range, range);
714 }
715
716 #[test]
717 fn test_time_range_parse_requires_order() {
718 let result = TimeRange::parse(Some("2025-01-02T00:00:00Z"), Some("2025-01-01T00:00:00Z"));
719 assert!(result.is_err());
720 }
721
722 #[test]
723 fn test_new_for_export_with_chunk_window_requires_bounded_range() {
724 let result = Manifest::new_for_export(
725 "greptime".to_string(),
726 vec!["public".to_string()],
727 false,
728 TimeRange::new(
729 None,
730 Some(Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap()),
731 ),
732 DataFormat::Parquet,
733 Some(Duration::from_secs(3600)),
734 );
735 assert!(result.is_err());
736 }
737}